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/lms/djangoapps/course_api/blocks/serializers.py b/lms/djangoapps/course_api/blocks/serializers.py index f0e1c546b330..da1dbad07ec2 100644 --- a/lms/djangoapps/course_api/blocks/serializers.py +++ b/lms/djangoapps/course_api/blocks/serializers.py @@ -85,6 +85,7 @@ def __init__( SupportedFieldType(BlockCompletionTransformer.COMPLETION, BlockCompletionTransformer), SupportedFieldType(BlockCompletionTransformer.COMPLETE), SupportedFieldType(BlockCompletionTransformer.RESUME_BLOCK), + SupportedFieldType(BlockCompletionTransformer.OPTIONAL_COMPLETION), SupportedFieldType(DiscussionsTopicLinkTransformer.EXTERNAL_ID), SupportedFieldType(DiscussionsTopicLinkTransformer.EMBED_URL), diff --git a/lms/djangoapps/course_api/blocks/transformers/block_completion.py b/lms/djangoapps/course_api/blocks/transformers/block_completion.py index 472555c4c7f9..e2df44ba579f 100644 --- a/lms/djangoapps/course_api/blocks/transformers/block_completion.py +++ b/lms/djangoapps/course_api/blocks/transformers/block_completion.py @@ -18,6 +18,7 @@ class BlockCompletionTransformer(BlockStructureTransformer): COMPLETION = 'completion' COMPLETE = 'complete' RESUME_BLOCK = 'resume_block' + OPTIONAL_COMPLETION = 'optional_completion' @classmethod def name(cls): @@ -43,7 +44,7 @@ def get_block_completion(cls, block_structure, block_key): @classmethod def collect(cls, block_structure): - block_structure.request_xblock_fields('completion_mode') + block_structure.request_xblock_fields('completion_mode', cls.OPTIONAL_COMPLETION) @staticmethod def _is_block_excluded(block_structure, block_key): diff --git a/lms/djangoapps/course_home_api/outline/serializers.py b/lms/djangoapps/course_home_api/outline/serializers.py index cfa518138a95..bb88f61dd3ce 100644 --- a/lms/djangoapps/course_home_api/outline/serializers.py +++ b/lms/djangoapps/course_home_api/outline/serializers.py @@ -50,6 +50,7 @@ def get_blocks(self, block): # pylint: disable=missing-function-docstring block_key: { 'children': [child['id'] for child in children], 'complete': block.get('complete', False), + 'optional_completion': block.get('optional_completion', False), 'description': description, 'display_name': display_name, 'due': block.get('due'), diff --git a/lms/djangoapps/courseware/courses.py b/lms/djangoapps/courseware/courses.py index 2fc727623541..df991de0c2cf 100644 --- a/lms/djangoapps/courseware/courses.py +++ b/lms/djangoapps/courseware/courses.py @@ -552,38 +552,51 @@ def get_course_assignment_date_blocks(course, user, request, num_return=None, @request_cached() -def get_course_blocks_completion_summary(course_key, user): +def get_course_blocks_completion_summary(course_key, user) -> dict[str, int]: """ - Returns an object with the number of complete units, incomplete units, and units that contain gated content + Returns a dict with the number of complete units, incomplete units, and units that contain gated content for the given course. The complete and incomplete counts only reflect units that are able to be completed by the given user. If a unit contains gated content, it is not counted towards the incomplete count. - The object contains fields: complete_count, incomplete_count, locked_count + The dict contains fields: + - complete_count + - incomplete_count + - locked_count + - optional_complete_count + - optional_incomplete_count + - optional_locked_count """ if not user.id: - return [] + return {} + store = modulestore() course_usage_key = store.make_course_usage_key(course_key) block_data = get_course_blocks(user, course_usage_key, allow_start_dates_in_future=True, include_completion=True) - complete_count, incomplete_count, locked_count = 0, 0, 0 + counts = { + 'complete_count': 0, + 'incomplete_count': 0, + 'locked_count': 0, + 'optional_complete_count': 0, + 'optional_incomplete_count': 0, + 'optional_locked_count': 0, + } for section_key in block_data.get_children(course_usage_key): # pylint: disable=too-many-nested-blocks for subsection_key in block_data.get_children(section_key): for unit_key in block_data.get_children(subsection_key): complete = block_data.get_xblock_field(unit_key, 'complete', False) contains_gated_content = block_data.get_xblock_field(unit_key, 'contains_gated_content', False) + optional = block_data.get_xblock_field(unit_key, 'optional_completion', False) + prefix = "optional_" if optional else "" + if contains_gated_content: - locked_count += 1 + counts[f"{prefix}locked_count"] += 1 elif complete: - complete_count += 1 + counts[f"{prefix}complete_count"] += 1 else: - incomplete_count += 1 + counts[f"{prefix}incomplete_count"] += 1 - return { - 'complete_count': complete_count, - 'incomplete_count': incomplete_count, - 'locked_count': locked_count - } + return counts @request_cached() diff --git a/openedx/features/course_experience/utils.py b/openedx/features/course_experience/utils.py index c20b7f077136..270533e3a248 100644 --- a/openedx/features/course_experience/utils.py +++ b/openedx/features/course_experience/utils.py @@ -114,6 +114,7 @@ def recurse_mark_auth_denial(block): 'weight', 'completion', 'complete', + 'optional_completion', 'resume_block', 'hide_from_toc', 'icon_class', diff --git a/xmodule/modulestore/inheritance.py b/xmodule/modulestore/inheritance.py index 4c5a14b769cb..980ef8d1773d 100644 --- a/xmodule/modulestore/inheritance.py +++ b/xmodule/modulestore/inheritance.py @@ -247,6 +247,17 @@ class InheritanceMixin(XBlockMixin): scope=Scope.settings ) + optional_completion = Boolean( + display_name=_("Optional"), + help=_( + "Set this to true to mark this block as optional. " + "Progress in this block won't count towards course completion progress " + "and will count as optional progress instead." + ), + default=False, + scope=Scope.settings, + ) + @property def close_date(self): """ diff --git a/xmodule/x_module.py b/xmodule/x_module.py index d25012275a5e..e5ac66cfbf4d 100644 --- a/xmodule/x_module.py +++ b/xmodule/x_module.py @@ -651,6 +651,13 @@ def editable_metadata_fields(self): metadata_fields[field.name] = self._create_metadata_editor_info(field) + if "optional_completion" in self.fields: + parent = self.get_parent() + if parent and not getattr(parent, "optional_completion", False): + metadata_fields["optional_completion"] = self._create_metadata_editor_info( + self.fields["optional_completion"] + ) + return metadata_fields def _create_metadata_editor_info(self, field):