This commit fixes an inconsistency in the way an exam due date is computed for courses that do not use an LTI based proctoring provider. The edx-exams microservice was released last year to provide support for LTI based proctoring providers. After the release of this microservice, all proctoring requests initiated by the platform began to be funneled through the microservice, which acted as a broker for these requests, routing them directly to the service for exams in courses using an LTI based proctoring provider or to the platform edx-proctoring plugin for all other cases. There is an asynchronous task in the platform that syncs exams from the platform to either the edx-exams microservice or the edx-proctoring plugin. Prior to the release of the microservice, this task computed the due date on exams as the exam subsection due date if the course was instructor-paced or None. After the release of the microservice, the task computed due dates differently than before. The due date on exams was computed as the due date on the exam, if there was one, or the end date of the course, if there was one, or None. This differed from the prior definition. This resulted in inconsistent due date behavior. The exams in courses that were published or republished after the edx-exams microservice was released had the new computation of due date, while exams in courses that were published or republished before the edx-exams microservice was released had the old computation of due date. This causes an issue for all exams in courses using non-LTI based providers. This is because the due date on exams across all courses that either do not use proctoring or that use a non-LTI based provider are inconsistent, depending on when they were last published. This commit reintroduces the old computation to the task for exams in courses using a non-LTI based proctoring provider (i.e. those courses whose exams are not powered by the edx-exams microservice). In order to maintain the functionality of edx-exams, we continue to compute the due date as before for exams in courses using an LTI based proctoring provider (i.e. those courses whose exams are powered by the edx-exams microservice).
268 lines
11 KiB
Python
268 lines
11 KiB
Python
"""
|
|
Test the exams service integration into Studio
|
|
"""
|
|
import itertools
|
|
from datetime import datetime, timedelta, timezone
|
|
from unittest.mock import patch, Mock
|
|
|
|
import ddt
|
|
from django.conf import settings
|
|
from edx_toggles.toggles.testutils import override_waffle_flag
|
|
from freezegun import freeze_time
|
|
from pytz import utc
|
|
|
|
from cms.djangoapps.contentstore.signals.handlers import listen_for_course_publish
|
|
from openedx.core.djangoapps.course_apps.toggles import EXAMS_IDA
|
|
from xmodule.modulestore.tests.django_utils import TEST_DATA_SPLIT_MODULESTORE, ModuleStoreTestCase
|
|
from xmodule.modulestore.tests.factories import CourseFactory, BlockFactory
|
|
|
|
|
|
@ddt.ddt
|
|
@override_waffle_flag(EXAMS_IDA, active=True)
|
|
@patch.dict('django.conf.settings.FEATURES', {'ENABLE_PROCTORED_EXAMS': True})
|
|
@patch.dict('django.conf.settings.FEATURES', {'ENABLE_SPECIAL_EXAMS': True})
|
|
@patch('cms.djangoapps.contentstore.exams._patch_course_exams')
|
|
@patch('cms.djangoapps.contentstore.signals.handlers.transaction.on_commit',
|
|
new=Mock(side_effect=lambda func: func()),) # run right away
|
|
class TestExamService(ModuleStoreTestCase):
|
|
"""
|
|
Test for syncing exams to the exam service
|
|
"""
|
|
MODULESTORE = TEST_DATA_SPLIT_MODULESTORE
|
|
|
|
def setUp(self):
|
|
"""
|
|
Initial data setup
|
|
"""
|
|
super().setUp()
|
|
|
|
self.course = CourseFactory.create(
|
|
org='edX',
|
|
course='900',
|
|
run='test_run',
|
|
enable_proctored_exams=True,
|
|
proctoring_provider='null',
|
|
)
|
|
self.chapter = BlockFactory.create(parent=self.course, category='chapter', display_name='Test Section')
|
|
self.course_key = str(self.course.id)
|
|
|
|
# create one non-exam sequence
|
|
chapter2 = BlockFactory.create(parent=self.course, category='chapter', display_name='Test Homework')
|
|
BlockFactory.create(
|
|
parent=chapter2,
|
|
category='sequential',
|
|
display_name='Homework 1',
|
|
graded=True,
|
|
is_time_limited=False,
|
|
due=datetime.now(utc) + timedelta(minutes=60),
|
|
)
|
|
|
|
def _get_exams_url(self, course_id):
|
|
return f'{settings.EXAMS_SERVICE_URL}/exams/course_id/{course_id}/'
|
|
|
|
def _get_exam_due_date(self, course, sequential):
|
|
"""
|
|
Return the expected exam due date for the exam, based on the selected course proctoring provider and the
|
|
exam due date or the course end date.
|
|
|
|
Arguments:
|
|
* course: the course that the exam subsection is in; may have a course.end attribute
|
|
* sequential: the exam subsection; may have a sequential.due attribute
|
|
"""
|
|
if course.proctoring_provider == 'lti_external':
|
|
return sequential.due.isoformat() if sequential.due else (course.end.isoformat() if course.end else None)
|
|
elif course.self_paced:
|
|
return None
|
|
else:
|
|
return sequential.due
|
|
|
|
@ddt.data(*(tuple(base) + (extra,) for base, extra in itertools.product(
|
|
[
|
|
(False, False, False, 'timed'),
|
|
(True, False, False, 'proctored'),
|
|
(True, True, False, 'practice'),
|
|
(True, True, True, 'onboarding'),
|
|
],
|
|
('null', 'lti_external')
|
|
)))
|
|
@ddt.unpack
|
|
@freeze_time('2024-01-01')
|
|
def test_publishing_exam(self, is_proctored_exam, is_practice_exam,
|
|
is_onboarding_exam, expected_type, proctoring_provider, mock_patch_course_exams):
|
|
"""
|
|
When a course is published it will register all exams sections with the exams service
|
|
"""
|
|
default_time_limit_minutes = 10
|
|
due_date = datetime.now(utc) + timedelta(minutes=default_time_limit_minutes + 1)
|
|
sequence = BlockFactory.create(
|
|
parent=self.chapter,
|
|
category='sequential',
|
|
display_name='Test Proctored Exam',
|
|
graded=True,
|
|
is_time_limited=True,
|
|
default_time_limit_minutes=default_time_limit_minutes,
|
|
is_proctored_enabled=is_proctored_exam,
|
|
is_practice_exam=is_practice_exam,
|
|
due=due_date,
|
|
hide_after_due=True,
|
|
is_onboarding_exam=is_onboarding_exam,
|
|
)
|
|
|
|
self.course.proctoring_provider = proctoring_provider
|
|
self.course = self.update_course(self.course, 1)
|
|
|
|
expected_due_date = self._get_exam_due_date(self.course, sequence)
|
|
|
|
expected_exams = [{
|
|
'course_id': self.course_key,
|
|
'content_id': str(sequence.location),
|
|
'exam_name': sequence.display_name,
|
|
'time_limit_mins': sequence.default_time_limit_minutes,
|
|
'due_date': expected_due_date,
|
|
'exam_type': expected_type,
|
|
'is_active': True,
|
|
'hide_after_due': True,
|
|
# backend is only required for edx-proctoring support edx-exams will maintain LTI backends
|
|
'backend': proctoring_provider,
|
|
}]
|
|
listen_for_course_publish(self, self.course.id)
|
|
mock_patch_course_exams.assert_called_once_with(expected_exams, self.course_key)
|
|
|
|
def test_publish_no_exam(self, mock_patch_course_exams):
|
|
"""
|
|
Exam service is called with an empty list if there are no exam sections.
|
|
This will deactivate any currently active exams
|
|
"""
|
|
listen_for_course_publish(self, self.course.id)
|
|
mock_patch_course_exams.assert_called_once_with([], self.course_key)
|
|
|
|
def test_dangling_exam(self, mock_patch_course_exams):
|
|
"""
|
|
Make sure we filter out all dangling items
|
|
"""
|
|
BlockFactory.create(
|
|
parent=self.chapter,
|
|
category='sequential',
|
|
display_name='Test Proctored Exam',
|
|
graded=True,
|
|
is_time_limited=True,
|
|
default_time_limit_minutes=10,
|
|
is_proctored_enabled=True,
|
|
hide_after_due=False,
|
|
)
|
|
self.store.delete_item(self.chapter.location, self.user.id)
|
|
|
|
listen_for_course_publish(self, self.course.id)
|
|
mock_patch_course_exams.assert_called_once_with([], self.course_key)
|
|
|
|
@patch.dict('django.conf.settings.FEATURES', {'ENABLE_SPECIAL_EXAMS': False})
|
|
def test_feature_flag_off(self, mock_patch_course_exams):
|
|
"""
|
|
Make sure the feature flag is honored
|
|
"""
|
|
BlockFactory.create(
|
|
parent=self.chapter,
|
|
category='sequential',
|
|
display_name='Test Proctored Exam',
|
|
graded=True,
|
|
is_time_limited=True,
|
|
default_time_limit_minutes=10,
|
|
is_proctored_enabled=True,
|
|
hide_after_due=False,
|
|
)
|
|
|
|
listen_for_course_publish(self, self.course.id)
|
|
mock_patch_course_exams.assert_not_called()
|
|
|
|
@ddt.data(
|
|
*itertools.product(
|
|
(True, False),
|
|
(datetime(2035, 1, 1, 0, 0, tzinfo=timezone.utc), None),
|
|
('null', 'lti_external'),
|
|
)
|
|
)
|
|
@ddt.unpack
|
|
def test_no_due_dates(self, is_self_paced, course_end_date, proctoring_provider, mock_patch_course_exams):
|
|
"""
|
|
Test that the the correct due date is registered for the exam when the subsection does not have a due date,
|
|
depending on the proctoring provider.
|
|
|
|
* lti_external
|
|
* The course 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.
|
|
* not lti_external
|
|
* None 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
|
|
self.course.end = course_end_date
|
|
self.course.proctoring_provider = proctoring_provider
|
|
self.course = self.update_course(self.course, 1)
|
|
sequence = 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=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]
|
|
|
|
expected_due_date = self._get_exam_due_date(self.course, sequence)
|
|
|
|
assert called_exams[0]['due_date'] == expected_due_date
|
|
|
|
@ddt.data(*itertools.product((True, False), ('lti_external', 'null')))
|
|
@ddt.unpack
|
|
@freeze_time('2024-01-01')
|
|
def test_subsection_due_date_prioritized(self, is_self_paced, proctoring_provider, 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.
|
|
|
|
Test that the the correct due date is registered for the exam when the subsection has a due date, depending on
|
|
the proctoring provider.
|
|
|
|
* lti_external
|
|
* 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
|
|
* not lti_external
|
|
* None is registered as the due date when both the subsection has a due date and the course has an end date
|
|
for self-paced exams.
|
|
* 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 instructor-paced exams.
|
|
"""
|
|
self.course.self_paced = is_self_paced
|
|
self.course.end = datetime(2035, 1, 1, 0, 0)
|
|
self.course.proctoring_provider = proctoring_provider
|
|
self.course = self.update_course(self.course, 1)
|
|
|
|
sequential_due_date = datetime.now(utc) + timedelta(minutes=60)
|
|
sequence = 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]
|
|
|
|
expected_due_date = self._get_exam_due_date(self.course, sequence)
|
|
|
|
assert called_exams[0]['due_date'] == expected_due_date
|