Commit 5f19a077 by Chris Dodge

allow for optional (default True) ability to allow SoftwareSecure to retransmit proctored results

parent 19a9e1ce
...@@ -15,6 +15,7 @@ import logging ...@@ -15,6 +15,7 @@ import logging
from django.conf import settings from django.conf import settings
from edx_proctoring.backends.backend import ProctoringBackendProvider from edx_proctoring.backends.backend import ProctoringBackendProvider
from edx_proctoring import constants
from edx_proctoring.exceptions import ( from edx_proctoring.exceptions import (
BackendProvideCannotRegisterAttempt, BackendProvideCannotRegisterAttempt,
StudentExamAttemptDoesNotExistsException, StudentExamAttemptDoesNotExistsException,
...@@ -188,29 +189,42 @@ class SoftwareSecureBackendProvider(ProctoringBackendProvider): ...@@ -188,29 +189,42 @@ class SoftwareSecureBackendProvider(ProctoringBackendProvider):
) )
raise ProctoredExamSuspiciousLookup(err_msg) raise ProctoredExamSuspiciousLookup(err_msg)
# do we already have a review for this attempt?!? It should not be updated! # do some limited parsing of the JSON payload
review_status = payload['reviewStatus']
video_review_link = payload['videoReviewLink']
# do we already have a review for this attempt?!? We may not allow updates
review = ProctoredExamSoftwareSecureReview.get_review_by_attempt_code(attempt_code) review = ProctoredExamSoftwareSecureReview.get_review_by_attempt_code(attempt_code)
if review: if review:
err_msg = ( if not constants.ALLOW_REVIEW_UPDATES:
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)
# we allow updates
warn_msg = (
'We already have a review submitted from SoftwareSecure regarding ' 'We already have a review submitted from SoftwareSecure regarding '
'attempt_code {attempt_code}. We do not allow for updates!'.format( 'attempt_code {attempt_code}. We have been configured to allow for '
'updates and will continue...'.format(
attempt_code=attempt_code attempt_code=attempt_code
) )
) )
raise ProctoredExamReviewAlreadyExists(err_msg) log.warn(warn_msg)
else:
# this is first time we've received this attempt_code, so
# make a new record in the review table
review = ProctoredExamSoftwareSecureReview()
# do some limited parsing of the JSON payload review.attempt_code = attempt_code
review_status = payload['reviewStatus'] review.raw_data = json.dumps(payload)
video_review_link = payload['videoReviewLink'] review.review_status = review_status
review.video_url = video_review_link
# 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() review.save()
# go through and populate all of the specific comments # go through and populate all of the specific comments
......
# coding=utf-8 # coding=utf-8
# pylint: disable=too-many-lines, invalid-name
""" """
Tests for the software_secure module Tests for the software_secure module
""" """
...@@ -32,6 +33,7 @@ from edx_proctoring. models import ( ...@@ -32,6 +33,7 @@ from edx_proctoring. models import (
ProctoredExamSoftwareSecureReview, ProctoredExamSoftwareSecureReview,
ProctoredExamSoftwareSecureComment, ProctoredExamSoftwareSecureComment,
ProctoredExamStudentAttemptStatus, ProctoredExamStudentAttemptStatus,
ProctoredExamSoftwareSecureReviewHistory,
) )
from edx_proctoring.backends.tests.test_review_payload import TEST_REVIEW_PAYLOAD from edx_proctoring.backends.tests.test_review_payload import TEST_REVIEW_PAYLOAD
...@@ -471,7 +473,8 @@ class SoftwareSecureTests(TestCase): ...@@ -471,7 +473,8 @@ class SoftwareSecureTests(TestCase):
self.assertEqual(len(comments), 6) self.assertEqual(len(comments), 6)
def test_review_resubmission(self): @patch('edx_proctoring.constants.ALLOW_REVIEW_UPDATES', False)
def test_disallow_review_resubmission(self):
""" """
Tests that an exception is raised if a review report is resubmitted for the same Tests that an exception is raised if a review report is resubmitted for the same
attempt attempt
...@@ -508,3 +511,70 @@ class SoftwareSecureTests(TestCase): ...@@ -508,3 +511,70 @@ class SoftwareSecureTests(TestCase):
# now call again # now call again
with self.assertRaises(ProctoredExamReviewAlreadyExists): with self.assertRaises(ProctoredExamReviewAlreadyExists):
provider.on_review_callback(json.loads(test_payload)) provider.on_review_callback(json.loads(test_payload))
@patch('edx_proctoring.constants.ALLOW_REVIEW_UPDATES', True)
def test_allow_review_resubmission(self):
"""
Tests that an resubmission is allowed
"""
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 history table is empty
records = ProctoredExamSoftwareSecureReviewHistory.objects.filter(attempt_code=attempt['attempt_code'])
self.assertEqual(len(records), 0)
# now call again, this will not throw exception
test_payload = test_payload.replace('Clean', 'Suspicious')
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, 'Suspicious')
self.assertEqual(
review.video_url,
'http://www.remoteproctor.com/AdminSite/Account/Reviewer/DirectLink-Generic.aspx?ID=foo'
)
self.assertIsNotNone(review.raw_data)
# make sure history table is no longer empty
records = ProctoredExamSoftwareSecureReviewHistory.objects.filter(attempt_code=attempt['attempt_code'])
self.assertEqual(len(records), 1)
self.assertEqual(records[0].review_status, 'Clean')
# now try to delete the record and make sure it was archived
review.delete()
records = ProctoredExamSoftwareSecureReviewHistory.objects.filter(attempt_code=attempt['attempt_code'])
self.assertEqual(len(records), 2)
self.assertEqual(records[0].review_status, 'Clean')
self.assertEqual(records[1].review_status, 'Suspicious')
...@@ -24,3 +24,8 @@ CONTACT_EMAIL = ( ...@@ -24,3 +24,8 @@ CONTACT_EMAIL = (
settings.PROCTORING_SETTINGS['CONTACT_EMAIL'] if settings.PROCTORING_SETTINGS['CONTACT_EMAIL'] if
'CONTACT_EMAIL' in settings.PROCTORING_SETTINGS else getattr(settings, 'CONTACT_EMAIL', FROM_EMAIL) 'CONTACT_EMAIL' in settings.PROCTORING_SETTINGS else getattr(settings, 'CONTACT_EMAIL', FROM_EMAIL)
) )
ALLOW_REVIEW_UPDATES = (
settings.PROCTORING_SETTINGS['ALLOW_REVIEW_UPDATES'] if
'ALLOW_REVIEW_UPDATES' in settings.PROCTORING_SETTINGS else getattr(settings, 'ALLOW_REVIEW_UPDATES', True)
)
...@@ -3,7 +3,7 @@ Data models for the proctoring subsystem ...@@ -3,7 +3,7 @@ Data models for the proctoring subsystem
""" """
from django.db import models from django.db import models
from django.db.models import Q from django.db.models import Q
from django.db.models.signals import post_save, pre_delete from django.db.models.signals import pre_save, pre_delete
from django.dispatch import receiver from django.dispatch import receiver
from model_utils.models import TimeStampedModel from model_utils.models import TimeStampedModel
from django.utils.translation import ugettext as _ from django.utils.translation import ugettext as _
...@@ -543,15 +543,16 @@ class ProctoredExamStudentAllowanceHistory(TimeStampedModel): ...@@ -543,15 +543,16 @@ class ProctoredExamStudentAllowanceHistory(TimeStampedModel):
# Hook up the post_save signal to record creations in the ProctoredExamStudentAllowanceHistory table. # Hook up the post_save signal to record creations in the ProctoredExamStudentAllowanceHistory table.
@receiver(post_save, sender=ProctoredExamStudentAllowance) @receiver(pre_save, sender=ProctoredExamStudentAllowance)
def on_allowance_saved(sender, instance, created, **kwargs): # pylint: disable=unused-argument def on_allowance_saved(sender, instance, **kwargs): # pylint: disable=unused-argument
""" """
Archiving all changes made to the Student Allowance. Archiving all changes made to the Student Allowance.
Will only archive on update, and not on new entries created. Will only archive on update, and not on new entries created.
""" """
if not created: if instance.id:
_make_archive_copy(instance) original = ProctoredExamStudentAllowance.objects.get(id=instance.id)
_make_archive_copy(original)
@receiver(pre_delete, sender=ProctoredExamStudentAllowance) @receiver(pre_delete, sender=ProctoredExamStudentAllowance)
...@@ -614,6 +615,66 @@ class ProctoredExamSoftwareSecureReview(TimeStampedModel): ...@@ -614,6 +615,66 @@ class ProctoredExamSoftwareSecureReview(TimeStampedModel):
return None return None
class ProctoredExamSoftwareSecureReviewHistory(TimeStampedModel):
"""
When records get updated, we will archive them here
"""
# 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_proctoredexamsoftwaresecurereviewhistory'
verbose_name = 'proctored exam review history'
# Hook up the post_save signal to record creations in the ProctoredExamStudentAllowanceHistory table.
@receiver(pre_save, sender=ProctoredExamSoftwareSecureReview)
def on_review_saved(sender, instance, **kwargs): # pylint: disable=unused-argument
"""
Archiving all changes made to the Student Allowance.
Will only archive on update, and not on new entries created.
"""
if instance.id:
original = ProctoredExamSoftwareSecureReview.objects.get(id=instance.id)
_make_review_archive_copy(original)
@receiver(pre_delete, sender=ProctoredExamSoftwareSecureReview)
def on_review_deleted(sender, instance, **kwargs): # pylint: disable=unused-argument
"""
Archive the allowance when the item is about to be deleted
"""
_make_review_archive_copy(instance)
def _make_review_archive_copy(instance):
"""
Do the copying into the history table
"""
archive_object = ProctoredExamSoftwareSecureReviewHistory(
attempt_code=instance.attempt_code,
review_status=instance.review_status,
raw_data=instance.raw_data,
video_url=instance.video_url,
)
archive_object.save()
class ProctoredExamSoftwareSecureComment(TimeStampedModel): class ProctoredExamSoftwareSecureComment(TimeStampedModel):
""" """
This is where we store the proctored exam review comments This is where we store the proctored exam review comments
......
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