diff --git a/CHANGELOG.rst b/CHANGELOG.rst index a079d287e9e..73b0c2adb8b 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -14,6 +14,10 @@ Change Log Unreleased ~~~~~~~~~~ +[3.16.0] - 2021-06-22 +~~~~~~~~~~~~~~~~~~~~~ +* Created a GET api endpoint which groups course allowances by users. + [3.15.1] - 2021-06-16 ~~~~~~~~~~~~~~~~~~~~~ * Fix a bug in exam attempt API where total time allowed for the exam would not include allowance time. diff --git a/edx_proctoring/__init__.py b/edx_proctoring/__init__.py index bc8d38447b5..392c2c7e1fe 100644 --- a/edx_proctoring/__init__.py +++ b/edx_proctoring/__init__.py @@ -3,6 +3,6 @@ """ # Be sure to update the version number in edx_proctoring/package.json -__version__ = '3.15.1' +__version__ = '3.16.0' default_app_config = 'edx_proctoring.apps.EdxProctoringConfig' # pylint: disable=invalid-name diff --git a/edx_proctoring/tests/test_views.py b/edx_proctoring/tests/test_views.py index 9350db0c7cd..3d1a507c6b7 100644 --- a/edx_proctoring/tests/test_views.py +++ b/edx_proctoring/tests/test_views.py @@ -47,7 +47,7 @@ ProctoredExamStudentAttemptHistory ) from edx_proctoring.runtime import get_runtime_service, set_runtime_service -from edx_proctoring.serializers import ProctoredExamSerializer +from edx_proctoring.serializers import ProctoredExamSerializer, ProctoredExamStudentAllowanceSerializer from edx_proctoring.statuses import ( InstructorDashboardOnboardingAttemptStatus, ProctoredExamStudentAttemptStatus, @@ -4900,6 +4900,184 @@ def test_add_bulk_allowance_no_exams(self): # pylint: disable=invalid-name self.assertEqual(response.status_code, 400) +class GroupedExamAllowancesByStudent(LoggedInTestCase): + """ + Tests for the GroupedExamAllowancesByStudent + """ + def setUp(self): + super().setUp() + self.user.is_staff = True + self.user.save() + self.client.login_user(self.user) + self.student_taking_exam = User() + self.student_taking_exam.save() + + set_runtime_service('instructor', MockInstructorService(is_user_course_staff=True)) + + def test_get_grouped_allowances(self): + """ + Test to get the exam allowances of a course + """ + # Create an exam. + user_list = self.create_batch_users(3) + user_id_list = [user.email for user in user_list] + exam1 = ProctoredExam.objects.create( + course_id='a/b/c', + content_id='test_content', + exam_name='Test Exam', + time_limit_mins=90, + is_active=True + ) + exam_list = [exam1.id] + + allowance_data = { + 'exam_ids': exam_list, + 'user_ids': user_id_list, + 'allowance_type': ADDITIONAL_TIME, + 'value': '30' + } + self.client.put( + reverse('edx_proctoring:proctored_exam.bulk_allowance'), + json.dumps(allowance_data), + content_type='application/json' + ) + url = reverse( + 'edx_proctoring:proctored_exam.allowance.grouped.course', + kwargs={'course_id': exam1.course_id} + ) + + # Create expected dictionary by getting each users allowance seperately + first_user = user_list[0].id + first_user_allowance = ProctoredExamStudentAllowance.get_allowance_for_user(exam1.id, first_user, + 'additional_time_granted') + first_serialized_allowance = ProctoredExamStudentAllowanceSerializer(first_user_allowance).data + second_user = user_list[1].id + second_user_allowance = ProctoredExamStudentAllowance.get_allowance_for_user(exam1.id, second_user, + 'additional_time_granted') + second_serialized_allowance = ProctoredExamStudentAllowanceSerializer(second_user_allowance).data + third_user = user_list[2].id + third_user_allowance = ProctoredExamStudentAllowance.get_allowance_for_user(exam1.id, third_user, + 'additional_time_granted') + third_serialized_allowance = ProctoredExamStudentAllowanceSerializer(third_user_allowance).data + expected_response = { + 'grouped_allowances': {str(first_user): [first_serialized_allowance], + str(second_user): [second_serialized_allowance], + str(third_user): [third_serialized_allowance]} + } + response = self.client.get(url) + self.assertEqual(response.status_code, 200) + response_data = json.loads(response.content.decode('utf-8')) + self.assertDictEqual(expected_response, response_data) + + def test_get_grouped_allowances_non_staff(self): + """ + Test to get the exam allowances of a course when not a staff member + """ + # Create an exam. + user_list = self.create_batch_users(3) + user_id_list = [user.email for user in user_list] + exam1 = ProctoredExam.objects.create( + course_id='a/b/c', + content_id='test_content', + exam_name='Test Exam', + time_limit_mins=90, + is_active=True + ) + exam2 = ProctoredExam.objects.create( + course_id='a/b/c', + content_id='test_content2', + exam_name='Test Exam2', + time_limit_mins=90, + is_active=True + ) + exam_list = [exam1.id, exam2.id] + + allowance_data = { + 'exam_ids': exam_list, + 'user_ids': user_id_list, + 'allowance_type': ADDITIONAL_TIME, + 'value': '30' + } + self.client.put( + reverse('edx_proctoring:proctored_exam.bulk_allowance'), + json.dumps(allowance_data), + content_type='application/json' + ) + self.user.is_staff = False + self.user.save() + set_runtime_service('instructor', MockInstructorService(is_user_course_staff=False)) + url = reverse( + 'edx_proctoring:proctored_exam.allowance.grouped.course', + kwargs={'course_id': exam1.course_id} + ) + response = self.client.get(url) + self.assertEqual(response.status_code, 403) + response_data = json.loads(response.content.decode('utf-8')) + self.assertEqual(response_data['detail'], 'Must be a Staff User to Perform this request.') + + def test_get_grouped_allowances_course_no_allowances(self): + """ + Test to get the exam allowances of a course with no allowances + """ + url = reverse( + 'edx_proctoring:proctored_exam.allowance.grouped.course', + kwargs={'course_id': 'a/c/d'} + ) + response = self.client.get(url) + self.assertEqual(response.status_code, 200) + response_data = json.loads(response.content.decode('utf-8')) + self.assertEqual(len(response_data), 1) + grouped_allowances = response_data['grouped_allowances'] + self.assertEqual(len(grouped_allowances), 0) + + def test_get_grouped_allowances_non_global_staff(self): + """ + Test to get the exam allowances of a course when a member is course staff, + but not global + """ + # Create an exam. + user_list = self.create_batch_users(3) + user_id_list = [user.email for user in user_list] + exam1 = ProctoredExam.objects.create( + course_id='a/b/c', + content_id='test_content', + exam_name='Test Exam', + time_limit_mins=90, + is_active=True + ) + exam2 = ProctoredExam.objects.create( + course_id='a/b/c', + content_id='test_content2', + exam_name='Test Exam2', + time_limit_mins=90, + is_active=True + ) + exam_list = [exam1.id, exam2.id] + + allowance_data = { + 'exam_ids': exam_list, + 'user_ids': user_id_list, + 'allowance_type': ADDITIONAL_TIME, + 'value': '30' + } + self.client.put( + reverse('edx_proctoring:proctored_exam.bulk_allowance'), + json.dumps(allowance_data), + content_type='application/json' + ) + set_runtime_service('instructor', MockInstructorService(is_user_course_staff=False)) + url = reverse( + 'edx_proctoring:proctored_exam.allowance.grouped.course', + kwargs={'course_id': exam1.course_id} + ) + response = self.client.get(url) + self.assertEqual(response.status_code, 200) + response_data = json.loads(response.content.decode('utf-8')) + self.assertEqual(len(response_data), 1) + grouped_allowances = response_data['grouped_allowances'] + self.assertEqual(len(grouped_allowances), 3) + + class TestActiveExamsForUserView(LoggedInTestCase): """ Tests for the ActiveExamsForUserView diff --git a/edx_proctoring/urls.py b/edx_proctoring/urls.py index 94481d42643..b1d2107199a 100644 --- a/edx_proctoring/urls.py +++ b/edx_proctoring/urls.py @@ -90,6 +90,11 @@ views.ExamBulkAllowanceView.as_view(), name='proctored_exam.bulk_allowance' ), + url( + r'edx_proctoring/v1/proctored_exam/allowance/grouped/course_id/{}$'.format(settings.COURSE_ID_PATTERN), + views.GroupedExamAllowancesByStudent.as_view(), + name='proctored_exam.allowance.grouped.course' + ), url( r'edx_proctoring/v1/proctored_exam/active_exams_for_user$', views.ActiveExamsForUserView.as_view(), diff --git a/edx_proctoring/views.py b/edx_proctoring/views.py index 5554b722267..39cef599ada 100644 --- a/edx_proctoring/views.py +++ b/edx_proctoring/views.py @@ -82,7 +82,11 @@ ProctoredExamStudentAttemptHistory ) from edx_proctoring.runtime import get_runtime_service -from edx_proctoring.serializers import ProctoredExamSerializer, ProctoredExamStudentAttemptSerializer +from edx_proctoring.serializers import ( + ProctoredExamSerializer, + ProctoredExamStudentAllowanceSerializer, + ProctoredExamStudentAttemptSerializer +) from edx_proctoring.statuses import ( InstructorDashboardOnboardingAttemptStatus, ProctoredExamStudentAttemptStatus, @@ -1456,6 +1460,56 @@ def put(self, request): ) +class GroupedExamAllowancesByStudent(ProctoredAPIView): + """ + Endpoint for the StudentProctoredGroupedExamAttemptsByCourse + + Supports: + HTTP GET: return information about learners' allowances + + **Expected Response** + HTTP GET: + The response will contain a dictionary with the allowances of a course grouped by student. + For example: + {'grouped_allowances':{'4': + [{'id': 4, 'created': '2021-06-21T14:47:17.847221Z', + 'modified': '2021-06-21T14:47:17.847221Z', + 'user': {'id': 4, 'username': 'student1', + 'email': 'student1@test.com'}, + 'key': 'additional_time_granted', 'value': '30', + 'proctored_exam': {'id': 2, 'course_id': 'a/b/c', 'content_id': 'test_content2', + 'external_id': None, 'exam_name': 'Test Exam2', 'time_limit_mins': 90, + 'is_proctored': False, 'is_practice_exam': False, + 'is_active': True, 'due_date': None, + 'hide_after_due': False, 'backend': None}}} + + **Exceptions** + HTTP GET: + * 403 if the requesting user is not staff or course staff for the course associated with + the supplied course ID + """ + @method_decorator(require_course_or_global_staff) + def get(self, request, course_id): + """ + HTTP GET Handler. + """ + + # Get all allowances from the course + all_allowances = ProctoredExamStudentAllowance.get_allowances_for_course(course_id) + + grouped_allowances = {} + + # Process allowances so they are grouped by user id + for allowance in all_allowances: + serialied_allowance = ProctoredExamStudentAllowanceSerializer(allowance).data + user_id = serialied_allowance['user']['id'] + grouped_allowances.setdefault(user_id, []).append(serialied_allowance) + + response_data = {'grouped_allowances': grouped_allowances} + + return Response(response_data) + + class ActiveExamsForUserView(ProctoredAPIView): """ Endpoint for the Active Exams for a user. diff --git a/package.json b/package.json index b0ac3aacc3c..341733d3ddd 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "name": "@edx/edx-proctoring", "//": "Be sure to update the version number in edx_proctoring/__init__.py", "//": "Note that the version format is slightly different than that of the Python version when using prereleases.", - "version": "3.15.1", + "version": "3.16.0", "main": "edx_proctoring/static/index.js", "scripts": { "test": "gulp test"