Skip to content

Commit

Permalink
feat: API updates to support edx-exams (#1109)
Browse files Browse the repository at this point in the history
* 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.
  • Loading branch information
Zacharis278 committed Mar 16, 2023
1 parent 4ebef2c commit 82e74fa
Show file tree
Hide file tree
Showing 7 changed files with 177 additions and 7 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
~~~~~~~~~~

[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
~~~~~~~~~~~~~~~~~~~~~
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__ = '4.14.0'
__version__ = '4.15.0'

default_app_config = 'edx_proctoring.apps.EdxProctoringConfig' # pylint: disable=invalid-name
3 changes: 3 additions & 0 deletions edx_proctoring/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
102 changes: 102 additions & 0 deletions edx_proctoring/tests/test_mfe_views.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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='[email protected]', 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='[email protected]')
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
Expand Down Expand Up @@ -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='[email protected]', 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='[email protected]')
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.
Expand Down
5 changes: 5 additions & 0 deletions edx_proctoring/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -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/<int:exam_id>/', views.ProctoredSettingsView.as_view(),
name='proctored_exam.proctoring_settings'
),
Expand Down
65 changes: 60 additions & 5 deletions edx_proctoring/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,8 @@
obscured_user_id
)

User = get_user_model()

ATTEMPTS_PER_PAGE = 25

LOG = logging.getLogger("edx_proctoring_views")
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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.
Expand All @@ -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'),
Expand All @@ -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
Expand All @@ -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,
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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"
Expand Down

0 comments on commit 82e74fa

Please sign in to comment.