Commit ca2e4656 by Chris Dodge

Add ability for edX staff to inspect and re-review any submissions for the…

Add ability for edX staff to inspect and re-review any submissions for the Proctoring Backend Providers
parent b0f9b552
""" """
Django Admin pages Django Admin pages
""" """
# pylint: disable=no-self-argument, no-member
from django.contrib import admin from django.contrib import admin
from django import forms
from edx_proctoring.models import ( from edx_proctoring.models import (
ProctoredExamReviewPolicy, ProctoredExamReviewPolicy,
ProctoredExam, ProctoredExamSoftwareSecureReview,
ProctoredExamSoftwareSecureReviewHistory,
) )
from edx_proctoring.utils import locate_attempt_by_attempt_code
from edx_proctoring.backends import get_backend_provider
class ProctoredExamReviewPolicyInline(admin.TabularInline):
"""
Custom inline definition to show related fields
"""
model = ProctoredExam
fields = ('course_id', 'exam_name',)
readonly_fields = ('course_id', 'exam_name',)
class ProctoredExamReviewPolicyAdmin(admin.ModelAdmin): class ProctoredExamReviewPolicyAdmin(admin.ModelAdmin):
...@@ -24,17 +20,17 @@ class ProctoredExamReviewPolicyAdmin(admin.ModelAdmin): ...@@ -24,17 +20,17 @@ class ProctoredExamReviewPolicyAdmin(admin.ModelAdmin):
""" """
readonly_fields = ['set_by_user'] readonly_fields = ['set_by_user']
def course_id(obj): # pylint: disable=no-self-argument def course_id(obj):
""" """
return course_id of related model return course_id of related model
""" """
return obj.proctored_exam.course_id # pylint: disable=no-member return obj.proctored_exam.course_id
def exam_name(obj): # pylint: disable=no-self-argument def exam_name(obj):
""" """
return exam name of related model return exam name of related model
""" """
return obj.proctored_exam.exam_name # pylint: disable=no-member return obj.proctored_exam.exam_name
list_display = [ list_display = [
course_id, course_id,
...@@ -50,4 +46,128 @@ class ProctoredExamReviewPolicyAdmin(admin.ModelAdmin): ...@@ -50,4 +46,128 @@ class ProctoredExamReviewPolicyAdmin(admin.ModelAdmin):
obj.set_by_user = request.user obj.set_by_user = request.user
obj.save() obj.save()
class ProctoredExamSoftwareSecureReviewForm(forms.ModelForm):
"""Admin Form to display for reading/updating a Review"""
class Meta(object): # pylint: disable=missing-docstring
model = ProctoredExamSoftwareSecureReview
REVIEW_STATUS_CHOICES = [
('Clean', 'Clean'),
('Rules Violation', 'Rules Violation'),
('Suspicious', 'Suspicious'),
('Not Reviewed', 'Not Reviewed'),
]
review_status = forms.ChoiceField(choices=REVIEW_STATUS_CHOICES)
video_url = forms.URLField()
raw_data = forms.CharField(widget=forms.Textarea, label='Reviewer Notes')
def video_url_for_review(obj):
"""Return hyperlink to review video url"""
return (
'<a href="{video_url}" target="_blank">{video_url}</a>'.format(video_url=obj.video_url)
)
video_url_for_review.allow_tags = True
class ProctoredExamSoftwareSecureReviewAdmin(admin.ModelAdmin):
"""
The admin panel for SoftwareSecure Review records
"""
readonly_fields = [video_url_for_review, 'attempt_code', 'exam', 'student', 'reviewed_by', 'modified']
list_filter = ['review_status', 'exam__course_id', 'exam__exam_name']
list_select_related = True
search_fields = ['student__username', 'attempt_code']
form = ProctoredExamSoftwareSecureReviewForm
def _get_exam_from_attempt_code(self, code):
"""Get exam from attempt code. Note that the attempt code could be an archived one"""
attempt = locate_attempt_by_attempt_code(code)
return attempt.proctored_exam if attempt else None
def course_id_for_review(self, obj):
"""Return course_id associated with review"""
if obj.exam:
return obj.exam.course_id
else:
exam = self._get_exam_from_attempt_code(obj.attempt_code)
return exam.exam_name if exam else '(none)'
def exam_name_for_review(self, obj):
"""Return course_id associated with review"""
if obj.exam:
return obj.exam.exam_name
else:
exam = self._get_exam_from_attempt_code(obj.attempt_code)
return exam.exam_name if exam else '(none)'
def student_username_for_review(self, obj):
"""Return username of student who took the test"""
if obj.student:
return obj.student.username
else:
attempt = locate_attempt_by_attempt_code(obj.attempt_code)
return attempt.user.username if attempt else '(None)'
list_display = [
'course_id_for_review',
'exam_name_for_review',
'student_username_for_review',
'attempt_code',
'modified',
'review_status'
]
def has_add_permission(self, request):
"""Don't allow adds"""
return False
def has_delete_permission(self, request, obj=None):
"""Don't allow deletes"""
return False
def save_model(self, request, review, form, change):
"""
Override callback so that we can inject the user_id that made the change
"""
review.set_by_user = request.user
review.save()
# call the review saved and since it's coming from
# the Django admin will we accept failures
get_backend_provider().on_review_saved(review, allow_status_update_on_fail=True)
def get_form(self, request, obj=None, **kwargs):
form = super(ProctoredExamSoftwareSecureReviewAdmin, self).get_form(request, obj, **kwargs)
del form.base_fields['video_url']
return form
class ProctoredExamSoftwareSecureReviewHistoryAdmin(ProctoredExamSoftwareSecureReviewAdmin):
"""
The admin panel for SoftwareSecure Review records
"""
readonly_fields = [
video_url_for_review,
'review_status',
'raw_data',
'attempt_code',
'exam',
'student',
'reviewed_by',
'modified',
]
def save_model(self, request, review, form, change):
"""
History can't be updated
"""
return
admin.site.register(ProctoredExamReviewPolicy, ProctoredExamReviewPolicyAdmin) admin.site.register(ProctoredExamReviewPolicy, ProctoredExamReviewPolicyAdmin)
admin.site.register(ProctoredExamSoftwareSecureReview, ProctoredExamSoftwareSecureReviewAdmin)
admin.site.register(ProctoredExamSoftwareSecureReviewHistory, ProctoredExamSoftwareSecureReviewHistoryAdmin)
...@@ -932,17 +932,17 @@ def _check_eligibility_of_prerequisites(credit_state): ...@@ -932,17 +932,17 @@ def _check_eligibility_of_prerequisites(credit_state):
STATUS_SUMMARY_MAP = { STATUS_SUMMARY_MAP = {
'_default': { '_default': {
'short_description': _('Taking As Proctored Exam'), 'short_description': _('Taking As Proctored Exam'),
'suggested_icon': 'fa-lock', 'suggested_icon': 'fa-pencil-square-o',
'in_completed_state': False 'in_completed_state': False
}, },
ProctoredExamStudentAttemptStatus.eligible: { ProctoredExamStudentAttemptStatus.eligible: {
'short_description': _('Proctored Option Available'), 'short_description': _('Proctored Option Available'),
'suggested_icon': 'fa-lock', 'suggested_icon': 'fa-pencil-square-o',
'in_completed_state': False 'in_completed_state': False
}, },
ProctoredExamStudentAttemptStatus.declined: { ProctoredExamStudentAttemptStatus.declined: {
'short_description': _('Taking As Open Exam'), 'short_description': _('Taking As Open Exam'),
'suggested_icon': 'fa-unlock', 'suggested_icon': 'fa-pencil-square-o',
'in_completed_state': False 'in_completed_state': False
}, },
ProctoredExamStudentAttemptStatus.submitted: { ProctoredExamStudentAttemptStatus.submitted: {
...@@ -971,7 +971,7 @@ STATUS_SUMMARY_MAP = { ...@@ -971,7 +971,7 @@ STATUS_SUMMARY_MAP = {
PRACTICE_STATUS_SUMMARY_MAP = { PRACTICE_STATUS_SUMMARY_MAP = {
'_default': { '_default': {
'short_description': _('Ungraded Practice Exam'), 'short_description': _('Ungraded Practice Exam'),
'suggested_icon': 'fa-lock', 'suggested_icon': 'fa-pencil-square-o',
'in_completed_state': False 'in_completed_state': False
}, },
ProctoredExamStudentAttemptStatus.submitted: { ProctoredExamStudentAttemptStatus.submitted: {
......
...@@ -50,3 +50,11 @@ class ProctoringBackendProvider(object): ...@@ -50,3 +50,11 @@ class ProctoringBackendProvider(object):
Called when the reviewing 3rd party service posts back the results Called when the reviewing 3rd party service posts back the results
""" """
raise NotImplementedError() raise NotImplementedError()
@abc.abstractmethod
def on_review_saved(self, review):
"""
called when a review has been save - either through API or via Django Admin panel
in order to trigger any workflow.
"""
raise NotImplementedError()
...@@ -41,3 +41,9 @@ class NullBackendProvider(ProctoringBackendProvider): ...@@ -41,3 +41,9 @@ class NullBackendProvider(ProctoringBackendProvider):
""" """
Called when the reviewing 3rd party service posts back the results Called when the reviewing 3rd party service posts back the results
""" """
def on_review_saved(self, review):
"""
called when a review has been save - either through API or via Django Admin panel
in order to trigger any workflow
"""
...@@ -23,12 +23,10 @@ from edx_proctoring.exceptions import ( ...@@ -23,12 +23,10 @@ from edx_proctoring.exceptions import (
ProctoredExamReviewAlreadyExists, ProctoredExamReviewAlreadyExists,
ProctoredExamBadReviewStatus, ProctoredExamBadReviewStatus,
) )
from edx_proctoring.utils import locate_attempt_by_attempt_code
from edx_proctoring. models import ( from edx_proctoring. models import (
ProctoredExamSoftwareSecureReview, ProctoredExamSoftwareSecureReview,
ProctoredExamSoftwareSecureComment, ProctoredExamSoftwareSecureComment,
ProctoredExamStudentAttempt,
ProctoredExamStudentAttemptHistory,
ProctoredExamStudentAttemptStatus, ProctoredExamStudentAttemptStatus,
) )
...@@ -154,20 +152,13 @@ class SoftwareSecureBackendProvider(ProctoringBackendProvider): ...@@ -154,20 +152,13 @@ class SoftwareSecureBackendProvider(ProctoringBackendProvider):
# what we recorded as the external_id. We need to look in both # what we recorded as the external_id. We need to look in both
# the attempt table as well as the archive table # the attempt table as well as the archive table
attempt_obj = ProctoredExamStudentAttempt.objects.get_exam_attempt_by_code(attempt_code) (attempt_obj, is_archived_attempt) = locate_attempt_by_attempt_code(attempt_code)
is_archived_attempt = False
if not attempt_obj: if not attempt_obj:
# try archive table # still can't find, error out
attempt_obj = ProctoredExamStudentAttemptHistory.get_exam_attempt_by_code(attempt_code) err_msg = (
is_archived_attempt = True 'Could not locate attempt_code: {attempt_code}'.format(attempt_code=attempt_code)
)
if not attempt_obj: raise StudentExamAttemptDoesNotExistsException(err_msg)
# 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 # then make sure we have the right external_id
# note that SoftwareSecure might send a case insensitive # note that SoftwareSecure might send a case insensitive
...@@ -224,6 +215,8 @@ class SoftwareSecureBackendProvider(ProctoringBackendProvider): ...@@ -224,6 +215,8 @@ class SoftwareSecureBackendProvider(ProctoringBackendProvider):
review.raw_data = json.dumps(payload) review.raw_data = json.dumps(payload)
review.review_status = review_status review.review_status = review_status
review.video_url = video_review_link review.video_url = video_review_link
review.student = attempt_obj.user
review.exam = attempt_obj.proctored_exam
review.save() review.save()
...@@ -234,21 +227,54 @@ class SoftwareSecureBackendProvider(ProctoringBackendProvider): ...@@ -234,21 +227,54 @@ class SoftwareSecureBackendProvider(ProctoringBackendProvider):
for comment in payload.get('desktopComments', []): for comment in payload.get('desktopComments', []):
self._save_review_comment(review, comment) self._save_review_comment(review, comment)
# we could have gottent a review for an archived attempt # we could have gotten a review for an archived attempt
# this should *not* cause an update in our credit # this should *not* cause an update in our credit
# eligibility table # eligibility table
if not is_archived_attempt: if not is_archived_attempt:
# update our attempt status, note we have to import api.py here because # update our attempt status, note we have to import api.py here because
# api.py imports software_secure.py, so we'll get an import circular reference # api.py imports software_secure.py, so we'll get an import circular reference
from edx_proctoring.api import update_attempt_status allow_status_update_on_fail = not constants.REQUIRE_FAILURE_SECOND_REVIEWS
self.on_review_saved(review, allow_status_update_on_fail=allow_status_update_on_fail)
def on_review_saved(self, review, allow_status_update_on_fail=False): # pylint: disable=arguments-differ
"""
called when a review has been save - either through API (on_review_callback) or via Django Admin panel
in order to trigger any workflow associated with proctoring review results
"""
(attempt_obj, is_archived_attempt) = locate_attempt_by_attempt_code(review.attempt_code)
if not attempt_obj:
# This should not happen, but it is logged in the help
# method
return
# only 'Clean' and 'Rules Violation' could as passing if is_archived_attempt:
status = ( # we don't trigger workflow on reviews on archived attempts
ProctoredExamStudentAttemptStatus.verified err_msg = (
if review_status in self.passing_review_status 'Got on_review_save() callback for an archived attempt with '
else ProctoredExamStudentAttemptStatus.rejected 'attempt_code {attempt_code}. Will not trigger workflow...'.format(
attempt_code=review.attempt_code
)
) )
log.warn(err_msg)
return
# only 'Clean' and 'Rules Violation' count as passing
status = (
ProctoredExamStudentAttemptStatus.verified
if review.review_status in self.passing_review_status
else ProctoredExamStudentAttemptStatus.rejected
)
# are we allowed to update the status if we have a failure status
# i.e. do we need a review to come in from Django Admin panel?
if status == ProctoredExamStudentAttemptStatus.verified or allow_status_update_on_fail:
# updating attempt status will trigger workflow
# (i.e. updating credit eligibility table)
from edx_proctoring.api import update_attempt_status
update_attempt_status( update_attempt_status(
attempt_obj.proctored_exam_id, attempt_obj.proctored_exam_id,
...@@ -285,6 +311,19 @@ class SoftwareSecureBackendProvider(ProctoringBackendProvider): ...@@ -285,6 +311,19 @@ class SoftwareSecureBackendProvider(ProctoringBackendProvider):
encrypted_text = cipher.encrypt(pad(pwd)) encrypted_text = cipher.encrypt(pad(pwd))
return base64.b64encode(encrypted_text) return base64.b64encode(encrypted_text)
def _split_fullname(self, full_name):
"""
Utility to break Full Name to first and last name
"""
first_name = ''
last_name = ''
name_elements = full_name.split(' ')
first_name = name_elements[0]
if len(name_elements) > 1:
last_name = ' '.join(name_elements[1:])
return (first_name, last_name)
def _get_payload(self, exam, context): def _get_payload(self, exam, context):
""" """
Constructs the data payload that Software Secure expects Constructs the data payload that Software Secure expects
...@@ -296,14 +335,7 @@ class SoftwareSecureBackendProvider(ProctoringBackendProvider): ...@@ -296,14 +335,7 @@ class SoftwareSecureBackendProvider(ProctoringBackendProvider):
callback_url = context['callback_url'] callback_url = context['callback_url']
full_name = context['full_name'] full_name = context['full_name']
review_policy = context.get('review_policy', constants.DEFAULT_SOFTWARE_SECURE_REVIEW_POLICY) review_policy = context.get('review_policy', constants.DEFAULT_SOFTWARE_SECURE_REVIEW_POLICY)
first_name = '' (first_name, last_name) = self._split_fullname(full_name)
last_name = ''
if full_name:
name_elements = full_name.split(' ')
first_name = name_elements[0]
if len(name_elements) > 1:
last_name = ' '.join(name_elements[1:])
now = datetime.datetime.utcnow() now = datetime.datetime.utcnow()
start_time_str = now.strftime("%a, %d %b %Y %H:%M:%S GMT") start_time_str = now.strftime("%a, %d %b %Y %H:%M:%S GMT")
......
...@@ -44,6 +44,12 @@ class TestBackendProvider(ProctoringBackendProvider): ...@@ -44,6 +44,12 @@ class TestBackendProvider(ProctoringBackendProvider):
Called when the reviewing 3rd party service posts back the results Called when the reviewing 3rd party service posts back the results
""" """
def on_review_saved(self, review):
"""
called when a review has been save - either through API or via Django Admin panel
in order to trigger any workflow
"""
class PassthroughBackendProvider(ProctoringBackendProvider): class PassthroughBackendProvider(ProctoringBackendProvider):
""" """
...@@ -92,6 +98,13 @@ class PassthroughBackendProvider(ProctoringBackendProvider): ...@@ -92,6 +98,13 @@ class PassthroughBackendProvider(ProctoringBackendProvider):
""" """
return super(PassthroughBackendProvider, self).on_review_callback(payload) return super(PassthroughBackendProvider, self).on_review_callback(payload)
def on_review_saved(self, review):
"""
called when a review has been save - either through API or via Django Admin panel
in order to trigger any workflow
"""
return super(PassthroughBackendProvider, self).on_review_saved(review)
class TestBackends(TestCase): class TestBackends(TestCase):
""" """
...@@ -120,6 +133,9 @@ class TestBackends(TestCase): ...@@ -120,6 +133,9 @@ class TestBackends(TestCase):
with self.assertRaises(NotImplementedError): with self.assertRaises(NotImplementedError):
provider.on_review_callback(None) provider.on_review_callback(None)
with self.assertRaises(NotImplementedError):
provider.on_review_saved(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
...@@ -132,3 +148,4 @@ class TestBackends(TestCase): ...@@ -132,3 +148,4 @@ class TestBackends(TestCase):
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)) self.assertIsNone(provider.on_review_callback(None))
self.assertIsNone(provider.on_review_saved(None))
# coding=utf-8 # coding=utf-8
# pylint: disable=too-many-lines, invalid-name # pylint: disable=too-many-lines, invalid-name, protected-access
""" """
Tests for the software_secure module Tests for the software_secure module
""" """
...@@ -30,12 +30,13 @@ from edx_proctoring.exceptions import ( ...@@ -30,12 +30,13 @@ from edx_proctoring.exceptions import (
ProctoredExamReviewAlreadyExists, ProctoredExamReviewAlreadyExists,
ProctoredExamBadReviewStatus ProctoredExamBadReviewStatus
) )
from edx_proctoring. models import ( from edx_proctoring.models import (
ProctoredExamSoftwareSecureReview, ProctoredExamSoftwareSecureReview,
ProctoredExamSoftwareSecureComment, ProctoredExamSoftwareSecureComment,
ProctoredExamStudentAttemptStatus, ProctoredExamStudentAttemptStatus,
ProctoredExamSoftwareSecureReviewHistory, ProctoredExamSoftwareSecureReviewHistory,
ProctoredExamReviewPolicy, ProctoredExamReviewPolicy,
ProctoredExamStudentAttemptHistory,
) )
from edx_proctoring.backends.tests.test_review_payload import TEST_REVIEW_PAYLOAD from edx_proctoring.backends.tests.test_review_payload import TEST_REVIEW_PAYLOAD
...@@ -348,6 +349,7 @@ class SoftwareSecureTests(TestCase): ...@@ -348,6 +349,7 @@ class SoftwareSecureTests(TestCase):
('Not Reviewed', 'failed'), ('Not Reviewed', 'failed'),
) )
@ddt.unpack @ddt.unpack
@patch('edx_proctoring.constants.REQUIRE_FAILURE_SECOND_REVIEWS', False)
def test_review_callback(self, review_status, credit_requirement_status): def test_review_callback(self, review_status, credit_requirement_status):
""" """
Simulates callbacks from SoftwareSecure with various statuses Simulates callbacks from SoftwareSecure with various statuses
...@@ -392,6 +394,7 @@ class SoftwareSecureTests(TestCase): ...@@ -392,6 +394,7 @@ class SoftwareSecureTests(TestCase):
'http://www.remoteproctor.com/AdminSite/Account/Reviewer/DirectLink-Generic.aspx?ID=foo' 'http://www.remoteproctor.com/AdminSite/Account/Reviewer/DirectLink-Generic.aspx?ID=foo'
) )
self.assertIsNotNone(review.raw_data) self.assertIsNotNone(review.raw_data)
self.assertIsNone(review.reviewed_by)
# now check the comments that were stored # now check the comments that were stored
comments = ProctoredExamSoftwareSecureComment.objects.filter(review_id=review.id) comments = ProctoredExamSoftwareSecureComment.objects.filter(review_id=review.id)
...@@ -476,6 +479,7 @@ class SoftwareSecureTests(TestCase): ...@@ -476,6 +479,7 @@ class SoftwareSecureTests(TestCase):
provider.on_review_callback(json.loads(test_payload)) provider.on_review_callback(json.loads(test_payload))
@patch.dict('django.conf.settings.PROCTORING_SETTINGS', {'ALLOW_CALLBACK_SIMULATION': True}) @patch.dict('django.conf.settings.PROCTORING_SETTINGS', {'ALLOW_CALLBACK_SIMULATION': True})
@patch('edx_proctoring.constants.REQUIRE_FAILURE_SECOND_REVIEWS', False)
def test_allow_simulated_callbacks(self): def test_allow_simulated_callbacks(self):
""" """
Verify that the configuration switch to Verify that the configuration switch to
...@@ -515,6 +519,7 @@ class SoftwareSecureTests(TestCase): ...@@ -515,6 +519,7 @@ class SoftwareSecureTests(TestCase):
attempt = get_exam_attempt_by_id(attempt_id) attempt = get_exam_attempt_by_id(attempt_id)
self.assertEqual(attempt['status'], ProctoredExamStudentAttemptStatus.verified) self.assertEqual(attempt['status'], ProctoredExamStudentAttemptStatus.verified)
@patch('edx_proctoring.constants.REQUIRE_FAILURE_SECOND_REVIEWS', False)
def test_review_on_archived_attempt(self): def test_review_on_archived_attempt(self):
""" """
Make sure we can process a review report for Make sure we can process a review report for
...@@ -570,6 +575,7 @@ class SoftwareSecureTests(TestCase): ...@@ -570,6 +575,7 @@ class SoftwareSecureTests(TestCase):
self.assertEqual(len(comments), 6) self.assertEqual(len(comments), 6)
@patch('edx_proctoring.constants.ALLOW_REVIEW_UPDATES', False) @patch('edx_proctoring.constants.ALLOW_REVIEW_UPDATES', False)
@patch('edx_proctoring.constants.REQUIRE_FAILURE_SECOND_REVIEWS', False)
def test_disallow_review_resubmission(self): 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
...@@ -674,3 +680,145 @@ class SoftwareSecureTests(TestCase): ...@@ -674,3 +680,145 @@ class SoftwareSecureTests(TestCase):
self.assertEqual(len(records), 2) self.assertEqual(len(records), 2)
self.assertEqual(records[0].review_status, 'Clean') self.assertEqual(records[0].review_status, 'Clean')
self.assertEqual(records[1].review_status, 'Suspicious') self.assertEqual(records[1].review_status, 'Suspicious')
def test_failure_submission(self):
"""
Tests that a submission of a failed test and make sure that we
don't automatically update the status to failure
"""
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)
test_payload = Template(TEST_REVIEW_PAYLOAD).substitute(
attempt_code=attempt['attempt_code'],
external_id=attempt['external_id']
)
test_payload = test_payload.replace('Clean', 'Suspicious')
# submit a Suspicious review payload
provider.on_review_callback(json.loads(test_payload))
# now look at the attempt and make sure it did not
# transition to failure on the callback,
# as we'll need a manual confirmation via Django Admin pages
attempt = get_exam_attempt_by_id(attempt_id)
self.assertNotEqual(attempt['status'], ProctoredExamStudentAttemptStatus.rejected)
review = ProctoredExamSoftwareSecureReview.objects.get(attempt_code=attempt['attempt_code'])
# now simulate a update via Django Admin table which will actually
# push through the failure into our attempt status (as well as trigger)
# other workflow
provider.on_review_saved(review, allow_status_update_on_fail=True)
attempt = get_exam_attempt_by_id(attempt_id)
self.assertEqual(attempt['status'], ProctoredExamStudentAttemptStatus.rejected)
def test_update_archived_attempt(self):
"""
Test calling the on_review_saved interface point with an attempt_code that was 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 process the report
provider.on_review_callback(json.loads(test_payload))
# now look at the attempt and make sure it did not
# transition to failure on the callback,
# as we'll need a manual confirmation via Django Admin pages
attempt = get_exam_attempt_by_id(attempt_id)
self.assertEqual(attempt['status'], attempt['status'])
# now delete the attempt, which puts it into the archive table
remove_exam_attempt(attempt_id)
review = ProctoredExamSoftwareSecureReview.objects.get(attempt_code=attempt['attempt_code'])
# now simulate a update via Django Admin table which will actually
# push through the failure into our attempt status but
# as this is an archived attempt, we don't do anything
provider.on_review_saved(review, allow_status_update_on_fail=True)
# look at the attempt again, since it moved into Archived state
# then it should still remain unchanged
archived_attempt = ProctoredExamStudentAttemptHistory.objects.get(attempt_code=attempt['attempt_code'])
self.assertEqual(archived_attempt.status, attempt['status'])
def test_on_review_saved_bad_code(self):
"""
Simulate calling on_review_saved() with an attempt code that cannot be found
"""
provider = get_backend_provider()
review = ProctoredExamSoftwareSecureReview()
review.attempt_code = 'foo'
self.assertIsNone(provider.on_review_saved(review, allow_status_update_on_fail=True))
def test_split_fullname(self):
"""
Make sure we are splitting up full names correctly
"""
provider = get_backend_provider()
(first_name, last_name) = provider._split_fullname('John Doe')
self.assertEqual(first_name, 'John')
self.assertEqual(last_name, 'Doe')
(first_name, last_name) = provider._split_fullname('Johnny')
self.assertEqual(first_name, 'Johnny')
self.assertEqual(last_name, '')
(first_name, last_name) = provider._split_fullname('Baron von Munchausen')
self.assertEqual(first_name, 'Baron')
self.assertEqual(last_name, 'von Munchausen')
(first_name, last_name) = provider._split_fullname(u'अआईउऊऋऌ अआईउऊऋऌ')
self.assertEqual(first_name, u'अआईउऊऋऌ')
self.assertEqual(last_name, u'अआईउऊऋऌ')
...@@ -35,3 +35,9 @@ DEFAULT_SOFTWARE_SECURE_REVIEW_POLICY = ( ...@@ -35,3 +35,9 @@ DEFAULT_SOFTWARE_SECURE_REVIEW_POLICY = (
'DEFAULT_REVIEW_POLICY' in settings.PROCTORING_SETTINGS 'DEFAULT_REVIEW_POLICY' in settings.PROCTORING_SETTINGS
else getattr(settings, 'DEFAULT_REVIEW_POLICY', 'Closed Book') else getattr(settings, 'DEFAULT_REVIEW_POLICY', 'Closed Book')
) )
REQUIRE_FAILURE_SECOND_REVIEWS = (
settings.PROCTORING_SETTINGS['REQUIRE_FAILURE_SECOND_REVIEWS'] if
'REQUIRE_FAILURE_SECOND_REVIEWS' in settings.PROCTORING_SETTINGS
else getattr(settings, 'REQUIRE_FAILURE_SECOND_REVIEWS', True)
)
# -*- coding: utf-8 -*-
from south.utils import datetime_utils as datetime
from south.db import db
from south.v2 import SchemaMigration
from django.db import models
class Migration(SchemaMigration):
def forwards(self, orm):
# Adding field 'ProctoredExamSoftwareSecureReview.reviewed_by'
db.add_column('proctoring_proctoredexamsoftwaresecurereview', 'reviewed_by',
self.gf('django.db.models.fields.related.ForeignKey')(to=orm['auth.User'], null=True),
keep_default=False)
# Adding field 'ProctoredExamSoftwareSecureReviewHistory.reviewed_by'
db.add_column('proctoring_proctoredexamsoftwaresecurereviewhistory', 'reviewed_by',
self.gf('django.db.models.fields.related.ForeignKey')(to=orm['auth.User'], null=True),
keep_default=False)
def backwards(self, orm):
# Deleting field 'ProctoredExamSoftwareSecureReview.reviewed_by'
db.delete_column('proctoring_proctoredexamsoftwaresecurereview', 'reviewed_by_id')
# Deleting field 'ProctoredExamSoftwareSecureReviewHistory.reviewed_by'
db.delete_column('proctoring_proctoredexamsoftwaresecurereviewhistory', 'reviewed_by_id')
models = {
'auth.group': {
'Meta': {'object_name': 'Group'},
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
'name': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '80'}),
'permissions': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Permission']", 'symmetrical': 'False', 'blank': 'True'})
},
'auth.permission': {
'Meta': {'ordering': "('content_type__app_label', 'content_type__model', 'codename')", 'unique_together': "(('content_type', 'codename'),)", 'object_name': 'Permission'},
'codename': ('django.db.models.fields.CharField', [], {'max_length': '100'}),
'content_type': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['contenttypes.ContentType']"}),
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
'name': ('django.db.models.fields.CharField', [], {'max_length': '50'})
},
'auth.user': {
'Meta': {'object_name': 'User'},
'date_joined': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}),
'email': ('django.db.models.fields.EmailField', [], {'max_length': '75', 'blank': 'True'}),
'first_name': ('django.db.models.fields.CharField', [], {'max_length': '30', 'blank': 'True'}),
'groups': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Group']", 'symmetrical': 'False', 'blank': 'True'}),
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
'is_active': ('django.db.models.fields.BooleanField', [], {'default': 'True'}),
'is_staff': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
'is_superuser': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
'last_login': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}),
'last_name': ('django.db.models.fields.CharField', [], {'max_length': '30', 'blank': 'True'}),
'password': ('django.db.models.fields.CharField', [], {'max_length': '128'}),
'user_permissions': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Permission']", 'symmetrical': 'False', 'blank': 'True'}),
'username': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '30'})
},
'contenttypes.contenttype': {
'Meta': {'ordering': "('name',)", 'unique_together': "(('app_label', 'model'),)", 'object_name': 'ContentType', 'db_table': "'django_content_type'"},
'app_label': ('django.db.models.fields.CharField', [], {'max_length': '100'}),
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
'model': ('django.db.models.fields.CharField', [], {'max_length': '100'}),
'name': ('django.db.models.fields.CharField', [], {'max_length': '100'})
},
'edx_proctoring.proctoredexam': {
'Meta': {'unique_together': "(('course_id', 'content_id'),)", 'object_name': 'ProctoredExam', 'db_table': "'proctoring_proctoredexam'"},
'content_id': ('django.db.models.fields.CharField', [], {'max_length': '255', 'db_index': 'True'}),
'course_id': ('django.db.models.fields.CharField', [], {'max_length': '255', 'db_index': 'True'}),
'created': ('model_utils.fields.AutoCreatedField', [], {'default': 'datetime.datetime.now'}),
'exam_name': ('django.db.models.fields.TextField', [], {}),
'external_id': ('django.db.models.fields.CharField', [], {'max_length': '255', 'null': 'True', 'db_index': 'True'}),
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
'is_active': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
'is_practice_exam': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
'is_proctored': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
'modified': ('model_utils.fields.AutoLastModifiedField', [], {'default': 'datetime.datetime.now'}),
'time_limit_mins': ('django.db.models.fields.IntegerField', [], {})
},
'edx_proctoring.proctoredexamreviewpolicy': {
'Meta': {'object_name': 'ProctoredExamReviewPolicy', 'db_table': "'proctoring_proctoredexamreviewpolicy'"},
'created': ('model_utils.fields.AutoCreatedField', [], {'default': 'datetime.datetime.now'}),
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
'modified': ('model_utils.fields.AutoLastModifiedField', [], {'default': 'datetime.datetime.now'}),
'proctored_exam': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['edx_proctoring.ProctoredExam']"}),
'review_policy': ('django.db.models.fields.TextField', [], {}),
'set_by_user': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['auth.User']"})
},
'edx_proctoring.proctoredexamreviewpolicyhistory': {
'Meta': {'object_name': 'ProctoredExamReviewPolicyHistory', 'db_table': "'proctoring_proctoredexamreviewpolicyhistory'"},
'created': ('model_utils.fields.AutoCreatedField', [], {'default': 'datetime.datetime.now'}),
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
'modified': ('model_utils.fields.AutoLastModifiedField', [], {'default': 'datetime.datetime.now'}),
'original_id': ('django.db.models.fields.IntegerField', [], {'db_index': 'True'}),
'proctored_exam': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['edx_proctoring.ProctoredExam']"}),
'review_policy': ('django.db.models.fields.TextField', [], {}),
'set_by_user': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['auth.User']"})
},
'edx_proctoring.proctoredexamsoftwaresecurecomment': {
'Meta': {'object_name': 'ProctoredExamSoftwareSecureComment', 'db_table': "'proctoring_proctoredexamstudentattemptcomment'"},
'comment': ('django.db.models.fields.TextField', [], {}),
'created': ('model_utils.fields.AutoCreatedField', [], {'default': 'datetime.datetime.now'}),
'duration': ('django.db.models.fields.IntegerField', [], {}),
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
'modified': ('model_utils.fields.AutoLastModifiedField', [], {'default': 'datetime.datetime.now'}),
'review': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['edx_proctoring.ProctoredExamSoftwareSecureReview']"}),
'start_time': ('django.db.models.fields.IntegerField', [], {}),
'status': ('django.db.models.fields.CharField', [], {'max_length': '255'}),
'stop_time': ('django.db.models.fields.IntegerField', [], {})
},
'edx_proctoring.proctoredexamsoftwaresecurereview': {
'Meta': {'object_name': 'ProctoredExamSoftwareSecureReview', 'db_table': "'proctoring_proctoredexamsoftwaresecurereview'"},
'attempt_code': ('django.db.models.fields.CharField', [], {'max_length': '255', 'db_index': 'True'}),
'created': ('model_utils.fields.AutoCreatedField', [], {'default': 'datetime.datetime.now'}),
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
'modified': ('model_utils.fields.AutoLastModifiedField', [], {'default': 'datetime.datetime.now'}),
'raw_data': ('django.db.models.fields.TextField', [], {}),
'review_status': ('django.db.models.fields.CharField', [], {'max_length': '255'}),
'reviewed_by': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['auth.User']", 'null': 'True'}),
'video_url': ('django.db.models.fields.TextField', [], {})
},
'edx_proctoring.proctoredexamsoftwaresecurereviewhistory': {
'Meta': {'object_name': 'ProctoredExamSoftwareSecureReviewHistory', 'db_table': "'proctoring_proctoredexamsoftwaresecurereviewhistory'"},
'attempt_code': ('django.db.models.fields.CharField', [], {'max_length': '255', 'db_index': 'True'}),
'created': ('model_utils.fields.AutoCreatedField', [], {'default': 'datetime.datetime.now'}),
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
'modified': ('model_utils.fields.AutoLastModifiedField', [], {'default': 'datetime.datetime.now'}),
'raw_data': ('django.db.models.fields.TextField', [], {}),
'review_status': ('django.db.models.fields.CharField', [], {'max_length': '255'}),
'reviewed_by': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['auth.User']", 'null': 'True'}),
'video_url': ('django.db.models.fields.TextField', [], {})
},
'edx_proctoring.proctoredexamstudentallowance': {
'Meta': {'unique_together': "(('user', 'proctored_exam', 'key'),)", 'object_name': 'ProctoredExamStudentAllowance', 'db_table': "'proctoring_proctoredexamstudentallowance'"},
'created': ('model_utils.fields.AutoCreatedField', [], {'default': 'datetime.datetime.now'}),
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
'key': ('django.db.models.fields.CharField', [], {'max_length': '255'}),
'modified': ('model_utils.fields.AutoLastModifiedField', [], {'default': 'datetime.datetime.now'}),
'proctored_exam': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['edx_proctoring.ProctoredExam']"}),
'user': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['auth.User']"}),
'value': ('django.db.models.fields.CharField', [], {'max_length': '255'})
},
'edx_proctoring.proctoredexamstudentallowancehistory': {
'Meta': {'object_name': 'ProctoredExamStudentAllowanceHistory', 'db_table': "'proctoring_proctoredexamstudentallowancehistory'"},
'allowance_id': ('django.db.models.fields.IntegerField', [], {}),
'created': ('model_utils.fields.AutoCreatedField', [], {'default': 'datetime.datetime.now'}),
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
'key': ('django.db.models.fields.CharField', [], {'max_length': '255'}),
'modified': ('model_utils.fields.AutoLastModifiedField', [], {'default': 'datetime.datetime.now'}),
'proctored_exam': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['edx_proctoring.ProctoredExam']"}),
'user': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['auth.User']"}),
'value': ('django.db.models.fields.CharField', [], {'max_length': '255'})
},
'edx_proctoring.proctoredexamstudentattempt': {
'Meta': {'unique_together': "(('user', 'proctored_exam'),)", 'object_name': 'ProctoredExamStudentAttempt', 'db_table': "'proctoring_proctoredexamstudentattempt'"},
'allowed_time_limit_mins': ('django.db.models.fields.IntegerField', [], {}),
'attempt_code': ('django.db.models.fields.CharField', [], {'max_length': '255', 'null': 'True', 'db_index': 'True'}),
'completed_at': ('django.db.models.fields.DateTimeField', [], {'null': 'True'}),
'created': ('model_utils.fields.AutoCreatedField', [], {'default': 'datetime.datetime.now'}),
'external_id': ('django.db.models.fields.CharField', [], {'max_length': '255', 'null': 'True', 'db_index': 'True'}),
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
'is_sample_attempt': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
'last_poll_ipaddr': ('django.db.models.fields.CharField', [], {'max_length': '32', 'null': 'True'}),
'last_poll_timestamp': ('django.db.models.fields.DateTimeField', [], {'null': 'True'}),
'modified': ('model_utils.fields.AutoLastModifiedField', [], {'default': 'datetime.datetime.now'}),
'proctored_exam': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['edx_proctoring.ProctoredExam']"}),
'review_policy_id': ('django.db.models.fields.IntegerField', [], {'null': 'True'}),
'started_at': ('django.db.models.fields.DateTimeField', [], {'null': 'True'}),
'status': ('django.db.models.fields.CharField', [], {'max_length': '64'}),
'student_name': ('django.db.models.fields.CharField', [], {'max_length': '255'}),
'taking_as_proctored': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
'user': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['auth.User']"})
},
'edx_proctoring.proctoredexamstudentattempthistory': {
'Meta': {'object_name': 'ProctoredExamStudentAttemptHistory', 'db_table': "'proctoring_proctoredexamstudentattempthistory'"},
'allowed_time_limit_mins': ('django.db.models.fields.IntegerField', [], {}),
'attempt_code': ('django.db.models.fields.CharField', [], {'max_length': '255', 'null': 'True', 'db_index': 'True'}),
'attempt_id': ('django.db.models.fields.IntegerField', [], {'null': 'True'}),
'completed_at': ('django.db.models.fields.DateTimeField', [], {'null': 'True'}),
'created': ('model_utils.fields.AutoCreatedField', [], {'default': 'datetime.datetime.now'}),
'external_id': ('django.db.models.fields.CharField', [], {'max_length': '255', 'null': 'True', 'db_index': 'True'}),
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
'is_sample_attempt': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
'modified': ('model_utils.fields.AutoLastModifiedField', [], {'default': 'datetime.datetime.now'}),
'proctored_exam': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['edx_proctoring.ProctoredExam']"}),
'review_policy_id': ('django.db.models.fields.IntegerField', [], {'null': 'True'}),
'started_at': ('django.db.models.fields.DateTimeField', [], {'null': 'True'}),
'status': ('django.db.models.fields.CharField', [], {'max_length': '64'}),
'student_name': ('django.db.models.fields.CharField', [], {'max_length': '255'}),
'taking_as_proctored': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
'user': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['auth.User']"})
}
}
complete_apps = ['edx_proctoring']
\ No newline at end of file
# -*- coding: utf-8 -*-
from south.utils import datetime_utils as datetime
from south.db import db
from south.v2 import SchemaMigration
from django.db import models
class Migration(SchemaMigration):
def forwards(self, orm):
# Adding field 'ProctoredExamSoftwareSecureReview.student'
db.add_column('proctoring_proctoredexamsoftwaresecurereview', 'student',
self.gf('django.db.models.fields.related.ForeignKey')(related_name='+', null=True, to=orm['auth.User']),
keep_default=False)
# Adding field 'ProctoredExamSoftwareSecureReview.exam'
db.add_column('proctoring_proctoredexamsoftwaresecurereview', 'exam',
self.gf('django.db.models.fields.related.ForeignKey')(to=orm['edx_proctoring.ProctoredExam'], null=True),
keep_default=False)
# Adding field 'ProctoredExamSoftwareSecureReviewHistory.student'
db.add_column('proctoring_proctoredexamsoftwaresecurereviewhistory', 'student',
self.gf('django.db.models.fields.related.ForeignKey')(related_name='+', null=True, to=orm['auth.User']),
keep_default=False)
# Adding field 'ProctoredExamSoftwareSecureReviewHistory.exam'
db.add_column('proctoring_proctoredexamsoftwaresecurereviewhistory', 'exam',
self.gf('django.db.models.fields.related.ForeignKey')(to=orm['edx_proctoring.ProctoredExam'], null=True),
keep_default=False)
def backwards(self, orm):
# Deleting field 'ProctoredExamSoftwareSecureReview.student'
db.delete_column('proctoring_proctoredexamsoftwaresecurereview', 'student_id')
# Deleting field 'ProctoredExamSoftwareSecureReview.exam'
db.delete_column('proctoring_proctoredexamsoftwaresecurereview', 'exam_id')
# Deleting field 'ProctoredExamSoftwareSecureReviewHistory.student'
db.delete_column('proctoring_proctoredexamsoftwaresecurereviewhistory', 'student_id')
# Deleting field 'ProctoredExamSoftwareSecureReviewHistory.exam'
db.delete_column('proctoring_proctoredexamsoftwaresecurereviewhistory', 'exam_id')
models = {
'auth.group': {
'Meta': {'object_name': 'Group'},
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
'name': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '80'}),
'permissions': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Permission']", 'symmetrical': 'False', 'blank': 'True'})
},
'auth.permission': {
'Meta': {'ordering': "('content_type__app_label', 'content_type__model', 'codename')", 'unique_together': "(('content_type', 'codename'),)", 'object_name': 'Permission'},
'codename': ('django.db.models.fields.CharField', [], {'max_length': '100'}),
'content_type': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['contenttypes.ContentType']"}),
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
'name': ('django.db.models.fields.CharField', [], {'max_length': '50'})
},
'auth.user': {
'Meta': {'object_name': 'User'},
'date_joined': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}),
'email': ('django.db.models.fields.EmailField', [], {'max_length': '75', 'blank': 'True'}),
'first_name': ('django.db.models.fields.CharField', [], {'max_length': '30', 'blank': 'True'}),
'groups': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Group']", 'symmetrical': 'False', 'blank': 'True'}),
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
'is_active': ('django.db.models.fields.BooleanField', [], {'default': 'True'}),
'is_staff': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
'is_superuser': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
'last_login': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}),
'last_name': ('django.db.models.fields.CharField', [], {'max_length': '30', 'blank': 'True'}),
'password': ('django.db.models.fields.CharField', [], {'max_length': '128'}),
'user_permissions': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Permission']", 'symmetrical': 'False', 'blank': 'True'}),
'username': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '30'})
},
'contenttypes.contenttype': {
'Meta': {'ordering': "('name',)", 'unique_together': "(('app_label', 'model'),)", 'object_name': 'ContentType', 'db_table': "'django_content_type'"},
'app_label': ('django.db.models.fields.CharField', [], {'max_length': '100'}),
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
'model': ('django.db.models.fields.CharField', [], {'max_length': '100'}),
'name': ('django.db.models.fields.CharField', [], {'max_length': '100'})
},
'edx_proctoring.proctoredexam': {
'Meta': {'unique_together': "(('course_id', 'content_id'),)", 'object_name': 'ProctoredExam', 'db_table': "'proctoring_proctoredexam'"},
'content_id': ('django.db.models.fields.CharField', [], {'max_length': '255', 'db_index': 'True'}),
'course_id': ('django.db.models.fields.CharField', [], {'max_length': '255', 'db_index': 'True'}),
'created': ('model_utils.fields.AutoCreatedField', [], {'default': 'datetime.datetime.now'}),
'exam_name': ('django.db.models.fields.TextField', [], {}),
'external_id': ('django.db.models.fields.CharField', [], {'max_length': '255', 'null': 'True', 'db_index': 'True'}),
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
'is_active': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
'is_practice_exam': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
'is_proctored': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
'modified': ('model_utils.fields.AutoLastModifiedField', [], {'default': 'datetime.datetime.now'}),
'time_limit_mins': ('django.db.models.fields.IntegerField', [], {})
},
'edx_proctoring.proctoredexamreviewpolicy': {
'Meta': {'object_name': 'ProctoredExamReviewPolicy', 'db_table': "'proctoring_proctoredexamreviewpolicy'"},
'created': ('model_utils.fields.AutoCreatedField', [], {'default': 'datetime.datetime.now'}),
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
'modified': ('model_utils.fields.AutoLastModifiedField', [], {'default': 'datetime.datetime.now'}),
'proctored_exam': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['edx_proctoring.ProctoredExam']"}),
'review_policy': ('django.db.models.fields.TextField', [], {}),
'set_by_user': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['auth.User']"})
},
'edx_proctoring.proctoredexamreviewpolicyhistory': {
'Meta': {'object_name': 'ProctoredExamReviewPolicyHistory', 'db_table': "'proctoring_proctoredexamreviewpolicyhistory'"},
'created': ('model_utils.fields.AutoCreatedField', [], {'default': 'datetime.datetime.now'}),
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
'modified': ('model_utils.fields.AutoLastModifiedField', [], {'default': 'datetime.datetime.now'}),
'original_id': ('django.db.models.fields.IntegerField', [], {'db_index': 'True'}),
'proctored_exam': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['edx_proctoring.ProctoredExam']"}),
'review_policy': ('django.db.models.fields.TextField', [], {}),
'set_by_user': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['auth.User']"})
},
'edx_proctoring.proctoredexamsoftwaresecurecomment': {
'Meta': {'object_name': 'ProctoredExamSoftwareSecureComment', 'db_table': "'proctoring_proctoredexamstudentattemptcomment'"},
'comment': ('django.db.models.fields.TextField', [], {}),
'created': ('model_utils.fields.AutoCreatedField', [], {'default': 'datetime.datetime.now'}),
'duration': ('django.db.models.fields.IntegerField', [], {}),
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
'modified': ('model_utils.fields.AutoLastModifiedField', [], {'default': 'datetime.datetime.now'}),
'review': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['edx_proctoring.ProctoredExamSoftwareSecureReview']"}),
'start_time': ('django.db.models.fields.IntegerField', [], {}),
'status': ('django.db.models.fields.CharField', [], {'max_length': '255'}),
'stop_time': ('django.db.models.fields.IntegerField', [], {})
},
'edx_proctoring.proctoredexamsoftwaresecurereview': {
'Meta': {'object_name': 'ProctoredExamSoftwareSecureReview', 'db_table': "'proctoring_proctoredexamsoftwaresecurereview'"},
'attempt_code': ('django.db.models.fields.CharField', [], {'max_length': '255', 'db_index': 'True'}),
'created': ('model_utils.fields.AutoCreatedField', [], {'default': 'datetime.datetime.now'}),
'exam': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['edx_proctoring.ProctoredExam']", 'null': 'True'}),
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
'modified': ('model_utils.fields.AutoLastModifiedField', [], {'default': 'datetime.datetime.now'}),
'raw_data': ('django.db.models.fields.TextField', [], {}),
'review_status': ('django.db.models.fields.CharField', [], {'max_length': '255'}),
'reviewed_by': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'+'", 'null': 'True', 'to': "orm['auth.User']"}),
'student': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'+'", 'null': 'True', 'to': "orm['auth.User']"}),
'video_url': ('django.db.models.fields.TextField', [], {})
},
'edx_proctoring.proctoredexamsoftwaresecurereviewhistory': {
'Meta': {'object_name': 'ProctoredExamSoftwareSecureReviewHistory', 'db_table': "'proctoring_proctoredexamsoftwaresecurereviewhistory'"},
'attempt_code': ('django.db.models.fields.CharField', [], {'max_length': '255', 'db_index': 'True'}),
'created': ('model_utils.fields.AutoCreatedField', [], {'default': 'datetime.datetime.now'}),
'exam': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['edx_proctoring.ProctoredExam']", 'null': 'True'}),
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
'modified': ('model_utils.fields.AutoLastModifiedField', [], {'default': 'datetime.datetime.now'}),
'raw_data': ('django.db.models.fields.TextField', [], {}),
'review_status': ('django.db.models.fields.CharField', [], {'max_length': '255'}),
'reviewed_by': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'+'", 'null': 'True', 'to': "orm['auth.User']"}),
'student': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'+'", 'null': 'True', 'to': "orm['auth.User']"}),
'video_url': ('django.db.models.fields.TextField', [], {})
},
'edx_proctoring.proctoredexamstudentallowance': {
'Meta': {'unique_together': "(('user', 'proctored_exam', 'key'),)", 'object_name': 'ProctoredExamStudentAllowance', 'db_table': "'proctoring_proctoredexamstudentallowance'"},
'created': ('model_utils.fields.AutoCreatedField', [], {'default': 'datetime.datetime.now'}),
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
'key': ('django.db.models.fields.CharField', [], {'max_length': '255'}),
'modified': ('model_utils.fields.AutoLastModifiedField', [], {'default': 'datetime.datetime.now'}),
'proctored_exam': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['edx_proctoring.ProctoredExam']"}),
'user': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['auth.User']"}),
'value': ('django.db.models.fields.CharField', [], {'max_length': '255'})
},
'edx_proctoring.proctoredexamstudentallowancehistory': {
'Meta': {'object_name': 'ProctoredExamStudentAllowanceHistory', 'db_table': "'proctoring_proctoredexamstudentallowancehistory'"},
'allowance_id': ('django.db.models.fields.IntegerField', [], {}),
'created': ('model_utils.fields.AutoCreatedField', [], {'default': 'datetime.datetime.now'}),
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
'key': ('django.db.models.fields.CharField', [], {'max_length': '255'}),
'modified': ('model_utils.fields.AutoLastModifiedField', [], {'default': 'datetime.datetime.now'}),
'proctored_exam': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['edx_proctoring.ProctoredExam']"}),
'user': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['auth.User']"}),
'value': ('django.db.models.fields.CharField', [], {'max_length': '255'})
},
'edx_proctoring.proctoredexamstudentattempt': {
'Meta': {'unique_together': "(('user', 'proctored_exam'),)", 'object_name': 'ProctoredExamStudentAttempt', 'db_table': "'proctoring_proctoredexamstudentattempt'"},
'allowed_time_limit_mins': ('django.db.models.fields.IntegerField', [], {}),
'attempt_code': ('django.db.models.fields.CharField', [], {'max_length': '255', 'null': 'True', 'db_index': 'True'}),
'completed_at': ('django.db.models.fields.DateTimeField', [], {'null': 'True'}),
'created': ('model_utils.fields.AutoCreatedField', [], {'default': 'datetime.datetime.now'}),
'external_id': ('django.db.models.fields.CharField', [], {'max_length': '255', 'null': 'True', 'db_index': 'True'}),
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
'is_sample_attempt': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
'last_poll_ipaddr': ('django.db.models.fields.CharField', [], {'max_length': '32', 'null': 'True'}),
'last_poll_timestamp': ('django.db.models.fields.DateTimeField', [], {'null': 'True'}),
'modified': ('model_utils.fields.AutoLastModifiedField', [], {'default': 'datetime.datetime.now'}),
'proctored_exam': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['edx_proctoring.ProctoredExam']"}),
'review_policy_id': ('django.db.models.fields.IntegerField', [], {'null': 'True'}),
'started_at': ('django.db.models.fields.DateTimeField', [], {'null': 'True'}),
'status': ('django.db.models.fields.CharField', [], {'max_length': '64'}),
'student_name': ('django.db.models.fields.CharField', [], {'max_length': '255'}),
'taking_as_proctored': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
'user': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['auth.User']"})
},
'edx_proctoring.proctoredexamstudentattempthistory': {
'Meta': {'object_name': 'ProctoredExamStudentAttemptHistory', 'db_table': "'proctoring_proctoredexamstudentattempthistory'"},
'allowed_time_limit_mins': ('django.db.models.fields.IntegerField', [], {}),
'attempt_code': ('django.db.models.fields.CharField', [], {'max_length': '255', 'null': 'True', 'db_index': 'True'}),
'attempt_id': ('django.db.models.fields.IntegerField', [], {'null': 'True'}),
'completed_at': ('django.db.models.fields.DateTimeField', [], {'null': 'True'}),
'created': ('model_utils.fields.AutoCreatedField', [], {'default': 'datetime.datetime.now'}),
'external_id': ('django.db.models.fields.CharField', [], {'max_length': '255', 'null': 'True', 'db_index': 'True'}),
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
'is_sample_attempt': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
'modified': ('model_utils.fields.AutoLastModifiedField', [], {'default': 'datetime.datetime.now'}),
'proctored_exam': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['edx_proctoring.ProctoredExam']"}),
'review_policy_id': ('django.db.models.fields.IntegerField', [], {'null': 'True'}),
'started_at': ('django.db.models.fields.DateTimeField', [], {'null': 'True'}),
'status': ('django.db.models.fields.CharField', [], {'max_length': '64'}),
'student_name': ('django.db.models.fields.CharField', [], {'max_length': '255'}),
'taking_as_proctored': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
'user': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['auth.User']"})
}
}
complete_apps = ['edx_proctoring']
\ No newline at end of file
...@@ -236,7 +236,8 @@ class ProctoredExamReviewPolicy(TimeStampedModel): ...@@ -236,7 +236,8 @@ class ProctoredExamReviewPolicy(TimeStampedModel):
class Meta: class Meta:
""" Meta class for this Django model """ """ Meta class for this Django model """
db_table = 'proctoring_proctoredexamreviewpolicy' db_table = 'proctoring_proctoredexamreviewpolicy'
verbose_name = 'proctored exam review policy' verbose_name = 'Proctored exam review policy'
verbose_name_plural = "Proctored exam review policies"
@classmethod @classmethod
def get_review_policy_for_exam(cls, exam_id): def get_review_policy_for_exam(cls, exam_id):
...@@ -721,10 +722,23 @@ class ProctoredExamSoftwareSecureReview(TimeStampedModel): ...@@ -721,10 +722,23 @@ class ProctoredExamSoftwareSecureReview(TimeStampedModel):
# URL for the exam video that had been reviewed # URL for the exam video that had been reviewed
video_url = models.TextField() video_url = models.TextField()
# user_id of person who did the review (can be None if submitted via server-to-server API)
reviewed_by = models.ForeignKey(User, null=True, related_name='+')
# student username for the exam
# this is an optimization for the Django Admin pane (so we can search)
# this is null because it is being added after initial production ship
student = models.ForeignKey(User, null=True, related_name='+')
# exam_id for the review
# this is an optimization for the Django Admin pane (so we can search)
# this is null because it is being added after initial production ship
exam = models.ForeignKey(ProctoredExam, null=True)
class Meta: class Meta:
""" Meta class for this Django model """ """ Meta class for this Django model """
db_table = 'proctoring_proctoredexamsoftwaresecurereview' db_table = 'proctoring_proctoredexamsoftwaresecurereview'
verbose_name = 'proctored exam software secure review' verbose_name = 'Proctored exam software secure review'
@classmethod @classmethod
def get_review_by_attempt_code(cls, attempt_code): def get_review_by_attempt_code(cls, attempt_code):
...@@ -756,10 +770,23 @@ class ProctoredExamSoftwareSecureReviewHistory(TimeStampedModel): ...@@ -756,10 +770,23 @@ class ProctoredExamSoftwareSecureReviewHistory(TimeStampedModel):
# URL for the exam video that had been reviewed # URL for the exam video that had been reviewed
video_url = models.TextField() video_url = models.TextField()
# user_id of person who did the review (can be None if submitted via server-to-server API)
reviewed_by = models.ForeignKey(User, null=True, related_name='+')
# student username for the exam
# this is an optimization for the Django Admin pane (so we can search)
# this is null because it is being added after initial production ship
student = models.ForeignKey(User, null=True, related_name='+')
# exam_id for the review
# this is an optimization for the Django Admin pane (so we can search)
# this is null because it is being added after initial production ship
exam = models.ForeignKey(ProctoredExam, null=True)
class Meta: class Meta:
""" Meta class for this Django model """ """ Meta class for this Django model """
db_table = 'proctoring_proctoredexamsoftwaresecurereviewhistory' db_table = 'proctoring_proctoredexamsoftwaresecurereviewhistory'
verbose_name = 'proctored exam review history' verbose_name = 'Proctored exam review archive'
# 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.
...@@ -795,6 +822,9 @@ def _make_review_archive_copy(instance): ...@@ -795,6 +822,9 @@ def _make_review_archive_copy(instance):
review_status=instance.review_status, review_status=instance.review_status,
raw_data=instance.raw_data, raw_data=instance.raw_data,
video_url=instance.video_url, video_url=instance.video_url,
reviewed_by=instance.reviewed_by,
student=instance.student,
exam=instance.exam,
) )
archive_object.save() archive_object.save()
......
...@@ -1542,7 +1542,7 @@ class ProctoredExamApiTests(LoggedInTestCase): ...@@ -1542,7 +1542,7 @@ class ProctoredExamApiTests(LoggedInTestCase):
ProctoredExamStudentAttemptStatus.eligible, { ProctoredExamStudentAttemptStatus.eligible, {
'status': ProctoredExamStudentAttemptStatus.eligible, 'status': ProctoredExamStudentAttemptStatus.eligible,
'short_description': 'Proctored Option Available', 'short_description': 'Proctored Option Available',
'suggested_icon': 'fa-lock', 'suggested_icon': 'fa-pencil-square-o',
'in_completed_state': False 'in_completed_state': False
} }
), ),
...@@ -1550,7 +1550,7 @@ class ProctoredExamApiTests(LoggedInTestCase): ...@@ -1550,7 +1550,7 @@ class ProctoredExamApiTests(LoggedInTestCase):
ProctoredExamStudentAttemptStatus.declined, { ProctoredExamStudentAttemptStatus.declined, {
'status': ProctoredExamStudentAttemptStatus.declined, 'status': ProctoredExamStudentAttemptStatus.declined,
'short_description': 'Taking As Open Exam', 'short_description': 'Taking As Open Exam',
'suggested_icon': 'fa-unlock', 'suggested_icon': 'fa-pencil-square-o',
'in_completed_state': False 'in_completed_state': False
} }
), ),
...@@ -1590,7 +1590,7 @@ class ProctoredExamApiTests(LoggedInTestCase): ...@@ -1590,7 +1590,7 @@ class ProctoredExamApiTests(LoggedInTestCase):
ProctoredExamStudentAttemptStatus.created, { ProctoredExamStudentAttemptStatus.created, {
'status': ProctoredExamStudentAttemptStatus.created, 'status': ProctoredExamStudentAttemptStatus.created,
'short_description': 'Taking As Proctored Exam', 'short_description': 'Taking As Proctored Exam',
'suggested_icon': 'fa-lock', 'suggested_icon': 'fa-pencil-square-o',
'in_completed_state': False 'in_completed_state': False
} }
), ),
...@@ -1598,7 +1598,7 @@ class ProctoredExamApiTests(LoggedInTestCase): ...@@ -1598,7 +1598,7 @@ class ProctoredExamApiTests(LoggedInTestCase):
ProctoredExamStudentAttemptStatus.ready_to_start, { ProctoredExamStudentAttemptStatus.ready_to_start, {
'status': ProctoredExamStudentAttemptStatus.ready_to_start, 'status': ProctoredExamStudentAttemptStatus.ready_to_start,
'short_description': 'Taking As Proctored Exam', 'short_description': 'Taking As Proctored Exam',
'suggested_icon': 'fa-lock', 'suggested_icon': 'fa-pencil-square-o',
'in_completed_state': False 'in_completed_state': False
} }
), ),
...@@ -1606,7 +1606,7 @@ class ProctoredExamApiTests(LoggedInTestCase): ...@@ -1606,7 +1606,7 @@ class ProctoredExamApiTests(LoggedInTestCase):
ProctoredExamStudentAttemptStatus.started, { ProctoredExamStudentAttemptStatus.started, {
'status': ProctoredExamStudentAttemptStatus.started, 'status': ProctoredExamStudentAttemptStatus.started,
'short_description': 'Taking As Proctored Exam', 'short_description': 'Taking As Proctored Exam',
'suggested_icon': 'fa-lock', 'suggested_icon': 'fa-pencil-square-o',
'in_completed_state': False 'in_completed_state': False
} }
), ),
...@@ -1614,7 +1614,7 @@ class ProctoredExamApiTests(LoggedInTestCase): ...@@ -1614,7 +1614,7 @@ class ProctoredExamApiTests(LoggedInTestCase):
ProctoredExamStudentAttemptStatus.ready_to_submit, { ProctoredExamStudentAttemptStatus.ready_to_submit, {
'status': ProctoredExamStudentAttemptStatus.ready_to_submit, 'status': ProctoredExamStudentAttemptStatus.ready_to_submit,
'short_description': 'Taking As Proctored Exam', 'short_description': 'Taking As Proctored Exam',
'suggested_icon': 'fa-lock', 'suggested_icon': 'fa-pencil-square-o',
'in_completed_state': False 'in_completed_state': False
} }
) )
...@@ -1645,7 +1645,7 @@ class ProctoredExamApiTests(LoggedInTestCase): ...@@ -1645,7 +1645,7 @@ class ProctoredExamApiTests(LoggedInTestCase):
ProctoredExamStudentAttemptStatus.eligible, { ProctoredExamStudentAttemptStatus.eligible, {
'status': ProctoredExamStudentAttemptStatus.eligible, 'status': ProctoredExamStudentAttemptStatus.eligible,
'short_description': 'Ungraded Practice Exam', 'short_description': 'Ungraded Practice Exam',
'suggested_icon': 'fa-lock', 'suggested_icon': 'fa-pencil-square-o',
'in_completed_state': False 'in_completed_state': False
} }
), ),
...@@ -1716,7 +1716,7 @@ class ProctoredExamApiTests(LoggedInTestCase): ...@@ -1716,7 +1716,7 @@ class ProctoredExamApiTests(LoggedInTestCase):
ProctoredExamStudentAttemptStatus.eligible, { ProctoredExamStudentAttemptStatus.eligible, {
'status': ProctoredExamStudentAttemptStatus.eligible, 'status': ProctoredExamStudentAttemptStatus.eligible,
'short_description': 'Ungraded Practice Exam', 'short_description': 'Ungraded Practice Exam',
'suggested_icon': 'fa-lock', 'suggested_icon': 'fa-pencil-square-o',
'in_completed_state': False 'in_completed_state': False
} }
), ),
...@@ -1769,7 +1769,7 @@ class ProctoredExamApiTests(LoggedInTestCase): ...@@ -1769,7 +1769,7 @@ class ProctoredExamApiTests(LoggedInTestCase):
expected = { expected = {
'status': ProctoredExamStudentAttemptStatus.eligible, 'status': ProctoredExamStudentAttemptStatus.eligible,
'short_description': 'Ungraded Practice Exam', 'short_description': 'Ungraded Practice Exam',
'suggested_icon': 'fa-lock', 'suggested_icon': 'fa-pencil-square-o',
'in_completed_state': False 'in_completed_state': False
} }
......
...@@ -3,6 +3,7 @@ Helpers for the HTTP APIs ...@@ -3,6 +3,7 @@ Helpers for the HTTP APIs
""" """
import pytz import pytz
import logging
from datetime import datetime, timedelta from datetime import datetime, timedelta
from django.utils.translation import ugettext as _ from django.utils.translation import ugettext as _
...@@ -10,6 +11,13 @@ from rest_framework.views import APIView ...@@ -10,6 +11,13 @@ from rest_framework.views import APIView
from rest_framework.authentication import SessionAuthentication from rest_framework.authentication import SessionAuthentication
from rest_framework.permissions import IsAuthenticated from rest_framework.permissions import IsAuthenticated
from edx_proctoring.models import (
ProctoredExamStudentAttempt,
ProctoredExamStudentAttemptHistory,
)
log = logging.getLogger(__name__)
class AuthenticatedAPIView(APIView): class AuthenticatedAPIView(APIView):
""" """
...@@ -82,3 +90,27 @@ def humanized_time(time_in_minutes): ...@@ -82,3 +90,27 @@ def humanized_time(time_in_minutes):
human_time = template.format(num_of_hours=hours, num_of_minutes=minutes) human_time = template.format(num_of_hours=hours, num_of_minutes=minutes)
return human_time return human_time
def locate_attempt_by_attempt_code(attempt_code):
"""
Helper method to look up an attempt by attempt_code. This can be either in
the ProctoredExamStudentAttempt *OR* ProctoredExamStudentAttemptHistory tables
we will return a tuple of (attempt, is_archived_attempt)
"""
attempt_obj = ProctoredExamStudentAttempt.objects.get_exam_attempt_by_code(attempt_code)
is_archived_attempt = False
if not attempt_obj:
# try archive table
attempt_obj = ProctoredExamStudentAttemptHistory.get_exam_attempt_by_code(attempt_code)
is_archived_attempt = True
if not attempt_obj:
# still can't find, error out
err_msg = (
'Could not locate attempt_code: {attempt_code}'.format(attempt_code=attempt_code)
)
log.error(err_msg)
return (attempt_obj, is_archived_attempt)
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