From 45000e80912bb4dd507bbdc86ac2ab2c74ec2d0e 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 | 90 ++++++++++++++----- .../djangoapps/course_date_signals/tests.py | 57 ++++++++++++ .../djangoapps/course_date_signals/waffle.py | 19 ++++ 3 files changed, 144 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..65a37f2e03f6 100644 --- a/openedx/core/djangoapps/course_date_signals/handlers.py +++ b/openedx/core/djangoapps/course_date_signals/handlers.py @@ -16,6 +16,7 @@ from .models import SelfPacedRelativeDatesConfig from .utils import spaced_out_sections +from .waffle import DISABLE_SPACED_OUT_SECTIONS log = logging.getLogger(__name__) @@ -118,6 +119,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 +187,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..721cd4d123d9 100644 --- a/openedx/core/djangoapps/course_date_signals/tests.py +++ b/openedx/core/djangoapps/course_date_signals/tests.py @@ -16,6 +16,7 @@ from openedx.core.djangoapps.course_date_signals.models import SelfPacedRelativeDatesConfig from . import utils +from .waffle import DISABLE_SPACED_OUT_SECTIONS class SelfPacedDueDatesTests(ModuleStoreTestCase): # lint-amnesty, pylint: disable=missing-class-docstring @@ -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..300c0cc4b100 --- /dev/null +++ b/openedx/core/djangoapps/course_date_signals/waffle.py @@ -0,0 +1,19 @@ +""" +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.disable_spaced_out_sections +# .. toggle_implementation: CourseWaffleFlag +# .. toggle_default: False +# .. toggle_description: Waffle flag to disable spaced out sections for self paced courses. +# .. 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}.disable_spaced_out_sections", __name__ +)