Support self-paced courses in learning_sequences app
The main difference between how instructor-paced and self-paced courses work with respect to the outline on the backend is how we treat the hide_after_due attribute on subsections (sequences). Namely, self-paced courses ignore due dates even if they are specified on the sequence (for example, by OLX import). If hide_after_due is True in a self-paced course sequence, we only make it inaccessible after the entire course ends. This was tracked as BD-29 and TNL-7262.
This commit is contained in:
@@ -104,6 +104,7 @@ def get_course_outline(course_key: CourseKey) -> CourseOutlineData:
|
||||
published_at=course_context.learning_context.published_at,
|
||||
published_version=course_context.learning_context.published_version,
|
||||
sections=sections_data,
|
||||
self_paced=course_context.self_paced,
|
||||
course_visibility=CourseVisibility(course_context.course_visibility),
|
||||
)
|
||||
TieredCache.set_all_tiers(cache_key, outline_data, 300)
|
||||
@@ -218,7 +219,10 @@ def _get_user_course_outline_and_processors(course_key: CourseKey,
|
||||
accessible_sequences=accessible_sequences,
|
||||
**{
|
||||
name: getattr(trimmed_course_outline, name)
|
||||
for name in ['course_key', 'title', 'published_at', 'published_version', 'sections', 'course_visibility']
|
||||
for name in [
|
||||
'course_key', 'title', 'published_at', 'published_version',
|
||||
'sections', 'self_paced', 'course_visibility'
|
||||
]
|
||||
}
|
||||
)
|
||||
|
||||
@@ -266,6 +270,7 @@ def _update_course_context(course_outline: CourseOutlineData):
|
||||
learning_context=learning_context,
|
||||
defaults={
|
||||
'course_visibility': course_outline.course_visibility.value,
|
||||
'self_paced': course_outline.self_paced,
|
||||
}
|
||||
)
|
||||
if created:
|
||||
|
||||
@@ -27,7 +27,6 @@ class ScheduleOutlineProcessor(OutlineProcessor):
|
||||
|
||||
Things we don't handle yet:
|
||||
* Beta test users
|
||||
* Self-paced courses
|
||||
* Things that are made inaccessible after they're due.
|
||||
"""
|
||||
|
||||
@@ -62,6 +61,8 @@ class ScheduleOutlineProcessor(OutlineProcessor):
|
||||
if self._course_start is None or self.at_time < self._course_start:
|
||||
return set(full_course_outline.sequences)
|
||||
|
||||
self_paced = full_course_outline.self_paced
|
||||
|
||||
inaccessible = set()
|
||||
for section in full_course_outline.sections:
|
||||
section_start = self.keys_to_schedule_fields[section.usage_key].get('start')
|
||||
@@ -77,7 +78,13 @@ class ScheduleOutlineProcessor(OutlineProcessor):
|
||||
inaccessible.add(seq.usage_key)
|
||||
continue
|
||||
|
||||
seq_due = self.keys_to_schedule_fields[seq.usage_key].get('due')
|
||||
# if course is self-paced, all sequences with enabled hide after due
|
||||
# must have due date equal to course's end date
|
||||
if self_paced:
|
||||
seq_due = self._course_end
|
||||
else:
|
||||
seq_due = self.keys_to_schedule_fields[seq.usage_key].get('due')
|
||||
|
||||
if seq.inaccessible_after_due:
|
||||
if seq_due and self.at_time > seq_due:
|
||||
inaccessible.add(seq.usage_key)
|
||||
|
||||
@@ -32,6 +32,7 @@ class TestCourseOutlineData(TestCase):
|
||||
published_at=datetime(2020, 5, 19, tzinfo=timezone.utc),
|
||||
published_version="5ebece4b69dd593d82fe2014",
|
||||
sections=generate_sections(cls.course_key, [3, 2]),
|
||||
self_paced=False,
|
||||
course_visibility=CourseVisibility.PRIVATE
|
||||
)
|
||||
|
||||
|
||||
@@ -43,6 +43,7 @@ class CourseOutlineTestCase(CacheIsolationTestCase):
|
||||
published_at=datetime(2020, 5, 20, tzinfo=timezone.utc),
|
||||
published_version="5ebece4b69dd593d82fe2015",
|
||||
sections=generate_sections(cls.course_key, [2, 2]),
|
||||
self_paced=False,
|
||||
course_visibility=CourseVisibility.PRIVATE
|
||||
)
|
||||
|
||||
@@ -133,6 +134,7 @@ class UserCourseOutlineTestCase(CacheIsolationTestCase):
|
||||
published_at=datetime(2020, 5, 20, tzinfo=timezone.utc),
|
||||
published_version="5ebece4b69dd593d82fe2020",
|
||||
sections=generate_sections(cls.course_key, [2, 1, 3]),
|
||||
self_paced=False,
|
||||
course_visibility=CourseVisibility.PRIVATE
|
||||
)
|
||||
replace_course_outline(cls.simple_outline)
|
||||
@@ -288,7 +290,8 @@ class ScheduleTestCase(CacheIsolationTestCase):
|
||||
),
|
||||
]
|
||||
)
|
||||
]
|
||||
],
|
||||
self_paced=False
|
||||
)
|
||||
replace_course_outline(cls.outline)
|
||||
|
||||
@@ -380,6 +383,105 @@ class ScheduleTestCase(CacheIsolationTestCase):
|
||||
assert key in student_details.outline.accessible_sequences
|
||||
|
||||
|
||||
class SelfPacedCourseOutlineTestCase(CacheIsolationTestCase):
|
||||
@classmethod
|
||||
def setUpTestData(cls):
|
||||
# Users...
|
||||
cls.global_staff = User.objects.create_user(
|
||||
'global_staff', email='gstaff@example.com', is_staff=True
|
||||
)
|
||||
cls.student = User.objects.create_user(
|
||||
'student', email='student@example.com', is_staff=False
|
||||
)
|
||||
|
||||
cls.course_key = CourseKey.from_string("course-v1:OpenEdX+Outline+T1")
|
||||
|
||||
# The UsageKeys we're going to set up for date tests.
|
||||
cls.section_key = cls.course_key.make_usage_key('chapter', 'ch1')
|
||||
|
||||
# Sequence with due date
|
||||
cls.seq_due_key = cls.course_key.make_usage_key('sequential', 'seq')
|
||||
|
||||
# Sequence with due date and "inaccessible after due" enabled
|
||||
cls.seq_hide_after_due_key = cls.course_key.make_usage_key('sequential', 'seq_hide_after_due_key')
|
||||
|
||||
# Set scheduling information into edx-when for a single Section with
|
||||
# two sequences with due date
|
||||
set_dates_for_course(
|
||||
cls.course_key,
|
||||
[
|
||||
(
|
||||
cls.course_key.make_usage_key('course', 'course'),
|
||||
{
|
||||
'start': datetime(2020, 5, 10, tzinfo=timezone.utc),
|
||||
}
|
||||
),
|
||||
(
|
||||
cls.section_key,
|
||||
{'start': datetime(2020, 5, 15, tzinfo=timezone.utc)}
|
||||
),
|
||||
(
|
||||
cls.seq_due_key,
|
||||
{'due': datetime(2020, 5, 21, tzinfo=timezone.utc)}
|
||||
),
|
||||
(
|
||||
cls.seq_hide_after_due_key,
|
||||
{'due': datetime(2020, 5, 21, tzinfo=timezone.utc)}
|
||||
),
|
||||
]
|
||||
)
|
||||
visibility = VisibilityData(
|
||||
hide_from_toc=False,
|
||||
visible_to_staff_only=False
|
||||
)
|
||||
cls.outline = CourseOutlineData(
|
||||
course_key=cls.course_key,
|
||||
title="User Outline Test Course!",
|
||||
published_at=datetime(2020, 5, 20, tzinfo=timezone.utc),
|
||||
published_version="5ebece4b69dd593d82fe2020",
|
||||
course_visibility=CourseVisibility.PRIVATE,
|
||||
sections=[
|
||||
CourseSectionData(
|
||||
usage_key=cls.section_key,
|
||||
title="Section",
|
||||
visibility=visibility,
|
||||
sequences=[
|
||||
CourseLearningSequenceData(
|
||||
usage_key=cls.seq_due_key,
|
||||
title='Due',
|
||||
visibility=visibility
|
||||
),
|
||||
CourseLearningSequenceData(
|
||||
usage_key=cls.seq_hide_after_due_key,
|
||||
title='Inaccessible after due',
|
||||
visibility=visibility,
|
||||
inaccessible_after_due=True
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
self_paced=True,
|
||||
)
|
||||
|
||||
replace_course_outline(cls.outline)
|
||||
|
||||
# Enroll student in the course
|
||||
cls.student.courseenrollment_set.create(course_id=cls.course_key, is_active=True, mode="audit")
|
||||
|
||||
def test_sequences_accessible_after_due(self):
|
||||
at_time = datetime(2020, 5, 22, tzinfo=timezone.utc)
|
||||
staff_outline = get_user_course_outline_details(self.course_key, self.global_staff, at_time).outline
|
||||
student_outline = get_user_course_outline_details(self.course_key, self.student, at_time).outline
|
||||
|
||||
# Staff can always access all sequences
|
||||
assert len(staff_outline.accessible_sequences) == 2
|
||||
|
||||
# In self-paced course, due date of sequences equals to due date of
|
||||
# course, so here student should see all sequences, even if their
|
||||
# due dates explicitly were set before end of course
|
||||
assert len(student_outline.accessible_sequences) == 2
|
||||
|
||||
|
||||
class VisbilityTestCase(CacheIsolationTestCase):
|
||||
"""
|
||||
Visibility-related tests.
|
||||
@@ -455,7 +557,8 @@ class VisbilityTestCase(CacheIsolationTestCase):
|
||||
]
|
||||
)
|
||||
|
||||
]
|
||||
],
|
||||
self_paced=False
|
||||
)
|
||||
replace_course_outline(cls.outline)
|
||||
|
||||
@@ -464,17 +567,17 @@ class VisbilityTestCase(CacheIsolationTestCase):
|
||||
|
||||
def test_visibility(self):
|
||||
at_time = datetime(2020, 5, 21, tzinfo=timezone.utc) # Exact value doesn't matter
|
||||
staff_details = get_user_course_outline_details(self.course_key, self.global_staff, at_time)
|
||||
student_details = get_user_course_outline_details(self.course_key, self.student, at_time)
|
||||
staff_outline = get_user_course_outline_details(self.course_key, self.global_staff, at_time).outline
|
||||
student_outline = get_user_course_outline_details(self.course_key, self.student, at_time).outline
|
||||
|
||||
# Sections visible
|
||||
assert len(staff_details.outline.sections) == 2
|
||||
assert len(student_details.outline.sections) == 1
|
||||
assert len(staff_outline.sections) == 2
|
||||
assert len(student_outline.sections) == 1
|
||||
|
||||
# Sequences visible
|
||||
assert len(staff_details.outline.sequences) == 4
|
||||
assert len(student_details.outline.sequences) == 1
|
||||
assert self.normal_in_normal_key in student_details.outline.sequences
|
||||
assert len(staff_outline.sequences) == 4
|
||||
assert len(student_outline.sequences) == 1
|
||||
assert self.normal_in_normal_key in student_outline.sequences
|
||||
|
||||
|
||||
class SequentialVisibilityTestCase(CacheIsolationTestCase):
|
||||
@@ -508,6 +611,7 @@ class SequentialVisibilityTestCase(CacheIsolationTestCase):
|
||||
published_at=datetime(2020, 5, 20, tzinfo=timezone.utc),
|
||||
published_version="5ebece4b69dd593d82fe2020",
|
||||
sections=generate_sections(cls.course_key, [2, 1, 3]),
|
||||
self_paced=False,
|
||||
course_visibility=CourseVisibility.PRIVATE
|
||||
)
|
||||
replace_course_outline(cls.course_outline)
|
||||
|
||||
@@ -134,6 +134,9 @@ class CourseOutlineData:
|
||||
|
||||
sections = attr.ib(type=List[CourseSectionData])
|
||||
|
||||
# Defines if course self-paced or instructor-paced.
|
||||
self_paced = attr.ib(type=bool)
|
||||
|
||||
# To make sure that our data structure is consistent, this field is
|
||||
# derived from what you pass into `sections`. Do not set this directly.
|
||||
sequences = attr.ib(type=Dict[UsageKey, CourseLearningSequenceData], init=False)
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
# Generated by Django 2.2.14 on 2020-08-02 23:48
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('learning_sequences', '0003_create_course_context_for_course_specific_models'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='coursecontext',
|
||||
name='self_paced',
|
||||
field=models.BooleanField(default=False),
|
||||
),
|
||||
]
|
||||
@@ -80,6 +80,7 @@ class CourseContext(TimeStampedModel):
|
||||
course_visibility = models.CharField(
|
||||
max_length=32, choices=[(constant.value, constant.value) for constant in CourseVisibility]
|
||||
)
|
||||
self_paced = models.BooleanField(default=False)
|
||||
|
||||
|
||||
class LearningSequence(TimeStampedModel):
|
||||
|
||||
@@ -72,6 +72,7 @@ def get_outline_from_modulestore(course_key):
|
||||
published_at=course.subtree_edited_on,
|
||||
published_version=str(course.course_version), # .course_version is a BSON obj
|
||||
sections=sections_data,
|
||||
self_paced=course.self_paced,
|
||||
course_visibility=CourseVisibility(course.course_visibility),
|
||||
)
|
||||
return course_outline_data
|
||||
|
||||
@@ -45,6 +45,7 @@ class CourseOutlineViewTest(CacheIsolationTestCase, APITestCase):
|
||||
published_at=datetime(2020, 5, 20, tzinfo=timezone.utc),
|
||||
published_version="5ebece4b69dd593d82fe2020",
|
||||
sections=generate_sections(cls.course_key, [2, 2]),
|
||||
self_paced=False,
|
||||
course_visibility=CourseVisibility.PUBLIC
|
||||
)
|
||||
replace_course_outline(cls.outline)
|
||||
|
||||
@@ -39,7 +39,7 @@ class CourseOutlineView(APIView):
|
||||
|
||||
This serializer was purposefully declared inline with the
|
||||
CourseOutlineView to discourage reuse/magic. Our goal is to make it
|
||||
extremely obvious how things are being serialized, and not have suprise
|
||||
extremely obvious how things are being serialized, and not have surprise
|
||||
regressions because a shared serializer in another module was modified
|
||||
to fix an issue in one of its three use cases.
|
||||
|
||||
@@ -70,6 +70,7 @@ class CourseOutlineView(APIView):
|
||||
"title": user_course_outline.title,
|
||||
"published_at": user_course_outline.published_at,
|
||||
"published_version": user_course_outline.published_version,
|
||||
"self_paced": user_course_outline.self_paced,
|
||||
|
||||
# Who and when this request was generated for (we can eventually
|
||||
# support arbitrary times).
|
||||
|
||||
Reference in New Issue
Block a user