diff --git a/cms/djangoapps/contentstore/tests/test_utils.py b/cms/djangoapps/contentstore/tests/test_utils.py index 5b4f4338b2b6..32437a19bcfc 100644 --- a/cms/djangoapps/contentstore/tests/test_utils.py +++ b/cms/djangoapps/contentstore/tests/test_utils.py @@ -336,6 +336,54 @@ def test_no_inheritance_for_orphan(self): self.assertFalse(utils.ancestor_has_staff_lock(self.orphan)) +class InheritedOptionalCompletionTest(CourseTestCase): + """Tests for determining if an xblock inherits optional completion.""" + + def setUp(self): + super().setUp() + chapter = BlockFactory.create(category='chapter', parent=self.course) + sequential = BlockFactory.create(category='sequential', parent=chapter) + vertical = BlockFactory.create(category='vertical', parent=sequential) + html = BlockFactory.create(category='html', parent=vertical) + problem = BlockFactory.create( + category='problem', parent=vertical, data="" + ) + self.chapter = self.store.get_item(chapter.location) + self.sequential = self.store.get_item(sequential.location) + self.vertical = self.store.get_item(vertical.location) + self.html = self.store.get_item(html.location) + self.problem = self.store.get_item(problem.location) + self.orphan = BlockFactory.create(category='vertical', parent_location=self.sequential.location) + + def set_optional_completion(self, xblock, value): + """ Sets optional_completion to specified value and calls update_item to persist the change. """ + xblock.optional_completion = value + self.store.update_item(xblock, self.user.id) + + def update_optional_completions(self, chapter, sequential, vertical): + self.set_optional_completion(self.chapter, chapter) + self.set_optional_completion(self.sequential, sequential) + self.set_optional_completion(self.vertical, vertical) + + def test_no_inheritance(self): + """Tests that vertical with no optional ancestors does not have an inherited optional completion""" + self.update_optional_completions(False, False, False) + self.assertFalse(utils.ancestor_has_optional_completion(self.vertical)) + self.update_optional_completions(False, False, True) + self.assertFalse(utils.ancestor_has_optional_completion(self.vertical)) + + def test_inheritance_in_optional_subsection(self): + """Tests that a vertical in an optional subsection has an inherited optional completion""" + self.update_optional_completions(False, True, False) + self.assertTrue(utils.ancestor_has_optional_completion(self.vertical)) + self.update_optional_completions(False, True, True) + self.assertTrue(utils.ancestor_has_optional_completion(self.vertical)) + + def test_no_inheritance_for_orphan(self): + """Tests that an orphaned xblock does not inherit optional completion""" + self.assertFalse(utils.ancestor_has_optional_completion(self.orphan)) + + class GroupVisibilityTest(CourseTestCase): """ Test content group access rules. diff --git a/cms/djangoapps/contentstore/utils.py b/cms/djangoapps/contentstore/utils.py index 94f689d2cb02..9045eda25806 100644 --- a/cms/djangoapps/contentstore/utils.py +++ b/cms/djangoapps/contentstore/utils.py @@ -342,7 +342,7 @@ def find_staff_lock_source(xblock): 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. """ if parent_xblock is None: @@ -354,6 +354,20 @@ def ancestor_has_staff_lock(xblock, parent_xblock=None): return parent_xblock.visible_to_staff_only +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.optional_completion + + def reverse_url(handler_name, key_name=None, key_value=None, kwargs=None): """ Creates the URL for the given handler. diff --git a/cms/djangoapps/contentstore/views/block.py b/cms/djangoapps/contentstore/views/block.py index 7305bc9adc89..55dbffa124ca 100644 --- a/cms/djangoapps/contentstore/views/block.py +++ b/cms/djangoapps/contentstore/views/block.py @@ -60,6 +60,7 @@ from xmodule.x_module import AUTHOR_VIEW, PREVIEW_VIEWS, STUDENT_VIEW, STUDIO_VIEW # lint-amnesty, pylint: disable=wrong-import-order from ..utils import ( + ancestor_has_optional_completion, ancestor_has_staff_lock, find_release_date_source, find_staff_lock_source, @@ -1321,6 +1322,7 @@ def create_xblock_info(xblock, data=None, metadata=None, include_ancestor_info=F 'group_access': xblock.group_access, 'user_partitions': user_partitions, 'show_correctness': xblock.show_correctness, + 'optional_completion': xblock.optional_completion, }) if xblock.category == 'sequential': @@ -1405,6 +1407,8 @@ def create_xblock_info(xblock, data=None, metadata=None, include_ancestor_info=F xblock_info['ancestor_has_staff_lock'] = False if course_outline: + xblock_info['ancestor_has_optional_completion'] = ancestor_has_optional_completion(xblock, parent_xblock) + if xblock_info['has_explicit_staff_lock']: xblock_info['staff_only_message'] = True elif child_info and child_info['children']: diff --git a/cms/djangoapps/contentstore/views/tests/test_block.py b/cms/djangoapps/contentstore/views/tests/test_block.py index 716b0d9a70b5..addb385c5797 100644 --- a/cms/djangoapps/contentstore/views/tests/test_block.py +++ b/cms/djangoapps/contentstore/views/tests/test_block.py @@ -2630,7 +2630,7 @@ def test_json_responses(self): @ddt.data( (ModuleStoreEnum.Type.split, 3, 3), - (ModuleStoreEnum.Type.mongo, 8, 12), + (ModuleStoreEnum.Type.mongo, 10, 14), ) @ddt.unpack def test_xblock_outline_handler_mongo_calls(self, store_type, chapter_queries, chapter_queries_1): diff --git a/cms/djangoapps/models/settings/course_metadata.py b/cms/djangoapps/models/settings/course_metadata.py index c072a4ae552b..3fde376bb781 100644 --- a/cms/djangoapps/models/settings/course_metadata.py +++ b/cms/djangoapps/models/settings/course_metadata.py @@ -78,6 +78,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 983edc4e5648..7c46a3e201c9 100644 --- a/cms/static/js/models/xblock_info.js +++ b/cms/static/js/models/xblock_info.js @@ -120,6 +120,10 @@ define( */ ancestor_has_staff_lock: null, /** + * True if this any of this xblock's ancestors are optional for 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/spec/views/pages/course_outline_spec.js b/cms/static/js/spec/views/pages/course_outline_spec.js index 074a77c47324..85b80e45a4a7 100644 --- a/cms/static/js/spec/views/pages/course_outline_spec.js +++ b/cms/static/js/spec/views/pages/course_outline_spec.js @@ -313,7 +313,7 @@ describe('CourseOutlinePage', function() { 'staff-lock-editor', 'unit-access-editor', 'discussion-editor', 'content-visibility-editor', 'settings-modal-tabs', 'timed-examination-preference-editor', 'access-editor', 'show-correctness-editor', 'highlights-editor', 'highlights-enable-editor', - 'course-highlights-enable' + 'course-highlights-enable', 'optional-completion-editor' ]); appendSetFixtures(mockOutlinePage); mockCourseJSON = createMockCourseJSON({}, [ @@ -1021,6 +1021,62 @@ describe('CourseOutlinePage', function() { ); expect($modalWindow.find('.outline-subsection').length).toBe(2); }); + + it('hides optional completion checkbox by default', function() { + createCourseOutlinePage(this, mockCourseJSON, false); + outlinePage.$('.section-header-actions .configure-button').click(); + expect($('.edit-optional-completion')).not.toExist(); + }); + + describe('supports optional completion and', function () { + beforeEach(function() { + window.course.attributes.completion_tracking_enabled = true; + }); + + it('shows optional completion checkbox unchecked by default', function() { + createCourseOutlinePage(this, mockCourseJSON, false); + outlinePage.$('.section-header-actions .configure-button').click(); + expect($('.edit-optional-completion')).toExist(); + expect($('#optional_completion').is(':checked')).toBe(false); + }); + + it('shows optional completion checkbox checked', function() { + var mockCourseJSON = createMockCourseJSON({}, [ + createMockSectionJSON({optional_completion: true}) + ]); + createCourseOutlinePage(this, mockCourseJSON, false); + outlinePage.$('.section-header-actions .configure-button').click(); + expect($('#optional_completion').is(':disabled')).toBe(false); + expect($('#optional_completion').is(':checked')).toBe(true); + }); + + it('disables optional completion checkbox when the parent uses optional completion', function() { + var mockCourseJSON = createMockCourseJSON({}, [ + createMockSectionJSON({ancestor_has_optional_completion: true}) + ]); + createCourseOutlinePage(this, mockCourseJSON, false); + outlinePage.$('.section-header-actions .configure-button').click(); + expect($('#optional_completion').is(':disabled')).toBe(true); + }); + + it('sets optional completion to null instead of false', function() { + var mockCourseJSON = createMockCourseJSON({}, [ + createMockSectionJSON({optional_completion: true}) + ]); + createCourseOutlinePage(this, mockCourseJSON, false); + outlinePage.$('.section-header-actions .configure-button').click(); + expect($('#optional_completion').is(':checked')).toBe(true); + $('#optional_completion').click() + expect($('#optional_completion').is(':checked')).toBe(false); + $('.wrapper-modal-window .action-save').click(); + AjaxHelpers.expectJsonRequest(requests, 'POST', '/xblock/mock-section', { + publish: 'republish', + metadata: { + optional_completion: null + } + }); + }); + }); }); describe('Subsection', function() { @@ -2321,6 +2377,76 @@ describe('CourseOutlinePage', function() { ); }); }) + + it('hides optional completion checkbox by default', function() { + createCourseOutlinePage(this, mockCourseJSON, false); + outlinePage.$('.outline-subsection .configure-button').click(); + expect($('.edit-optional-completion')).not.toExist(); + }); + + describe('supports optional completion and', function () { + beforeEach(function() { + window.course.attributes.completion_tracking_enabled = true; + }); + + it('shows optional completion checkbox unchecked by default', function() { + createCourseOutlinePage(this, mockCourseJSON, false); + outlinePage.$('.outline-subsection .configure-button').click(); + expect($('.edit-optional-completion')).toExist(); + expect($('#optional_completion').is(':checked')).toBe(false); + }); + + it('shows optional completion checkbox checked', function() { + var mockCourseJSON = createMockCourseJSON({}, [ + createMockSectionJSON({}, [ + createMockSubsectionJSON({optional_completion: true}, []) + ]) + ]); + createCourseOutlinePage(this, mockCourseJSON, false); + outlinePage.$('.outline-subsection .configure-button').click(); + expect($('#optional_completion').is(':disabled')).toBe(false); + expect($('#optional_completion').is(':checked')).toBe(true); + }); + + it('disables optional completion checkbox when the parent uses optional completion', function() { + var mockCourseJSON = createMockCourseJSON({}, [ + createMockSectionJSON({}, [ + createMockSubsectionJSON({ancestor_has_optional_completion: true}, []) + ]) + ]); + createCourseOutlinePage(this, mockCourseJSON, false); + outlinePage.$('.outline-subsection .configure-button').click(); + expect($('#optional_completion').is(':disabled')).toBe(true); + }); + + it('sets optional completion to null instead of false', function() { + var mockCourseJSON = createMockCourseJSON({}, [ + createMockSectionJSON({}, [ + createMockSubsectionJSON({optional_completion: true}, []) + ]) + ]); + createCourseOutlinePage(this, mockCourseJSON, false); + outlinePage.$('.outline-subsection .configure-button').click(); + expect($('#optional_completion').is(':checked')).toBe(true); + $('#optional_completion').click() + expect($('#optional_completion').is(':checked')).toBe(false); + $('.wrapper-modal-window .action-save').click(); + AjaxHelpers.expectJsonRequest(requests, 'POST', '/xblock/mock-subsection', { + publish: 'republish', + graderType: 'notgraded', + isPrereq: false, + metadata: { + optional_completion: null, + due: null, + is_practice_exam: false, + is_time_limited: false, + is_proctored_enabled: false, + default_time_limit_minutes: null, + is_onboarding_exam: false, + } + }); + }); + }); }); // Note: most tests for units can be found in Bok Choy @@ -2437,6 +2563,54 @@ describe('CourseOutlinePage', function() { ]) ]); }); + + it('hides optional completion checkbox by default', function() { + getUnitStatus({}, {}); + outlinePage.$('.outline-unit .configure-button').click(); + expect($('.edit-optional-completion')).not.toExist(); + }); + + describe('supports optional completion and', function () { + beforeEach(function() { + window.course.attributes.completion_tracking_enabled = true; + }); + + it('shows optional completion checkbox unchecked by default', function() { + getUnitStatus({}, {}); + outlinePage.$('.outline-unit .configure-button').click(); + expect($('.edit-optional-completion')).toExist(); + expect($('#optional_completion').is(':checked')).toBe(false); + }); + + it('shows optional completion checkbox checked', function() { + getUnitStatus({optional_completion: true}, {}); + outlinePage.$('.outline-unit .configure-button').click(); + expect($('#optional_completion').is(':disabled')).toBe(false); + expect($('#optional_completion').is(':checked')).toBe(true); + }); + + it('disables optional completion checkbox when the parent uses optional completion', function() { + getUnitStatus({ancestor_has_optional_completion: true}, {}); + outlinePage.$('.outline-unit .configure-button').click(); + expect($('#optional_completion').is(':disabled')).toBe(true); + }); + + it('sets optional completion to null instead of false', function() { + getUnitStatus({optional_completion: true}, {}); + outlinePage.$('.outline-unit .configure-button').click(); + expect($('#optional_completion').is(':checked')).toBe(true); + $('#optional_completion').click() + expect($('#optional_completion').is(':checked')).toBe(false); + $('.wrapper-modal-window .action-save').click(); + AjaxHelpers.expectJsonRequest(requests, 'POST', '/xblock/mock-unit', { + publish: 'republish', + metadata: { + visible_to_staff_only: null, + optional_completion: null + } + }); + }); + }); }); describe('Date and Time picker', function() { diff --git a/cms/static/js/views/modals/course_outline_modals.js b/cms/static/js/views/modals/course_outline_modals.js index a10a4f23749d..e626d241b2da 100644 --- a/cms/static/js/views/modals/course_outline_modals.js +++ b/cms/static/js/views/modals/course_outline_modals.js @@ -18,7 +18,7 @@ define(['jquery', 'backbone', 'underscore', 'gettext', 'js/views/baseview', ReleaseDateEditor, DueDateEditor, SelfPacedDueDateEditor, GradingEditor, PublishEditor, AbstractVisibilityEditor, StaffLockEditor, UnitAccessEditor, ContentVisibilityEditor, TimedExaminationPreferenceEditor, AccessEditor, ShowCorrectnessEditor, HighlightsEditor, HighlightsEnableXBlockModal, HighlightsEnableEditor, - DiscussionEditor; + DiscussionEditor, OptionalCompletionEditor; CourseOutlineXBlockModal = BaseModal.extend({ events: _.extend({}, BaseModal.prototype.events, { @@ -1201,6 +1201,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') { @@ -1263,6 +1307,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 b6508ef49a01..92ccfe2f1876 100644 --- a/cms/static/sass/elements/_modal-window.scss +++ b/cms/static/sass/elements/_modal-window.scss @@ -747,6 +747,7 @@ .edit-discussion, .edit-staff-lock, + .edit-optional-completion, .edit-content-visibility, .edit-unit-access { margin-bottom: $baseline; @@ -760,6 +761,7 @@ // UI: staff lock and discussion .edit-discussion, .edit-staff-lock, + .edit-optional-completion, .edit-settings-timed-examination, .edit-unit-access { .checkbox-cosmetic .input-checkbox { @@ -830,8 +832,17 @@ } } + .edit-optional-completion { + .field-message { + @extend %t-copy-sub1; + color: $gray-d1; + margin-bottom: ($baseline/4); + } + } + .edit-discussion, .edit-unit-access, + .edit-optional-completion, .edit-staff-lock { .modal-section-content { @include font-size(16); @@ -874,6 +885,7 @@ .edit-discussion, .edit-unit-access, + .edit-optional-completion, .edit-staff-lock { .modal-section-content { @include font-size(16); diff --git a/cms/templates/base.html b/cms/templates/base.html index 1e88505c7fde..a606038c59e3 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 @@ -158,6 +159,7 @@ revision: "${context_course.location.branch | n, js_escaped_string}", 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}, + completion_tracking_enabled: ${ENABLE_COMPLETION_TRACKING_SWITCH.is_enabled() | 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} }); diff --git a/cms/templates/course_outline.html b/cms/templates/course_outline.html index f9f499c87bd8..e61999553bd5 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']: +% 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', 'optional-completion-editor']: diff --git a/cms/templates/js/course-outline.underscore b/cms/templates/js/course-outline.underscore index 7f82410196bb..39058ef6f6c0 100644 --- a/cms/templates/js/course-outline.underscore +++ b/cms/templates/js/course-outline.underscore @@ -22,6 +22,8 @@ var addStatusMessage = function (statusType, message) { statusIconClass = 'fa-lock'; } else if (statusType === 'partition-groups') { statusIconClass = 'fa-eye'; + } else if (statusType === 'optional-completion') { + statusIconClass = 'fa-lightbulb-o'; } statusMessages.push({iconClass: statusIconClass, text: message}); @@ -81,6 +83,11 @@ var gradingType = gettext('Ungraded'); if (xblockInfo.get('graded')) { gradingType = xblockInfo.get('format') } +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'); 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 ac031600a02c..1fe137fad9fc 100644 --- a/lms/djangoapps/course_api/blocks/serializers.py +++ b/lms/djangoapps/course_api/blocks/serializers.py @@ -56,6 +56,7 @@ def __init__( SupportedFieldType('has_score'), SupportedFieldType('has_scheduled_content'), SupportedFieldType('weight'), + SupportedFieldType('optional_completion'), SupportedFieldType('show_correctness'), # 'student_view_data' SupportedFieldType(StudentViewTransformer.STUDENT_VIEW_DATA, StudentViewTransformer), diff --git a/lms/djangoapps/course_api/blocks/transformers/block_completion.py b/lms/djangoapps/course_api/blocks/transformers/block_completion.py index 472555c4c7f9..8790463d5908 100644 --- a/lms/djangoapps/course_api/blocks/transformers/block_completion.py +++ b/lms/djangoapps/course_api/blocks/transformers/block_completion.py @@ -43,7 +43,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', '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 20b205e3040d..82739aeb0dcf 100644 --- a/lms/djangoapps/course_home_api/outline/serializers.py +++ b/lms/djangoapps/course_home_api/outline/serializers.py @@ -54,6 +54,7 @@ def get_blocks(self, block): # pylint: disable=missing-function-docstring 'resume_block': block.get('resume_block', False), 'type': block_type, 'has_scheduled_content': block.get('has_scheduled_content'), + 'optional_completion': block.get('optional_completion', False), }, } for child in children: diff --git a/lms/djangoapps/course_home_api/outline/tests/test_view.py b/lms/djangoapps/course_home_api/outline/tests/test_view.py index f02ad646ef48..b7bd34968a80 100644 --- a/lms/djangoapps/course_home_api/outline/tests/test_view.py +++ b/lms/djangoapps/course_home_api/outline/tests/test_view.py @@ -437,3 +437,18 @@ def test_cannot_enroll_if_full(self): self.update_course_and_overview() CourseEnrollment.enroll(UserFactory(), self.course.id) # grr, some rando took our spot! self.assert_can_enroll(False) + + def test_optional_completion_off_by_default(self): + CourseEnrollment.enroll(self.user, self.course.id) + assert not self.course.optional_completion + response = self.client.get(self.url) + for block in response.data['course_blocks']['blocks'].values(): + assert not block['optional_completion'] + + def test_optional_completion_on_is_inherited(self): + self.course.optional_completion = True + self.update_course_and_overview() + CourseEnrollment.enroll(self.user, self.course.id) + response = self.client.get(self.url) + for block in response.data['course_blocks']['blocks'].values(): + assert block['optional_completion'] diff --git a/lms/djangoapps/course_home_api/progress/serializers.py b/lms/djangoapps/course_home_api/progress/serializers.py index 190cd76bf9b5..98667314674b 100644 --- a/lms/djangoapps/course_home_api/progress/serializers.py +++ b/lms/djangoapps/course_home_api/progress/serializers.py @@ -134,6 +134,7 @@ class ProgressTabSerializer(VerifiedModeSerializer): access_expiration = serializers.DictField() certificate_data = CertificateDataSerializer() completion_summary = serializers.DictField() + optional_completion_summary = serializers.DictField() course_grade = CourseGradeSerializer() credit_course_requirements = serializers.DictField() end = serializers.DateTimeField() diff --git a/lms/djangoapps/course_home_api/progress/tests/test_views.py b/lms/djangoapps/course_home_api/progress/tests/test_views.py index d13ebec29c21..60c648a5e94b 100644 --- a/lms/djangoapps/course_home_api/progress/tests/test_views.py +++ b/lms/djangoapps/course_home_api/progress/tests/test_views.py @@ -314,3 +314,13 @@ def test_course_grade_considers_subsection_grade_visibility(self, is_staff, expe assert response.status_code == 200 assert response.data['course_grade']['percent'] == expected_percent assert response.data['course_grade']['is_passing'] == (expected_percent >= 0.5) + + def test_optional_completion(self): + CourseEnrollment.enroll(self.user, self.course.id) + response = self.client.get(self.url) + assert response.status_code == 200 + assert response.data['optional_completion_summary'] == { + 'complete_count': 0, + 'incomplete_count': 0, + 'locked_count': 0, + } diff --git a/lms/djangoapps/course_home_api/progress/views.py b/lms/djangoapps/course_home_api/progress/views.py index dc0ea63525f7..ad1bbd4ef5ce 100644 --- a/lms/djangoapps/course_home_api/progress/views.py +++ b/lms/djangoapps/course_home_api/progress/views.py @@ -246,6 +246,7 @@ def get(self, request, *args, **kwargs): 'access_expiration': access_expiration, 'certificate_data': get_cert_data(student, course, enrollment_mode, course_grade), 'completion_summary': get_course_blocks_completion_summary(course_key, student), + 'optional_completion_summary': get_course_blocks_completion_summary(course_key, student, optional=True), 'course_grade': course_grade, 'credit_course_requirements': credit_course_requirements(course_key, student), 'end': course.end, diff --git a/lms/djangoapps/courseware/courses.py b/lms/djangoapps/courseware/courses.py index 6ded860329fb..c4a03c639450 100644 --- a/lms/djangoapps/courseware/courses.py +++ b/lms/djangoapps/courseware/courses.py @@ -552,24 +552,38 @@ 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, optional=False): """ Returns an object 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 - """ + + Args: + course_key (CourseKey): the course key object. + user (User): student user object. + optional (bool): if true will only count optional blocks towards summary, else it will exclude optional + blocks from summary. + """ if not user.id: 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) + def _is_optional(*keys): + for key in keys: + if block_data.get_xblock_field(key, 'optional_completion', False): + return True + return False + complete_count, incomplete_count, locked_count = 0, 0, 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): + if optional != _is_optional(section_key, subsection_key, unit_key): + continue complete = block_data.get_xblock_field(unit_key, 'complete', False) contains_gated_content = block_data.get_xblock_field(unit_key, 'contains_gated_content', False) if contains_gated_content: diff --git a/openedx/features/course_experience/utils.py b/openedx/features/course_experience/utils.py index d58b54f6139f..8dc2f5d248be 100644 --- a/openedx/features/course_experience/utils.py +++ b/openedx/features/course_experience/utils.py @@ -115,6 +115,7 @@ def recurse_mark_auth_denial(block): 'completion', 'complete', 'resume_block', + 'optional_completion', ], allow_start_dates_in_future=allow_start_dates_in_future, ) diff --git a/xmodule/modulestore/inheritance.py b/xmodule/modulestore/inheritance.py index e75b9ab083c4..21a30861892d 100644 --- a/xmodule/modulestore/inheritance.py +++ b/xmodule/modulestore/inheritance.py @@ -238,6 +238,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): """