Skip to content

Commit

Permalink
feat: [BD-26] Add API endpoint to fetch exam review policy and extend…
Browse files Browse the repository at this point in the history
… current API (#870)

* feat: add new API endpoint to be able to get exam review policy, add additional data to attempt data API response which is needed for mfe

* fix: sort imports

* fix: get software download url directly from provider instead of building it on the frontend, also log review policy not found exception

* fix: fix exception name

* feat: add verification url to attempt endpoint response

* feat: add tests for exam review policy API + fix code style

* feat: update existing tests for exam attempt API

* fix imports

* docs: update changelog

* feat: check if prerequisites are satisfied before user starts an exam

* feat: update tests for prerequisites checks

* docs: update changelog

* feat: add more tests for the new function and update comments

* feat: in the mfe API check for prerequisites only when exam is proctored (previously also checked for practice and onboarding exams)

* docs: update changelog

* feat: update mfe API to provide exam type and additional proctoring provider settings

* style: improve code style

* docs: update changelog

* docs: update changelog and fix typo in docstring
  • Loading branch information
viktorrusakov authored Jun 7, 2021
1 parent b1a6f23 commit ac1f1c4
Show file tree
Hide file tree
Showing 8 changed files with 328 additions and 17 deletions.
6 changes: 6 additions & 0 deletions CHANGELOG.rst
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,12 @@ Change Log
Unreleased
~~~~~~~~~~
* Extend exam attempt API to return exam type and to check if
user has satisfied prerequisites before taking proctored exam.
* Extend proctoring settings API to return additional data about proctoring
provider.
* Add API endpoint which provides exam review policy for specific exam.
Usage case is to provide required data for the learning app MFE.

[3.12.0] - 2021-06-04
~~~~~~~~~~~~~~~~~~~~~
Expand Down
70 changes: 66 additions & 4 deletions edx_proctoring/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -100,7 +100,14 @@ def get_proctoring_settings_by_exam_id(exam_id):
except NotImplementedError as error:
log.exception(str(error))
raise BackendProviderNotConfigured(str(error)) from error
proctoring_settings_data['exam_proctoring_backend'] = provider.get_proctoring_config()
proctoring_settings_data.update({
'exam_proctoring_backend': provider.get_proctoring_config(),
'provider_tech_support_email': provider.tech_support_email,
'provider_tech_support_phone': provider.tech_support_phone,
'provider_name': provider.verbose_name,
'learner_notification_from_email': provider.learner_notification_from_email,
'integration_specific_email': get_integration_specific_email(provider),
})
return proctoring_settings_data


Expand Down Expand Up @@ -618,7 +625,7 @@ def get_exam_attempt_data(exam_id, attempt_id, is_learning_mfe=False):
attempt_data = {
'in_timed_exam': True,
'taking_as_proctored': attempt['taking_as_proctored'],
'exam_type': get_exam_type(provider, attempt),
'exam_type': get_exam_type(exam, provider)['humanized_type'],
'exam_display_name': exam['exam_name'],
'exam_url_path': exam_url_path,
'time_remaining_seconds': time_remaining_seconds,
Expand All @@ -637,8 +644,23 @@ def get_exam_attempt_data(exam_id, attempt_id, is_learning_mfe=False):
}

if provider:
attempt_data['desktop_application_js_url'] = provider.get_javascript()
attempt_data['ping_interval'] = provider.ping_interval
attempt_data.update({
'desktop_application_js_url': provider.get_javascript(),
'ping_interval': provider.ping_interval,
'attempt_code': attempt['attempt_code']
})
# in case user is not verified we need to send them to verification page
if attempt['status'] == ProctoredExamStudentAttemptStatus.created:
attempt_data['verification_url'] = '{base_url}/id-verification'.format(
base_url=settings.ACCOUNT_MICROFRONTEND_URL
)
if attempt['status'] in (
ProctoredExamStudentAttemptStatus.created,
ProctoredExamStudentAttemptStatus.download_software_clicked
):
provider_attempt = provider.get_attempt(attempt)
download_url = provider_attempt.get('download_url', None) or provider.get_software_download_url()
attempt_data['software_download_url'] = download_url
else:
attempt_data['desktop_application_js_url'] = ''

Expand Down Expand Up @@ -1758,6 +1780,46 @@ def get_active_exams_for_user(user_id, course_id=None):
return result


def check_prerequisites(exam, user_id):
"""
Check if prerequisites are satisfied for user to take the exam
"""
credit_service = get_runtime_service('credit')
credit_state = credit_service.get_credit_state(user_id, exam['course_id'])
if not credit_state:
return exam
credit_requirement_status = credit_state.get('credit_requirement_status', [])

prerequisite_status = _are_prerequirements_satisfied(
credit_requirement_status,
evaluate_for_requirement_name=exam['content_id'],
filter_out_namespaces=['grade']
)

exam.update({
'prerequisite_status': prerequisite_status
})

if not prerequisite_status['are_prerequisites_satisifed']:
# do we have any declined prerequisites, if so, then we
# will auto-decline this proctored exam
if prerequisite_status['declined_prerequisites']:
_create_and_decline_attempt(exam['id'], user_id)
return exam

if prerequisite_status['failed_prerequisites']:
prerequisite_status['failed_prerequisites'] = _resolve_prerequisite_links(
exam,
prerequisite_status['failed_prerequisites']
)
else:
prerequisite_status['pending_prerequisites'] = _resolve_prerequisite_links(
exam,
prerequisite_status['pending_prerequisites']
)
return exam


def _get_ordered_prerequisites(prerequisites_statuses, filter_out_namespaces=None):
"""
Apply filter and ordering of requirements status in our credit_state dictionary. This will
Expand Down
56 changes: 56 additions & 0 deletions edx_proctoring/tests/test_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
_get_ordered_prerequisites,
_get_review_policy_by_exam_id,
add_allowance_for_user,
check_prerequisites,
create_exam,
create_exam_attempt,
create_exam_review_policy,
Expand Down Expand Up @@ -3103,3 +3104,58 @@ def test_get_exam_attempt(self, is_proctored):
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')


@ddt.ddt
class CheckPrerequisitesTests(ProctoredExamTestCase):
"""
Tests for check_prerequisites.
"""
def setUp(self):
"""
Initialize
"""
super().setUp()
self.proctored_exam_id = self._create_proctored_exam()
self.exam = {
'id': self.proctored_exam_id,
'course_id': self.course_id,
'content_id': self.content_id
}

@ddt.data(
('pending', False, 1),
('failed', False, 1),
('satisfied', True, 2),
('declined', False, 1),
)
@ddt.unpack
def test_check_prerequisites(self, status, are_satisfied, expected_prerequisites_len):
"""
Testing that prerequisites are checked correctly
"""
if status == 'declined':
prerequisites = self.declined_prerequisites
else:
prerequisites = [item for item in self.prerequisites if item['status'] == status]
with patch(
'edx_proctoring.tests.test_services.MockCreditService.get_credit_state',
return_value={'credit_requirement_status': prerequisites}
):
result = check_prerequisites(self.exam, self.user_id)
self.assertEqual(result['prerequisite_status']['are_prerequisites_satisifed'], are_satisfied)
self.assertEqual(
len(result['prerequisite_status']['{}_prerequisites'.format(status)]),
expected_prerequisites_len
)
if status == 'declined':
attempt = get_current_exam_attempt(self.exam['id'], self.user_id)
self.assertEqual(attempt['status'], ProctoredExamStudentAttemptStatus.declined)

def test_check_prerequisites_with_no_credit_state(self):
"""
Testing that prerequisites are not checked if we do not have credit state
"""
set_runtime_service('credit', MockCreditServiceNone())
result = check_prerequisites(self.exam, self.user_id)
self.assertDictEqual(self.exam, result)
128 changes: 125 additions & 3 deletions edx_proctoring/tests/test_mfe_views.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,13 +10,14 @@
from django.test.utils import override_settings
from django.urls import reverse

from edx_proctoring.api import get_review_policy_by_exam_id
from edx_proctoring.exceptions import BackendProviderNotConfigured, ProctoredExamNotFoundException
from edx_proctoring.statuses import ProctoredExamStudentAttemptStatus

from .utils import ProctoredExamTestCase


@override_settings(LEARNING_MICROFRONTEND_URL='https//learningmfe')
@override_settings(LEARNING_MICROFRONTEND_URL='https//learningmfe', ACCOUNT_MICROFRONTEND_URL='https//localhost')
@ddt.ddt
class ProctoredExamAttemptsMFEViewTests(ProctoredExamTestCase):
"""
Expand All @@ -29,6 +30,8 @@ def setUp(self):
super().setUp()
self.timed_exam_id = self._create_timed_exam()
self.proctored_exam_id = self._create_proctored_exam()
self.practice_exam_id = self._create_practice_exam()
self.onboarding_exam_id = self._create_onboarding_exam()
self.url = reverse(
'edx_proctoring:proctored_exam.exam_attempts',
kwargs={
Expand All @@ -40,13 +43,20 @@ def setUp(self):
settings.LEARNING_MICROFRONTEND_URL, self.course_id, self.content_id
)

def assertHasExamData(self, response_data, has_attempt, content_id=None):
def assertHasExamData(self, response_data, has_attempt,
has_verification_url=False, has_download_url=False, content_id=None):
""" Ensure expected exam data is present. """
exam_data = response_data['exam']
assert 'exam' in response_data
assert 'attempt' in exam_data
if has_attempt:
assert exam_data['attempt']
if has_verification_url:
assert exam_data['attempt']['verification_url']
if has_download_url:
assert 'software_download_url' in exam_data['attempt']
else:
assert 'software_download_url' not in exam_data['attempt']
else:
assert not exam_data['attempt']
self.assertEqual(exam_data['course_id'], self.course_id)
Expand All @@ -63,9 +73,13 @@ def test_get_started_proctored_exam_attempts_data(self):
self.assertEqual(response.status_code, 200)
response_data = json.loads(response.content.decode('utf-8'))
exam_data = response_data['exam']

# if exam started the prerequisites are not checked
assert 'prerequisite_status' not in exam_data
assert 'active_attempt' in response_data and response_data['active_attempt']
self.assertHasExamData(response_data, has_attempt=True)
self.assertEqual(exam_data['attempt']['exam_url_path'], self.expected_exam_url)
self.assertEqual(exam_data['type'], 'proctored')

def test_get_started_timed_exam_attempts_data(self):
"""
Expand All @@ -88,19 +102,23 @@ def test_get_started_timed_exam_attempts_data(self):
expected_exam_url = '{}/course/{}/{}'.format(
settings.LEARNING_MICROFRONTEND_URL, self.course_id, self.content_id_timed
)
assert 'prerequisite_status' not in exam_data
assert 'active_attempt' in response_data and response_data['active_attempt']
self.assertHasExamData(response_data, has_attempt=True, content_id=self.content_id_timed)
self.assertEqual(exam_data['attempt']['exam_url_path'], expected_exam_url)

def test_no_attempts_data_before_exam_starts(self):
"""
Test we get exam data before exam is started. Ensure no attempts data returned.
Test we get exam data before exam is started. Ensure no attempts data returned and prerequisites are checked.
"""
response = self.client.get(self.url)
self.assertEqual(response.status_code, 200)
response_data = json.loads(response.content.decode('utf-8'))
exam_data = response_data['exam']
assert 'active_attempt' in response_data and not response_data['active_attempt']
assert 'prerequisite_status' in exam_data
self.assertHasExamData(response_data, has_attempt=False)
self.assertEqual(exam_data['type'], 'proctored')

def test_get_exam_attempts_data_after_exam_is_submitted(self):
"""
Expand All @@ -113,9 +131,11 @@ def test_get_exam_attempts_data_after_exam_is_submitted(self):
response_data = json.loads(response.content.decode('utf-8'))
exam_data = response_data['exam']
assert 'active_attempt' in response_data and not response_data['active_attempt']
assert 'prerequisite_status' not in exam_data
self.assertHasExamData(response_data, has_attempt=True)
self.assertEqual(exam_data['attempt']['exam_url_path'], self.expected_exam_url)
self.assertEqual(exam_data['attempt']['attempt_status'], ProctoredExamStudentAttemptStatus.submitted)
self.assertEqual(exam_data['type'], 'proctored')

def test_no_exam_data_returned_for_non_exam_sequence(self):
"""
Expand All @@ -136,6 +156,66 @@ def test_no_exam_data_returned_for_non_exam_sequence(self):
assert not response_data['active_attempt']
assert not exam_data

@ddt.data(
('content_id', True),
('content_id_timed', False),
('content_id_onboarding', False),
('content_id_practice', False),
)
@ddt.unpack
def test_prerequisites_are_not_checked_if_exam_is_not_proctored(self, content_id, should_check_prerequisites):
"""
Tests that prerequisites are not checked for non proctored exams.
"""
content_id = getattr(self, content_id)
url = reverse(
'edx_proctoring:proctored_exam.exam_attempts',
kwargs={
'course_id': self.course_id,
'content_id': content_id
}
) + '?is_learning_mfe=true'

response = self.client.get(url)
self.assertEqual(response.status_code, 200)
response_data = json.loads(response.content.decode('utf-8'))
exam_data = response_data['exam']
self.assertEqual('prerequisite_status' in exam_data, should_check_prerequisites)

@ddt.data(
ProctoredExamStudentAttemptStatus.created,
ProctoredExamStudentAttemptStatus.download_software_clicked,
ProctoredExamStudentAttemptStatus.ready_to_start,
ProctoredExamStudentAttemptStatus.started,
ProctoredExamStudentAttemptStatus.ready_to_submit,
ProctoredExamStudentAttemptStatus.declined,
ProctoredExamStudentAttemptStatus.submitted,
ProctoredExamStudentAttemptStatus.rejected,
ProctoredExamStudentAttemptStatus.expired
)
def test_exam_data_contains_necessary_data_based_on_the_attempt_status(self, status):
"""
Tests the GET exam attempts data contains software download url ONLY when attempt
is in created or download_software_clicked status and contains verification
url ONLY when attempt is in created status
"""
self._create_exam_attempt(self.proctored_exam_id, status=status)

response = self.client.get(self.url)
self.assertEqual(response.status_code, 200)
has_download_url = status in (
ProctoredExamStudentAttemptStatus.created,
ProctoredExamStudentAttemptStatus.download_software_clicked
)
has_verification_url = status == ProctoredExamStudentAttemptStatus.created
response_data = json.loads(response.content.decode('utf-8'))
self.assertHasExamData(
response_data,
has_attempt=True,
has_download_url=has_download_url,
has_verification_url=has_verification_url,
)


class ProctoredSettingsViewTests(ProctoredExamTestCase):
"""
Expand Down Expand Up @@ -202,3 +282,45 @@ def test_get_proctoring_settings_for_exam_with_not_configured_backend(self):
response = self.client.get(url)
self.assertEqual(response.status_code, 500)
self.assertRaises(BackendProviderNotConfigured)


class ProctoredExamReviewPolicyView(ProctoredExamTestCase):
"""
Tests for the ProctoredExamReviewPolicyView.
"""

def setUp(self):
"""
Initialize.
"""
super().setUp()
self.proctored_exam_id = self._create_proctored_exam()
self.review_policy_id = self._create_review_policy(self.proctored_exam_id)
self.url = reverse(
'edx_proctoring:proctored_exam.review_policy',
kwargs={
'exam_id': self.proctored_exam_id,
}
)

def test_get_exam_review_policy_for_proctored_exam(self):
"""
Tests the GET exam review policy endpoint for proctored exam with existing policy.
"""
response = self.client.get(self.url)
self.assertEqual(response.status_code, 200)
response_data = json.loads(response.content.decode('utf-8'))
expected_review_policy = get_review_policy_by_exam_id(self.proctored_exam_id)
assert 'review_policy' in response_data
self.assertEqual(response_data['review_policy'], expected_review_policy['review_policy'])

def test_get_exam_review_policy_for_proctored_exam_with_no_existing_review(self):
"""
Tests the GET exam review policy endpoint for proctored exam which has no review policy configured.
"""
with patch('edx_proctoring.models.ProctoredExamReviewPolicy.get_review_policy_for_exam', return_value=None):
response = self.client.get(self.url)
self.assertEqual(response.status_code, 200)
response_data = json.loads(response.content.decode('utf-8'))
assert 'review_policy' in response_data
self.assertIsNone(response_data['review_policy'])
Loading

0 comments on commit ac1f1c4

Please sign in to comment.