From 0ce433add470fa781450411fc866f25976cdcba3 Mon Sep 17 00:00:00 2001 From: Alie Langston Date: Mon, 22 Jul 2024 09:12:11 -0400 Subject: [PATCH] feat: add view for allowance creation --- .gitignore | 2 + edx_exams/apps/api/serializers.py | 10 ++-- edx_exams/apps/api/v1/tests/test_views.py | 60 +++++++++++++++++++++-- edx_exams/apps/api/v1/views.py | 53 +++++++++++++++++++- 4 files changed, 114 insertions(+), 11 deletions(-) diff --git a/.gitignore b/.gitignore index 24aefe00..11cfa2a8 100644 --- a/.gitignore +++ b/.gitignore @@ -96,3 +96,5 @@ docs/_build/ # Temp reports file reports/ + +requirements/private.txt diff --git a/edx_exams/apps/api/serializers.py b/edx_exams/apps/api/serializers.py index 431e3561..afb00594 100644 --- a/edx_exams/apps/api/serializers.py +++ b/edx_exams/apps/api/serializers.py @@ -289,13 +289,13 @@ class AllowanceSerializer(serializers.ModelSerializer): # directly from the Allowance Model id = serializers.IntegerField(required=False) - exam_id = serializers.IntegerField() - user_id = serializers.IntegerField() - extra_time_mins = serializers.IntegerField() + exam_id = serializers.IntegerField(required=True) + user_id = serializers.IntegerField(required=True) + extra_time_mins = serializers.IntegerField(required=True) # custom fields based on related models - username = serializers.CharField(source='user.username') - exam_name = serializers.CharField(source='exam.exam_name') + username = serializers.CharField(source='user.username', required=False) + exam_name = serializers.CharField(source='exam.exam_name', required=False) class Meta: """ diff --git a/edx_exams/apps/api/v1/tests/test_views.py b/edx_exams/apps/api/v1/tests/test_views.py index da1be480..13dfc0e1 100644 --- a/edx_exams/apps/api/v1/tests/test_views.py +++ b/edx_exams/apps/api/v1/tests/test_views.py @@ -17,7 +17,14 @@ from edx_exams.apps.api.test_utils import ExamsAPITestCase from edx_exams.apps.core.exam_types import get_exam_type from edx_exams.apps.core.exceptions import ExamAttemptOnPastDueExam, ExamIllegalStatusTransition -from edx_exams.apps.core.models import CourseExamConfiguration, CourseStaffRole, Exam, ExamAttempt, ProctoringProvider +from edx_exams.apps.core.models import ( + CourseExamConfiguration, + CourseStaffRole, + Exam, + ExamAttempt, + ProctoringProvider, + StudentAllowance +) from edx_exams.apps.core.statuses import ExamAttemptStatus from edx_exams.apps.core.test_utils.factories import ( AssessmentControlResultFactory, @@ -1768,18 +1775,21 @@ def setUp(self): course_id=self.course_id, ) - def request_api(self, method, user, course_id): + def request_api(self, method, user, course_id, data=None): """ Helper function to make API request """ - assert method in ['get'] + assert method in ['get', 'post'] headers = self.build_jwt_headers(user) url = reverse( 'api:v1:course-allowances', kwargs={'course_id': course_id} ) - return getattr(self.client, method)(url, **headers) + if data: + return getattr(self.client, method)(url, json.dumps(data), **headers, content_type='application/json') + else: + return getattr(self.client, method)(url, **headers) def test_auth_required(self): """ @@ -1851,3 +1861,45 @@ def test_get_empty_response(self): response = self.request_api('get', self.user, 'course-v1:edx+no+allowances') self.assertEqual(response.status_code, 200) self.assertEqual(response.data, []) + + def test_post_allowances(self): + """ + Test that the endpoint creates allowances for the given request data + """ + other_exam_in_course = ExamFactory.create(course_id=self.exam.course_id) + other_user = UserFactory() + StudentAllowanceFactory.create( + exam=self.exam, + user=self.user, + extra_time_mins=30, + ) + StudentAllowanceFactory.create( + exam=other_exam_in_course, + user=self.user, + extra_time_mins=30, + ) + + request_data = [ + {'exam_id': self.exam.id, 'user_id': self.user.id, 'extra_time_mins': 45}, + {'exam_id': other_exam_in_course.id, 'user_id': self.user.id, 'extra_time_mins': 45}, + {'exam_id': other_exam_in_course.id, 'user_id': other_user.id, 'extra_time_mins': 45}, + ] + + response = self.request_api('post', self.user, self.exam.course_id, data=request_data) + self.assertEqual(response.status_code, 200) + + course_allowances = StudentAllowance.objects.all() + self.assertEqual(len(course_allowances), 3) + + self.assertEqual(len(StudentAllowance.objects.filter(user_id=self.user.id)), 2) + self.assertEqual(len(StudentAllowance.objects.filter(extra_time_mins=45)), 3) + + def test_invalid_post(self): + """ + Test that 400 response is returned if serializer is invalid + """ + request_data = [ + {'exam_id': self.exam.id, 'user_id': self.user.id, 'extra_time_mins': 'yyyy'}, + ] + response = self.request_api('post', self.user, self.exam.course_id, data=request_data) + self.assertEqual(response.status_code, 400) diff --git a/edx_exams/apps/api/v1/views.py b/edx_exams/apps/api/v1/views.py index 533fbaea..e5fc4d07 100644 --- a/edx_exams/apps/api/v1/views.py +++ b/edx_exams/apps/api/v1/views.py @@ -46,7 +46,14 @@ update_attempt_status ) from edx_exams.apps.core.exam_types import get_exam_type -from edx_exams.apps.core.models import CourseExamConfiguration, Exam, ExamAttempt, ProctoringProvider, StudentAllowance +from edx_exams.apps.core.models import ( + CourseExamConfiguration, + Exam, + ExamAttempt, + ProctoringProvider, + StudentAllowance, + User +) from edx_exams.apps.core.statuses import ExamAttemptStatus from edx_exams.apps.router.interop import get_active_exam_attempt @@ -784,13 +791,25 @@ def get(self, request, course_id, exam_id): class AllowanceView(ExamsAPIView): """ - Endpoint for getting allowances in a course + Endpoint for the StudentAllowance /exams/course_id/{course_id}/allowances Supports: HTTP GET: Returns a list of allowances for a course. + HTTP POST: + Create one or more allowances + + Expected POST data: [{ + "user_id": 11111, + "exam_id": 1234, + "extra_time_mins": 30, + }] + **POST data Parameters** + * user_id: User ID for which to create or update an allowance. + * exam_id: ID of the exam for which to create or update an allowance + * extra_time_mins: Extra time (in minutes) that a student is allotted for an exam. """ authentication_classes = (JwtAuthentication,) @@ -802,3 +821,33 @@ def get(self, request, course_id): """ allowances = StudentAllowance.get_allowances_for_course(course_id) return Response(AllowanceSerializer(allowances, many=True).data) + + def post(self, request, course_id): # pylint: disable=unused-argument + """ + HTTP POST handler. Creates allowances based on the given list. + """ + allowances = request.data + + serializer = AllowanceSerializer(data=allowances, many=True) + + if serializer.is_valid(): + allowance_objects = [ + StudentAllowance( + user=User.objects.get(id=allowance['user_id']), + exam=Exam.objects.get(id=allowance['exam_id']), + extra_time_mins=allowance['extra_time_mins'] + ) + for allowance in allowances + ] + StudentAllowance.objects.bulk_create( + allowance_objects, + update_conflicts=True, + unique_fields=['user', 'exam'], + update_fields=['extra_time_mins'] + ) + + return Response(status=status.HTTP_200_OK) + else: + response_status = status.HTTP_400_BAD_REQUEST + data = {'detail': 'Invalid data', 'errors': serializer.errors} + return Response(status=response_status, data=data)