diff --git a/openedx/core/djangoapps/course_date_signals/handlers.py b/openedx/core/djangoapps/course_date_signals/handlers.py index 6f3f4ed9a713..ca6625bdc549 100644 --- a/openedx/core/djangoapps/course_date_signals/handlers.py +++ b/openedx/core/djangoapps/course_date_signals/handlers.py @@ -14,6 +14,8 @@ from xmodule.modulestore.django import SignalHandler, modulestore # lint-amnesty, pylint: disable=wrong-import-order from xmodule.util.misc import is_xblock_an_assignment # lint-amnesty, pylint: disable=wrong-import-order +from openedx.core.djangoapps.course_date_signals.waffle import DISABLE_SPACED_OUT_SECTIONS + from .models import SelfPacedRelativeDatesConfig from .utils import spaced_out_sections @@ -118,6 +120,63 @@ def _get_custom_pacing_children(subsection, num_weeks): return section_date_items +def extract_dates_from_course_spaced_out_sections(course): + """ + Extract all dates from the supplied course. Apply PLS to subsections that + don't have custom relative_weeks_due set, by spacing them out evenly based + on the estimated course duration. + """ + date_items = [] + # Apply the same relative due date to all content inside a section, + # unless that item already has a relative date set + for _, section, weeks_to_complete in spaced_out_sections(course): + section_date_items = [] + # section_due_date will end up being the max of all due dates of its subsections + section_due_date = timedelta(weeks=1) + for subsection in section.get_children(): + # 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. + relative_weeks_due = subsection.fields['relative_weeks_due'].read_from(subsection) + if (CUSTOM_RELATIVE_DATES.is_enabled(course.id) and relative_weeks_due): + section_due_date = max(section_due_date, timedelta(weeks=relative_weeks_due)) + section_date_items.extend(_get_custom_pacing_children(subsection, relative_weeks_due)) + else: + section_due_date = max(section_due_date, weeks_to_complete) + section_date_items.extend(_gather_graded_items(subsection, weeks_to_complete)) + if section_date_items and (section.graded or CUSTOM_RELATIVE_DATES.is_enabled(course.id)): + date_items.append((section.location, {'due': section_due_date})) + date_items.extend(section_date_items) + return date_items + + +def extract_dates_from_course_custom_dates_only(course): + """ + Extract all dates from the supplied course. Only considers subsections that + have relative_weeks_due set, either custom or through Advanced Settings. + """ + date_items = [] + # Apply relative due date only to content inside a section, + # that already has a relative date set. Also inherits relative + # due date set in the advanced settings. + for section in course.get_children(): + if section.visible_to_staff_only: + continue + section_date_items = [] + for subsection in section.get_children(): + # If custom pacing is set on a subsection, apply the set relative + # date to all the content inside the subsection. + relative_weeks_due = subsection.fields['relative_weeks_due'].read_from(subsection) + if relative_weeks_due: + section_due_date = timedelta(weeks=relative_weeks_due) + section_date_items.extend(_get_custom_pacing_children(subsection, relative_weeks_due)) + if section_date_items: + date_items.append((section.location, {'due': section_due_date})) + date_items.extend(section_date_items) + return date_items + + def extract_dates_from_course(course): """ Extract all dates from the supplied course. @@ -129,28 +188,16 @@ def extract_dates_from_course(course): metadata.pop('due', None) date_items = [(course.location, metadata)] - if SelfPacedRelativeDatesConfig.current(course_key=course.id).enabled: - # Apply the same relative due date to all content inside a section, - # unless that item already has a relative date set - for _, section, weeks_to_complete in spaced_out_sections(course): - section_date_items = [] - # section_due_date will end up being the max of all due dates of its subsections - section_due_date = timedelta(weeks=1) - for subsection in section.get_children(): - # 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. - relative_weeks_due = subsection.fields['relative_weeks_due'].read_from(subsection) - if (CUSTOM_RELATIVE_DATES.is_enabled(course.id) and relative_weeks_due): - section_due_date = max(section_due_date, timedelta(weeks=relative_weeks_due)) - section_date_items.extend(_get_custom_pacing_children(subsection, relative_weeks_due)) - else: - section_due_date = max(section_due_date, weeks_to_complete) - section_date_items.extend(_gather_graded_items(subsection, weeks_to_complete)) - if section_date_items and (section.graded or CUSTOM_RELATIVE_DATES.is_enabled(course.id)): - date_items.append((section.location, {'due': section_due_date})) - date_items.extend(section_date_items) + self_paced_relative_dates_config = SelfPacedRelativeDatesConfig.current(course_key=course.id) + if self_paced_relative_dates_config.enabled: + if not DISABLE_SPACED_OUT_SECTIONS.is_enabled(course.id): + date_items.extend( + extract_dates_from_course_spaced_out_sections(course) + ) + elif CUSTOM_RELATIVE_DATES.is_enabled(course.id): + date_items.extend( + extract_dates_from_course_custom_dates_only(course) + ) else: date_items = [] store = modulestore() diff --git a/openedx/core/djangoapps/course_date_signals/tests.py b/openedx/core/djangoapps/course_date_signals/tests.py index ee1e95b7b5a2..ebfbced76dd0 100644 --- a/openedx/core/djangoapps/course_date_signals/tests.py +++ b/openedx/core/djangoapps/course_date_signals/tests.py @@ -14,6 +14,7 @@ extract_dates_from_course ) from openedx.core.djangoapps.course_date_signals.models import SelfPacedRelativeDatesConfig +from openedx.core.djangoapps.course_date_signals.waffle import DISABLE_SPACED_OUT_SECTIONS from . import utils @@ -370,3 +371,59 @@ def test_extract_dates_from_course_no_subsections(self): expected_dates = [(self.course.location, {})] course = self.store.get_item(self.course.location) self.assertCountEqual(extract_dates_from_course(course), expected_dates) + + @override_waffle_flag(CUSTOM_RELATIVE_DATES, active=True) + @override_waffle_flag(DISABLE_SPACED_OUT_SECTIONS, active=True) + def test_extract_dates_from_course_spaced_out_sections_disabled(self): + """ + A section with a subsection that has relative_weeks_due and + a subsection without relative_weeks_due that has graded content. + With DISABLE_SPACED_OUT_SECTIONS active, PLS should not apply for the + subsections without relative_weeks_due, even if it's graded. In other + words, when DISABLE_SPACED_OUT_SECTIONS is active, only custom set + relative_weeks_due are applied. + """ + with self.store.bulk_operations(self.course.id): + sequential1 = BlockFactory.create(category='sequential', parent=self.chapter, relative_weeks_due=2) + vertical1 = BlockFactory.create(category='vertical', parent=sequential1) + problem1 = BlockFactory.create(category='problem', parent=vertical1) + + chapter2 = BlockFactory.create(category='chapter', parent=self.course) + sequential2 = BlockFactory.create(category='sequential', parent=chapter2, graded=True) + vertical2 = BlockFactory.create(category='vertical', parent=sequential2) + problem2 = BlockFactory.create(category='problem', parent=vertical2) + + expected_dates = [ + (self.course.location, {}), + (self.chapter.location, {'due': timedelta(days=14)}), + (sequential1.location, {'due': timedelta(days=14)}), + (vertical1.location, {'due': timedelta(days=14)}), + (problem1.location, {'due': timedelta(days=14)}), + ] + course = self.store.get_item(self.course.location) + self.assertCountEqual(extract_dates_from_course(course), expected_dates) + + @override_waffle_flag(CUSTOM_RELATIVE_DATES, active=False) + @override_waffle_flag(DISABLE_SPACED_OUT_SECTIONS, active=True) + def test_extract_dates_from_course_spaced_out_sections_and_custom_dates_disabled(self): + """ + A section with a subsection that has relative_weeks_due and + a subsection without relative_weeks_due that has graded content. + With DISABLE_SPACED_OUT_SECTIONS active and CUSTOM_RELATIVE_DATES + disabled, PLS should not apply for the subsections with relative_weeks_due. + """ + with self.store.bulk_operations(self.course.id): + sequential1 = BlockFactory.create(category='sequential', parent=self.chapter, relative_weeks_due=2) + vertical1 = BlockFactory.create(category='vertical', parent=sequential1) + problem1 = BlockFactory.create(category='problem', parent=vertical1) + + chapter2 = BlockFactory.create(category='chapter', parent=self.course) + sequential2 = BlockFactory.create(category='sequential', parent=chapter2, graded=True) + vertical2 = BlockFactory.create(category='vertical', parent=sequential2) + problem2 = BlockFactory.create(category='problem', parent=vertical2) + + expected_dates = [ + (self.course.location, {}), + ] + course = self.store.get_item(self.course.location) + self.assertCountEqual(extract_dates_from_course(course), expected_dates) diff --git a/openedx/core/djangoapps/course_date_signals/waffle.py b/openedx/core/djangoapps/course_date_signals/waffle.py new file mode 100644 index 000000000000..f9b1a6500256 --- /dev/null +++ b/openedx/core/djangoapps/course_date_signals/waffle.py @@ -0,0 +1,32 @@ +""" +This module contains various configuration settings via waffle switches for +course date signals. +""" + +from openedx.core.djangoapps.waffle_utils import CourseWaffleFlag + +WAFFLE_FLAG_NAMESPACE = "course_date_signals" + +# .. toggle_name: course_date_signals.relative_dates_disable_suggested_schedule +# .. toggle_implementation: CourseWaffleFlag +# .. toggle_default: False +# .. toggle_description: Waffle flag to disable suggested schedule for self paced courses. +# When suggested schedule is enabled, graded content in self paced courses +# will be assigned a suggested relative due date. Suggested relative due dates +# are calculated by getting an average time needed per section, by getting an +# estimated duration of a course and dividing it by a number of sections, +# and then multiplying it by an index of a section that is currently being +# assigned a due date. E.g. if a course is estimated to be 4 weeks, has 4 +# sections, and each one is marked as graded, the first section's relative due +# date is going to be one week from the date of the enrollment, the second - +# two weeks, etc. +# The estimated course duration is fetched from the Course Discovery service, +# and is clamped between 4 and 18 weeks. If Course Discovery is not available +# or value is not set for a course that is being requested, the estimated time +# would be set to 4 weeks. +# .. toggle_use_cases: open_edx +# .. toggle_creation_date: 2024-09-02 +# .. toggle_target_removal_date: None +DISABLE_SPACED_OUT_SECTIONS = CourseWaffleFlag( + f"{WAFFLE_FLAG_NAMESPACE}.relative_dates_disable_suggested_schedule", __name__ +)