Skip to content

Commit

Permalink
Merge pull request #726 from edx/bseverino/onboarding-status
Browse files Browse the repository at this point in the history
Add endpoint for onboarding status
  • Loading branch information
bseverino authored Dec 11, 2020
2 parents e6dff18 + bee3c30 commit 85a51a3
Show file tree
Hide file tree
Showing 7 changed files with 330 additions and 4 deletions.
5 changes: 5 additions & 0 deletions CHANGELOG.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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
~~~~~~~~~~~~~~~~~~~~

Expand Down
2 changes: 1 addition & 1 deletion edx_proctoring/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
211 changes: 211 additions & 0 deletions edx_proctoring/tests/test_views.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
"""
Expand Down
5 changes: 5 additions & 0 deletions edx_proctoring/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand Down
94 changes: 93 additions & 1 deletion edx_proctoring/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

import json
import logging
from itertools import chain

import waffle
from crum import get_current_request
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
15 changes: 14 additions & 1 deletion test_urls.py
Original file line number Diff line number Diff line change
@@ -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<location>.*)$'.format(
settings.COURSE_ID_PATTERN,
),
views.StudentOnboardingStatusView.as_view(),
name='jump_to',
)
]

0 comments on commit 85a51a3

Please sign in to comment.