Commit 19a9e1ce by chrisndodge

Merge pull request #167 from edx/hotfix/2015-09-21

Hotfix/2015 09 21
parents da5746f5 f100c234
...@@ -552,6 +552,21 @@ def update_attempt_status(exam_id, user_id, to_status, raise_if_not_found=True, ...@@ -552,6 +552,21 @@ def update_attempt_status(exam_id, user_id, to_status, raise_if_not_found=True,
) )
raise ProctoredExamIllegalStatusTransition(err_msg) raise ProctoredExamIllegalStatusTransition(err_msg)
# special case logic, if we are in a completed status we shouldn't allow
# for a transition to 'Error' state
if in_completed_status and to_status == ProctoredExamStudentAttemptStatus.error:
err_msg = (
'A status transition from {from_status} to {to_status} was attempted '
'on exam_id {exam_id} for user_id {user_id}. This is not '
'allowed!'.format(
from_status=exam_attempt_obj.status,
to_status=to_status,
exam_id=exam_id,
user_id=user_id
)
)
raise ProctoredExamIllegalStatusTransition(err_msg)
# OK, state transition is fine, we can proceed # OK, state transition is fine, we can proceed
exam_attempt_obj.status = to_status exam_attempt_obj.status = to_status
exam_attempt_obj.save() exam_attempt_obj.save()
...@@ -591,6 +606,13 @@ def update_attempt_status(exam_id, user_id, to_status, raise_if_not_found=True, ...@@ -591,6 +606,13 @@ def update_attempt_status(exam_id, user_id, to_status, raise_if_not_found=True,
) )
if cascade_effects and ProctoredExamStudentAttemptStatus.is_a_cascadable_failure(to_status): if cascade_effects and ProctoredExamStudentAttemptStatus.is_a_cascadable_failure(to_status):
if to_status == ProctoredExamStudentAttemptStatus.declined:
# if user declines attempt, make sure we clear out the external_id and
# taking_as_proctored fields
exam_attempt_obj.taking_as_proctored = False
exam_attempt_obj.external_id = None
exam_attempt_obj.save()
# some state transitions (namely to a rejected or declined status) # some state transitions (namely to a rejected or declined status)
# will mark other exams as declined because once we fail or decline # will mark other exams as declined because once we fail or decline
# one exam all other (un-completed) proctored exams will be likewise # one exam all other (un-completed) proctored exams will be likewise
......
...@@ -298,7 +298,12 @@ class SoftwareSecureBackendProvider(ProctoringBackendProvider): ...@@ -298,7 +298,12 @@ class SoftwareSecureBackendProvider(ProctoringBackendProvider):
"organization": self.organization, "organization": self.organization,
"duration": time_limit_mins, "duration": time_limit_mins,
"reviewedExam": not is_sample_attempt, "reviewedExam": not is_sample_attempt,
"reviewerNotes": 'Closed Book', # NOTE: we will have to allow these notes to be authorable in Studio
# and then we will pull this from the exam database model
"reviewerNotes": (
'Closed Book; Allow users to take notes on paper during the exam; '
'Allow users to use a hand-held calculator during the exam'
),
"examPassword": self._encrypt_password(self.crypto_key, attempt_code), "examPassword": self._encrypt_password(self.crypto_key, attempt_code),
"examSponsor": self.exam_sponsor, "examSponsor": self.exam_sponsor,
"examName": exam['exam_name'], "examName": exam['exam_name'],
......
"""
This is a python module
"""
"""
Django management command to manually set the attempt status for a user in a proctored exam
"""
from optparse import make_option
from django.core.management.base import BaseCommand
from edx_proctoring.models import ProctoredExamStudentAttemptStatus
class Command(BaseCommand):
"""
Django Management command to force a background check of all possible notifications
"""
option_list = BaseCommand.option_list + (
make_option('-e', '--exam',
metavar='EXAM_ID',
dest='exam_id',
help='exam_id to change'),
make_option('-u', '--user',
metavar='USER',
dest='user_id',
help="user_id of user to affect"),
make_option('-t', '--to',
metavar='TO_STATUS',
dest='to_status',
help='the status to set'),
)
def handle(self, *args, **options):
"""
Management command entry point, simply call into the signal firiing
"""
from edx_proctoring.api import (
update_attempt_status,
get_exam_by_id
)
exam_id = options['exam_id']
user_id = options['user_id']
to_status = options['to_status']
msg = (
'Running management command to update user {user_id} '
'attempt status on exam_id {exam_id} to {to_status}'.format(
user_id=user_id,
exam_id=exam_id,
to_status=to_status
)
)
print msg
if not ProctoredExamStudentAttemptStatus.is_valid_status(to_status):
raise Exception('{to_status} is not a valid attempt status!'.format(to_status=to_status))
# get exam, this will throw exception if does not exist, so let it bomb out
get_exam_by_id(exam_id)
update_attempt_status(exam_id, user_id, to_status)
print 'Completed!'
"""
Tests for the set_attempt_status management command
"""
from datetime import datetime
import pytz
from edx_proctoring.tests.utils import LoggedInTestCase
from edx_proctoring.api import create_exam, get_exam_attempt
from edx_proctoring.management.commands import set_attempt_status
from edx_proctoring.models import ProctoredExamStudentAttemptStatus, ProctoredExamStudentAttempt
from edx_proctoring.tests.test_services import (
MockCreditService,
)
from edx_proctoring.runtime import set_runtime_service
class SetAttemptStatusTests(LoggedInTestCase):
"""
Coverage of the set_attempt_status.py file
"""
def setUp(self):
"""
Build up test data
"""
super(SetAttemptStatusTests, self).setUp()
set_runtime_service('credit', MockCreditService())
self.exam_id = create_exam(
course_id='foo',
content_id='bar',
exam_name='Test Exam',
time_limit_mins=90
)
ProctoredExamStudentAttempt.objects.create(
proctored_exam_id=self.exam_id,
user_id=self.user.id,
external_id='foo',
started_at=datetime.now(pytz.UTC),
status=ProctoredExamStudentAttemptStatus.started,
allowed_time_limit_mins=10,
taking_as_proctored=True,
is_sample_attempt=False
)
def test_run_comand(self):
"""
Run the management command
"""
set_attempt_status.Command().handle(
exam_id=self.exam_id,
user_id=self.user.id,
to_status=ProctoredExamStudentAttemptStatus.rejected
)
attempt = get_exam_attempt(self.exam_id, self.user.id)
self.assertEqual(attempt['status'], ProctoredExamStudentAttemptStatus.rejected)
set_attempt_status.Command().handle(
exam_id=self.exam_id,
user_id=self.user.id,
to_status=ProctoredExamStudentAttemptStatus.verified
)
attempt = get_exam_attempt(self.exam_id, self.user.id)
self.assertEqual(attempt['status'], ProctoredExamStudentAttemptStatus.verified)
def test_bad_status(self):
"""
Try passing a bad status
"""
with self.assertRaises(Exception):
set_attempt_status.Command().handle(
exam_id=self.exam_id,
user_id=self.user.id,
to_status='bad'
)
...@@ -200,6 +200,13 @@ class ProctoredExamStudentAttemptStatus(object): ...@@ -200,6 +200,13 @@ class ProctoredExamStudentAttemptStatus(object):
return cls.status_alias_mapping.get(status, '') return cls.status_alias_mapping.get(status, '')
@classmethod
def is_valid_status(cls, status):
"""
Makes sure that passed in status string is valid
"""
return cls.is_completed_status(status) or cls.is_incomplete_status(status)
class ProctoredExamStudentAttemptManager(models.Manager): class ProctoredExamStudentAttemptManager(models.Manager):
""" """
......
...@@ -1230,6 +1230,7 @@ class ProctoredExamApiTests(LoggedInTestCase): ...@@ -1230,6 +1230,7 @@ class ProctoredExamApiTests(LoggedInTestCase):
(ProctoredExamStudentAttemptStatus.rejected, ProctoredExamStudentAttemptStatus.started), (ProctoredExamStudentAttemptStatus.rejected, ProctoredExamStudentAttemptStatus.started),
(ProctoredExamStudentAttemptStatus.not_reviewed, ProctoredExamStudentAttemptStatus.started), (ProctoredExamStudentAttemptStatus.not_reviewed, ProctoredExamStudentAttemptStatus.started),
(ProctoredExamStudentAttemptStatus.error, ProctoredExamStudentAttemptStatus.started), (ProctoredExamStudentAttemptStatus.error, ProctoredExamStudentAttemptStatus.started),
(ProctoredExamStudentAttemptStatus.submitted, ProctoredExamStudentAttemptStatus.error),
) )
@ddt.unpack @ddt.unpack
@patch.dict('django.conf.settings.PROCTORING_SETTINGS', {'ALLOW_TIMED_OUT_STATE': True}) @patch.dict('django.conf.settings.PROCTORING_SETTINGS', {'ALLOW_TIMED_OUT_STATE': True})
......
...@@ -580,7 +580,61 @@ class TestStudentProctoredExamAttempt(LoggedInTestCase): ...@@ -580,7 +580,61 @@ class TestStudentProctoredExamAttempt(LoggedInTestCase):
) )
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
response_data = json.loads(response.content) response_data = json.loads(response.content)
self.assertEqual(response_data['status'], 'started') self.assertEqual(response_data['status'], ProctoredExamStudentAttemptStatus.started)
attempt_code = response_data['attempt_code']
# test the polling callback point
response = self.client.get(
reverse(
'edx_proctoring.anonymous.proctoring_poll_status',
args=[attempt_code]
)
)
self.assertEqual(response.status_code, 200)
# now reset the time to 2 minutes in the future.
reset_time = datetime.now(pytz.UTC) + timedelta(minutes=2)
with freeze_time(reset_time):
response = self.client.get(
reverse('edx_proctoring.proctored_exam.attempt', args=[attempt_id])
)
self.assertEqual(response.status_code, 200)
response_data = json.loads(response.content)
self.assertEqual(response_data['status'], ProctoredExamStudentAttemptStatus.error)
def test_attempt_status_stickiness(self):
"""
Test to confirm that a status timeout error will not alter a completed state
"""
# Create an exam.
proctored_exam = ProctoredExam.objects.create(
course_id='a/b/c',
content_id='test_content',
exam_name='Test Exam',
external_id='123aXqe3',
time_limit_mins=90
)
attempt_data = {
'exam_id': proctored_exam.id,
'external_id': proctored_exam.external_id,
'start_clock': True,
}
response = self.client.post(
reverse('edx_proctoring.proctored_exam.attempt.collection'),
attempt_data
)
self.assertEqual(response.status_code, 200)
response_data = json.loads(response.content)
attempt_id = response_data['exam_attempt_id']
self.assertEqual(attempt_id, 1)
response = self.client.get(
reverse('edx_proctoring.proctored_exam.attempt', args=[attempt_id])
)
self.assertEqual(response.status_code, 200)
response_data = json.loads(response.content)
self.assertEqual(response_data['status'], ProctoredExamStudentAttemptStatus.started)
attempt_code = response_data['attempt_code'] attempt_code = response_data['attempt_code']
# test the polling callback point # test the polling callback point
...@@ -592,6 +646,13 @@ class TestStudentProctoredExamAttempt(LoggedInTestCase): ...@@ -592,6 +646,13 @@ class TestStudentProctoredExamAttempt(LoggedInTestCase):
) )
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
# now switched to a submitted state
update_attempt_status(
proctored_exam.id,
self.user.id,
ProctoredExamStudentAttemptStatus.submitted
)
# now reset the time to 2 minutes in the future. # now reset the time to 2 minutes in the future.
reset_time = datetime.now(pytz.UTC) + timedelta(minutes=2) reset_time = datetime.now(pytz.UTC) + timedelta(minutes=2)
with freeze_time(reset_time): with freeze_time(reset_time):
...@@ -600,7 +661,11 @@ class TestStudentProctoredExamAttempt(LoggedInTestCase): ...@@ -600,7 +661,11 @@ class TestStudentProctoredExamAttempt(LoggedInTestCase):
) )
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
response_data = json.loads(response.content) response_data = json.loads(response.content)
self.assertEqual(response_data['status'], 'error') # make sure the submitted status is sticky
self.assertEqual(
response_data['status'],
ProctoredExamStudentAttemptStatus.submitted
)
@ddt.data( @ddt.data(
ProctoredExamStudentAttemptStatus.created, ProctoredExamStudentAttemptStatus.created,
......
...@@ -37,6 +37,7 @@ from edx_proctoring.exceptions import ( ...@@ -37,6 +37,7 @@ from edx_proctoring.exceptions import (
UserNotFoundException, UserNotFoundException,
ProctoredExamPermissionDenied, ProctoredExamPermissionDenied,
StudentExamAttemptDoesNotExistsException, StudentExamAttemptDoesNotExistsException,
ProctoredExamIllegalStatusTransition,
) )
from edx_proctoring.serializers import ProctoredExamSerializer, ProctoredExamStudentAttemptSerializer from edx_proctoring.serializers import ProctoredExamSerializer, ProctoredExamStudentAttemptSerializer
from edx_proctoring.models import ProctoredExamStudentAttemptStatus, ProctoredExamStudentAttempt from edx_proctoring.models import ProctoredExamStudentAttemptStatus, ProctoredExamStudentAttempt
...@@ -285,12 +286,16 @@ class StudentProctoredExamAttempt(AuthenticatedAPIView): ...@@ -285,12 +286,16 @@ class StudentProctoredExamAttempt(AuthenticatedAPIView):
last_poll_timestamp = attempt['last_poll_timestamp'] last_poll_timestamp = attempt['last_poll_timestamp']
if last_poll_timestamp is not None \ if last_poll_timestamp is not None \
and (datetime.now(pytz.UTC) - last_poll_timestamp).total_seconds() > SOFTWARE_SECURE_CLIENT_TIMEOUT: and (datetime.now(pytz.UTC) - last_poll_timestamp).total_seconds() > SOFTWARE_SECURE_CLIENT_TIMEOUT:
attempt['status'] = 'error' try:
update_attempt_status( update_attempt_status(
attempt['proctored_exam']['id'], attempt['proctored_exam']['id'],
attempt['user']['id'], attempt['user']['id'],
ProctoredExamStudentAttemptStatus.error ProctoredExamStudentAttemptStatus.error
) )
attempt['status'] = ProctoredExamStudentAttemptStatus.error
except ProctoredExamIllegalStatusTransition:
# don't transition a completed state to an error state
pass
# add in the computed time remaining as a helper to a client app # add in the computed time remaining as a helper to a client app
time_remaining_seconds = get_time_remaining_for_attempt(attempt) time_remaining_seconds = get_time_remaining_for_attempt(attempt)
......
...@@ -34,7 +34,7 @@ def load_requirements(*requirements_paths): ...@@ -34,7 +34,7 @@ def load_requirements(*requirements_paths):
setup( setup(
name='edx-proctoring', name='edx-proctoring',
version='0.9.6b', version='0.9.6e',
description='Proctoring subsystem for Open edX', description='Proctoring subsystem for Open edX',
long_description=open('README.md').read(), long_description=open('README.md').read(),
author='edX', author='edX',
......
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