Commit de4b37df by chrisndodge

Merge pull request #22 from edx/cdodge/add-backends

Cdodge/add backends
parents 3ee49f6b b347ec53
......@@ -7,6 +7,7 @@ In-Proc API (aka Library) for the edx_proctoring subsystem. This is not to be co
API which is in the views.py file, per edX coding standards
"""
import pytz
import uuid
from datetime import datetime, timedelta
from django.template import Context, loader
from django.core.urlresolvers import reverse
......@@ -30,6 +31,8 @@ from edx_proctoring.serializers import (
)
from edx_proctoring.utils import humanized_time
from edx_proctoring.backends import get_backend_provider
def create_exam(course_id, content_id, exam_name, time_limit_mins,
is_proctored=True, external_id=None, is_active=True):
......@@ -161,7 +164,16 @@ def get_exam_attempt(exam_id, user_id):
return serialized_attempt_obj.data if exam_attempt_obj else None
def create_exam_attempt(exam_id, user_id, external_id):
def get_exam_attempt_by_id(attempt_id):
"""
Return an existing exam attempt for the given student
"""
exam_attempt_obj = ProctoredExamStudentAttempt.get_exam_attempt_by_id(attempt_id)
serialized_attempt_obj = ProctoredExamStudentAttemptSerializer(exam_attempt_obj)
return serialized_attempt_obj.data if exam_attempt_obj else None
def create_exam_attempt(exam_id, user_id, taking_as_proctored=False):
"""
Creates an exam attempt for user_id against exam_id. There should only be
one exam_attempt per user per exam. Multiple attempts by user will be archived
......@@ -177,14 +189,45 @@ def create_exam_attempt(exam_id, user_id, external_id):
# for now the student is allowed the exam default
exam = get_exam_by_id(exam_id)
allowed_time_limit_mins = exam['time_limit_mins']
# add in the allowed additional time
allowance = ProctoredExamStudentAllowance.get_allowance_for_user(
exam_id,
user_id,
"Additional time (minutes)"
)
if allowance:
allowance_extra_mins = int(allowance.value)
allowed_time_limit_mins += allowance_extra_mins
attempt_code = unicode(uuid.uuid4())
external_id = None
if taking_as_proctored:
callback_url = reverse(
'edx_proctoring.anonymous.proctoring_launch_callback.start_exam',
args=[attempt_code]
)
# now call into the backend provider to register exam attempt
external_id = get_backend_provider().register_exam_attempt(
exam,
allowed_time_limit_mins,
attempt_code,
False,
callback_url
)
attempt = ProctoredExamStudentAttempt.create_exam_attempt(
exam_id,
user_id,
'', # student name is TBD
allowed_time_limit_mins,
attempt_code,
taking_as_proctored,
False,
external_id
)
return attempt.id
......@@ -208,12 +251,39 @@ def start_exam_attempt(exam_id, user_id):
raise StudentExamAttemptDoesNotExistsException(err_msg)
_start_exam_attempt(existing_attempt)
def start_exam_attempt_by_code(attempt_code):
"""
Signals the beginning of an exam attempt when we only have
an attempt code
"""
existing_attempt = ProctoredExamStudentAttempt.get_exam_attempt_by_code(attempt_code)
if not existing_attempt:
err_msg = (
'Cannot start exam attempt for attempt_code = {attempt_code} '
'because it does not exist!'
).format(attempt_code=attempt_code)
raise StudentExamAttemptDoesNotExistsException(err_msg)
_start_exam_attempt(existing_attempt)
def _start_exam_attempt(existing_attempt):
"""
Helper method
"""
if existing_attempt.started_at:
# cannot restart an attempt
err_msg = (
'Cannot start exam attempt for exam_id = {exam_id} '
'and user_id = {user_id} because it has already started!'
).format(exam_id=exam_id, user_id=user_id)
).format(exam_id=existing_attempt.proctored_exam.id, user_id=existing_attempt.user_id)
raise StudentExamAttemptedAlreadyStarted(err_msg)
......@@ -350,12 +420,13 @@ def get_student_view(user_id, course_id, content_id, context):
if not has_started_exam:
# determine whether to show a timed exam only entrance screen
# or a screen regarding proctoring
if is_proctored:
if not attempt:
student_view_template = 'proctoring/seq_proctored_exam_entrance.html'
else:
student_view_template = 'proctoring/seq_proctored_exam_instructions.html'
context.update({'exam_code': '@asDASD@E2313213SDASD213123423WEWA'})
context.update({'exam_code': attempt['attempt_code']})
else:
student_view_template = 'proctoring/seq_timed_exam_entrance.html'
elif has_finished_exam:
......@@ -370,7 +441,11 @@ def get_student_view(user_id, course_id, content_id, context):
django_context.update({
'total_time': total_time,
'exam_id': exam_id,
'enter_exam_endpoint': reverse('edx_proctoring.proctored_exam.attempt'),
'enter_exam_endpoint': reverse('edx_proctoring.proctored_exam.attempt.collection'),
'exam_started_poll_url': reverse(
'edx_proctoring.proctored_exam.attempt',
args=[attempt['id']]
) if attempt else ''
})
return template.render(django_context)
......
"""
All supporting Proctoring backends
"""
from importlib import import_module
from django.conf import settings
from django.core.exceptions import ImproperlyConfigured
# Cached instance of backend provider
_BACKEND_PROVIDER = None
def get_backend_provider():
"""
Returns an instance of the configured backend provider that is configured
via the settings file
"""
global _BACKEND_PROVIDER # pylint: disable=global-statement
if not _BACKEND_PROVIDER:
config = getattr(settings, 'PROCTORING_BACKEND_PROVIDER')
if not config:
raise ImproperlyConfigured("Settings not configured with PROCTORING_BACKEND_PROVIDER!")
if 'class' not in config or 'options' not in config:
msg = (
"Misconfigured PROCTORING_BACKEND_PROVIDER settings, "
"must have both 'class' and 'options' keys."
)
raise ImproperlyConfigured(msg)
module_path, _, name = config['class'].rpartition('.')
class_ = getattr(import_module(module_path), name)
_BACKEND_PROVIDER = class_(**config['options'])
return _BACKEND_PROVIDER
"""
Defines the abstract base class that all backends should derive from
"""
import abc
class ProctoringBackendProvider(object):
"""
The base abstract class for all proctoring service providers
"""
# don't allow instantiation of this class, it must be subclassed
__metaclass__ = abc.ABCMeta
@abc.abstractmethod
def register_exam_attempt(self, exam, time_limit_mins, attempt_code,
is_sample_attempt, callback_url):
"""
Called when the exam attempt has been created but not started
"""
raise NotImplementedError()
@abc.abstractmethod
def start_exam_attempt(self, exam, attempt):
"""
Method that is responsible for communicating with the backend provider
to establish a new proctored exam
"""
raise NotImplementedError()
@abc.abstractmethod
def stop_exam_attempt(self, exam, attempt):
"""
Method that is responsible for communicating with the backend provider
to establish a new proctored exam
"""
raise NotImplementedError()
"""
Implementation of a backend provider, which does nothing
"""
from edx_proctoring.backends.backend import ProctoringBackendProvider
class NullBackendProvider(ProctoringBackendProvider):
"""
Implementation of the ProctoringBackendProvider that does nothing
"""
def register_exam_attempt(self, exam, time_limit_mins, attempt_code,
is_sample_attempt, callback_url):
"""
Called when the exam attempt has been created but not started
"""
return None
def start_exam_attempt(self, exam, attempt):
"""
Method that is responsible for communicating with the backend provider
to establish a new proctored exam
"""
return None
def stop_exam_attempt(self, exam, attempt):
"""
Method that is responsible for communicating with the backend provider
to establish a new proctored exam
"""
return None
"""
Integration with Software Secure's proctoring system
"""
from Crypto.Cipher import DES3
import base64
from hashlib import sha256
import requests
import hmac
import binascii
import datetime
import json
import logging
from edx_proctoring.backends.backend import ProctoringBackendProvider
from edx_proctoring.exceptions import BackendProvideCannotRegisterAttempt
log = logging.getLogger(__name__)
class SoftwareSecureBackendProvider(ProctoringBackendProvider):
"""
Implementation of the ProctoringBackendProvider for Software Secure's
RPNow product
"""
def __init__(self, organization, exam_sponsor, exam_register_endpoint,
secret_key_id, secret_key, crypto_key):
"""
Class initializer
"""
self.organization = organization
self.exam_sponsor = exam_sponsor
self.exam_register_endpoint = exam_register_endpoint
self.secret_key_id = secret_key_id
self.secret_key = secret_key
self.crypto_key = crypto_key
self.timeout = 10
def register_exam_attempt(self, exam, time_limit_mins, attempt_code,
is_sample_attempt, callback_url):
"""
Method that is responsible for communicating with the backend provider
to establish a new proctored exam
"""
data = self._get_payload(
exam,
time_limit_mins,
attempt_code,
is_sample_attempt,
callback_url
)
headers = {
"Content-Type": 'application/json'
}
http_date = datetime.datetime.utcnow().strftime("%a, %d %b %Y %H:%M:%S GMT")
signature = self._sign_doc(data, 'POST', headers, http_date)
status, response = self._send_request_to_ssi(data, signature, http_date)
if status not in [200, 201]:
err_msg = (
'Could not register attempt_code = {attempt_code}. '
'HTTP Status code was {status_code} and response was {response}.'.format(
attempt_code=attempt_code,
status_code=status,
response=response
)
)
raise BackendProvideCannotRegisterAttempt(err_msg)
# get the external ID that Software Secure has defined
# for this attempt
ssi_record_locator = json.loads(response)['ssiRecordLocator']
return ssi_record_locator
def start_exam_attempt(self, exam, attempt): # pylint: disable=unused-argument
"""
Called when the exam attempt has been created but not started
"""
return None
def stop_exam_attempt(self, exam, attempt):
"""
Method that is responsible for communicating with the backend provider
to establish a new proctored exam
"""
return None
def _encrypt_password(self, key, pwd):
"""
Encrypt the exam passwork with the given key
"""
block_size = DES3.block_size
def pad(text):
"""
Apply padding
"""
return text + (block_size - len(text) % block_size) * chr(block_size - len(text) % block_size)
cipher = DES3.new(key, DES3.MODE_ECB)
encrypted_text = cipher.encrypt(pad(pwd))
return base64.b64encode(encrypted_text)
def _get_payload(self, exam, time_limit_mins, attempt_code,
is_sample_attempt, callback_url):
"""
Constructs the data payload that Software Secure expects
"""
now = datetime.datetime.utcnow()
start_time_str = now.strftime("%a, %d %b %Y %H:%M:%S GMT")
end_time_str = (now + datetime.timedelta(minutes=time_limit_mins)).strftime("%a, %d %b %Y %H:%M:%S GMT")
return {
"examCode": attempt_code,
"organization": self.organization,
"duration": time_limit_mins,
"reviewedExam": not is_sample_attempt,
"reviewerNotes": 'Closed Book',
"examPassword": self._encrypt_password(self.crypto_key, attempt_code),
"examSponsor": self.exam_sponsor,
"examName": exam['exam_name'],
"ssiProduct": 'rp-now',
# need to pass in a URL to the LMS?
"examUrl": (
'http://localhost:8000/{}'.format(callback_url)
),
"orgExtra": {
"examStartDate": start_time_str,
"examEndDate": end_time_str,
"noOfStudents": 1,
"examID": exam['id'],
"courseID": exam['course_id'],
}
}
def _header_string(self, headers, date):
"""
Composes the HTTP header string that SoftwareSecure expects
"""
# Headers
string = ""
if 'Content-Type' in headers:
string += headers.get('Content-Type')
string += '\n'
if date:
string += date
string += '\n'
return string
def _body_string(self, body_json, prefix=""):
"""
Serializes out the HTTP body that SoftwareSecure expects
"""
keys = body_json.keys()
keys.sort()
string = ""
for key in keys:
value = body_json[key]
if str(value) == 'True':
value = 'true'
if str(value) == 'False':
value = 'false'
if isinstance(value, (list, tuple)):
for idx, arr in enumerate(value):
if isinstance(arr, dict):
string += self._body_string(arr, key + '.' + str(idx) + '.')
else:
string += key + '.' + str(idx) + ':' + arr + '\n'
elif isinstance(value, dict):
string += self._body_string(value, key + '.')
else:
if value != "" and not value:
value = "null"
string += str(prefix) + str(key) + ":" + str(value).encode('utf-8') + '\n'
return string
def _sign_doc(self, body_json, method, headers, date):
"""
Digitaly signs the datapayload that SoftwareSecure expects
"""
body_str = self._body_string(body_json)
method_string = method + '\n\n'
headers_str = self._header_string(headers, date)
message = method_string + headers_str + body_str
# HMAC requires a string not a unicode
message = str(message)
log_msg = (
'About to send payload to SoftwareSecure:\n{message}'.format(message=message)
)
log.info(log_msg)
hashed = hmac.new(str(self.secret_key), str(message), sha256)
computed = binascii.b2a_base64(hashed.digest()).rstrip('\n')
return 'SSI ' + self.secret_key_id + ':' + computed
def _send_request_to_ssi(self, data, sig, date):
"""
Performs the webservice call to SoftwareSecure
"""
response = requests.post(
self.exam_register_endpoint,
headers={
'Content-Type': 'application/json',
"Authorization": sig,
"Date": date
},
data=json.dumps(data),
timeout=self.timeout
)
return response.status_code, response.text
"""
Directory to hold all of the tests
"""
"""
Tests for backend.py
"""
from django.test import TestCase
from edx_proctoring.backends.backend import ProctoringBackendProvider
from edx_proctoring.backends.null import NullBackendProvider
class TestBackendProvider(ProctoringBackendProvider):
"""
Implementation of the ProctoringBackendProvider that does nothing
"""
def register_exam_attempt(self, exam, time_limit_mins, attempt_code,
is_sample_attempt, callback_url):
"""
Called when the exam attempt has been created but not started
"""
return 'testexternalid'
def start_exam_attempt(self, exam, attempt):
"""
Method that is responsible for communicating with the backend provider
to establish a new proctored exam
"""
return None
def stop_exam_attempt(self, exam, attempt):
"""
Method that is responsible for communicating with the backend provider
to establish a new proctored exam
"""
return None
class PassthroughBackendProvider(ProctoringBackendProvider):
"""
Implementation of the ProctoringBackendProvider that just calls the base class
"""
def register_exam_attempt(self, exam, time_limit_mins, attempt_code,
is_sample_attempt, callback_url):
"""
Called when the exam attempt has been created but not started
"""
return super(PassthroughBackendProvider, self).register_exam_attempt(
exam,
time_limit_mins,
attempt_code,
is_sample_attempt,
callback_url
)
def start_exam_attempt(self, exam, attempt):
"""
Method that is responsible for communicating with the backend provider
to establish a new proctored exam
"""
return super(PassthroughBackendProvider, self).start_exam_attempt(
exam,
attempt
)
def stop_exam_attempt(self, exam, attempt):
"""
Method that is responsible for communicating with the backend provider
to establish a new proctored exam
"""
return super(PassthroughBackendProvider, self).stop_exam_attempt(
exam,
attempt
)
class TestBackends(TestCase):
"""
Miscelaneous tests for backends.py
"""
def test_raises_exception(self):
"""
Makes sure the abstract base class raises NotImplementedError
"""
provider = PassthroughBackendProvider()
with self.assertRaises(NotImplementedError):
provider.register_exam_attempt(None, None, None, None, None)
with self.assertRaises(NotImplementedError):
provider.start_exam_attempt(None, None)
with self.assertRaises(NotImplementedError):
provider.stop_exam_attempt(None, None)
def test_null_provider(self):
"""
Assert that the Null provider does nothing
"""
provider = NullBackendProvider()
self.assertIsNone(provider.register_exam_attempt(None, None, None, None, None))
self.assertIsNone(provider.start_exam_attempt(None, None))
self.assertIsNone(provider.stop_exam_attempt(None, None))
"""
Tests for the software_secure module
"""
from mock import patch
from httmock import all_requests, HTTMock
import json
from django.test import TestCase
from django.contrib.auth.models import User
from edx_proctoring.backends import get_backend_provider
from edx_proctoring.exceptions import BackendProvideCannotRegisterAttempt
from edx_proctoring.api import get_exam_attempt_by_id
from edx_proctoring.api import (
create_exam,
create_exam_attempt,
)
@all_requests
def response_content(url, request): # pylint: disable=unused-argument
"""
Mock HTTP response from SoftwareSecure
"""
return {
'status_code': 200,
'content': json.dumps({
'ssiRecordLocator': 'foobar'
})
}
@all_requests
def response_error(url, request): # pylint: disable=unused-argument
"""
Mock HTTP response from SoftwareSecure
"""
return {
'status_code': 404,
'content': 'Page not found'
}
@patch(
'django.conf.settings.PROCTORING_BACKEND_PROVIDER',
{
"class": "edx_proctoring.backends.software_secure.SoftwareSecureBackendProvider",
"options": {
"secret_key_id": "foo",
"secret_key": "4B230FA45A6EC5AE8FDE2AFFACFABAA16D8A3D0B",
"crypto_key": "123456789123456712345678",
"exam_register_endpoint": "http://test",
"organization": "edx",
"exam_sponsor": "edX LMS",
}
}
)
class SoftwareSecureTests(TestCase):
"""
All tests for the SoftwareSecureBackendProvider
"""
def setUp(self):
"""
Initialize
"""
self.user = User(username='foo', email='foo@bar.com')
self.user.save()
def test_provider_instance(self):
"""
Makes sure the instance of the proctoring module can be created
"""
provider = get_backend_provider()
self.assertIsNotNone(provider)
def test_register_attempt(self):
"""
Makes sure we can register an attempt
"""
exam_id = create_exam(
course_id='foo/bar/baz',
content_id='content',
exam_name='Sample Exam',
time_limit_mins=10,
is_proctored=True
)
with HTTMock(response_content):
attempt_id = create_exam_attempt(exam_id, self.user.id, taking_as_proctored=True)
self.assertIsNotNone(attempt_id)
attempt = get_exam_attempt_by_id(attempt_id)
self.assertEqual(attempt['external_id'], 'foobar')
self.assertIsNone(attempt['started_at'])
def test_failing_register_attempt(self):
"""
Makes sure we can register an attempt
"""
exam_id = create_exam(
course_id='foo/bar/baz',
content_id='content',
exam_name='Sample Exam',
time_limit_mins=10,
is_proctored=True
)
# now try a failing request
with HTTMock(response_error):
with self.assertRaises(BackendProvideCannotRegisterAttempt):
create_exam_attempt(exam_id, self.user.id, taking_as_proctored=True)
def test_payload_construction(self):
"""
Calls directly into the SoftwareSecure payload construction
"""
provider = get_backend_provider()
body = provider._body_string({ # pylint: disable=protected-access
'foo': False,
'none': None,
})
self.assertIn('false', body)
self.assertIn('null', body)
body = provider._body_string({ # pylint: disable=protected-access
'foo': ['first', {'here': 'yes'}]
})
self.assertIn('first', body)
self.assertIn('here', body)
self.assertIn('yes', body)
def test_start_proctored_exam(self):
"""
Test that SoftwareSecure's implementation returns None, because there is no
work that needs to happen right now
"""
provider = get_backend_provider()
self.assertIsNone(provider.start_exam_attempt(None, None))
def test_stop_proctored_exam(self):
"""
Test that SoftwareSecure's implementation returns None, because there is no
work that needs to happen right now
"""
provider = get_backend_provider()
self.assertIsNone(provider.stop_exam_attempt(None, None))
"""
Various callback paths
"""
from django.template import Context, loader
from django.http import HttpResponse
from edx_proctoring.exceptions import StudentExamAttemptDoesNotExistsException
from edx_proctoring.api import (
start_exam_attempt_by_code,
)
def start_exam_callback(request, attempt_code): # pylint: disable=unused-argument
"""
A callback endpoint which is called when SoftwareSecure completes
the proctoring setup and the exam should be started.
NOTE: This returns HTML as it will be displayed in an embedded browser
This is an authenticated endpoint and the attempt_code is passed in
as a query string parameter
"""
# start the exam!
try:
start_exam_attempt_by_code(attempt_code)
except StudentExamAttemptDoesNotExistsException:
return HttpResponse(
content='That exam code is not valid',
status=404
)
template = loader.get_template('proctoring/proctoring_launch_callback.html')
return HttpResponse(template.render(Context({})))
......@@ -43,3 +43,15 @@ class UserNotFoundException(ProctoredBaseException):
"""
Raised when the user not found.
"""
class BackendProvideCannotRegisterAttempt(ProctoredBaseException):
"""
Raised when a back-end provider cannot register an attempt
"""
class ProctoredExamPermissionDenied(ProctoredBaseException):
"""
Raised when the calling user does not have access to the requested object.
"""
# -*- 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 'ProctoredExamStudentAttempt.attempt_code'
db.add_column('proctoring_proctoredexamstudentattempt', 'attempt_code',
self.gf('django.db.models.fields.CharField')(max_length=255, null=True, db_index=True),
keep_default=False)
# Adding field 'ProctoredExamStudentAttempt.is_sample_attempt'
db.add_column('proctoring_proctoredexamstudentattempt', 'is_sample_attempt',
self.gf('django.db.models.fields.BooleanField')(default=False),
keep_default=False)
def backwards(self, orm):
# Deleting field 'ProctoredExamStudentAttempt.attempt_code'
db.delete_column('proctoring_proctoredexamstudentattempt', 'attempt_code')
# Deleting field 'ProctoredExamStudentAttempt.is_sample_attempt'
db.delete_column('proctoring_proctoredexamstudentattempt', 'is_sample_attempt')
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_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.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': {'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'}),
'modified': ('model_utils.fields.AutoLastModifiedField', [], {'default': 'datetime.datetime.now'}),
'proctored_exam': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['edx_proctoring.ProctoredExam']"}),
'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
......@@ -89,6 +89,10 @@ class ProctoredExamStudentAttempt(TimeStampedModel):
started_at = models.DateTimeField(null=True)
completed_at = models.DateTimeField(null=True)
# this will be a unique string ID that the user
# will have to use when starting the proctored exam
attempt_code = models.CharField(max_length=255, null=True, db_index=True)
# This will be a integration specific ID - say to SoftwareSecure.
external_id = models.CharField(max_length=255, null=True, db_index=True)
......@@ -102,6 +106,10 @@ class ProctoredExamStudentAttempt(TimeStampedModel):
# in case there is an option to opt-out
taking_as_proctored = models.BooleanField()
# Whether this attampt is considered a sample attempt, e.g. to try out
# the proctoring software
is_sample_attempt = models.BooleanField()
student_name = models.CharField(max_length=255)
class Meta:
......@@ -115,7 +123,8 @@ class ProctoredExamStudentAttempt(TimeStampedModel):
return self.started_at and not self.completed_at
@classmethod
def create_exam_attempt(cls, exam_id, user_id, student_name, allowed_time_limit_mins, external_id):
def create_exam_attempt(cls, exam_id, user_id, student_name, allowed_time_limit_mins,
attempt_code, taking_as_proctored, is_sample_attempt, external_id):
"""
Create a new exam attempt entry for a given exam_id and
user_id.
......@@ -126,6 +135,9 @@ class ProctoredExamStudentAttempt(TimeStampedModel):
user_id=user_id,
student_name=student_name,
allowed_time_limit_mins=allowed_time_limit_mins,
attempt_code=attempt_code,
taking_as_proctored=taking_as_proctored,
is_sample_attempt=is_sample_attempt,
external_id=external_id
)
......@@ -133,7 +145,6 @@ class ProctoredExamStudentAttempt(TimeStampedModel):
"""
sets the model's state when an exam attempt has started
"""
self.started_at = datetime.now(pytz.UTC)
self.save()
......@@ -150,6 +161,29 @@ class ProctoredExamStudentAttempt(TimeStampedModel):
return exam_attempt_obj
@classmethod
def get_exam_attempt_by_id(cls, attempt_id):
"""
Returns the Student Exam Attempt by the attempt_id else return None
"""
try:
exam_attempt_obj = cls.objects.get(id=attempt_id)
except cls.DoesNotExist: # pylint: disable=no-member
exam_attempt_obj = None
return exam_attempt_obj
@classmethod
def get_exam_attempt_by_code(cls, attempt_code):
"""
Returns the Student Exam Attempt object if found
else Returns None.
"""
try:
exam_attempt_obj = cls.objects.get(attempt_code=attempt_code)
except cls.DoesNotExist: # pylint: disable=no-member
exam_attempt_obj = None
return exam_attempt_obj
@classmethod
def get_active_student_attempts(cls, user_id, course_id=None):
"""
Returns the active student exams (user in-progress exams)
......
......@@ -77,7 +77,8 @@ class ProctoredExamStudentAttemptSerializer(serializers.ModelSerializer):
fields = (
"id", "created", "modified", "user_id", "started_at", "completed_at",
"external_id", "status", "proctored_exam_id", "allowed_time_limit_mins"
"external_id", "status", "proctored_exam_id", "allowed_time_limit_mins",
"attempt_code", "is_sample_attempt"
)
......
<html>
<body>
</body>
<p>Your proctored exam has started. Please immediately go back to the website to enter into your exam</p>
</html>
......@@ -13,7 +13,7 @@
</p>
<div class="gated-sequence">
<span><i class="fa fa-lock"></i></span>
<a class="start-timed-exam" data-ajax-url="{{enter_exam_endpoint}}" data-exam-id="{{exam_id}}" data-attempt-proctored=true>
<a class="start-timed-exam" data-ajax-url="{{enter_exam_endpoint}}" data-exam-id="{{exam_id}}" data-attempt-proctored=true data-start-immediately=false>
{% trans "Yes, take this as a proctored exam (and be eligible for credit)" %}
</a>
<p>
......@@ -22,11 +22,11 @@
your exam. After successful installation, you will be <strong>guided through setting up your
proctored session and begin the exam immediately </strong>afterwards.</p>
{% endblocktrans %}
<i class="fa fa-arrow-circle-right start-timed-exam" data-ajax-url="{{enter_exam_endpoint}}" data-exam-id="{{exam_id}}" data-attempt-proctored=true></i>
<i class="fa fa-arrow-circle-right start-timed-exam" data-ajax-url="{{enter_exam_endpoint}}" data-exam-id="{{exam_id}}" data-attempt-proctored=true data-start-immediately=false></i>
</div>
<div class="gated-sequence">
<span><i class="fa fa-unlock"></i></span>
<a class="start-timed-exam" data-ajax-url="{{enter_exam_endpoint}}" data-exam-id="{{exam_id}}" data-attempt-proctored=false>
<a class="start-timed-exam" data-ajax-url="{{enter_exam_endpoint}}" data-exam-id="{{exam_id}}" data-attempt-proctored=false data-start-immediately=true>
{% trans "No, take this as an open exam (and not be eligible for credit)" %}
</a>
<p>
......@@ -35,7 +35,7 @@
college credit </strong>upon completing the exam or this course in general.
{% endblocktrans %}
</p>
<i class="fa fa-arrow-circle-right start-timed-exam" data-ajax-url="{{enter_exam_endpoint}}" data-exam-id="{{exam_id}}" data-attempt-proctored=false></i>
<i class="fa fa-arrow-circle-right start-timed-exam" data-ajax-url="{{enter_exam_endpoint}}" data-exam-id="{{exam_id}}" data-attempt-proctored=false data-start-immediately=true></i>
</div>
</div>
<div class="footer-sequence">
......@@ -54,6 +54,7 @@
var action_url = $(this).data('ajax-url');
var exam_id = $(this).data('exam-id');
var attempt_proctored = $(this).data('attempt-proctored');
var start_immediately = $(this).data('start-immediately');
if (typeof action_url === "undefined" ) {
return false;
}
......@@ -62,7 +63,7 @@
{
"exam_id": exam_id,
"attempt_proctored": attempt_proctored,
"start_clock": false
"start_clock": start_immediately
},
function(data) {
// reload the page, because we've unlocked it
......
{% load i18n %}
<div class="sequence proctored-exam instructions" data-exam-id="{{exam_id}}">
<div class="sequence proctored-exam instructions" data-exam-id="{{exam_id}}" data-exam-started-poll-url="{{exam_started_poll_url}}">
<h3>
{% blocktrans %}
Awaiting Proctoring Installation & Set Up
......@@ -39,3 +39,23 @@
</p>
</div>
{% include 'proctoring/seq_proctored_exam_footer.html' %}
<script type="text/javascript">
$(document).ready(function(){
setInterval(
poll_exam_started,
5000
);
});
function poll_exam_started() {
var url = $('.instructions').data('exam-started-poll-url')
$.ajax(url).success(function(data){
if (data.started_at !== null) {
location.reload()
}
});
}
</script>
......@@ -3,6 +3,8 @@ All tests for the models.py
"""
from datetime import datetime
import pytz
from edx_proctoring.api import (
create_exam,
update_exam,
......@@ -11,13 +13,15 @@ from edx_proctoring.api import (
add_allowance_for_user,
remove_allowance_for_user,
start_exam_attempt,
start_exam_attempt_by_code,
stop_exam_attempt,
get_active_exams_for_user,
get_exam_attempt,
create_exam_attempt,
get_student_view,
get_allowances_for_course,
get_all_exams_for_course
get_all_exams_for_course,
get_exam_attempt_by_id
)
from edx_proctoring.exceptions import (
ProctoredExamAlreadyExists,
......@@ -53,7 +57,7 @@ class ProctoredExamApiTests(LoggedInTestCase):
self.content_id = 'test_content_id'
self.disabled_content_id = 'test_disabled_content_id'
self.exam_name = 'Test Exam'
self.user_id = 1
self.user_id = self.user.id
self.key = 'Test Key'
self.value = 'Test Value'
self.external_id = 'test_external_id'
......@@ -248,9 +252,26 @@ class ProctoredExamApiTests(LoggedInTestCase):
"""
Create an unstarted exam attempt.
"""
attempt_id = create_exam_attempt(self.proctored_exam_id, self.user_id, '')
attempt_id = create_exam_attempt(self.proctored_exam_id, self.user_id)
self.assertGreater(attempt_id, 0)
def test_attempt_with_allowance(self):
"""
Create an unstarted exam attempt with additional time.
"""
allowed_extra_time = 10
add_allowance_for_user(
self.proctored_exam_id,
self.user.username,
"Additional time (minutes)",
str(allowed_extra_time)
)
attempt_id = create_exam_attempt(self.proctored_exam_id, self.user_id)
self.assertGreater(attempt_id, 0)
attempt = get_exam_attempt_by_id(attempt_id)
self.assertEqual(attempt['allowed_time_limit_mins'], self.default_time_limit + allowed_extra_time)
def test_recreate_an_exam_attempt(self):
"""
Start an exam attempt that has already been created.
......@@ -258,7 +279,7 @@ class ProctoredExamApiTests(LoggedInTestCase):
"""
proctored_exam_student_attempt = self._create_unstarted_exam_attempt()
with self.assertRaises(StudentExamAttemptAlreadyExistsException):
create_exam_attempt(proctored_exam_student_attempt.proctored_exam, self.user_id, self.external_id)
create_exam_attempt(proctored_exam_student_attempt.proctored_exam, self.user_id)
def test_get_exam_attempt(self):
"""
......@@ -278,6 +299,9 @@ class ProctoredExamApiTests(LoggedInTestCase):
with self.assertRaises(StudentExamAttemptDoesNotExistsException):
start_exam_attempt(self.proctored_exam_id, self.user_id)
with self.assertRaises(StudentExamAttemptDoesNotExistsException):
start_exam_attempt_by_code('foobar')
def test_start_a_created_attempt(self):
"""
Test to attempt starting an attempt which has been created but not started.
......@@ -285,6 +309,13 @@ class ProctoredExamApiTests(LoggedInTestCase):
self._create_unstarted_exam_attempt()
start_exam_attempt(self.proctored_exam_id, self.user_id)
def test_start_by_code(self):
"""
Test to attempt starting an attempt which has been created but not started.
"""
attempt = self._create_unstarted_exam_attempt()
start_exam_attempt_by_code(attempt.attempt_code)
def test_restart_a_started_attempt(self):
"""
Test to attempt starting an attempt which has been created but not started.
......@@ -327,8 +358,7 @@ class ProctoredExamApiTests(LoggedInTestCase):
)
create_exam_attempt(
exam_id=exam_id,
user_id=self.user_id,
external_id=self.external_id
user_id=self.user_id
)
start_exam_attempt(
exam_id=exam_id,
......
"""
URL mappings for edX Proctoring Server.
"""
from edx_proctoring import views
from edx_proctoring import views, callbacks
from django.conf import settings
from django.conf.urls import patterns, url, include
......@@ -31,11 +31,16 @@ urlpatterns = patterns( # pylint: disable=invalid-name
name='edx_proctoring.proctored_exam.exams_by_course_id'
),
url(
r'edx_proctoring/v1/proctored_exam/attempt$',
r'edx_proctoring/v1/proctored_exam/attempt/(?P<attempt_id>\d+)$',
views.StudentProctoredExamAttempt.as_view(),
name='edx_proctoring.proctored_exam.attempt'
),
url(
r'edx_proctoring/v1/proctored_exam/attempt$',
views.StudentProctoredExamAttemptCollection.as_view(),
name='edx_proctoring.proctored_exam.attempt.collection'
),
url(
r'edx_proctoring/v1/proctored_exam/{}/allowance$'.format(settings.COURSE_ID_PATTERN),
views.ExamAllowanceView.as_view(),
name='edx_proctoring.proctored_exam.allowance'
......@@ -50,5 +55,10 @@ urlpatterns = patterns( # pylint: disable=invalid-name
views.ActiveExamsForUserView.as_view(),
name='edx_proctoring.proctored_exam.active_exams_for_user'
),
url(
r'edx_proctoring/proctoring_launch_callback/start_exam/(?P<attempt_code>[-\w]+)$',
callbacks.start_exam_callback,
name='edx_proctoring.anonymous.proctoring_launch_callback.start_exam'
),
url(r'^', include('rest_framework.urls', namespace='rest_framework'))
)
......@@ -22,12 +22,16 @@ from edx_proctoring.api import (
get_active_exams_for_user,
create_exam_attempt,
get_allowances_for_course,
get_all_exams_for_course
get_all_exams_for_course,
get_exam_attempt_by_id,
)
from edx_proctoring.exceptions import (
ProctoredBaseException,
ProctoredExamNotFoundException,
UserNotFoundException)
UserNotFoundException,
ProctoredExamPermissionDenied,
StudentExamAttemptDoesNotExistsException,
)
from edx_proctoring.serializers import ProctoredExamSerializer
from .utils import AuthenticatedAPIView
......@@ -208,6 +212,112 @@ class StudentProctoredExamAttempt(AuthenticatedAPIView):
HTTP PUT: Stops an exam attempt.
HTTP GET: Returns the status of an exam attempt.
HTTP PUT
Stops the existing exam attempt in progress
PUT data : {
....
}
**PUT data Parameters**
* exam_id: The unique identifier for the proctored exam attempt.
**Response Values**
* {'exam_attempt_id': ##}, The exam_attempt_id of the Proctored Exam Attempt..
HTTP GET
** Scenarios **
return the status of the exam attempt
"""
def get(self, request, attempt_id):
"""
HTTP GET Handler. Returns the status of the exam attempt.
"""
try:
attempt = get_exam_attempt_by_id(attempt_id)
if not attempt:
err_msg = (
'Attempted to access attempt_id {attempt_id} but '
'it does not exist.'.format(
attempt_id=attempt_id
)
)
raise StudentExamAttemptDoesNotExistsException(err_msg)
# make sure the the attempt belongs to the calling user_id
if attempt['user_id'] != request.user.id:
err_msg = (
'Attempted to access attempt_id {attempt_id} but '
'does not have access to it.'.format(
attempt_id=attempt_id
)
)
raise ProctoredExamPermissionDenied(err_msg)
return Response(
data=attempt,
status=status.HTTP_200_OK
)
except ProctoredBaseException, ex:
return Response(
status=status.HTTP_400_BAD_REQUEST,
data={"detail": str(ex)}
)
def put(self, request, attempt_id):
"""
HTTP POST handler. To stop an exam.
"""
try:
attempt = get_exam_attempt_by_id(attempt_id)
if not attempt:
err_msg = (
'Attempted to access attempt_id {attempt_id} but '
'it does not exist.'.format(
attempt_id=attempt_id
)
)
raise StudentExamAttemptDoesNotExistsException(err_msg)
# make sure the the attempt belongs to the calling user_id
if attempt['user_id'] != request.user.id:
err_msg = (
'Attempted to access attempt_id {attempt_id} but '
'does not have access to it.'.format(
attempt_id=attempt_id
)
)
raise ProctoredExamPermissionDenied(err_msg)
exam_attempt_id = stop_exam_attempt(
exam_id=attempt['proctored_exam_id'],
user_id=request.user.id
)
return Response({"exam_attempt_id": exam_attempt_id})
except ProctoredBaseException, ex:
return Response(
status=status.HTTP_400_BAD_REQUEST,
data={"detail": str(ex)}
)
class StudentProctoredExamAttemptCollection(AuthenticatedAPIView):
"""
Endpoint for the StudentProctoredExamAttempt
/edx_proctoring/v1/proctored_exam/attempt
Supports:
HTTP POST: Starts an exam attempt.
HTTP PUT: Stops an exam attempt.
HTTP GET: Returns the status of an exam attempt.
HTTP POST
create an exam attempt.
Expected POST data: {
......@@ -246,7 +356,7 @@ class StudentProctoredExamAttempt(AuthenticatedAPIView):
return the status of the exam attempt
"""
def get(self, request): # pylint: disable=unused-argument
def get(self, request):
"""
HTTP GET Handler. Returns the status of the exam attempt.
"""
......@@ -291,11 +401,12 @@ class StudentProctoredExamAttempt(AuthenticatedAPIView):
"""
start_immediately = request.DATA.get('start_clock', 'false').lower() == 'true'
exam_id = request.DATA.get('exam_id', None)
attempt_proctored = request.DATA.get('attempt_proctored', 'false').lower() == 'true'
try:
exam_attempt_id = create_exam_attempt(
exam_id=exam_id,
user_id=request.user.id,
external_id=request.DATA.get('external_id', None),
taking_as_proctored=attempt_proctored
)
if start_immediately:
......@@ -309,23 +420,6 @@ class StudentProctoredExamAttempt(AuthenticatedAPIView):
data={"detail": str(ex)}
)
def put(self, request):
"""
HTTP POST handler. To stop an exam.
"""
try:
exam_attempt_id = stop_exam_attempt(
exam_id=request.DATA.get('exam_id', None),
user_id=request.user.id
)
return Response({"exam_attempt_id": exam_attempt_id})
except ProctoredBaseException, ex:
return Response(
status=status.HTTP_400_BAD_REQUEST,
data={"detail": str(ex)}
)
class ExamAllowanceView(AuthenticatedAPIView):
"""
......
......@@ -3,4 +3,5 @@ django>=1.4.12,<=1.4.20
django-model-utils==1.4.0
South>=0.7.6
djangorestframework>=2.3.5,<=2.3.14
pytz==2012h
pytz>=2012h
pycrypto>=2.6
......@@ -69,4 +69,9 @@ MIDDLEWARE_CLASSES = (
ROOT_URLCONF = 'edx_proctoring.urls'
COURSE_ID_REGEX = r'[^/+]+(/|\+)[^/+]+(/|\+)[^/]+'
COURSE_ID_PATTERN = r'(?P<course_id>%s)'%COURSE_ID_REGEX
COURSE_ID_PATTERN = r'(?P<course_id>%s)' % COURSE_ID_REGEX
PROCTORING_BACKEND_PROVIDER = {
"class": "edx_proctoring.backends.tests.test_backend.TestBackendProvider",
"options": {}
}
......@@ -15,3 +15,4 @@ sure==1.2.7
ddt==0.8.0
selenium>=2.45.0
freezegun==0.3.1
httmock==1.2.3
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