diff --git a/cms/djangoapps/contentstore/utils.py b/cms/djangoapps/contentstore/utils.py index 3080d3582568..0baca15d4b67 100644 --- a/cms/djangoapps/contentstore/utils.py +++ b/cms/djangoapps/contentstore/utils.py @@ -631,18 +631,38 @@ def find_staff_lock_source(xblock): return find_staff_lock_source(parent) +def _get_parent_xblock(xblock, parent_xblock=None): + """ + Returns the parent xblock if provided, otherwise fetches it from the modulestore. + Returns None if the xblock has no parent (orphaned). + """ + if parent_xblock is not None: + return parent_xblock + parent_location = modulestore().get_parent_location( + xblock.location, + revision=ModuleStoreEnum.RevisionOption.draft_preferred + ) + if not parent_location: + return None + return modulestore().get_item(parent_location) + + def ancestor_has_staff_lock(xblock, parent_xblock=None): """ - Returns True iff one of xblock's ancestors has staff lock. + Returns True if one of xblock's ancestors has staff lock. + Can avoid mongo query by passing in parent_xblock. + """ + parent = _get_parent_xblock(xblock, parent_xblock) + return parent.visible_to_staff_only if parent else False + + +def ancestor_has_optional_completion(xblock, parent_xblock=None): + """ + Returns True if one of xblock's ancestors has optional_completion. Can avoid mongo query by passing in parent_xblock. """ - if parent_xblock is None: - parent_location = modulestore().get_parent_location(xblock.location, - revision=ModuleStoreEnum.RevisionOption.draft_preferred) - if not parent_location: - return False - parent_xblock = modulestore().get_item(parent_location) - return parent_xblock.visible_to_staff_only + parent = _get_parent_xblock(xblock, parent_xblock) + return parent.optional_completion if parent else False def get_sequence_usage_keys(course): diff --git a/cms/djangoapps/contentstore/xblock_storage_handlers/view_handlers.py b/cms/djangoapps/contentstore/xblock_storage_handlers/view_handlers.py index e2f9d8334a37..c4716562e6ef 100644 --- a/cms/djangoapps/contentstore/xblock_storage_handlers/view_handlers.py +++ b/cms/djangoapps/contentstore/xblock_storage_handlers/view_handlers.py @@ -59,6 +59,7 @@ from xmodule.tabs import CourseTabList from ..utils import ( + ancestor_has_optional_completion, ancestor_has_staff_lock, find_release_date_source, find_staff_lock_source, @@ -1102,6 +1103,7 @@ def create_xblock_info( # lint-amnesty, pylint: disable=too-many-statements "hide_from_toc": xblock.hide_from_toc, "enable_hide_from_toc_ui": settings.FEATURES.get("ENABLE_HIDE_FROM_TOC_UI", False), "xblock_type": get_icon(xblock), + "optional_completion": xblock.optional_completion, } ) @@ -1252,6 +1254,8 @@ def create_xblock_info( # lint-amnesty, pylint: disable=too-many-statements xblock_info["course_tags_count"] = _get_course_tags_count(course.id) xblock_info["tag_counts_by_block"] = _get_course_block_tags(xblock.location.context_key) + xblock_info["ancestor_has_optional_completion"] = ancestor_has_optional_completion(xblock, parent_xblock) + xblock_info[ "has_partition_group_components" ] = has_children_visible_to_specific_partition_groups(xblock) diff --git a/cms/djangoapps/models/settings/course_metadata.py b/cms/djangoapps/models/settings/course_metadata.py index d5523e69f8a6..9b0f508ccde0 100644 --- a/cms/djangoapps/models/settings/course_metadata.py +++ b/cms/djangoapps/models/settings/course_metadata.py @@ -81,6 +81,7 @@ class CourseMetadata: 'highlights_enabled_for_messaging', 'is_onboarding_exam', 'discussions_settings', + 'optional_completion', ] @classmethod diff --git a/cms/static/js/models/xblock_info.js b/cms/static/js/models/xblock_info.js index 2b82cf72b15b..eb8efc67fecd 100644 --- a/cms/static/js/models/xblock_info.js +++ b/cms/static/js/models/xblock_info.js @@ -121,6 +121,10 @@ define( */ ancestor_has_staff_lock: null, /** + * True if any of this xblock's ancestors has optional completion. + */ + ancestor_has_optional_completion: null, + /** * The xblock which is determining the staff lock value. For instance, for a unit, * this will either be the parent subsection or the grandparent section. * This can be null if the xblock has no inherited staff lock. Will only be present if diff --git a/cms/static/js/views/modals/course_outline_modals.js b/cms/static/js/views/modals/course_outline_modals.js index aac0d7aa1e0d..0b98e19c1fb4 100644 --- a/cms/static/js/views/modals/course_outline_modals.js +++ b/cms/static/js/views/modals/course_outline_modals.js @@ -20,7 +20,7 @@ define(['jquery', 'backbone', 'underscore', 'gettext', 'js/views/baseview', StaffLockEditor, UnitAccessEditor, ContentVisibilityEditor, TimedExaminationPreferenceEditor, AccessEditor, ShowCorrectnessEditor, HighlightsEditor, HighlightsEnableXBlockModal, HighlightsEnableEditor, DiscussionEditor, SummaryConfigurationEditor, SubsectionShareLinkXBlockModal, FullPageShareLinkEditor, - EmbedLinkShareLinkEditor; + EmbedLinkShareLinkEditor, OptionalCompletionEditor; CourseOutlineXBlockModal = BaseModal.extend({ events: _.extend({}, BaseModal.prototype.events, { @@ -1359,6 +1359,50 @@ define(['jquery', 'backbone', 'underscore', 'gettext', 'js/views/baseview', } }); + OptionalCompletionEditor = AbstractEditor.extend({ + templateName: 'optional-completion-editor', + className: 'edit-optional-completion', + + afterRender: function() { + AbstractEditor.prototype.afterRender.call(this); + this.setValue(this.model.get('optional_completion')); + }, + + setValue: function(value) { + this.$('input[name=optional_completion]').prop('checked', value); + }, + + currentValue: function() { + return this.$('input[name=optional_completion]').is(':checked'); + }, + + hasChanges: function() { + return this.model.get('optional_completion') !== this.currentValue(); + }, + + getRequestData: function() { + if (this.hasChanges()) { + return { + publish: 'republish', + metadata: { + // This variable relies on the inheritance mechanism, so we want to unset it instead of + // explicitly setting it to `false`. + optional_completion: this.currentValue() || null + } + }; + } else { + return {}; + } + }, + + getContext: function() { + return { + optional_completion: this.model.get('optional_completion'), + optional_ancestor: this.model.get('ancestor_has_optional_completion') + }; + }, + }); + return { getModal: function(type, xblockInfo, options) { if (type === 'edit') { @@ -1427,6 +1471,14 @@ define(['jquery', 'backbone', 'underscore', 'gettext', 'js/views/baseview', } } + if (course.get('completion_tracking_enabled')) { + if (tabs.length > 0) { + tabs[0].editors.push(OptionalCompletionEditor); + } else { + editors.push(OptionalCompletionEditor); + } + } + /* globals course */ if (course.get('self_paced')) { editors = _.without(editors, ReleaseDateEditor, DueDateEditor); diff --git a/cms/static/sass/elements/_modal-window.scss b/cms/static/sass/elements/_modal-window.scss index 82c6f3da4c16..8b07f853faa7 100644 --- a/cms/static/sass/elements/_modal-window.scss +++ b/cms/static/sass/elements/_modal-window.scss @@ -830,6 +830,7 @@ .edit-discussion, .edit-staff-lock, + .edit-optional-completion, .summary-configuration, .edit-content-visibility, .edit-unit-access { @@ -915,9 +916,18 @@ } } + .edit-optional-completion { + .field-message { + @extend %t-copy-sub1; + color: $gray-d1; + margin-bottom: ($baseline/4); + } + } + .edit-discussion, .edit-unit-access, .edit-staff-lock, + .edit-optional-completion, .summary-configuration { .modal-section-content { @include font-size(16); @@ -961,6 +971,7 @@ .edit-discussion, .edit-unit-access, .edit-staff-lock, + .edit-optional-completion, .summary-configuration { .modal-section-content { @include font-size(16); diff --git a/cms/templates/base.html b/cms/templates/base.html index 68df2d1a0e13..1c70d0097a79 100644 --- a/cms/templates/base.html +++ b/cms/templates/base.html @@ -8,6 +8,7 @@ ## Standard imports <%namespace name='static' file='static_content.html'/> <%! +from completion.waffle import ENABLE_COMPLETION_TRACKING_SWITCH from django.utils.translation import gettext as _ from cms.djangoapps.contentstore.config.waffle import CUSTOM_RELATIVE_DATES @@ -175,7 +176,8 @@ self_paced: ${ context_course.self_paced | n, dump_js_escaped_json }, is_custom_relative_dates_active: ${CUSTOM_RELATIVE_DATES.is_enabled(context_course.id) | n, dump_js_escaped_json}, start: ${context_course.start | n, dump_js_escaped_json}, - discussions_settings: ${context_course.discussions_settings | n, dump_js_escaped_json} + discussions_settings: ${context_course.discussions_settings | n, dump_js_escaped_json}, + completion_tracking_enabled: ${ENABLE_COMPLETION_TRACKING_SWITCH.is_enabled() | n, dump_js_escaped_json}, }); % endif diff --git a/cms/templates/course_outline.html b/cms/templates/course_outline.html index 16d9ccbd4ca5..d16ce41100cc 100644 --- a/cms/templates/course_outline.html +++ b/cms/templates/course_outline.html @@ -29,7 +29,7 @@ <%block name="header_extras"> -% for template_name in ['course-outline', 'xblock-string-field-editor', 'basic-modal', 'modal-button', 'course-outline-modal', 'due-date-editor', 'self-paced-due-date-editor', 'release-date-editor', 'grading-editor', 'publish-editor', 'staff-lock-editor', 'unit-access-editor', 'discussion-editor', 'content-visibility-editor', 'verification-access-editor', 'timed-examination-preference-editor', 'access-editor', 'settings-modal-tabs', 'show-correctness-editor', 'highlights-editor', 'highlights-enable-editor', 'course-highlights-enable', 'course-manage-tags', 'course-video-sharing-enable', 'summary-configuration-editor', 'tag-count', 'subsection-share-link-modal-tabs', 'full-page-share-link-editor', 'embed-link-share-link-editor']: +% for template_name in ['course-outline', 'xblock-string-field-editor', 'basic-modal', 'modal-button', 'course-outline-modal', 'due-date-editor', 'self-paced-due-date-editor', 'release-date-editor', 'grading-editor', 'publish-editor', 'staff-lock-editor', 'unit-access-editor', 'discussion-editor', 'content-visibility-editor', 'verification-access-editor', 'timed-examination-preference-editor', 'access-editor', 'settings-modal-tabs', 'show-correctness-editor', 'highlights-editor', 'highlights-enable-editor', 'course-highlights-enable', 'course-manage-tags', 'course-video-sharing-enable', 'summary-configuration-editor', 'tag-count', 'subsection-share-link-modal-tabs', 'full-page-share-link-editor', 'embed-link-share-link-editor', 'optional-completion-editor']: diff --git a/cms/templates/js/course-outline.underscore b/cms/templates/js/course-outline.underscore index eec4be4cb5cf..f0694cc3c206 100644 --- a/cms/templates/js/course-outline.underscore +++ b/cms/templates/js/course-outline.underscore @@ -30,6 +30,8 @@ var addStatusMessage = function (statusType, message) { } else if (statusType === 'partition-groups') { statusIconClass = 'fa-eye'; + } else if (statusType === 'optional-completion') { + statusIconClass = 'fa-lightbulb-o'; } statusMessages.push({iconClass: statusIconClass, text: message}); @@ -105,6 +107,12 @@ if (xblockInfo.get('graded')) { } } +if (xblockInfo.get('optional_completion') && !xblockInfo.get('ancestor_has_optional_completion')) { + messageType = 'optional-completion'; + messageText = gettext('Optional completion'); + addStatusMessage(messageType, messageText); +} + var is_proctored_exam = xblockInfo.get('is_proctored_exam'); var is_practice_exam = xblockInfo.get('is_practice_exam'); var is_onboarding_exam = xblockInfo.get('is_onboarding_exam'); diff --git a/cms/templates/js/optional-completion-editor.underscore b/cms/templates/js/optional-completion-editor.underscore new file mode 100644 index 000000000000..9a7d55fe847a --- /dev/null +++ b/cms/templates/js/optional-completion-editor.underscore @@ -0,0 +1,26 @@ +
+ + +
diff --git a/common/templates/xblock_wrapper.html b/common/templates/xblock_wrapper.html index 35a0c505e6d8..5bf9825b0aa8 100644 --- a/common/templates/xblock_wrapper.html +++ b/common/templates/xblock_wrapper.html @@ -1,8 +1,17 @@ ## xss-lint: disable=mako-missing-default <%! from openedx.core.djangolib.js_utils import dump_js_escaped_json +from django.utils.translation import gettext as _ %> +% if 'data-is-optional="True"' in data_attributes: + ${_("Optional 1")} +% endif
+ +% if 'data-is-optional="True"' in data_attributes: + ${_("Optional 2")} +% endif + % if js_init_parameters: