Skip to content

Commit

Permalink
feat: [BD-26] special exams mfe support (#837)
Browse files Browse the repository at this point in the history
* feat: [BD-26] Investigate if special exam details can be or should be fetched from API

* [BD-26] Create combined API endpoint for exam and attempts (#21)

* feat: Create combined API endpoint for exam and attempts

* added docstring for ProctoredExamWithUserAttemptView

* code review

* feat: change url for exam_url_path and use get_exam_attempt_data in StudentProctoredExamAttemptCollection

* feat: refactor get_exam_attempt_data

* fix: fix linter errors

* revert: active exams endpoint changes

* feat: create mfe urls, add mfe view test

* fix: mfe endpoint url

* fix: remove mfe urls module

* refactor: timer feature

* Fix exam attempt api test (#23)

* fix: fix ProctoredExamAttemptsMFEViewTests

* fix: change edx-proctoring version

* fix: get_exam_attempt_data docstring

* refactor: avoid getting exam and attempt if we have already active exam with same args

* feat: improve tests coverage

* feat: improve tests coverage

* fix: removed debug artifacts, fixed typo in constant name, fixed failing test

* fix: remove extra str conversion, removed extra mocking of reverse

* fix: move attempt data tests from student view to api tests

* fix: proctored exam limit mins None value conversion to float

* feat: move getting exam type to a separate helper function, minor refactoring

* fix: quality

* fix: Imports are incorrectly sorted and/or formatted.

* fix: tests coverage

* fix: revert exam not found ret value

Co-authored-by: Sagirov Eugeniy <[email protected]>
Co-authored-by: Sagirov Evgeniy <[email protected]>
Co-authored-by: Vladas Tamoshaitis <[email protected]>
  • Loading branch information
4 people authored May 17, 2021
1 parent d0e4602 commit 98f9bf2
Show file tree
Hide file tree
Showing 10 changed files with 469 additions and 192 deletions.
7 changes: 7 additions & 0 deletions CHANGELOG.rst
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,13 @@ Change Log
Unreleased
~~~~~~~~~~

[3.9.0] - 2021-05-07
~~~~~~~~~~~~~~~~~~~~
* Add API endpoint which provides sequence exam data with current active attempt.
Usage case is to provide required data for the learning app MFE.
* Moved StudentProctoredExamAttemptCollection collecting attempt data logic
to a separate standalone `get_exam_attempt_data` function.

[3.8.9] - 2021-05-07
~~~~~~~~~~~~~~~~~~~~
* Update language on proctored exam info panel if learner has
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__ = '3.8.9'
__version__ = '3.9.0'

default_app_config = 'edx_proctoring.apps.EdxProctoringConfig' # pylint: disable=invalid-name
70 changes: 70 additions & 0 deletions edx_proctoring/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@

import pytz
from opaque_keys import InvalidKeyError
from opaque_keys.edx.keys import CourseKey, UsageKey

from django.conf import settings
from django.contrib.auth import get_user_model
Expand Down Expand Up @@ -57,6 +58,8 @@
from edx_proctoring.utils import (
emit_event,
get_exam_due_date,
get_exam_type,
get_time_remaining_for_attempt,
has_due_date_passed,
humanized_time,
is_reattempting_exam,
Expand Down Expand Up @@ -549,6 +552,73 @@ def get_exam_attempt_by_code(attempt_code):
return _get_exam_attempt(exam_attempt_obj)


def get_exam_attempt_data(exam_id, attempt_id, is_learning_mfe=False):
"""
Args:
int: exam id
int: exam attempt id
bool: indicates if exam_url_path should be built for the MFE
Returns:
dict: our exam attempt
"""

exam = get_exam_by_id(exam_id)
attempt = get_exam_attempt_by_id(attempt_id)
provider = get_backend_provider(exam)

time_remaining_seconds = get_time_remaining_for_attempt(attempt)

proctoring_settings = getattr(settings, 'PROCTORING_SETTINGS', {})
low_threshold_pct = proctoring_settings.get('low_threshold_pct', .2)
critically_low_threshold_pct = proctoring_settings.get('critically_low_threshold_pct', .05)

allowed_time_limit_mins = attempt.get('allowed_time_limit_mins', 0)

low_threshold = int(low_threshold_pct * float(allowed_time_limit_mins) * 60)
critically_low_threshold = int(
critically_low_threshold_pct * float(allowed_time_limit_mins) * 60
)

# resolve the LMS url, note we can't assume we're running in
# a same process as the LMS
if is_learning_mfe:
course_key = CourseKey.from_string(exam['course_id'])
usage_key = UsageKey.from_string(exam['content_id'])
exam_url_path = '{}/course/{}/{}'.format(settings.LEARNING_MICROFRONTEND_URL, course_key, usage_key)

else:
exam_url_path = reverse('jump_to', args=[exam['course_id'], exam['content_id']])

attempt_data = {
'in_timed_exam': True,
'taking_as_proctored': attempt['taking_as_proctored'],
'exam_type': get_exam_type(provider, attempt),
'exam_display_name': exam['exam_name'],
'exam_url_path': exam_url_path,
'time_remaining_seconds': time_remaining_seconds,
'low_threshold_sec': low_threshold,
'critically_low_threshold_sec': critically_low_threshold,
'course_id': exam['course_id'],
'attempt_id': attempt['id'],
'accessibility_time_string': _('you have {remaining_time} remaining').format(
remaining_time=humanized_time(int(round(time_remaining_seconds / 60.0, 0)))
),
'attempt_status': attempt['status'],
'exam_started_poll_url': reverse(
'edx_proctoring:proctored_exam.attempt',
args=[attempt['id']]
),
}

if provider:
attempt_data['desktop_application_js_url'] = provider.get_javascript()
attempt_data['ping_interval'] = provider.ping_interval
else:
attempt_data['desktop_application_js_url'] = ''

return attempt_data


def update_exam_attempt(attempt_id, **kwargs):
"""
Update exam_attempt
Expand Down
128 changes: 127 additions & 1 deletion edx_proctoring/tests/test_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@
"""
All tests for the api.py
"""

from datetime import datetime, timedelta
from itertools import product

Expand All @@ -13,7 +12,10 @@
from freezegun import freeze_time
from mock import MagicMock, patch

from django.conf import settings
from django.core import mail
from django.test.utils import override_settings
from django.urls import reverse

from edx_proctoring.api import (
_are_prerequirements_satisfied,
Expand All @@ -34,6 +36,7 @@
get_current_exam_attempt,
get_enrollments_can_take_proctored_exams,
get_exam_attempt_by_id,
get_exam_attempt_data,
get_exam_by_content_id,
get_exam_by_id,
get_exam_configuration_dashboard_url,
Expand Down Expand Up @@ -2913,3 +2916,126 @@ def test_more_courses(self, setup_current_course):
self.assertEqual(9, len(attempts_dict.items()))
self._assert_verified_attempts(all_users[0:5], attempts_dict)
self._assert_verified_attempts(third_course_verified, attempts_dict)


@ddt.ddt
class GetExamAttemptDataTests(ProctoredExamTestCase):
"""
Tests for get_exam_attempt_data.
"""
def setUp(self):
"""
Initialize
"""
super().setUp()
self.timed_exam_id = self._create_timed_exam()
self.proctored_exam_id = self._create_proctored_exam()

@ddt.data(
(True, False),
(True, True),
(False, False),
(False, True),
)
@ddt.unpack
@override_settings(LEARNING_MICROFRONTEND_URL='http://learningmfe')
def test_get_exam_attempt_data(self, is_proctored_exam, is_learning_mfe):
""" Test expected attempt data returned by get_exam_attempt_data. """
attempt = self._create_started_exam_attempt(is_proctored=is_proctored_exam)
exam_id = self.timed_exam_id if not is_proctored_exam else self.proctored_exam_id
attempt_data = get_exam_attempt_data(exam_id, attempt.id, is_learning_mfe)
content_id = self.content_id if is_proctored_exam else self.content_id_timed
expected_exam_url = '{}/course/{}/{}'.format(
settings.LEARNING_MICROFRONTEND_URL, self.course_id, content_id
) if is_learning_mfe else reverse('jump_to', args=[self.course_id, content_id])

assert attempt_data
assert 'attempt_id' in attempt_data
assert attempt_data['attempt_id'] == attempt.id
assert 'exam_url_path' in attempt_data
assert attempt_data['exam_url_path'] == expected_exam_url

@ddt.data(
(True, True, 'an onboarding exam'),
(True, False, 'a proctored exam'),
(False, False, 'a timed exam')
)
@ddt.unpack
def test_exam_type(self, is_proctored, is_practice, expected_exam_type):
"""
Testing the exam type
"""
self._test_exam_type(is_proctored, is_practice, expected_exam_type)

def _test_exam_type(self, is_proctored, is_practice, expected_exam_type):
"""
Testing the exam type
"""
proctored_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_proctored=is_proctored,
is_practice_exam=is_practice
)

attempt = ProctoredExamStudentAttempt.objects.create(
proctored_exam=proctored_exam,
user=self.user,
allowed_time_limit_mins=90,
taking_as_proctored=is_proctored,
is_sample_attempt=is_practice,
external_id=proctored_exam.external_id,
status=ProctoredExamStudentAttemptStatus.started
)

data = get_exam_attempt_data(proctored_exam.id, attempt.id)
self.assertEqual(data['exam_type'], expected_exam_type)

def test_practice_exam_type(self):
"""
Test practice exam type with short special setup and teardown
"""
test_backend = get_backend_provider(name='test')
previous_value = test_backend.supports_onboarding
test_backend.supports_onboarding = False
self._test_exam_type(True, True, 'a practice exam')
test_backend.supports_onboarding = previous_value

@ddt.data(True, False)
def test_get_exam_attempt(self, is_proctored):
"""
Test Case for retrieving student proctored exam attempt status.
"""
# Create an exam.
proctored_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_proctored=is_proctored
)

attempt_data = {
'exam_id': proctored_exam.id,
'user_id': self.user.id,
'external_id': proctored_exam.external_id,
'attempt_proctored': is_proctored,
'start_clock': True
}
response = self.client.post(
reverse('edx_proctoring:proctored_exam.attempt.collection'),
attempt_data
)
self.assertEqual(response.status_code, 200)
response_data = response.json()

data = get_exam_attempt_data(proctored_exam.id, response_data['exam_attempt_id'])
self.assertEqual(data['exam_display_name'], 'Test Exam')
self.assertEqual(data['low_threshold_sec'], 1080)
self.assertEqual(data['critically_low_threshold_sec'], 270)
# make sure we have the accessible human string
self.assertEqual(data['accessibility_time_string'], 'you have 1 hour and 30 minutes remaining')
Loading

0 comments on commit 98f9bf2

Please sign in to comment.