diff --git a/edx_exams/apps/api/v1/tests/test_views.py b/edx_exams/apps/api/v1/tests/test_views.py index da1be480..3a7b088d 100644 --- a/edx_exams/apps/api/v1/tests/test_views.py +++ b/edx_exams/apps/api/v1/tests/test_views.py @@ -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 diff --git a/edx_exams/apps/api/v1/views.py b/edx_exams/apps/api/v1/views.py index 533fbaea..ce0e29f1 100644 --- a/edx_exams/apps/api/v1/views.py +++ b/edx_exams/apps/api/v1/views.py @@ -711,6 +711,7 @@ 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) @@ -718,13 +719,18 @@ def get(self, request, course_id, content_id): # pylint: disable=unused-argumen 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) diff --git a/edx_exams/apps/core/api.py b/edx_exams/apps/core/api.py index b9855194..d5c98791 100644 --- a/edx_exams/apps/core/api.py +++ b/edx_exams/apps/core/api.py @@ -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, @@ -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) @@ -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: diff --git a/edx_exams/apps/core/models.py b/edx_exams/apps/core/models.py index 7fe68dde..52456fc5 100644 --- a/edx_exams/apps/core/models.py +++ b/edx_exams/apps/core/models.py @@ -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 diff --git a/edx_exams/apps/core/tests/test_api.py b/edx_exams/apps/core/tests/test_api.py index b449e8bc..718247ea 100644 --- a/edx_exams/apps/core/tests/test_api.py +++ b/edx_exams/apps/core/tests/test_api.py @@ -43,6 +43,7 @@ ExamAttemptFactory, ExamFactory, ProctoringProviderFactory, + StudentAllowanceFactory, UserFactory ) @@ -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 diff --git a/edx_exams/apps/core/tests/test_models.py b/edx_exams/apps/core/tests/test_models.py index 9b7c0808..09993426 100644 --- a/edx_exams/apps/core/tests/test_models.py +++ b/edx_exams/apps/core/tests/test_models.py @@ -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 ) @@ -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]) + )