Commit 0de557b0 by Chris Dodge

Build out review callback support for SoftwareSecure

parent aae446e6
...@@ -43,3 +43,10 @@ class ProctoringBackendProvider(object): ...@@ -43,3 +43,10 @@ class ProctoringBackendProvider(object):
the corresponding desktop software the corresponding desktop software
""" """
raise NotImplementedError() raise NotImplementedError()
@abc.abstractmethod
def on_review_callback(self, payload):
"""
Called when the reviewing 3rd party service posts back the results
"""
raise NotImplementedError()
...@@ -36,3 +36,8 @@ class NullBackendProvider(ProctoringBackendProvider): ...@@ -36,3 +36,8 @@ class NullBackendProvider(ProctoringBackendProvider):
the corresponding desktop software the corresponding desktop software
""" """
return None return None
def on_review_callback(self, payload):
"""
Called when the reviewing 3rd party service posts back the results
"""
...@@ -13,8 +13,19 @@ import json ...@@ -13,8 +13,19 @@ import json
import logging import logging
from edx_proctoring.backends.backend import ProctoringBackendProvider from edx_proctoring.backends.backend import ProctoringBackendProvider
from edx_proctoring.exceptions import BackendProvideCannotRegisterAttempt from edx_proctoring.exceptions import (
BackendProvideCannotRegisterAttempt,
StudentExamAttemptDoesNotExistsException,
ProctoredExamSuspiciousLookup,
ProctoredExamReviewAlreadyExists,
)
from edx_proctoring. models import (
ProctoredExamSoftwareSecureReview,
ProctoredExamSoftwareSecureComment,
ProctoredExamStudentAttempt,
ProctoredExamStudentAttemptHistory
)
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
...@@ -97,6 +108,108 @@ class SoftwareSecureBackendProvider(ProctoringBackendProvider): ...@@ -97,6 +108,108 @@ class SoftwareSecureBackendProvider(ProctoringBackendProvider):
""" """
return self.software_download_url return self.software_download_url
def on_review_callback(self, payload):
"""
Called when the reviewing 3rd party service posts back the results
Documentation on the data format can be found from SoftwareSecure's
documentation named "Reviewer Data Transfer"
"""
log_msg = (
'Received callback from SoftwareSecure with review data: {payload}'.format(
payload=payload
)
)
log.info(log_msg)
# payload from SoftwareSecure is a JSON payload
# which has been converted to a dict by our caller
data = payload['payload']
# what we consider the external_id is SoftwareSecure's 'ssiRecordLocator'
external_id = data['examMetaData']['ssiRecordLocator']
# what we consider the attempt_code is SoftwareSecure's 'examCode'
attempt_code = data['examMetaData']['examCode']
# do a lookup on the attempt by examCode, and compare the
# passed in ssiRecordLocator and make sure it matches
# what we recorded as the external_id. We need to look in both
# the attempt table as well as the archive table
attempt_obj = ProctoredExamStudentAttempt.objects.get_exam_attempt_by_code(attempt_code)
if not attempt_obj:
# try archive table
attempt_obj = ProctoredExamStudentAttemptHistory.get_exam_attempt_by_code(attempt_code)
if not attempt_obj:
# still can't find, error out
err_msg = (
'Could not locate attempt_code: {attempt_code}'.format(attempt_code=attempt_code)
)
raise StudentExamAttemptDoesNotExistsException(err_msg)
# then make sure we have the right external_id
if attempt_obj.external_id != external_id:
err_msg = (
'Found attempt_code {attempt_code}, but the recorded external_id did not '
'match the ssiRecordLocator that had been recorded previously. Has {existing} '
'but received {received}!'.format(
attempt_code=attempt_code,
existing=attempt_obj.external_id,
received=external_id
)
)
raise ProctoredExamSuspiciousLookup(err_msg)
# do we already have a review for this attempt?!? It should not be updated!
review = ProctoredExamSoftwareSecureReview.get_review_by_attempt_code(attempt_code)
if review:
err_msg = (
'We already have a review submitted from SoftwareSecure regarding '
'attempt_code {attempt_code}. We do not allow for updates!'.format(
attempt_code=attempt_code
)
)
raise ProctoredExamReviewAlreadyExists(err_msg)
# do some limited parsing of the JSON payload
review_status = data['reviewStatus']
video_review_link = data['videoReviewLink']
# make a new record in the review table
review = ProctoredExamSoftwareSecureReview(
attempt_code=attempt_code,
raw_data=json.dumps(payload),
review_status=review_status,
video_url=video_review_link,
)
review.save()
# go through and populate all of the specific comments
for comment in data['webCamComments']:
self._save_review_comment(review, comment)
for comment in data['desktopComments']:
self._save_review_comment(review, comment)
def _save_review_comment(self, review, comment):
"""
Helper method to save a review comment
"""
comment = ProctoredExamSoftwareSecureComment(
review=review,
start_time=comment['eventStart'],
stop_time=comment['eventFinish'],
duration=comment['duration'],
comment=comment['comments'],
status=comment['eventStatus']
)
comment.save()
def _encrypt_password(self, key, pwd): def _encrypt_password(self, key, pwd):
""" """
Encrypt the exam passwork with the given key Encrypt the exam passwork with the given key
......
...@@ -39,6 +39,11 @@ class TestBackendProvider(ProctoringBackendProvider): ...@@ -39,6 +39,11 @@ class TestBackendProvider(ProctoringBackendProvider):
""" """
return None return None
def on_review_callback(self, payload):
"""
Called when the reviewing 3rd party service posts back the results
"""
class PassthroughBackendProvider(ProctoringBackendProvider): class PassthroughBackendProvider(ProctoringBackendProvider):
""" """
...@@ -81,6 +86,12 @@ class PassthroughBackendProvider(ProctoringBackendProvider): ...@@ -81,6 +86,12 @@ class PassthroughBackendProvider(ProctoringBackendProvider):
""" """
return super(PassthroughBackendProvider, self).get_software_download_url() return super(PassthroughBackendProvider, self).get_software_download_url()
def on_review_callback(self, payload):
"""
Called when the reviewing 3rd party service posts back the results
"""
return super(PassthroughBackendProvider, self).on_review_callback(payload)
class TestBackends(TestCase): class TestBackends(TestCase):
""" """
...@@ -106,6 +117,9 @@ class TestBackends(TestCase): ...@@ -106,6 +117,9 @@ class TestBackends(TestCase):
with self.assertRaises(NotImplementedError): with self.assertRaises(NotImplementedError):
provider.get_software_download_url() provider.get_software_download_url()
with self.assertRaises(NotImplementedError):
provider.on_review_callback(None)
def test_null_provider(self): def test_null_provider(self):
""" """
Assert that the Null provider does nothing Assert that the Null provider does nothing
...@@ -117,3 +131,4 @@ class TestBackends(TestCase): ...@@ -117,3 +131,4 @@ class TestBackends(TestCase):
self.assertIsNone(provider.start_exam_attempt(None, None)) self.assertIsNone(provider.start_exam_attempt(None, None))
self.assertIsNone(provider.stop_exam_attempt(None, None)) self.assertIsNone(provider.stop_exam_attempt(None, None))
self.assertIsNone(provider.get_software_download_url()) self.assertIsNone(provider.get_software_download_url())
self.assertIsNone(provider.on_review_callback(None))
"""
Some canned data for SoftwareSecure callback testing.
"""
TEST_REVIEW_PAYLOAD = '''
{
"orgCallbackURL": "http://reds.rpexams.com/reviewerdatatransfer",
"payload": {
"examDate": "Jul 15 2015 1:13AM",
"examProcessingStatus": "Review Completed",
"examTakerEmail": "4d07a01a-1502-422e-b943-93ac04dc6ced",
"examTakerFirstName": "John",
"examTakerLastName": "Doe",
"keySetVersion": "",
"examApiData": {
"duration": 1,
"examCode": "4d07a01a-1502-422e-b943-93ac04dc6ced",
"examName": "edX Exams",
"examPassword": "hQxvA8iUKKlsqKt0fQVBaXqmAziGug4NfxUChg94eGacYDcFwaIyBA==",
"examSponsor": "edx LMS",
"examUrl": "http://localhost:8000/api/edx_proctoring/proctoring_launch_callback/start_exam/4d07a01a-1502-422e-b943-93ac04dc6ced",
"orgExtra": {
"courseID": "edX/DemoX/Demo_Course",
"examEndDate": "Wed, 15 Jul 2015 05:11:31 GMT",
"examID": 6,
"examStartDate": "Wed, 15 Jul 2015 05:10:31 GMT",
"noOfStudents": 1
},
"organization": "edx",
"reviewedExam": true,
"reviewerNotes": "Closed Book",
"ssiProduct": "rp-now"
},
"overAllComments": ";Candidates should always wear suit and tie for exams.",
"reviewStatus": "Clean",
"userPhotoBase64String": "",
"videoReviewLink": "http://www.remoteproctor.com/AdminSite/Account/Reviewer/DirectLink-Generic.aspx?ID=foo",
"examMetaData": {
"examCode": "$attempt_code",
"examName": "edX Exams",
"examSponsor": "edx LMS",
"organization": "edx",
"reviewedExam": "True",
"reviewerNotes": "Closed Book",
"simulatedExam": "False",
"ssiExamToken": "4E44F7AA-275D-4741-B531-73AE2407ECFB",
"ssiProduct": "rp-now",
"ssiRecordLocator": "$external_id"
},
"desktopComments": [
{
"comments": "Browsing other websites",
"duration": 88,
"eventFinish": 88,
"eventStart": 12,
"eventStatus": "Suspicious"
},
{
"comments": "Browsing local computer",
"duration": 88,
"eventFinish": 88,
"eventStart": 15,
"eventStatus": "Rules Violation"
},
{
"comments": "Student never entered the exam.",
"duration": 88,
"eventFinish": 88,
"eventStart": 87,
"eventStatus": "Clean"
}
],
"webCamComments": [
{
"comments": "Photo ID not provided",
"duration": 796,
"eventFinish": 796,
"eventStart": 0,
"eventStatus": "Suspicious"
},
{
"comments": "Exam environment not confirmed",
"duration": 796,
"eventFinish": 796,
"eventStart": 10,
"eventStatus": "Rules Violation"
},
{
"comments": "Looking away from computer",
"duration": 796,
"eventFinish": 796,
"eventStart": 107,
"eventStatus": "Rules Violation"
}
]
}
}
'''
...@@ -2,9 +2,10 @@ ...@@ -2,9 +2,10 @@
Tests for the software_secure module Tests for the software_secure module
""" """
import json
from string import Template # pylint: disable=deprecated-module
from mock import patch from mock import patch
from httmock import all_requests, HTTMock from httmock import all_requests, HTTMock
import json
from django.test import TestCase from django.test import TestCase
from django.contrib.auth.models import User from django.contrib.auth.models import User
...@@ -17,11 +18,23 @@ from edx_proctoring.api import ( ...@@ -17,11 +18,23 @@ from edx_proctoring.api import (
get_exam_attempt_by_id, get_exam_attempt_by_id,
create_exam, create_exam,
create_exam_attempt, create_exam_attempt,
get_exam_attempt_by_id,
remove_exam_attempt,
) )
from edx_proctoring.exceptions import (
StudentExamAttemptDoesNotExistsException,
ProctoredExamSuspiciousLookup,
ProctoredExamReviewAlreadyExists,
)
from edx_proctoring. models import (
ProctoredExamSoftwareSecureReview,
ProctoredExamSoftwareSecureComment,
)
from edx_proctoring.backends.tests.test_review_payload import TEST_REVIEW_PAYLOAD
@all_requests @all_requests
def response_content(url, request): # pylint: disable=unused-argument def mock_response_content(url, request): # pylint: disable=unused-argument
""" """
Mock HTTP response from SoftwareSecure Mock HTTP response from SoftwareSecure
""" """
...@@ -34,7 +47,7 @@ def response_content(url, request): # pylint: disable=unused-argument ...@@ -34,7 +47,7 @@ def response_content(url, request): # pylint: disable=unused-argument
@all_requests @all_requests
def response_error(url, request): # pylint: disable=unused-argument def mock_response_error(url, request): # pylint: disable=unused-argument
""" """
Mock HTTP response from SoftwareSecure Mock HTTP response from SoftwareSecure
""" """
...@@ -114,7 +127,7 @@ class SoftwareSecureTests(TestCase): ...@@ -114,7 +127,7 @@ class SoftwareSecureTests(TestCase):
is_proctored=True is_proctored=True
) )
with HTTMock(response_content): with HTTMock(mock_response_content):
attempt_id = create_exam_attempt(exam_id, self.user.id, taking_as_proctored=True) attempt_id = create_exam_attempt(exam_id, self.user.id, taking_as_proctored=True)
self.assertIsNotNone(attempt_id) self.assertIsNotNone(attempt_id)
...@@ -143,7 +156,7 @@ class SoftwareSecureTests(TestCase): ...@@ -143,7 +156,7 @@ class SoftwareSecureTests(TestCase):
is_proctored=True is_proctored=True
) )
with HTTMock(response_content): with HTTMock(mock_response_content):
attempt_id = create_exam_attempt(exam_id, self.user.id, taking_as_proctored=True) attempt_id = create_exam_attempt(exam_id, self.user.id, taking_as_proctored=True)
self.assertIsNotNone(attempt_id) self.assertIsNotNone(attempt_id)
...@@ -161,7 +174,7 @@ class SoftwareSecureTests(TestCase): ...@@ -161,7 +174,7 @@ class SoftwareSecureTests(TestCase):
) )
# now try a failing request # now try a failing request
with HTTMock(response_error): with HTTMock(mock_response_error):
with self.assertRaises(BackendProvideCannotRegisterAttempt): with self.assertRaises(BackendProvideCannotRegisterAttempt):
create_exam_attempt(exam_id, self.user.id, taking_as_proctored=True) create_exam_attempt(exam_id, self.user.id, taking_as_proctored=True)
...@@ -202,3 +215,195 @@ class SoftwareSecureTests(TestCase): ...@@ -202,3 +215,195 @@ class SoftwareSecureTests(TestCase):
provider = get_backend_provider() provider = get_backend_provider()
self.assertIsNone(provider.stop_exam_attempt(None, None)) self.assertIsNone(provider.stop_exam_attempt(None, None))
def test_review_callback(self):
"""
Simulates a happy path when SoftwareSecure calls us back with a payload
"""
provider = get_backend_provider()
exam_id = create_exam(
course_id='foo/bar/baz',
content_id='content',
exam_name='Sample Exam',
time_limit_mins=10,
is_proctored=True
)
# be sure to use the mocked out SoftwareSecure handlers
with HTTMock(mock_response_content):
attempt_id = create_exam_attempt(
exam_id,
self.user.id,
taking_as_proctored=True
)
attempt = get_exam_attempt_by_id(attempt_id)
self.assertIsNotNone(attempt['external_id'])
test_payload = Template(TEST_REVIEW_PAYLOAD).substitute(
attempt_code=attempt['attempt_code'],
external_id=attempt['external_id']
)
provider.on_review_callback(json.loads(test_payload))
# make sure that what we have in the Database matches what we expect
review = ProctoredExamSoftwareSecureReview.get_review_by_attempt_code(attempt['attempt_code'])
self.assertIsNotNone(review)
self.assertEqual(review.review_status, 'Clean')
self.assertEqual(
review.video_url,
'http://www.remoteproctor.com/AdminSite/Account/Reviewer/DirectLink-Generic.aspx?ID=foo'
)
self.assertIsNotNone(review.raw_data)
# now check the comments that were stored
comments = ProctoredExamSoftwareSecureComment.objects.filter(review_id=review.id)
self.assertEqual(len(comments), 6)
def test_review_bad_code(self):
"""
Asserts raising of an exception if we get a report for
an attempt code which does not exist
"""
provider = get_backend_provider()
test_payload = Template(TEST_REVIEW_PAYLOAD).substitute(
attempt_code='not-here',
external_id='also-not-here'
)
with self.assertRaises(StudentExamAttemptDoesNotExistsException):
provider.on_review_callback(json.loads(test_payload))
def test_review_mistmatched_tokens(self):
"""
Asserts raising of an exception if we get a report for
an attempt code which has a external_id which does not
match the report
"""
provider = get_backend_provider()
exam_id = create_exam(
course_id='foo/bar/baz',
content_id='content',
exam_name='Sample Exam',
time_limit_mins=10,
is_proctored=True
)
# be sure to use the mocked out SoftwareSecure handlers
with HTTMock(mock_response_content):
attempt_id = create_exam_attempt(
exam_id,
self.user.id,
taking_as_proctored=True
)
attempt = get_exam_attempt_by_id(attempt_id)
self.assertIsNotNone(attempt['external_id'])
test_payload = Template(TEST_REVIEW_PAYLOAD).substitute(
attempt_code=attempt['attempt_code'],
external_id='bogus'
)
with self.assertRaises(ProctoredExamSuspiciousLookup):
provider.on_review_callback(json.loads(test_payload))
def test_review_on_archived_attempt(self):
"""
Make sure we can process a review report for
an attempt which has been archived
"""
provider = get_backend_provider()
exam_id = create_exam(
course_id='foo/bar/baz',
content_id='content',
exam_name='Sample Exam',
time_limit_mins=10,
is_proctored=True
)
# be sure to use the mocked out SoftwareSecure handlers
with HTTMock(mock_response_content):
attempt_id = create_exam_attempt(
exam_id,
self.user.id,
taking_as_proctored=True
)
attempt = get_exam_attempt_by_id(attempt_id)
self.assertIsNotNone(attempt['external_id'])
test_payload = Template(TEST_REVIEW_PAYLOAD).substitute(
attempt_code=attempt['attempt_code'],
external_id=attempt['external_id']
)
# now delete the attempt, which puts it into the archive table
remove_exam_attempt(attempt_id)
# now process the report
provider.on_review_callback(json.loads(test_payload))
# make sure that what we have in the Database matches what we expect
review = ProctoredExamSoftwareSecureReview.get_review_by_attempt_code(attempt['attempt_code'])
self.assertIsNotNone(review)
self.assertEqual(review.review_status, 'Clean')
self.assertEqual(
review.video_url,
'http://www.remoteproctor.com/AdminSite/Account/Reviewer/DirectLink-Generic.aspx?ID=foo'
)
self.assertIsNotNone(review.raw_data)
# now check the comments that were stored
comments = ProctoredExamSoftwareSecureComment.objects.filter(review_id=review.id)
self.assertEqual(len(comments), 6)
def test_review_resubmission(self):
"""
Tests that an exception is raised if a review report is resubmitted for the same
attempt
"""
provider = get_backend_provider()
exam_id = create_exam(
course_id='foo/bar/baz',
content_id='content',
exam_name='Sample Exam',
time_limit_mins=10,
is_proctored=True
)
# be sure to use the mocked out SoftwareSecure handlers
with HTTMock(mock_response_content):
attempt_id = create_exam_attempt(
exam_id,
self.user.id,
taking_as_proctored=True
)
attempt = get_exam_attempt_by_id(attempt_id)
self.assertIsNotNone(attempt['external_id'])
test_payload = Template(TEST_REVIEW_PAYLOAD).substitute(
attempt_code=attempt['attempt_code'],
external_id=attempt['external_id']
)
provider.on_review_callback(json.loads(test_payload))
# now call again
with self.assertRaises(ProctoredExamReviewAlreadyExists):
provider.on_review_callback(json.loads(test_payload))
...@@ -2,14 +2,22 @@ ...@@ -2,14 +2,22 @@
Various callback paths Various callback paths
""" """
import logging
from django.template import Context, loader from django.template import Context, loader
from django.http import HttpResponse from django.http import HttpResponse
from rest_framework.views import APIView
from rest_framework.response import Response
from edx_proctoring.api import ( from edx_proctoring.api import (
get_exam_attempt_by_code, get_exam_attempt_by_code,
mark_exam_attempt_as_ready, mark_exam_attempt_as_ready,
) )
from edx_proctoring.backends import get_backend_provider
log = logging.getLogger(__name__)
def start_exam_callback(request, attempt_code): # pylint: disable=unused-argument def start_exam_callback(request, attempt_code): # pylint: disable=unused-argument
""" """
...@@ -34,3 +42,24 @@ def start_exam_callback(request, attempt_code): # pylint: disable=unused-argume ...@@ -34,3 +42,24 @@ def start_exam_callback(request, attempt_code): # pylint: disable=unused-argume
template = loader.get_template('proctoring/proctoring_launch_callback.html') template = loader.get_template('proctoring/proctoring_launch_callback.html')
return HttpResponse(template.render(Context({}))) return HttpResponse(template.render(Context({})))
class ExamReviewCallback(APIView):
"""
This endpoint is called by a 3rd party proctoring review service when
there are results available for us to record
"""
def post(self, request):
"""
Post callback handler
"""
provider = get_backend_provider()
# call down into the underlying provider code
provider.on_review_callback(request.DATA)
return Response(
data='OK',
status=200
)
...@@ -55,3 +55,17 @@ class ProctoredExamPermissionDenied(ProctoredBaseException): ...@@ -55,3 +55,17 @@ class ProctoredExamPermissionDenied(ProctoredBaseException):
""" """
Raised when the calling user does not have access to the requested object. Raised when the calling user does not have access to the requested object.
""" """
class ProctoredExamSuspiciousLookup(ProctoredBaseException):
"""
Raised when a lookup on the student attempt table does not fully match
all expected security keys
"""
class ProctoredExamReviewAlreadyExists(ProctoredBaseException):
"""
Raised when a lookup on the student attempt table does not fully match
all expected security keys
"""
...@@ -304,6 +304,18 @@ class ProctoredExamStudentAttemptHistory(TimeStampedModel): ...@@ -304,6 +304,18 @@ class ProctoredExamStudentAttemptHistory(TimeStampedModel):
student_name = models.CharField(max_length=255) student_name = models.CharField(max_length=255)
@classmethod
def get_exam_attempt_by_code(cls, attempt_code):
"""
Returns the Student Exam Attempt object if found
else Returns None.
"""
try:
exam_attempt_obj = cls.objects.get(attempt_code=attempt_code)
except ObjectDoesNotExist: # pylint: disable=no-member
exam_attempt_obj = None
return exam_attempt_obj
@receiver(pre_delete, sender=ProctoredExamStudentAttempt) @receiver(pre_delete, sender=ProctoredExamStudentAttempt)
def on_attempt_deleted(sender, instance, **kwargs): # pylint: disable=unused-argument def on_attempt_deleted(sender, instance, **kwargs): # pylint: disable=unused-argument
...@@ -473,3 +485,69 @@ def _make_archive_copy(item): ...@@ -473,3 +485,69 @@ def _make_archive_copy(item):
value=item.value value=item.value
) )
archive_object.save() archive_object.save()
class ProctoredExamSoftwareSecureReview(TimeStampedModel):
"""
This is where we store the proctored exam review feedback
from the exam reviewers
"""
# which student attempt is this feedback for?
attempt_code = models.CharField(max_length=255, db_index=True)
# overall status of the review
review_status = models.CharField(max_length=255)
# The raw payload that was received back from the
# reviewing service
raw_data = models.TextField()
# URL for the exam video that had been reviewed
video_url = models.TextField()
class Meta:
""" Meta class for this Django model """
db_table = 'proctoring_proctoredexamsoftwaresecurereview'
verbose_name = 'proctored exam software secure review'
@classmethod
def get_review_by_attempt_code(cls, attempt_code):
"""
Does a lookup by attempt_code
"""
try:
review = cls.objects.get(attempt_code=attempt_code)
return review
except cls.DoesNotExist: # pylint: disable=no-member
return None
class ProctoredExamSoftwareSecureComment(TimeStampedModel):
"""
This is where we store the proctored exam review comments
from the exam reviewers
"""
# which student attempt is this feedback for?
review = models.ForeignKey(ProctoredExamSoftwareSecureReview)
# start time in the video, in seconds, regarding the comment
start_time = models.IntegerField()
# stop time in the video, in seconds, regarding the comment
stop_time = models.IntegerField()
# length of time, in seconds, regarding the comment
duration = models.IntegerField()
# the text that the reviewer typed in
comment = models.TextField()
# reviewers opinion regarding exam validitity based on the comment
status = models.CharField(max_length=255)
class Meta:
""" Meta class for this Django model """
db_table = 'proctoring_proctoredexamstudentattemptcomment'
verbose_name = 'proctored exam software secure comment'
...@@ -3,6 +3,8 @@ ...@@ -3,6 +3,8 @@
All tests for the proctored_exams.py All tests for the proctored_exams.py
""" """
import json import json
from httmock import HTTMock
from string import Template # pylint: disable=deprecated-module
from datetime import datetime from datetime import datetime
from django.test.client import Client from django.test.client import Client
from django.core.urlresolvers import reverse, NoReverseMatch from django.core.urlresolvers import reverse, NoReverseMatch
...@@ -10,7 +12,9 @@ import pytz ...@@ -10,7 +12,9 @@ import pytz
from edx_proctoring.models import ProctoredExam, ProctoredExamStudentAttempt, ProctoredExamStudentAllowance from edx_proctoring.models import ProctoredExam, ProctoredExamStudentAttempt, ProctoredExamStudentAllowance
from edx_proctoring.views import require_staff from edx_proctoring.views import require_staff
from edx_proctoring.api import ( from edx_proctoring.api import (
get_exam_attempt_by_id create_exam,
create_exam_attempt,
get_exam_attempt_by_id,
) )
from django.contrib.auth.models import User from django.contrib.auth.models import User
...@@ -20,6 +24,8 @@ from .utils import ( ...@@ -20,6 +24,8 @@ from .utils import (
from mock import Mock from mock import Mock
from edx_proctoring.urls import urlpatterns from edx_proctoring.urls import urlpatterns
from edx_proctoring.backends.tests.test_review_payload import TEST_REVIEW_PAYLOAD
from edx_proctoring.backends.tests.test_software_secure import mock_response_content
class ProctoredExamsApiTests(LoggedInTestCase): class ProctoredExamsApiTests(LoggedInTestCase):
...@@ -941,6 +947,54 @@ class TestStudentProctoredExamAttempt(LoggedInTestCase): ...@@ -941,6 +947,54 @@ class TestStudentProctoredExamAttempt(LoggedInTestCase):
) )
self.assertEqual(response.status_code, 404) self.assertEqual(response.status_code, 404)
def test_review_callback(self):
"""
Simulates a callback from the proctoring service with the
review data
"""
exam_id = create_exam(
course_id='foo/bar/baz',
content_id='content',
exam_name='Sample Exam',
time_limit_mins=10,
is_proctored=True
)
# be sure to use the mocked out SoftwareSecure handlers
with HTTMock(mock_response_content):
attempt_id = create_exam_attempt(
exam_id,
self.user.id,
taking_as_proctored=True
)
attempt = get_exam_attempt_by_id(attempt_id)
self.assertIsNotNone(attempt['external_id'])
test_payload = Template(TEST_REVIEW_PAYLOAD).substitute(
attempt_code=attempt['attempt_code'],
external_id=attempt['external_id']
)
response = self.client.post(
reverse('edx_proctoring.anonymous.proctoring_review_callback'),
data=test_payload,
content_type='application/json'
)
self.assertEqual(response.status_code, 200)
def test_review_callback_get(self):
"""
We don't support any http METHOD other than GET
"""
response = self.client.get(
reverse('edx_proctoring.anonymous.proctoring_review_callback'),
)
self.assertEqual(response.status_code, 405)
class TestExamAllowanceView(LoggedInTestCase): class TestExamAllowanceView(LoggedInTestCase):
""" """
......
...@@ -71,5 +71,10 @@ urlpatterns = patterns( # pylint: disable=invalid-name ...@@ -71,5 +71,10 @@ urlpatterns = patterns( # pylint: disable=invalid-name
callbacks.start_exam_callback, callbacks.start_exam_callback,
name='edx_proctoring.anonymous.proctoring_launch_callback.start_exam' name='edx_proctoring.anonymous.proctoring_launch_callback.start_exam'
), ),
url(
r'edx_proctoring/proctoring_review_callback/$',
callbacks.ExamReviewCallback.as_view(),
name='edx_proctoring.anonymous.proctoring_review_callback'
),
url(r'^', include('rest_framework.urls', namespace='rest_framework')) url(r'^', include('rest_framework.urls', namespace='rest_framework'))
) )
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment