From 82e74fa624d5d85bce6f07dca10e83fecb0add8e Mon Sep 17 00:00:00 2001 From: Zachary Hancock Date: Thu, 16 Mar 2023 16:09:54 -0400 Subject: [PATCH] feat: API updates to support edx-exams (#1109) * Adds a new URL to return just the currently active exam attempt. This is the same behavior as calling the existing endpoint edx_proctoring/v1/proctored_exam/attempt/course_id/COURSE except it does not require a course id. * Adds a flag to exams/attempts returned to UI to indicate this data originated from edx-proctoring. This is so the UI can know what service to request attempt create / update on. --- CHANGELOG.rst | 5 ++ edx_proctoring/__init__.py | 2 +- edx_proctoring/api.py | 3 + edx_proctoring/tests/test_mfe_views.py | 102 +++++++++++++++++++++++++ edx_proctoring/urls.py | 5 ++ edx_proctoring/views.py | 65 ++++++++++++++-- package.json | 2 +- 7 files changed, 177 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.rst b/CHANGELOG.rst index d121a2358e8..50a7369d334 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -14,6 +14,11 @@ Change Log Unreleased ~~~~~~~~~~ +[4.15.0] - 2023-03-16 +~~~~~~~~~~~~~~~~~~~~~ +* Add new endpoint get the currently active exam attempt. +* Add parameter for staff users to request another users attempt. This is used by + the edx-exams service worker. [4.14.0] - 2023-02-28 ~~~~~~~~~~~~~~~~~~~~~ diff --git a/edx_proctoring/__init__.py b/edx_proctoring/__init__.py index bdc4aad92ce..db65dca9739 100644 --- a/edx_proctoring/__init__.py +++ b/edx_proctoring/__init__.py @@ -3,6 +3,6 @@ """ # Be sure to update the version number in edx_proctoring/package.json -__version__ = '4.14.0' +__version__ = '4.15.0' default_app_config = 'edx_proctoring.apps.EdxProctoringConfig' # pylint: disable=invalid-name diff --git a/edx_proctoring/api.py b/edx_proctoring/api.py index 9cbf78da032..748ff5dce76 100644 --- a/edx_proctoring/api.py +++ b/edx_proctoring/api.py @@ -828,6 +828,9 @@ def get_exam_attempt_data(exam_id, attempt_id, is_learning_mfe=False): args=[attempt['id']] ), 'attempt_ready_to_resume': is_attempt_ready_to_resume(attempt), + # used by the frontend to determine if attempt is managed by edx-proctoring + # instead of the newer edx-exams service + 'use_legacy_attempt_api': True, } if provider: diff --git a/edx_proctoring/tests/test_mfe_views.py b/edx_proctoring/tests/test_mfe_views.py index 525200f146c..c467caab618 100644 --- a/edx_proctoring/tests/test_mfe_views.py +++ b/edx_proctoring/tests/test_mfe_views.py @@ -10,6 +10,7 @@ from opaque_keys.edx.locator import BlockUsageLocator from django.conf import settings +from django.contrib.auth import get_user_model from django.test.utils import override_settings from django.urls import reverse from django.utils import timezone @@ -24,6 +25,8 @@ from .test_services import MockLearningSequencesService, MockScheduleItemData from .utils import ProctoredExamTestCase +User = get_user_model() + @override_settings(LEARNING_MICROFRONTEND_URL='https//learningmfe', ACCOUNT_MICROFRONTEND_URL='https//localhost') @ddt.ddt @@ -80,6 +83,33 @@ def assertHasExamData(self, response_data, has_attempt, has_download_url=False, self.assertEqual(exam_data['content_id'], self.content_id if not content_id else content_id) self.assertEqual(exam_data['time_limit_mins'], self.default_time_limit) + def test_staff_get_user_exam_data(self): + """ + Test staff can get any user's exam data. + """ + started_attempt = self._create_started_exam_attempt(is_proctored=True) + + # login as different staff user + admin_user = User(username='test_staff', email='staff@test.com', is_staff=True) + admin_user.save() + self.client.login_user(admin_user) + response = self.client.get(self.url + f'&user_id={self.user.id}') + response_data = json.loads(response.content.decode('utf-8')) + + self.assertEqual(response_data['active_attempt']['attempt_id'], started_attempt.id) + + def test_non_staff_cannot_get_specific_user_data(self): + """ + Test non staff cannot get other user's exam data. + """ + self._create_started_exam_attempt(is_proctored=True) + + other_user = User(username='nefarious_bob', email='tester_bob@test.com') + other_user.save() + self.client.login_user(other_user) + response = self.client.get(self.url + f'&user_id={self.user.id}') + self.assertEqual(response.status_code, 403) + def test_exam_total_time_with_allowance_time_before_exam_starts(self): """ Tests that exam has correct total time when user has additional @@ -368,6 +398,78 @@ def test_onboarding_errors_and_onboarding_exam_not_available(self): self.assertEqual(exam['onboarding_link'], '') +# @override_settings(LEARNING_MICROFRONTEND_URL='https//learningmfe', ACCOUNT_MICROFRONTEND_URL='https//localhost') +class ProctoredExamActiveAttemptsMFEViewTests(ProctoredExamTestCase): + """ + Tests for the ProctoredExamView called from MFE application. + """ + def setUp(self): + """ + Initialize + """ + super().setUp() + self.proctored_exam_id = self._create_proctored_exam() + self.url = reverse('edx_proctoring:proctored_exam.active_attempt') + + def test_get_started_attempt(self): + """ + Tests the get attempt for user with started attempt. + """ + started_attempt = self._create_started_exam_attempt(is_proctored=True) + self._create_exam_attempt( + self.proctored_exam_id, + ProctoredExamStudentAttemptStatus.rejected, + ) + + response = self.client.get(self.url) + self.assertEqual(response.status_code, 200) + response_data = json.loads(response.content.decode('utf-8')) + + self.assertEqual(response_data['attempt_id'], started_attempt.id) + + def test_get_no_started_attempts(self): + """ + Tests the get attempt for user with no started attempts. + """ + self._create_exam_attempt( + self.proctored_exam_id, + ProctoredExamStudentAttemptStatus.submitted, + ) + + response = self.client.get(self.url) + self.assertEqual(response.status_code, 200) + response_data = json.loads(response.content.decode('utf-8')) + + self.assertEqual(response_data, {}) + + def test_staff_get_user_attempt(self): + """ + Test staff can get any user's started attempt. + """ + started_attempt = self._create_started_exam_attempt(is_proctored=True) + + # login as different staff user + admin_user = User(username='test_staff', email='staff@test.com', is_staff=True) + admin_user.save() + self.client.login_user(admin_user) + response = self.client.get(self.url + f'?user_id={self.user.id}') + response_data = json.loads(response.content.decode('utf-8')) + + self.assertEqual(response_data['attempt_id'], started_attempt.id) + + def test_non_staff_cannot_get_user_attempt(self): + """ + Test non staff cannot get another user's attempt. + """ + self._create_started_exam_attempt(is_proctored=True) + + other_user = User(username='nefarious_bob', email='tester_bob@test.com') + other_user.save() + self.client.login_user(other_user) + response = self.client.get(self.url + f'?user_id={self.user.id}') + self.assertEqual(response.status_code, 403) + + class ProctoredSettingsViewTests(ProctoredExamTestCase): """ Tests for the ProctoredSettingsView. diff --git a/edx_proctoring/urls.py b/edx_proctoring/urls.py index 8a1658792a4..40313b6024b 100644 --- a/edx_proctoring/urls.py +++ b/edx_proctoring/urls.py @@ -116,6 +116,11 @@ views.ProctoredExamAttemptView.as_view(), name='proctored_exam.exam_attempts' ), + re_path( + 'edx_proctoring/v1/proctored_exam/active_attempt', + views.ProctoredExamActiveAttemptView.as_view(), + name='proctored_exam.active_attempt' + ), path('edx_proctoring/v1/proctored_exam/settings/exam_id//', views.ProctoredSettingsView.as_view(), name='proctored_exam.proctoring_settings' ), diff --git a/edx_proctoring/views.py b/edx_proctoring/views.py index 2076128e601..12a83d2e20e 100644 --- a/edx_proctoring/views.py +++ b/edx_proctoring/views.py @@ -112,6 +112,8 @@ obscured_user_id ) +User = get_user_model() + ATTEMPTS_PER_PAGE = 25 LOG = logging.getLogger("edx_proctoring_views") @@ -194,6 +196,47 @@ def handle_exception(self, exc): return resp +class ProctoredExamActiveAttemptView(ProctoredAPIView): + """ + Endpoint for getting attempt data for an active exam attempt. + + Supports: + HTTP GET: + Active attempt for any exam in progress (for the timer feature) + """ + def get(self, request): + """ + HTTP GET handler. Returns active attempt + """ + user_id = request.user.id + requested_user_id = request.GET.get('user_id', None) + if requested_user_id: + if request.user.is_staff: + user_id = requested_user_id + else: + return Response( + status=status.HTTP_403_FORBIDDEN, + data={'detail': 'Must be a Staff User to Perform this request.'} + ) + + active_exams = get_active_exams_for_user(user_id) + if active_exams: + # Even if there is more than one exam, we want the first one. + # Normally this should not be an issue as there will be list of one item. + active_exam_info = active_exams[0] + active_exam = active_exam_info['exam'] + active_attempt = active_exam_info['attempt'] + active_attempt_data = get_exam_attempt_data( + active_exam.get('id'), + active_attempt.get('id'), + is_learning_mfe=True + ) + else: + active_attempt_data = {} + + return Response(data=active_attempt_data) + + class ProctoredExamAttemptView(ProctoredAPIView): """ Endpoint for getting timed or proctored exam and its attempt data. @@ -228,7 +271,18 @@ def get(self, request, course_id, content_id=None): is_learning_mfe = request.GET.get('is_learning_mfe') in ['1', 'true', 'True'] content_id = request.GET.get('content_id', content_id) - active_exams = get_active_exams_for_user(request.user.id) + user_id = request.user.id + requested_user_id = request.GET.get('user_id', None) + if requested_user_id: + if request.user.is_staff: + user_id = requested_user_id + else: + return Response( + status=status.HTTP_403_FORBIDDEN, + data={'detail': 'Must be a Staff User to Perform this request.'} + ) + + active_exams = get_active_exams_for_user(user_id) if active_exams: # Even if there is more than one exam, we want the first one. # Normally this should not be an issue as there will be list of one item. @@ -254,7 +308,7 @@ def get(self, request, course_id, content_id=None): else: try: exam = get_exam_by_content_id(course_id, content_id) - attempt = get_current_exam_attempt(exam.get('id'), request.user.id) + attempt = get_current_exam_attempt(exam.get('id'), user_id) if attempt: attempt_data = get_exam_attempt_data( exam.get('id'), @@ -264,14 +318,14 @@ def get(self, request, course_id, content_id=None): else: # calculate total allowed time for the exam including # allowance time to show on the MFE entrance pages - exam['total_time'] = get_total_allowed_time_for_exam(exam, request.user.id) + exam['total_time'] = get_total_allowed_time_for_exam(exam, user_id) # Exam hasn't been started yet but it is proctored so needs to be checked # if prerequisites are satisfied. We only do this for proctored exam hence # additional check 'not exam['is_practice_exam']', meaning we do not check # prerequisites for practice or onboarding exams if exam['is_proctored'] and not exam['is_practice_exam']: - exam = check_prerequisites(exam, request.user.id) + exam = check_prerequisites(exam, user_id) # if user hasn't completed required onboarding exam before taking # proctored exam we need to navigate them to it with a link @@ -284,7 +338,8 @@ def get(self, request, course_id, content_id=None): if exam: provider = get_backend_provider(exam) exam['type'] = get_exam_type(exam, provider)['type'] - exam['passed_due_date'] = is_exam_passed_due(exam, user=request.user.id) + exam['passed_due_date'] = is_exam_passed_due(exam, user=User.objects.get(id=user_id)) + exam['use_legacy_attempt_api'] = True response_dict = { 'exam': exam, 'active_attempt': active_attempt_data, diff --git a/package.json b/package.json index 536095ef741..7f8668b2c3c 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "@edx/edx-proctoring", "//": "Note that the version format is slightly different than that of the Python version when using prereleases.", - "version": "4.14.0", + "version": "4.15.0", "main": "edx_proctoring/static/index.js", "scripts": { "test": "gulp test"