diff --git a/openedx/core/djangoapps/content/learning_sequences/api/data.py b/openedx/core/djangoapps/content/learning_sequences/api/data.py index 0812c45e28f5a8ae22374bcda282fdc439f772b0..edb319b8aa03da8e2fef9ffe5f0150630e71c296 100644 --- a/openedx/core/djangoapps/content/learning_sequences/api/data.py +++ b/openedx/core/djangoapps/content/learning_sequences/api/data.py @@ -76,6 +76,8 @@ class CourseLearningSequenceData: title = attr.ib(type=str) visibility = attr.ib(type=VisibilityData) + inaccessible_after_due = attr.ib(type=bool, default=True) + @attr.s(frozen=True) class CourseSectionData: diff --git a/openedx/core/djangoapps/content/learning_sequences/api/outlines.py b/openedx/core/djangoapps/content/learning_sequences/api/outlines.py index ed64cec0948fa89494af365854ba88bfaec37bb5..70bc8c15d1b1db1e494e9f1788e3f0978bff4760 100644 --- a/openedx/core/djangoapps/content/learning_sequences/api/outlines.py +++ b/openedx/core/djangoapps/content/learning_sequences/api/outlines.py @@ -75,6 +75,7 @@ def get_course_outline(course_key: CourseKey) -> CourseOutlineData: sequence_data = CourseLearningSequenceData( usage_key=sequence_model.usage_key, title=sequence_model.title, + inaccessible_after_due=sec_seq_model.inaccessible_after_due, visibility=VisibilityData( hide_from_toc=sec_seq_model.hide_from_toc, visible_to_staff_only=sec_seq_model.visible_to_staff_only, @@ -315,6 +316,7 @@ def _update_course_section_sequences(course_outline: CourseOutlineData, learning sequence=sequence_models[sequence_data.usage_key], defaults={ 'ordering': ordering, + 'inaccessible_after_due': sequence_data.inaccessible_after_due, 'hide_from_toc': sequence_data.visibility.hide_from_toc, 'visible_to_staff_only': sequence_data.visibility.visible_to_staff_only, }, 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 6d86bc1f37cc189753405034336a1c9b496d19fc..1952640b0a0f39f1fead31815ac320b0b09def55 100644 --- a/openedx/core/djangoapps/content/learning_sequences/api/processors/schedule.py +++ b/openedx/core/djangoapps/content/learning_sequences/api/processors/schedule.py @@ -75,6 +75,13 @@ class ScheduleOutlineProcessor(OutlineProcessor): seq_start = self.keys_to_schedule_fields[seq.usage_key].get('start') if seq_start and self.at_time < seq_start: inaccessible.add(seq.usage_key) + continue + + 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) + continue return inaccessible 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 c1b86ad2c432c045ae851943e1859e164db7fe77..46f016e8ec96cf13154d40b662573e399b4f2898 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 @@ -21,7 +21,10 @@ class TestCourseOutlineData(TestCase): test as needed. """ super().setUpClass() - normal_visibility = VisibilityData(hide_from_toc=False, visible_to_staff_only=False) + normal_visibility = VisibilityData( + hide_from_toc=False, + visible_to_staff_only=False + ) cls.course_key = CourseKey.from_string("course-v1:OpenEdX+Learning+TestRun") cls.course_outline = CourseOutlineData( course_key=cls.course_key, @@ -110,7 +113,10 @@ def generate_sections(course_key, num_sequences): All sections and sequences have normal visibility. """ - normal_visibility = VisibilityData(hide_from_toc=False, visible_to_staff_only=False) + normal_visibility = VisibilityData( + hide_from_toc=False, + visible_to_staff_only=False + ) sections = [] for sec_num, seq_count in enumerate(num_sequences, 1): sections.append( 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 3270f4edfb591ab326a782226f45a96a412026d5..a720dbcc844a6a6e21ec944824b81c81124a5b7f 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 @@ -30,7 +30,8 @@ class CourseOutlineTestCase(CacheIsolationTestCase): def setUpTestData(cls): cls.course_key = CourseKey.from_string("course-v1:OpenEdX+Learn+Roundtrip") normal_visibility = VisibilityData( - hide_from_toc=False, visible_to_staff_only=False + hide_from_toc=False, + visible_to_staff_only=False ) cls.course_outline = CourseOutlineData( course_key=cls.course_key, @@ -122,7 +123,8 @@ class UserCourseOutlineTestCase(CacheIsolationTestCase): # Seed with data cls.course_key = CourseKey.from_string("course-v1:OpenEdX+Outline+T1") normal_visibility = VisibilityData( - hide_from_toc=False, visible_to_staff_only=False + hide_from_toc=False, + visible_to_staff_only=False ) cls.simple_outline = CourseOutlineData( course_key=cls.course_key, @@ -185,6 +187,15 @@ class ScheduleTestCase(CacheIsolationTestCase): cls.seq_same_key = cls.course_key.make_usage_key('sequential', 'seq_same') cls.seq_after_key = cls.course_key.make_usage_key('sequential', 'seq_after') cls.seq_inherit_key = cls.course_key.make_usage_key('sequential', 'seq_inherit') + cls.seq_due_key = cls.course_key.make_usage_key('sequential', 'seq_due') + + cls.all_seq_keys = [ + cls.seq_before_key, + cls.seq_same_key, + cls.seq_after_key, + cls.seq_inherit_key, + cls.seq_due_key, + ] # Set scheduling information into edx-when for a single Section with # sequences starting at various times. @@ -219,9 +230,20 @@ class ScheduleTestCase(CacheIsolationTestCase): cls.seq_inherit_key, {'start': None} ), + # Sequence should inherit start information from Section, but has a due date set. + ( + cls.seq_due_key, + { + 'start': None, + 'due': datetime(2020, 5, 20, tzinfo=timezone.utc) + } + ), ] ) - visibility = VisibilityData(hide_from_toc=False, visible_to_staff_only=False) + visibility = VisibilityData( + hide_from_toc=False, + visible_to_staff_only=False + ) cls.outline = CourseOutlineData( course_key=cls.course_key, title="User Outline Test Course!", @@ -234,16 +256,29 @@ class ScheduleTestCase(CacheIsolationTestCase): visibility=visibility, sequences=[ CourseLearningSequenceData( - usage_key=cls.seq_before_key, title='Before', visibility=visibility + usage_key=cls.seq_before_key, + title='Before', + visibility=visibility + ), + CourseLearningSequenceData( + usage_key=cls.seq_same_key, + title='Same', visibility=visibility ), CourseLearningSequenceData( - usage_key=cls.seq_same_key, title='Same', visibility=visibility + usage_key=cls.seq_after_key, + title='After', + visibility=visibility ), CourseLearningSequenceData( - usage_key=cls.seq_after_key, title='After', visibility=visibility + usage_key=cls.seq_inherit_key, + title='Inherit', + visibility=visibility ), CourseLearningSequenceData( - usage_key=cls.seq_inherit_key, title='Inherit', visibility=visibility + usage_key=cls.seq_due_key, + title='Due', + visibility=visibility, + inaccessible_after_due=True ), ] ) @@ -256,25 +291,32 @@ class ScheduleTestCase(CacheIsolationTestCase): student_details = get_user_course_outline_details(self.course_key, self.student, at_time) return staff_details, student_details + def get_sequence_keys(self, exclude=None): + if exclude is None: + exclude = [] + if not isinstance(exclude, list): + raise TypeError("`exclude` must be a list of keys to be excluded") + return [key for key in self.all_seq_keys if key not in exclude] + def test_before_course_starts(self): staff_details, student_details = self.get_details( datetime(2020, 5, 9, tzinfo=timezone.utc) ) # Staff can always access all sequences - assert len(staff_details.outline.accessible_sequences) == 4 + assert len(staff_details.outline.accessible_sequences) == 5 # Student can access nothing assert len(student_details.outline.accessible_sequences) == 0 # Everyone can see everything - assert len(staff_details.outline.sequences) == 4 - assert len(student_details.outline.sequences) == 4 + assert len(staff_details.outline.sequences) == 5 + assert len(student_details.outline.sequences) == 5 def test_before_section_starts(self): staff_details, student_details = self.get_details( datetime(2020, 5, 14, tzinfo=timezone.utc) ) # Staff can always access all sequences - assert len(staff_details.outline.accessible_sequences) == 4 + assert len(staff_details.outline.accessible_sequences) == 5 # Student can access nothing -- even though one of the sequences is set # to start on 2020-05-14, it's not available because the section hasn't @@ -289,14 +331,44 @@ class ScheduleTestCase(CacheIsolationTestCase): datetime(2020, 5, 15, tzinfo=timezone.utc) ) # Staff can always access all sequences - assert len(staff_details.outline.accessible_sequences) == 4 + assert len(staff_details.outline.accessible_sequences) == 5 # Student can access all sequences except the one that starts after this # datetime (self.seq_after_key) - assert len(student_details.outline.accessible_sequences) == 3 - assert self.seq_before_key in student_details.outline.accessible_sequences - assert self.seq_same_key in student_details.outline.accessible_sequences - assert self.seq_inherit_key in student_details.outline.accessible_sequences + assert len(student_details.outline.accessible_sequences) == 4 + assert self.seq_after_key not in student_details.outline.accessible_sequences + for key in self.get_sequence_keys(exclude=[self.seq_after_key]): + assert key in student_details.outline.accessible_sequences + + def test_is_due_and_before_due(self): + staff_details, student_details = self.get_details( + datetime(2020, 5, 16, tzinfo=timezone.utc) + ) + # Staff can always access all sequences + assert len(staff_details.outline.accessible_sequences) == 5 + + # Student can access all sequences including the one that is due in + # the future (self.seq_due_key) + assert len(student_details.outline.accessible_sequences) == 5 + assert self.seq_due_key in student_details.outline.accessible_sequences + + seq_due_sched_item_data = student_details.schedule.sequences[self.seq_due_key] + assert seq_due_sched_item_data.due == datetime(2020, 5, 20, tzinfo=timezone.utc) + + def test_is_due_and_after_due(self): + staff_details, student_details = self.get_details( + datetime(2020, 5, 21, tzinfo=timezone.utc) + ) + # Staff can always access all sequences + assert len(staff_details.outline.accessible_sequences) == 5 + + # Student can access all sequences except the one that is due before this + # datetime (self.seq_due_key) + assert len(student_details.outline.accessible_sequences) == 4 + assert self.seq_due_key not in student_details.outline.accessible_sequences + assert self.seq_due_key in student_details.outline.sequences + for key in self.get_sequence_keys(exclude=[self.seq_due_key]): + assert key in student_details.outline.accessible_sequences class VisbilityTestCase(CacheIsolationTestCase): @@ -322,12 +394,22 @@ class VisbilityTestCase(CacheIsolationTestCase): cls.staff_in_normal_key = cls.course_key.make_usage_key('sequential', 'staff_in_normal') cls.hide_in_normal_key = cls.course_key.make_usage_key('sequential', 'hide_in_normal') + cls.due_in_normal_key = cls.course_key.make_usage_key('sequential', 'due_in_normal') cls.normal_in_normal_key = cls.course_key.make_usage_key('sequential', 'normal_in_normal') cls.normal_in_staff_key = cls.course_key.make_usage_key('sequential', 'normal_in_staff') - v_normal = VisibilityData(hide_from_toc=False, visible_to_staff_only=False) - v_hide_from_toc = VisibilityData(hide_from_toc=True, visible_to_staff_only=False) - v_staff_only = VisibilityData(hide_from_toc=False, visible_to_staff_only=True) + v_normal = VisibilityData( + hide_from_toc=False, + visible_to_staff_only=False + ) + v_hide_from_toc = VisibilityData( + hide_from_toc=True, + visible_to_staff_only=False + ) + v_staff_only = VisibilityData( + hide_from_toc=False, + visible_to_staff_only=True + ) cls.outline = CourseOutlineData( course_key=cls.course_key, diff --git a/openedx/core/djangoapps/content/learning_sequences/migrations/0002_coursesectionsequence_inaccessible_after_due.py b/openedx/core/djangoapps/content/learning_sequences/migrations/0002_coursesectionsequence_inaccessible_after_due.py new file mode 100644 index 0000000000000000000000000000000000000000..ae28844adeaecdb1654a6e801d04f9227ef2c019 --- /dev/null +++ b/openedx/core/djangoapps/content/learning_sequences/migrations/0002_coursesectionsequence_inaccessible_after_due.py @@ -0,0 +1,18 @@ +# Generated by Django 2.2.14 on 2020-07-09 06:01 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('learning_sequences', '0001_initial'), + ] + + operations = [ + migrations.AddField( + model_name='coursesectionsequence', + name='inaccessible_after_due', + 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 5d2568f30715438b16b34e352407d1203265170c..02149d4656c810000aa8ae37b2e6b8d553cea0ae 100644 --- a/openedx/core/djangoapps/content/learning_sequences/models.py +++ b/openedx/core/djangoapps/content/learning_sequences/models.py @@ -168,6 +168,9 @@ class CourseSectionSequence(CourseContentVisibilityMixin, TimeStampedModel): section = models.ForeignKey(CourseSection, on_delete=models.CASCADE) sequence = models.ForeignKey(LearningSequence, on_delete=models.CASCADE) + # Make the sequence inaccessible from the outline after the due date has passed + inaccessible_after_due = models.BooleanField(null=False, default=False) + # Ordering, starts with 0, but global for the course. So if you had 200 # sequences across 20 sections, the numbering here would be 0-199. ordering = models.PositiveIntegerField(null=False) diff --git a/openedx/core/djangoapps/content/learning_sequences/tasks.py b/openedx/core/djangoapps/content/learning_sequences/tasks.py index d9e0f00a9e122ba97f7a2bcbd1415185be93a853..91d973384649967e2a71d366101d225998b67e5d 100644 --- a/openedx/core/djangoapps/content/learning_sequences/tasks.py +++ b/openedx/core/djangoapps/content/learning_sequences/tasks.py @@ -29,6 +29,7 @@ def update_from_modulestore(course_key): CourseLearningSequenceData( usage_key=sequence.location, title=sequence.display_name, + inaccessible_after_due=sequence.hide_after_due, visibility=VisibilityData( hide_from_toc=sequence.hide_from_toc, visible_to_staff_only=sequence.visible_to_staff_only diff --git a/openedx/core/djangoapps/content/learning_sequences/views.py b/openedx/core/djangoapps/content/learning_sequences/views.py index 41d87fab234108235b5dba0b6ba5ead4c9890340..223f0e8524a4dce9dd09e3162d27a84ec9069f0a 100644 --- a/openedx/core/djangoapps/content/learning_sequences/views.py +++ b/openedx/core/djangoapps/content/learning_sequences/views.py @@ -112,6 +112,7 @@ class CourseOutlineView(APIView): "id": str(sequence.usage_key), "title": sequence.title, "accessible": sequence.usage_key in accessible_sequences, + "inaccessible_after_due": sequence.inaccessible_after_due, **schedule_item_dict, }