+ <%- 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)