def test_status_reviewed_by_field(self): """ Test that `reviewed_by` field of Review model is correctly assigned to None or to a User object. """ # no reviewed_by field test_payload = self.get_review_payload(ReviewStatus.suspicious) # submit a Suspicious review payload ProctoredExamReviewCallback().make_review(self.attempt, test_payload) review = ProctoredExamSoftwareSecureReview.objects.get( attempt_code=self.attempt['attempt_code']) self.assertIsNone(review.reviewed_by) # reviewed_by field with no corresponding User object reviewed_by_email = '*****@*****.**' test_payload['reviewed_by'] = reviewed_by_email # submit a Suspicious review payload ProctoredExamReviewCallback().make_review(self.attempt, test_payload) review = ProctoredExamSoftwareSecureReview.objects.get( attempt_code=self.attempt['attempt_code']) self.assertIsNone(review.reviewed_by) # reviewed_by field with corresponding User object user = User.objects.create(email=reviewed_by_email, username='******') # submit a Suspicious review payload ProctoredExamReviewCallback().make_review(self.attempt, test_payload) review = ProctoredExamSoftwareSecureReview.objects.get( attempt_code=self.attempt['attempt_code']) self.assertEqual(review.reviewed_by, user)
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_disallow_review_resubmission(self): """ Tests that an exception is raised if a review report is resubmitted for the same attempt """ test_payload = self.get_review_payload(ReviewStatus.passed) ProctoredExamReviewCallback().make_review(self.attempt, test_payload) # now call again with self.assertRaises(ProctoredExamReviewAlreadyExists): ProctoredExamReviewCallback().make_review(self.attempt, test_payload)
def test_failure_submission_rejected(self): """ Tests that a submission of a failed test and make sure that we don't automatically update the status to failure """ test_payload = self.get_review_payload(ReviewStatus.suspicious) allow_rejects = not constants.REQUIRE_FAILURE_SECOND_REVIEWS # submit a Suspicious review payload ProctoredExamReviewCallback().make_review(self.attempt, test_payload) # now look at the attempt and make sure it did not # transition to failure on the callback, # as we'll need a manual confirmation via Django Admin pages attempt = get_exam_attempt_by_id(self.attempt_id) self.assertNotEqual(attempt['status'], ProctoredExamStudentAttemptStatus.rejected) review = ProctoredExamSoftwareSecureReview.objects.get( attempt_code=self.attempt['attempt_code']) attempt = get_exam_attempt_by_id(self.attempt_id) # if we don't allow rejects to be stored in attempt status # then we should expect a 'second_review_required' status expected_status = ( ProctoredExamStudentAttemptStatus.rejected if allow_rejects else ProctoredExamStudentAttemptStatus.second_review_required) self.assertEqual(attempt['status'], expected_status) self.assertEqual(review.review_status, SoftwareSecureReviewStatus.suspicious)
def test_review_on_archived_attempt(self): """ Make sure we can process a review report for an attempt which has been archived """ test_payload = self.get_review_payload(ReviewStatus.passed) # now delete the attempt, which puts it into the archive table remove_exam_attempt(self.attempt_id, requesting_user=self.user) # now process the report 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, SoftwareSecureReviewStatus.clean) self.assertFalse(review.video_url) self.assertIsNotNone(review.raw_data) # now check the comments that were stored comments = ProctoredExamSoftwareSecureComment.objects.filter( review_id=review.id) self.assertEqual(len(comments), 3)
def test_bad_review_status(self): """ Tests that an exception is raised if the review has an invalid status """ test_payload = self.get_review_payload('bogus') with self.assertRaises(ProctoredExamBadReviewStatus): ProctoredExamReviewCallback().make_review(self.attempt, test_payload)
def test_allow_review_resubmission(self): """ Tests that an resubmission is allowed """ test_payload = self.get_review_payload(ReviewStatus.passed) ProctoredExamReviewCallback().make_review(self.attempt, test_payload) # make sure history table is empty records = ProctoredExamSoftwareSecureReviewHistory.objects.filter( attempt_code=self.attempt['attempt_code']) self.assertEqual(len(records), 0) # now call again, this will not throw exception test_payload['status'] = ReviewStatus.suspicious 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, SoftwareSecureReviewStatus.suspicious) self.assertFalse(review.video_url) self.assertIsNotNone(review.raw_data) # make sure history table is no longer empty records = ProctoredExamSoftwareSecureReviewHistory.objects.filter( attempt_code=self.attempt['attempt_code']) self.assertEqual(len(records), 1) self.assertEqual(records[0].review_status, 'Clean') # now try to delete the record and make sure it was archived review.delete() records = ProctoredExamSoftwareSecureReviewHistory.objects.filter( attempt_code=self.attempt['attempt_code']) self.assertEqual(len(records), 2) self.assertEqual(records[0].review_status, SoftwareSecureReviewStatus.clean) self.assertEqual(records[1].review_status, SoftwareSecureReviewStatus.suspicious)
def test_reviewed_by_is_course_or_global_staff(self, logger_mock): """ Test that a "reviewed_by" field of a review that corresponds to a user that is not a course staff or global staff causes a warning to be logged. Test that no warning is logged if a user is course staff or global staff. """ test_payload = self.get_review_payload() reviewed_by_email = '*****@*****.**' test_payload['reviewed_by'] = reviewed_by_email # reviewed_by field with corresponding User object user = User.objects.create(email=reviewed_by_email, username='******') log_format_string = ( 'user=%(user)s does not have the required permissions ' 'to submit a review for attempt_code=%(attempt_code)s.') log_format_dictionary = { 'user': user, 'attempt_code': self.attempt['attempt_code'], } with patch('edx_proctoring.views.is_user_course_or_global_staff', return_value=False): ProctoredExamReviewCallback().make_review(self.attempt, test_payload) # using assert_any_call instead of assert_called_with due to logging in analytics emit_event function logger_mock.assert_any_call(log_format_string, log_format_dictionary) logger_mock.reset_mock() with patch('edx_proctoring.views.is_user_course_or_global_staff', return_value=True): ProctoredExamReviewCallback().make_review(self.attempt, test_payload) # the mock API doesn't have a "assert_not_called_with" method # pylint: disable=wrong-assert-type self.assertFalse( call(log_format_string, log_format_dictionary) in logger_mock.call_args_list)
def test_failure_not_reviewed(self): """ Tests that a review which comes back as "not reviewed" transitions to an error state """ test_payload = self.get_review_payload(ReviewStatus.not_reviewed) ProctoredExamReviewCallback().make_review(self.attempt, test_payload) attempt = get_exam_attempt_by_id(self.attempt_id) self.assertEqual(attempt['status'], ProctoredExamStudentAttemptStatus.error)
def test_clean_status(self): """ Test that defining `passing_statuses` on the backend works """ test_backend = get_backend_provider(name='test') with patch.object(test_backend, 'passing_statuses', [SoftwareSecureReviewStatus.clean], create=True): test_payload = self.get_review_payload(status=ReviewStatus.violation) ProctoredExamReviewCallback().make_review(self.attempt, test_payload) attempt = get_exam_attempt_by_id(self.attempt_id) self.assertEqual(attempt['status'], ProctoredExamStudentAttemptStatus.second_review_required)
def test_update_archived_attempt(self): """ Test calling the interface point with an attempt_code that was archived """ test_payload = self.get_review_payload() # now process the report ProctoredExamReviewCallback().make_review(self.attempt, test_payload) # now look at the attempt and make sure it did not # transition to failure on the callback, # as we'll need a manual confirmation via Django Admin pages attempt = get_exam_attempt_by_id(self.attempt_id) self.assertEqual(attempt['status'], 'verified') # now delete the attempt, which puts it into the archive table remove_exam_attempt(self.attempt_id, requesting_user=self.user) review = ProctoredExamSoftwareSecureReview.objects.get( attempt_code=self.attempt['attempt_code']) # look at the attempt again, since it moved into Archived state # then it should still remain unchanged archived_attempt = ProctoredExamStudentAttemptHistory.objects.filter( attempt_code=self.attempt['attempt_code']).latest('created') self.assertEqual(archived_attempt.status, attempt['status']) self.assertEqual(review.review_status, SoftwareSecureReviewStatus.clean) # now we'll make another review for the archived attempt. It should NOT update the status test_payload = self.get_review_payload(ReviewStatus.suspicious) self.attempt['is_archived'] = True ProctoredExamReviewCallback().make_review(self.attempt, test_payload) attempt, is_archived = locate_attempt_by_attempt_code( self.attempt['attempt_code']) self.assertTrue(is_archived) self.assertEqual(attempt.status, 'verified')
def test_onboarding_attempts_no_second_review_necessary(self): """ Test that onboarding exams do not require a manual pass of review before they land in rejected """ exam_creation_params = self.exam_creation_params.copy() exam_creation_params.update({ 'is_practice_exam': True, 'content_id': 'onboarding_content', }) onboarding_exam_id = create_exam(**exam_creation_params) onboarding_attempt_id = create_exam_attempt( onboarding_exam_id, self.user.id, taking_as_proctored=True, ) onboarding_attempt = get_exam_attempt_by_id(onboarding_attempt_id) test_payload = self.get_review_payload(ReviewStatus.suspicious) ProctoredExamReviewCallback().make_review(onboarding_attempt, test_payload) onboarding_attempt = get_exam_attempt_by_id(onboarding_attempt_id) assert onboarding_attempt['status'] != ProctoredExamStudentAttemptStatus.second_review_required
def test_review_update_attempt_active_field(self): """ Make sure we update the is_active_attempt field when an attempt is archived """ test_payload = self.get_review_payload(ReviewStatus.passed) ProctoredExamReviewCallback().make_review(self.attempt, test_payload) review = ProctoredExamSoftwareSecureReview.get_review_by_attempt_code( self.attempt['attempt_code']) self.assertTrue(review.is_attempt_active) # now delete the attempt, which puts it into the archive table with mock.patch('edx_proctoring.api.update_attempt_status' ) as mock_update_status: remove_exam_attempt(self.attempt_id, requesting_user=self.user) # check that the field has been updated review = ProctoredExamSoftwareSecureReview.get_review_by_attempt_code( self.attempt['attempt_code']) self.assertFalse(review.is_attempt_active) # check that update_attempt_status has not been called, as the attempt has been archived mock_update_status.assert_not_called()