Merge pull request #28016 from edx/syoon/AA-844

feat: AA-883 Basic prototype for self paced due dates in Studio
This commit is contained in:
sofiayoon
2021-07-09 09:23:05 -04:00
committed by GitHub
13 changed files with 540 additions and 23 deletions

View File

@@ -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:

View File

@@ -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)