From e82d102784211e624ac1e11905b15f7dd6aedd76 Mon Sep 17 00:00:00 2001 From: Maxim Beder Date: Mon, 2 Sep 2024 12:58:13 +0200 Subject: [PATCH] feat: allow disabling spaced out sections in self paced courses In self paced courses, if relative due dates are enabled via SelfPacedRelativeDatesConfig, all graded content would be assigned relative due dates which are evenly spaced out over an estimated duration of a course (aka. Personal Learner Schedule or PLS). If CUSTOM_RELATIVE_DATES are enabled, custom set relative due dates would (sometimes) override the "spaced out" ones. However, there are some usecases, when custom relative due dates are desired, without the PLS. For this usecase we are adding a DISABLE_SPACED_OUT_SECTIONS CourseWaffleFlag. None of the existing behaviour is changed unwillingly. When the flag is enabled, the relative due dates will only be applied to the subsections that have custom relative due dates set, or when a similar setting is set in Advanced Settings of a course. --- .../course_date_signals/handlers.py | 91 ++++++++++++++----- .../djangoapps/course_date_signals/tests.py | 57 ++++++++++++ .../djangoapps/course_date_signals/waffle.py | 28 ++++++ 3 files changed, 154 insertions(+), 22 deletions(-) create mode 100644 openedx/core/djangoapps/course_date_signals/waffle.py 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..35b9ff2e0ec2 --- /dev/null +++ b/openedx/core/djangoapps/course_date_signals/waffle.py @@ -0,0 +1,28 @@ +""" +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 the course and dividing it by the number of sections, +# and then multiplying it by the index of the section that is currently being +# assigned a due date. 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 the 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__ +)