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.
This commit is contained in:
michaelroytman
2023-05-16 16:55:02 -04:00
parent 36e18f291b
commit 344200862e
2 changed files with 50 additions and 13 deletions

View File

@@ -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,

View File

@@ -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()