Skip to content

Commit

Permalink
feat: add middleware routing for onboarding endpoint (#355)
Browse files Browse the repository at this point in the history
  • Loading branch information
alangsto authored Jan 29, 2025
1 parent 52f2f79 commit 1109ef3
Show file tree
Hide file tree
Showing 8 changed files with 223 additions and 4 deletions.
43 changes: 43 additions & 0 deletions edx_exams/apps/api/v1/tests/test_views.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
8 changes: 7 additions & 1 deletion edx_exams/apps/api/v1/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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'
),
]
18 changes: 18 additions & 0 deletions edx_exams/apps/api/v1/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
22 changes: 22 additions & 0 deletions edx_exams/apps/router/interop.py
Original file line number Diff line number Diff line change
Expand Up @@ -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__)

Expand Down Expand Up @@ -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)
Expand Down
15 changes: 13 additions & 2 deletions edx_exams/apps/router/middleware.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,16 +6,27 @@

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__)

LEGACY_VIEW_MAP = {
CourseExamsView: CourseExamsLegacyView,
CourseExamAttemptView: CourseExamAttemptLegacyView,
ProctoringSettingsView: ProctoringSettingsLegacyView,
UserOnboardingView: UserOnboardingLegacyView
}


Expand Down
33 changes: 33 additions & 0 deletions edx_exams/apps/router/tests/test_interop.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
get_active_exam_attempt,
get_provider_settings,
get_student_exam_attempt_data,
get_user_onboarding_data,
register_exams
)

Expand Down Expand Up @@ -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"})
59 changes: 59 additions & 0 deletions edx_exams/apps/router/tests/test_views.py
Original file line number Diff line number Diff line change
Expand Up @@ -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')
29 changes: 28 additions & 1 deletion edx_exams/apps/router/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -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__)

Expand Down Expand Up @@ -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
)

0 comments on commit 1109ef3

Please sign in to comment.