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).
155 lines
5.4 KiB
Python
155 lines
5.4 KiB
Python
"""
|
|
Code related to working with the exam service
|
|
"""
|
|
|
|
import logging
|
|
|
|
import requests
|
|
from django.conf import settings
|
|
from django.contrib.auth import get_user_model
|
|
from edx_rest_api_client.auth import SuppliedJwtAuth
|
|
|
|
from openedx.core.djangoapps.course_apps.toggles import exams_ida_enabled
|
|
from openedx.core.djangoapps.oauth_dispatch.jwt import create_jwt_for_user
|
|
from xmodule.modulestore.django import modulestore
|
|
from xmodule.modulestore.exceptions import ItemNotFoundError
|
|
|
|
from .helpers import is_item_in_course_tree
|
|
|
|
log = logging.getLogger(__name__)
|
|
User = get_user_model()
|
|
|
|
|
|
def register_exams(course_key):
|
|
"""
|
|
This is typically called on a course published signal. The course is examined for sequences
|
|
that are marked as timed exams. Then these are registered with the exams service.
|
|
Likewise, if formerly registered exams are not included in the payload they will
|
|
be marked inactive by the exam service.
|
|
"""
|
|
if not settings.FEATURES.get('ENABLE_SPECIAL_EXAMS') or not exams_ida_enabled(course_key):
|
|
# if feature is not enabled then do a quick exit
|
|
return
|
|
|
|
course = modulestore().get_course(course_key)
|
|
if course is None:
|
|
raise ItemNotFoundError("Course {} does not exist", str(course_key)) # lint-amnesty, pylint: disable=raising-format-tuple
|
|
|
|
# get all sequences, since they can be marked as timed/proctored exams
|
|
_timed_exams = modulestore().get_items(
|
|
course_key,
|
|
qualifiers={
|
|
'category': 'sequential',
|
|
},
|
|
settings={
|
|
'is_time_limited': True,
|
|
}
|
|
)
|
|
|
|
# filter out any potential dangling sequences
|
|
timed_exams = [
|
|
timed_exam
|
|
for timed_exam in _timed_exams
|
|
if is_item_in_course_tree(timed_exam)
|
|
]
|
|
|
|
exams_list = []
|
|
locations = []
|
|
for timed_exam in timed_exams:
|
|
location = str(timed_exam.location)
|
|
msg = (
|
|
'Found {location} as an exam in course structure.'.format(
|
|
location=location
|
|
)
|
|
)
|
|
log.info(msg)
|
|
locations.append(location)
|
|
|
|
exam_type = get_exam_type(
|
|
timed_exam.is_proctored_exam,
|
|
timed_exam.is_practice_exam,
|
|
timed_exam.is_onboarding_exam
|
|
)
|
|
|
|
# Exams in courses not using an LTI based proctoring provider should use the original definition of due_date
|
|
# from contentstore/proctoring.py. These exams are powered by the edx-proctoring plugin and not the edx-exams
|
|
# microservice.
|
|
if course.proctoring_provider == 'lti_external':
|
|
due_date = (
|
|
timed_exam.due.isoformat() if timed_exam.due
|
|
else (course.end.isoformat() if course.end else None)
|
|
)
|
|
else:
|
|
due_date = timed_exam.due if not course.self_paced else None
|
|
|
|
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,
|
|
# 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': due_date,
|
|
'exam_type': exam_type,
|
|
'is_active': True,
|
|
'hide_after_due': timed_exam.hide_after_due,
|
|
# backend is only required for continued edx-proctoring support
|
|
'backend': course.proctoring_provider,
|
|
})
|
|
|
|
try:
|
|
_patch_course_exams(exams_list, str(course_key))
|
|
log.info(f'Successfully registered {locations} with exam service')
|
|
# pylint: disable=broad-except
|
|
except Exception as ex:
|
|
log.exception('Failed to register exams with exam API', exc_info=True)
|
|
raise ex
|
|
|
|
|
|
def get_exam_type(is_proctored, is_practice, is_onboarding):
|
|
"""
|
|
Get the exam type string based on the proctored, practice and onboarding
|
|
attributes.
|
|
"""
|
|
if is_proctored:
|
|
if is_onboarding:
|
|
exam_type = 'onboarding'
|
|
elif is_practice:
|
|
exam_type = 'practice'
|
|
else:
|
|
exam_type = 'proctored'
|
|
else:
|
|
exam_type = 'timed'
|
|
|
|
return exam_type
|
|
|
|
|
|
def _get_exams_api_client():
|
|
"""
|
|
Returns an API client which can be used to make Exams API requests.
|
|
"""
|
|
user = User.objects.get(username=settings.EXAMS_SERVICE_USERNAME)
|
|
jwt = create_jwt_for_user(user)
|
|
client = requests.Session()
|
|
client.auth = SuppliedJwtAuth(jwt)
|
|
|
|
return client
|
|
|
|
|
|
def _patch_course_exams(exams_list, course_id):
|
|
"""
|
|
Make a PATCH request to update course exams
|
|
"""
|
|
url = f'{settings.EXAMS_SERVICE_URL}/exams/course_id/{course_id}/'
|
|
api_client = _get_exams_api_client()
|
|
|
|
response = api_client.patch(url, json=exams_list)
|
|
response.raise_for_status()
|
|
response = response.json()
|
|
return response
|