From c8a09cef836436647d829f23d76c32d7270003a9 Mon Sep 17 00:00:00 2001 From: Matt Hughes Date: Wed, 2 Jan 2019 16:20:54 -0500 Subject: [PATCH] block starting session while one session is 'started' status introduces a new blocking interstitial which links to the currently started exam. Interstitial blocks all three of timed, practice, and proctored exams. It is still possible to start two separate exams at one time (e.g. by having the two different exams in state "ready_to_start" at the same time, and proceeding simultaneously without reloading the page), but much less probable for learners to accidentally happen upon. JIRA:EDUCATOR-3844 --- edx_proctoring/__init__.py | 2 +- edx_proctoring/api.py | 218 +++++++++++------- .../other_exam_in_progress.html | 15 ++ edx_proctoring/tests/test_student_view.py | 53 +++++ package.json | 2 +- 5 files changed, 204 insertions(+), 86 deletions(-) create mode 100644 edx_proctoring/templates/proctored_exam/other_exam_in_progress.html diff --git a/edx_proctoring/__init__.py b/edx_proctoring/__init__.py index 7b1eb30ccb6..582c7208591 100644 --- a/edx_proctoring/__init__.py +++ b/edx_proctoring/__init__.py @@ -5,6 +5,6 @@ from __future__ import absolute_import # Be sure to update the version number in edx_proctoring/package.json -__version__ = '1.5.1' +__version__ = '1.5.2' default_app_config = 'edx_proctoring.apps.EdxProctoringConfig' # pylint: disable=invalid-name diff --git a/edx_proctoring/api.py b/edx_proctoring/api.py index 3f1ac626e76..2b6928ae246 100644 --- a/edx_proctoring/api.py +++ b/edx_proctoring/api.py @@ -1241,8 +1241,8 @@ def get_last_exam_completion_date(course_id, username): def get_active_exams_for_user(user_id, course_id=None): """ This method will return a list of active exams for the user, - i.e. started_at != None and completed_at == None. Theoretically there - could be more than one, but in practice it will be one active exam. + i.e. started_at != None and completed_at == None. There will only + be one. If course_id is set, then attempts only for an exam in that course_id should be returned. @@ -1602,7 +1602,12 @@ def _get_timed_exam_view(exam, context, exam_id, user_id, course_id): if has_due_date_passed(exam['due_date']): student_view_template = 'timed_exam/expired.html' else: - student_view_template = 'timed_exam/entrance.html' + is_other_exam, other_exam_url = _get_running_exam_info(user_id, exam_id) + if is_other_exam: + context.update({'exam_url': other_exam_url}) + student_view_template = 'proctored_exam/other_exam_in_progress.html' + else: + student_view_template = 'timed_exam/entrance.html' elif attempt_status == ProctoredExamStudentAttemptStatus.started: # when we're taking the exam we should not override the view return None @@ -1611,7 +1616,7 @@ def _get_timed_exam_view(exam, context, exam_id, user_id, course_id): elif attempt_status == ProctoredExamStudentAttemptStatus.submitted: # If we are not hiding the exam after the due_date has passed, # check if the exam's due_date has passed. If so, return None - # so that the user can see his exam answers in read only mode. + # so that the user can see their exam answers in read only mode. if not exam['hide_after_due'] and has_due_date_passed(exam['due_date']): return None @@ -1745,22 +1750,35 @@ def _get_practice_exam_view(exam, context, exam_id, user_id, course_id): attempt_status = attempt['status'] if attempt else None provider = get_backend_provider(exam) - if not attempt_status: - student_view_template = 'practice_exam/entrance.html' - elif attempt_status == ProctoredExamStudentAttemptStatus.started: + if attempt_status == ProctoredExamStudentAttemptStatus.started: # when we're taking the exam we should not override the view return None - elif attempt_status in [ProctoredExamStudentAttemptStatus.created, - ProctoredExamStudentAttemptStatus.download_software_clicked]: - provider_attempt = provider.get_attempt(attempt) - student_view_template = 'proctored_exam/instructions.html' - context.update({ - 'exam_code': attempt['attempt_code'], - 'backend_instructions': provider_attempt.get('instructions', None), - 'software_download_url': provider_attempt.get('download_url', None) or provider.get_software_download_url(), - }) - elif attempt_status == ProctoredExamStudentAttemptStatus.ready_to_start: - student_view_template = 'proctored_exam/ready_to_start.html' + elif not attempt_status or attempt_status in [ + ProctoredExamStudentAttemptStatus.created, + ProctoredExamStudentAttemptStatus.download_software_clicked, + ProctoredExamStudentAttemptStatus.ready_to_start, + ]: + is_other_exam, other_exam_url = _get_running_exam_info(user_id, exam_id) + if is_other_exam: + context.update({'exam_url': other_exam_url}) + student_view_template = 'proctored_exam/other_exam_in_progress.html' + elif not attempt_status: + student_view_template = 'practice_exam/entrance.html' + elif attempt_status in [ + ProctoredExamStudentAttemptStatus.created, + ProctoredExamStudentAttemptStatus.download_software_clicked, + ]: + provider_attempt = provider.get_attempt(attempt) + student_view_template = 'proctored_exam/instructions.html' + context.update({ + 'exam_code': attempt['attempt_code'], + 'backend_instructions': provider_attempt.get('instructions', None), + 'software_download_url': (provider_attempt.get('download_url', None) + or provider.get_software_download_url()), + }) + else: + # note: then the status must be ready_to_start + student_view_template = 'proctored_exam/ready_to_start.html' elif attempt_status == ProctoredExamStudentAttemptStatus.error: student_view_template = 'practice_exam/error.html' elif attempt_status == ProctoredExamStudentAttemptStatus.submitted: @@ -1807,76 +1825,86 @@ def _get_proctored_exam_view(exam, context, exam_id, user_id, course_id): provider = get_backend_provider(exam) - if not attempt_status: - # student has not started an attempt - # so, show them: - # 1) If there are failed prerequisites then block user and say why - # 2) If there are pending prerequisites then block user and allow them to remediate them - # 3) If there are declined prerequisites, then we auto-decline proctoring since user - # explicitly declined their interest in credit - # 4) Otherwise - all prerequisites are satisfied - then give user - # option to take exam as proctored - - # get information about prerequisites - - credit_requirement_status = ( - credit_state.get('credit_requirement_status') - if credit_state else [] - ) - - prerequisite_status = _are_prerequirements_satisfied( - credit_requirement_status, - evaluate_for_requirement_name=exam['content_id'], - filter_out_namespaces=['grade'] - ) + if attempt_status == ProctoredExamStudentAttemptStatus.started: + # when we're taking the exam we should not override the view + return None + elif not attempt_status or attempt_status in [ + ProctoredExamStudentAttemptStatus.created, + ProctoredExamStudentAttemptStatus.download_software_clicked, + ProctoredExamStudentAttemptStatus.ready_to_start, + ]: + is_other_exam, other_exam_url = _get_running_exam_info(user_id, exam_id) + if is_other_exam: + context.update({'exam_url': other_exam_url}) + student_view_template = 'proctored_exam/other_exam_in_progress.html' + elif not attempt_status: + # student has not started an attempt + # so, show them: + # 1) If there are failed prerequisites then block user and say why + # 2) If there are pending prerequisites then block user and allow them to remediate them + # 3) If there are declined prerequisites, then we auto-decline proctoring since user + # explicitly declined their interest in credit + # 4) Otherwise - all prerequisites are satisfied - then give user + # option to take exam as proctored + + # get information about prerequisites + + credit_requirement_status = ( + credit_state.get('credit_requirement_status') + if credit_state else [] + ) - # add any prerequisite information, if applicable - context.update({ - 'prerequisite_status': prerequisite_status - }) + prerequisite_status = _are_prerequirements_satisfied( + credit_requirement_status, + evaluate_for_requirement_name=exam['content_id'], + filter_out_namespaces=['grade'] + ) - # if exam due date has passed, then we can't take the exam - if has_due_date_passed(exam['due_date']): - student_view_template = 'proctored_exam/expired.html' - elif not prerequisite_status['are_prerequisites_satisifed']: - # do we have any declined prerequisites, if so, then we - # will auto-decline this proctored exam - if prerequisite_status['declined_prerequisites']: - # user hasn't a record of attempt, create one now - # so we can mark it as declined - _create_and_decline_attempt(exam_id, user_id) - return None + # add any prerequisite information, if applicable + context.update({ + 'prerequisite_status': prerequisite_status + }) - # do we have failed prerequisites? That takes priority in terms of - # messaging - if prerequisite_status['failed_prerequisites']: - # Let's resolve the URLs to jump to this prequisite - prerequisite_status['failed_prerequisites'] = _resolve_prerequisite_links( - exam, - prerequisite_status['failed_prerequisites'] - ) - student_view_template = 'proctored_exam/failed-prerequisites.html' + # if exam due date has passed, then we can't take the exam + if has_due_date_passed(exam['due_date']): + student_view_template = 'proctored_exam/expired.html' + elif not prerequisite_status['are_prerequisites_satisifed']: + # do we have any declined prerequisites, if so, then we + # will auto-decline this proctored exam + if prerequisite_status['declined_prerequisites']: + # user hasn't a record of attempt, create one now + # so we can mark it as declined + _create_and_decline_attempt(exam_id, user_id) + return None + + # do we have failed prerequisites? That takes priority in terms of + # messaging + if prerequisite_status['failed_prerequisites']: + # Let's resolve the URLs to jump to this prequisite + prerequisite_status['failed_prerequisites'] = _resolve_prerequisite_links( + exam, + prerequisite_status['failed_prerequisites'] + ) + student_view_template = 'proctored_exam/failed-prerequisites.html' + else: + # Let's resolve the URLs to jump to this prequisite + prerequisite_status['pending_prerequisites'] = _resolve_prerequisite_links( + exam, + prerequisite_status['pending_prerequisites'] + ) + student_view_template = 'proctored_exam/pending-prerequisites.html' else: - # Let's resolve the URLs to jump to this prequisite - prerequisite_status['pending_prerequisites'] = _resolve_prerequisite_links( - exam, - prerequisite_status['pending_prerequisites'] - ) - student_view_template = 'proctored_exam/pending-prerequisites.html' - else: - student_view_template = 'proctored_exam/entrance.html' - # emit an event that the user was presented with the option - # to start timed exam - emit_event(exam, 'option-presented') - elif attempt_status == ProctoredExamStudentAttemptStatus.started: - # when we're taking the exam we should not override the view - return None - elif attempt_status in [ProctoredExamStudentAttemptStatus.created, - ProctoredExamStudentAttemptStatus.download_software_clicked]: - if context.get('verification_status') is not APPROVED_STATUS: + student_view_template = 'proctored_exam/entrance.html' + # emit an event that the user was presented with the option + # to start timed exam + emit_event(exam, 'option-presented') + elif context.get('verification_status') is not APPROVED_STATUS: # if the user has not id verified yet, show them the page that requires them to do so student_view_template = 'proctored_exam/id_verification.html' - else: + elif attempt_status in [ + ProctoredExamStudentAttemptStatus.created, + ProctoredExamStudentAttemptStatus.download_software_clicked, + ]: provider_attempt = provider.get_attempt(attempt) student_view_template = 'proctored_exam/instructions.html' download_url = provider_attempt.get('download_url', None) or provider.get_software_download_url() @@ -1885,8 +1913,9 @@ def _get_proctored_exam_view(exam, context, exam_id, user_id, course_id): 'backend_instructions': provider_attempt.get('instructions', None), 'software_download_url': download_url }) - elif attempt_status == ProctoredExamStudentAttemptStatus.ready_to_start: - student_view_template = 'proctored_exam/ready_to_start.html' + else: + # note: then the status must be ready_to_start + student_view_template = 'proctored_exam/ready_to_start.html' elif attempt_status == ProctoredExamStudentAttemptStatus.error: student_view_template = 'proctored_exam/error.html' elif attempt_status == ProctoredExamStudentAttemptStatus.timed_out: @@ -1995,6 +2024,27 @@ def get_student_view(user_id, course_id, content_id, return None +def _get_running_exam_info(user_id, currently_visited_exam_id): + """ + Check whether there are any other currently active exams besides + that currently being viewed + """ + active_exam_attempts = get_active_exams_for_user(user_id) + if active_exam_attempts: + active_exam = active_exam_attempts[0]['exam'] + is_other_exam_running = active_exam['id'] != currently_visited_exam_id + try: + # resolve the LMS url, note we can't assume we're running in + # a same process as the LMS + other_exam_url = reverse('jump_to', args=[active_exam['course_id'], active_exam['content_id']]) + except NoReverseMatch: + log.exception("Can't find exam url for course %s", active_exam['course_id']) + other_exam_url = '' + else: + is_other_exam_running, other_exam_url = False, '' + return is_other_exam_running, other_exam_url + + def get_exam_violation_report(course_id, include_practice_exams=False): """ Returns proctored exam attempts for the course id, including review details. diff --git a/edx_proctoring/templates/proctored_exam/other_exam_in_progress.html b/edx_proctoring/templates/proctored_exam/other_exam_in_progress.html new file mode 100644 index 00000000000..665f3334448 --- /dev/null +++ b/edx_proctoring/templates/proctored_exam/other_exam_in_progress.html @@ -0,0 +1,15 @@ +{% load i18n %} +
+

+ {% blocktrans %} + Complete in-progress exam + {% endblocktrans %} +

+ +

+ {% blocktrans %} + You must complete your currently in-progress exam before you can attempt this one. + {% endblocktrans %} +

+
+{% include 'proctored_exam/footer.html' %} diff --git a/edx_proctoring/tests/test_student_view.py b/edx_proctoring/tests/test_student_view.py index 248134c7f16..97fdd540cd3 100644 --- a/edx_proctoring/tests/test_student_view.py +++ b/edx_proctoring/tests/test_student_view.py @@ -64,6 +64,7 @@ def setUp(self): self.practice_exam_submitted_msg = 'You have submitted this practice proctored exam' self.take_exam_without_proctoring_msg = 'Take this exam without proctoring' self.ready_to_start_msg = 'Important' + self.complete_other_exam_first_msg = 'Complete in-progress exam' self.footer_msg = 'About Proctored Exams' self.timed_footer_msg = 'Can I request additional time to complete my exam?' @@ -131,6 +132,18 @@ def render_practice_exam(self, context_overrides=None): context_overrides=exam_context_overrides ) + def render_timed_exam(self): + """ + Renders a test timed exam + """ + exam_context_overrides = { + 'is_proctored': False + } + return self._render_exam( + self.timed_exam_id, + context_overrides=exam_context_overrides + ) + def test_get_student_view(self): """ Test for get_student_view prompting the user to take the exam @@ -457,6 +470,46 @@ def test_declined_attempt(self): rendered_response = self.render_proctored_exam() self.assertIsNone(rendered_response) + def test_get_studentview_exam_in_progress(self): + """ + Assert that we get the right content when another exam was + started first + """ + self._create_started_exam_attempt() + self.content_id = self.content_id + '_new' + self.proctored_exam_id = self._create_proctored_exam() + unstarted_attempt = self._create_unstarted_exam_attempt() + + unstarted_attempt.status = ProctoredExamStudentAttemptStatus.created + unstarted_attempt.save() + + rendered_response = self.render_proctored_exam() + self.assertIn(self.complete_other_exam_first_msg, rendered_response) + + def test_get_studentview_exam_in_progress_timed(self): + """ + Assert that we get the right content when another exam was + started first for timed and practice exams + """ + self._create_started_exam_attempt() + + rendered_response = self.render_timed_exam() + self.assertIn(self.complete_other_exam_first_msg, rendered_response) + + def test_get_studentview_exam_in_progress_practice(self): + """ + Assert that we get the right content when another exam was + started first for timed and practice exams + """ + self._create_started_exam_attempt() + unstarted_attempt = self._create_unstarted_exam_attempt(is_practice=True) + + unstarted_attempt.status = ProctoredExamStudentAttemptStatus.created + unstarted_attempt.save() + + rendered_response = self.render_practice_exam() + self.assertIn(self.complete_other_exam_first_msg, rendered_response) + def test_get_studentview_ready(self): """ Assert that we get the right content diff --git a/package.json b/package.json index ced351f0498..564bad7dba4 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "name": "@edx/edx-proctoring", "//": "Be sure to update the version number in edx_proctoring/__init__.py", "//": "Note that the version format is slightly different than that of the Python version when using prereleases.", - "version": "1.5.1", + "version": "1.5.2", "main": "edx_proctoring/static/index.js", "repository": { "type": "git",