diff --git a/cms/djangoapps/contentstore/config/waffle.py b/cms/djangoapps/contentstore/config/waffle.py index e9c1bd90cd..4cf7b4a38f 100644 --- a/cms/djangoapps/contentstore/config/waffle.py +++ b/cms/djangoapps/contentstore/config/waffle.py @@ -67,3 +67,15 @@ REDIRECT_TO_LIBRARY_AUTHORING_MICROFRONTEND = LegacyWaffleFlag( flag_name='library_authoring_mfe', module_name=__name__, ) + + +# .. toggle_name: studio.custom_pls +# .. toggle_implementation: CourseWaffleFlag +# .. toggle_default: False +# .. toggle_description: Waffle flag to enable custom pacing for PLS +# .. toggle_use_cases: temporary +# .. toggle_creation_date: 2021-07-08 +# .. toggle_target_removal_date: 2021-12-31 +# .. toggle_warnings: For this flag to be active, add flag 'studio.custom_pls' in Django Admin +# .. toggle_tickets: https://openedx.atlassian.net/browse/AA-844 +CUSTOM_PLS = CourseWaffleFlag(WAFFLE_NAMESPACE, 'custom_pls', module_name=__name__,) diff --git a/cms/djangoapps/contentstore/config/waffle_utils.py b/cms/djangoapps/contentstore/config/waffle_utils.py index a63057c62a..eaf9e5e456 100644 --- a/cms/djangoapps/contentstore/config/waffle_utils.py +++ b/cms/djangoapps/contentstore/config/waffle_utils.py @@ -9,6 +9,4 @@ def should_show_checklists_quality(course_key): Determine if the ENABLE_CHECKLISTS_QUALITY waffle flag is set and if the user is able to see it """ - if ENABLE_CHECKLISTS_QUALITY.is_enabled(course_key): - return True - return False + return ENABLE_CHECKLISTS_QUALITY.is_enabled(course_key) diff --git a/cms/djangoapps/contentstore/views/item.py b/cms/djangoapps/contentstore/views/item.py index 43c1b1aeae..3dcc3e4cec 100644 --- a/cms/djangoapps/contentstore/views/item.py +++ b/cms/djangoapps/contentstore/views/item.py @@ -1230,6 +1230,7 @@ def create_xblock_info(xblock, data=None, metadata=None, include_ancestor_info=F 'graded': xblock.graded, 'due_date': get_default_time_display(xblock.due), 'due': xblock.fields['due'].to_json(xblock.due), + 'due_num_weeks': xblock.due_num_weeks, 'format': xblock.format, 'course_graders': [grader.get('type') for grader in graders], 'has_changes': has_changes, 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 afb5d5cfd3..e8691cc740 100644 --- a/cms/static/js/spec/views/pages/course_outline_spec.js +++ b/cms/static/js/spec/views/pages/course_outline_spec.js @@ -12,7 +12,7 @@ describe('CourseOutlinePage', function() { var createCourseOutlinePage, displayNameInput, model, outlinePage, requests, getItemsOfType, getItemHeaders, verifyItemsExpanded, expandItemsAndVerifyState, collapseItemsAndVerifyState, selectBasicSettings, selectVisibilitySettings, selectAdvancedSettings, createMockCourseJSON, createMockSectionJSON, - createMockSubsectionJSON, verifyTypePublishable, mockCourseJSON, mockEmptyCourseJSON, setSelfPaced, + createMockSubsectionJSON, verifyTypePublishable, mockCourseJSON, mockEmptyCourseJSON, setSelfPaced,setSelfPacedCustomPLS, mockSingleSectionCourseJSON, createMockVerticalJSON, createMockIndexJSON, mockCourseEntranceExamJSON, selectOnboardingExam, createMockCourseJSONWithReviewRules,mockCourseJSONWithReviewRules, mockOutlinePage = readFixtures('templates/mock/mock-course-outline-page.underscore'), @@ -202,6 +202,11 @@ describe('CourseOutlinePage', function() { course.set('self_paced', true); }; + setSelfPacedCustomPLS = function() { + setSelfPaced(); + course.set('is_custom_pls_active', true); + } + createCourseOutlinePage = function(test, courseJSON, createOnly) { requests = AjaxHelpers.requests(test); model = new XBlockOutlineInfo(courseJSON, {parse: true}); @@ -294,7 +299,7 @@ describe('CourseOutlinePage', function() { TemplateHelpers.installTemplates([ 'course-outline', 'xblock-string-field-editor', 'modal-button', 'basic-modal', 'course-outline-modal', 'release-date-editor', - 'due-date-editor', 'grading-editor', 'publish-editor', + 'due-date-editor', 'self-paced-due-date-editor', 'grading-editor', 'publish-editor', 'staff-lock-editor', 'unit-access-editor', 'content-visibility-editor', 'settings-modal-tabs', 'timed-examination-preference-editor', 'access-editor', 'show-correctness-editor', 'highlights-editor', 'highlights-enable-editor', @@ -1002,9 +1007,9 @@ describe('CourseOutlinePage', function() { }); describe('Subsection', function() { - var getDisplayNameWrapper, setEditModalValues, setContentVisibility, mockServerValuesJson, - selectDisableSpecialExams, selectTimedExam, selectProctoredExam, selectPracticeExam, - selectPrerequisite, selectLastPrerequisiteSubsection, checkOptionFieldVisibility, + var getDisplayNameWrapper, setEditModalValues, setEditModalValuesForCustomPacing, setContentVisibility, mockServerValuesJson, + mockCustomPacingServerValuesJson, selectDisableSpecialExams, selectTimedExam, selectProctoredExam, selectPracticeExam, + selectPrerequisite, selectLastPrerequisiteSubsection, selectDueNumWeeksSubsection, checkOptionFieldVisibility, defaultModalSettings, modalSettingsWithExamReviewRules, getMockNoPrereqOrExamsCourseJSON, expectShowCorrectness; getDisplayNameWrapper = function() { @@ -2117,6 +2122,170 @@ describe('CourseOutlinePage', function() { ); expect($modalWindow.find('.outline-subsection')).not.toExist(); }); + + describe('Self Paced with Custom Personalized Learner Schedules (PLS)', function () { + beforeEach(function() { + var mockCourseJSON = createMockCourseJSON({}, [ + createMockSectionJSON({}, [ + createMockSubsectionJSON({}, []) + ]) + ]); + createCourseOutlinePage(this, mockCourseJSON, false); + setSelfPacedCustomPLS(); + }); + + setEditModalValuesForCustomPacing = function(due_in, grading_type) { + $('#due_in').val(due_in); + $('#grading_type').val(grading_type); + }; + + selectDueNumWeeksSubsection = function(weeks) { + $('#due_in').val(weeks).trigger('keyup'); + } + + mockCustomPacingServerValuesJson = createMockSectionJSON({ + release_date: 'Jan 01, 2970 at 05:00 UTC' + }, [ + createMockSubsectionJSON({ + graded: true, + due_num_weeks: 3, + format: 'Lab', + has_explicit_staff_lock: true, + staff_only_message: true, + is_prereq: false, + show_correctness: 'never', + is_time_limited: false, + is_practice_exam: false, + is_proctored_exam: false, + default_time_limit_minutes: null, + }, [ + createMockVerticalJSON({ + has_changes: true, + published: false + }) + ]) + ]); + + it('can show correct editors for self_paced course with custom pacing', function (){ + outlinePage.$('.outline-subsection .configure-button').click(); + expect($('.edit-settings-release').length).toBe(0); + // Due date input exists for custom pacing self paced courses + expect($('.grading-due-date').length).toBe(1); + expect($('.edit-settings-grading').length).toBe(1); + expect($('.edit-content-visibility').length).toBe(1); + expect($('.edit-show-correctness').length).toBe(1); + }); + + it('can be edited when custom pacing for self paced course is active', function() { + outlinePage.$('.outline-subsection .configure-button').click(); + setEditModalValuesForCustomPacing('3', 'Lab'); + selectAdvancedSettings(); + $('.wrapper-modal-window .action-save').click(); + AjaxHelpers.expectJsonRequest(requests, 'POST', '/xblock/mock-subsection', { + graderType: 'Lab', + isPrereq: false, + metadata: { + due_num_weeks: 3, + is_time_limited: false, + is_practice_exam: false, + is_proctored_enabled: false, + default_time_limit_minutes: null, + is_onboarding_exam: false, + } + }); + expect(requests[0].requestHeaders['X-HTTP-Method-Override']).toBe('PATCH'); + AjaxHelpers.respondWithJson(requests, {}); + + AjaxHelpers.expectJsonRequest(requests, 'GET', '/xblock/outline/mock-section'); + AjaxHelpers.respondWithJson(requests, mockCustomPacingServerValuesJson); + AjaxHelpers.expectNoRequests(requests); + + expect($('.outline-subsection .status-grading-value')).toContainText( + 'Lab' + ); + expect($('.outline-subsection .status-message-copy')).toContainText( + 'Contains staff only content' + ); + + expect($('.outline-item .outline-subsection .status-grading-value')).toContainText('Lab'); + outlinePage.$('.outline-item .outline-subsection .configure-button').click(); + expect($('#due_in').val()).toBe('3'); + expect($('#grading_type').val()).toBe('Lab'); + expect($('input[name=content-visibility][value=staff_only]').is(':checked')).toBe(true); + expect($('input.timed_exam').is(':checked')).toBe(false); + expect($('input.proctored_exam').is(':checked')).toBe(false); + expect($('input.no_special_exam').is(':checked')).toBe(true); + expect($('input.practice_exam').is(':checked')).toBe(false); + expectShowCorrectness('never'); + }); + + it('shows validation error on due number of weeks', function() { + outlinePage.$('.outline-subsection .configure-button').click(); + + // when due number of weeks goes over 18 + selectDueNumWeeksSubsection('19'); + expect($('#due-num-weeks-warning-max').css('display')).not.toBe('none'); + expect($('.wrapper-modal-window .action-save').prop('disabled')).toBe(true); + expect($('.wrapper-modal-window .action-save').hasClass('is-disabled')).toBe(true); + + // when due number of weeks is less than 1 + selectDueNumWeeksSubsection('-1'); + expect($('#due-num-weeks-warning-min').css('display')).not.toBe('none'); + expect($('.wrapper-modal-window .action-save').prop('disabled')).toBe(true); + expect($('.wrapper-modal-window .action-save').hasClass('is-disabled')).toBe(true); + + // when no validation error should show up + selectDueNumWeeksSubsection('10'); + expect($('#due-num-weeks-warning-max').css('display')).toBe('none'); + expect($('#due-num-weeks-warning-min').css('display')).toBe('none'); + expect($('.wrapper-modal-window .action-save').prop('disabled')).toBe(false); + expect($('.wrapper-modal-window .action-save').hasClass('is-disabled')).toBe(false); + }); + + it('due num weeks (due_in) can be cleared.', function() { + outlinePage.$('.outline-item .outline-subsection .configure-button').click(); + setEditModalValuesForCustomPacing('3', 'Lab'); + setContentVisibility('staff_only'); + $('.wrapper-modal-window .action-save').click(); + + // This is the response for the change operation. + AjaxHelpers.respondWithJson(requests, {}); + // This is the response for the subsequent fetch operation. + AjaxHelpers.respondWithJson(requests, mockCustomPacingServerValuesJson); + + expect($('.outline-subsection .status-grading-value')).toContainText( + 'Lab' + ); + expect($('.outline-subsection .status-message-copy')).toContainText( + 'Contains staff only content' + ); + + outlinePage.$('.outline-subsection .configure-button').click(); + expect($('#due_in').val()).toBe('3'); + expect($('#grading_type').val()).toBe('Lab'); + expect($('input[name=content-visibility][value=staff_only]').is(':checked')).toBe(true); + + $('.wrapper-modal-window .due-date-input .action-clear').click(); + expect($('#due_in').val()).toBe(''); + + $('#grading_type').val('notgraded'); + setContentVisibility('visible'); + + $('.wrapper-modal-window .action-save').click(); + + // This is the response for the change operation. + AjaxHelpers.respondWithJson(requests, {}); + // This is the response for the subsequent fetch operation. + AjaxHelpers.respondWithJson(requests, + createMockSectionJSON({}, [createMockSubsectionJSON()]) + ); + + expect($('.outline-subsection .status-grading-value')).not.toExist(); + expect($('.outline-subsection .status-message-copy')).not.toContainText( + 'Contains staff only content' + ); + }); + }) }); // Note: most tests for units can be found in Bok Choy diff --git a/cms/static/js/views/modals/course_outline_modals.js b/cms/static/js/views/modals/course_outline_modals.js index 7419a0ae10..164cfe31e9 100644 --- a/cms/static/js/views/modals/course_outline_modals.js +++ b/cms/static/js/views/modals/course_outline_modals.js @@ -15,7 +15,7 @@ define(['jquery', 'backbone', 'underscore', 'gettext', 'js/views/baseview', 'use strict'; var CourseOutlineXBlockModal, SettingsXBlockModal, PublishXBlockModal, HighlightsXBlockModal, AbstractEditor, BaseDateEditor, - ReleaseDateEditor, DueDateEditor, GradingEditor, PublishEditor, AbstractVisibilityEditor, + ReleaseDateEditor, DueDateEditor, SelfPacedDueDateEditor, GradingEditor, PublishEditor, AbstractVisibilityEditor, StaffLockEditor, UnitAccessEditor, ContentVisibilityEditor, TimedExaminationPreferenceEditor, AccessEditor, ShowCorrectnessEditor, HighlightsEditor, HighlightsEnableXBlockModal, HighlightsEnableEditor; @@ -389,6 +389,58 @@ define(['jquery', 'backbone', 'underscore', 'gettext', 'js/views/baseview', } }); + SelfPacedDueDateEditor = AbstractEditor.extend({ + fieldName: 'due_num_weeks', + templateName: 'self-paced-due-date-editor', + className: 'modal-section-content has-actions due-date-input grading-due-date', + + events: { + 'click .clear-date': 'clearValue', + 'keyup #due_in': 'validateDueIn', + 'blur #due_in': 'validateDueIn', + }, + + getValue: function() { + return parseInt(this.$('#due_in').val()); + }, + + validateDueIn: function() { + if (this.getValue() > 18){ + this.$('#due-num-weeks-warning-max').show(); + BaseModal.prototype.disableActionButton.call(this.parent, 'save'); + } + else if (this.getValue() < 1){ + this.$('#due-num-weeks-warning-min').show() + BaseModal.prototype.disableActionButton.call(this.parent, 'save'); + } + else { + this.$('#due-num-weeks-warning-max').hide(); + this.$('#due-num-weeks-warning-min').hide(); + BaseModal.prototype.enableActionButton.call(this.parent, 'save'); + } + }, + + clearValue: function(event) { + event.preventDefault(); + this.$('#due_in').val(''); + }, + + afterRender: function() { + AbstractEditor.prototype.afterRender.call(this); + this.$('.field-due-in input').val(this.model.get('due_num_weeks')); + }, + + getRequestData: function() { + if (this.getValue() < 19 && this.getValue() > 0) { + return { + metadata: { + due_num_weeks: this.getValue() + } + }; + } + } + }); + ReleaseDateEditor = BaseDateEditor.extend({ fieldName: 'start', templateName: 'release-date-editor', @@ -1077,6 +1129,9 @@ define(['jquery', 'backbone', 'underscore', 'gettext', 'js/views/baseview', } else if (xblockInfo.isSequential()) { tabs[0].editors = [ReleaseDateEditor, GradingEditor, DueDateEditor]; tabs[1].editors = [ContentVisibilityEditor, ShowCorrectnessEditor]; + if (course.get('self_paced') && course.get('is_custom_pls_active')) { + tabs[0].editors.push(SelfPacedDueDateEditor); + } if (options.enable_proctored_exams || options.enable_timed_exams) { advancedTab.editors.push(TimedExaminationPreferenceEditor); diff --git a/cms/templates/base.html b/cms/templates/base.html index abc3ad8677..53da173858 100644 --- a/cms/templates/base.html +++ b/cms/templates/base.html @@ -10,6 +10,7 @@ <%! from django.utils.translation import ugettext as _ +from cms.djangoapps.contentstore.config.waffle import CUSTOM_PLS from lms.djangoapps.branding import api as branding_api from openedx.core.djangoapps.util.user_messages import PageLevelMessages from openedx.core.djangolib.js_utils import ( @@ -155,7 +156,8 @@ from openedx.core.release import RELEASE_LINE num: "${context_course.location.course | n, js_escaped_string}", display_course_number: "${context_course.display_coursenumber | n, js_escaped_string}", revision: "${context_course.location.branch | n, js_escaped_string}", - self_paced: ${ context_course.self_paced | n, dump_js_escaped_json } + self_paced: ${ context_course.self_paced | n, dump_js_escaped_json }, + is_custom_pls_active: ${CUSTOM_PLS.is_enabled(context_course.id) | n, dump_js_escaped_json} }); % endif diff --git a/cms/templates/course_outline.html b/cms/templates/course_outline.html index 0bf9ce60d4..e9f6f3ad54 100644 --- a/cms/templates/course_outline.html +++ b/cms/templates/course_outline.html @@ -29,7 +29,7 @@ from django.urls import reverse <%block name="header_extras"> -% for template_name in ['course-outline', 'xblock-string-field-editor', 'basic-modal', 'modal-button', 'course-outline-modal', 'due-date-editor', 'release-date-editor', 'grading-editor', 'publish-editor', 'staff-lock-editor', 'unit-access-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', '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']: diff --git a/cms/templates/js/self-paced-due-date-editor.underscore b/cms/templates/js/self-paced-due-date-editor.underscore new file mode 100644 index 0000000000..7d1e894399 --- /dev/null +++ b/cms/templates/js/self-paced-due-date-editor.underscore @@ -0,0 +1,25 @@ + + + + +
+ <%- gettext('The maximum number of weeks this subsection can be due in is 18 weeks.') %> +
+ +
+ <%- gettext('The minimum number of weeks this subsection can be due in is 1 week.') %> +
+ diff --git a/common/lib/xmodule/xmodule/modulestore/inheritance.py b/common/lib/xmodule/xmodule/modulestore/inheritance.py index aac6d198a5..81757341a5 100644 --- a/common/lib/xmodule/xmodule/modulestore/inheritance.py +++ b/common/lib/xmodule/xmodule/modulestore/inheritance.py @@ -46,6 +46,12 @@ class InheritanceMixin(XBlockMixin): help=_("Enter the default date by which problems are due."), scope=Scope.settings, ) + # This attribute is for custom pacing in self paced courses for Studio if CUSTOM_PLS flag is active + due_num_weeks = Integer( + display_name=_("Number of Weeks Due By"), + help=_("Enter the number of weeks the problems are due by relative to the learner's start date"), + scope=Scope.settings, + ) visible_to_staff_only = Boolean( help=_("If true, can be seen only by course staff, regardless of start date."), default=False, diff --git a/common/lib/xmodule/xmodule/modulestore/xml_importer.py b/common/lib/xmodule/xmodule/modulestore/xml_importer.py index 07b184cf6c..57db76be52 100644 --- a/common/lib/xmodule/xmodule/modulestore/xml_importer.py +++ b/common/lib/xmodule/xmodule/modulestore/xml_importer.py @@ -1051,7 +1051,7 @@ def allowed_metadata_by_category(category): return { 'vertical': [], 'chapter': ['start'], - 'sequential': ['due', 'format', 'start', 'graded'] + 'sequential': ['due', 'due_num_weeks', 'format', 'start', 'graded'] }.get(category, ['*']) diff --git a/common/lib/xmodule/xmodule/seq_module.py b/common/lib/xmodule/xmodule/seq_module.py index 3cd2fd4833..6938ed5911 100644 --- a/common/lib/xmodule/xmodule/seq_module.py +++ b/common/lib/xmodule/xmodule/seq_module.py @@ -78,6 +78,12 @@ class SequenceFields: # lint-amnesty, pylint: disable=missing-class-docstring help=_("Enter the date by which problems are due."), scope=Scope.settings, ) + # This attribute is for custom pacing in self paced courses for Studio if CUSTOM_PLS flag is active + due_num_weeks = Integer( + display_name=_("Number of Weeks Due By"), + help=_("Enter the number of weeks the problems are due by relative to the learner's start date"), + scope=Scope.settings, + ) hide_after_due = Boolean( display_name=_("Hide sequence content After Due Date"), diff --git a/openedx/core/djangoapps/course_date_signals/handlers.py b/openedx/core/djangoapps/course_date_signals/handlers.py index 263916c538..6cb58de305 100644 --- a/openedx/core/djangoapps/course_date_signals/handlers.py +++ b/openedx/core/djangoapps/course_date_signals/handlers.py @@ -1,16 +1,18 @@ """Signal handlers for writing course dates into edx_when.""" +from datetime import timedelta import logging from django.dispatch import receiver from edx_when.api import FIELDS_TO_EXTRACT, set_dates_for_course +from xblock.fields import Scope -from xmodule.util.misc import is_xblock_an_assignment +from cms.djangoapps.contentstore.config.waffle import CUSTOM_PLS from openedx.core.lib.graph_traversals import get_children, leaf_filter, traverse_pre_order -from xblock.fields import Scope # lint-amnesty, pylint: disable=wrong-import-order from xmodule.modulestore import ModuleStoreEnum from xmodule.modulestore.django import SignalHandler, modulestore +from xmodule.util.misc import is_xblock_an_assignment from .models import SelfPacedRelativeDatesConfig from .utils import spaced_out_sections @@ -78,6 +80,21 @@ def _gather_graded_items(root, due): # lint-amnesty, pylint: disable=missing-fu return [] +def _get_custom_pacing_children(subsection, num_weeks): + """ + Return relative date items for the subsection and its children + """ + items = [subsection] + section_date_items = [] + while items: + next_item = items.pop() + # Open response assessment problems have their own due dates + if next_item.category != 'openassessment': + section_date_items.append((next_item.location, {'due': timedelta(weeks=num_weeks)})) + items.extend(next_item.get_children()) + return section_date_items + + def extract_dates_from_course(course): """ Extract all dates from the supplied course. @@ -95,9 +112,17 @@ def extract_dates_from_course(course): for _, section, weeks_to_complete in spaced_out_sections(course): section_date_items = [] for subsection in section.get_children(): - section_date_items.extend(_gather_graded_items(subsection, weeks_to_complete)) - - if section_date_items and section.graded: + # If custom pacing is set on a subsection, apply the set relative + # date to all the content inside the subsection. Otherwise + # apply the default Personalized Learner Schedules (PLS) + # logic for self paced courses. + due_num_weeks = subsection.fields['due_num_weeks'].read_from(subsection) + if (CUSTOM_PLS.is_enabled(course.id) and due_num_weeks): + section_date_items.extend(_get_custom_pacing_children(subsection, due_num_weeks)) + else: + section_date_items.extend(_gather_graded_items(subsection, weeks_to_complete)) + # if custom pls is active, we will allow due dates to be set for ungraded items as well + if section_date_items and (section.graded or CUSTOM_PLS.is_enabled(course.id)): date_items.append((section.location, weeks_to_complete)) date_items.extend(section_date_items) else: diff --git a/openedx/core/djangoapps/course_date_signals/tests.py b/openedx/core/djangoapps/course_date_signals/tests.py index 7bbec59e5a..47b9931bbe 100644 --- a/openedx/core/djangoapps/course_date_signals/tests.py +++ b/openedx/core/djangoapps/course_date_signals/tests.py @@ -1,16 +1,22 @@ # lint-amnesty, pylint: disable=missing-module-docstring from datetime import timedelta -import ddt from unittest.mock import patch # lint-amnesty, pylint: disable=wrong-import-order -from openedx.core.djangoapps.course_date_signals.handlers import _gather_graded_items, _has_assignment_blocks +from cms.djangoapps.contentstore.config.waffle import CUSTOM_PLS +from edx_toggles.toggles.testutils import override_waffle_flag +from openedx.core.djangoapps.course_date_signals.handlers import ( + _gather_graded_items, + _get_custom_pacing_children, + _has_assignment_blocks, + extract_dates_from_course +) +from openedx.core.djangoapps.course_date_signals.models import SelfPacedRelativeDatesConfig from xmodule.modulestore.django import modulestore -from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase +from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase, SharedModuleStoreTestCase from xmodule.modulestore.tests.factories import CourseFactory, ItemFactory from . import utils -@ddt.ddt class SelfPacedDueDatesTests(ModuleStoreTestCase): # lint-amnesty, pylint: disable=missing-class-docstring def setUp(self): super().setUp() @@ -91,7 +97,6 @@ class SelfPacedDueDatesTests(ModuleStoreTestCase): # lint-amnesty, pylint: disa with modulestore().bulk_operations(self.course.id): sequence = ItemFactory(parent=self.course, category="sequential") vertical = ItemFactory(parent=sequence, category="vertical") - sequence = modulestore().get_item(sequence.location) ItemFactory.create( parent=vertical, category='problem', @@ -114,5 +119,218 @@ class SelfPacedDueDatesTests(ModuleStoreTestCase): # lint-amnesty, pylint: disa (ungraded_problem_2.location, {'due': None}), (graded_problem_1.location, {'due': 5}), ] - sequence = modulestore().get_item(sequence.location) self.assertCountEqual(_gather_graded_items(sequence, 5), expected_graded_items) + + def test_get_custom_pacing_children(self): + """ + _get_custom_pacing_items should return a list of (block item location, field metadata dictionary) + where the due dates are set from due_num_weeks + """ + # A subsection with multiple units but no problems. Units should inherit due date. + with modulestore().bulk_operations(self.course.id): + sequence = ItemFactory(parent=self.course, category='sequential', due_num_weeks=2) + vertical1 = ItemFactory(parent=sequence, category='vertical') + vertical2 = ItemFactory(parent=sequence, category='vertical') + vertical3 = ItemFactory(parent=sequence, category='vertical') + expected_dates = [ + (sequence.location, {'due': timedelta(weeks=2)}), + (vertical1.location, {'due': timedelta(weeks=2)}), + (vertical2.location, {'due': timedelta(weeks=2)}), + (vertical3.location, {'due': timedelta(weeks=2)}) + ] + self.assertCountEqual(_get_custom_pacing_children(sequence, 2), expected_dates) + + # A subsection with multiple units, each of which has a problem. + # Problems should also inherit due date. + problem1 = ItemFactory(parent=vertical1, category='problem') + problem2 = ItemFactory(parent=vertical2, category='problem') + expected_dates.extend([ + (problem1.location, {'due': timedelta(weeks=2)}), + (problem2.location, {'due': timedelta(weeks=2)}) + ]) + sequence = modulestore().get_item(sequence.location) + self.assertCountEqual(_get_custom_pacing_children(sequence, 2), expected_dates) + + # A subsection that has ORA as a problem. ORA should not inherit due date. + ItemFactory.create(parent=vertical3, category='openassessment') + sequence = modulestore().get_item(sequence.location) + self.assertCountEqual(_get_custom_pacing_children(sequence, 2), expected_dates) + + # A subsection that has an ORA problem and a non ORA problem. ORA should + # not inherit due date, but non ORA problems should. + problem3 = ItemFactory(parent=vertical3, category='problem') + expected_dates.append((problem3.location, {'due': timedelta(weeks=2)})) + sequence = modulestore().get_item(sequence.location) + self.assertCountEqual(_get_custom_pacing_children(sequence, 2), expected_dates) + + +class SelfPacedCustomDueDateTests(SharedModuleStoreTestCase): + """ + Tests the custom Personalized Learner Schedule (PLS) dates in self paced courses + """ + def setUp(self): + super().setUp() + SelfPacedRelativeDatesConfig.objects.create(enabled=True) + + # setUpClassAndTestData() already calls setUpClass on SharedModuleStoreTestCase + # pylint: disable=super-method-not-called + with super().setUpClassAndTestData(): + self.course = CourseFactory.create(self_paced=True) + self.chapter = ItemFactory.create(category='chapter', parent=self.course) + + @override_waffle_flag(CUSTOM_PLS, active=True) + def test_extract_dates_from_course_inheritance(self): + """ + extract_dates_from_course should return a list of (block item location, field metadata dictionary) + and the blocks should inherit the dates from those above in the hiearchy + (ex. If a subsection is assigned a due date, its children should also have the same due date) + """ + course = self.course + with self.store.bulk_operations(course.id): + sequential = ItemFactory.create(category='sequential', parent=self.chapter, due_num_weeks=3) + vertical = ItemFactory.create(category='vertical', parent=sequential) + problem = ItemFactory.create(category='problem', parent=vertical) + expected_dates = [ + (course.location, {}), + (self.chapter.location, timedelta(days=28)), + (sequential.location, {'due': timedelta(days=21)}), + (vertical.location, {'due': timedelta(days=21)}), + (problem.location, {'due': timedelta(days=21)}) + ] + course = modulestore().get_item(course.location) + self.assertCountEqual(extract_dates_from_course(course), expected_dates) + + @override_waffle_flag(CUSTOM_PLS, active=True) + def test_extract_dates_from_course_custom_and_default_pls_one_subsection(self): + """ + due_num_weeks in one of the subsections. Only one of them should have a set due date. + The other subsections do not have due dates because they are not graded + and default PLS do not assign due dates to non graded assignments. + + If custom PLS is not set, the subsection will fall back to the default + PLS logic of evenly spaced sections. + """ + course = self.course + with self.store.bulk_operations(course.id): + sequential = ItemFactory.create(category='sequential', parent=self.chapter, due_num_weeks=3) + ItemFactory.create(category='sequential', parent=self.chapter) + ItemFactory.create(category='sequential', parent=self.chapter) + expected_dates = [ + (course.location, {}), + (self.chapter.location, timedelta(days=28)), + (sequential.location, {'due': timedelta(days=21)}) + ] + course = modulestore().get_item(course.location) + self.assertCountEqual(extract_dates_from_course(course), expected_dates) + + @override_waffle_flag(CUSTOM_PLS, active=True) + def test_extract_dates_from_course_custom_and_default_pls_one_subsection_graded(self): + """ + A section with a subsection that has due_num_weeks and + a subsection without due_num_weeks that has graded content. + Default PLS should apply for the subsection without due_num_weeks that has graded content. + + If custom PLS is not set, the subsection will fall back to the default + PLS logic of evenly spaced sections. + """ + course = self.course + with self.store.bulk_operations(course.id): + sequential1 = ItemFactory.create(category='sequential', parent=self.chapter, due_num_weeks=2) + vertical1 = ItemFactory.create(category='vertical', parent=sequential1) + problem1 = ItemFactory.create(category='problem', parent=vertical1) + + chapter2 = ItemFactory.create(category='chapter', parent=course) + sequential2 = ItemFactory.create(category='sequential', parent=chapter2, graded=True) + vertical2 = ItemFactory.create(category='vertical', parent=sequential2) + problem2 = ItemFactory.create(category='problem', parent=vertical2) + + expected_dates = [ + (course.location, {}), + (self.chapter.location, timedelta(days=21)), + (sequential1.location, {'due': timedelta(days=14)}), + (vertical1.location, {'due': timedelta(days=14)}), + (problem1.location, {'due': timedelta(days=14)}), + (chapter2.location, timedelta(days=42)), + (sequential2.location, {'due': timedelta(days=42)}), + (vertical2.location, {'due': timedelta(days=42)}), + (problem2.location, {'due': timedelta(days=42)}) + ] + course = modulestore().get_item(course.location) + with patch.object(utils, 'get_expected_duration', return_value=timedelta(weeks=6)): + self.assertCountEqual(extract_dates_from_course(course), expected_dates) + + @override_waffle_flag(CUSTOM_PLS, active=True) + def test_extract_dates_from_course_custom_and_default_pls_multiple_subsections_graded(self): + """ + A section with a subsection that has due_num_weeks and multiple sections without + due_num_weeks that has graded content. Default PLS should apply for the subsections + without due_num_weeks that has graded content. + + If custom PLS is not set, the subsection will fall back to the default + PLS logic of evenly spaced sections. + """ + course = self.course + with self.store.bulk_operations(course.id): + sequential1 = ItemFactory.create(category='sequential', parent=self.chapter, due_num_weeks=4) + vertical1 = ItemFactory.create(category='vertical', parent=sequential1) + problem1 = ItemFactory.create(category='problem', parent=vertical1) + + expected_dates = [ + (course.location, {}), + (self.chapter.location, timedelta(days=14)), + (sequential1.location, {'due': timedelta(days=28)}), + (vertical1.location, {'due': timedelta(days=28)}), + (problem1.location, {'due': timedelta(days=28)}) + ] + + for i in range(3): + chapter = ItemFactory.create(category='chapter', parent=course) + sequential = ItemFactory.create(category='sequential', parent=chapter, graded=True) + vertical = ItemFactory.create(category='vertical', parent=sequential) + problem = ItemFactory.create(category='problem', parent=vertical) + num_days = i * 14 + 28 + expected_dates.extend([ + (chapter.location, timedelta(days=num_days)), + (sequential.location, {'due': timedelta(days=num_days)}), + (vertical.location, {'due': timedelta(days=num_days)}), + (problem.location, {'due': timedelta(days=num_days)}), + ]) + + course = modulestore().get_item(course.location) + with patch.object(utils, 'get_expected_duration', return_value=timedelta(weeks=8)): + self.assertCountEqual(extract_dates_from_course(course), expected_dates) + + @override_waffle_flag(CUSTOM_PLS, active=True) + def test_extract_dates_from_course_all_subsections(self): + """ + With due_num_weeks on all subsections. All subsections should + have their corresponding due dates. + """ + course = self.course + with self.store.bulk_operations(course.id): + sequential1 = ItemFactory.create(category='sequential', parent=self.chapter, due_num_weeks=3) + sequential2 = ItemFactory.create(category='sequential', parent=self.chapter, due_num_weeks=4) + sequential3 = ItemFactory.create(category='sequential', parent=self.chapter, due_num_weeks=5) + expected_dates = [ + (course.location, {}), + (self.chapter.location, timedelta(days=28)), + (sequential1.location, {'due': timedelta(days=21)}), + (sequential2.location, {'due': timedelta(days=28)}), + (sequential3.location, {'due': timedelta(days=35)}) + ] + course = modulestore().get_item(course.location) + self.assertCountEqual(extract_dates_from_course(course), expected_dates) + + @override_waffle_flag(CUSTOM_PLS, active=True) + def test_extract_dates_from_course_no_subsections(self): + """ + Without due_num_weeks on all subsections. None of the subsections should + have due dates. + """ + course = self.course + with self.store.bulk_operations(course.id): + for _ in range(3): + ItemFactory.create(category='sequential', parent=self.chapter) + expected_dates = [(course.location, {})] + course = modulestore().get_item(course.location) + self.assertCountEqual(extract_dates_from_course(course), expected_dates)