From 752b88e11bc613063d2647292fb2747eec085785 Mon Sep 17 00:00:00 2001 From: Bianca Severino Date: Tue, 13 Jul 2021 10:18:39 -0400 Subject: [PATCH] feat: use verified name in exam attempt creation If verified name functionality is enabled through both a runtime service and a course waffle flag, it will be used in proctored exam attempt creation. --- CHANGELOG.rst | 8 + edx_proctoring/__init__.py | 2 +- edx_proctoring/api.py | 106 +++++++++++-- .../proctoring/js/exam_action_handler.js | 12 +- .../templates/practice_exam/entrance.html | 8 +- .../templates/proctored_exam/footer.html | 7 +- edx_proctoring/tests/test_api.py | 141 +++++++++++++++++- edx_proctoring/tests/test_services.py | 48 ++++++ edx_proctoring/views.py | 6 +- package.json | 2 +- requirements/base.in | 1 - 11 files changed, 321 insertions(+), 20 deletions(-) diff --git a/CHANGELOG.rst b/CHANGELOG.rst index ded386e566b..fd16f6863d6 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -14,6 +14,14 @@ Change Log Unreleased ~~~~~~~~~~ +[3.22.0] - 2021-07-26 +~~~~~~~~~~~~~~~~~~~~~ +* If verified name functionality is enabled through the "name_affirmation" runtime service, + use it in proctored exam attempt creation. (see https://github.com/edx/edx-name-affirmation) +* When updating a proctored exam attempt to "verified" status, update the user's verified + name status, if verified name functionality is enabled and they have one linked to that + exam attempt. + [3.21.1] - 2021-07-26 ~~~~~~~~~~~~~~~~~~~~~ * Removed name field in proctored exam attempt from the DB. diff --git a/edx_proctoring/__init__.py b/edx_proctoring/__init__.py index f4fdbcf7602..cb14a356941 100644 --- a/edx_proctoring/__init__.py +++ b/edx_proctoring/__init__.py @@ -3,6 +3,6 @@ """ # Be sure to update the version number in edx_proctoring/package.json -__version__ = '3.21.1' +__version__ = '3.22.0' default_app_config = 'edx_proctoring.apps.EdxProctoringConfig' # pylint: disable=invalid-name diff --git a/edx_proctoring/api.py b/edx_proctoring/api.py index 0f691e423f8..98fb9668efc 100644 --- a/edx_proctoring/api.py +++ b/edx_proctoring/api.py @@ -891,7 +891,9 @@ def _create_and_decline_attempt(exam_id, user_id): ) -def _register_proctored_exam_attempt(user_id, exam_id, exam, attempt_code, review_policy): +def _register_proctored_exam_attempt( + user_id, exam_id, exam, attempt_code, review_policy, verified_name=None, +): """ Call the proctoring backend to register the exam attempt. If there are exceptions the external_id returned might be None. If the backend have onboarding status errors, @@ -905,16 +907,19 @@ def _register_proctored_exam_attempt(user_id, exam_id, exam, attempt_code, revie review_policy_exception = ProctoredExamStudentAllowance.get_review_policy_exception(exam_id, user_id) # get the name of the user, if the service is available - full_name = '' email = None external_id = None force_status = None + full_name = verified_name or '' + profile_name = '' credit_service = get_runtime_service('credit') if credit_service: credit_state = credit_service.get_credit_state(user_id, exam['course_id']) if credit_state: - full_name = credit_state['profile_fullname'] + profile_name = credit_state['profile_fullname'] + if not verified_name: + full_name = profile_name email = credit_state['student_email'] context = { @@ -993,10 +998,62 @@ def _register_proctored_exam_attempt(user_id, exam_id, exam, attempt_code, revie ) log.error(log_msg) - return external_id, force_status + return external_id, force_status, full_name, profile_name + + +def _get_verified_name(user_id, name_affirmation_service): + """ + Get the user's verified name if it exists. + + Returns a verified name object (or None), as well as a boolean describing whether a + new verified name should be created along with the proctored exam attempt. + """ + verified_name = None + should_create_verified_name = False + + user = USER_MODEL.objects.get(id=user_id) + verified_name_obj = name_affirmation_service.get_verified_name(user) + + if not verified_name_obj or not verified_name_obj.is_verified: + # If the user's verified name is still pending ID verification, we still want to + # use it. However, there is no guarantee that it will be verified later, so create + # a new entry to be verified by this exam attempt. + should_create_verified_name = True + + if verified_name_obj: + verified_name = verified_name_obj.verified_name + return verified_name, should_create_verified_name -def create_exam_attempt(exam_id, user_id, taking_as_proctored=False): + +def _create_verified_name(user_id, full_name, profile_name, attempt_id, name_affirmation_service): + """ + Create a verified name with the proctored exam attempt ID. + """ + if not full_name or not profile_name: + # Log a warning instead of raising an exception if verified name creation fails + # due an empty string. + log_msg = ( + 'Attempted to create a verified name for user_id={user_id} linked to ' + 'proctored_exam_attempt_id={proctored_exam_attempt_id}, but an empty ' + 'string was supplied for the name. Skipping verified name creation.'.format( + user_id=user_id, proctored_exam_attempt_id=attempt_id, + ) + ) + log.warning(log_msg) + else: + user = USER_MODEL.objects.get(id=user_id) + name_affirmation_service.create_verified_name( + user, + verified_name=full_name, + profile_name=profile_name, + proctored_exam_attempt_id=attempt_id, + ) + + +def create_exam_attempt( + exam_id, user_id, taking_as_proctored=False, is_verified_name_enabled=False, +): """ Creates an exam attempt for user_id against exam_id. There should only be one exam_attempt per user per exam, with one exception described below. @@ -1052,9 +1109,15 @@ def create_exam_attempt(exam_id, user_id, taking_as_proctored=False): ) raise StudentExamAttemptOnPastDueProctoredExam(err_msg) + name_affirmation_service = get_runtime_service('name_affirmation') + should_create_verified_name = False + if taking_as_proctored: - external_id, force_status = _register_proctored_exam_attempt( - user_id, exam_id, exam, attempt_code, review_policy + verified_name = None + if name_affirmation_service and is_verified_name_enabled: + verified_name, should_create_verified_name = _get_verified_name(user_id, name_affirmation_service) + external_id, force_status, full_name, profile_name = _register_proctored_exam_attempt( + user_id, exam_id, exam, attempt_code, review_policy, verified_name, ) attempt = ProctoredExamStudentAttempt.create_exam_attempt( @@ -1069,6 +1132,10 @@ def create_exam_attempt(exam_id, user_id, taking_as_proctored=False): time_remaining_seconds=time_remaining_seconds, ) + # Only create a verified name after the attempt is created, as we need the attempt ID + if should_create_verified_name: + _create_verified_name(user_id, full_name, profile_name, attempt.id, name_affirmation_service) + # Emit event when exam attempt created emit_event(exam, attempt.status, attempt=_get_exam_attempt(attempt)) @@ -1583,6 +1650,22 @@ def update_attempt_status(attempt_id, to_status, # we use the 'status' field as the name of the event 'verb' emit_event(exam, attempt['status'], attempt=attempt) + name_affirmation_service = get_runtime_service('name_affirmation') + if ( + name_affirmation_service + and to_status == ProctoredExamStudentAttemptStatus.verified + and exam['is_proctored'] + ): + # If the user has a verified name entry linked to this exam attempt, approve it + user = USER_MODEL.objects.get(id=user_id) + try: + name_affirmation_service.update_is_verified_status( + user, True, proctored_exam_attempt_id=attempt_id + ) + except Exception: # pylint: disable=broad-except + # An exception will be raised if no verified name exists, so just pass in this case + pass + return attempt['id'] @@ -1722,7 +1805,7 @@ def _get_proctoring_escalation_email(course_id): return proctoring_escalation_email -def reset_practice_exam(exam_id, user_id, requesting_user): +def reset_practice_exam(exam_id, user_id, requesting_user, is_verified_name_enabled=False): """ Resets a completed practice exam attempt back to the created state. """ @@ -1775,7 +1858,12 @@ def reset_practice_exam(exam_id, user_id, requesting_user): emit_event(exam, 'reset_practice_exam', attempt=_get_exam_attempt(exam_attempt_obj)) - return create_exam_attempt(exam_id, user_id, taking_as_proctored=exam_attempt_obj.taking_as_proctored) + return create_exam_attempt( + exam_id, + user_id, + taking_as_proctored=exam_attempt_obj.taking_as_proctored, + is_verified_name_enabled=is_verified_name_enabled, + ) def remove_exam_attempt(attempt_id, requesting_user): diff --git a/edx_proctoring/static/proctoring/js/exam_action_handler.js b/edx_proctoring/static/proctoring/js/exam_action_handler.js index b2f5d88b8eb..cddd795bded 100644 --- a/edx_proctoring/static/proctoring/js/exam_action_handler.js +++ b/edx_proctoring/static/proctoring/js/exam_action_handler.js @@ -83,13 +83,14 @@ edx = edx || {}; } // Update the state of the attempt - function updateExamAttemptStatusPromise(actionUrl, action) { + function updateExamAttemptStatusPromise(actionUrl, action, isVerifiedNameEnabled) { return function() { return Promise.resolve($.ajax({ url: actionUrl, type: 'PUT', data: { - action: action + action: action, + is_verified_name_enabled: isVerifiedNameEnabled } })); }; @@ -123,7 +124,12 @@ edx = edx || {}; var $this = $(this); var actionUrl = $this.data('change-state-url'); var action = $this.data('action'); - updateExamAttemptStatusPromise(actionUrl, action)() + + // Course waffle flag for verified name + var main = document.getElementById('main'); + var isVerifiedNameEnabled = main.dataset.isVerifiedNameEnabled.toLowerCase() === 'true'; + + updateExamAttemptStatusPromise(actionUrl, action, isVerifiedNameEnabled)() .then(reloadPage) .catch(errorHandlerGivenMessage( $this, diff --git a/edx_proctoring/templates/practice_exam/entrance.html b/edx_proctoring/templates/practice_exam/entrance.html index 2e0c910df5a..f3cd0611733 100644 --- a/edx_proctoring/templates/practice_exam/entrance.html +++ b/edx_proctoring/templates/practice_exam/entrance.html @@ -32,7 +32,6 @@

{% include 'proctored_exam/footer.html' %}