Commit 120755b9 by chrisndodge

Merge pull request #6 from edx/muhhshoaib/PHX-38

(WIP) PHX-38 added the api level functionality
parents e577b8a6 a7fb2e2f
......@@ -6,6 +6,19 @@
In-Proc API (aka Library) for the edx_proctoring subsystem. This is not to be confused with a HTTP REST
API which is in the views.py file, per edX coding standards
"""
import pytz
from datetime import datetime, timedelta
from django.template import Context, loader
from django.core.urlresolvers import reverse
from edx_proctoring.exceptions import (
ProctoredExamAlreadyExists, ProctoredExamNotFoundException, StudentExamAttemptAlreadyExistsException,
StudentExamAttemptDoesNotExistsException)
from edx_proctoring.models import (
ProctoredExam, ProctoredExamStudentAllowance, ProctoredExamStudentAttempt
)
from edx_proctoring.serializers import ProctoredExamSerializer, ProctoredExamStudentAttemptSerializer, \
ProctoredExamStudentAllowanceSerializer
def create_exam(course_id, content_id, exam_name, time_limit_mins,
......@@ -16,6 +29,19 @@ def create_exam(course_id, content_id, exam_name, time_limit_mins,
Returns: id (PK)
"""
if ProctoredExam.get_exam_by_content_id(course_id, content_id) is not None:
raise ProctoredExamAlreadyExists
proctored_exam = ProctoredExam.objects.create(
course_id=course_id,
content_id=content_id,
external_id=external_id,
exam_name=exam_name,
time_limit_mins=time_limit_mins,
is_proctored=is_proctored,
is_active=is_active
)
return proctored_exam.id
def update_exam(exam_id, exam_name=None, time_limit_mins=None,
......@@ -26,6 +52,22 @@ def update_exam(exam_id, exam_name=None, time_limit_mins=None,
Returns: id
"""
proctored_exam = ProctoredExam.get_exam_by_id(exam_id)
if proctored_exam is None:
raise ProctoredExamNotFoundException
if exam_name is not None:
proctored_exam.exam_name = exam_name
if time_limit_mins is not None:
proctored_exam.time_limit_mins = time_limit_mins
if is_proctored is not None:
proctored_exam.is_proctored = is_proctored
if external_id is not None:
proctored_exam.external_id = external_id
if is_active is not None:
proctored_exam.is_active = is_active
proctored_exam.save()
return proctored_exam.id
def get_exam_by_id(exam_id):
......@@ -33,7 +75,23 @@ def get_exam_by_id(exam_id):
Looks up exam by the Primary Key. Raises exception if not found.
Returns dictionary version of the Django ORM object
e.g.
{
"course_id": "edX/DemoX/Demo_Course",
"content_id": "123",
"external_id": "",
"exam_name": "Midterm",
"time_limit_mins": 90,
"is_proctored": true,
"is_active": true
}
"""
proctored_exam = ProctoredExam.get_exam_by_id(exam_id)
if proctored_exam is None:
raise ProctoredExamNotFoundException
serialized_exam_object = ProctoredExamSerializer(proctored_exam)
return serialized_exam_object.data
def get_exam_by_content_id(course_id, content_id):
......@@ -41,19 +99,47 @@ def get_exam_by_content_id(course_id, content_id):
Looks up exam by the course_id/content_id pair. Raises exception if not found.
Returns dictionary version of the Django ORM object
e.g.
{
"course_id": "edX/DemoX/Demo_Course",
"content_id": "123",
"external_id": "",
"exam_name": "Midterm",
"time_limit_mins": 90,
"is_proctored": true,
"is_active": true
}
"""
proctored_exam = ProctoredExam.get_exam_by_content_id(course_id, content_id)
if proctored_exam is None:
raise ProctoredExamNotFoundException
serialized_exam_object = ProctoredExamSerializer(proctored_exam)
return serialized_exam_object.data
def add_allowance_for_user(exam_id, user_id, key, value):
"""
Adds (or updates) an allowance for a user within a given exam
"""
ProctoredExamStudentAllowance.add_allowance_for_user(exam_id, user_id, key, value)
def remove_allowance_for_user(exam_id, user_id, key):
"""
Deletes an allowance for a user within a given exam.
"""
student_allowance = ProctoredExamStudentAllowance.get_allowance_for_user(exam_id, user_id, key)
if student_allowance is not None:
student_allowance.delete()
def get_exam_attempt(exam_id, user_id):
"""
Return an existing exam attempt for the given student
"""
exam_attempt_obj = ProctoredExamStudentAttempt.get_student_exam_attempt(exam_id, user_id)
return exam_attempt_obj.__dict__ if exam_attempt_obj else None
def start_exam_attempt(exam_id, user_id, external_id):
......@@ -63,12 +149,24 @@ def start_exam_attempt(exam_id, user_id, external_id):
Returns: exam_attempt_id (PK)
"""
exam_attempt_obj = ProctoredExamStudentAttempt.start_exam_attempt(exam_id, user_id, external_id)
if exam_attempt_obj is None:
raise StudentExamAttemptAlreadyExistsException
else:
return exam_attempt_obj.id
def stop_exam_attempt(exam_id, user):
def stop_exam_attempt(exam_id, user_id):
"""
Marks the exam attempt as completed (sets the completed_at field and updates the record)
"""
exam_attempt_obj = ProctoredExamStudentAttempt.get_student_exam_attempt(exam_id, user_id)
if exam_attempt_obj is None:
raise StudentExamAttemptDoesNotExistsException
else:
exam_attempt_obj.completed_at = datetime.now(pytz.UTC)
exam_attempt_obj.save()
return exam_attempt_obj.id
def get_active_exams_for_user(user_id, course_id=None):
......@@ -90,3 +188,74 @@ def get_active_exams_for_user(user_id, course_id=None):
}, {}, ...]
"""
result = []
student_active_exams = ProctoredExamStudentAttempt.get_active_student_exams(user_id, course_id)
for active_exam in student_active_exams:
# convert the django orm objects
# into the serialized form.
exam_serialized_data = ProctoredExamSerializer(active_exam.proctored_exam).data
active_exam_serialized_data = ProctoredExamStudentAttemptSerializer(active_exam).data
student_allowances = ProctoredExamStudentAllowance.get_allowances_for_user(
active_exam.proctored_exam.id, user_id
)
allowance_serialized_data = [ProctoredExamStudentAllowanceSerializer(allowance).data for allowance in
student_allowances]
result.append({
'exam': exam_serialized_data,
'attempt': active_exam_serialized_data,
'allowances': allowance_serialized_data
})
return result
def get_student_view(user_id, course_id, content_id, context):
"""
Helper method that will return the view HTML related to the exam control
flow (i.e. entering, expired, completed, etc.) If there is no specific
content to display, then None will be returned and the caller should
render it's own view
"""
has_started_exam = False
has_finished_exam = False
has_time_expired = False
student_view_template = None
exam_id = None
try:
exam = get_exam_by_content_id(course_id, content_id)
exam_id = exam['id']
except ProctoredExamNotFoundException:
exam_id = create_exam(
course_id=course_id,
content_id=unicode(content_id),
exam_name=context['display_name'],
time_limit_mins=context['default_time_limit_mins']
)
attempt = get_exam_attempt(exam_id, user_id)
has_started_exam = attempt is not None
if attempt:
now_utc = datetime.now(pytz.UTC)
expires_at = attempt['started_at'] + timedelta(minutes=context['default_time_limit_mins'])
has_time_expired = now_utc > expires_at
if not has_started_exam:
student_view_template = 'proctoring/seq_timed_exam_entrance.html'
elif has_finished_exam:
student_view_template = 'proctoring/seq_timed_exam_completed.html'
elif has_time_expired:
student_view_template = 'proctoring/seq_timed_exam_expired.html'
if student_view_template:
template = loader.get_template(student_view_template)
django_context = Context(context)
django_context.update({
'exam_id': exam_id,
'enter_exam_endpoint': reverse('edx_proctoring.proctored_exam.attempt'),
})
return template.render(django_context)
return None
"""
The custom decorators for the REST API.
"""
"""
Specialized exceptions for the Notification subsystem
"""
class ProctoredExamAlreadyExists(Exception):
"""
Raised when trying to create an Exam that already exists.
"""
class ProctoredExamNotFoundException(Exception):
"""
Raised when a look up fails.
"""
class StudentExamAttemptAlreadyExistsException(Exception):
"""
Raised when trying to start an exam when an Exam Attempt already exists.
"""
class StudentExamAttemptDoesNotExistsException(Exception):
"""
Raised when trying to stop an exam attempt where the Exam Attempt doesn't exist.
"""
......@@ -9,13 +9,13 @@ class Migration(SchemaMigration):
def forwards(self, orm):
# Adding model 'ProctoredExam'
db.create_table('edx_proctoring_proctoredexam', (
db.create_table('proctoring_proctoredexam', (
('id', self.gf('django.db.models.fields.AutoField')(primary_key=True)),
('created', self.gf('model_utils.fields.AutoCreatedField')(default=datetime.datetime.now)),
('modified', self.gf('model_utils.fields.AutoLastModifiedField')(default=datetime.datetime.now)),
('course_id', self.gf('django.db.models.fields.CharField')(max_length=255, db_index=True)),
('content_id', self.gf('django.db.models.fields.CharField')(max_length=255, db_index=True)),
('external_id', self.gf('django.db.models.fields.TextField')(null=True, db_index=True)),
('external_id', self.gf('django.db.models.fields.CharField')(max_length=255, null=True, db_index=True)),
('exam_name', self.gf('django.db.models.fields.TextField')()),
('time_limit_mins', self.gf('django.db.models.fields.IntegerField')()),
('is_proctored', self.gf('django.db.models.fields.BooleanField')(default=False)),
......@@ -24,10 +24,10 @@ class Migration(SchemaMigration):
db.send_create_signal('edx_proctoring', ['ProctoredExam'])
# Adding unique constraint on 'ProctoredExam', fields ['course_id', 'content_id']
db.create_unique('edx_proctoring_proctoredexam', ['course_id', 'content_id'])
db.create_unique('proctoring_proctoredexam', ['course_id', 'content_id'])
# Adding model 'ProctoredExamStudentAttempt'
db.create_table('edx_proctoring_proctoredexamstudentattempt', (
db.create_table('proctoring_proctoredexamstudentattempt', (
('id', self.gf('django.db.models.fields.AutoField')(primary_key=True)),
('created', self.gf('model_utils.fields.AutoCreatedField')(default=datetime.datetime.now)),
('modified', self.gf('model_utils.fields.AutoLastModifiedField')(default=datetime.datetime.now)),
......@@ -35,13 +35,13 @@ class Migration(SchemaMigration):
('proctored_exam', self.gf('django.db.models.fields.related.ForeignKey')(to=orm['edx_proctoring.ProctoredExam'])),
('started_at', self.gf('django.db.models.fields.DateTimeField')(null=True)),
('completed_at', self.gf('django.db.models.fields.DateTimeField')(null=True)),
('external_id', self.gf('django.db.models.fields.TextField')(null=True, db_index=True)),
('external_id', self.gf('django.db.models.fields.CharField')(max_length=255, null=True, db_index=True)),
('status', self.gf('django.db.models.fields.CharField')(max_length=64)),
))
db.send_create_signal('edx_proctoring', ['ProctoredExamStudentAttempt'])
# Adding model 'ProctoredExamStudentAllowance'
db.create_table('edx_proctoring_proctoredexamstudentallowance', (
db.create_table('proctoring_proctoredexamstudentallowance', (
('id', self.gf('django.db.models.fields.AutoField')(primary_key=True)),
('created', self.gf('model_utils.fields.AutoCreatedField')(default=datetime.datetime.now)),
('modified', self.gf('model_utils.fields.AutoLastModifiedField')(default=datetime.datetime.now)),
......@@ -53,10 +53,10 @@ class Migration(SchemaMigration):
db.send_create_signal('edx_proctoring', ['ProctoredExamStudentAllowance'])
# Adding unique constraint on 'ProctoredExamStudentAllowance', fields ['user_id', 'proctored_exam', 'key']
db.create_unique('edx_proctoring_proctoredexamstudentallowance', ['user_id', 'proctored_exam_id', 'key'])
db.create_unique('proctoring_proctoredexamstudentallowance', ['user_id', 'proctored_exam_id', 'key'])
# Adding model 'ProctoredExamStudentAllowanceHistory'
db.create_table('edx_proctoring_proctoredexamstudentallowancehistory', (
db.create_table('proctoring_proctoredexamstudentallowancehistory', (
('id', self.gf('django.db.models.fields.AutoField')(primary_key=True)),
('created', self.gf('model_utils.fields.AutoCreatedField')(default=datetime.datetime.now)),
('modified', self.gf('model_utils.fields.AutoLastModifiedField')(default=datetime.datetime.now)),
......@@ -71,32 +71,32 @@ class Migration(SchemaMigration):
def backwards(self, orm):
# Removing unique constraint on 'ProctoredExamStudentAllowance', fields ['user_id', 'proctored_exam', 'key']
db.delete_unique('edx_proctoring_proctoredexamstudentallowance', ['user_id', 'proctored_exam_id', 'key'])
db.delete_unique('proctoring_proctoredexamstudentallowance', ['user_id', 'proctored_exam_id', 'key'])
# Removing unique constraint on 'ProctoredExam', fields ['course_id', 'content_id']
db.delete_unique('edx_proctoring_proctoredexam', ['course_id', 'content_id'])
db.delete_unique('proctoring_proctoredexam', ['course_id', 'content_id'])
# Deleting model 'ProctoredExam'
db.delete_table('edx_proctoring_proctoredexam')
db.delete_table('proctoring_proctoredexam')
# Deleting model 'ProctoredExamStudentAttempt'
db.delete_table('edx_proctoring_proctoredexamstudentattempt')
db.delete_table('proctoring_proctoredexamstudentattempt')
# Deleting model 'ProctoredExamStudentAllowance'
db.delete_table('edx_proctoring_proctoredexamstudentallowance')
db.delete_table('proctoring_proctoredexamstudentallowance')
# Deleting model 'ProctoredExamStudentAllowanceHistory'
db.delete_table('edx_proctoring_proctoredexamstudentallowancehistory')
db.delete_table('proctoring_proctoredexamstudentallowancehistory')
models = {
'edx_proctoring.proctoredexam': {
'Meta': {'unique_together': "(('course_id', 'content_id'),)", 'object_name': '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.TextField', [], {'null': 'True', 'db_index': 'True'}),
'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'}),
......@@ -104,7 +104,7 @@ class Migration(SchemaMigration):
'time_limit_mins': ('django.db.models.fields.IntegerField', [], {})
},
'edx_proctoring.proctoredexamstudentallowance': {
'Meta': {'unique_together': "(('user_id', 'proctored_exam', 'key'),)", 'object_name': 'ProctoredExamStudentAllowance'},
'Meta': {'unique_together': "(('user_id', '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'}),
......@@ -114,7 +114,7 @@ class Migration(SchemaMigration):
'value': ('django.db.models.fields.CharField', [], {'max_length': '255'})
},
'edx_proctoring.proctoredexamstudentallowancehistory': {
'Meta': {'object_name': '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'}),
......@@ -125,10 +125,10 @@ class Migration(SchemaMigration):
'value': ('django.db.models.fields.CharField', [], {'max_length': '255'})
},
'edx_proctoring.proctoredexamstudentattempt': {
'Meta': {'object_name': 'ProctoredExamStudentAttempt'},
'Meta': {'object_name': 'ProctoredExamStudentAttempt', 'db_table': "'proctoring_proctoredexamstudentattempt'"},
'completed_at': ('django.db.models.fields.DateTimeField', [], {'null': 'True'}),
'created': ('model_utils.fields.AutoCreatedField', [], {'default': 'datetime.datetime.now'}),
'external_id': ('django.db.models.fields.TextField', [], {'null': 'True', 'db_index': 'True'}),
'external_id': ('django.db.models.fields.CharField', [], {'max_length': '255', 'null': 'True', 'db_index': 'True'}),
'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']"}),
......
"""
Data models for the proctoring subsystem
"""
import pytz
from datetime import datetime
from django.db import models
from django.db.models import Q
from django.db.models.signals import post_save, pre_delete
from django.dispatch import receiver
from model_utils.models import TimeStampedModel
......@@ -19,23 +22,48 @@ class ProctoredExam(TimeStampedModel):
content_id = models.CharField(max_length=255, db_index=True)
# This will be a integration specific ID - say to SoftwareSecure.
external_id = models.TextField(null=True, db_index=True)
external_id = models.CharField(max_length=255, null=True, db_index=True)
# This is the display name of the course
# This is the display name of the Exam (Midterm etc).
exam_name = models.TextField()
# Time limit (in minutes) that a student can finish this exam
# Time limit (in minutes) that a student can finish this exam.
time_limit_mins = models.IntegerField()
# Whether this exam actually is proctored or not
# Whether this exam actually is proctored or not.
is_proctored = models.BooleanField()
# This will be a integration specific ID - say to SoftwareSecure.
# Whether this exam will be active.
is_active = models.BooleanField()
class Meta:
""" Meta class for this Django model """
unique_together = (('course_id', 'content_id'),)
db_table = 'proctoring_proctoredexam'
@classmethod
def get_exam_by_content_id(cls, course_id, content_id):
"""
Returns the Proctored Exam if found else returns None,
Given course_id and content_id
"""
try:
proctored_exam = cls.objects.get(course_id=course_id, content_id=content_id)
except cls.DoesNotExist: # pylint: disable=no-member
proctored_exam = None
return proctored_exam
@classmethod
def get_exam_by_id(cls, exam_id):
"""
Returns the Proctored Exam if found else returns None,
Given exam_id (PK)
"""
try:
proctored_exam = cls.objects.get(id=exam_id)
except cls.DoesNotExist: # pylint: disable=no-member
proctored_exam = None
return proctored_exam
class ProctoredExamStudentAttempt(TimeStampedModel):
......@@ -52,20 +80,64 @@ class ProctoredExamStudentAttempt(TimeStampedModel):
completed_at = models.DateTimeField(null=True)
# This will be a integration specific ID - say to SoftwareSecure.
external_id = models.TextField(null=True, db_index=True)
external_id = models.CharField(max_length=255, null=True, db_index=True)
# what is the status of this attempt
status = models.CharField(max_length=64)
class Meta:
""" Meta class for this Django model """
db_table = 'proctoring_proctoredexamstudentattempt'
verbose_name = 'proctored exam attempt'
@property
def is_active(self):
""" returns boolean if this attempt is considered active """
return self.started_at and not self.completed_at
@classmethod
def start_exam_attempt(cls, exam_id, user_id, external_id):
"""
create and return an exam attempt entry for a given
exam_id. If one already exists, then returns None.
"""
if cls.get_student_exam_attempt(exam_id, user_id) is None:
return cls.objects.create(
proctored_exam_id=exam_id,
user_id=user_id,
external_id=external_id,
started_at=datetime.now(pytz.UTC)
)
else:
return None
@classmethod
def get_student_exam_attempt(cls, exam_id, user_id):
"""
Returns the Student Exam Attempt object if found
else Returns None.
"""
try:
exam_attempt_obj = cls.objects.get(proctored_exam_id=exam_id, user_id=user_id)
except cls.DoesNotExist: # pylint: disable=no-member
exam_attempt_obj = None
return exam_attempt_obj
@classmethod
def get_active_student_exams(cls, user_id, course_id=None):
"""
Returns the active student exams (user in-progress exams)
"""
filtered_query = Q(user_id=user_id) & Q(started_at__isnull=False) & Q(completed_at__isnull=True)
if course_id is not None:
filtered_query = filtered_query & Q(proctored_exam__course_id=course_id)
return cls.objects.filter(filtered_query)
class QuerySetWithUpdateOverride(models.query.QuerySet):
"""
Custom QuerySet class to send the POST_UPDATE_SIGNAL
Custom QuerySet class to make an archive copy
every time the object is updated.
"""
def update(self, **kwargs):
......@@ -76,7 +148,7 @@ class QuerySetWithUpdateOverride(models.query.QuerySet):
class ProctoredExamStudentAllowanceManager(models.Manager):
"""
Custom manager to override with the custom queryset
to enable the POST_UPDATE_SIGNAL
to enable archiving on Allowance updation.
"""
def get_query_set(self):
return QuerySetWithUpdateOverride(self.model, using=self._db)
......@@ -100,6 +172,38 @@ class ProctoredExamStudentAllowance(TimeStampedModel):
class Meta:
""" Meta class for this Django model """
unique_together = (('user_id', 'proctored_exam', 'key'),)
db_table = 'proctoring_proctoredexamstudentallowance'
verbose_name = 'proctored allowance'
@classmethod
def get_allowance_for_user(cls, exam_id, user_id, key):
"""
Returns an allowance for a user within a given exam
"""
try:
student_allowance = cls.objects.get(proctored_exam_id=exam_id, user_id=user_id, key=key)
except cls.DoesNotExist: # pylint: disable=no-member
student_allowance = None
return student_allowance
@classmethod
def get_allowances_for_user(cls, exam_id, user_id):
"""
Returns an allowances for a user within a given exam
"""
return cls.objects.filter(proctored_exam_id=exam_id, user_id=user_id)
@classmethod
def add_allowance_for_user(cls, exam_id, user_id, key, value):
"""
Add or (Update) an allowance for a user within a given exam
"""
try:
student_allowance = cls.objects.get(proctored_exam_id=exam_id, user_id=user_id, key=key)
student_allowance.value = value
student_allowance.save()
except cls.DoesNotExist: # pylint: disable=no-member
cls.objects.create(proctored_exam_id=exam_id, user_id=user_id, key=key, value=value)
class ProctoredExamStudentAllowanceHistory(TimeStampedModel):
......@@ -119,8 +223,13 @@ class ProctoredExamStudentAllowanceHistory(TimeStampedModel):
value = models.CharField(max_length=255)
class Meta:
""" Meta class for this Django model """
db_table = 'proctoring_proctoredexamstudentallowancehistory'
verbose_name = 'proctored allowance history'
# Hook up the custom POST_UPDATE_SIGNAL signal to record updations in the ProctoredExamStudentAllowanceHistory table.
# Hook up the post_save signal to record creations in the ProctoredExamStudentAllowanceHistory table.
@receiver(post_save, sender=ProctoredExamStudentAllowance)
def on_allowance_saved(sender, instance, created, **kwargs): # pylint: disable=unused-argument
"""
......
"""Defines serializers used by the Proctoring API."""
from rest_framework import serializers
from edx_proctoring.models import ProctoredExam, ProctoredExamStudentAttempt, ProctoredExamStudentAllowance
class StrictBooleanField(serializers.BooleanField):
"""
Boolean field serializer to cater for a bug in DRF BooleanField serializer
where required=True is ignored.
"""
def from_native(self, value):
if value in ('true', 't', 'True', '1'):
return True
if value in ('false', 'f', 'False', '0'):
return False
return None
class ProctoredExamSerializer(serializers.ModelSerializer):
"""
Serializer for the ProctoredExam Model.
"""
id = serializers.IntegerField(required=False)
course_id = serializers.CharField(required=True)
content_id = serializers.CharField(required=True)
external_id = serializers.CharField(required=True)
exam_name = serializers.CharField(required=True)
time_limit_mins = serializers.IntegerField(required=True)
is_active = StrictBooleanField(required=True)
is_proctored = StrictBooleanField(required=True)
class Meta:
"""
Meta Class
"""
model = ProctoredExam
fields = (
"id", "course_id", "content_id", "external_id", "exam_name",
"time_limit_mins", "is_proctored", "is_active"
)
class ProctoredExamStudentAttemptSerializer(serializers.ModelSerializer):
"""
Serializer for the ProctoredExamStudentAttempt Model.
"""
class Meta:
"""
Meta Class
"""
model = ProctoredExamStudentAttempt
fields = (
"id", "created", "modified", "user_id", "started_at", "completed_at",
"external_id", "status"
)
class ProctoredExamStudentAllowanceSerializer(serializers.ModelSerializer):
"""
Serializer for the ProctoredExamStudentAllowance Model.
"""
class Meta:
"""
Meta Class
"""
model = ProctoredExamStudentAllowance
fields = (
"id", "created", "modified", "user_id", "key", "value"
)
"""
A wrapper class around all methods exposed in api.py
"""
from edx_proctoring import api as edx_proctoring_api
import types
class ProctoringService(object):
"""
An xBlock service for xBlocks to talk to the Proctoring subsystem. This class basically introspects
and exposes all functions in the api libraries, so it is a direct pass through.
NOTE: This is a Singleton class. We should only have one instance of it!
"""
_instance = None
def __new__(cls, *args, **kwargs):
"""
This is the class factory to make sure this is a Singleton
"""
if not cls._instance:
cls._instance = super(ProctoringService, cls).__new__(cls, *args, **kwargs)
return cls._instance
def __init__(self):
"""
Class initializer, which just inspects the libraries and exposes the same functions
as a direct pass through
"""
self._bind_to_module_functions(edx_proctoring_api)
def _bind_to_module_functions(self, module):
"""
bind module functions. Since we use underscores to mean private methods, let's exclude those.
"""
for attr_name in dir(module):
attr = getattr(module, attr_name, None)
if isinstance(attr, types.FunctionType) and not attr_name.startswith('_'):
if not hasattr(self, attr_name):
setattr(self, attr_name, attr)
$(function() {
var proctored_exam_view = new edx.coursware.proctored_exam.ProctoredExamView({
el: $(".proctored_exam_status"),
proctored_template: '#proctored-exam-status-tpl',
model: new ProctoredExamModel()
});
proctored_exam_view.render();
});
(function(Backbone) {
var ProctoredExamModel = Backbone.Model.extend({
/* we should probably pull this from a data attribute on the HTML */
url: '/api/edx_proctoring/v1/proctored_exam/attempt',
defaults: {
in_timed_exam: false,
is_proctored: false,
exam_display_name: '',
exam_url_path: '',
time_remaining_seconds: 0,
low_threshold: 0,
critically_low_threshold: 0,
lastFetched: new Date()
},
getRemainingSeconds: function () {
var currentTime = (new Date()).getTime();
var lastFetched = this.get('lastFetched').getTime();
var totalSeconds = this.get('time_remaining_seconds') - (currentTime - lastFetched) / 1000;
return (totalSeconds > 0) ? totalSeconds : 0;
},
getFormattedRemainingTime: function () {
var totalSeconds = this.getRemainingSeconds();
var hours = parseInt(totalSeconds / 3600) % 24;
var minutes = parseInt(totalSeconds / 60) % 60;
var seconds = Math.floor(totalSeconds % 60);
return hours + ":" + (minutes < 10 ? "0" + minutes : minutes)
+ ":" + (seconds < 10 ? "0" + seconds : seconds);
},
getRemainingTimeState: function () {
var totalSeconds = this.getRemainingSeconds();
if (totalSeconds > this.get('low_threshold')) {
return "";
}
else if (totalSeconds <= this.get('low_threshold') && totalSeconds > this.get('critically_low_threshold')) {
return "low-time warning";
}
else {
return "low-time critical";
}
}
});
this.ProctoredExamModel = ProctoredExamModel;
}).call(this, Backbone);
var edx = edx || {};
(function(Backbone, $, _) {
'use strict';
edx.coursware = edx.coursware || {};
edx.coursware.proctored_exam = {};
edx.coursware.proctored_exam.ProctoredExamView = Backbone.View.extend({
initialize: function (options) {
this.$el = options.el;
this.model = options.model;
this.templateId = options.proctored_template;
this.template = null;
this.timerId = null;
var template_html = $(this.templateId).text();
if (template_html !== null) {
/* don't assume this backbone view is running on a page with the underscore templates */
this.template = _.template(template_html);
}
/* re-render if the model changes */
this.listenTo(this.model,'change', this.modelChanged);
/* make the async call to the backend REST API */
/* after it loads, the listenTo event will file and */
/* will call into the rendering */
this.model.fetch();
},
modelChanged: function() {
this.render();
},
render: function () {
if (this.template !== null) {
if (this.model.get('in_timed_exam') && this.model.get('time_remaining_seconds') > 0) {
var html = this.template(this.model.toJSON());
this.$el.html(html);
this.$el.show();
this.updateRemainingTime(this);
this.timerId = setInterval(this.updateRemainingTime, 1000, this);
}
}
return this;
},
updateRemainingTime: function (self) {
self.$el.find('div.exam-timer').removeClass("low-time warning critical");
self.$el.find('div.exam-timer').addClass(self.model.getRemainingTimeState());
self.$el.find('span#time_remaining_id b').html(self.model.getFormattedRemainingTime());
if (self.model.getRemainingSeconds() <= 0) {
clearInterval(self.timerId); // stop the timer once the time finishes.
// refresh the page when the timer expired
location.reload();
}
}
});
this.edx.coursware.proctored_exam.ProctoredExamView = edx.coursware.proctored_exam.ProctoredExamView;
}).call(this, Backbone, $, _);
define(['jquery', 'backbone', 'common/js/spec_helpers/template_helpers', 'js/courseware/base/models/proctored_exam_model', 'js/courseware/base/views/proctored_exam_view'
], function($, Backbone, TemplateHelpers, ProctoredExamModel, ProctoredExamView) {
'use strict';
describe('Proctored Exam', function () {
beforeEach(function () {
this.model = new ProctoredExamModel();
});
it('model has properties', function () {
expect(this.model.get('in_timed_exam')).toBeDefined();
expect(this.model.get('is_proctored')).toBeDefined();
expect(this.model.get('exam_display_name')).toBeDefined();
expect(this.model.get('exam_url_path')).toBeDefined();
expect(this.model.get('time_remaining_seconds')).toBeDefined();
expect(this.model.get('low_threshold')).toBeDefined();
expect(this.model.get('critically_low_threshold')).toBeDefined();
expect(this.model.get('lastFetched')).toBeDefined();
});
});
describe('ProctoredExamView', function () {
beforeEach(function () {
TemplateHelpers.installTemplate('templates/courseware/proctored-exam-status', true, 'proctored-exam-status-tpl');
appendSetFixtures('<div class="proctored_exam_status"></div>');
this.model = new ProctoredExamModel({
in_timed_exam: true,
is_proctored: true,
exam_display_name: 'Midterm',
exam_url_path: '/test_url',
time_remaining_seconds: 45, //2 * 60 + 15,
low_threshold: 30,
critically_low_threshold: 15,
lastFetched: new Date()
});
this.proctored_exam_view = new edx.coursware.proctored_exam.ProctoredExamView(
{
model: this.model,
el: $(".proctored_exam_status"),
proctored_template: '#proctored-exam-status-tpl'
}
);
this.proctored_exam_view.render();
});
it('renders items correctly', function () {
expect(this.proctored_exam_view.$el.find('a')).toHaveAttr('href', this.model.get("exam_url_path"));
expect(this.proctored_exam_view.$el.find('a')).toContainHtml(this.model.get('exam_display_name'));
});
it('changes behavior when clock time decreases low threshold', function () {
spyOn(this.model, 'getRemainingSeconds').andCallFake(function () {
return 25;
});
expect(this.model.getRemainingSeconds()).toEqual(25);
expect(this.proctored_exam_view.$el.find('div.exam-timer')).not.toHaveClass('low-time warning');
this.proctored_exam_view.render();
expect(this.proctored_exam_view.$el.find('div.exam-timer')).toHaveClass('low-time warning');
});
it('changes behavior when clock time decreases critically low threshold', function () {
spyOn(this.model, 'getRemainingSeconds').andCallFake(function () {
return 5;
});
expect(this.model.getRemainingSeconds()).toEqual(5);
expect(this.proctored_exam_view.$el.find('div.exam-timer')).not.toHaveClass('low-time critical');
this.proctored_exam_view.render();
expect(this.proctored_exam_view.$el.find('div.exam-timer')).toHaveClass('low-time critical');
});
});
});
<div class="exam-timer">
<%- gettext("You are taking") %>
<a href="<%- interpolate(gettext('%(exam_url_path)s'), { exam_url_path: exam_url_path }, true)%>" >
<%- interpolate(gettext(' %(exam_display_name)s '), { exam_display_name: exam_display_name }, true) %>
</a>
<%- gettext(" exam as a proctored exam. Good Luck!") %>
<span id="time_remaining_id" class="pull-right">
<b>
</b>
</span>
</div>
<div class="sequence" >
<div class="gated-sequence">
All done!
</div>
</div>
<div class="sequence" data-exam-id="{{exam_id}}">
<div class="gated-sequence">
This is a timed exam. Would you like to <a class='start-timed-exam' data-ajax-url="{{enter_exam_endpoint}}">enter</a> it?
</div>
</div>
<script type="text/javascript">
$('.start-timed-exam').click(
function(event) {
var target = $(event.target);
var action_url = target.data('ajax-url');
var exam_id = target.parent().parent().data('exam-id');
$.post(
action_url,
{
"exam_id": exam_id,
},
function(data) {
// reload the page, because we've unlocked it
location.reload();
}
);
}
);
</script>
<div class="sequence">
<div class="gated-sequence">
You have run out of time!
</div>
</div>
"""
All tests for the models.py
"""
from datetime import datetime
import pytz
from edx_proctoring.api import create_exam, update_exam, get_exam_by_id, get_exam_by_content_id, \
add_allowance_for_user, remove_allowance_for_user, start_exam_attempt, stop_exam_attempt, get_active_exams_for_user
from edx_proctoring.exceptions import ProctoredExamAlreadyExists, ProctoredExamNotFoundException, \
StudentExamAttemptAlreadyExistsException, StudentExamAttemptDoesNotExistsException
from edx_proctoring.models import ProctoredExam, ProctoredExamStudentAllowance, ProctoredExamStudentAttempt
from .utils import (
LoggedInTestCase
)
class ProctoredExamApiTests(LoggedInTestCase):
"""
All tests for the models.py
"""
def setUp(self):
"""
Build out test harnessing
"""
super(ProctoredExamApiTests, self).setUp()
self.default_time_limit = 21
self.course_id = 'test_course'
self.content_id = 'test_content_id'
self.exam_name = 'Test Exam'
self.user_id = 1
self.key = 'Test Key'
self.value = 'Test Value'
self.external_id = 'test_external_id'
self.proctored_exam_id = self._create_proctored_exam()
def _create_proctored_exam(self):
"""
Calls the api's create_exam to create an exam object.
"""
return create_exam(
course_id=self.course_id,
content_id=self.content_id,
exam_name=self.exam_name,
time_limit_mins=self.default_time_limit
)
def _create_student_exam_attempt(self):
"""
Creates the ProctoredExamStudentAttempt object.
"""
return ProctoredExamStudentAttempt.objects.create(
proctored_exam_id=self.proctored_exam_id,
user_id=self.user_id,
external_id=self.external_id,
started_at=datetime.now(pytz.UTC)
)
def _add_allowance_for_user(self):
"""
creates allowance for user.
"""
return ProctoredExamStudentAllowance.objects.create(
proctored_exam_id=self.proctored_exam_id, user_id=self.user_id, key=self.key, value=self.value
)
def test_create_duplicate_exam(self):
"""
Test to create a proctored exam that has already exist in the
database and will throw an exception ProctoredExamAlreadyExists.
"""
with self.assertRaises(ProctoredExamAlreadyExists):
self._create_proctored_exam()
def test_update_proctored_exam(self):
"""
test update the existing proctored exam
"""
updated_proctored_exam_id = update_exam(
self.proctored_exam_id, exam_name='Updated Exam Name', time_limit_mins=30,
is_proctored=True, external_id='external_id', is_active=True
)
# only those fields were updated, whose
# values are passed.
self.assertEqual(self.proctored_exam_id, updated_proctored_exam_id)
update_proctored_exam = ProctoredExam.objects.get(id=updated_proctored_exam_id)
self.assertEqual(update_proctored_exam.exam_name, 'Updated Exam Name')
self.assertEqual(update_proctored_exam.time_limit_mins, 30)
self.assertEqual(update_proctored_exam.course_id, 'test_course')
self.assertEqual(update_proctored_exam.content_id, 'test_content_id')
def test_update_non_existing_exam(self):
"""
test to update the non-existing proctored exam
which will throw the exception
"""
with self.assertRaises(ProctoredExamNotFoundException):
update_exam(2, exam_name='Updated Exam Name', time_limit_mins=30)
def test_get_proctored_exam(self):
"""
test to get the exam by the exam_id and
then compare their values.
"""
proctored_exam = get_exam_by_id(self.proctored_exam_id)
self.assertEqual(proctored_exam['course_id'], self.course_id)
self.assertEqual(proctored_exam['content_id'], self.content_id)
self.assertEqual(proctored_exam['exam_name'], self.exam_name)
proctored_exam = get_exam_by_content_id(self.course_id, self.content_id)
self.assertEqual(proctored_exam['course_id'], self.course_id)
self.assertEqual(proctored_exam['content_id'], self.content_id)
self.assertEqual(proctored_exam['exam_name'], self.exam_name)
def test_get_invalid_proctored_exam(self):
"""
test to get the exam by the invalid exam_id which will
raises exception
"""
with self.assertRaises(ProctoredExamNotFoundException):
get_exam_by_id(2)
with self.assertRaises(ProctoredExamNotFoundException):
get_exam_by_content_id('teasd', 'tewasda')
def test_add_allowance_for_user(self):
"""
Test to add allowance for user.
"""
add_allowance_for_user(self.proctored_exam_id, self.user_id, self.key, self.value)
student_allowance = ProctoredExamStudentAllowance.get_allowance_for_user(
self.proctored_exam_id, self.user_id, self.key
)
self.assertIsNotNone(student_allowance)
def test_update_existing_allowance(self):
"""
Test updation to the allowance that already exists.
"""
student_allowance = self._add_allowance_for_user()
add_allowance_for_user(student_allowance.proctored_exam.id, self.user_id, self.key, 'new_value')
student_allowance = ProctoredExamStudentAllowance.get_allowance_for_user(
student_allowance.proctored_exam.id, self.user_id, self.key
)
self.assertIsNotNone(student_allowance)
self.assertEqual(student_allowance.value, 'new_value')
def test_get_non_existing_allowance(self):
"""
Test to get an allowance which does not exist.
"""
student_allowance = ProctoredExamStudentAllowance.get_allowance_for_user(
self.proctored_exam_id, self.user_id, self.key
)
self.assertIsNone(student_allowance)
def test_remove_allowance_for_user(self):
"""
Test to remove an allowance for user.
"""
student_allowance = self._add_allowance_for_user()
self.assertEqual(len(ProctoredExamStudentAllowance.objects.filter()), 1)
remove_allowance_for_user(student_allowance.proctored_exam.id, self.user_id, self.key)
self.assertEqual(len(ProctoredExamStudentAllowance.objects.filter()), 0)
def test_start_an_exam_attempt(self):
"""
Start an exam attempt.
"""
attempt_id = start_exam_attempt(self.proctored_exam_id, self.user_id, self.external_id)
self.assertGreater(attempt_id, 0)
def test_restart_exam_attempt(self):
"""
Start an exam attempt that has already been started.
Raises StudentExamAttemptAlreadyExistsException
"""
proctored_exam_student_attempt = self._create_student_exam_attempt()
with self.assertRaises(StudentExamAttemptAlreadyExistsException):
start_exam_attempt(proctored_exam_student_attempt.proctored_exam, self.user_id, self.external_id)
def test_stop_exam_attempt(self):
"""
Stop an exam attempt.
"""
proctored_exam_student_attempt = self._create_student_exam_attempt()
self.assertIsNone(proctored_exam_student_attempt.completed_at)
proctored_exam_attempt_id = stop_exam_attempt(
proctored_exam_student_attempt.proctored_exam, self.user_id
)
self.assertEqual(proctored_exam_student_attempt.id, proctored_exam_attempt_id)
def test_stop_a_non_started_exam(self):
"""
Stop an exam attempt that had not started yet.
"""
with self.assertRaises(StudentExamAttemptDoesNotExistsException):
stop_exam_attempt(self.proctored_exam_id, self.user_id)
def test_get_active_exams_for_user(self):
"""
Test to get the all the active
exams for the user.
"""
active_exam_attempt = self._create_student_exam_attempt()
self.assertEqual(active_exam_attempt.is_active, True)
exam_id = create_exam(
course_id=self.course_id,
content_id='test_content_2',
exam_name='Final Test Exam',
time_limit_mins=self.default_time_limit
)
start_exam_attempt(
exam_id=exam_id,
user_id=self.user_id,
external_id=self.external_id
)
add_allowance_for_user(self.proctored_exam_id, self.user_id, self.key, self.value)
add_allowance_for_user(self.proctored_exam_id, self.user_id, 'new_key', 'new_value')
student_active_exams = get_active_exams_for_user(self.user_id, self.course_id)
self.assertEqual(len(student_active_exams), 2)
self.assertEqual(len(student_active_exams[0]['allowances']), 2)
self.assertEqual(len(student_active_exams[1]['allowances']), 0)
"""
Tests for the custom StrictBooleanField serializer used by the ProctoredExamSerializer
"""
import unittest
from edx_proctoring.serializers import ProctoredExamSerializer
class TestProctoredExamSerializer(unittest.TestCase):
"""
Tests for ProctoredExamSerializer
"""
def test_boolean_fields(self):
"""
Tests the boolean fields. Should cause a validation error in case a field is required.
"""
data = {
'id': "123",
'course_id': "a/b/c",
'exam_name': "midterm1",
'content_id': '123aXqe0',
'time_limit_mins': 90,
'external_id': '123',
'is_proctored': 'bla',
'is_active': 'f'
}
serializer = ProctoredExamSerializer(data=data)
self.assertFalse(serializer.is_valid())
self.assertDictEqual(
{'is_proctored': [u'This field is required.']}, serializer.errors
)
"""
Test for the xBlock service
"""
import unittest
from edx_proctoring.services import ProctoringService
from edx_proctoring import api as edx_proctoring_api
import types
class TestProctoringService(unittest.TestCase):
"""
Tests for ProctoringService
"""
def test_basic(self):
"""
See if the ProctoringService exposes the expected methods
"""
service = ProctoringService()
for attr_name in dir(edx_proctoring_api):
attr = getattr(edx_proctoring_api, attr_name, None)
if isinstance(attr, types.FunctionType) and not attr_name.startswith('_'):
self.assertTrue(hasattr(service, attr_name))
"""
All tests for the proctored_exams.py
"""
import json
from datetime import datetime
from django.test.client import Client
from django.core.urlresolvers import reverse, NoReverseMatch
import pytz
from edx_proctoring.models import ProctoredExam, ProctoredExamStudentAttempt, ProctoredExamStudentAllowance
from edx_proctoring.views import require_staff
from django.contrib.auth.models import User
from .utils import (
LoggedInTestCase
)
from mock import Mock
from edx_proctoring.urls import urlpatterns
......@@ -26,25 +33,563 @@ class ProctoredExamsApiTests(LoggedInTestCase):
"""
Make sure we cannot access any API methods without being logged in
"""
self.client = Client() # use AnonymousUser on the API calls
for urlpattern in urlpatterns:
if hasattr(urlpattern, 'name'):
try:
response = self.client.get(reverse(urlpattern.name))
except NoReverseMatch:
# some of our URL mappings may require a argument substitution
response = self.client.get(reverse(urlpattern.name, args=[0]))
try:
response = self.client.get(reverse(urlpattern.name, args=[0]))
except NoReverseMatch:
# some require 2 args.
response = self.client.get(reverse(urlpattern.name, args=["0/0/0", 0]))
self.assertEqual(response.status_code, 403)
def test_get_proctored_exam_status(self):
class StudentProctoredExamAttempt(LoggedInTestCase):
"""
Tests for StudentProctoredExamAttempt
"""
def setUp(self):
super(StudentProctoredExamAttempt, self).setUp()
self.user.is_staff = True
self.user.save()
self.client.login_user(self.user)
def test_get_exam_attempt(self):
"""
Test Case for retrieving student proctored exam attempt status.
"""
response = self.client.get(
reverse('edx_proctoring.proctored_exam.attempt')
)
self.assertEqual(response.status_code, 200)
class ProctoredExamViewTests(LoggedInTestCase):
"""
Tests for the ProctoredExamView
"""
def setUp(self):
super(ProctoredExamViewTests, self).setUp()
self.user.is_staff = True
self.user.save()
self.client.login_user(self.user)
def test_create_exam(self):
"""
Test the POST method of the exam endpoint to create an exam.
"""
exam_data = {
'course_id': "a/b/c",
'exam_name': "midterm1",
'content_id': '123aXqe0',
'time_limit_mins': 90,
'external_id': '123',
'is_proctored': True,
'is_active': True
}
response = self.client.post(
reverse('edx_proctoring.proctored_exam.exam'),
exam_data
)
self.assertEqual(response.status_code, 200)
response_data = json.loads(response.content)
self.assertGreater(response_data['exam_id'], 0)
# Now lookup the exam by giving the exam_id returned and match the data.
response = self.client.get(
reverse('edx_proctoring.proctored_exam.exam_by_id', kwargs={'exam_id': response_data['exam_id']})
)
self.assertEqual(response.status_code, 200)
response_data = json.loads(response.content)
self.assertEqual(response_data['course_id'], exam_data['course_id'])
self.assertEqual(response_data['exam_name'], exam_data['exam_name'])
self.assertEqual(response_data['content_id'], exam_data['content_id'])
self.assertEqual(response_data['external_id'], exam_data['external_id'])
self.assertEqual(response_data['time_limit_mins'], exam_data['time_limit_mins'])
def test_create_duplicate_exam(self):
"""
Tests the POST method error handling if a duplicate exam is created.
"""
exam_data = {
'course_id': "a/b/c",
'exam_name': "midterm1",
'content_id': '123aXqe0',
'time_limit_mins': 90,
'external_id': '123',
'is_proctored': True,
'is_active': True
}
response = self.client.post(
reverse('edx_proctoring.proctored_exam.exam'),
exam_data
)
self.assertEqual(response.status_code, 200)
response_data = json.loads(response.content)
self.assertGreater(response_data['exam_id'], 0)
response = self.client.post(
reverse('edx_proctoring.proctored_exam.exam'),
exam_data
)
self.assertEqual(response.status_code, 400)
def test_update_existing_exam(self):
"""
Test the PUT method of the exam endpoint to update an existing exam.
"""
proctored_exam = ProctoredExam.objects.create(
course_id='a/b/c',
content_id='123aXqe0',
exam_name='Test Exam',
external_id='123aXqe3',
time_limit_mins=90
)
exam_id = proctored_exam.id
updated_exam_data = {
'exam_id': exam_id,
'exam_name': "midterm1",
'time_limit_mins': 90,
'external_id': '123',
'is_proctored': True,
'is_active': True
}
response = self.client.put(
reverse('edx_proctoring.proctored_exam.exam'),
updated_exam_data
)
self.assertEqual(response.status_code, 200)
response_data = json.loads(response.content)
self.assertEqual(response_data['exam_id'], exam_id)
# Now lookup the exam by giving the exam_id returned and match the data.
response = self.client.get(
reverse('edx_proctoring.proctored_exam.exam_by_id', kwargs={'exam_id': response_data['exam_id']})
)
self.assertEqual(response.status_code, 200)
response_data = json.loads(response.content)
self.assertEqual(response_data['course_id'], proctored_exam.course_id)
self.assertEqual(response_data['content_id'], proctored_exam.content_id)
self.assertEqual(response_data['exam_name'], updated_exam_data['exam_name'])
self.assertEqual(response_data['external_id'], updated_exam_data['external_id'])
self.assertEqual(response_data['time_limit_mins'], updated_exam_data['time_limit_mins'])
def test_decorator_staff_user(self):
"""
Test assert require_staff before hitting any api url.
"""
func = Mock()
decorated_func = require_staff(func)
request = self.mock_request()
response = decorated_func(request)
self.assertEqual(response.status_code, 403)
self.assertFalse(func.called)
def mock_request(self):
"""
mock request
"""
request = Mock()
self.user.is_staff = False
self.user.save()
request.user = self.user
return request
def test_update_non_existing_exam(self):
"""
Test the PUT method of the exam endpoint to update an existing exam.
In case the exam_id is not found, it should return a bad request.
"""
exam_id = 99999
updated_exam_data = {
'exam_id': exam_id,
'exam_name': "midterm1",
'time_limit_mins': 90,
'external_id': '123',
'is_proctored': True,
'is_active': True
}
response = self.client.put(
reverse('edx_proctoring.proctored_exam.exam'),
updated_exam_data
)
self.assertEqual(response.status_code, 400)
response_data = json.loads(response.content)
self.assertEqual(response_data, {'detail': 'The exam_id does not exist.'})
def test_get_exam_by_id(self):
"""
Tests the Get Exam by id endpoint
"""
Test Case for retrieving student proctored exam status.
# Create an exam.
proctored_exam = ProctoredExam.objects.create(
course_id='test_course',
content_id='test_content',
exam_name='Test Exam',
external_id='123aXqe3',
time_limit_mins=90
)
response = self.client.get(
reverse('edx_proctoring.proctored_exam.exam_by_id', kwargs={'exam_id': proctored_exam.id})
)
self.assertEqual(response.status_code, 200)
response_data = json.loads(response.content)
self.assertEqual(response_data['course_id'], proctored_exam.course_id)
self.assertEqual(response_data['exam_name'], proctored_exam.exam_name)
self.assertEqual(response_data['content_id'], proctored_exam.content_id)
self.assertEqual(response_data['external_id'], proctored_exam.external_id)
self.assertEqual(response_data['time_limit_mins'], proctored_exam.time_limit_mins)
def test_get_exam_by_bad_id(self):
"""
Tests the Get Exam by id endpoint
"""
# Create an exam.
response = self.client.get(
reverse('edx_proctoring.proctored_exam.exam_by_id', kwargs={'exam_id': 99999})
)
self.assertEqual(response.status_code, 400)
response_data = json.loads(response.content)
self.assertEqual(response_data['detail'], 'The exam_id does not exist.')
def test_get_exam_by_content_id(self):
"""
Tests the Get Exam by content id endpoint
"""
# 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
)
response = self.client.get(
reverse('edx_proctoring.proctored_exam.exam_by_content_id', kwargs={
'course_id': proctored_exam.course_id,
'content_id': proctored_exam.content_id
})
)
self.assertEqual(response.status_code, 200)
response_data = json.loads(response.content)
self.assertEqual(response_data['course_id'], proctored_exam.course_id)
self.assertEqual(response_data['exam_name'], proctored_exam.exam_name)
self.assertEqual(response_data['content_id'], proctored_exam.content_id)
self.assertEqual(response_data['external_id'], proctored_exam.external_id)
self.assertEqual(response_data['time_limit_mins'], proctored_exam.time_limit_mins)
def test_get_exam_by_bad_content_id(self):
"""
Tests the Get Exam by content id endpoint
"""
# 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
)
response = self.client.get(
reverse('edx_proctoring.proctored_exam.exam_by_content_id', kwargs={
'course_id': 'c/d/e',
'content_id': proctored_exam.content_id
})
)
self.assertEqual(response.status_code, 400)
response_data = json.loads(response.content)
self.assertEqual(response_data['detail'], 'The exam with course_id, content_id does not exist.')
def test_get_exam_insufficient_args(self):
"""
Tests the Get Exam by content id endpoint
"""
# 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
)
response = self.client.get(
reverse('edx_proctoring.proctored_exam.exam_by_content_id', kwargs={
'course_id': proctored_exam.course_id,
'content_id': proctored_exam.content_id
})
)
self.assertEqual(response.status_code, 200)
response_data = json.loads(response.content)
self.assertEqual(response_data['course_id'], proctored_exam.course_id)
self.assertEqual(response_data['exam_name'], proctored_exam.exam_name)
self.assertEqual(response_data['content_id'], proctored_exam.content_id)
self.assertEqual(response_data['external_id'], proctored_exam.external_id)
self.assertEqual(response_data['time_limit_mins'], proctored_exam.time_limit_mins)
class TestStudentProctoredExamAttempt(LoggedInTestCase):
"""
Tests for the StudentProctoredExamAttempt
"""
def setUp(self):
super(TestStudentProctoredExamAttempt, self).setUp()
self.user.is_staff = True
self.user.save()
self.client.login_user(self.user)
self.student_taking_exam = User()
self.student_taking_exam.save()
def test_start_exam_attempt(self):
"""
Start an exam (create an exam attempt)
"""
# 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,
'user_id': self.student_taking_exam.id,
'external_id': proctored_exam.external_id
}
response = self.client.post(
reverse('edx_proctoring.proctored_exam.attempt'),
attempt_data
)
self.assertEqual(response.status_code, 200)
response_data = json.loads(response.content)
self.assertGreater(response_data['exam_attempt_id'], 0)
def test_restart_exam_attempt(self):
"""
Start an exam that has already started should raise error.
"""
# 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,
'user_id': self.student_taking_exam.id,
'external_id': proctored_exam.external_id
}
response = self.client.post(
reverse('edx_proctoring.proctored_exam.attempt'),
attempt_data
)
self.assertEqual(response.status_code, 200)
response_data = json.loads(response.content)
self.assertGreater(response_data['exam_attempt_id'], 0)
response = self.client.post(
reverse('edx_proctoring.proctored_exam.attempt'),
attempt_data
)
self.assertEqual(response.status_code, 400)
response_data = json.loads(response.content)
self.assertEqual(response_data['detail'], 'Error. Trying to start an exam that has already started.')
def test_stop_exam_attempt(self):
"""
Start an exam (create an exam attempt)
"""
# 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,
'user_id': self.student_taking_exam.id,
'external_id': proctored_exam.external_id
}
response = self.client.post(
reverse('edx_proctoring.proctored_exam.attempt'),
attempt_data
)
self.assertEqual(response.status_code, 200)
response_data = json.loads(response.content)
self.assertGreater(response_data['exam_attempt_id'], 0)
old_attempt_id = response_data['exam_attempt_id']
stop_attempt_data = {
'exam_id': proctored_exam.id,
'user_id': self.student_taking_exam.id
}
response = self.client.put(
reverse('edx_proctoring.proctored_exam.attempt'),
stop_attempt_data
)
self.assertEqual(response.status_code, 200)
response_data = json.loads(response.content)
self.assertEqual(response_data['exam_attempt_id'], old_attempt_id)
def test_stop_unstarted_attempt(self):
"""
Start an exam (create an exam attempt)
"""
# Create an exam.
attempt_data = {
'exam_id': 999999,
'user_id': self.student_taking_exam.id,
'external_id': "123456"
}
response = self.client.put(
reverse('edx_proctoring.proctored_exam.attempt'),
attempt_data
)
self.assertEqual(response.status_code, 400)
response_data = json.loads(response.content)
self.assertEqual(response_data['detail'], 'Error. Trying to stop an exam that is not in progress.')
class TestExamAllowanceView(LoggedInTestCase):
"""
Tests for the ExamAllowanceView
"""
def setUp(self):
super(TestExamAllowanceView, self).setUp()
self.user.is_staff = True
self.user.save()
self.client.login_user(self.user)
self.student_taking_exam = User()
self.student_taking_exam.save()
def test_add_allowance_for_user(self):
"""
Add allowance for a user for an exam.
"""
# 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
)
allowance_data = {
'exam_id': proctored_exam.id,
'user_id': self.student_taking_exam.id,
'key': 'a_key',
'value': '30'
}
response = self.client.put(
reverse('edx_proctoring.proctored_exam.allowance'),
allowance_data
)
self.assertEqual(response.status_code, 200)
def test_remove_allowance_for_user(self):
"""
Remove allowance for a user for an exam.
"""
# 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
)
allowance_data = {
'exam_id': proctored_exam.id,
'user_id': self.student_taking_exam.id,
'key': 'a_key',
'value': '30'
}
response = self.client.put(
reverse('edx_proctoring.proctored_exam.allowance'),
allowance_data
)
self.assertEqual(response.status_code, 200)
allowance_data.pop('value')
response = self.client.delete(
reverse('edx_proctoring.proctored_exam.allowance'),
allowance_data
)
self.assertEqual(response.status_code, 200)
class TestActiveExamsForUserView(LoggedInTestCase):
"""
Tests for the ActiveExamsForUserView
"""
def setUp(self):
super(TestActiveExamsForUserView, self).setUp()
self.user.is_staff = True
self.user.save()
self.client.login_user(self.user)
self.student_taking_exam = User()
self.student_taking_exam.save()
def test_get_active_exams_for_user(self):
"""
Test to get all the exams the user is currently taking.
"""
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
)
ProctoredExamStudentAttempt.objects.create(
proctored_exam_id=proctored_exam.id,
user_id=self.student_taking_exam.id,
external_id='123aXqe3',
started_at=datetime.now(pytz.UTC)
)
ProctoredExamStudentAllowance.objects.create(
proctored_exam_id=proctored_exam.id,
user_id=self.student_taking_exam.id,
key='a_key',
value="30"
)
exams_query_data = {
'user_id': self.student_taking_exam.id,
'course_id': proctored_exam.course_id
}
response = self.client.get(
reverse('edx_proctoring.proctored_exam.status')
reverse('edx_proctoring.proctored_exam.active_exams_for_user'),
exams_query_data
)
self.assertEqual(response.status_code, 200)
......@@ -2,15 +2,42 @@
URL mappings for edX Proctoring Server.
"""
from edx_proctoring import views
from django.conf import settings
from django.conf.urls import patterns, url, include
urlpatterns = patterns( # pylint: disable=invalid-name
'',
url(
r'edx_proctoring/v1/proctored_exam/status$',
views.StudentProctoredExamStatus.as_view(),
name='edx_proctoring.proctored_exam.status'
r'edx_proctoring/v1/proctored_exam/exam$',
views.ProctoredExamView.as_view(),
name='edx_proctoring.proctored_exam.exam'
),
url(
r'edx_proctoring/v1/proctored_exam/exam/exam_id/(?P<exam_id>\d+)$',
views.ProctoredExamView.as_view(),
name='edx_proctoring.proctored_exam.exam_by_id'
),
url(
r'edx_proctoring/v1/proctored_exam/exam/course_id/{}/content_id/(?P<content_id>[A-z0-9]+)$'.format(
settings.COURSE_ID_PATTERN),
views.ProctoredExamView.as_view(),
name='edx_proctoring.proctored_exam.exam_by_content_id'
),
url(
r'edx_proctoring/v1/proctored_exam/attempt$',
views.StudentProctoredExamAttempt.as_view(),
name='edx_proctoring.proctored_exam.attempt'
),
url(
r'edx_proctoring/v1/proctored_exam/allowance$',
views.ExamAllowanceView.as_view(),
name='edx_proctoring.proctored_exam.allowance'
),
url(
r'edx_proctoring/v1/proctored_exam/active_exams_for_user$',
views.ActiveExamsForUserView.as_view(),
name='edx_proctoring.proctored_exam.active_exams_for_user'
),
url(r'^', include('rest_framework.urls', namespace='rest_framework'))
)
......@@ -3,33 +3,319 @@ Proctored Exams HTTP-based API endpoints
"""
import logging
import pytz
from datetime import datetime, timedelta
from django.utils.decorators import method_decorator
from rest_framework import status
from rest_framework.response import Response
from edx_proctoring.api import (
create_exam,
update_exam,
get_exam_by_id,
get_exam_by_content_id,
start_exam_attempt,
stop_exam_attempt,
add_allowance_for_user,
remove_allowance_for_user,
get_active_exams_for_user
)
from edx_proctoring.exceptions import ProctoredExamNotFoundException, \
StudentExamAttemptAlreadyExistsException, StudentExamAttemptDoesNotExistsException
from edx_proctoring.serializers import ProctoredExamSerializer
from .utils import AuthenticatedAPIView
LOG = logging.getLogger("edx_proctoring_views")
class StudentProctoredExamStatus(AuthenticatedAPIView):
def require_staff(func):
"""View decorator that requires that the user have staff permissions. """
def wrapped(request, *args, **kwargs): # pylint: disable=missing-docstring
if request.user.is_staff:
return func(request, *args, **kwargs)
else:
return Response(
status=status.HTTP_403_FORBIDDEN,
data={"detail": "Must be a Staff User to Perform this request."}
)
return wrapped
class ProctoredExamView(AuthenticatedAPIView):
"""
Endpoint for the Proctored Exams
/edx_proctoring/v1/proctored_exam/exam
Supports:
HTTP POST: Creates a new Exam.
HTTP PUT: Updates an existing Exam.
HTTP GET: Returns an existing exam (by id or by content id)
HTTP POST
Creates a new Exam.
Expected POST data: {
"course_id": "edX/DemoX/Demo_Course",
"content_id": 123,
"exam_name": "Midterm",
"time_limit_mins": 90,
"is_proctored": true,
"external_id": "",
"is_active": true
}
**POST data Parameters**
* course_id: The unique identifier for the course.
* content_id: This will be the pointer to the id of the piece of course_ware which is the proctored exam.
* exam_name: This is the display name of the Exam (Midterm etc).
* time_limit_mins: Time limit (in minutes) that a student can finish this exam.
* is_proctored: Whether this exam actually is proctored or not.
* external_id: This will be a integration specific ID - say to SoftwareSecure.
* is_active: Whether this exam will be active.
**Response Values**
* {'exam_id': ##}, The exam_id of the created Proctored Exam.
**Exceptions**
* HTTP_400_BAD_REQUEST, data={"message": "Trying to create a duplicate exam."}
HTTP PUT
Updates an existing Exam.
PUT data : {
"exam_id": 533,
"exam_name": "Final",
"time_limit_mins": 120,
"is_proctored": true,
"external_id": 235
"is_active": true
}
**PUT data Parameters**
see the POST data parameters
**Response Values**
* {'exam_id': ##}, The exam_id of the created Proctored Exam.
HTTP GET
** Scenarios **
?exam_id=533
returns an existing exam object matching the exam_id
?course_id=edX/DemoX/Demo_Course&content_id=123
returns an existing exam object matching the course_id and the content_id
"""
@method_decorator(require_staff)
def post(self, request):
"""
Http POST handler. Creates an exam.
"""
serializer = ProctoredExamSerializer(data=request.DATA)
if serializer.is_valid():
exam_id = create_exam(
course_id=request.DATA.get('course_id', None),
content_id=request.DATA.get('content_id', None),
exam_name=request.DATA.get('exam_name', None),
time_limit_mins=request.DATA.get('time_limit_mins', None),
is_proctored=request.DATA.get('is_proctored', None),
external_id=request.DATA.get('external_id', None),
is_active=request.DATA.get('is_active', None)
)
return Response({'exam_id': exam_id})
else:
return Response(
status=status.HTTP_400_BAD_REQUEST,
data=serializer.errors
)
@method_decorator(require_staff)
def put(self, request):
"""
HTTP PUT handler. To update an exam.
calls the update_exam
"""
try:
exam_id = update_exam(
exam_id=request.DATA.get('exam_id', None),
exam_name=request.DATA.get('exam_name', None),
time_limit_mins=request.DATA.get('time_limit_mins', None),
is_proctored=request.DATA.get('is_proctored', None),
external_id=request.DATA.get('external_id', None),
is_active=request.DATA.get('is_active', None),
)
return Response({'exam_id': exam_id})
except ProctoredExamNotFoundException:
return Response(
status=status.HTTP_400_BAD_REQUEST,
data={"detail": "The exam_id does not exist."}
)
def get(self, request, exam_id=None, course_id=None, content_id=None): # pylint: disable=unused-argument
"""
HTTP GET handler.
Scenarios:
by exam_id: calls get_exam_by_id()
by course_id, content_id: get_exam_by_content_id()
"""
if exam_id:
try:
return Response(
data=get_exam_by_id(exam_id),
status=status.HTTP_200_OK
)
except ProctoredExamNotFoundException:
return Response(
status=status.HTTP_400_BAD_REQUEST,
data={"detail": "The exam_id does not exist."}
)
elif course_id is not None and content_id is not None:
try:
return Response(
data=get_exam_by_content_id(course_id, content_id),
status=status.HTTP_200_OK
)
except ProctoredExamNotFoundException:
return Response(
status=status.HTTP_400_BAD_REQUEST,
data={"detail": "The exam with course_id, content_id does not exist."}
)
class StudentProctoredExamAttempt(AuthenticatedAPIView):
"""
Returns the status of the proctored exam.
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.
"""
def get(self, request): # pylint: disable=unused-argument
"""
HTTP GET Handler
HTTP GET Handler. Returns the status of the exam attempt.
"""
response_dict = {
'in_timed_exam': True,
'is_proctored': True,
'exam_display_name': 'Midterm',
'exam_url_path': '',
'time_remaining_seconds': 45,
'low_threshold': 30,
'critically_low_threshold': 15,
}
exams = get_active_exams_for_user(request.user.id)
return Response(response_dict, status=status.HTTP_200_OK)
if exams:
exam = exams[0]
# need to adjust for allowances
expires_at = exam['attempt']['started_at'] + timedelta(minutes=exam['exam']['time_limit_mins'])
now_utc = datetime.now(pytz.UTC)
if expires_at > now_utc:
time_remaining_seconds = (expires_at - now_utc).seconds
else:
time_remaining_seconds = 0
response_dict = {
'in_timed_exam': True,
'is_proctored': True,
'exam_display_name': exam['exam']['exam_name'],
'exam_url_path': '',
'time_remaining_seconds': time_remaining_seconds,
'low_threshold': 30,
'critically_low_threshold': 15,
}
else:
response_dict = {
'in_timed_exam': False,
'is_proctored': False,
}
return Response(
data=response_dict,
status=status.HTTP_200_OK
)
def post(self, request):
"""
HTTP POST handler. To start an exam.
"""
try:
exam_attempt_id = start_exam_attempt(
exam_id=request.DATA.get('exam_id', None),
user_id=request.user.id,
external_id=request.DATA.get('external_id', None)
)
return Response({'exam_attempt_id': exam_attempt_id})
except StudentExamAttemptAlreadyExistsException:
return Response(
status=status.HTTP_400_BAD_REQUEST,
data={"detail": "Error. Trying to start an exam that has already started."}
)
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 StudentExamAttemptDoesNotExistsException:
return Response(
status=status.HTTP_400_BAD_REQUEST,
data={"detail": "Error. Trying to stop an exam that is not in progress."}
)
class ExamAllowanceView(AuthenticatedAPIView):
"""
Endpoint for the ExamAlloawnce
/edx_proctoring/v1/proctored_exam/allowance
Supports:
HTTP PUT: Creates or Updates the allowance for a user.
HTTP DELETE: Removed an allowance for a user.
"""
@method_decorator(require_staff)
def put(self, request):
"""
HTTP GET handler. Adds or updates Allowance
"""
return Response(add_allowance_for_user(
exam_id=request.DATA.get('exam_id', None),
user_id=request.DATA.get('user_id', None),
key=request.DATA.get('key', None),
value=request.DATA.get('value', None)
))
@method_decorator(require_staff)
def delete(self, request):
"""
HTTP DELETE handler. Removes Allowance.
"""
return Response(remove_allowance_for_user(
exam_id=request.DATA.get('exam_id', None),
user_id=request.DATA.get('user_id', None),
key=request.DATA.get('key', None)
))
class ActiveExamsForUserView(AuthenticatedAPIView):
"""
Endpoint for the Active Exams for a user.
/edx_proctoring/v1/proctored_exam/active_exams_for_user
Supports:
HTTP GET: returns a list of active exams for the user
"""
def get(self, request):
"""
returns the get_active_exams_for_user
"""
return Response(get_active_exams_for_user(
user_id=request.DATA.get('user_id', None),
course_id=request.DATA.get('course_id', None)
))
# Django/Framework Packages
django>=1.4.12,<=1.4.18
django>=1.4.12,<=1.4.20
django-model-utils==1.4.0
South>=0.7.6
djangorestframework>=2.3.5,<=2.3.14
......
......@@ -67,3 +67,6 @@ MIDDLEWARE_CLASSES = (
)
ROOT_URLCONF = 'edx_proctoring.urls'
COURSE_ID_REGEX = r'[^/+]+(/|\+)[^/+]+(/|\+)[^/]+'
COURSE_ID_PATTERN = r'(?P<course_id>%s)'%COURSE_ID_REGEX
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