def post(self, request): """ HTTP POST handler. To create an exam attempt. """ start_immediately = request.data.get('start_clock', 'false').lower() == 'true' exam_id = request.data.get('exam_id', None) attempt_proctored = request.data.get('attempt_proctored', 'false').lower() == 'true' exam = get_exam_by_id(exam_id) # Bypassing the due date check for practice exam # because student can attempt the practice after the due date if not exam.get("is_practice_exam") and is_exam_passed_due(exam, request.user): raise ProctoredExamPermissionDenied( 'Attempted to access expired exam with exam_id {exam_id}'.format(exam_id=exam_id) ) exam_attempt_id = create_exam_attempt( exam_id=exam_id, user_id=request.user.id, taking_as_proctored=attempt_proctored ) # if use elected not to take as proctored exam, then # use must take as open book, and loose credit eligibility if exam['is_proctored'] and not attempt_proctored: update_attempt_status( exam_id, request.user.id, ProctoredExamStudentAttemptStatus.declined ) elif start_immediately: start_exam_attempt(exam_id, request.user.id) data = {'exam_attempt_id': exam_attempt_id} return Response(data)
def test_send_email_unicode(self): """ Assert that email can be sent with a unicode course name. """ course_name = u'अआईउऊऋऌ अआईउऊऋऌ' set_runtime_service('credit', MockCreditService(course_name=course_name)) exam_attempt = self._create_started_exam_attempt() credit_state = get_runtime_service('credit').get_credit_state(self.user_id, self.course_id) update_attempt_status( exam_attempt.proctored_exam_id, self.user.id, ProctoredExamStudentAttemptStatus.submitted ) self.assertEqual(len(mail.outbox), 1) # Verify the subject actual_subject = self._normalize_whitespace(mail.outbox[0].subject) self.assertIn('Proctoring Review In Progress', actual_subject) self.assertIn(course_name, actual_subject) # Verify the body actual_body = self._normalize_whitespace(mail.outbox[0].body) self.assertIn('was submitted successfully', actual_body) self.assertIn(credit_state['course_name'], actual_body)
def test_get_studentview_unstarted_exam(self, allow_proctoring_opt_out): """ Test for get_student_view proctored exam which has not started yet. """ self._create_unstarted_exam_attempt() # Verify that the option to skip proctoring is shown if allowed rendered_response = self.render_proctored_exam({ 'allow_proctoring_opt_out': allow_proctoring_opt_out, }) self.assertIn(self.chose_proctored_exam_msg, rendered_response) if allow_proctoring_opt_out: self.assertIn(self.proctored_exam_optout_msg, rendered_response) else: self.assertNotIn(self.proctored_exam_optout_msg, rendered_response) # Now make sure content remains the same if the status transitions # to 'download_software_clicked'. update_attempt_status( self.proctored_exam_id, self.user_id, ProctoredExamStudentAttemptStatus.download_software_clicked ) rendered_response = self.render_proctored_exam() self.assertIn(self.chose_proctored_exam_msg, rendered_response) self.assertIn(self.proctored_exam_optout_msg, rendered_response)
def handle(self, *args, **options): """ Management command entry point, simply call into the signal firiing """ from edx_proctoring.api import ( update_attempt_status, get_exam_by_id ) exam_id = options['exam_id'] user_id = options['user_id'] to_status = options['to_status'] msg = ( 'Running management command to update user {user_id} ' 'attempt status on exam_id {exam_id} to {to_status}'.format( user_id=user_id, exam_id=exam_id, to_status=to_status ) ) self.stdout.write(msg) if not ProctoredExamStudentAttemptStatus.is_valid_status(to_status): raise CommandError('{to_status} is not a valid attempt status!'.format(to_status=to_status)) # get exam, this will throw exception if does not exist, so let it bomb out get_exam_by_id(exam_id) update_attempt_status(exam_id, user_id, to_status) self.stdout.write('Completed!')
def test_send_email(self, status, expected_subject, expected_message_string): """ Assert that email is sent on the following statuses of proctoring attempt. """ exam_attempt = self._create_started_exam_attempt() credit_state = get_runtime_service('credit').get_credit_state(self.user_id, self.course_id) update_attempt_status( exam_attempt.proctored_exam_id, self.user.id, status ) self.assertEqual(len(mail.outbox), 1) # Verify the subject actual_subject = self._normalize_whitespace(mail.outbox[0].subject) self.assertIn(expected_subject, actual_subject) self.assertIn(self.exam_name, actual_subject) # Verify the body actual_body = self._normalize_whitespace(mail.outbox[0].body) self.assertIn('Hi tester,', actual_body) self.assertIn('Your proctored exam "Test Exam"', actual_body) self.assertIn(credit_state['course_name'], actual_body) self.assertIn(expected_message_string, actual_body)
def handle(self, *args, **options): """ Management command entry point, simply call into the signal firiing """ from edx_proctoring.api import (update_attempt_status, get_exam_by_id) exam_id = options['exam_id'] user_id = options['user_id'] to_status = options['to_status'] msg = ('Running management command to update user {user_id} ' 'attempt status on exam_id {exam_id} to {to_status}'.format( user_id=user_id, exam_id=exam_id, to_status=to_status)) print msg if not ProctoredExamStudentAttemptStatus.is_valid_status(to_status): raise Exception( '{to_status} is not a valid attempt status!'.format( to_status=to_status)) # get exam, this will throw exception if does not exist, so let it bomb out get_exam_by_id(exam_id) update_attempt_status(exam_id, user_id, to_status) print 'Completed!'
def post(self, request): """ HTTP POST handler. To create an exam attempt. """ start_immediately = request.data.get('start_clock', 'false').lower() == 'true' exam_id = request.data.get('exam_id', None) attempt_proctored = request.data.get('attempt_proctored', 'false').lower() == 'true' try: exam_attempt_id = create_exam_attempt( exam_id=exam_id, user_id=request.user.id, taking_as_proctored=attempt_proctored) exam = get_exam_by_id(exam_id) # if use elected not to take as proctored exam, then # use must take as open book, and loose credit eligibility if exam['is_proctored'] and not attempt_proctored: update_attempt_status( exam_id, request.user.id, ProctoredExamStudentAttemptStatus.declined) elif start_immediately: start_exam_attempt(exam_id, request.user.id) return Response({'exam_attempt_id': exam_attempt_id}) except ProctoredBaseException, ex: LOG.exception(ex) return Response(status=status.HTTP_400_BAD_REQUEST, data={"detail": unicode(ex)})
def test_send_email_unicode(self): """ Assert that email can be sent with a unicode course name. """ course_name = u'अआईउऊऋऌ अआईउऊऋऌ' set_runtime_service('credit', MockCreditService(course_name=course_name)) exam_attempt = self._create_started_exam_attempt() credit_state = get_runtime_service('credit').get_credit_state( self.user_id, self.course_id) update_attempt_status(exam_attempt.proctored_exam_id, self.user.id, ProctoredExamStudentAttemptStatus.submitted) self.assertEqual(len(mail.outbox), 1) # Verify the subject actual_subject = self._normalize_whitespace(mail.outbox[0].subject) self.assertIn('Proctoring Review In Progress', actual_subject) self.assertIn(course_name, actual_subject) # Verify the body actual_body = self._normalize_whitespace(mail.outbox[0].body) self.assertIn('was submitted successfully', actual_body) self.assertIn(credit_state['course_name'], actual_body)
def test_email_not_sent(self, to_status): """ Assert that an email is not sent for the following attempt status codes. """ exam_attempt = self._create_exam_attempt(self.proctored_exam_id) update_attempt_status(exam_attempt.id, to_status) self.assertEqual(len(mail.outbox), 0)
def test_correct_edx_support_url(self, status, override_email): """ Test that the correct edX support URL is used in emails. The email should use either the backend specific contact URL, if one is specified, or fall back to the edX contact us support page. """ contact_url = 'http://{site_name}/support/contact_us'.format( site_name=SITE_NAME) backend_settings = settings.PROCTORING_BACKENDS if override_email: contact_url = 'www.example.com' backend_settings = deepcopy(backend_settings) backend_settings['test'] = { 'LINK_URLS': { 'contact': contact_url, } } with self.settings(PROCTORING_BACKENDS=backend_settings): exam_attempt = self._create_started_exam_attempt() update_attempt_status(exam_attempt.id, status) # Verify the edX support URL actual_body = self._normalize_whitespace(mail.outbox[0].body) self.assertIn( u'<a href="{contact_url}"> ' u'{contact_url} </a>'.format(contact_url=contact_url), actual_body)
def post(self, request): """ HTTP POST handler. To create an exam attempt. """ start_immediately = request.data.get('start_clock', 'false').lower() == 'true' exam_id = request.data.get('exam_id', None) attempt_proctored = request.data.get('attempt_proctored', 'false').lower() == 'true' exam = get_exam_by_id(exam_id) # Bypassing the due date check for practice exam # because student can attempt the practice after the due date if not exam.get("is_practice_exam") and is_exam_passed_due(exam, request.user): raise ProctoredExamPermissionDenied( u'Attempted to access expired exam with exam_id {exam_id}'.format(exam_id=exam_id) ) exam_attempt_id = create_exam_attempt( exam_id=exam_id, user_id=request.user.id, taking_as_proctored=attempt_proctored ) # if use elected not to take as proctored exam, then # use must take as open book, and loose credit eligibility if exam['is_proctored'] and not attempt_proctored: update_attempt_status( exam_id, request.user.id, ProctoredExamStudentAttemptStatus.declined ) elif start_immediately: start_exam_attempt(exam_id, request.user.id) data = {'exam_attempt_id': exam_attempt_id} return Response(data)
def test_get_studentview_unstarted_exam(self, allow_proctoring_opt_out): """ Test for get_student_view proctored exam which has not started yet. """ self._create_unstarted_exam_attempt() # Verify that the option to skip proctoring is shown if allowed rendered_response = self.render_proctored_exam({ 'allow_proctoring_opt_out': allow_proctoring_opt_out, }) self.assertIn(self.chose_proctored_exam_msg, rendered_response) if allow_proctoring_opt_out: self.assertIn(self.proctored_exam_optout_msg, rendered_response) else: self.assertNotIn(self.proctored_exam_optout_msg, rendered_response) # Now make sure content remains the same if the status transitions # to 'download_software_clicked'. update_attempt_status( self.proctored_exam_id, self.user_id, ProctoredExamStudentAttemptStatus.download_software_clicked) rendered_response = self.render_proctored_exam() self.assertIn(self.chose_proctored_exam_msg, rendered_response) self.assertIn(self.proctored_exam_optout_msg, rendered_response)
def post(self, request): """ HTTP POST handler. To create an exam attempt. """ start_immediately = request.DATA.get('start_clock', 'false').lower() == 'true' exam_id = request.DATA.get('exam_id', None) attempt_proctored = request.DATA.get('attempt_proctored', 'false').lower() == 'true' try: exam_attempt_id = create_exam_attempt( exam_id=exam_id, user_id=request.user.id, taking_as_proctored=attempt_proctored ) exam = get_exam_by_id(exam_id) # if use elected not to take as proctored exam, then # use must take as open book, and loose credit eligibility if exam['is_proctored'] and not attempt_proctored: update_attempt_status( exam_id, request.user.id, ProctoredExamStudentAttemptStatus.declined ) elif start_immediately: start_exam_attempt(exam_id, request.user.id) return Response({'exam_attempt_id': exam_attempt_id}) except ProctoredBaseException, ex: LOG.exception(ex) return Response( status=status.HTTP_400_BAD_REQUEST, data={"detail": unicode(ex)} )
def handle(self, *args, **options): """ Management command entry point, simply call into the signal firiing """ # pylint: disable=import-outside-toplevel from edx_proctoring.api import (update_attempt_status, get_exam_by_id) exam_id = options['exam_id'] user_id = options['user_id'] to_status = options['to_status'] msg = (u'Running management command to update user {user_id} ' u'attempt status on exam_id {exam_id} to {to_status}'.format( user_id=user_id, exam_id=exam_id, to_status=to_status)) self.stdout.write(msg) if not ProctoredExamStudentAttemptStatus.is_valid_status(to_status): raise CommandError( u'{to_status} is not a valid attempt status!'.format( to_status=to_status)) # get exam, this will throw exception if does not exist, so let it bomb out get_exam_by_id(exam_id) update_attempt_status(exam_id, user_id, to_status) self.stdout.write('Completed!')
def test_send_email(self, status, expected_subject, expected_message_string): """ Assert that email is sent on the following statuses of proctoring attempt. """ exam_attempt = self._create_started_exam_attempt() credit_state = get_runtime_service('credit').get_credit_state(self.user_id, self.course_id) update_attempt_status( exam_attempt.proctored_exam_id, self.user.id, status ) self.assertEqual(len(mail.outbox), 1) # Verify the subject actual_subject = self._normalize_whitespace(mail.outbox[0].subject) self.assertIn(expected_subject, actual_subject) self.assertIn(self.exam_name, actual_subject) # Verify the body actual_body = self._normalize_whitespace(mail.outbox[0].body) self.assertIn('Hello tester,', actual_body) self.assertIn('Your proctored exam "Test Exam"', actual_body) self.assertIn(credit_state['course_name'], actual_body) self.assertIn(expected_message_string, actual_body)
def finish_review_workflow(sender, instance, signal, **kwargs): # pylint: disable=unused-argument """ Updates the attempt status based on the review status Also notifies support about suspicious reviews. """ review = instance attempt_obj, is_archived = locate_attempt_by_attempt_code(review.attempt_code) attempt = api.ProctoredExamStudentAttemptSerializer(attempt_obj).data # we could have gotten a review for an archived attempt # this should *not* cause an update in our credit # eligibility table if review.review_status in SoftwareSecureReviewStatus.passing_statuses: attempt_status = ProctoredExamStudentAttemptStatus.verified elif review.reviewed_by or not constants.REQUIRE_FAILURE_SECOND_REVIEWS: # reviews from the django admin have a reviewer set. They should be allowed to # reject an attempt attempt_status = ProctoredExamStudentAttemptStatus.rejected else: # if we are not allowed to store 'rejected' on this # code path, then put status into 'second_review_required' attempt_status = ProctoredExamStudentAttemptStatus.second_review_required if review.review_status in SoftwareSecureReviewStatus.notify_support_for_status: instructor_service = api.get_runtime_service('instructor') request = get_current_request() if instructor_service and request: course_id = attempt['proctored_exam']['course_id'] exam_id = attempt['proctored_exam']['id'] review_url = request.build_absolute_uri( u'{}?attempt={}'.format( reverse('edx_proctoring:instructor_dashboard_exam', args=[course_id, exam_id]), attempt['external_id'] )) instructor_service.send_support_notification( course_id=attempt['proctored_exam']['course_id'], exam_name=attempt['proctored_exam']['exam_name'], student_username=attempt['user']['username'], review_status=review.review_status, review_url=review_url, ) if not is_archived: # updating attempt status will trigger workflow # (i.e. updating credit eligibility table) # archived attempts should not trigger the workflow api.update_attempt_status( attempt['proctored_exam']['id'], attempt['user']['id'], attempt_status, raise_if_not_found=False ) # emit an event for 'review_received' data = { 'review_attempt_code': review.attempt_code, 'review_status': review.review_status, } emit_event(attempt['proctored_exam'], 'review_received', attempt=attempt, override_data=data)
def get(self, request, attempt_id): """ HTTP GET Handler. Returns the status of the exam attempt. """ try: attempt = get_exam_attempt_by_id(attempt_id) if not attempt: err_msg = ('Attempted to access attempt_id {attempt_id} but ' 'it does not exist.'.format(attempt_id=attempt_id)) return Response(status=status.HTTP_400_BAD_REQUEST) # make sure the the attempt belongs to the calling user_id if attempt['user']['id'] != request.user.id: err_msg = ('Attempted to access attempt_id {attempt_id} but ' 'does not have access to it.'.format( attempt_id=attempt_id)) raise ProctoredExamPermissionDenied(err_msg) # check if the last_poll_timestamp is not None # and if it is older than CLIENT_TIMEOUT # then attempt status should be marked as error. last_poll_timestamp = attempt['last_poll_timestamp'] if last_poll_timestamp is not None \ and (datetime.now(pytz.UTC) - last_poll_timestamp).total_seconds() > CLIENT_TIMEOUT: try: update_attempt_status( attempt['proctored_exam']['id'], attempt['user']['id'], ProctoredExamStudentAttemptStatus.error) attempt['status'] = ProctoredExamStudentAttemptStatus.error except ProctoredExamIllegalStatusTransition: # don't transition a completed state to an error state pass # add in the computed time remaining as a helper to a client app time_remaining_seconds = get_time_remaining_for_attempt(attempt) attempt['time_remaining_seconds'] = time_remaining_seconds accessibility_time_string = _( 'you have {remaining_time} remaining').format( remaining_time=humanized_time( int(round(time_remaining_seconds / 60.0, 0)))) # special case if we are less than a minute, since we don't produce # text translations of granularity at the seconds range if time_remaining_seconds < 60: accessibility_time_string = _( 'you have less than a minute remaining') attempt['accessibility_time_string'] = accessibility_time_string return Response(data=attempt, status=status.HTTP_200_OK) except ProctoredBaseException, ex: LOG.exception(ex) return Response(status=status.HTTP_400_BAD_REQUEST, data={"detail": str(ex)})
def put(self, request, attempt_id): """ HTTP POST handler. To stop an exam. """ attempt = get_exam_attempt_by_id(attempt_id) if not attempt: err_msg = (u'Attempted to access attempt_id {attempt_id} but ' u'it does not exist.'.format(attempt_id=attempt_id)) raise StudentExamAttemptDoesNotExistsException(err_msg) # make sure the the attempt belongs to the calling user_id if attempt['user']['id'] != request.user.id: err_msg = (u'Attempted to access attempt_id {attempt_id} but ' u'does not have access to it.'.format( attempt_id=attempt_id)) raise ProctoredExamPermissionDenied(err_msg) action = request.data.get('action') if action == 'stop': exam_attempt_id = stop_exam_attempt( exam_id=attempt['proctored_exam']['id'], user_id=request.user.id) elif action == 'start': exam_attempt_id = start_exam_attempt( exam_id=attempt['proctored_exam']['id'], user_id=request.user.id) elif action == 'submit': exam_attempt_id = update_attempt_status( attempt['proctored_exam']['id'], request.user.id, ProctoredExamStudentAttemptStatus.submitted) elif action == 'click_download_software': exam_attempt_id = update_attempt_status( attempt['proctored_exam']['id'], request.user.id, ProctoredExamStudentAttemptStatus.download_software_clicked) elif action == 'error': backend = attempt['proctored_exam']['backend'] waffle_name = PING_FAILURE_PASSTHROUGH_TEMPLATE.format(backend) should_block_user = not ( backend and waffle.switch_is_active(waffle_name)) and ( not attempt['status'] == ProctoredExamStudentAttemptStatus.submitted) if should_block_user: exam_attempt_id = update_attempt_status( attempt['proctored_exam']['id'], request.user.id, ProctoredExamStudentAttemptStatus.error) else: exam_attempt_id = False LOG.warning( u'Browser JS reported problem with proctoring desktop ' u'application. Did block user: %s, for attempt: %s', should_block_user, attempt['id']) elif action == 'decline': exam_attempt_id = update_attempt_status( attempt['proctored_exam']['id'], request.user.id, ProctoredExamStudentAttemptStatus.declined) data = {"exam_attempt_id": exam_attempt_id} return Response(data)
def test_not_send_email_timed_exam(self, status): """ Assert that email is not sent when exam is timed/not-proctoring """ exam_attempt = self._create_started_exam_attempt(is_proctored=False) update_attempt_status(exam_attempt.id, status) self.assertEqual(len(mail.outbox), 0)
def get(self, request, attempt_id): """ HTTP GET Handler. Returns the status of the exam attempt. """ try: attempt = get_exam_attempt_by_id(attempt_id) if not attempt: err_msg = ( 'Attempted to access attempt_id {attempt_id} but ' 'it does not exist.'.format( attempt_id=attempt_id ) ) return Response( status=status.HTTP_400_BAD_REQUEST ) # make sure the the attempt belongs to the calling user_id if attempt['user']['id'] != request.user.id: err_msg = ( 'Attempted to access attempt_id {attempt_id} but ' 'does not have access to it.'.format( attempt_id=attempt_id ) ) raise ProctoredExamPermissionDenied(err_msg) # check if the last_poll_timestamp is not None # and if it is older than SOFTWARE_SECURE_CLIENT_TIMEOUT # then attempt status should be marked as error. last_poll_timestamp = attempt['last_poll_timestamp'] if last_poll_timestamp is not None \ and (datetime.now(pytz.UTC) - last_poll_timestamp).total_seconds() > SOFTWARE_SECURE_CLIENT_TIMEOUT: attempt['status'] = 'error' update_attempt_status( attempt['proctored_exam']['id'], attempt['user']['id'], ProctoredExamStudentAttemptStatus.error ) # add in the computed time remaining as a helper to a client app time_remaining_seconds = get_time_remaining_for_attempt(attempt) attempt['time_remaining_seconds'] = time_remaining_seconds return Response( data=attempt, status=status.HTTP_200_OK ) except ProctoredBaseException, ex: LOG.exception(ex) return Response( status=status.HTTP_400_BAD_REQUEST, data={"detail": str(ex)} )
def put(self, request, attempt_id): """ HTTP POST handler. To stop an exam. """ try: attempt = get_exam_attempt_by_id(attempt_id) if not attempt: err_msg = ( 'Attempted to access attempt_id {attempt_id} but ' 'it does not exist.'.format( attempt_id=attempt_id ) ) raise StudentExamAttemptDoesNotExistsException(err_msg) # make sure the the attempt belongs to the calling user_id if attempt['user']['id'] != request.user.id: err_msg = ( 'Attempted to access attempt_id {attempt_id} but ' 'does not have access to it.'.format( attempt_id=attempt_id ) ) raise ProctoredExamPermissionDenied(err_msg) action = request.DATA.get('action') if action == 'stop': exam_attempt_id = stop_exam_attempt( exam_id=attempt['proctored_exam']['id'], user_id=request.user.id ) elif action == 'start': exam_attempt_id = start_exam_attempt( exam_id=attempt['proctored_exam']['id'], user_id=request.user.id ) elif action == 'submit': exam_attempt_id = update_attempt_status( attempt['proctored_exam']['id'], request.user.id, ProctoredExamStudentAttemptStatus.submitted ) elif action == 'decline': exam_attempt_id = update_attempt_status( attempt['proctored_exam']['id'], request.user.id, ProctoredExamStudentAttemptStatus.declined ) return Response({"exam_attempt_id": exam_attempt_id}) except ProctoredBaseException, ex: LOG.exception(ex) return Response( status=status.HTTP_400_BAD_REQUEST, data={"detail": str(ex)} )
def save_model(self, request, obj, form, change): """ Override callback so that we can change the status by "update_attempt_status" function """ try: if change: update_attempt_status(obj.proctored_exam.id, obj.user.id, form.cleaned_data['status']) except (ProctoredExamIllegalStatusTransition, StudentExamAttemptDoesNotExistsException) as ex: messages.error(request, ex.message)
def save_model(self, request, obj, form, change): """ Override callback so that we can change the status by "update_attempt_status" function """ try: if change: update_attempt_status(obj.proctored_exam.id, obj.user.id, form.cleaned_data['status']) except (ProctoredExamIllegalStatusTransition, StudentExamAttemptDoesNotExistsException) as ex: messages.error(request, ex.message)
def test_not_send_email_sample_exam(self, status): """ Assert that email is not sent when there is practice/sample exam """ exam_attempt = self._create_started_exam_attempt( is_sample_attempt=True) update_attempt_status(exam_attempt.id, status) self.assertEqual(len(mail.outbox), 0)
def test_email_not_sent(self, status): """ Assert than email is not sent on the following statuses of proctoring attempt """ exam_attempt = self._create_started_exam_attempt() update_attempt_status(exam_attempt.proctored_exam_id, self.user.id, status) self.assertEquals(len(mail.outbox), 0)
def start_exam_callback(request, attempt_code): # pylint: disable=unused-argument """ A callback endpoint which is called when SoftwareSecure completes the proctoring setup and the exam should be started. This is an authenticated endpoint and the attempt_code is passed in as part of the URL path IMPORTANT: This is an unauthenticated endpoint, so be VERY CAREFUL about extending this endpoint """ attempt = get_exam_attempt_by_code(attempt_code) if not attempt: log.warning(u"Attempt code %r cannot be found.", attempt_code) return HttpResponse( content='You have entered an exam code that is not valid.', status=404 ) proctored_exam_id = attempt['proctored_exam']['id'] attempt_status = attempt['status'] user_id = attempt['user']['id'] if attempt_status in [ProctoredExamStudentAttemptStatus.created, ProctoredExamStudentAttemptStatus.download_software_clicked]: mark_exam_attempt_as_ready(proctored_exam_id, user_id) # if a user attempts to re-enter an exam that has not yet been submitted, submit the exam if ProctoredExamStudentAttemptStatus.is_in_progress_status(attempt_status): update_attempt_status(proctored_exam_id, user_id, ProctoredExamStudentAttemptStatus.submitted) else: log.warning(u"Attempted to enter proctored exam attempt {attempt_id} when status was {attempt_status}" .format( attempt_id=attempt['id'], attempt_status=attempt_status, )) if switch_is_active(RPNOWV4_WAFFLE_NAME): # pylint: disable=illegal-waffle-usage course_id = attempt['proctored_exam']['course_id'] content_id = attempt['proctored_exam']['content_id'] exam_url = '' try: exam_url = reverse('jump_to', args=[course_id, content_id]) except NoReverseMatch: log.exception(u"BLOCKING ERROR: Can't find course info url for course %s", course_id) response = HttpResponseRedirect(exam_url) response.set_signed_cookie('exam', attempt['attempt_code']) return response template = loader.get_template('proctored_exam/proctoring_launch_callback.html') return HttpResponse( template.render({ 'platform_name': settings.PLATFORM_NAME, 'link_urls': settings.PROCTORING_SETTINGS.get('LINK_URLS', {}) }) )
def test_not_send_email_timed_exam(self, status): """ Assert that email is not sent when exam is timed/not-proctoring """ exam_attempt = self._create_started_exam_attempt(is_proctored=False) update_attempt_status( exam_attempt.proctored_exam_id, self.user.id, status ) self.assertEqual(len(mail.outbox), 0)
def test_not_send_email_sample_exam(self, status): """ Assert that email is not sent when there is practice/sample exam """ exam_attempt = self._create_started_exam_attempt(is_sample_attempt=True) update_attempt_status( exam_attempt.proctored_exam_id, self.user.id, status ) self.assertEqual(len(mail.outbox), 0)
def test_email_not_sent(self, status): """ Assert that an email is not sent for the following attempt status codes. """ exam_attempt = self._create_started_exam_attempt() update_attempt_status( exam_attempt.proctored_exam_id, self.user.id, status ) self.assertEqual(len(mail.outbox), 0)
def test_escalation_email_not_included(self, status): """ Test that verified and rejected emails link to support if an escalation email is not given. """ set_runtime_service('instructor', None) exam_attempt = self._create_started_exam_attempt() update_attempt_status(exam_attempt.id, status) actual_body = self._normalize_whitespace(mail.outbox[0].body) self.assertIn('support/contact_us', actual_body)
def save_model(self, request, obj, form, change): """ Override callback so that we can change the status by "update_attempt_status" function """ try: if change: update_attempt_status(obj.id, form.cleaned_data['status']) except (ProctoredExamIllegalStatusTransition, StudentExamAttemptDoesNotExistsException) as ex: # prevent showing success message inappropriately messages.set_level(request, messages.ERROR) messages.error(request, str(ex))
def put(self, request, attempt_id): """ HTTP POST handler. To stop an exam. """ try: attempt_id = int(attempt_id) attempt = get_exam_attempt_by_id(attempt_id) except: attempt = get_exam_attempt_by_code(attempt_id) try: if not attempt: err_msg = ('Attempted to access attempt_id {attempt_id} but ' 'it does not exist.'.format(attempt_id=attempt_id)) raise StudentExamAttemptDoesNotExistsException(err_msg) # make sure the the attempt belongs to the calling user_id if attempt['user']['id'] != request.user.id: err_msg = ('Attempted to access attempt_id {attempt_id} but ' 'does not have access to it.'.format( attempt_id=attempt_id)) raise ProctoredExamPermissionDenied(err_msg) action = request.data.get('action') if action == 'stop': exam_attempt_id = stop_exam_attempt( exam_id=attempt['proctored_exam']['id'], user_id=request.user.id) elif action == 'start': exam_attempt_id = start_exam_attempt( exam_id=attempt['proctored_exam']['id'], user_id=request.user.id) elif action == 'submit': exam_attempt_id = update_attempt_status( attempt['proctored_exam']['id'], request.user.id, ProctoredExamStudentAttemptStatus.submitted) elif action == 'click_download_software': exam_attempt_id = update_attempt_status( attempt['proctored_exam']['id'], request.user.id, ProctoredExamStudentAttemptStatus.download_software_clicked ) elif action == 'decline': exam_attempt_id = update_attempt_status( attempt['proctored_exam']['id'], request.user.id, ProctoredExamStudentAttemptStatus.declined) return Response({"exam_attempt_id": exam_attempt_id}) except ProctoredBaseException, ex: LOG.exception(ex) return Response(status=status.HTTP_400_BAD_REQUEST, data={"detail": str(ex)})
def test_correct_edx_support_url(self, status): exam_attempt = self._create_started_exam_attempt() update_attempt_status(exam_attempt.proctored_exam_id, self.user.id, status) # Verify the edX support URL contact_url = 'http://{site_name}/support/contact_us'.format( site_name=SITE_NAME) actual_body = self._normalize_whitespace(mail.outbox[0].body) self.assertIn( u'You can also reach Open edX Support at ' u'<a href="{contact_url}"> ' u'{contact_url} </a>'.format(contact_url=contact_url), actual_body)
def test_proctoring_escalation_email_exceptions(self, error): """ Test that when an error is raised when trying to retrieve a proctoring escalation email, it sets `proctoring_escalation_email` to None and link to support is used instead """ instructor_service = get_runtime_service('instructor') instructor_service.mock_proctoring_escalation_email_error(error) exam_attempt = self._create_started_exam_attempt() update_attempt_status(exam_attempt.id, ProctoredExamStudentAttemptStatus.verified) actual_body = self._normalize_whitespace(mail.outbox[0].body) self.assertIn('support/contact_us', actual_body)
def start_exam_callback(request, attempt_code): # pylint: disable=unused-argument """ A callback endpoint which is called when SoftwareSecure completes the proctoring setup and the exam should be started. This is an authenticated endpoint and the attempt_code is passed in as part of the URL path IMPORTANT: This is an unauthenticated endpoint, so be VERY CAREFUL about extending this endpoint """ attempt = get_exam_attempt_by_code(attempt_code) if not attempt: log.warning('attempt_code={attempt_code} cannot be found.'.format( attempt_code=attempt_code)) return HttpResponse( content='You have entered an exam code that is not valid.', status=404) attempt_status = attempt['status'] if attempt_status in [ ProctoredExamStudentAttemptStatus.created, ProctoredExamStudentAttemptStatus.download_software_clicked ]: mark_exam_attempt_as_ready(attempt['id']) # if a user attempts to re-enter an exam that has not yet been submitted, submit the exam if ProctoredExamStudentAttemptStatus.is_in_progress_status(attempt_status): update_attempt_status(attempt['id'], ProctoredExamStudentAttemptStatus.submitted) else: log.warning( 'Attempted to enter proctored exam attempt_id={attempt_id} when status={attempt_status}' .format( attempt_id=attempt['id'], attempt_status=attempt_status, )) course_id = attempt['proctored_exam']['course_id'] content_id = attempt['proctored_exam']['content_id'] exam_url = '' try: exam_url = reverse('jump_to', args=[course_id, content_id]) except NoReverseMatch: log.exception( "BLOCKING ERROR: Can't find course info url for course_id=%s", course_id) response = HttpResponseRedirect(exam_url) response.set_signed_cookie('exam', attempt['attempt_code']) return response
def finish_review_workflow(sender, instance, signal, **kwargs): # pylint: disable=unused-argument """ Updates the attempt status based on the review status """ review = instance attempt_obj, is_archived = locate_attempt_by_attempt_code( review.attempt_code) attempt = api.ProctoredExamStudentAttemptSerializer(attempt_obj).data backend = get_backend_provider(attempt['proctored_exam']) # we could have gotten a review for an archived attempt # this should *not* cause an update in our credit # eligibility table if review.is_passing: attempt_status = ProctoredExamStudentAttemptStatus.verified elif review.review_status == SoftwareSecureReviewStatus.not_reviewed: attempt_status = ProctoredExamStudentAttemptStatus.error elif review.reviewed_by or not constants.REQUIRE_FAILURE_SECOND_REVIEWS: # reviews from the django admin have a reviewer set. They should be allowed to # reject an attempt attempt_status = ProctoredExamStudentAttemptStatus.rejected elif backend and backend.supports_onboarding and attempt[ 'is_sample_attempt']: attempt_status = ProctoredExamStudentAttemptStatus.rejected else: # if we are not allowed to store 'rejected' on this # code path, then put status into 'second_review_required' attempt_status = ProctoredExamStudentAttemptStatus.second_review_required if not is_archived: # updating attempt status will trigger workflow # (i.e. updating credit eligibility table) # archived attempts should not trigger the workflow api.update_attempt_status(attempt['proctored_exam']['id'], attempt['user']['id'], attempt_status, raise_if_not_found=False, update_attributable_to=review.reviewed_by or None) # emit an event for 'review_received' data = { 'review_attempt_code': review.attempt_code, 'review_status': review.review_status, } emit_event(attempt['proctored_exam'], 'review_received', attempt=attempt, override_data=data)
def put(self, request, attempt_code): """ HTTP POST handler. To stop an exam. """ try: attempt = get_exam_attempt_by_code(attempt_code) if not attempt: err_msg = ( 'Attempted to access attempt_code {attempt_code} but ' 'it does not exist.'.format( attempt_code=attempt_code ) ) raise StudentExamAttemptDoesNotExistsException(err_msg) action = request.DATA.get('action') user_id = request.DATA.get('user_id') exam_id = attempt['proctored_exam']['id'] if action and action == 'submit': exam_attempt_id = update_attempt_status( exam_id, user_id, ProctoredExamStudentAttemptStatus.submitted ) if action and action == 'fail': exam_attempt_id = update_attempt_status( exam_id, user_id, ProctoredExamStudentAttemptStatus.error ) if action and action == 'decline': exam_attempt_id = update_attempt_status( exam_id, user_id, ProctoredExamStudentAttemptStatus.timed_out ) return Response({"exam_attempt_id": exam_attempt_id}) except ProctoredBaseException, ex: LOG.exception(ex) return Response( status=status.HTTP_400_BAD_REQUEST, data={"detail": str(ex)} )
def test_escalation_email_included(self, status): """ Test that verified and rejected emails include a proctoring escalation email if given. """ instructor_service = get_runtime_service('instructor') mock_escalation_email = '*****@*****.**' instructor_service.mock_proctoring_escalation_email( mock_escalation_email) exam_attempt = self._create_started_exam_attempt() update_attempt_status(exam_attempt.id, status) actual_body = self._normalize_whitespace(mail.outbox[0].body) self.assertIn(mock_escalation_email, actual_body) self.assertNotIn('support/contact_us', actual_body)
def test_email_template_select(self, status, template_name, select_template_mock): """ Assert that we search for the correct email templates, including any backend specific overriding templates and the base template. """ exam_attempt = self._create_started_exam_attempt() update_attempt_status(exam_attempt.id, status) expected_args = [ 'emails/proctoring/{backend}/{template_name}'.format( backend=exam_attempt.proctored_exam.backend, template_name=template_name), 'emails/{template_name}'.format(template_name=template_name) ] select_template_mock.assert_called_once_with(expected_args)
def on_review_saved(self, review, allow_rejects=False): # pylint: disable=arguments-differ """ called when a review has been save - either through API (on_review_callback) or via Django Admin panel in order to trigger any workflow associated with proctoring review results """ (attempt_obj, is_archived_attempt) = locate_attempt_by_attempt_code(review.attempt_code) if not attempt_obj: # This should not happen, but it is logged in the help # method return if is_archived_attempt: # we don't trigger workflow on reviews on archived attempts err_msg = ( 'Got on_review_save() callback for an archived attempt with ' 'attempt_code {attempt_code}. Will not trigger workflow...'.format( attempt_code=review.attempt_code ) ) log.warn(err_msg) return # only 'Clean' and 'Rules Violation' count as passing status = ( ProctoredExamStudentAttemptStatus.verified if review.review_status in self.passing_review_status else ( # if we are not allowed to store 'rejected' on this # code path, then put status into 'second_review_required' ProctoredExamStudentAttemptStatus.rejected if allow_rejects else ProctoredExamStudentAttemptStatus.second_review_required ) ) # updating attempt status will trigger workflow # (i.e. updating credit eligibility table) from edx_proctoring.api import update_attempt_status update_attempt_status( attempt_obj.proctored_exam_id, attempt_obj.user_id, status )
def on_review_saved(self, review, allow_rejects=False): # pylint: disable=arguments-differ """ called when a review has been save - either through API (on_review_callback) or via Django Admin panel in order to trigger any workflow associated with proctoring review results """ (attempt_obj, is_archived_attempt) = locate_attempt_by_attempt_code(review.attempt_code) if not attempt_obj: # This should not happen, but it is logged in the help # method return if is_archived_attempt: # we don't trigger workflow on reviews on archived attempts err_msg = ( 'Got on_review_save() callback for an archived attempt with ' 'attempt_code {attempt_code}. Will not trigger workflow...'.format( attempt_code=review.attempt_code ) ) log.warn(err_msg) return # only 'Clean' and 'Rules Violation' count as passing status = ( ProctoredExamStudentAttemptStatus.verified if review.review_status in self.passing_review_status else ( # if we are not allowed to store 'rejected' on this # code path, then put status into 'second_review_required' ProctoredExamStudentAttemptStatus.rejected if allow_rejects else ProctoredExamStudentAttemptStatus.second_review_required ) ) # updating attempt status will trigger workflow # (i.e. updating credit eligibility table) from edx_proctoring.api import update_attempt_status update_attempt_status( attempt_obj.proctored_exam_id, attempt_obj.user_id, status )
def put(self, request, attempt_id): """ HTTP POST handler. To stop an exam. """ try: attempt = get_exam_attempt_by_id(attempt_id) if not attempt: err_msg = "Attempted to access attempt_id {attempt_id} but " "it does not exist.".format( attempt_id=attempt_id ) raise StudentExamAttemptDoesNotExistsException(err_msg) # make sure the the attempt belongs to the calling user_id if attempt["user"]["id"] != request.user.id: err_msg = "Attempted to access attempt_id {attempt_id} but " "does not have access to it.".format( attempt_id=attempt_id ) raise ProctoredExamPermissionDenied(err_msg) action = request.data.get("action") if action == "stop": exam_attempt_id = stop_exam_attempt(exam_id=attempt["proctored_exam"]["id"], user_id=request.user.id) elif action == "start": exam_attempt_id = start_exam_attempt(exam_id=attempt["proctored_exam"]["id"], user_id=request.user.id) elif action == "submit": exam_attempt_id = update_attempt_status( attempt["proctored_exam"]["id"], request.user.id, ProctoredExamStudentAttemptStatus.submitted ) elif action == "click_download_software": exam_attempt_id = update_attempt_status( attempt["proctored_exam"]["id"], request.user.id, ProctoredExamStudentAttemptStatus.download_software_clicked, ) elif action == "decline": exam_attempt_id = update_attempt_status( attempt["proctored_exam"]["id"], request.user.id, ProctoredExamStudentAttemptStatus.declined ) return Response({"exam_attempt_id": exam_attempt_id}) except ProctoredBaseException, ex: LOG.exception(ex) return Response(status=status.HTTP_400_BAD_REQUEST, data={"detail": str(ex)})
def test_send_email(self, status): """ Assert that email is sent on the following statuses of proctoring attempt. """ exam_attempt = self._create_started_exam_attempt() credit_state = get_runtime_service('credit').get_credit_state( self.user_id, self.course_id) update_attempt_status(exam_attempt.proctored_exam_id, self.user.id, status) self.assertEquals(len(mail.outbox), 1) self.assertIn(self.proctored_exam_email_subject, mail.outbox[0].subject) self.assertIn(self.proctored_exam_email_body, mail.outbox[0].body) self.assertIn( ProctoredExamStudentAttemptStatus.get_status_alias(status), mail.outbox[0].body) self.assertIn(credit_state['course_name'], mail.outbox[0].body)
def setup_proctored_exam(self, block, attempt_status, user_id): """ Test helper to configure the given block as a proctored exam. """ exam_id = create_exam( course_id=unicode(block.location.course_key), content_id=unicode(block.location), exam_name='foo', time_limit_mins=10, is_proctored=True, is_practice_exam=block.is_practice_exam, ) set_runtime_service('credit', MockCreditService(enrollment_mode='verified')) create_exam_attempt(exam_id, user_id, taking_as_proctored=True) update_attempt_status(exam_id, user_id, attempt_status)
def test_get_studentview_submitted_status(self): """ Test for get_student_view proctored exam which has been submitted. """ exam_attempt = self._create_started_exam_attempt() exam_attempt.status = ProctoredExamStudentAttemptStatus.submitted exam_attempt.save() rendered_response = self.render_proctored_exam() self.assertIn(self.proctored_exam_submitted_msg, rendered_response) # now make sure if this status transitions to 'second_review_required' # the student will still see a 'submitted' message update_attempt_status( exam_attempt.proctored_exam_id, exam_attempt.user_id, ProctoredExamStudentAttemptStatus.second_review_required) rendered_response = self.render_proctored_exam() self.assertIn(self.proctored_exam_submitted_msg, rendered_response)
def finish_review_workflow(sender, instance, signal, **kwargs): # pylint: disable=unused-argument """ Updates the attempt status based on the review status """ review = instance attempt_obj, is_archived = locate_attempt_by_attempt_code(review.attempt_code) attempt = api.ProctoredExamStudentAttemptSerializer(attempt_obj).data backend = get_backend_provider(attempt['proctored_exam']) # we could have gotten a review for an archived attempt # this should *not* cause an update in our credit # eligibility table if review.is_passing: attempt_status = ProctoredExamStudentAttemptStatus.verified elif review.review_status == SoftwareSecureReviewStatus.not_reviewed: attempt_status = ProctoredExamStudentAttemptStatus.error elif review.reviewed_by or not constants.REQUIRE_FAILURE_SECOND_REVIEWS: # reviews from the django admin have a reviewer set. They should be allowed to # reject an attempt attempt_status = ProctoredExamStudentAttemptStatus.rejected elif backend and backend.supports_onboarding and attempt['is_sample_attempt']: attempt_status = ProctoredExamStudentAttemptStatus.rejected else: # if we are not allowed to store 'rejected' on this # code path, then put status into 'second_review_required' attempt_status = ProctoredExamStudentAttemptStatus.second_review_required if not is_archived: # updating attempt status will trigger workflow # (i.e. updating credit eligibility table) # archived attempts should not trigger the workflow api.update_attempt_status( attempt['proctored_exam']['id'], attempt['user']['id'], attempt_status, raise_if_not_found=False ) # emit an event for 'review_received' data = { 'review_attempt_code': review.attempt_code, 'review_status': review.review_status, } emit_event(attempt['proctored_exam'], 'review_received', attempt=attempt, override_data=data)
def setup_proctored_exam(self, block, attempt_status, user_id): """ Test helper to configure the given block as a proctored exam. """ exam_id = create_exam( course_id=unicode(block.location.course_key), content_id=unicode(block.location), exam_name='foo', time_limit_mins=10, is_proctored=True, is_practice_exam=block.is_practice_exam, ) set_runtime_service( 'credit', MockCreditService(enrollment_mode='verified') ) create_exam_attempt(exam_id, user_id, taking_as_proctored=True) update_attempt_status(exam_id, user_id, attempt_status)
def test_get_studentview_submitted_status(self): """ Test for get_student_view proctored exam which has been submitted. """ exam_attempt = self._create_started_exam_attempt() exam_attempt.status = ProctoredExamStudentAttemptStatus.submitted exam_attempt.save() rendered_response = self.render_proctored_exam() self.assertIn(self.proctored_exam_submitted_msg, rendered_response) # now make sure if this status transitions to 'second_review_required' # the student will still see a 'submitted' message update_attempt_status( exam_attempt.proctored_exam_id, exam_attempt.user_id, ProctoredExamStudentAttemptStatus.second_review_required ) rendered_response = self.render_proctored_exam() self.assertIn(self.proctored_exam_submitted_msg, rendered_response)
def test_correct_edx_email(self, status, integration_specific_email,): exam_attempt = self._create_started_exam_attempt() test_backend = get_backend_provider(name='test') test_backend.integration_specific_email = integration_specific_email update_attempt_status( exam_attempt.proctored_exam_id, self.user.id, status ) # Verify the edX email expected_email = get_integration_specific_email(test_backend) actual_body = self._normalize_whitespace(mail.outbox[0].body) self.assertIn('contact Open edX support at ' '<a href="mailto:{email}?Subject=Proctored exam Test Exam in edx demo for user tester"> ' '{email} </a>'.format(email=expected_email), actual_body)
def get(self, request, attempt_id): """ HTTP GET Handler. Returns the status of the exam attempt. """ try: attempt = get_exam_attempt_by_id(attempt_id) if not attempt: err_msg = ( 'Attempted to access attempt_id {attempt_id} but ' 'it does not exist.'.format( attempt_id=attempt_id ) ) return Response( status=status.HTTP_400_BAD_REQUEST ) # make sure the the attempt belongs to the calling user_id if attempt['user']['id'] != request.user.id: err_msg = ( 'Attempted to access attempt_id {attempt_id} but ' 'does not have access to it.'.format( attempt_id=attempt_id ) ) raise ProctoredExamPermissionDenied(err_msg) # check if the last_poll_timestamp is not None # and if it is older than CLIENT_TIMEOUT # then attempt status should be marked as error. last_poll_timestamp = attempt['last_poll_timestamp'] if last_poll_timestamp is not None \ and (datetime.now(pytz.UTC) - last_poll_timestamp).total_seconds() > CLIENT_TIMEOUT: try: update_attempt_status( attempt['proctored_exam']['id'], attempt['user']['id'], ProctoredExamStudentAttemptStatus.error ) attempt['status'] = ProctoredExamStudentAttemptStatus.error except ProctoredExamIllegalStatusTransition: # don't transition a completed state to an error state pass # add in the computed time remaining as a helper to a client app time_remaining_seconds = get_time_remaining_for_attempt(attempt) attempt['time_remaining_seconds'] = time_remaining_seconds accessibility_time_string = _('you have {remaining_time} remaining').format( remaining_time=humanized_time(int(round(time_remaining_seconds / 60.0, 0)))) # special case if we are less than a minute, since we don't produce # text translations of granularity at the seconds range if time_remaining_seconds < 60: accessibility_time_string = _('you have less than a minute remaining') attempt['accessibility_time_string'] = accessibility_time_string return Response( data=attempt, status=status.HTTP_200_OK ) except ProctoredBaseException, ex: LOG.exception(ex) return Response( status=status.HTTP_400_BAD_REQUEST, data={"detail": str(ex)} )
def on_review_callback(self, payload): """ Called when the reviewing 3rd party service posts back the results Documentation on the data format can be found from SoftwareSecure's documentation named "Reviewer Data Transfer" """ log_msg = ( 'Received callback from SoftwareSecure with review data: {payload}'.format( payload=payload ) ) log.info(log_msg) # what we consider the external_id is SoftwareSecure's 'ssiRecordLocator' external_id = payload['examMetaData']['ssiRecordLocator'] # what we consider the attempt_code is SoftwareSecure's 'examCode' attempt_code = payload['examMetaData']['examCode'] # get the SoftwareSecure status on this attempt review_status = payload['reviewStatus'] bad_status = review_status not in [ 'Not Reviewed', 'Suspicious', 'Rules Violation', 'Clean' ] if bad_status: err_msg = ( 'Received unexpected reviewStatus field calue from payload. ' 'Was {review_status}.'.format(review_status=review_status) ) raise ProctoredExamBadReviewStatus(err_msg) # do a lookup on the attempt by examCode, and compare the # passed in ssiRecordLocator and make sure it matches # what we recorded as the external_id. We need to look in both # the attempt table as well as the archive table attempt_obj = ProctoredExamStudentAttempt.objects.get_exam_attempt_by_code(attempt_code) is_archived_attempt = False if not attempt_obj: # try archive table attempt_obj = ProctoredExamStudentAttemptHistory.get_exam_attempt_by_code(attempt_code) is_archived_attempt = True if not attempt_obj: # still can't find, error out err_msg = ( 'Could not locate attempt_code: {attempt_code}'.format(attempt_code=attempt_code) ) raise StudentExamAttemptDoesNotExistsException(err_msg) # then make sure we have the right external_id # note that SoftwareSecure might send a case insensitive # ssiRecordLocator than what it returned when we registered the # exam match = ( attempt_obj.external_id.lower() == external_id.lower() or settings.PROCTORING_SETTINGS.get('ALLOW_CALLBACK_SIMULATION', False) ) if not match: err_msg = ( 'Found attempt_code {attempt_code}, but the recorded external_id did not ' 'match the ssiRecordLocator that had been recorded previously. Has {existing} ' 'but received {received}!'.format( attempt_code=attempt_code, existing=attempt_obj.external_id, received=external_id ) ) raise ProctoredExamSuspiciousLookup(err_msg) # do we already have a review for this attempt?!? It should not be updated! review = ProctoredExamSoftwareSecureReview.get_review_by_attempt_code(attempt_code) if review: err_msg = ( 'We already have a review submitted from SoftwareSecure regarding ' 'attempt_code {attempt_code}. We do not allow for updates!'.format( attempt_code=attempt_code ) ) raise ProctoredExamReviewAlreadyExists(err_msg) # do some limited parsing of the JSON payload review_status = payload['reviewStatus'] video_review_link = payload['videoReviewLink'] # make a new record in the review table review = ProctoredExamSoftwareSecureReview( attempt_code=attempt_code, raw_data=json.dumps(payload), review_status=review_status, video_url=video_review_link, ) review.save() # go through and populate all of the specific comments for comment in payload.get('webCamComments', []): self._save_review_comment(review, comment) for comment in payload.get('desktopComments', []): self._save_review_comment(review, comment) # we could have gottent a review for an archived attempt # this should *not* cause an update in our credit # eligibility table if not is_archived_attempt: # update our attempt status, note we have to import api.py here because # api.py imports software_secure.py, so we'll get an import circular reference from edx_proctoring.api import update_attempt_status # only 'Clean' and 'Rules Violation' could as passing status = ( ProctoredExamStudentAttemptStatus.verified if review_status in ['Clean', 'Suspicious'] else ProctoredExamStudentAttemptStatus.rejected ) update_attempt_status( attempt_obj.proctored_exam_id, attempt_obj.user_id, status )
def get(self, request, attempt_id): """ HTTP GET Handler. Returns the status of the exam attempt. """ try: attempt = get_exam_attempt_by_id(attempt_id) if not attempt: err_msg = ( 'Attempted to access attempt_id {attempt_id} but ' 'it does not exist.'.format( attempt_id=attempt_id ) ) return Response( status=status.HTTP_400_BAD_REQUEST ) # make sure the the attempt belongs to the calling user_id if attempt['user']['id'] != request.user.id: err_msg = ( 'Attempted to access attempt_id {attempt_id} but ' 'does not have access to it.'.format( attempt_id=attempt_id ) ) raise ProctoredExamPermissionDenied(err_msg) # check if the last_poll_timestamp is not None # and if it is older than SOFTWARE_SECURE_CLIENT_TIMEOUT # then attempt status should be marked as error. last_poll_timestamp = attempt['last_poll_timestamp'] # if we never heard from the client, then we assume it is shut down attempt['client_has_shutdown'] = last_poll_timestamp is None if last_poll_timestamp is not None: # Let's pass along information if we think the SoftwareSecure has completed # a healthy shutdown which is when our attempt is in a 'submitted' status if attempt['status'] == ProctoredExamStudentAttemptStatus.submitted: attempt['client_has_shutdown'] = has_client_app_shutdown(attempt) else: # otherwise, let's see if the shutdown happened in error # e.g. a crash time_passed_since_last_poll = (datetime.now(pytz.UTC) - last_poll_timestamp).total_seconds() if time_passed_since_last_poll > constants.SOFTWARE_SECURE_CLIENT_TIMEOUT: try: update_attempt_status( attempt['proctored_exam']['id'], attempt['user']['id'], ProctoredExamStudentAttemptStatus.error ) attempt['status'] = ProctoredExamStudentAttemptStatus.error except ProctoredExamIllegalStatusTransition: # don't transition a completed state to an error state pass # add in the computed time remaining as a helper to a client app time_remaining_seconds = get_time_remaining_for_attempt(attempt) attempt['time_remaining_seconds'] = time_remaining_seconds accessibility_time_string = _('you have {remaining_time} remaining').format( remaining_time=humanized_time(int(round(time_remaining_seconds / 60.0, 0)))) # special case if we are less than a minute, since we don't produce # text translations of granularity at the seconds range if time_remaining_seconds < 60: accessibility_time_string = _('you have less than a minute remaining') attempt['accessibility_time_string'] = accessibility_time_string return Response( data=attempt, status=status.HTTP_200_OK ) except ProctoredBaseException, ex: LOG.exception(ex) return Response( status=status.HTTP_400_BAD_REQUEST, data={"detail": str(ex)} )
def put(self, request, attempt_id): """ HTTP POST handler. To stop an exam. """ attempt = get_exam_attempt_by_id(attempt_id) if not attempt: err_msg = ( 'Attempted to access attempt_id {attempt_id} but ' 'it does not exist.'.format( attempt_id=attempt_id ) ) raise StudentExamAttemptDoesNotExistsException(err_msg) # make sure the the attempt belongs to the calling user_id if attempt['user']['id'] != request.user.id: err_msg = ( 'Attempted to access attempt_id {attempt_id} but ' 'does not have access to it.'.format( attempt_id=attempt_id ) ) raise ProctoredExamPermissionDenied(err_msg) action = request.data.get('action') if action == 'stop': exam_attempt_id = stop_exam_attempt( exam_id=attempt['proctored_exam']['id'], user_id=request.user.id ) elif action == 'start': exam_attempt_id = start_exam_attempt( exam_id=attempt['proctored_exam']['id'], user_id=request.user.id ) elif action == 'submit': exam_attempt_id = update_attempt_status( attempt['proctored_exam']['id'], request.user.id, ProctoredExamStudentAttemptStatus.submitted ) elif action == 'click_download_software': exam_attempt_id = update_attempt_status( attempt['proctored_exam']['id'], request.user.id, ProctoredExamStudentAttemptStatus.download_software_clicked ) elif action == 'error': backend = attempt['proctored_exam']['backend'] waffle_name = PING_FAILURE_PASSTHROUGH_TEMPLATE.format(backend) should_block_user = not (backend and waffle.switch_is_active(waffle_name)) and ( not attempt['status'] == ProctoredExamStudentAttemptStatus.submitted ) if should_block_user: exam_attempt_id = update_attempt_status( attempt['proctored_exam']['id'], request.user.id, ProctoredExamStudentAttemptStatus.error ) else: exam_attempt_id = False LOG.warning(u'Browser JS reported problem with proctoring desktop ' u'application. Did block user: %s, for attempt: %s', should_block_user, attempt['id']) elif action == 'decline': exam_attempt_id = update_attempt_status( attempt['proctored_exam']['id'], request.user.id, ProctoredExamStudentAttemptStatus.declined ) data = {"exam_attempt_id": exam_attempt_id} return Response(data)