From 3189e02702db9289f2b987a269f581363f72b330 Mon Sep 17 00:00:00 2001 From: Ari Rizzitano Date: Mon, 14 Aug 2017 10:22:36 -0400 Subject: [PATCH] Adds an endpoint get_exam_violation_report EDUCATOR-411 add report generation endpoint + tests EDUCATOR-411 quality add include_practice_exams parameter bump version added review_status join exams with reviews, not comments quality + one more assertion select_related -> prefetch_related --- edx_proctoring/__init__.py | 2 +- edx_proctoring/api.py | 46 +++++++++ edx_proctoring/tests/test_api.py | 154 +++++++++++++++++++++++++++++++ 3 files changed, 201 insertions(+), 1 deletion(-) diff --git a/edx_proctoring/__init__.py b/edx_proctoring/__init__.py index e61bec4787c..bae2bcf0eac 100644 --- a/edx_proctoring/__init__.py +++ b/edx_proctoring/__init__.py @@ -4,6 +4,6 @@ from __future__ import absolute_import -__version__ = '1.1.0' +__version__ = '1.2.0' default_app_config = 'edx_proctoring.apps.EdxProctoringConfig' # pylint: disable=invalid-name diff --git a/edx_proctoring/api.py b/edx_proctoring/api.py index c9e51663074..4c09298dbc7 100644 --- a/edx_proctoring/api.py +++ b/edx_proctoring/api.py @@ -37,6 +37,7 @@ ProctoredExamStudentAttempt, ProctoredExamStudentAttemptStatus, ProctoredExamReviewPolicy, + ProctoredExamSoftwareSecureReview, ) from edx_proctoring.serializers import ( ProctoredExamSerializer, @@ -1947,3 +1948,48 @@ def get_student_view(user_id, course_id, content_id, if sub_view_func: return sub_view_func(exam, context, exam_id, user_id, course_id) return None + + +def get_exam_violation_report(course_id, include_practice_exams=False): + """ + Returns proctored exam attempts for the course id, including review details. + Violation status messages are aggregated as a list per attempt for each + violation type. + """ + attempts_by_code = { + attempt['attempt_code']: { + 'course_id': attempt['proctored_exam']['course_id'], + 'exam_name': attempt['proctored_exam']['exam_name'], + 'username': attempt['user']['username'], + 'email': attempt['user']['email'], + 'attempt_code': attempt['attempt_code'], + 'allowed_time_limit_mins': attempt['allowed_time_limit_mins'], + 'is_sample_attempt': attempt['is_sample_attempt'], + 'started_at': attempt['started_at'], + 'completed_at': attempt['completed_at'], + 'status': attempt['status'], + 'review_status': None + } for attempt in get_all_exam_attempts(course_id) + } + + reviews = ProctoredExamSoftwareSecureReview.objects.prefetch_related( + 'proctoredexamsoftwaresecurecomment_set' + ).filter( + exam__course_id=course_id, + exam__is_practice_exam=include_practice_exams + ) + + for review in reviews: + attempt_code = review.attempt_code + + attempts_by_code[attempt_code]['review_status'] = review.review_status + + for comment in review.proctoredexamsoftwaresecurecomment_set.all(): + comments_key = '{status} Comments'.format(status=comment.status) + + if comments_key not in attempts_by_code[attempt_code]: + attempts_by_code[attempt_code][comments_key] = [] + + attempts_by_code[attempt_code][comments_key].append(comment.comment) + + return sorted(attempts_by_code.values(), key=lambda a: a['exam_name']) diff --git a/edx_proctoring/tests/test_api.py b/edx_proctoring/tests/test_api.py index 97bd87f0377..066ef060d94 100644 --- a/edx_proctoring/tests/test_api.py +++ b/edx_proctoring/tests/test_api.py @@ -31,6 +31,7 @@ get_exam_attempt_by_id, remove_exam_attempt, get_all_exam_attempts, + get_exam_violation_report, get_filtered_exam_attempts, get_last_exam_completion_date, mark_exam_attempt_timeout, @@ -62,7 +63,10 @@ ) from edx_proctoring.models import ( ProctoredExam, + ProctoredExamSoftwareSecureReview, + ProctoredExamSoftwareSecureComment, ProctoredExamStudentAllowance, + ProctoredExamStudentAttempt, ProctoredExamStudentAttemptStatus, ProctoredExamReviewPolicy, ) @@ -1663,3 +1667,153 @@ def test_summary_without_credit_state(self): timed_exam['content_id'] ) self.assertIsNone(summary) + + def test_get_exam_violation_report(self): + """ + Test to get all the exam attempts. + """ + # attempt with comments in multiple categories + exam1_id = create_exam( + course_id=self.course_id, + content_id='test_content_1', + exam_name='DDDDDD', + time_limit_mins=self.default_time_limit + ) + + exam1_attempt_id = create_exam_attempt( + exam_id=exam1_id, + user_id=self.user_id + ) + + exam1_attempt = ProctoredExamStudentAttempt.objects.get_exam_attempt_by_id( + exam1_attempt_id + ) + + exam1_review = ProctoredExamSoftwareSecureReview.objects.create( + exam=ProctoredExam.get_exam_by_id(exam1_id), + attempt_code=exam1_attempt.attempt_code, + review_status="Suspicious" + ) + + ProctoredExamSoftwareSecureComment.objects.create( + review=exam1_review, + status="Rules Violation", + comment="foo", + start_time=0, + stop_time=1, + duration=1 + ) + + ProctoredExamSoftwareSecureComment.objects.create( + review=exam1_review, + status="Suspicious", + comment="bar", + start_time=0, + stop_time=1, + duration=1 + ) + + ProctoredExamSoftwareSecureComment.objects.create( + review=exam1_review, + status="Suspicious", + comment="baz", + start_time=0, + stop_time=1, + duration=1 + ) + + # attempt with comments in only one category + exam2_id = create_exam( + course_id=self.course_id, + content_id='test_content_2', + exam_name='CCCCCC', + time_limit_mins=self.default_time_limit + ) + + exam2_attempt_id = create_exam_attempt( + exam_id=exam2_id, + user_id=self.user_id + ) + + exam2_attempt = ProctoredExamStudentAttempt.objects.get_exam_attempt_by_id( + exam2_attempt_id + ) + + exam2_review = ProctoredExamSoftwareSecureReview.objects.create( + exam=ProctoredExam.get_exam_by_id(exam2_id), + attempt_code=exam2_attempt.attempt_code, + review_status="Rules Violation" + ) + + ProctoredExamSoftwareSecureComment.objects.create( + review=exam2_review, + status="Rules Violation", + comment="bar", + start_time=0, + stop_time=1, + duration=1 + ) + + # attempt with no comments, on a different exam + exam3_id = create_exam( + course_id=self.course_id, + content_id='test_content_3', + exam_name='BBBBBB', + time_limit_mins=self.default_time_limit + ) + + exam3_attempt_id = create_exam_attempt( + exam_id=exam3_id, + user_id=self.user_id + ) + + exam3_attempt = ProctoredExamStudentAttempt.objects.get_exam_attempt_by_id( + exam3_attempt_id + ) + + ProctoredExamSoftwareSecureReview.objects.create( + exam=ProctoredExam.get_exam_by_id(exam3_id), + attempt_code=exam3_attempt.attempt_code, + review_status="Clean" + ) + + # attempt with no comments or review + exam4_id = create_exam( + course_id=self.course_id, + content_id='test_content_4', + exam_name='AAAAAA', + time_limit_mins=self.default_time_limit + ) + + exam4_attempt_id = create_exam_attempt( + exam_id=exam4_id, + user_id=self.user_id + ) + + ProctoredExamStudentAttempt.objects.get_exam_attempt_by_id( + exam4_attempt_id + ) + + report = get_exam_violation_report(self.course_id) + + self.assertEqual(len(report), 4) + self.assertEqual([attempt['exam_name'] for attempt in report], [ + 'AAAAAA', + 'BBBBBB', + 'CCCCCC', + 'DDDDDD' + ]) + self.assertTrue('Rules Violation Comments' in report[3]) + self.assertEqual(len(report[3]['Rules Violation Comments']), 1) + self.assertTrue('Suspicious Comments' in report[3]) + self.assertEqual(len(report[3]['Suspicious Comments']), 2) + self.assertEqual(report[3]['review_status'], 'Suspicious') + + self.assertTrue('Suspicious Comments' not in report[2]) + self.assertTrue('Rules Violation Comments' in report[2]) + self.assertEqual(len(report[2]['Rules Violation Comments']), 1) + self.assertEqual(report[2]['review_status'], 'Rules Violation') + + self.assertEqual(report[1]['review_status'], 'Clean') + + self.assertIsNone(report[0]['review_status'])