From 344200862ee0f9184cfa8c9d4ffc299f7389852f Mon Sep 17 00:00:00 2001 From: michaelroytman Date: Tue, 16 May 2023 16:55:02 -0400 Subject: [PATCH] feat: send course end date as due date to exams service if exam has no due date An exam due date can be inferred from the end date of the course if the exam does not have a due date. In the legacy proctoring system (the edx-proctoring plugin), this inference is made in the proctoring code by calling the edx-when API. This is possible because edx-proctoring is a plugin that's installed into the edx-platform, into which edx-when is also installed. In the new exams service, we do not want to call to the LMS to get due date information from edx-when. This poses a number of problems, not all of which are solved by this commit. This commit allows the exams service to infer a due date for an exam if that exam does not have a due date at the subsection level. Note that this is a departure from edx-proctoring. This also opts out exams powered by the new edx-exams service from personalized learner schedules (PLS)/relative dates, because we no longer consider the pacing type of the course. --- cms/djangoapps/contentstore/exams.py | 10 +++- .../contentstore/tests/test_exams.py | 53 ++++++++++++++----- 2 files changed, 50 insertions(+), 13 deletions(-) diff --git a/cms/djangoapps/contentstore/exams.py b/cms/djangoapps/contentstore/exams.py index 314f1df1f2..1e9f1b75d3 100644 --- a/cms/djangoapps/contentstore/exams.py +++ b/cms/djangoapps/contentstore/exams.py @@ -70,12 +70,20 @@ def register_exams(course_key): timed_exam.is_practice_exam, timed_exam.is_onboarding_exam ) + exams_list.append({ 'course_id': str(course_key), 'content_id': str(timed_exam.location), 'exam_name': timed_exam.display_name, 'time_limit_mins': timed_exam.default_time_limit_minutes, - 'due_date': timed_exam.due.isoformat() if timed_exam.due and not course.self_paced else None, + # If the subsection has no due date, then infer a due date from the course end date. This behavior is a + # departure from the legacy register_exams function used by the edx-proctoring plugin because + # edx-proctoring makes a direct call to edx-when API when computing an exam's due date. + # By sending the course end date when registering exams, we can avoid calling to the platform from the + # exam service. Also note that we no longer consider the pacing type of the course - this applies to both + # self-paced and indstructor-paced courses. Therefore, this effectively opts out exams powered by edx-exams + # from personalized learner schedules/relative dates. + 'due_date': timed_exam.due.isoformat() if timed_exam.due else course.end.isoformat(), 'exam_type': exam_type, 'is_active': True, 'hide_after_due': timed_exam.hide_after_due, diff --git a/cms/djangoapps/contentstore/tests/test_exams.py b/cms/djangoapps/contentstore/tests/test_exams.py index 2a1a6e83a3..6b2f39687e 100644 --- a/cms/djangoapps/contentstore/tests/test_exams.py +++ b/cms/djangoapps/contentstore/tests/test_exams.py @@ -1,7 +1,7 @@ """ Test the exams service integration into Studio """ -from datetime import datetime, timedelta +from datetime import datetime, timedelta, timezone from unittest.mock import patch, Mock import ddt @@ -147,8 +147,15 @@ class TestExamService(ModuleStoreTestCase): listen_for_course_publish(self, self.course.id) mock_patch_course_exams.assert_not_called() - def test_self_paced_no_due_dates(self, mock_patch_course_exams): - self.course.self_paced = True + @ddt.data(True, False) + def test_no_due_dates(self, is_self_paced, mock_patch_course_exams): + """ + Test that the coures end date is registered as the due date when the subsection does not have a due date for + both self-paced and instructor-paced exams. + """ + self.course.self_paced = is_self_paced + end_date = datetime(2035, 1, 1, 0, 0, tzinfo=timezone.utc) + self.course.end = end_date self.course = self.update_course(self.course, 1) BlockFactory.create( parent=self.chapter, @@ -159,18 +166,40 @@ class TestExamService(ModuleStoreTestCase): default_time_limit_minutes=60, is_proctored_enabled=False, is_practice_exam=False, - due=datetime.now(UTC) + timedelta(minutes=60), + due=None, hide_after_due=True, is_onboarding_exam=False, ) - listen_for_course_publish(self, self.course.id) - called_exams, called_course = mock_patch_course_exams.call_args[0] - assert called_exams[0]['due_date'] is None - # now switch to instructor paced - # the exam will be updated with a due date - self.course.self_paced = False - self.course = self.update_course(self.course, 1) listen_for_course_publish(self, self.course.id) called_exams, called_course = mock_patch_course_exams.call_args[0] - assert called_exams[0]['due_date'] is not None + assert called_exams[0]['due_date'] == end_date.isoformat() + + @ddt.data(True, False) + def test_subsection_due_date_prioritized(self, is_self_paced, mock_patch_course_exams): + """ + Test that the subsection due date is registered as the due date when both the subsection has a due date and the + course has an end date for both self-paced and instructor-paced exams. + """ + self.course.self_paced = is_self_paced + self.course.end = datetime(2035, 1, 1, 0, 0) + self.course = self.update_course(self.course, 1) + + sequential_due_date = datetime.now(UTC) + timedelta(minutes=60) + BlockFactory.create( + parent=self.chapter, + category='sequential', + display_name='Test Proctored Exam', + graded=True, + is_time_limited=True, + default_time_limit_minutes=60, + is_proctored_enabled=False, + is_practice_exam=False, + due=sequential_due_date, + hide_after_due=True, + is_onboarding_exam=False, + ) + + listen_for_course_publish(self, self.course.id) + called_exams, called_course = mock_patch_course_exams.call_args[0] + assert called_exams[0]['due_date'] == sequential_due_date.isoformat()