From 1109ef3aeb0da08aa2c1ef60d3b0db5db3266078 Mon Sep 17 00:00:00 2001 From: Alison Langston <46360176+alangsto@users.noreply.github.com> Date: Wed, 29 Jan 2025 11:33:38 -0500 Subject: [PATCH] feat: add middleware routing for onboarding endpoint (#355) --- edx_exams/apps/api/v1/tests/test_views.py | 43 +++++++++++++++ edx_exams/apps/api/v1/urls.py | 8 ++- edx_exams/apps/api/v1/views.py | 18 +++++++ edx_exams/apps/router/interop.py | 22 ++++++++ edx_exams/apps/router/middleware.py | 15 +++++- edx_exams/apps/router/tests/test_interop.py | 33 ++++++++++++ edx_exams/apps/router/tests/test_views.py | 59 +++++++++++++++++++++ edx_exams/apps/router/views.py | 29 +++++++++- 8 files changed, 223 insertions(+), 4 deletions(-) diff --git a/edx_exams/apps/api/v1/tests/test_views.py b/edx_exams/apps/api/v1/tests/test_views.py index 76a5405b..dcc5b85e 100644 --- a/edx_exams/apps/api/v1/tests/test_views.py +++ b/edx_exams/apps/api/v1/tests/test_views.py @@ -1996,3 +1996,46 @@ def test_post_unauthorized_course(self): ] response = self.request_api('post', self.user, self.exam.course_id, data=request_data) self.assertEqual(response.status_code, 400) + + +class UserOnboardingViewTest(ExamsAPITestCase): + """ + Tests UserOnboardingView + """ + def setUp(self): + super().setUp() + + self.course_id = 'course-v1:edx+test+f19' + CourseExamConfigurationFactory.create(course_id=self.course_id) + + def get_api(self): + """ + Helper function to make patch request to the API + """ + + headers = self.build_jwt_headers(self.user) + url = reverse( + 'api:v1:student-onboarding', + kwargs={'course_id': self.course_id} + ) + + return self.client.get(url, **headers) + + def test_auth_required(self): + """ + Test endpoint requires authentication + """ + + # no auth + response = self.client.get( + reverse('api:v1:student-onboarding', kwargs={'course_id': self.course_id}), + ) + self.assertEqual(response.status_code, 401) + + def test_404_response(self): + """ + Test that endpoint returns 404 response + """ + + response = self.get_api() + self.assertEqual(response.status_code, 404) diff --git a/edx_exams/apps/api/v1/urls.py b/edx_exams/apps/api/v1/urls.py index 73db0061..95b8e909 100644 --- a/edx_exams/apps/api/v1/urls.py +++ b/edx_exams/apps/api/v1/urls.py @@ -12,7 +12,8 @@ InstructorAttemptsListView, LatestExamAttemptView, ProctoringProvidersView, - ProctoringSettingsView + ProctoringSettingsView, + UserOnboardingView ) from edx_exams.apps.core.constants import COURSE_ID_PATTERN, EXAM_ID_PATTERN, USAGE_KEY_PATTERN @@ -79,4 +80,9 @@ ProctoringSettingsView.as_view(), name='proctoring-settings' ), + re_path( + fr'student/course_id/{COURSE_ID_PATTERN}/onboarding', + UserOnboardingView.as_view(), + name='student-onboarding' + ), ] diff --git a/edx_exams/apps/api/v1/views.py b/edx_exams/apps/api/v1/views.py index 9a07dff8..83608e9f 100644 --- a/edx_exams/apps/api/v1/views.py +++ b/edx_exams/apps/api/v1/views.py @@ -885,3 +885,21 @@ def post(self, request, course_id): response_status = status.HTTP_400_BAD_REQUEST data = {'detail': 'Invalid data', 'errors': serializer.errors} return Response(status=response_status, data=data) + + +class UserOnboardingView(ExamsAPIView): + """ + Endpoint to retrieve onboarding data. Note that onboarding exams are not currently supported + in edx-exams, but this endpoint has been created for middleware requests to edx-proctoring + + student/course_id/{course_id}/onboarding + """ + + authentication_classes = (JwtAuthentication,) + permission_classes = (IsAuthenticated,) + + def get(self, request, course_id): # pylint: disable=unused-argument + """ + HTTP GET handler. Returns a 404 as onboarding is not currently supported in the edx-exams service. + """ + return Response(status=status.HTTP_404_NOT_FOUND) diff --git a/edx_exams/apps/router/interop.py b/edx_exams/apps/router/interop.py index 905c1edf..eab1f96a 100644 --- a/edx_exams/apps/router/interop.py +++ b/edx_exams/apps/router/interop.py @@ -18,6 +18,7 @@ LMS_PROCTORED_EXAM_ATTEMPT_DATA_API_TPL = 'proctored_exam/attempt/course_id/{}?content_id={}&user_id={}' LMS_PROCTORED_EXAM_ATTEMPT_API = 'proctored_exam/attempt' LMS_PROCTORED_EXAM_PROVIDER_SETTINGS_API_TPL = 'proctored_exam/settings/exam_id/{}/' +LMS_PROCTORED_EXAM_ONBOARDING_DATA_API_TPL = 'user_onboarding/status?is_learning_mfe=true&course_id={}' log = logging.getLogger(__name__) @@ -82,6 +83,27 @@ def get_provider_settings(exam_id): return response_data, response.status_code +def get_user_onboarding_data(course_id, username=None): + """ + Get user onboarding data given a course_id and optional username + """ + template = LMS_PROCTORED_EXAM_ONBOARDING_DATA_API_TPL + + if username: + template += '&username={}' + path = template.format(course_id, username) + else: + path = template.format(course_id) + + response = _make_proctoring_request(path, 'GET') + + response_data = _get_json_data(response) + if response.status_code != status.HTTP_200_OK: + log.error(f'Failed to get onboarding data, response was {response.content}') + + return response_data, response.status_code + + def _make_proctoring_request(path, method, data=None): """ Make request to proctoring service """ url = _proctoring_api_url(path) diff --git a/edx_exams/apps/router/middleware.py b/edx_exams/apps/router/middleware.py index 41a5a2cb..d9105a7c 100644 --- a/edx_exams/apps/router/middleware.py +++ b/edx_exams/apps/router/middleware.py @@ -6,9 +6,19 @@ from django.utils.deprecation import MiddlewareMixin -from edx_exams.apps.api.v1.views import CourseExamAttemptView, CourseExamsView, ProctoringSettingsView +from edx_exams.apps.api.v1.views import ( + CourseExamAttemptView, + CourseExamsView, + ProctoringSettingsView, + UserOnboardingView +) from edx_exams.apps.core.models import CourseExamConfiguration -from edx_exams.apps.router.views import CourseExamAttemptLegacyView, CourseExamsLegacyView, ProctoringSettingsLegacyView +from edx_exams.apps.router.views import ( + CourseExamAttemptLegacyView, + CourseExamsLegacyView, + ProctoringSettingsLegacyView, + UserOnboardingLegacyView +) log = logging.getLogger(__name__) @@ -16,6 +26,7 @@ CourseExamsView: CourseExamsLegacyView, CourseExamAttemptView: CourseExamAttemptLegacyView, ProctoringSettingsView: ProctoringSettingsLegacyView, + UserOnboardingView: UserOnboardingLegacyView } diff --git a/edx_exams/apps/router/tests/test_interop.py b/edx_exams/apps/router/tests/test_interop.py index 307a6825..a7978950 100644 --- a/edx_exams/apps/router/tests/test_interop.py +++ b/edx_exams/apps/router/tests/test_interop.py @@ -14,6 +14,7 @@ get_active_exam_attempt, get_provider_settings, get_student_exam_attempt_data, + get_user_onboarding_data, register_exams ) @@ -154,3 +155,35 @@ def test_get_provider_settings(self, response_status): response_data, status = get_provider_settings(self.exam_id) self.assertEqual(status, response_status) self.assertEqual(response_data, {"foo": "bar"}) + + @ddt.data( + (200, None), + (422, None), + (200, 'edx'), + (422, 'edx') + ) + @mock_oauth_login + @responses.activate + @ddt.unpack + def test_get_onboarding_data(self, response_status, username): + """ + Request is authenticated and forwarded to the LMS. + HTTP exceptions are handled and response is returned for + non-200 states codes + """ + self.lms_url = ( + f'{settings.LMS_ROOT_URL}/api/edx_proctoring/v1/user_onboarding/status' + f'?is_learning_mfe=true&course_id={self.course_id}' + ) + if username: + self.lms_url += f'&username={username}' + + responder = responses.add( + responses.GET, + self.lms_url, + status=response_status, + json={"foo": "bar"}, + ) + response_data, status = get_user_onboarding_data(self.course_id, username) + self.assertEqual(status, response_status) + self.assertEqual(response_data, {"foo": "bar"}) diff --git a/edx_exams/apps/router/tests/test_views.py b/edx_exams/apps/router/tests/test_views.py index 0e0819e6..b30615a5 100644 --- a/edx_exams/apps/router/tests/test_views.py +++ b/edx_exams/apps/router/tests/test_views.py @@ -335,3 +335,62 @@ def test_get_lms_provider_settings(self, mock_get_provider_settings): self.assertEqual(response.status_code, 200) self.assertTrue(response.json().get('learner_notification_from_email')) self.assertEqual(response.json().get('provider_name'), 'Test Provider') + + +class UserOnboardingDataLegacyViewTest(ExamsAPITestCase): + """ + Tests for the legacy onboarding data endpoint. + """ + + def setUp(self): + super().setUp() + + self.course_id = 'course-v1:edx+test+f19' + self.username = 'edx' + self.url = reverse( + 'api:v1:student-onboarding', + kwargs={'course_id': self.course_id} + ) + f'?username={self.username}' + + def get_api(self, user): + """ + Helper function to make a patch request to the API + """ + headers = self.build_jwt_headers(user) + return self.client.get(self.url, **headers) + + def test_auth_failures(self): + """ + Verify the endpoint validates permissions + """ + # Test unauthenticated + response = self.client.get(self.url) + self.assertEqual(response.status_code, 401) + + @mock.patch('edx_exams.apps.router.views.get_user_onboarding_data') + def test_get_lms_onboarding_data_failed(self, mock_get_user_onboarding_data): + """ + An error response from the LMS should be returned with the same code + """ + mock_get_user_onboarding_data.return_value = ('some error', 500) + + response = self.get_api(self.user) + mock_get_user_onboarding_data.assert_called_once_with(self.course_id, self.username) + self.assertEqual(response.status_code, 500) + + @mock.patch('edx_exams.apps.router.views.get_user_onboarding_data') + def test_get_lms_onboarding_data(self, mock_get_user_onboarding_data): + """ + Provider settings data should be returned by the LMS + """ + mock_get_user_onboarding_data.return_value = ({ + 'onboarding_status': 'verified', + 'onboarding_link': 'test.com', + 'expiration_date': None, + 'onboarding_past_due': False, + }, 200) + response = self.get_api(self.user) + mock_get_user_onboarding_data.assert_called_once_with(self.course_id, self.username) + self.assertEqual(response.status_code, 200) + self.assertFalse(response.json().get('expiration_date')) + self.assertEqual(response.json().get('onboarding_status'), 'verified') diff --git a/edx_exams/apps/router/views.py b/edx_exams/apps/router/views.py index c8248ff5..0c9d7809 100644 --- a/edx_exams/apps/router/views.py +++ b/edx_exams/apps/router/views.py @@ -13,7 +13,12 @@ from edx_exams.apps.api.permissions import CourseStaffUserPermissions from edx_exams.apps.core.exam_types import get_exam_type -from edx_exams.apps.router.interop import get_provider_settings, get_student_exam_attempt_data, register_exams +from edx_exams.apps.router.interop import ( + get_provider_settings, + get_student_exam_attempt_data, + get_user_onboarding_data, + register_exams +) log = logging.getLogger(__name__) @@ -103,3 +108,25 @@ def get(self, request, course_id, exam_id): # pylint: disable=unused-argument status=response_status, safe=False ) + + +class UserOnboardingLegacyView(APIView): + """ + View to handle user onboarding for exams managed by edx-proctoring + """ + authentication_classes = (JwtAuthentication,) + permission_classes = (IsAuthenticated,) + + def get(self, request, course_id): + """ + Get user onboarding data given course_id and an optional username + """ + username = request.GET.get('username') + + response_data, response_status = get_user_onboarding_data(course_id, username) + + return JsonResponse( + data=response_data, + status=response_status, + safe=False + )