def test_psi_review_callback(self, psi_review_status, review_status, credit_requirement_status): """ Simulates callbacks from SoftwareSecure with various statuses """ test_payload = json.loads( create_test_review_payload( attempt_code=self.attempt['attempt_code'], external_id=self.attempt['external_id'], review_status=psi_review_status)) self.attempt['proctored_exam']['backend'] = 'software_secure' if review_status is None: with self.assertRaises(ProctoredExamBadReviewStatus): ProctoredExamReviewCallback().make_review( self.attempt, test_payload) else: ProctoredExamReviewCallback().make_review(self.attempt, test_payload) # make sure that what we have in the Database matches what we expect review = ProctoredExamSoftwareSecureReview.get_review_by_attempt_code( self.attempt['attempt_code']) self.assertIsNotNone(review) self.assertEqual(review.review_status, review_status) self.assertFalse(review.video_url) self.assertIsNotNone(review.raw_data) self.assertIsNone(review.reviewed_by) # now check the comments that were stored comments = ProctoredExamSoftwareSecureComment.objects.filter( review_id=review.id) self.assertEqual(len(comments), 6) # check that we got credit requirement set appropriately credit_service = get_runtime_service('credit') credit_status = credit_service.get_credit_state( self.user.id, 'foo/bar/baz') self.assertEqual( credit_status['credit_requirement_status'][0]['status'], credit_requirement_status) instructor_service = get_runtime_service('instructor') notifications = instructor_service.notifications if psi_review_status == SoftwareSecureReviewStatus.suspicious: # check to see whether the zendesk ticket was created self.assertEqual(len(notifications), 1) exam = self.attempt['proctored_exam'] review_url = 'http://testserver/edx_proctoring/v1/instructor/foo/bar/baz/1?attempt=testexternalid' self.assertEqual(notifications, [(exam['course_id'], exam['exam_name'], self.attempt['user']['username'], review.review_status, review_url)]) else: self.assertEqual(len(notifications), 0)
def test_psi_review_callback(self, psi_review_status, review_status, credit_requirement_status): """ Simulates callbacks from SoftwareSecure with various statuses """ test_payload = json.loads(create_test_review_payload( attempt_code=self.attempt['attempt_code'], external_id=self.attempt['external_id'], review_status=psi_review_status )) self.attempt['proctored_exam']['backend'] = 'software_secure' if review_status is None: with self.assertRaises(ProctoredExamBadReviewStatus): ProctoredExamReviewCallback().make_review(self.attempt, test_payload) else: ProctoredExamReviewCallback().make_review(self.attempt, test_payload) # make sure that what we have in the Database matches what we expect review = ProctoredExamSoftwareSecureReview.get_review_by_attempt_code(self.attempt['attempt_code']) self.assertIsNotNone(review) self.assertEqual(review.review_status, review_status) self.assertFalse(review.video_url) self.assertIsNotNone(review.raw_data) self.assertIsNone(review.reviewed_by) # now check the comments that were stored comments = ProctoredExamSoftwareSecureComment.objects.filter(review_id=review.id) self.assertEqual(len(comments), 6) # check that we got credit requirement set appropriately credit_service = get_runtime_service('credit') credit_status = credit_service.get_credit_state(self.user.id, 'foo/bar/baz') self.assertEqual( credit_status['credit_requirement_status'][0]['status'], credit_requirement_status ) instructor_service = get_runtime_service('instructor') notifications = instructor_service.notifications if psi_review_status == SoftwareSecureReviewStatus.suspicious: # check to see whether the zendesk ticket was created self.assertEqual(len(notifications), 1) exam = self.attempt['proctored_exam'] review_url = 'http://testserver/edx_proctoring/v1/instructor/foo/bar/baz/1?attempt=testexternalid' self.assertEqual(notifications, [(exam['course_id'], exam['exam_name'], self.attempt['user']['username'], review.review_status, review_url)]) else: self.assertEqual(len(notifications), 0)
def wrapped(request, *args, **kwargs): # pylint: disable=missing-docstring instructor_service = get_runtime_service('instructor') course_id = kwargs['course_id'] if 'course_id' in kwargs else None exam_id = request.data.get('exam_id', None) attempt_id = kwargs['attempt_id'] if 'attempt_id' in kwargs else None if request.user.is_staff: return func(request, *args, **kwargs) else: if course_id is None: if exam_id is not None: exam = ProctoredExam.get_exam_by_id(exam_id) course_id = exam.course_id elif attempt_id is not None: exam_attempt = ProctoredExamStudentAttempt.objects.get_exam_attempt_by_id(attempt_id) course_id = exam_attempt.proctored_exam.course_id else: response_message = _("could not determine the course_id") return Response( status=status.HTTP_403_FORBIDDEN, data={"detail": response_message} ) if instructor_service.is_course_staff(request.user, course_id): return func(request, *args, **kwargs) else: return Response( status=status.HTTP_403_FORBIDDEN, data={"detail": _("Must be a Staff User to Perform this request.")} )
def wrapped(request, *args, **kwargs): # pylint: disable=missing-docstring instructor_service = get_runtime_service('instructor') course_id = kwargs['course_id'] if 'course_id' in kwargs else None exam_id = request.data.get('exam_id', None) attempt_id = kwargs['attempt_id'] if 'attempt_id' in kwargs else None if request.user.is_staff: return func(request, *args, **kwargs) else: if course_id is None: if exam_id is not None: exam = ProctoredExam.get_exam_by_id(exam_id) course_id = exam.course_id elif attempt_id is not None: exam_attempt = ProctoredExamStudentAttempt.objects.get_exam_attempt_by_id( attempt_id) course_id = exam_attempt.proctored_exam.course_id else: response_message = _("could not determine the course_id") return Response(status=status.HTTP_403_FORBIDDEN, data={"detail": response_message}) if instructor_service.is_course_staff(request.user, course_id): return func(request, *args, **kwargs) else: return Response( status=status.HTTP_403_FORBIDDEN, data={ "detail": _("Must be a Staff User to Perform this request.") })
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 remove_exam_attempt(attempt_id): """ Removes an exam attempt given the attempt id. """ existing_attempt = ProctoredExamStudentAttempt.objects.get_exam_attempt_by_id( attempt_id) if not existing_attempt: err_msg = ('Cannot remove attempt for attempt_id = {attempt_id} ' 'because it does not exist!').format(attempt_id=attempt_id) raise StudentExamAttemptDoesNotExistsException(err_msg) username = existing_attempt.user.username user_id = existing_attempt.user.id course_id = existing_attempt.proctored_exam.course_id content_id = existing_attempt.proctored_exam.content_id to_status = existing_attempt.status log_msg = ('{attempt_code} - {username} ({email}) ' 'Removing exam attempt {attempt_id}'.format( attempt_id=attempt_id, attempt_code=existing_attempt.attempt_code, username=username, email=existing_attempt.user.email)) log.info(log_msg) existing_attempt.delete_exam_attempt() instructor_service = get_runtime_service('instructor') if instructor_service: instructor_service.delete_student_attempt(username, course_id, content_id)
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_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 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 remove_exam_attempt(attempt_id): """ Removes an exam attempt given the attempt id. """ log_msg = ( 'Removing exam attempt {attempt_id}'.format(attempt_id=attempt_id) ) log.info(log_msg) existing_attempt = ProctoredExamStudentAttempt.objects.get_exam_attempt_by_id(attempt_id) if not existing_attempt: err_msg = ( 'Cannot remove attempt for attempt_id = {attempt_id} ' 'because it does not exist!' ).format(attempt_id=attempt_id) raise StudentExamAttemptDoesNotExistsException(err_msg) username = existing_attempt.user.username course_id = existing_attempt.proctored_exam.course_id content_id = existing_attempt.proctored_exam.content_id existing_attempt.delete_exam_attempt() instructor_service = get_runtime_service('instructor') if instructor_service: instructor_service.delete_student_attempt(username, course_id, content_id)
def test_review_callback(self, review_status, credit_requirement_status): """ Simulates callbacks from SoftwareSecure with various statuses """ provider = get_backend_provider() exam_id = create_exam( course_id='foo/bar/baz', content_id='content', exam_name='Sample Exam', time_limit_mins=10, is_proctored=True ) # be sure to use the mocked out SoftwareSecure handlers with HTTMock(mock_response_content): attempt_id = create_exam_attempt( exam_id, self.user.id, taking_as_proctored=True ) attempt = get_exam_attempt_by_id(attempt_id) self.assertIsNotNone(attempt['external_id']) test_payload = Template(TEST_REVIEW_PAYLOAD).substitute( attempt_code=attempt['attempt_code'], external_id=attempt['external_id'] ) test_payload = test_payload.replace('Clean', review_status) provider.on_review_callback(json.loads(test_payload)) # make sure that what we have in the Database matches what we expect review = ProctoredExamSoftwareSecureReview.get_review_by_attempt_code(attempt['attempt_code']) self.assertIsNotNone(review) self.assertEqual(review.review_status, review_status) self.assertEqual( review.video_url, 'http://www.remoteproctor.com/AdminSite/Account/Reviewer/DirectLink-Generic.aspx?ID=foo' ) self.assertIsNotNone(review.raw_data) # now check the comments that were stored comments = ProctoredExamSoftwareSecureComment.objects.filter(review_id=review.id) self.assertEqual(len(comments), 6) # check that we got credit requirement set appropriately credit_service = get_runtime_service('credit') credit_status = credit_service.get_credit_state(self.user.id, 'foo/bar/baz') self.assertEqual( credit_status['credit_requirement_status'][0]['status'], credit_requirement_status )
def is_user_course_or_global_staff(user, course_id): """ Return whether a user is course staff for a given course, described by the course_id, or is global staff. """ instructor_service = get_runtime_service('instructor') return user.is_staff or instructor_service.is_course_staff(user, course_id)
def test_review_callback(self, review_status, credit_requirement_status): """ Simulates callbacks from SoftwareSecure with various statuses """ provider = get_backend_provider() exam_id = create_exam(course_id='foo/bar/baz', content_id='content', exam_name='Sample Exam', time_limit_mins=10, is_proctored=True) # be sure to use the mocked out SoftwareSecure handlers with HTTMock(mock_response_content): attempt_id = create_exam_attempt(exam_id, self.user.id, taking_as_proctored=True) attempt = get_exam_attempt_by_id(attempt_id) self.assertIsNotNone(attempt['external_id']) test_payload = Template(TEST_REVIEW_PAYLOAD).substitute( attempt_code=attempt['attempt_code'], external_id=attempt['external_id']) test_payload = test_payload.replace('Clean', review_status) provider.on_review_callback(json.loads(test_payload)) # make sure that what we have in the Database matches what we expect review = ProctoredExamSoftwareSecureReview.get_review_by_attempt_code( attempt['attempt_code']) self.assertIsNotNone(review) self.assertEqual(review.review_status, review_status) self.assertEqual( review.video_url, 'http://www.remoteproctor.com/AdminSite/Account/Reviewer/DirectLink-Generic.aspx?ID=foo' ) self.assertIsNotNone(review.raw_data) self.assertIsNone(review.reviewed_by) # now check the comments that were stored comments = ProctoredExamSoftwareSecureComment.objects.filter( review_id=review.id) self.assertEqual(len(comments), 6) # check that we got credit requirement set appropriately credit_service = get_runtime_service('credit') credit_status = credit_service.get_credit_state( self.user.id, 'foo/bar/baz') self.assertEqual( credit_status['credit_requirement_status'][0]['status'], credit_requirement_status)
def remove_exam_attempt(attempt_id): """ Removes an exam attempt given the attempt id. """ log_msg = ( 'Removing exam attempt {attempt_id}'.format(attempt_id=attempt_id) ) log.info(log_msg) existing_attempt = ProctoredExamStudentAttempt.objects.get_exam_attempt_by_id(attempt_id) if not existing_attempt: err_msg = ( 'Cannot remove attempt for attempt_id = {attempt_id} ' 'because it does not exist!' ).format(attempt_id=attempt_id) raise StudentExamAttemptDoesNotExistsException(err_msg) username = existing_attempt.user.username user_id = existing_attempt.user.id course_id = existing_attempt.proctored_exam.course_id content_id = existing_attempt.proctored_exam.content_id to_status = existing_attempt.status existing_attempt.delete_exam_attempt() instructor_service = get_runtime_service('instructor') if instructor_service: instructor_service.delete_student_attempt(username, course_id, content_id) # see if the status transition this changes credit requirement status if ProctoredExamStudentAttemptStatus.needs_credit_status_update(to_status): # trigger credit workflow, as needed credit_service = get_runtime_service('credit') credit_service.remove_credit_requirement_status( user_id=user_id, course_key_or_id=course_id, req_namespace=u'proctored_exam', req_name=content_id )
def _create_zendesk_ticket(self, review, serialized_exam_object, serialized_attempt_obj): """ Creates a Zendesk ticket for reviews with status listed in self.notify_support_for_status """ if review.review_status in self.notify_support_for_status: instructor_service = get_runtime_service('instructor') if instructor_service: instructor_service.send_support_notification( course_id=serialized_exam_object["course_id"], exam_name=serialized_exam_object["exam_name"], student_username=serialized_attempt_obj["user"]["username"], review_status=review.review_status )
def test_review_callback(self, review_status, credit_requirement_status): """ Simulates callbacks from SoftwareSecure with various statuses """ provider = get_backend_provider() exam_id = create_exam( course_id="foo/bar/baz", content_id="content", exam_name="Sample Exam", time_limit_mins=10, is_proctored=True, ) # be sure to use the mocked out SoftwareSecure handlers with HTTMock(mock_response_content): attempt_id = create_exam_attempt(exam_id, self.user.id, taking_as_proctored=True) attempt = get_exam_attempt_by_id(attempt_id) self.assertIsNotNone(attempt["external_id"]) test_payload = Template(TEST_REVIEW_PAYLOAD).substitute( attempt_code=attempt["attempt_code"], external_id=attempt["external_id"] ) test_payload = test_payload.replace("Clean", review_status) provider.on_review_callback(json.loads(test_payload)) # make sure that what we have in the Database matches what we expect review = ProctoredExamSoftwareSecureReview.get_review_by_attempt_code(attempt["attempt_code"]) self.assertIsNotNone(review) self.assertEqual(review.review_status, review_status) self.assertFalse(review.video_url) self.assertIsNotNone(review.raw_data) self.assertIsNone(review.reviewed_by) # now check the comments that were stored comments = ProctoredExamSoftwareSecureComment.objects.filter(review_id=review.id) self.assertEqual(len(comments), 6) # check that we got credit requirement set appropriately credit_service = get_runtime_service("credit") credit_status = credit_service.get_credit_state(self.user.id, "foo/bar/baz") self.assertEqual(credit_status["credit_requirement_status"][0]["status"], credit_requirement_status)
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 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_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 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.assertEquals(len(mail.outbox), 1) self.assertIn(self.proctored_exam_email_subject, mail.outbox[0].subject) self.assertIn(course_name, mail.outbox[0].subject) self.assertIn(self.proctored_exam_email_body, mail.outbox[0].body) self.assertIn( ProctoredExamStudentAttemptStatus.get_status_alias( ProctoredExamStudentAttemptStatus.submitted), mail.outbox[0].body) self.assertIn(credit_state['course_name'], mail.outbox[0].body)
} """ try: exam = get_exam_by_content_id(course_id, content_id) except ProctoredExamNotFoundException, ex: # this really shouldn't happen, but log it at least log.exception(ex) return None # check if the exam is not proctored if not exam['is_proctored']: return TIMED_EXAM_STATUS_SUMMARY_MAP['_default'] # let's check credit eligibility credit_service = get_runtime_service('credit') # practice exams always has an attempt status regardless of # eligibility if credit_service and not exam['is_practice_exam']: credit_state = credit_service.get_credit_state(user_id, unicode(course_id)) if not _check_credit_eligibility(credit_state): return None attempt = get_exam_attempt(exam['id'], user_id) status = attempt['status'] if attempt else ProctoredExamStudentAttemptStatus.eligible status_map = STATUS_SUMMARY_MAP if not exam['is_practice_exam'] else PRACTICE_STATUS_SUMMARY_MAP summary = None if status in status_map: summary = status_map[status]
def make_review(self, attempt, data, backend=None): """ Save the review and review comments """ attempt_code = attempt['attempt_code'] if not backend: backend = get_backend_provider(attempt['proctored_exam']) # this method should convert the payload into a normalized format backend_review = backend.on_review_callback(attempt, data) # do we already have a review for this attempt?!? We may not allow updates review = ProctoredExamSoftwareSecureReview.get_review_by_attempt_code(attempt_code) if review: if not constants.ALLOW_REVIEW_UPDATES: err_msg = ( 'We already have a review submitted regarding ' 'attempt_code {attempt_code}. We do not allow for updates!'.format( attempt_code=attempt_code ) ) raise ProctoredExamReviewAlreadyExists(err_msg) # we allow updates warn_msg = ( 'We already have a review submitted from our proctoring provider regarding ' 'attempt_code {attempt_code}. We have been configured to allow for ' 'updates and will continue...'.format( attempt_code=attempt_code ) ) LOG.warning(warn_msg) else: # this is first time we've received this attempt_code, so # make a new record in the review table review = ProctoredExamSoftwareSecureReview() # first, validate that the backend review status is valid ReviewStatus.validate(backend_review['status']) # For now, we'll convert the standard review status to the old # software secure review status. # In the future, the old data should be standardized. review.review_status = SoftwareSecureReviewStatus.from_standard_status.get(backend_review['status']) review.attempt_code = attempt_code review.raw_data = json.dumps(data) review.student_id = attempt['user']['id'] review.exam_id = attempt['proctored_exam']['id'] try: review.reviewed_by = get_user_model().objects.get(email=data['reviewed_by']) except (ObjectDoesNotExist, KeyError): review.reviewed_by = None # If the reviewing user is a user in the system (user may be None for automated reviews) and does # not have permission to submit a review, log a warning. course_id = attempt['proctored_exam']['course_id'] if review.reviewed_by is not None and not is_user_course_or_global_staff(review.reviewed_by, course_id): LOG.warning( 'User %(user)s does not have the required permissions to submit ' 'a review for attempt_code %(attempt_code)s.', {'user': review.reviewed_by, 'attempt_code': attempt_code} ) review.save() # go through and populate all of the specific comments for comment in backend_review.get('comments', []): comment = ProctoredExamSoftwareSecureComment( review=review, start_time=comment.get('start', 0), stop_time=comment.get('stop', 0), duration=comment.get('duration', 0), comment=comment['comment'], status=comment['status'] ) comment.save() if review.should_notify: instructor_service = 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, )
def create_exam_attempt(exam_id, user_id, taking_as_proctored=False): """ Creates an exam attempt for user_id against exam_id. There should only be one exam_attempt per user per exam. Multiple attempts by user will be archived in a separate table """ # for now the student is allowed the exam default exam = get_exam_by_id(exam_id) existing_attempt = ProctoredExamStudentAttempt.objects.get_exam_attempt(exam_id, user_id) if existing_attempt: log_msg = ( 'Creating exam attempt for exam_id {exam_id} for ' 'user_id {user_id} with taking as proctored = {taking_as_proctored}'.format( exam_id=exam_id, user_id=user_id, taking_as_proctored=taking_as_proctored ) ) log.info(log_msg) if existing_attempt.is_sample_attempt: # Archive the existing attempt by deleting it. existing_attempt.delete_exam_attempt() else: err_msg = ( 'Cannot create new exam attempt for exam_id = {exam_id} and ' 'user_id = {user_id} because it already exists!' ).format(exam_id=exam_id, user_id=user_id) raise StudentExamAttemptAlreadyExistsException(err_msg) allowed_time_limit_mins = exam['time_limit_mins'] # add in the allowed additional time allowance_extra_mins = ProctoredExamStudentAllowance.get_additional_time_granted(exam_id, user_id) if allowance_extra_mins: allowed_time_limit_mins += allowance_extra_mins attempt_code = unicode(uuid.uuid4()).upper() external_id = None review_policy = ProctoredExamReviewPolicy.get_review_policy_for_exam(exam_id) review_policy_exception = ProctoredExamStudentAllowance.get_review_policy_exception(exam_id, user_id) if taking_as_proctored: content_id = exam['content_id'].split('@')[-1] # get hash scheme = 'https' if getattr(settings, 'HTTPS', 'on') == 'on' else 'http' callback_url = '{scheme}://{hostname}{path}'.format( scheme=scheme, hostname=settings.SITE_NAME, path=reverse( 'jump_to_id', kwargs={'course_id': exam['course_id'], 'module_id': content_id} ) ) # get the name of the user, if the service is available full_name = None credit_service = get_runtime_service('credit') user = User.objects.get(pk=user_id) context = { 'time_limit_mins': allowed_time_limit_mins, 'attempt_code': attempt_code, 'is_sample_attempt': exam['is_practice_exam'], 'callback_url': callback_url, 'user_id': user_id, 'full_name': " ".join((user.first_name,user.last_name)), 'username': user.username, 'email': user.email } # see if there is an exam review policy for this exam # if so, then pass it into the provider if review_policy: context.update({ 'review_policy': review_policy.review_policy }) # see if there is a review policy exception for this *user* # exceptions are granted on a individual basis as an # allowance if review_policy_exception: context.update({ 'review_policy_exception': review_policy_exception }) # now call into the backend provider to register exam attempt provider_name = get_provider_name_by_course_id(exam['course_id']) external_id = get_backend_provider(provider_name).register_exam_attempt( exam, context=context, ) attempt = ProctoredExamStudentAttempt.create_exam_attempt( exam_id, user_id, '', # student name is TBD allowed_time_limit_mins, attempt_code, taking_as_proctored, exam['is_practice_exam'], external_id, review_policy_id=review_policy.id if review_policy else None, ) log_msg = ( '{attempt_code} - {username} ({email}) ' 'Created exam attempt ({attempt_id}) for exam_id {exam_id} for ' 'user_id {user_id} with taking as proctored = {taking_as_proctored} ' 'with allowed time limit minutes of {allowed_time_limit_mins}. ' 'external_id of {external_id}'.format( attempt_id=attempt.id, exam_id=exam_id, user_id=user_id, taking_as_proctored=taking_as_proctored, allowed_time_limit_mins=allowed_time_limit_mins, attempt_code=attempt_code, external_id=external_id, username=attempt.user.username, email=attempt.user.email ) ) log.info(log_msg) return attempt.id
def make_review(self, attempt, data, backend=None): """ Save the review and review comments """ attempt_code = attempt['attempt_code'] if not backend: backend = get_backend_provider(attempt['proctored_exam']) # this method should convert the payload into a normalized format backend_review = backend.on_review_callback(attempt, data) # do we already have a review for this attempt?!? We may not allow updates review = ProctoredExamSoftwareSecureReview.get_review_by_attempt_code(attempt_code) if review: if not constants.ALLOW_REVIEW_UPDATES: err_msg = ( u'We already have a review submitted regarding ' u'attempt_code {attempt_code}. We do not allow for updates!'.format( attempt_code=attempt_code ) ) raise ProctoredExamReviewAlreadyExists(err_msg) # we allow updates warn_msg = ( u'We already have a review submitted from our proctoring provider regarding ' u'attempt_code {attempt_code}. We have been configured to allow for ' u'updates and will continue...'.format( attempt_code=attempt_code ) ) LOG.warning(warn_msg) else: # this is first time we've received this attempt_code, so # make a new record in the review table review = ProctoredExamSoftwareSecureReview() # first, validate that the backend review status is valid ReviewStatus.validate(backend_review['status']) # For now, we'll convert the standard review status to the old # software secure review status. # In the future, the old data should be standardized. review.review_status = SoftwareSecureReviewStatus.from_standard_status.get(backend_review['status']) review.attempt_code = attempt_code review.raw_data = json.dumps(data) review.student_id = attempt['user']['id'] review.exam_id = attempt['proctored_exam']['id'] try: review.reviewed_by = get_user_model().objects.get(email=data['reviewed_by']) except (ObjectDoesNotExist, KeyError): review.reviewed_by = None # If the reviewing user is a user in the system (user may be None for automated reviews) and does # not have permission to submit a review, log a warning. course_id = attempt['proctored_exam']['course_id'] if review.reviewed_by is not None and not is_user_course_or_global_staff(review.reviewed_by, course_id): LOG.warning( u'User %(user)s does not have the required permissions to submit ' u'a review for attempt_code %(attempt_code)s.', {'user': review.reviewed_by, 'attempt_code': attempt_code} ) review.save() # go through and populate all of the specific comments for comment in backend_review.get('comments', []): comment = ProctoredExamSoftwareSecureComment( review=review, start_time=comment.get('start', 0), stop_time=comment.get('stop', 0), duration=comment.get('duration', 0), comment=comment['comment'], status=comment['status'] ) comment.save() if review.should_notify: instructor_service = 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, )
} """ try: exam = get_exam_by_content_id(course_id, content_id) except ProctoredExamNotFoundException, ex: # this really shouldn't happen, but log it at least log.exception(ex) return None # check if the exam is not proctored if not exam['is_proctored']: return TIMED_EXAM_STATUS_SUMMARY_MAP['_default'] # let's check credit eligibility credit_service = get_runtime_service('credit') # practice exams always has an attempt status regardless of # eligibility if credit_service and not exam['is_practice_exam']: credit_state = credit_service.get_credit_state(user_id, unicode(course_id)) if not _check_credit_eligibility(credit_state): return None attempt = get_exam_attempt(exam['id'], user_id) status = attempt[ 'status'] if attempt else ProctoredExamStudentAttemptStatus.eligible status_map = STATUS_SUMMARY_MAP if not exam[ 'is_practice_exam'] else PRACTICE_STATUS_SUMMARY_MAP
def create_exam_attempt(exam_id, user_id, taking_as_proctored=False): """ Creates an exam attempt for user_id against exam_id. There should only be one exam_attempt per user per exam. Multiple attempts by user will be archived in a separate table """ # for now the student is allowed the exam default exam = get_exam_by_id(exam_id) existing_attempt = ProctoredExamStudentAttempt.objects.get_exam_attempt( exam_id, user_id) if existing_attempt: log_msg = ( 'Creating exam attempt for exam_id {exam_id} for ' 'user_id {user_id} with taking as proctored = {taking_as_proctored}' .format(exam_id=exam_id, user_id=user_id, taking_as_proctored=taking_as_proctored)) log.info(log_msg) if existing_attempt.is_sample_attempt: # Archive the existing attempt by deleting it. existing_attempt.delete_exam_attempt() else: err_msg = ( 'Cannot create new exam attempt for exam_id = {exam_id} and ' 'user_id = {user_id} because it already exists!').format( exam_id=exam_id, user_id=user_id) raise StudentExamAttemptAlreadyExistsException(err_msg) allowed_time_limit_mins = exam['time_limit_mins'] # add in the allowed additional time allowance_extra_mins = ProctoredExamStudentAllowance.get_additional_time_granted( exam_id, user_id) if allowance_extra_mins: allowed_time_limit_mins += allowance_extra_mins attempt_code = unicode(uuid.uuid4()).upper() external_id = None review_policy = ProctoredExamReviewPolicy.get_review_policy_for_exam( exam_id) review_policy_exception = ProctoredExamStudentAllowance.get_review_policy_exception( exam_id, user_id) if taking_as_proctored: content_id = exam['content_id'].split('@')[-1] # get hash scheme = 'https' if getattr(settings, 'HTTPS', 'on') == 'on' else 'http' callback_url = '{scheme}://{hostname}{path}'.format( scheme=scheme, hostname=settings.SITE_NAME, path=reverse('jump_to_id', kwargs={ 'course_id': exam['course_id'], 'module_id': content_id })) # get the name of the user, if the service is available full_name = None credit_service = get_runtime_service('credit') user = User.objects.get(pk=user_id) context = { 'time_limit_mins': allowed_time_limit_mins, 'attempt_code': attempt_code, 'is_sample_attempt': exam['is_practice_exam'], 'callback_url': callback_url, 'user_id': user_id, 'full_name': " ".join((user.first_name, user.last_name)), 'username': user.username, 'email': user.email } # see if there is an exam review policy for this exam # if so, then pass it into the provider if review_policy: context.update({'review_policy': review_policy.review_policy}) # see if there is a review policy exception for this *user* # exceptions are granted on a individual basis as an # allowance if review_policy_exception: context.update( {'review_policy_exception': review_policy_exception}) # now call into the backend provider to register exam attempt provider_name = get_provider_name_by_course_id(exam['course_id']) external_id = get_backend_provider( provider_name).register_exam_attempt( exam, context=context, ) attempt = ProctoredExamStudentAttempt.create_exam_attempt( exam_id, user_id, '', # student name is TBD allowed_time_limit_mins, attempt_code, taking_as_proctored, exam['is_practice_exam'], external_id, review_policy_id=review_policy.id if review_policy else None, ) log_msg = ( '{attempt_code} - {username} ({email}) ' 'Created exam attempt ({attempt_id}) for exam_id {exam_id} for ' 'user_id {user_id} with taking as proctored = {taking_as_proctored} ' 'with allowed time limit minutes of {allowed_time_limit_mins}. ' 'external_id of {external_id}'.format( attempt_id=attempt.id, exam_id=exam_id, user_id=user_id, taking_as_proctored=taking_as_proctored, allowed_time_limit_mins=allowed_time_limit_mins, attempt_code=attempt_code, external_id=external_id, username=attempt.user.username, email=attempt.user.email)) log.info(log_msg) return attempt.id
def update_attempt_status(exam_id, user_id, to_status, raise_if_not_found=True, cascade_effects=True): """ Internal helper to handle state transitions of attempt status """ exam = get_exam_by_id(exam_id) provider_name = get_provider_name_by_course_id(exam['course_id']) proctoring_settings = get_proctoring_settings(provider_name) exam_attempt_obj = ProctoredExamStudentAttempt.objects.get_exam_attempt( exam_id, user_id) if exam_attempt_obj is None: if raise_if_not_found: raise StudentExamAttemptDoesNotExistsException( 'Error. Trying to look up an exam that does not exist.') else: return else: log_msg = ('{attempt_code} - {username} ({email}) ' 'Updating attempt status for exam_id {exam_id} ' 'for user_id {user_id} to status {to_status}'.format( exam_id=exam_id, user_id=user_id, to_status=to_status, attempt_code=exam_attempt_obj.attempt_code, username=exam_attempt_obj.user.username, email=exam_attempt_obj.user.email)) log.info(log_msg) timed_out_state = False if exam_attempt_obj.status == ProctoredExamStudentAttemptStatus.created: timed_out_state = True # In some configuration we may treat timeouts the same # as the user saying he/she wises to submit the exam alias_timeout = (to_status == ProctoredExamStudentAttemptStatus.timed_out and not proctoring_settings.get('ALLOW_TIMED_OUT_STATE', timed_out_state)) if alias_timeout: to_status = ProctoredExamStudentAttemptStatus.submitted # # don't allow state transitions from a completed state to an incomplete state # if a re-attempt is desired then the current attempt must be deleted # in_completed_status = ProctoredExamStudentAttemptStatus.is_completed_status( exam_attempt_obj.status) to_incompleted_status = ProctoredExamStudentAttemptStatus.is_incomplete_status( to_status) if in_completed_status and to_incompleted_status: err_msg = ( 'A status transition from {from_status} to {to_status} was attempted ' 'on exam_id {exam_id} for user_id {user_id}. This is not ' 'allowed!'.format(from_status=exam_attempt_obj.status, to_status=to_status, exam_id=exam_id, user_id=user_id)) raise ProctoredExamIllegalStatusTransition(err_msg) # special case logic, if we are in a completed status we shouldn't allow # for a transition to 'Error' state if in_completed_status and to_status == ProctoredExamStudentAttemptStatus.error: err_msg = ( 'A status transition from {from_status} to {to_status} was attempted ' 'on exam_id {exam_id} for user_id {user_id}. This is not ' 'allowed!'.format(from_status=exam_attempt_obj.status, to_status=to_status, exam_id=exam_id, user_id=user_id)) raise ProctoredExamIllegalStatusTransition(err_msg) if to_status == exam_attempt_obj.status: log_msg = ( '{attempt_code} - {username} ({email}) ' 'Try to change attempt status for exam_id {exam_id} for user_id ' '{user_id} to the same status. Rejected'.format( exam_id=exam_id, user_id=user_id, attempt_code=exam_attempt_obj.attempt_code, username=exam_attempt_obj.user.username, email=exam_attempt_obj.user.email)) log.info(log_msg) return exam_attempt_obj.id # OK, state transition is fine, we can proceed exam_attempt_obj.status = to_status exam_attempt_obj.save() # see if the status transition this changes credit requirement status if ProctoredExamStudentAttemptStatus.needs_credit_status_update(to_status): # trigger credit workflow, as needed credit_service = get_runtime_service('credit') exam = get_exam_by_id(exam_id) if to_status == ProctoredExamStudentAttemptStatus.verified: verification = 'satisfied' elif to_status == ProctoredExamStudentAttemptStatus.submitted: verification = 'submitted' else: verification = 'failed' log_msg = ('{attempt_code} - {username} ({email}) ' 'Calling set_credit_requirement_status for ' 'user_id {user_id} on {course_id} for ' 'content_id {content_id}. Status: {status}'.format( user_id=exam_attempt_obj.user_id, course_id=exam['course_id'], content_id=exam_attempt_obj.proctored_exam.content_id, status=verification, attempt_code=exam_attempt_obj.attempt_code, username=exam_attempt_obj.user.username, email=exam_attempt_obj.user.email)) log.info(log_msg) credit_service.set_credit_requirement_status( user_id=exam_attempt_obj.user_id, course_key_or_id=exam['course_id'], req_namespace='proctored_exam', req_name=exam_attempt_obj.proctored_exam.content_id, status=verification) if cascade_effects and ProctoredExamStudentAttemptStatus.is_a_cascadable_failure( to_status): if to_status == ProctoredExamStudentAttemptStatus.declined: # if user declines attempt, make sure we clear out the external_id and # taking_as_proctored fields exam_attempt_obj.taking_as_proctored = False exam_attempt_obj.external_id = None exam_attempt_obj.save() # some state transitions (namely to a rejected or declined status) # will mark other exams as declined because once we fail or decline # one exam all other (un-completed) proctored exams will be likewise # updated to reflect a declined status # get all other unattempted exams and mark also as declined _exams = ProctoredExam.get_all_exams_for_course( exam_attempt_obj.proctored_exam.course_id, active_only=True) # we just want other exams which are proctored and are not practice exams = [ exam for exam in _exams if (exam.content_id != exam_attempt_obj.proctored_exam.content_id and exam.is_proctored and not exam.is_practice_exam) ] for exam in exams: # see if there was an attempt on those other exams already attempt = get_exam_attempt(exam.id, user_id) if attempt and ProctoredExamStudentAttemptStatus.is_completed_status( attempt['status']): # don't touch any completed statuses # we won't revoke those continue if not attempt: create_exam_attempt(exam.id, user_id, taking_as_proctored=False) # update any new or existing status to declined update_attempt_status(exam.id, user_id, ProctoredExamStudentAttemptStatus.declined, cascade_effects=False) if to_status == ProctoredExamStudentAttemptStatus.submitted: # also mark the exam attempt completed_at timestamp # after we submit the attempt exam_attempt_obj.completed_at = datetime.now(pytz.UTC) exam_attempt_obj.save() # if we have transitioned to started and haven't set our # started_at timestamp, do so now add_start_time = (to_status == ProctoredExamStudentAttemptStatus.started and not exam_attempt_obj.started_at) if add_start_time: exam_attempt_obj.started_at = datetime.now(pytz.UTC) exam_attempt_obj.save() # email will be send when the exam is proctored and not practice exam # and the status is verified, submitted or rejected should_send_status_email = ( exam_attempt_obj.taking_as_proctored and not exam_attempt_obj.is_sample_attempt and ProctoredExamStudentAttemptStatus.needs_status_change_email( exam_attempt_obj.status)) if should_send_status_email: # trigger credit workflow, as needed credit_service = get_runtime_service('credit') # call service to get course name. credit_state = credit_service.get_credit_state( exam_attempt_obj.user_id, exam_attempt_obj.proctored_exam.course_id, #return_course_name=True ) send_proctoring_attempt_status_email( exam_attempt_obj, credit_state.get('course_name', _('your course'))) return exam_attempt_obj.id
def create_exam_attempt(exam_id, user_id, taking_as_proctored=False): """ Creates an exam attempt for user_id against exam_id. There should only be one exam_attempt per user per exam. Multiple attempts by user will be archived in a separate table """ # for now the student is allowed the exam default log_msg = ( 'Creating exam attempt for exam_id {exam_id} for ' 'user_id {user_id} with taking as proctored = {taking_as_proctored}'.format( exam_id=exam_id, user_id=user_id, taking_as_proctored=taking_as_proctored ) ) log.info(log_msg) exam = get_exam_by_id(exam_id) existing_attempt = ProctoredExamStudentAttempt.objects.get_exam_attempt(exam_id, user_id) if existing_attempt: if existing_attempt.is_sample_attempt: # Archive the existing attempt by deleting it. existing_attempt.delete_exam_attempt() else: err_msg = ( 'Cannot create new exam attempt for exam_id = {exam_id} and ' 'user_id = {user_id} because it already exists!' ).format(exam_id=exam_id, user_id=user_id) raise StudentExamAttemptAlreadyExistsException(err_msg) allowed_time_limit_mins = exam['time_limit_mins'] # add in the allowed additional time allowance_extra_mins = ProctoredExamStudentAllowance.get_additional_time_granted(exam_id, user_id) if allowance_extra_mins: allowed_time_limit_mins += allowance_extra_mins attempt_code = unicode(uuid.uuid4()).upper() external_id = None review_policy = ProctoredExamReviewPolicy.get_review_policy_for_exam(exam_id) review_policy_exception = ProctoredExamStudentAllowance.get_review_policy_exception(exam_id, user_id) if taking_as_proctored: scheme = 'https' if getattr(settings, 'HTTPS', 'on') == 'on' else 'http' callback_url = '{scheme}://{hostname}{path}'.format( scheme=scheme, hostname=settings.SITE_NAME, path=reverse( 'edx_proctoring.anonymous.proctoring_launch_callback.start_exam', args=[attempt_code] ) ) # get the name of the user, if the service is available full_name = None credit_service = get_runtime_service('credit') if credit_service: credit_state = credit_service.get_credit_state(user_id, exam['course_id']) full_name = credit_state['profile_fullname'] context = { 'time_limit_mins': allowed_time_limit_mins, 'attempt_code': attempt_code, 'is_sample_attempt': exam['is_practice_exam'], 'callback_url': callback_url, 'full_name': full_name, } # see if there is an exam review policy for this exam # if so, then pass it into the provider if review_policy: context.update({ 'review_policy': review_policy.review_policy }) # see if there is a review policy exception for this *user* # exceptions are granted on a individual basis as an # allowance if review_policy_exception: context.update({ 'review_policy_exception': review_policy_exception }) # now call into the backend provider to register exam attempt course_id = exam['course_id'] course_key = CourseKey.from_string(course_id) course = modulestore().get_course(course_key) provider_name = course.proctoring_service external_id = get_backend_provider(provider_name).register_exam_attempt( exam, context=context, ) attempt = ProctoredExamStudentAttempt.create_exam_attempt( exam_id, user_id, '', # student name is TBD allowed_time_limit_mins, attempt_code, taking_as_proctored, exam['is_practice_exam'], external_id, review_policy_id=review_policy.id if review_policy else None, ) log_msg = ( 'Created exam attempt ({attempt_id}) for exam_id {exam_id} for ' 'user_id {user_id} with taking as proctored = {taking_as_proctored} ' 'with allowed time limit minutes of {allowed_time_limit_mins}. ' 'Attempt_code {attempt_code} was generated which has a ' 'external_id of {external_id}'.format( attempt_id=attempt.id, exam_id=exam_id, user_id=user_id, taking_as_proctored=taking_as_proctored, allowed_time_limit_mins=allowed_time_limit_mins, attempt_code=attempt_code, external_id=external_id ) ) log.info(log_msg) return attempt.id
def update_attempt_status(exam_id, user_id, to_status, raise_if_not_found=True, cascade_effects=True): """ Internal helper to handle state transitions of attempt status """ log_msg = ( 'Updating attempt status for exam_id {exam_id} ' 'for user_id {user_id} to status {to_status}'.format( exam_id=exam_id, user_id=user_id, to_status=to_status ) ) log.info(log_msg) # In some configuration we may treat timeouts the same # as the user saying he/she wises to submit the exam alias_timeout = ( to_status == ProctoredExamStudentAttemptStatus.timed_out and not settings.PROCTORING_SETTINGS.get('ALLOW_TIMED_OUT_STATE', False) ) if alias_timeout: to_status = ProctoredExamStudentAttemptStatus.submitted exam_attempt_obj = ProctoredExamStudentAttempt.objects.get_exam_attempt(exam_id, user_id) if exam_attempt_obj is None: if raise_if_not_found: raise StudentExamAttemptDoesNotExistsException('Error. Trying to look up an exam that does not exist.') else: return # # don't allow state transitions from a completed state to an incomplete state # if a re-attempt is desired then the current attempt must be deleted # in_completed_status = ProctoredExamStudentAttemptStatus.is_completed_status(exam_attempt_obj.status) to_incompleted_status = ProctoredExamStudentAttemptStatus.is_incomplete_status(to_status) if in_completed_status and to_incompleted_status: err_msg = ( 'A status transition from {from_status} to {to_status} was attempted ' 'on exam_id {exam_id} for user_id {user_id}. This is not ' 'allowed!'.format( from_status=exam_attempt_obj.status, to_status=to_status, exam_id=exam_id, user_id=user_id ) ) raise ProctoredExamIllegalStatusTransition(err_msg) # special case logic, if we are in a completed status we shouldn't allow # for a transition to 'Error' state if in_completed_status and to_status == ProctoredExamStudentAttemptStatus.error: err_msg = ( 'A status transition from {from_status} to {to_status} was attempted ' 'on exam_id {exam_id} for user_id {user_id}. This is not ' 'allowed!'.format( from_status=exam_attempt_obj.status, to_status=to_status, exam_id=exam_id, user_id=user_id ) ) raise ProctoredExamIllegalStatusTransition(err_msg) # OK, state transition is fine, we can proceed exam_attempt_obj.status = to_status exam_attempt_obj.save() # see if the status transition this changes credit requirement status if ProctoredExamStudentAttemptStatus.needs_credit_status_update(to_status): # trigger credit workflow, as needed credit_service = get_runtime_service('credit') exam = get_exam_by_id(exam_id) if to_status == ProctoredExamStudentAttemptStatus.verified: verification = 'satisfied' elif to_status == ProctoredExamStudentAttemptStatus.submitted: verification = 'submitted' else: verification = 'failed' log_msg = ( 'Calling set_credit_requirement_status for ' 'user_id {user_id} on {course_id} for ' 'content_id {content_id}. Status: {status}'.format( user_id=exam_attempt_obj.user_id, course_id=exam['course_id'], content_id=exam_attempt_obj.proctored_exam.content_id, status=verification ) ) log.info(log_msg) credit_service.set_credit_requirement_status( user_id=exam_attempt_obj.user_id, course_key_or_id=exam['course_id'], req_namespace='proctored_exam', req_name=exam_attempt_obj.proctored_exam.content_id, status=verification ) if cascade_effects and ProctoredExamStudentAttemptStatus.is_a_cascadable_failure(to_status): if to_status == ProctoredExamStudentAttemptStatus.declined: # if user declines attempt, make sure we clear out the external_id and # taking_as_proctored fields exam_attempt_obj.taking_as_proctored = False exam_attempt_obj.external_id = None exam_attempt_obj.save() # some state transitions (namely to a rejected or declined status) # will mark other exams as declined because once we fail or decline # one exam all other (un-completed) proctored exams will be likewise # updated to reflect a declined status # get all other unattempted exams and mark also as declined _exams = ProctoredExam.get_all_exams_for_course( exam_attempt_obj.proctored_exam.course_id, active_only=True ) # we just want other exams which are proctored and are not practice exams = [ exam for exam in _exams if ( exam.content_id != exam_attempt_obj.proctored_exam.content_id and exam.is_proctored and not exam.is_practice_exam ) ] for exam in exams: # see if there was an attempt on those other exams already attempt = get_exam_attempt(exam.id, user_id) if attempt and ProctoredExamStudentAttemptStatus.is_completed_status(attempt['status']): # don't touch any completed statuses # we won't revoke those continue if not attempt: create_exam_attempt(exam.id, user_id, taking_as_proctored=False) # update any new or existing status to declined update_attempt_status( exam.id, user_id, ProctoredExamStudentAttemptStatus.declined, cascade_effects=False ) if to_status == ProctoredExamStudentAttemptStatus.submitted: # also mark the exam attempt completed_at timestamp # after we submit the attempt exam_attempt_obj.completed_at = datetime.now(pytz.UTC) exam_attempt_obj.save() # if we have transitioned to started and haven't set our # started_at timestamp, do so now add_start_time = ( to_status == ProctoredExamStudentAttemptStatus.started and not exam_attempt_obj.started_at ) if add_start_time: exam_attempt_obj.started_at = datetime.now(pytz.UTC) exam_attempt_obj.save() # email will be send when the exam is proctored and not practice exam # and the status is verified, submitted or rejected should_send_status_email = ( exam_attempt_obj.taking_as_proctored and not exam_attempt_obj.is_sample_attempt and ProctoredExamStudentAttemptStatus.needs_status_change_email(exam_attempt_obj.status) ) if should_send_status_email: # trigger credit workflow, as needed credit_service = get_runtime_service('credit') # call service to get course name. credit_state = credit_service.get_credit_state( exam_attempt_obj.user_id, exam_attempt_obj.proctored_exam.course_id, return_course_name=True ) send_proctoring_attempt_status_email( exam_attempt_obj, credit_state.get('course_name', _('your course')) ) return exam_attempt_obj.id