From 592a673e3e46c26c814c36cc321467b65133b626 Mon Sep 17 00:00:00 2001 From: Brian Buck Date: Wed, 13 May 2026 15:30:04 -0600 Subject: [PATCH 1/3] feat: add optional ordering param to exam query functions --- edx_proctoring/api.py | 16 +- edx_proctoring/tests/test_api_ordering.py | 274 ++++++++++++++++++++++ 2 files changed, 286 insertions(+), 4 deletions(-) create mode 100644 edx_proctoring/tests/test_api_ordering.py diff --git a/edx_proctoring/api.py b/edx_proctoring/api.py index 72fb270a64..eda731ee6d 100644 --- a/edx_proctoring/api.py +++ b/edx_proctoring/api.py @@ -519,13 +519,15 @@ def add_allowance_for_user(exam_id, user_info, key, value): emit_event(exam, f'allowance.{action}', override_data=data) -def get_allowances_for_course(course_id): +def get_allowances_for_course(course_id, ordering=None): """ Get all the allowances for the course. """ student_allowances = ProctoredExamStudentAllowance.get_allowances_for_course( course_id ) + if ordering: + student_allowances = student_allowances.order_by(ordering) return [ProctoredExamStudentAllowanceSerializer(allowance).data for allowance in student_allowances] @@ -1948,7 +1950,7 @@ def remove_exam_attempt(attempt_id, requesting_user): emit_event(exam, 'deleted', attempt=attempt) -def get_all_exams_for_course(course_id, active_only=False): +def get_all_exams_for_course(course_id, active_only=False, ordering=None): """ This method will return all exams for a course. This will return a list of dictionaries, whose schema is the same as what is returned in @@ -1976,23 +1978,29 @@ def get_all_exams_for_course(course_id, active_only=False): course_id, active_only=active_only ) + if ordering: + exams = exams.order_by(ordering) return [ProctoredExamSerializer(proctored_exam).data for proctored_exam in exams] -def get_all_exam_attempts(course_id): +def get_all_exam_attempts(course_id, ordering=None): """ Returns all the exam attempts for the course id. """ exam_attempts = ProctoredExamStudentAttempt.objects.get_all_exam_attempts(course_id) + if ordering: + exam_attempts = exam_attempts.order_by(ordering) return [ProctoredExamStudentAttemptSerializer(active_exam).data for active_exam in exam_attempts] -def get_filtered_exam_attempts(course_id, search_by): +def get_filtered_exam_attempts(course_id, search_by, ordering=None): """ Returns all exam attempts for a course id filtered by the search_by string in user names and emails. """ exam_attempts = ProctoredExamStudentAttempt.objects.get_filtered_exam_attempts(course_id, search_by) + if ordering: + exam_attempts = exam_attempts.order_by(ordering) return [ProctoredExamStudentAttemptSerializer(active_exam).data for active_exam in exam_attempts] diff --git a/edx_proctoring/tests/test_api_ordering.py b/edx_proctoring/tests/test_api_ordering.py new file mode 100644 index 0000000000..f4b59e9564 --- /dev/null +++ b/edx_proctoring/tests/test_api_ordering.py @@ -0,0 +1,274 @@ +""" +Tests for optional ordering parameter on api functions. + +See: https://github.com/openedx/edx-proctoring/issues/1320 +""" +from datetime import datetime, timedelta + +import pytz + +from edx_proctoring.api import ( + create_exam, + create_exam_attempt, + get_all_exam_attempts, + get_all_exams_for_course, + get_allowances_for_course, + get_filtered_exam_attempts, +) +from edx_proctoring.models import ProctoredExamStudentAllowance +from edx_proctoring.runtime import set_runtime_service +from edx_proctoring.statuses import ProctoredExamStudentAttemptStatus +from edx_proctoring.tests.test_services import ( + MockCertificateService, + MockCreditService, + MockInstructorService, + MockNameAffirmationService, +) +from edx_proctoring.tests.test_utils.utils import ProctoredExamTestCase + + +class TestGetAllExamAttemptsOrdering(ProctoredExamTestCase): + """ + Tests for the ordering parameter on get_all_exam_attempts. + """ + + def setUp(self): + super().setUp() + self.proctored_exam_id = self._create_proctored_exam() + set_runtime_service('credit', MockCreditService()) + set_runtime_service('certificates', MockCertificateService()) + set_runtime_service('instructor', MockInstructorService()) + set_runtime_service('name_affirmation', MockNameAffirmationService()) + + def test_default_ordering_without_param(self): + """ + When ordering is not provided, results use the default queryset ordering. + """ + exam_id_2 = create_exam( + course_id=self.course_id, + content_id='test_content_2', + exam_name='Second Exam', + time_limit_mins=self.default_time_limit, + ) + create_exam_attempt(exam_id=self.proctored_exam_id, user_id=self.user_id) + create_exam_attempt(exam_id=exam_id_2, user_id=self.user_id) + + results = get_all_exam_attempts(self.course_id) + self.assertEqual(len(results), 2) + # Default is -created, so newest first + self.assertGreaterEqual(results[0]['created'], results[1]['created']) + + def test_ordering_ascending(self): + """ + When ordering='created', results are sorted oldest first. + """ + exam_id_2 = create_exam( + course_id=self.course_id, + content_id='test_content_2', + exam_name='Second Exam', + time_limit_mins=self.default_time_limit, + ) + create_exam_attempt(exam_id=self.proctored_exam_id, user_id=self.user_id) + create_exam_attempt(exam_id=exam_id_2, user_id=self.user_id) + + results = get_all_exam_attempts(self.course_id, ordering='created') + self.assertEqual(len(results), 2) + self.assertLessEqual(results[0]['created'], results[1]['created']) + + def test_ordering_descending(self): + """ + When ordering='-created', results are sorted newest first. + """ + exam_id_2 = create_exam( + course_id=self.course_id, + content_id='test_content_2', + exam_name='Second Exam', + time_limit_mins=self.default_time_limit, + ) + create_exam_attempt(exam_id=self.proctored_exam_id, user_id=self.user_id) + create_exam_attempt(exam_id=exam_id_2, user_id=self.user_id) + + results = get_all_exam_attempts(self.course_id, ordering='-created') + self.assertEqual(len(results), 2) + self.assertGreaterEqual(results[0]['created'], results[1]['created']) + + def test_ordering_none_is_backward_compatible(self): + """ + Passing ordering=None behaves the same as not passing it. + """ + create_exam_attempt(exam_id=self.proctored_exam_id, user_id=self.user_id) + results_default = get_all_exam_attempts(self.course_id) + results_none = get_all_exam_attempts(self.course_id, ordering=None) + self.assertEqual(results_default, results_none) + + +class TestGetFilteredExamAttemptsOrdering(ProctoredExamTestCase): + """ + Tests for the ordering parameter on get_filtered_exam_attempts. + """ + + def setUp(self): + super().setUp() + self.proctored_exam_id = self._create_proctored_exam() + set_runtime_service('credit', MockCreditService()) + set_runtime_service('certificates', MockCertificateService()) + set_runtime_service('instructor', MockInstructorService()) + set_runtime_service('name_affirmation', MockNameAffirmationService()) + + def test_default_ordering_without_param(self): + """ + When ordering is not provided, results use the default queryset ordering. + """ + exam_id_2 = create_exam( + course_id=self.course_id, + content_id='test_content_2', + exam_name='Second Exam', + time_limit_mins=self.default_time_limit, + ) + create_exam_attempt(exam_id=self.proctored_exam_id, user_id=self.user_id) + create_exam_attempt(exam_id=exam_id_2, user_id=self.user_id) + + results = get_filtered_exam_attempts(self.course_id, self.user.username) + self.assertEqual(len(results), 2) + self.assertGreaterEqual(results[0]['created'], results[1]['created']) + + def test_ordering_ascending(self): + """ + When ordering='created', results are sorted oldest first. + """ + exam_id_2 = create_exam( + course_id=self.course_id, + content_id='test_content_2', + exam_name='Second Exam', + time_limit_mins=self.default_time_limit, + ) + create_exam_attempt(exam_id=self.proctored_exam_id, user_id=self.user_id) + create_exam_attempt(exam_id=exam_id_2, user_id=self.user_id) + + results = get_filtered_exam_attempts(self.course_id, self.user.username, ordering='created') + self.assertEqual(len(results), 2) + self.assertLessEqual(results[0]['created'], results[1]['created']) + + def test_ordering_none_is_backward_compatible(self): + """ + Passing ordering=None behaves the same as not passing it. + """ + create_exam_attempt(exam_id=self.proctored_exam_id, user_id=self.user_id) + results_default = get_filtered_exam_attempts(self.course_id, self.user.username) + results_none = get_filtered_exam_attempts(self.course_id, self.user.username, ordering=None) + self.assertEqual(results_default, results_none) + + +class TestGetAllExamsForCourseOrdering(ProctoredExamTestCase): + """ + Tests for the ordering parameter on get_all_exams_for_course. + """ + + def setUp(self): + super().setUp() + set_runtime_service('credit', MockCreditService()) + set_runtime_service('certificates', MockCertificateService()) + set_runtime_service('instructor', MockInstructorService()) + set_runtime_service('name_affirmation', MockNameAffirmationService()) + # Create exams with different names for ordering tests + self.exam_id_a = create_exam( + course_id=self.course_id, + content_id='content_a', + exam_name='Alpha Exam', + time_limit_mins=30, + ) + self.exam_id_b = create_exam( + course_id=self.course_id, + content_id='content_b', + exam_name='Beta Exam', + time_limit_mins=60, + ) + + def test_default_ordering_without_param(self): + """ + When ordering is not provided, results use the default queryset ordering. + """ + results = get_all_exams_for_course(self.course_id) + self.assertEqual(len(results), 2) + + def test_ordering_by_exam_name(self): + """ + When ordering='exam_name', results are sorted alphabetically by name. + """ + results = get_all_exams_for_course(self.course_id, ordering='exam_name') + self.assertEqual(results[0]['exam_name'], 'Alpha Exam') + self.assertEqual(results[1]['exam_name'], 'Beta Exam') + + def test_ordering_by_exam_name_descending(self): + """ + When ordering='-exam_name', results are sorted reverse alphabetically. + """ + results = get_all_exams_for_course(self.course_id, ordering='-exam_name') + self.assertEqual(results[0]['exam_name'], 'Beta Exam') + self.assertEqual(results[1]['exam_name'], 'Alpha Exam') + + def test_ordering_none_is_backward_compatible(self): + """ + Passing ordering=None behaves the same as not passing it. + """ + results_default = get_all_exams_for_course(self.course_id) + results_none = get_all_exams_for_course(self.course_id, ordering=None) + self.assertEqual(results_default, results_none) + + +class TestGetAllowancesForCourseOrdering(ProctoredExamTestCase): + """ + Tests for the ordering parameter on get_allowances_for_course. + """ + + def setUp(self): + super().setUp() + self.proctored_exam_id = self._create_proctored_exam() + set_runtime_service('credit', MockCreditService()) + set_runtime_service('certificates', MockCertificateService()) + set_runtime_service('instructor', MockInstructorService()) + set_runtime_service('name_affirmation', MockNameAffirmationService()) + # Create two allowances with different values + ProctoredExamStudentAllowance.objects.create( + proctored_exam_id=self.proctored_exam_id, + user_id=self.user_id, + key='additional_time_granted', + value='10', + ) + ProctoredExamStudentAllowance.objects.create( + proctored_exam_id=self.proctored_exam_id, + user_id=self.user_id, + key='review_policy_exception', + value='5', + ) + + def test_default_ordering_without_param(self): + """ + When ordering is not provided, results use the default queryset ordering. + """ + results = get_allowances_for_course(self.course_id) + self.assertEqual(len(results), 2) + + def test_ordering_by_created(self): + """ + When ordering='created', results are sorted by creation time ascending. + """ + results = get_allowances_for_course(self.course_id, ordering='created') + self.assertEqual(len(results), 2) + self.assertLessEqual(results[0]['created'], results[1]['created']) + + def test_ordering_descending(self): + """ + When ordering='-created', results are sorted by creation time descending. + """ + results = get_allowances_for_course(self.course_id, ordering='-created') + self.assertEqual(len(results), 2) + self.assertGreaterEqual(results[0]['created'], results[1]['created']) + + def test_ordering_none_is_backward_compatible(self): + """ + Passing ordering=None behaves the same as not passing it. + """ + results_default = get_allowances_for_course(self.course_id) + results_none = get_allowances_for_course(self.course_id, ordering=None) + self.assertEqual(results_default, results_none) From 2fe235b164f208f0bad8856317f462f4a79e4426 Mon Sep 17 00:00:00 2001 From: Brian Buck Date: Thu, 14 May 2026 09:34:27 -0600 Subject: [PATCH 2/3] fix: Remove unused imports in test module --- edx_proctoring/tests/test_api_ordering.py | 5 ----- 1 file changed, 5 deletions(-) diff --git a/edx_proctoring/tests/test_api_ordering.py b/edx_proctoring/tests/test_api_ordering.py index f4b59e9564..1b02159a9e 100644 --- a/edx_proctoring/tests/test_api_ordering.py +++ b/edx_proctoring/tests/test_api_ordering.py @@ -3,10 +3,6 @@ See: https://github.com/openedx/edx-proctoring/issues/1320 """ -from datetime import datetime, timedelta - -import pytz - from edx_proctoring.api import ( create_exam, create_exam_attempt, @@ -17,7 +13,6 @@ ) from edx_proctoring.models import ProctoredExamStudentAllowance from edx_proctoring.runtime import set_runtime_service -from edx_proctoring.statuses import ProctoredExamStudentAttemptStatus from edx_proctoring.tests.test_services import ( MockCertificateService, MockCreditService, From 7758d542ce1f63757a797bddc5b00c72611ced8d Mon Sep 17 00:00:00 2001 From: Brian Buck Date: Thu, 14 May 2026 09:42:11 -0600 Subject: [PATCH 3/3] fix: Update formatting of imports for isort --- edx_proctoring/tests/test_api_ordering.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/edx_proctoring/tests/test_api_ordering.py b/edx_proctoring/tests/test_api_ordering.py index 1b02159a9e..d5ce928039 100644 --- a/edx_proctoring/tests/test_api_ordering.py +++ b/edx_proctoring/tests/test_api_ordering.py @@ -9,7 +9,7 @@ get_all_exam_attempts, get_all_exams_for_course, get_allowances_for_course, - get_filtered_exam_attempts, + get_filtered_exam_attempts ) from edx_proctoring.models import ProctoredExamStudentAllowance from edx_proctoring.runtime import set_runtime_service @@ -17,7 +17,7 @@ MockCertificateService, MockCreditService, MockInstructorService, - MockNameAffirmationService, + MockNameAffirmationService ) from edx_proctoring.tests.test_utils.utils import ProctoredExamTestCase