diff --git a/CHANGELOG.rst b/CHANGELOG.rst index c49a0fb1191..22468752757 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -14,6 +14,11 @@ Change Log Unreleased ~~~~~~~~~~ +[2.5.1] - 2020-12-10 +~~~~~~~~~~~~~~~~~~~~ + +* Add endpoint to expose the learner's onboarding status + [2.5.0] - 2020-12-09 ~~~~~~~~~~~~~~~~~~~~ diff --git a/edx_proctoring/__init__.py b/edx_proctoring/__init__.py index e62a0f8a680..1c559c4bfd9 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__ = '2.5.0' +__version__ = '2.5.1' default_app_config = 'edx_proctoring.apps.EdxProctoringConfig' # pylint: disable=invalid-name diff --git a/edx_proctoring/tests/test_views.py b/edx_proctoring/tests/test_views.py index 66d4e905a7a..8028ffeec5c 100644 --- a/edx_proctoring/tests/test_views.py +++ b/edx_proctoring/tests/test_views.py @@ -408,6 +408,217 @@ def test_get_exam_insufficient_args(self): self.assertEqual(response_data['time_limit_mins'], proctored_exam.time_limit_mins) +class TestStudentOnboardingStatusView(LoggedInTestCase): + """ + Tests for StudentOnboardingStatusView + """ + def setUp(self): + super(TestStudentOnboardingStatusView, self).setUp() + set_runtime_service('instructor', MockInstructorService(is_user_course_staff=False)) + self.other_user = User.objects.create(username='otheruser', password='test') + + def _create_onboarding_exam(self): + """ + Create an onboarding exam + """ + onboarding_exam = ProctoredExam.objects.create( + course_id='a/b/c', + content_id='test_content', + exam_name='Test Exam', + external_id='123aXqe3', + time_limit_mins=90, + is_active=True, + is_proctored=True, + is_practice_exam=True, + backend='test', + ) + return onboarding_exam + + def _create_onboarding_exam_attempt(self, onboarding_exam, user): + """ + Create an exam attempt related to the given onboarding exam + """ + attempt_id = create_exam_attempt(onboarding_exam.id, user.id, True) + attempt = ProctoredExamStudentAttempt.objects.filter(id=attempt_id).first() + return attempt + + def test_no_course_id(self): + response = self.client.get(reverse('edx_proctoring:user_onboarding.status')) + self.assertEqual(response.status_code, 400) + response_data = json.loads(response.content.decode('utf-8')) + message = 'Missing required query parameter course_id' + self.assertEqual(response_data['detail'], message) + + def test_no_username(self): + onboarding_exam = self._create_onboarding_exam() + # Create the user's own attempt + own_attempt = self._create_onboarding_exam_attempt(onboarding_exam, self.user) + own_attempt.status = ProctoredExamStudentAttemptStatus.submitted + own_attempt.save() + # Create another user's attempt + other_attempt = self._create_onboarding_exam_attempt(onboarding_exam, self.other_user) + other_attempt.status = ProctoredExamStudentAttemptStatus.verified + other_attempt.save() + # Assert that the onboarding status returned is 'submitted' + response = self.client.get( + reverse('edx_proctoring:user_onboarding.status') + + '?course_id={}'.format(onboarding_exam.course_id) + ) + self.assertEqual(response.status_code, 200) + response_data = json.loads(response.content.decode('utf-8')) + self.assertEqual(response_data['onboarding_status'], ProctoredExamStudentAttemptStatus.submitted) + + def test_unauthorized(self): + onboarding_exam = self._create_onboarding_exam() + response = self.client.get( + reverse('edx_proctoring:user_onboarding.status') + + '?username={}&course_id={}'.format(self.other_user.username, onboarding_exam.course_id) + ) + self.assertEqual(response.status_code, 403) + response_data = json.loads(response.content.decode('utf-8')) + message = 'Must be a Staff User to Perform this request.' + self.assertEqual(response_data['detail'], message) + + def test_staff_authorization(self): + self.user.is_staff = True + self.user.save() + onboarding_exam = self._create_onboarding_exam() + response = self.client.get( + reverse('edx_proctoring:user_onboarding.status') + + '?username={}&course_id={}'.format(self.other_user.username, onboarding_exam.course_id) + ) + self.assertEqual(response.status_code, 200) + # Should also work for course staff + set_runtime_service('instructor', MockInstructorService(is_user_course_staff=True)) + self.user.is_staff = False + self.user.save() + response = self.client.get( + reverse('edx_proctoring:user_onboarding.status') + + '?username={}&course_id={}'.format(self.other_user.username, onboarding_exam.course_id) + ) + self.assertEqual(response.status_code, 200) + + def test_no_onboarding_exam(self): + response = self.client.get( + reverse('edx_proctoring:user_onboarding.status') + + '?course_id=edX/DemoX/Demo_Course' + ) + self.assertEqual(response.status_code, 404) + response_data = json.loads(response.content.decode('utf-8')) + message = 'There is no onboarding exam related to this course id.' + self.assertEqual(response_data['detail'], message) + + def test_no_exam_attempts(self): + onboarding_exam = self._create_onboarding_exam() + response = self.client.get( + reverse('edx_proctoring:user_onboarding.status') + + '?course_id={}'.format(onboarding_exam.course_id) + ) + self.assertEqual(response.status_code, 200) + response_data = json.loads(response.content.decode('utf-8')) + self.assertIsNone(response_data['onboarding_status']) + self.assertEqual(response_data['onboarding_link'], reverse( + 'jump_to', + args=[onboarding_exam.course_id, onboarding_exam.content_id] + )) + + def test_no_verified_attempts(self): + onboarding_exam = self._create_onboarding_exam() + # Create first attempt + attempt = self._create_onboarding_exam_attempt(onboarding_exam, self.user) + attempt.status = ProctoredExamStudentAttemptStatus.timed_out + attempt.save() + response = self.client.get( + reverse('edx_proctoring:user_onboarding.status') + + '?course_id={}'.format(onboarding_exam.course_id) + ) + self.assertEqual(response.status_code, 200) + response_data = json.loads(response.content.decode('utf-8')) + self.assertEqual(response_data['onboarding_status'], ProctoredExamStudentAttemptStatus.timed_out) + self.assertEqual(response_data['onboarding_link'], reverse( + 'jump_to', + args=[onboarding_exam.course_id, onboarding_exam.content_id] + )) + # Create second attempt and assert that most recent attempt is returned + attempt = self._create_onboarding_exam_attempt(onboarding_exam, self.user) + response = self.client.get( + reverse('edx_proctoring:user_onboarding.status') + + '?course_id={}'.format(onboarding_exam.course_id) + ) + self.assertEqual(response.status_code, 200) + response_data = json.loads(response.content.decode('utf-8')) + self.assertEqual(response_data['onboarding_status'], ProctoredExamStudentAttemptStatus.created) + self.assertEqual(response_data['onboarding_link'], reverse( + 'jump_to', + args=[onboarding_exam.course_id, onboarding_exam.content_id] + )) + + def test_get_verified_attempt(self): + onboarding_exam = self._create_onboarding_exam() + # Create first attempt + attempt = self._create_onboarding_exam_attempt(onboarding_exam, self.user) + attempt.status = ProctoredExamStudentAttemptStatus.verified + attempt.save() + response = self.client.get( + reverse('edx_proctoring:user_onboarding.status') + + '?course_id={}'.format(onboarding_exam.course_id) + ) + self.assertEqual(response.status_code, 200) + response_data = json.loads(response.content.decode('utf-8')) + self.assertEqual(response_data['onboarding_status'], ProctoredExamStudentAttemptStatus.verified) + self.assertEqual(response_data['onboarding_link'], reverse( + 'jump_to', + args=[onboarding_exam.course_id, onboarding_exam.content_id] + )) + # Create second attempt and assert that verified attempt is still returned + attempt = self._create_onboarding_exam_attempt(onboarding_exam, self.user) + response = self.client.get( + reverse('edx_proctoring:user_onboarding.status') + + '?course_id={}'.format(onboarding_exam.course_id) + ) + self.assertEqual(response.status_code, 200) + response_data = json.loads(response.content.decode('utf-8')) + self.assertEqual(response_data['onboarding_status'], ProctoredExamStudentAttemptStatus.verified) + self.assertEqual(response_data['onboarding_link'], reverse( + 'jump_to', + args=[onboarding_exam.course_id, onboarding_exam.content_id] + )) + + def test_only_onboarding_exam(self): + # Create an onboarding exam, along with a practice exam and + # a proctored exam, all in the same course + onboarding_exam = self._create_onboarding_exam() + ProctoredExam.objects.create( + course_id='a/b/c', + content_id='practice_content', + exam_name='Practice Exam', + external_id='123aXqe4', + time_limit_mins=90, + is_active=True, + is_practice_exam=True, + backend='test', + ) + ProctoredExam.objects.create( + course_id='a/b/c', + content_id='proctored_content', + exam_name='Proctored Exam', + external_id='123aXqe5', + time_limit_mins=90, + is_active=True, + is_proctored=True, + backend='test', + ) + # Assert that the onboarding exam link is returned + response = self.client.get( + reverse('edx_proctoring:user_onboarding.status') + + '?course_id=a/b/c' + ) + self.assertEqual(response.status_code, 200) + response_data = json.loads(response.content.decode('utf-8')) + onboarding_link = reverse('jump_to', args=['a/b/c', onboarding_exam.content_id]) + self.assertEqual(response_data['onboarding_link'], onboarding_link) + + @ddt.ddt class TestStudentProctoredExamAttempt(LoggedInTestCase): """ diff --git a/edx_proctoring/urls.py b/edx_proctoring/urls.py index ed2f3362404..12e5457d44d 100644 --- a/edx_proctoring/urls.py +++ b/edx_proctoring/urls.py @@ -83,6 +83,11 @@ views.ActiveExamsForUserView.as_view(), name='proctored_exam.active_exams_for_user' ), + url( + r'edx_proctoring/v1/user_onboarding/status$', + views.StudentOnboardingStatusView.as_view(), + name='user_onboarding.status' + ), url( r'edx_proctoring/v1/instructor/{}$'.format(settings.COURSE_ID_PATTERN), views.InstructorDashboard.as_view(), diff --git a/edx_proctoring/views.py b/edx_proctoring/views.py index e5a34911a12..d735f42e95e 100644 --- a/edx_proctoring/views.py +++ b/edx_proctoring/views.py @@ -4,6 +4,7 @@ import json import logging +from itertools import chain import waffle from crum import get_current_request @@ -286,6 +287,97 @@ def get(self, request, exam_id=None, course_id=None, content_id=None): # pylint return Response(data) +class StudentOnboardingStatusView(ProctoredAPIView): + """ + Endpoint for the StudentOnboardingStatusView + + Supports: + HTTP GET: returns the learner's onboarding status relative to the given course_id + + HTTP GET + /edx_proctoring/v1/user_onboarding/status?course_id={course_id}&username={username} + + **Query Parameters** + * 'course_id': The unique identifier for the course. + * 'username': Optional. If not given, the endpoint will return the user's own status. + ** In order to view other users' statuses, the user must be course or global staff. + + **Response Values** + * 'onboarding_status': String specifying the learner's onboarding status. + ** Will return NULL if there are no onboarding attempts, or the given user does not exist + * 'onboarding_link': Link to the onboarding exam. + """ + def get(self, request): + """ + HTTP GET handler. Returns the learner's onboarding status. + """ + data = { + 'onboarding_status': None, + 'onboarding_link': None + } + + attempt_filters = { + 'proctored_exam__is_practice_exam': True, + 'taking_as_proctored': True, + 'user__username': request.user.username + } + + username = request.GET.get('username') + course_id = request.GET.get('course_id') + + if not course_id: + # This parameter is currently required, as the onboarding experience is tied + # to a single course. However, this could be dropped in future iterations. + return Response( + status=400, + data={'detail': _('Missing required query parameter course_id')} + ) + + if username: + # Check that the user is staff if trying to view another user's status + if username != request.user.username: + if ((course_id and not is_user_course_or_global_staff(request.user, course_id)) or + (not course_id and not request.user.is_staff)): + return Response( + status=status.HTTP_403_FORBIDDEN, + data={'detail': _('Must be a Staff User to Perform this request.')} + ) + attempt_filters['user__username'] = username + + # If there are multiple onboarding exams, use the first exam + onboarding_exam = ProctoredExam.objects.filter( + course_id=course_id, + is_active=True, + is_practice_exam=True, + is_proctored=True + ).order_by('-created').first() + if not onboarding_exam: + return Response( + status=404, + data={'detail': _('There is no onboarding exam related to this course id.')} + ) + # Also filter attempts by the course_id + attempt_filters['proctored_exam__course_id'] = course_id + + data['onboarding_link'] = reverse('jump_to', args=[course_id, onboarding_exam.content_id]) + + recent_attempts = ProctoredExamStudentAttempt.objects.filter(**attempt_filters).order_by('-modified') + past_attempts = ProctoredExamStudentAttemptHistory.objects.filter(**attempt_filters).order_by('-modified') + attempts = list(chain(recent_attempts, past_attempts)) + if len(attempts) == 0: + # If there are no attempts, return the data with 'onboarding_status' set to None + return Response(data) + + # Default to the most recent attempt if there are no verified attempts + relevant_attempt = attempts[0] + for attempt in attempts: + if attempt.status == ProctoredExamStudentAttemptStatus.verified: + relevant_attempt = attempt + data['onboarding_status'] = relevant_attempt.status + + return Response(data) + + class StudentProctoredExamAttempt(ProctoredAPIView): """ Endpoint for the StudentProctoredExamAttempt @@ -1091,7 +1183,7 @@ class BackendUserManagementAPI(AuthenticatedAPIView): """ Manage user information stored on the backends """ - def post(self, request, user_id): # pylint: disable=unused-argument + def post(self, request, user_id, **kwargs): # pylint: disable=unused-argument """ Deletes all user data for the particular user_id from all configured backends diff --git a/package.json b/package.json index 8fff18877e4..f5471f4636d 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "name": "@edx/edx-proctoring", "//": "Be sure to update the version number in edx_proctoring/__init__.py", "//": "Note that the version format is slightly different than that of the Python version when using prereleases.", - "version": "2.5.0", + "version": "2.5.1", "main": "edx_proctoring/static/index.js", "repository": { "type": "git", diff --git a/test_urls.py b/test_urls.py index f325df54af4..9f28e3dbb36 100644 --- a/test_urls.py +++ b/test_urls.py @@ -1,5 +1,18 @@ +from django.conf import settings from django.conf.urls import include, url -urlpatterns = [url(r'^', include('edx_proctoring.urls', namespace='edx_proctoring'))] +from edx_proctoring import views + +urlpatterns = [ + url(r'^', include('edx_proctoring.urls', namespace='edx_proctoring')), + # Fake view to mock url pattern provided by edx_platform + url( + r'^courses/{}/jump_to/(?P.*)$'.format( + settings.COURSE_ID_PATTERN, + ), + views.StudentOnboardingStatusView.as_view(), + name='jump_to', + ) +]