From 4cf5d52ab8ed62f19a0020f51644999bbfddcc0c Mon Sep 17 00:00:00 2001 From: Zachary Hancock Date: Tue, 16 Jul 2024 08:58:26 -0400 Subject: [PATCH] feat: GET allowances (#288) --- edx_exams/apps/api/serializers.py | 26 ++++++ edx_exams/apps/api/v1/tests/test_views.py | 99 +++++++++++++++++++++ edx_exams/apps/api/v1/urls.py | 6 ++ edx_exams/apps/api/v1/views.py | 25 +++++- edx_exams/apps/core/admin.py | 1 + edx_exams/apps/core/models.py | 9 ++ edx_exams/apps/core/test_utils/factories.py | 15 +++- 7 files changed, 179 insertions(+), 2 deletions(-) diff --git a/edx_exams/apps/api/serializers.py b/edx_exams/apps/api/serializers.py index b48998d2..431e3561 100644 --- a/edx_exams/apps/api/serializers.py +++ b/edx_exams/apps/api/serializers.py @@ -280,3 +280,29 @@ class Meta: 'allowed_time_limit_mins', 'exam_type', 'exam_display_name', 'username', 'proctored_review', ) + + +class AllowanceSerializer(serializers.ModelSerializer): + """ + Serializer for the Allowance model + """ + + # directly from the Allowance Model + id = serializers.IntegerField(required=False) + exam_id = serializers.IntegerField() + user_id = serializers.IntegerField() + extra_time_mins = serializers.IntegerField() + + # custom fields based on related models + username = serializers.CharField(source='user.username') + exam_name = serializers.CharField(source='exam.exam_name') + + class Meta: + """ + Meta Class + """ + model = ExamAttempt + + fields = ( + 'id', 'exam_id', 'user_id', 'extra_time_mins', 'username', 'exam_name' + ) diff --git a/edx_exams/apps/api/v1/tests/test_views.py b/edx_exams/apps/api/v1/tests/test_views.py index 5db0e651..da1be480 100644 --- a/edx_exams/apps/api/v1/tests/test_views.py +++ b/edx_exams/apps/api/v1/tests/test_views.py @@ -25,6 +25,7 @@ ExamAttemptFactory, ExamFactory, ProctoringProviderFactory, + StudentAllowanceFactory, UserFactory ) @@ -1752,3 +1753,101 @@ def test_exam_provider(self): 'proctoring_escalation_email': self.config.escalation_email, } ) + + +class AllowanceViewTests(ExamsAPITestCase): + """ + Tests AllowanceView + """ + + def setUp(self): + super().setUp() + + self.course_id = 'course-v1:edx+test+f19' + self.exam = ExamFactory( + course_id=self.course_id, + ) + + def request_api(self, method, user, course_id): + """ + Helper function to make API request + """ + assert method in ['get'] + headers = self.build_jwt_headers(user) + url = reverse( + 'api:v1:course-allowances', + kwargs={'course_id': course_id} + ) + + return getattr(self.client, method)(url, **headers) + + def test_auth_required(self): + """ + Test endpoint requires authentication and a staff user + """ + + # no auth + response = self.client.get( + reverse('api:v1:course-allowances', kwargs={'course_id': self.course_id}), + ) + self.assertEqual(response.status_code, 401) + + # no permissions + user = UserFactory.create(is_staff=False) + response = self.request_api('get', user, self.course_id) + self.assertEqual(response.status_code, 403) + + # course staff has access + course_staff_user = UserFactory.create() + CourseStaffRole.objects.create(user=course_staff_user, course_id=self.course_id) + response = self.request_api('get', course_staff_user, self.course_id) + self.assertEqual(response.status_code, 200) + + def test_get_allowances(self): + """ + Test that the endpoint returns allowances for the requested course + and only the requested course + """ + other_exam_in_course = ExamFactory.create(course_id=self.exam.course_id) + StudentAllowanceFactory.create( + exam=self.exam, + user=self.user, + ) + StudentAllowanceFactory.create( + exam=other_exam_in_course, + user=self.user, + ) + StudentAllowanceFactory.create( + exam=ExamFactory.create(course_id='course-v1:edx+another+course'), + user=self.user, + ) + + response = self.request_api('get', self.user, self.exam.course_id) + self.assertEqual(response.status_code, 200) + self.assertEqual(len(response.data), 2) + + response.data.sort(key=lambda x: x['id']) + self.assertDictEqual(response.data[0], { + 'id': 1, + 'exam_id': self.exam.id, + 'user_id': self.user.id, + 'exam_name': self.exam.exam_name, + 'username': self.user.username, + 'extra_time_mins': 30, + }) + self.assertDictEqual(response.data[1], { + 'id': 2, + 'exam_id': other_exam_in_course.id, + 'user_id': self.user.id, + 'exam_name': other_exam_in_course.exam_name, + 'username': self.user.username, + 'extra_time_mins': 30, + }) + + def test_get_empty_response(self): + """ + Test that the endpoint returns an empty list if no allowances exist + """ + response = self.request_api('get', self.user, 'course-v1:edx+no+allowances') + self.assertEqual(response.status_code, 200) + self.assertEqual(response.data, []) diff --git a/edx_exams/apps/api/v1/urls.py b/edx_exams/apps/api/v1/urls.py index 2207bdde..e5720f18 100644 --- a/edx_exams/apps/api/v1/urls.py +++ b/edx_exams/apps/api/v1/urls.py @@ -3,6 +3,7 @@ from django.urls import path, re_path from edx_exams.apps.api.v1.views import ( + AllowanceView, CourseExamAttemptView, CourseExamConfigurationsView, CourseExamsView, @@ -18,6 +19,11 @@ app_name = 'v1' urlpatterns = [ + re_path( + fr'exams/course_id/{COURSE_ID_PATTERN}/allowances', + AllowanceView.as_view(), + name='course-allowances' + ), re_path( fr'exams/course_id/{COURSE_ID_PATTERN}', CourseExamsView.as_view(), diff --git a/edx_exams/apps/api/v1/views.py b/edx_exams/apps/api/v1/views.py index 344a7dd8..533fbaea 100644 --- a/edx_exams/apps/api/v1/views.py +++ b/edx_exams/apps/api/v1/views.py @@ -18,6 +18,7 @@ from edx_exams.apps.api.permissions import CourseStaffOrReadOnlyPermissions, CourseStaffUserPermissions from edx_exams.apps.api.serializers import ( + AllowanceSerializer, CourseExamConfigurationReadSerializer, CourseExamConfigurationWriteSerializer, ExamSerializer, @@ -45,7 +46,7 @@ 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 +from edx_exams.apps.core.models import CourseExamConfiguration, Exam, ExamAttempt, ProctoringProvider, StudentAllowance from edx_exams.apps.core.statuses import ExamAttemptStatus from edx_exams.apps.router.interop import get_active_exam_attempt @@ -779,3 +780,25 @@ def get(self, request, course_id, exam_id): data['proctoring_escalation_email'] = config_data.escalation_email return Response(data) + + +class AllowanceView(ExamsAPIView): + """ + Endpoint for getting allowances in a course + + /exams/course_id/{course_id}/allowances + + Supports: + HTTP GET: + Returns a list of allowances for a course. + """ + + authentication_classes = (JwtAuthentication,) + permission_classes = (CourseStaffUserPermissions,) + + def get(self, request, course_id): + """ + HTTP GET handler. Returns a list of allowances for a course. + """ + allowances = StudentAllowance.get_allowances_for_course(course_id) + return Response(AllowanceSerializer(allowances, many=True).data) diff --git a/edx_exams/apps/core/admin.py b/edx_exams/apps/core/admin.py index 3819ff3e..c351fe9a 100644 --- a/edx_exams/apps/core/admin.py +++ b/edx_exams/apps/core/admin.py @@ -109,6 +109,7 @@ class CourseStaffRoleAdmin(admin.ModelAdmin): @admin.register(StudentAllowance) class StudentAllowanceAdmin(admin.ModelAdmin): """ Admin configuration for the Student Allowance model """ + raw_id_fields = ('user', 'exam') list_display = ('username', 'course_id', 'exam_name', 'extra_time_mins') search_fields = ('user__username', 'exam__course_id', 'exam__exam_name') ordering = ('-modified',) diff --git a/edx_exams/apps/core/models.py b/edx_exams/apps/core/models.py index 617e61c9..7fe68dde 100644 --- a/edx_exams/apps/core/models.py +++ b/edx_exams/apps/core/models.py @@ -5,6 +5,7 @@ from django.contrib.auth.models import AbstractUser from django.core.exceptions import ObjectDoesNotExist from django.db import models, transaction +from django.db.models import Q from django.utils.translation import gettext_lazy as _ from model_utils.models import TimeStampedModel from simple_history.models import HistoricalRecords @@ -447,3 +448,11 @@ class Meta: db_table = 'exams_studentallowance' verbose_name = 'student allowance' unique_together = ('user', 'exam') + + @classmethod + def get_allowances_for_course(cls, course_id): + """ + Returns all the allowances for a course. + """ + filtered_query = Q(exam__course_id=course_id) + return cls.objects.filter(filtered_query) diff --git a/edx_exams/apps/core/test_utils/factories.py b/edx_exams/apps/core/test_utils/factories.py index e6516ef2..bab742ee 100644 --- a/edx_exams/apps/core/test_utils/factories.py +++ b/edx_exams/apps/core/test_utils/factories.py @@ -14,7 +14,8 @@ CourseStaffRole, Exam, ExamAttempt, - ProctoringProvider + ProctoringProvider, + StudentAllowance ) from edx_exams.apps.core.statuses import ExamAttemptStatus @@ -129,3 +130,15 @@ class Meta: user = factory.SubFactory(UserFactory) course_id = 'course-v1:edX+Test+Test_Course' role = 'staff' + + +class StudentAllowanceFactory(DjangoModelFactory): + """ + Factory to create allowances + """ + class Meta: + model = StudentAllowance + + user = factory.SubFactory(UserFactory) + exam = factory.SubFactory(ExamFactory) + extra_time_mins = 30