Skip to content

Commit

Permalink
feat: GET allowances (#288)
Browse files Browse the repository at this point in the history
  • Loading branch information
zacharis278 authored Jul 16, 2024
1 parent 5ef7ed6 commit 4cf5d52
Show file tree
Hide file tree
Showing 7 changed files with 179 additions and 2 deletions.
26 changes: 26 additions & 0 deletions edx_exams/apps/api/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -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'
)
99 changes: 99 additions & 0 deletions edx_exams/apps/api/v1/tests/test_views.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@
ExamAttemptFactory,
ExamFactory,
ProctoringProviderFactory,
StudentAllowanceFactory,
UserFactory
)

Expand Down Expand Up @@ -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, [])
6 changes: 6 additions & 0 deletions edx_exams/apps/api/v1/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
from django.urls import path, re_path

from edx_exams.apps.api.v1.views import (
AllowanceView,
CourseExamAttemptView,
CourseExamConfigurationsView,
CourseExamsView,
Expand All @@ -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(),
Expand Down
25 changes: 24 additions & 1 deletion edx_exams/apps/api/v1/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@

from edx_exams.apps.api.permissions import CourseStaffOrReadOnlyPermissions, CourseStaffUserPermissions
from edx_exams.apps.api.serializers import (
AllowanceSerializer,
CourseExamConfigurationReadSerializer,
CourseExamConfigurationWriteSerializer,
ExamSerializer,
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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)
1 change: 1 addition & 0 deletions edx_exams/apps/core/admin.py
Original file line number Diff line number Diff line change
Expand Up @@ -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',)
Expand Down
9 changes: 9 additions & 0 deletions edx_exams/apps/core/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
15 changes: 14 additions & 1 deletion edx_exams/apps/core/test_utils/factories.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,8 @@
CourseStaffRole,
Exam,
ExamAttempt,
ProctoringProvider
ProctoringProvider,
StudentAllowance
)
from edx_exams.apps.core.statuses import ExamAttemptStatus

Expand Down Expand Up @@ -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

0 comments on commit 4cf5d52

Please sign in to comment.