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:
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user