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
"""
# pylint: disable=no-self-argument, no-member
from django.contrib import admin
from django import forms
from edx_proctoring.models import (
ProctoredExamReviewPolicy,
ProctoredExam,
ProctoredExamSoftwareSecureReview,
ProctoredExamSoftwareSecureReviewHistory,
)
class ProctoredExamReviewPolicyInline(admin.TabularInline):
"""
Custom inline definition to show related fields
"""
model = ProctoredExam
fields = ('course_id', 'exam_name',)
readonly_fields = ('course_id', 'exam_name',)
from edx_proctoring.utils import locate_attempt_by_attempt_code
from edx_proctoring.backends import get_backend_provider
class ProctoredExamReviewPolicyAdmin(admin.ModelAdmin):
......@@ -24,17 +20,17 @@ class ProctoredExamReviewPolicyAdmin(admin.ModelAdmin):
"""
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 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 obj.proctored_exam.exam_name # pylint: disable=no-member
return obj.proctored_exam.exam_name
list_display = [
course_id,
......@@ -50,4 +46,128 @@ class ProctoredExamReviewPolicyAdmin(admin.ModelAdmin):
obj.set_by_user = request.user
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(ProctoredExamSoftwareSecureReview, ProctoredExamSoftwareSecureReviewAdmin)
admin.site.register(ProctoredExamSoftwareSecureReviewHistory, ProctoredExamSoftwareSecureReviewHistoryAdmin)
......@@ -932,17 +932,17 @@ def _check_eligibility_of_prerequisites(credit_state):
STATUS_SUMMARY_MAP = {
'_default': {
'short_description': _('Taking As Proctored Exam'),
'suggested_icon': 'fa-lock',
'suggested_icon': 'fa-pencil-square-o',
'in_completed_state': False
},
ProctoredExamStudentAttemptStatus.eligible: {
'short_description': _('Proctored Option Available'),
'suggested_icon': 'fa-lock',
'suggested_icon': 'fa-pencil-square-o',
'in_completed_state': False
},
ProctoredExamStudentAttemptStatus.declined: {
'short_description': _('Taking As Open Exam'),
'suggested_icon': 'fa-unlock',
'suggested_icon': 'fa-pencil-square-o',
'in_completed_state': False
},
ProctoredExamStudentAttemptStatus.submitted: {
......@@ -971,7 +971,7 @@ STATUS_SUMMARY_MAP = {
PRACTICE_STATUS_SUMMARY_MAP = {
'_default': {
'short_description': _('Ungraded Practice Exam'),
'suggested_icon': 'fa-lock',
'suggested_icon': 'fa-pencil-square-o',
'in_completed_state': False
},
ProctoredExamStudentAttemptStatus.submitted: {
......
......@@ -50,3 +50,11 @@ class ProctoringBackendProvider(object):
Called when the reviewing 3rd party service posts back the results
"""
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):
"""
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 (
ProctoredExamReviewAlreadyExists,
ProctoredExamBadReviewStatus,
)
from edx_proctoring.utils import locate_attempt_by_attempt_code
from edx_proctoring. models import (
ProctoredExamSoftwareSecureReview,
ProctoredExamSoftwareSecureComment,
ProctoredExamStudentAttempt,
ProctoredExamStudentAttemptHistory,
ProctoredExamStudentAttemptStatus,
)
......@@ -154,20 +152,13 @@ class SoftwareSecureBackendProvider(ProctoringBackendProvider):
# 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)
is_archived_attempt = False
(attempt_obj, is_archived_attempt) = locate_attempt_by_attempt_code(attempt_code)
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)
)
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
# note that SoftwareSecure might send a case insensitive
......@@ -224,6 +215,8 @@ class SoftwareSecureBackendProvider(ProctoringBackendProvider):
review.raw_data = json.dumps(payload)
review.review_status = review_status
review.video_url = video_review_link
review.student = attempt_obj.user
review.exam = attempt_obj.proctored_exam
review.save()
......@@ -234,21 +227,54 @@ class SoftwareSecureBackendProvider(ProctoringBackendProvider):
for comment in payload.get('desktopComments', []):
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
# eligibility table
if not is_archived_attempt:
# 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
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
status = (
ProctoredExamStudentAttemptStatus.verified
if review_status in self.passing_review_status
else ProctoredExamStudentAttemptStatus.rejected
if is_archived_attempt:
# we don't trigger workflow on reviews on archived attempts
err_msg = (
'Got on_review_save() callback for an archived attempt with '
'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(
attempt_obj.proctored_exam_id,
......@@ -285,6 +311,19 @@ class SoftwareSecureBackendProvider(ProctoringBackendProvider):
encrypted_text = cipher.encrypt(pad(pwd))
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):
"""
Constructs the data payload that Software Secure expects
......@@ -296,14 +335,7 @@ class SoftwareSecureBackendProvider(ProctoringBackendProvider):
callback_url = context['callback_url']
full_name = context['full_name']
review_policy = context.get('review_policy', constants.DEFAULT_SOFTWARE_SECURE_REVIEW_POLICY)
first_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:])
(first_name, last_name) = self._split_fullname(full_name)
now = datetime.datetime.utcnow()
start_time_str = now.strftime("%a, %d %b %Y %H:%M:%S GMT")
......
......@@ -44,6 +44,12 @@ class TestBackendProvider(ProctoringBackendProvider):
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):
"""
......@@ -92,6 +98,13 @@ class PassthroughBackendProvider(ProctoringBackendProvider):
"""
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):
"""
......@@ -120,6 +133,9 @@ class TestBackends(TestCase):
with self.assertRaises(NotImplementedError):
provider.on_review_callback(None)
with self.assertRaises(NotImplementedError):
provider.on_review_saved(None)
def test_null_provider(self):
"""
Assert that the Null provider does nothing
......@@ -132,3 +148,4 @@ class TestBackends(TestCase):
self.assertIsNone(provider.stop_exam_attempt(None, None))
self.assertIsNone(provider.get_software_download_url())
self.assertIsNone(provider.on_review_callback(None))
self.assertIsNone(provider.on_review_saved(None))
# 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
"""
......@@ -30,12 +30,13 @@ from edx_proctoring.exceptions import (
ProctoredExamReviewAlreadyExists,
ProctoredExamBadReviewStatus
)
from edx_proctoring. models import (
from edx_proctoring.models import (
ProctoredExamSoftwareSecureReview,
ProctoredExamSoftwareSecureComment,
ProctoredExamStudentAttemptStatus,
ProctoredExamSoftwareSecureReviewHistory,
ProctoredExamReviewPolicy,
ProctoredExamStudentAttemptHistory,
)
from edx_proctoring.backends.tests.test_review_payload import TEST_REVIEW_PAYLOAD
......@@ -348,6 +349,7 @@ class SoftwareSecureTests(TestCase):
('Not Reviewed', 'failed'),
)
@ddt.unpack
@patch('edx_proctoring.constants.REQUIRE_FAILURE_SECOND_REVIEWS', False)
def test_review_callback(self, review_status, credit_requirement_status):
"""
Simulates callbacks from SoftwareSecure with various statuses
......@@ -392,6 +394,7 @@ class SoftwareSecureTests(TestCase):
'http://www.remoteproctor.com/AdminSite/Account/Reviewer/DirectLink-Generic.aspx?ID=foo'
)
self.assertIsNotNone(review.raw_data)
self.assertIsNone(review.reviewed_by)
# now check the comments that were stored
comments = ProctoredExamSoftwareSecureComment.objects.filter(review_id=review.id)
......@@ -476,6 +479,7 @@ class SoftwareSecureTests(TestCase):
provider.on_review_callback(json.loads(test_payload))
@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):
"""
Verify that the configuration switch to
......@@ -515,6 +519,7 @@ class SoftwareSecureTests(TestCase):
attempt = get_exam_attempt_by_id(attempt_id)
self.assertEqual(attempt['status'], ProctoredExamStudentAttemptStatus.verified)
@patch('edx_proctoring.constants.REQUIRE_FAILURE_SECOND_REVIEWS', False)
def test_review_on_archived_attempt(self):
"""
Make sure we can process a review report for
......@@ -570,6 +575,7 @@ class SoftwareSecureTests(TestCase):
self.assertEqual(len(comments), 6)
@patch('edx_proctoring.constants.ALLOW_REVIEW_UPDATES', False)
@patch('edx_proctoring.constants.REQUIRE_FAILURE_SECOND_REVIEWS', False)
def test_disallow_review_resubmission(self):
"""
Tests that an exception is raised if a review report is resubmitted for the same
......@@ -674,3 +680,145 @@ class SoftwareSecureTests(TestCase):
self.assertEqual(len(records), 2)
self.assertEqual(records[0].review_status, 'Clean')
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 = (
'DEFAULT_REVIEW_POLICY' in settings.PROCTORING_SETTINGS
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)
)
......@@ -236,7 +236,8 @@ class ProctoredExamReviewPolicy(TimeStampedModel):
class Meta:
""" Meta class for this Django model """
db_table = 'proctoring_proctoredexamreviewpolicy'
verbose_name = 'proctored exam review policy'
verbose_name = 'Proctored exam review policy'
verbose_name_plural = "Proctored exam review policies"
@classmethod
def get_review_policy_for_exam(cls, exam_id):
......@@ -721,10 +722,23 @@ class ProctoredExamSoftwareSecureReview(TimeStampedModel):
# URL for the exam video that had been reviewed
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:
""" Meta class for this Django model """
db_table = 'proctoring_proctoredexamsoftwaresecurereview'
verbose_name = 'proctored exam software secure review'
verbose_name = 'Proctored exam software secure review'
@classmethod
def get_review_by_attempt_code(cls, attempt_code):
......@@ -756,10 +770,23 @@ class ProctoredExamSoftwareSecureReviewHistory(TimeStampedModel):
# URL for the exam video that had been reviewed
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:
""" Meta class for this Django model """
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.
......@@ -795,6 +822,9 @@ def _make_review_archive_copy(instance):
review_status=instance.review_status,
raw_data=instance.raw_data,
video_url=instance.video_url,
reviewed_by=instance.reviewed_by,
student=instance.student,
exam=instance.exam,
)
archive_object.save()
......
......@@ -1542,7 +1542,7 @@ class ProctoredExamApiTests(LoggedInTestCase):
ProctoredExamStudentAttemptStatus.eligible, {
'status': ProctoredExamStudentAttemptStatus.eligible,
'short_description': 'Proctored Option Available',
'suggested_icon': 'fa-lock',
'suggested_icon': 'fa-pencil-square-o',
'in_completed_state': False
}
),
......@@ -1550,7 +1550,7 @@ class ProctoredExamApiTests(LoggedInTestCase):
ProctoredExamStudentAttemptStatus.declined, {
'status': ProctoredExamStudentAttemptStatus.declined,
'short_description': 'Taking As Open Exam',
'suggested_icon': 'fa-unlock',
'suggested_icon': 'fa-pencil-square-o',
'in_completed_state': False
}
),
......@@ -1590,7 +1590,7 @@ class ProctoredExamApiTests(LoggedInTestCase):
ProctoredExamStudentAttemptStatus.created, {
'status': ProctoredExamStudentAttemptStatus.created,
'short_description': 'Taking As Proctored Exam',
'suggested_icon': 'fa-lock',
'suggested_icon': 'fa-pencil-square-o',
'in_completed_state': False
}
),
......@@ -1598,7 +1598,7 @@ class ProctoredExamApiTests(LoggedInTestCase):
ProctoredExamStudentAttemptStatus.ready_to_start, {
'status': ProctoredExamStudentAttemptStatus.ready_to_start,
'short_description': 'Taking As Proctored Exam',
'suggested_icon': 'fa-lock',
'suggested_icon': 'fa-pencil-square-o',
'in_completed_state': False
}
),
......@@ -1606,7 +1606,7 @@ class ProctoredExamApiTests(LoggedInTestCase):
ProctoredExamStudentAttemptStatus.started, {
'status': ProctoredExamStudentAttemptStatus.started,
'short_description': 'Taking As Proctored Exam',
'suggested_icon': 'fa-lock',
'suggested_icon': 'fa-pencil-square-o',
'in_completed_state': False
}
),
......@@ -1614,7 +1614,7 @@ class ProctoredExamApiTests(LoggedInTestCase):
ProctoredExamStudentAttemptStatus.ready_to_submit, {
'status': ProctoredExamStudentAttemptStatus.ready_to_submit,
'short_description': 'Taking As Proctored Exam',
'suggested_icon': 'fa-lock',
'suggested_icon': 'fa-pencil-square-o',
'in_completed_state': False
}
)
......@@ -1645,7 +1645,7 @@ class ProctoredExamApiTests(LoggedInTestCase):
ProctoredExamStudentAttemptStatus.eligible, {
'status': ProctoredExamStudentAttemptStatus.eligible,
'short_description': 'Ungraded Practice Exam',
'suggested_icon': 'fa-lock',
'suggested_icon': 'fa-pencil-square-o',
'in_completed_state': False
}
),
......@@ -1716,7 +1716,7 @@ class ProctoredExamApiTests(LoggedInTestCase):
ProctoredExamStudentAttemptStatus.eligible, {
'status': ProctoredExamStudentAttemptStatus.eligible,
'short_description': 'Ungraded Practice Exam',
'suggested_icon': 'fa-lock',
'suggested_icon': 'fa-pencil-square-o',
'in_completed_state': False
}
),
......@@ -1769,7 +1769,7 @@ class ProctoredExamApiTests(LoggedInTestCase):
expected = {
'status': ProctoredExamStudentAttemptStatus.eligible,
'short_description': 'Ungraded Practice Exam',
'suggested_icon': 'fa-lock',
'suggested_icon': 'fa-pencil-square-o',
'in_completed_state': False
}
......
......@@ -3,6 +3,7 @@ Helpers for the HTTP APIs
"""
import pytz
import logging
from datetime import datetime, timedelta
from django.utils.translation import ugettext as _
......@@ -10,6 +11,13 @@ from rest_framework.views import APIView
from rest_framework.authentication import SessionAuthentication
from rest_framework.permissions import IsAuthenticated
from edx_proctoring.models import (
ProctoredExamStudentAttempt,
ProctoredExamStudentAttemptHistory,
)
log = logging.getLogger(__name__)
class AuthenticatedAPIView(APIView):
"""
......@@ -82,3 +90,27 @@ def humanized_time(time_in_minutes):
human_time = template.format(num_of_hours=hours, num_of_minutes=minutes)
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