From bf306ec48221619efcab0e86269c253eb4ac7a2c Mon Sep 17 00:00:00 2001 From: Demid Date: Thu, 27 Aug 2020 19:54:20 +0300 Subject: [PATCH] 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. --- .../learning_sequences/api/outlines.py | 7 +- .../api/processors/schedule.py | 11 +- .../learning_sequences/api/tests/test_data.py | 1 + .../api/tests/test_outlines.py | 122 ++++++++++++++++-- .../content/learning_sequences/data.py | 3 + .../0004_coursecontext_self_paced.py | 18 +++ .../content/learning_sequences/models.py | 1 + .../content/learning_sequences/tasks.py | 1 + .../learning_sequences/tests/test_views.py | 1 + .../content/learning_sequences/views.py | 3 +- 10 files changed, 155 insertions(+), 13 deletions(-) create mode 100644 openedx/core/djangoapps/content/learning_sequences/migrations/0004_coursecontext_self_paced.py diff --git a/openedx/core/djangoapps/content/learning_sequences/api/outlines.py b/openedx/core/djangoapps/content/learning_sequences/api/outlines.py index b1d23b0758..b8c18271bc 100644 --- a/openedx/core/djangoapps/content/learning_sequences/api/outlines.py +++ b/openedx/core/djangoapps/content/learning_sequences/api/outlines.py @@ -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: diff --git a/openedx/core/djangoapps/content/learning_sequences/api/processors/schedule.py b/openedx/core/djangoapps/content/learning_sequences/api/processors/schedule.py index f6a0d94699..a0429e8253 100644 --- a/openedx/core/djangoapps/content/learning_sequences/api/processors/schedule.py +++ b/openedx/core/djangoapps/content/learning_sequences/api/processors/schedule.py @@ -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) diff --git a/openedx/core/djangoapps/content/learning_sequences/api/tests/test_data.py b/openedx/core/djangoapps/content/learning_sequences/api/tests/test_data.py index f8bdc34bc1..0c678c605e 100644 --- a/openedx/core/djangoapps/content/learning_sequences/api/tests/test_data.py +++ b/openedx/core/djangoapps/content/learning_sequences/api/tests/test_data.py @@ -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 ) diff --git a/openedx/core/djangoapps/content/learning_sequences/api/tests/test_outlines.py b/openedx/core/djangoapps/content/learning_sequences/api/tests/test_outlines.py index 83233bca55..d90efc9d7c 100644 --- a/openedx/core/djangoapps/content/learning_sequences/api/tests/test_outlines.py +++ b/openedx/core/djangoapps/content/learning_sequences/api/tests/test_outlines.py @@ -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) diff --git a/openedx/core/djangoapps/content/learning_sequences/data.py b/openedx/core/djangoapps/content/learning_sequences/data.py index 556e763f54..10325133cd 100644 --- a/openedx/core/djangoapps/content/learning_sequences/data.py +++ b/openedx/core/djangoapps/content/learning_sequences/data.py @@ -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) diff --git a/openedx/core/djangoapps/content/learning_sequences/migrations/0004_coursecontext_self_paced.py b/openedx/core/djangoapps/content/learning_sequences/migrations/0004_coursecontext_self_paced.py new file mode 100644 index 0000000000..c5bfdd01a8 --- /dev/null +++ b/openedx/core/djangoapps/content/learning_sequences/migrations/0004_coursecontext_self_paced.py @@ -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), + ), + ] diff --git a/openedx/core/djangoapps/content/learning_sequences/models.py b/openedx/core/djangoapps/content/learning_sequences/models.py index ac0b54c50c..8ef5cdf20c 100644 --- a/openedx/core/djangoapps/content/learning_sequences/models.py +++ b/openedx/core/djangoapps/content/learning_sequences/models.py @@ -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): diff --git a/openedx/core/djangoapps/content/learning_sequences/tasks.py b/openedx/core/djangoapps/content/learning_sequences/tasks.py index 1c456e8044..743441c5ac 100644 --- a/openedx/core/djangoapps/content/learning_sequences/tasks.py +++ b/openedx/core/djangoapps/content/learning_sequences/tasks.py @@ -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 diff --git a/openedx/core/djangoapps/content/learning_sequences/tests/test_views.py b/openedx/core/djangoapps/content/learning_sequences/tests/test_views.py index 56d8ae7f8e..098764ab27 100644 --- a/openedx/core/djangoapps/content/learning_sequences/tests/test_views.py +++ b/openedx/core/djangoapps/content/learning_sequences/tests/test_views.py @@ -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) diff --git a/openedx/core/djangoapps/content/learning_sequences/views.py b/openedx/core/djangoapps/content/learning_sequences/views.py index a1d9dfd522..dd9753be78 100644 --- a/openedx/core/djangoapps/content/learning_sequences/views.py +++ b/openedx/core/djangoapps/content/learning_sequences/views.py @@ -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).