Skip to content

Commit

Permalink
feat: calculate exam time with allowances (#290)
Browse files Browse the repository at this point in the history
  • Loading branch information
zacharis278 authored Jul 22, 2024
1 parent 4cf5d52 commit 9cd4618
Show file tree
Hide file tree
Showing 6 changed files with 93 additions and 7 deletions.
14 changes: 14 additions & 0 deletions edx_exams/apps/api/v1/tests/test_views.py
Original file line number Diff line number Diff line change
Expand Up @@ -1634,6 +1634,20 @@ def test_no_active_attempt(self):
self.assertEqual(response.status_code, 200)
self.assertEqual(response_exam, expected_data)

def test_user_has_allowance(self):
"""
Test that if user has an allowance, the total time is calculated correctly
"""
StudentAllowanceFactory.create(
user=self.user,
exam=self.exam,
extra_time_mins=30
)

response = self.get_api(self.user, self.course_id, self.content_id)
response_exam = response.data['exam']
self.assertEqual(response_exam['total_time'], self.exam.time_limit_mins + 30)

def test_active_attempt(self):
"""
Test that if attempt exists, it is returned as part of the exam object
Expand Down
10 changes: 8 additions & 2 deletions edx_exams/apps/api/v1/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -711,20 +711,26 @@ def get(self, request, course_id, content_id): # pylint: disable=unused-argumen
return Response(data)

serialized_exam = ExamSerializer(exam).data
allowance = StudentAllowance.get_allowance_for_user(request.user.id, exam.id)

exam_type_class = get_exam_type(exam.exam_type)

# the following are additional fields that the frontend expects
serialized_exam['type'] = exam.exam_type
serialized_exam['is_proctored'] = exam_type_class.is_proctored
serialized_exam['is_practice_exam'] = exam_type_class.is_practice
# total time is equivalent to time_limit_mins for now because allowances are not yet supported
serialized_exam['total_time'] = exam.time_limit_mins
# timed exams will have None as a backend
serialized_exam['backend'] = exam.provider.verbose_name if exam.provider is not None else None

serialized_exam['passed_due_date'] = is_exam_passed_due(serialized_exam)

if allowance is not None:
serialized_exam['total_time'] = exam.time_limit_mins + allowance.extra_time_mins
else:
serialized_exam['total_time'] = exam.time_limit_mins

exam_attempt = get_current_exam_attempt(request.user.id, exam.id)

if exam_attempt is not None:
exam_attempt = check_if_exam_timed_out(exam_attempt)

Expand Down
10 changes: 7 additions & 3 deletions edx_exams/apps/core/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@
ExamDoesNotExist,
ExamIllegalStatusTransition
)
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.signals.signals import (
emit_exam_attempt_errored_event,
emit_exam_attempt_rejected_event,
Expand Down Expand Up @@ -100,7 +100,7 @@ def update_attempt_status(attempt_id, to_status):
raise ExamIllegalStatusTransition(error_msg)

attempt_obj.start_time = datetime.now(pytz.UTC)
attempt_obj.allowed_time_limit_mins = _calculate_allowed_mins(attempt_obj.exam)
attempt_obj.allowed_time_limit_mins = _calculate_allowed_mins(attempt_obj.user, attempt_obj.exam)

course_key = CourseKey.from_string(attempt_obj.exam.course_id)
usage_key = UsageKey.from_string(attempt_obj.exam.content_id)
Expand Down Expand Up @@ -210,15 +210,19 @@ def _check_exam_is_allowed_to_start(attempt_obj, user_id):
return True, ''


def _calculate_allowed_mins(exam):
def _calculate_allowed_mins(user, exam):
"""
Calculate the allowed minutes for an attempt, taking due date into account
If an exam's duration + start time exceeds the due date, return the remaining time between
due date and the current time
"""
due_datetime = exam.due_date
allowance = StudentAllowance.get_allowance_for_user(user.id, exam.id)
allowed_time_limit_mins = exam.time_limit_mins

if allowance:
allowed_time_limit_mins += allowance.extra_time_mins

if due_datetime:
current_datetime = datetime.now(pytz.UTC)
if current_datetime + timedelta(minutes=allowed_time_limit_mins) > due_datetime:
Expand Down
11 changes: 11 additions & 0 deletions edx_exams/apps/core/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -456,3 +456,14 @@ def get_allowances_for_course(cls, course_id):
"""
filtered_query = Q(exam__course_id=course_id)
return cls.objects.filter(filtered_query)

@classmethod
def get_allowance_for_user(cls, user_id, exam_id):
"""
Returns the allowance for a user in an exam.
"""
try:
allowance = cls.objects.get(user_id=user_id, exam_id=exam_id)
except cls.DoesNotExist:
allowance = None
return allowance
17 changes: 17 additions & 0 deletions edx_exams/apps/core/tests/test_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@
ExamAttemptFactory,
ExamFactory,
ProctoringProviderFactory,
StudentAllowanceFactory,
UserFactory
)

Expand Down Expand Up @@ -261,6 +262,22 @@ def test_start_attempt(self):
self.assertEqual(updated_attempt.start_time, timezone.now())
self.assertEqual(updated_attempt.allowed_time_limit_mins, self.exam.time_limit_mins)

def test_start_attempt_with_time_allowance(self):
"""
Test starting an exam with a time allowance grants the correct amount of time
"""
with freeze_time(timezone.now()):
StudentAllowanceFactory.create(
user=self.user,
exam=self.exam,
extra_time_mins=10
)
attempt_id = update_attempt_status(self.exam_attempt.id, ExamAttemptStatus.started)
updated_attempt = ExamAttempt.get_attempt_by_id(attempt_id)
self.assertEqual(updated_attempt.status, ExamAttemptStatus.started)
self.assertEqual(updated_attempt.start_time, timezone.now())
self.assertEqual(updated_attempt.allowed_time_limit_mins, self.exam.time_limit_mins + 10)

@ddt.data(
True,
False
Expand Down
38 changes: 36 additions & 2 deletions edx_exams/apps/core/tests/test_models.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,13 @@
from django_dynamic_fixture import G
from social_django.models import UserSocialAuth

from edx_exams.apps.core.models import CourseExamConfiguration, Exam, User
from edx_exams.apps.core.models import CourseExamConfiguration, Exam, StudentAllowance, User
from edx_exams.apps.core.test_utils.factories import (
CourseExamConfigurationFactory,
ExamFactory,
ProctoringProviderFactory
ProctoringProviderFactory,
StudentAllowanceFactory,
UserFactory
)


Expand Down Expand Up @@ -133,3 +135,35 @@ def test_create_or_update_new_config(self):
new_config = CourseExamConfiguration.objects.get(course_id=other_course_id)
self.assertEqual(new_config.provider, self.config.provider)
self.assertEqual(new_config.escalation_email, self.escalation_email)


class StudentAllowanceTests(TestCase):
"""
StudentAllowance model tests.
"""

def setUp(self):
super().setUp()

self.course_id = 'course-v1:edX+Test+Test_Course'
self.exam = ExamFactory(course_id=self.course_id)

def test_get_allowance_for_user(self):
user = UserFactory()
user_2 = UserFactory()
allowance = StudentAllowanceFactory(user=user, exam=self.exam)

self.assertEqual(StudentAllowance.get_allowance_for_user(self.exam.id, user.id), allowance)
self.assertIsNone(StudentAllowance.get_allowance_for_user(self.exam.id, user_2.id))

def test_get_allowances_for_course(self):
user = UserFactory()
user_2 = UserFactory()
exam_other_course = ExamFactory(course_id='course-v1:edX+Test+Test_Course_Other')
allowance = StudentAllowanceFactory(user=user, exam=self.exam)
allowance_2 = StudentAllowanceFactory(user=user_2, exam=self.exam)
StudentAllowanceFactory(user=user, exam=exam_other_course)

self.assertEqual(
set(StudentAllowance.get_allowances_for_course(self.course_id)), set([allowance, allowance_2])
)

0 comments on commit 9cd4618

Please sign in to comment.