From 15f91ac64dfb6f7d33f6942d2a391db05ba1ca3d Mon Sep 17 00:00:00 2001 From: Agrendalath Date: Thu, 22 Jan 2026 21:44:59 +0100 Subject: [PATCH 1/3] feat: implement optional completion This adds a new XBlock field that allows marking sections, subsections, and units as optional, which means that their completion is counted separately. --- .../course_api/blocks/serializers.py | 1 + .../blocks/transformers/block_completion.py | 3 +- .../course_home_api/outline/serializers.py | 1 + lms/djangoapps/courseware/courses.py | 39 ++++++++++++------- openedx/features/course_experience/utils.py | 1 + xmodule/modulestore/inheritance.py | 11 ++++++ xmodule/seq_block.py | 1 + 7 files changed, 43 insertions(+), 14 deletions(-) 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/seq_block.py b/xmodule/seq_block.py index 944c73400ab5..b95f6d1b86bf 100644 --- a/xmodule/seq_block.py +++ b/xmodule/seq_block.py @@ -807,6 +807,7 @@ def _render_student_view_for_blocks(self, context, children, fragment, view=STUD 'path': " > ".join(display_names + [block.display_name_with_default]), 'graded': block.graded, 'contains_content_type_gated_content': contains_content_type_gated_content, + 'optional_completion': getattr(block, 'optional_completion', False), } if not render_blocks: # The item url format can be defined in the template context like so: From da169031a3e2dd391c9dfe33bc2a22aa67e73e64 Mon Sep 17 00:00:00 2001 From: Agrendalath Date: Tue, 27 Jan 2026 21:38:35 +0100 Subject: [PATCH 2/3] feat: support optional completion for individual XBlocks --- common/templates/xblock_wrapper.html | 4 ++++ openedx/core/lib/xblock_utils/__init__.py | 8 ++++++++ xmodule/tests/test_xml_block.py | 4 ++-- xmodule/x_module.py | 12 ++++++++++++ 4 files changed, 26 insertions(+), 2 deletions(-) diff --git a/common/templates/xblock_wrapper.html b/common/templates/xblock_wrapper.html index 35a0c505e6d8..977514c314f7 100644 --- a/common/templates/xblock_wrapper.html +++ b/common/templates/xblock_wrapper.html @@ -1,7 +1,11 @@ ## 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")} +% endif
% if js_init_parameters: % 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 @@ +
+ + +