From 02e5e43e411ab654d4bb51213542dac5f8df0ebb Mon Sep 17 00:00:00 2001 From: mohtamba Date: Mon, 21 Jun 2021 12:48:51 -0400 Subject: [PATCH 1/3] Initial attempt at grouped allowance endpoint Added grouped allowance function within views and the endpoint within urls.py. Added test cases to test_views.py. --- edx_proctoring/tests/test_views.py | 125 +++++++++++++++++++++++++++++ edx_proctoring/urls.py | 5 ++ edx_proctoring/views.py | 47 ++++++++++- 3 files changed, 176 insertions(+), 1 deletion(-) diff --git a/edx_proctoring/tests/test_views.py b/edx_proctoring/tests/test_views.py index 9350db0c7cd..9029d684281 100644 --- a/edx_proctoring/tests/test_views.py +++ b/edx_proctoring/tests/test_views.py @@ -4900,6 +4900,131 @@ 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 + ) + 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' + ) + 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) + # Check that all users allowances are inputted correctly + first_user = str(user_list[0].id) + self.assertEqual(len(grouped_allowances[first_user]), 2) + self.assertNotEqual(grouped_allowances[first_user][0], grouped_allowances[first_user][1]) + + 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) + + 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 b0549c9b87d..ce6d1ad4e8c 100644 --- a/edx_proctoring/views.py +++ b/edx_proctoring/views.py @@ -81,7 +81,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, @@ -1450,6 +1454,47 @@ 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. + + **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'] + if user_id in grouped_allowances: + grouped_allowances[user_id].append(serialied_allowance) + else: + grouped_allowances[user_id] = [serialied_allowance] + + response_data = {'grouped_allowances': grouped_allowances} + + return Response(response_data) + + class ActiveExamsForUserView(ProctoredAPIView): """ Endpoint for the Active Exams for a user. From 209599174a76cc2b6b911cf966996dec8a515194 Mon Sep 17 00:00:00 2001 From: mohtamba Date: Tue, 22 Jun 2021 10:26:33 -0400 Subject: [PATCH 2/3] Added comments and test cases Added comments/refactored code to be more concise/readable. Adjust test case to be more thorough. --- edx_proctoring/tests/test_views.py | 85 ++++++++++++++++++++++++------ edx_proctoring/views.py | 17 ++++-- 2 files changed, 82 insertions(+), 20 deletions(-) diff --git a/edx_proctoring/tests/test_views.py b/edx_proctoring/tests/test_views.py index 9029d684281..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, @@ -4928,14 +4928,7 @@ def test_get_grouped_allowances(self): 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] + exam_list = [exam1.id] allowance_data = { 'exam_ids': exam_list, @@ -4952,16 +4945,29 @@ def test_get_grouped_allowances(self): '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.assertEqual(len(response_data), 1) - grouped_allowances = response_data['grouped_allowances'] - self.assertEqual(len(grouped_allowances), 3) - # Check that all users allowances are inputted correctly - first_user = str(user_list[0].id) - self.assertEqual(len(grouped_allowances[first_user]), 2) - self.assertNotEqual(grouped_allowances[first_user][0], grouped_allowances[first_user][1]) + self.assertDictEqual(expected_response, response_data) def test_get_grouped_allowances_non_staff(self): """ @@ -5024,6 +5030,53 @@ def test_get_grouped_allowances_course_no_allowances(self): 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): """ diff --git a/edx_proctoring/views.py b/edx_proctoring/views.py index ce6d1ad4e8c..24d4fc94d55 100644 --- a/edx_proctoring/views.py +++ b/edx_proctoring/views.py @@ -1464,6 +1464,18 @@ class GroupedExamAllowancesByStudent(ProctoredAPIView): **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: @@ -1485,10 +1497,7 @@ def get(self, request, course_id): for allowance in all_allowances: serialied_allowance = ProctoredExamStudentAllowanceSerializer(allowance).data user_id = serialied_allowance['user']['id'] - if user_id in grouped_allowances: - grouped_allowances[user_id].append(serialied_allowance) - else: - grouped_allowances[user_id] = [serialied_allowance] + grouped_allowances.setdefault(user_id, []).append(serialied_allowance) response_data = {'grouped_allowances': grouped_allowances} From c3be58d000d94cc19511a990c4698a6fa5d07bb5 Mon Sep 17 00:00:00 2001 From: mohtamba Date: Tue, 22 Jun 2021 14:11:47 -0400 Subject: [PATCH 3/3] Update version number and changelog --- CHANGELOG.rst | 4 ++++ edx_proctoring/__init__.py | 2 +- package.json | 2 +- 3 files changed, 6 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.rst b/CHANGELOG.rst index b4d77392ef7..20ec687ceae 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.0] - 2021-06-15 ~~~~~~~~~~~~~~~~~~~~~ * Created a POST api endpoint to add allowances for multiple students and multiple exams at the same time. diff --git a/edx_proctoring/__init__.py b/edx_proctoring/__init__.py index fff3a666688..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.0' +__version__ = '3.16.0' default_app_config = 'edx_proctoring.apps.EdxProctoringConfig' # pylint: disable=invalid-name diff --git a/package.json b/package.json index 27dcbf32b73..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.0", + "version": "3.16.0", "main": "edx_proctoring/static/index.js", "scripts": { "test": "gulp test"