Commit 34cd882f by chrisndodge

Merge pull request #23 from edx/muhhshoaib/PHX-11

(WIP) PHX-11
parents edc4d2a1 19ceb59d
......@@ -160,7 +160,7 @@ def get_exam_attempt(exam_id, user_id):
"""
Return an existing exam attempt for the given student
"""
exam_attempt_obj = ProctoredExamStudentAttempt.get_exam_attempt(exam_id, user_id)
exam_attempt_obj = ProctoredExamStudentAttempt.objects.get_exam_attempt(exam_id, user_id)
serialized_attempt_obj = ProctoredExamStudentAttemptSerializer(exam_attempt_obj)
return serialized_attempt_obj.data if exam_attempt_obj else None
......@@ -169,7 +169,7 @@ 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)
exam_attempt_obj = ProctoredExamStudentAttempt.objects.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
......@@ -180,7 +180,7 @@ def create_exam_attempt(exam_id, user_id, taking_as_proctored=False):
one exam_attempt per user per exam. Multiple attempts by user will be archived
in a separate table
"""
if ProctoredExamStudentAttempt.get_exam_attempt(exam_id, user_id):
if ProctoredExamStudentAttempt.objects.get_exam_attempt(exam_id, user_id):
err_msg = (
'Cannot create new exam attempt for exam_id = {exam_id} and '
'user_id = {user_id} because it already exists!'
......@@ -245,7 +245,7 @@ def start_exam_attempt(exam_id, user_id):
Returns: exam_attempt_id (PK)
"""
existing_attempt = ProctoredExamStudentAttempt.get_exam_attempt(exam_id, user_id)
existing_attempt = ProctoredExamStudentAttempt.objects.get_exam_attempt(exam_id, user_id)
if not existing_attempt:
err_msg = (
......@@ -264,7 +264,7 @@ def start_exam_attempt_by_code(attempt_code):
an attempt code
"""
existing_attempt = ProctoredExamStudentAttempt.get_exam_attempt_by_code(attempt_code)
existing_attempt = ProctoredExamStudentAttempt.objects.get_exam_attempt_by_code(attempt_code)
if not existing_attempt:
err_msg = (
......@@ -298,7 +298,7 @@ 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_exam_attempt(exam_id, user_id)
exam_attempt_obj = ProctoredExamStudentAttempt.objects.get_exam_attempt(exam_id, user_id)
if exam_attempt_obj is None:
raise StudentExamAttemptDoesNotExistsException('Error. Trying to stop an exam that is not in progress.')
else:
......@@ -307,6 +307,24 @@ def stop_exam_attempt(exam_id, user_id):
return exam_attempt_obj.id
def remove_exam_attempt_by_id(attempt_id):
"""
Removes an exam attempt given the attempt id.
"""
existing_attempt = ProctoredExamStudentAttempt.objects.get_exam_attempt_by_id(attempt_id)
if not existing_attempt:
err_msg = (
'Cannot remove attempt for attempt_id = {attempt_id} '
'because it does not exist!'
).format(attempt_id=attempt_id)
raise StudentExamAttemptDoesNotExistsException(err_msg)
existing_attempt.delete_exam_attempt()
def get_all_exams_for_course(course_id):
"""
This method will return all exams for a course. This will return a list
......@@ -336,6 +354,22 @@ def get_all_exams_for_course(course_id):
return [ProctoredExamSerializer(proctored_exam).data for proctored_exam in exams]
def get_all_exam_attempts(course_id):
"""
Returns all the exam attempts for the course id.
"""
exam_attempts = ProctoredExamStudentAttempt.objects.get_all_exam_attempts(course_id)
return [ProctoredExamStudentAttemptSerializer(active_exam).data for active_exam in exam_attempts]
def get_filtered_exam_attempts(course_id, search_by):
"""
returns all exam attempts for a course id filtered by the search_by string in user names and emails.
"""
exam_attempts = ProctoredExamStudentAttempt.objects.get_filtered_exam_attempts(course_id, search_by)
return [ProctoredExamStudentAttemptSerializer(active_exam).data for active_exam in exam_attempts]
def get_active_exams_for_user(user_id, course_id=None):
"""
This method will return a list of active exams for the user,
......@@ -357,7 +391,7 @@ def get_active_exams_for_user(user_id, course_id=None):
"""
result = []
student_active_exams = ProctoredExamStudentAttempt.get_active_student_attempts(user_id, course_id)
student_active_exams = ProctoredExamStudentAttempt.objects.get_active_student_attempts(user_id, course_id)
for active_exam in student_active_exams:
# convert the django orm objects
# into the serialized form.
......
# -*- 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 model 'ProctoredExamStudentAttemptHistory'
db.create_table('edx_proctoring_proctoredexamstudentattempthistory', (
('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)),
('user', self.gf('django.db.models.fields.related.ForeignKey')(to=orm['auth.User'])),
('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)),
('attempt_code', self.gf('django.db.models.fields.CharField')(max_length=255, null=True, db_index=True)),
('external_id', self.gf('django.db.models.fields.CharField')(max_length=255, null=True, db_index=True)),
('allowed_time_limit_mins', self.gf('django.db.models.fields.IntegerField')()),
('status', self.gf('django.db.models.fields.CharField')(max_length=64)),
('taking_as_proctored', self.gf('django.db.models.fields.BooleanField')(default=False)),
('is_sample_attempt', self.gf('django.db.models.fields.BooleanField')(default=False)),
('student_name', self.gf('django.db.models.fields.CharField')(max_length=255)),
))
db.send_create_signal('edx_proctoring', ['ProctoredExamStudentAttemptHistory'])
def backwards(self, orm):
# Deleting model 'ProctoredExamStudentAttemptHistory'
db.delete_table('edx_proctoring_proctoredexamstudentattempthistory')
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']"})
},
'edx_proctoring.proctoredexamstudentattempthistory': {
'Meta': {'object_name': 'ProctoredExamStudentAttemptHistory'},
'allowed_time_limit_mins': ('django.db.models.fields.IntegerField', [], {}),
'attempt_code': ('django.db.models.fields.CharField', [], {'max_length': '255', 'null': 'True', 'db_index': 'True'}),
'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
......@@ -11,6 +11,7 @@ from model_utils.models import TimeStampedModel
from django.contrib.auth.models import User
from edx_proctoring.exceptions import UserNotFoundException
from django.db.models.base import ObjectDoesNotExist
class ProctoredExam(TimeStampedModel):
......@@ -76,11 +77,77 @@ class ProctoredExam(TimeStampedModel):
return cls.objects.filter(course_id=course_id)
class ProctoredExamStudentAttemptManager(models.Manager):
"""
Custom manager
"""
def get_exam_attempt(self, exam_id, user_id):
"""
Returns the Student Exam Attempt object if found
else Returns None.
"""
try:
exam_attempt_obj = self.get(proctored_exam_id=exam_id, user_id=user_id)
except ObjectDoesNotExist: # pylint: disable=no-member
exam_attempt_obj = None
return exam_attempt_obj
def get_exam_attempt_by_id(self, attempt_id):
"""
Returns the Student Exam Attempt by the attempt_id else return None
"""
try:
exam_attempt_obj = self.get(id=attempt_id)
except ObjectDoesNotExist: # pylint: disable=no-member
exam_attempt_obj = None
return exam_attempt_obj
def get_exam_attempt_by_code(self, attempt_code):
"""
Returns the Student Exam Attempt object if found
else Returns None.
"""
try:
exam_attempt_obj = self.get(attempt_code=attempt_code)
except ObjectDoesNotExist: # pylint: disable=no-member
exam_attempt_obj = None
return exam_attempt_obj
def get_all_exam_attempts(self, course_id):
"""
Returns the Student Exam Attempts for the given course_id.
"""
return self.filter(proctored_exam__course_id=course_id)
def get_filtered_exam_attempts(self, course_id, search_by):
"""
Returns the Student Exam Attempts for the given course_id filtered by search_by.
"""
filtered_query = Q(proctored_exam__course_id=course_id) & (
Q(user__username__contains=search_by) | Q(user__email__contains=search_by)
)
return self.filter(filtered_query)
def get_active_student_attempts(self, 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 self.filter(filtered_query)
class ProctoredExamStudentAttempt(TimeStampedModel):
"""
Information about the Student Attempt on a
Proctored Exam.
"""
objects = ProctoredExamStudentAttemptManager()
user = models.ForeignKey(User, db_index=True)
proctored_exam = models.ForeignKey(ProctoredExam, db_index=True)
......@@ -148,51 +215,72 @@ class ProctoredExamStudentAttempt(TimeStampedModel):
self.started_at = datetime.now(pytz.UTC)
self.save()
@classmethod
def get_exam_attempt(cls, exam_id, user_id):
def delete_exam_attempt(self):
"""
Returns the Student Exam Attempt object if found
else Returns None.
deletes the exam attempt object.
"""
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
self.delete()
@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
class ProctoredExamStudentAttemptHistory(TimeStampedModel):
"""
This should be the same schema as ProctoredExamStudentAttempt
but will record (for audit history) all entries that have been updated.
"""
@classmethod
def get_active_student_attempts(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)
user = models.ForeignKey(User, db_index=True)
proctored_exam = models.ForeignKey(ProctoredExam, db_index=True)
return cls.objects.filter(filtered_query)
# started/completed date times
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)
# this is the time limit allowed to the student
allowed_time_limit_mins = models.IntegerField()
# what is the status of this attempt
status = models.CharField(max_length=64)
# if the user is attempting this as a proctored exam
# 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)
@receiver(pre_delete, sender=ProctoredExamStudentAttempt)
def on_attempt_deleted(sender, instance, **kwargs): # pylint: disable=unused-argument
"""
Archive the exam attempt when the item is about to be deleted
Make a clone and populate in the History table
"""
archive_object = ProctoredExamStudentAttemptHistory(
user=instance.user,
proctored_exam=instance.proctored_exam,
started_at=instance.started_at,
completed_at=instance.completed_at,
attempt_code=instance.attempt_code,
external_id=instance.external_id,
allowed_time_limit_mins=instance.allowed_time_limit_mins,
status=instance.status,
taking_as_proctored=instance.taking_as_proctored,
is_sample_attempt=instance.is_sample_attempt,
student_name=instance.student_name
)
archive_object.save()
class QuerySetWithUpdateOverride(models.query.QuerySet):
......
......@@ -66,8 +66,8 @@ class ProctoredExamStudentAttemptSerializer(serializers.ModelSerializer):
"""
Serializer for the ProctoredExamStudentAttempt Model.
"""
proctored_exam_id = serializers.IntegerField(source="proctored_exam_id")
user_id = serializers.IntegerField(required=False)
proctored_exam = ProctoredExamSerializer()
user = UserSerializer()
class Meta:
"""
......@@ -76,8 +76,8 @@ class ProctoredExamStudentAttemptSerializer(serializers.ModelSerializer):
model = ProctoredExamStudentAttempt
fields = (
"id", "created", "modified", "user_id", "started_at", "completed_at",
"external_id", "status", "proctored_exam_id", "allowed_time_limit_mins",
"id", "created", "modified", "user", "started_at", "completed_at",
"external_id", "status", "proctored_exam", "allowed_time_limit_mins",
"attempt_code", "is_sample_attempt", "taking_as_proctored"
)
......
var edx = edx || {};
(function(Backbone) {
edx.instructor_dashboard = edx.instructor_dashboard || {};
edx.instructor_dashboard.proctoring = edx.instructor_dashboard.proctoring || {};
edx.instructor_dashboard.proctoring.ProctoredExamAttemptCollection = Backbone.Collection.extend({
/* model for a collection of ProctoredExamAllowance */
model: edx.instructor_dashboard.proctoring.ProctoredExamAttemptModel,
url: '/api/edx_proctoring/v1/proctored_exam/attempt/course_id/'
});
this.edx.instructor_dashboard.proctoring.ProctoredExamAttemptCollection = edx.instructor_dashboard.proctoring.ProctoredExamAttemptCollection;
}).call(this, Backbone);
\ No newline at end of file
var edx = edx || {};
(function(Backbone) {
'use strict';
edx.instructor_dashboard = edx.instructor_dashboard || {};
edx.instructor_dashboard.proctoring = edx.instructor_dashboard.proctoring || {};
edx.instructor_dashboard.proctoring.ProctoredExamAttemptModel = Backbone.Model.extend({
url: '/api/edx_proctoring/v1/proctored_exam/attempt/'
});
this.edx.instructor_dashboard.proctoring.ProctoredExamAttemptModel = edx.instructor_dashboard.proctoring.ProctoredExamAttemptModel;
}).call(this, Backbone);
......@@ -5,4 +5,10 @@ $(function() {
model: new ProctoredExamModel()
});
proctored_exam_view.render();
var proctored_exam_attempt_view = new edx.instructor_dashboard.proctoring.ProctoredExamAttemptView({
el: $('.student-proctored-exam-container'),
template_url: '/static/proctoring/templates/student-proctored-exam-attempts.underscore',
collection: new edx.instructor_dashboard.proctoring.ProctoredExamAttemptCollection(),
model: new edx.instructor_dashboard.proctoring.ProctoredExamAttemptModel()
});
});
var edx = edx || {};
(function (Backbone, $, _) {
'use strict';
edx.instructor_dashboard = edx.instructor_dashboard || {};
edx.instructor_dashboard.proctoring = edx.instructor_dashboard.proctoring || {};
var viewHelper = {
getDateFormat: function(date) {
if (date) {
return new Date(date).toString('MMM dd, yyyy h:mmtt');
}
else {
return 'N/A';
}
}
};
edx.instructor_dashboard.proctoring.ProctoredExamAttemptView = Backbone.View.extend({
initialize: function (options) {
this.$el = options.el;
this.collection = options.collection;
this.tempate_url = options.template_url;
this.model = options.model;
this.course_id = this.$el.data('course-id');
this.template = null;
this.initial_url = this.collection.url;
this.attempt_url = this.model.url;
this.collection.url = this.initial_url + this.course_id;
this.inSearchMode = false;
this.searchText = "";
/* re-render if the model changes */
this.listenTo(this.collection, 'change', this.collectionChanged);
/* Load the static template for rendering. */
this.loadTemplateData();
},
events: {
"click .remove-attempt": "onRemoveAttempt",
'click li > a.target-link': 'getPaginatedAttempts',
'click .search-attempts > span.search': 'searchAttempts',
'click .search-attempts > span.clear-search': 'clearSearch'
},
searchAttempts: function(event) {
var searchText = $('#search_attempt_id').val();
if (searchText !== "") {
this.inSearchMode = true;
this.searchText = searchText;
this.collection.url = this.initial_url + this.course_id + "/search/" + searchText;
this.hydrate();
event.stopPropagation();
event.preventDefault();
}
},
clearSearch: function(event) {
this.inSearchMode = false;
this.searchText = "";
this.collection.url = this.initial_url + this.course_id;
this.hydrate();
event.stopPropagation();
event.preventDefault();
},
getPaginatedAttempts: function(event) {
var target = $(event.currentTarget);
this.collection.url = target.data('target-url');
this.hydrate();
event.stopPropagation();
event.preventDefault();
},
getCSRFToken: function () {
var cookieValue = null;
var name = 'csrftoken';
if (document.cookie && document.cookie != '') {
var cookies = document.cookie.split(';');
for (var i = 0; i < cookies.length; i++) {
var cookie = jQuery.trim(cookies[i]);
// Does this cookie string begin with the name we want?
if (cookie.substring(0, name.length + 1) == (name + '=')) {
cookieValue = decodeURIComponent(cookie.substring(name.length + 1));
break;
}
}
}
return cookieValue;
},
loadTemplateData: function () {
var self = this;
$.ajax({url: self.tempate_url, dataType: "html"})
.error(function (jqXHR, textStatus, errorThrown) {
})
.done(function (template_data) {
self.template = _.template(template_data);
self.hydrate();
});
},
hydrate: function () {
/* This function will load the bound collection */
/* add and remove a class when we do the initial loading */
/* we might - at some point - add a visual element to the */
/* loading, like a spinner */
var self = this;
self.collection.fetch({
success: function () {
self.render();
}
});
},
collectionChanged: function () {
this.hydrate();
},
render: function () {
if (this.template !== null) {
var self = this;
var data = {
proctored_exam_attempts: this.collection.toJSON()[0].proctored_exam_attempts,
pagination_info: this.collection.toJSON()[0].pagination_info,
attempt_url: this.collection.toJSON()[0].attempt_url,
inSearchMode: this.inSearchMode,
searchText: this.searchText
};
_.extend(data, viewHelper);
var html = this.template(data);
this.$el.html(html);
this.$el.show();
}
},
onRemoveAttempt: function (event) {
event.preventDefault();
var $target = $(event.currentTarget);
var attemptId = $target.data("attemptId");
var self = this;
self.model.url = this.attempt_url + attemptId;
self.model.fetch( {
headers: {
"X-CSRFToken": this.getCSRFToken()
},
type: 'DELETE',
success: function () {
// fetch the attempts again.
self.hydrate();
}
});
}
});
this.edx.instructor_dashboard.proctoring.ProctoredExamAttemptView = edx.instructor_dashboard.proctoring.ProctoredExamAttemptView;
}).call(this, Backbone, $, _);
<div class="wrapper-content wrapper">
<section class="content">
<div class="top-header">
<div class='search-attempts'>
<input type="text" id="search_attempt_id" placeholder="e.g johndoe or john.do@gmail.com"
<% if (inSearchMode) { %>
value="<%= searchText %>"
<%} %>
/>
<span class="search"><i class="fa fa-search"></i></span>
<span class="clear-search"><i class="fa fa-remove"></i></i></span>
</div>
<ul class="pagination">
<% if (!pagination_info.has_previous){ %>
<li class="disabled">
<a aria-label="Previous">
<span aria-hidden="true">&laquo;</span>
</a>
</li>
<% } else { %>
<li>
<a class="target-link " data-target-url="
<%- interpolate(
'%(attempt_url)s?page=%(count)s ',
{
attempt_url: attempt_url,
count: pagination_info.current_page - 1
},
true
) %> "
href="#" aria-label="Previous">
<span aria-hidden="true">&laquo;</span>
</a>
</li>
<% }%>
<% for(var n = 1; n <= pagination_info.total_pages; n++) { %>
<li>
<a class="target-link <% if (pagination_info.current_page == n){ %> active <% } %>"
data-target-url="
<%- interpolate(
'%(attempt_url)s?page=%(count)s ',
{
attempt_url: attempt_url,
count: n
},
true
) %>
"
href="#"><%= n %>
</a>
</li>
<% } %>
<% if (!pagination_info.has_next){ %>
<li class="disabled">
<a aria-label="Next">
<span aria-hidden="true">&raquo;</span>
</a>
</li>
<% } else { %>
<li>
<a class="target-link" href="#" aria-label="Next" data-target-url="
<%- interpolate(
'%(attempt_url)s?page=%(count)s ',
{
attempt_url: attempt_url,
count: pagination_info.current_page + 1
},
true
) %> "
>
<span aria-hidden="true">&raquo;</span>
</a>
</li>
<% }%>
</ul>
<div class="clearfix"></div>
</div>
<table class="exam-attempts-table">
<thead>
<tr class="exam-attempt-headings">
<th class="username"><%- gettext("Username") %></th>
<th class="exam-name"><%- gettext("Exam Name") %></th>
<th class="attempt-allowed-time"><%- gettext("Allowed Time (Minutes)") %> </th>
<th class="attempt-started-at"><%- gettext("Started At") %></th>
<th class="attempt-completed-at"><%- gettext("Completed At") %> </th>
<th class="attempt-status"><%- gettext("Status") %> </th>
<th class="c_action"><%- gettext("Action") %> </th>
</tr>
</thead>
<tbody>
<% _.each(proctored_exam_attempts, function(proctored_exam_attempt){ %>
<tr class="allowance-items">
<td>
<%- interpolate(gettext(' %(username)s '), { username: proctored_exam_attempt.user.username }, true) %>
</td>
<td>
<%- interpolate(gettext(' %(exam_display_name)s '), { exam_display_name: proctored_exam_attempt.proctored_exam.exam_name }, true) %>
</td>
<td> <%= proctored_exam_attempt.allowed_time_limit_mins %></td>
<td> <%= getDateFormat(proctored_exam_attempt.started_at) %></td>
<td> <%= getDateFormat(proctored_exam_attempt.completed_at) %></td>
<td>
<% if (proctored_exam_attempt.status){ %>
<%= proctored_exam_attempt.status %>
<% } else { %>
N/A
<% } %>
</td>
<td>
<% if (proctored_exam_attempt.status){ %>
<a href="#" class="remove-attempt" data-attempt-id="<%= proctored_exam_attempt.id %>" >[x]</a>
</td>
<% } else { %>
N/A
<% } %>
</tr>
<% }); %>
</tbody>
</table>
</section>
</div>
\ No newline at end of file
......@@ -21,8 +21,10 @@ from edx_proctoring.api import (
get_student_view,
get_allowances_for_course,
get_all_exams_for_course,
get_exam_attempt_by_id
)
get_exam_attempt_by_id,
remove_exam_attempt_by_id,
get_all_exam_attempts,
get_filtered_exam_attempts)
from edx_proctoring.exceptions import (
ProctoredExamAlreadyExists,
ProctoredExamNotFoundException,
......@@ -288,8 +290,8 @@ class ProctoredExamApiTests(LoggedInTestCase):
self._create_unstarted_exam_attempt()
exam_attempt = get_exam_attempt(self.proctored_exam_id, self.user_id)
self.assertEqual(exam_attempt['proctored_exam_id'], self.proctored_exam_id)
self.assertEqual(exam_attempt['user_id'], self.user_id)
self.assertEqual(exam_attempt['proctored_exam']['id'], self.proctored_exam_id)
self.assertEqual(exam_attempt['user']['id'], self.user_id)
def test_start_uncreated_attempt(self):
"""
......@@ -336,6 +338,19 @@ class ProctoredExamApiTests(LoggedInTestCase):
)
self.assertEqual(proctored_exam_student_attempt.id, proctored_exam_attempt_id)
def test_remove_exam_attempt(self):
"""
Calling the api remove function removes the attempt.
"""
with self.assertRaises(StudentExamAttemptDoesNotExistsException):
remove_exam_attempt_by_id(9999)
proctored_exam_student_attempt = self._create_unstarted_exam_attempt()
remove_exam_attempt_by_id(proctored_exam_student_attempt.id)
with self.assertRaises(StudentExamAttemptDoesNotExistsException):
remove_exam_attempt_by_id(proctored_exam_student_attempt.id)
def test_stop_a_non_started_exam(self):
"""
Stop an exam attempt that had not started yet.
......@@ -371,6 +386,47 @@ class ProctoredExamApiTests(LoggedInTestCase):
self.assertEqual(len(student_active_exams[0]['allowances']), 2)
self.assertEqual(len(student_active_exams[1]['allowances']), 0)
def test_get_filtered_exam_attempts(self):
"""
Test to get all the exams filtered by the course_id
and search type.
"""
exam_attempt = self._create_started_exam_attempt()
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
)
new_exam_attempt = create_exam_attempt(
exam_id=exam_id,
user_id=self.user_id
)
filtered_attempts = get_filtered_exam_attempts(self.course_id, self.user.username)
self.assertEqual(len(filtered_attempts), 2)
self.assertEqual(filtered_attempts[0]['id'], exam_attempt.id)
self.assertEqual(filtered_attempts[1]['id'], new_exam_attempt)
def test_get_all_exam_attempts(self):
"""
Test to get all the exam attempts.
"""
exam_attempt = self._create_started_exam_attempt()
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
)
updated_exam_attempt_id = create_exam_attempt(
exam_id=exam_id,
user_id=self.user_id
)
all_exams = get_all_exam_attempts(self.course_id)
self.assertEqual(len(all_exams), 2)
self.assertEqual(all_exams[0]['id'], exam_attempt.id)
self.assertEqual(all_exams[1]['id'], updated_exam_attempt_id)
def test_get_student_view(self):
"""
Test for get_student_view promting the user to take the exam
......
"""
All tests for the models.py
"""
from edx_proctoring.models import ProctoredExam, ProctoredExamStudentAllowance, ProctoredExamStudentAllowanceHistory
from edx_proctoring.models import ProctoredExam, ProctoredExamStudentAllowance, ProctoredExamStudentAllowanceHistory, \
ProctoredExamStudentAttempt, ProctoredExamStudentAttemptHistory
from .utils import (
LoggedInTestCase
......@@ -104,3 +105,40 @@ class ProctoredExamModelTests(LoggedInTestCase):
proctored_exam_student_history = ProctoredExamStudentAllowanceHistory.objects.filter(user_id=1)
self.assertEqual(len(proctored_exam_student_history), 1)
class ProctoredExamStudentAttemptTests(LoggedInTestCase):
"""
Tests for the ProctoredExamStudentAttempt Model
"""
def test_delete_proctored_exam_attempt(self): # pylint: disable=invalid-name
"""
Deleting the proctored exam attempt creates an entry in the history table.
"""
proctored_exam = ProctoredExam.objects.create(
course_id='test_course',
content_id='test_content',
exam_name='Test Exam',
external_id='123aXqe3',
time_limit_mins=90
)
attempt = ProctoredExamStudentAttempt.objects.create(
proctored_exam_id=proctored_exam.id,
user_id=1,
student_name="John. D",
allowed_time_limit_mins=10,
attempt_code="123456",
taking_as_proctored=True,
is_sample_attempt=True,
external_id=1
)
# No entry in the History table on creation of the Allowance entry.
attempt_history = ProctoredExamStudentAttemptHistory.objects.filter(user_id=1)
self.assertEqual(len(attempt_history), 0)
attempt.delete_exam_attempt()
attempt_history = ProctoredExamStudentAttemptHistory.objects.filter(user_id=1)
self.assertEqual(len(attempt_history), 1)
# pylint: disable=too-many-lines
"""
All tests for the proctored_exams.py
"""
......@@ -429,10 +430,50 @@ class TestStudentProctoredExamAttempt(LoggedInTestCase):
self.assertEqual(response.status_code, 200)
response_data = json.loads(response.content)
self.assertEqual(response_data['id'], attempt_id)
self.assertEqual(response_data['proctored_exam_id'], proctored_exam.id)
self.assertEqual(response_data['proctored_exam']['id'], proctored_exam.id)
self.assertIsNotNone(response_data['started_at'])
self.assertIsNone(response_data['completed_at'])
def test_remove_attempt(self):
"""
Confirms that an attempt can be removed
"""
# 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.delete(
reverse('edx_proctoring.proctored_exam.attempt', args=[1])
)
self.assertEqual(response.status_code, 400)
attempt_data = {
'exam_id': proctored_exam.id,
'external_id': proctored_exam.external_id,
'start_clock': True,
}
response = self.client.post(
reverse('edx_proctoring.proctored_exam.attempt.collection'),
attempt_data
)
self.assertEqual(response.status_code, 200)
response_data = json.loads(response.content)
attempt_id = response_data['exam_attempt_id']
self.assertGreater(attempt_id, 0)
response = self.client.delete(
reverse('edx_proctoring.proctored_exam.attempt', args=[attempt_id])
)
self.assertEqual(response.status_code, 200)
def test_read_others_attempt(self):
"""
Confirms that we cnanot read someone elses attempt
......@@ -547,6 +588,88 @@ class TestStudentProctoredExamAttempt(LoggedInTestCase):
response_data = json.loads(response.content)
self.assertEqual(response_data['exam_attempt_id'], old_attempt_id)
def test_get_exam_attempts(self):
"""
Test to get the exam attempts in a course.
"""
# 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.collection'),
attempt_data
)
url = reverse('edx_proctoring.proctored_exam.attempt', kwargs={'course_id': proctored_exam.course_id})
self.assertEqual(response.status_code, 200)
response = self.client.get(url)
self.assertEqual(response.status_code, 200)
response_data = json.loads(response.content)
self.assertEqual(len(response_data['proctored_exam_attempts']), 1)
url = '{url}?page={invalid_page_no}'.format(url=url, invalid_page_no=9999)
# url with the invalid page # still gives us the first page result.
response = self.client.get(url)
self.assertEqual(response.status_code, 200)
response_data = json.loads(response.content)
self.assertEqual(len(response_data['proctored_exam_attempts']), 1)
def test_get_filtered_exam_attempts(self):
"""
Test to get the exam attempts in a course.
"""
# 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,
'start_clock': False,
'attempt_proctored': False
}
# create a exam attempt
response = self.client.post(
reverse('edx_proctoring.proctored_exam.attempt.collection'),
attempt_data
)
self.assertEqual(response.status_code, 200)
self.client.login_user(self.second_user)
# create a new exam attempt for second student
response = self.client.post(
reverse('edx_proctoring.proctored_exam.attempt.collection'),
attempt_data
)
self.assertEqual(response.status_code, 200)
self.client.login_user(self.user)
response = self.client.get(
reverse(
'edx_proctoring.proctored_exam.attempt.search',
kwargs={
'course_id': proctored_exam.course_id,
'search_by': 'tester'
}
)
)
self.assertEqual(response.status_code, 200)
response_data = json.loads(response.content)
self.assertEqual(len(response_data['proctored_exam_attempts']), 2)
def test_stop_others_attempt(self):
"""
Start an exam (create an exam attempt)
......
......@@ -36,6 +36,17 @@ urlpatterns = patterns( # pylint: disable=invalid-name
name='edx_proctoring.proctored_exam.attempt'
),
url(
r'edx_proctoring/v1/proctored_exam/attempt/course_id/{}$'.format(settings.COURSE_ID_PATTERN),
views.StudentProctoredExamAttemptCollection.as_view(),
name='edx_proctoring.proctored_exam.attempt'
),
url(
r'edx_proctoring/v1/proctored_exam/attempt/course_id/{}/search/(?P<search_by>.+)$'.format(
settings.COURSE_ID_PATTERN),
views.StudentProctoredExamAttemptCollection.as_view(),
name='edx_proctoring.proctored_exam.attempt.search'
),
url(
r'edx_proctoring/v1/proctored_exam/attempt$',
views.StudentProctoredExamAttemptCollection.as_view(),
name='edx_proctoring.proctored_exam.attempt.collection'
......
......@@ -12,6 +12,7 @@ from django.core.urlresolvers import reverse, NoReverseMatch
from rest_framework import status
from rest_framework.response import Response
from django.core.paginator import Paginator, EmptyPage, PageNotAnInteger
from edx_proctoring.api import (
create_exam,
update_exam,
......@@ -26,7 +27,9 @@ from edx_proctoring.api import (
get_allowances_for_course,
get_all_exams_for_course,
get_exam_attempt_by_id,
)
get_all_exam_attempts,
remove_exam_attempt_by_id,
get_filtered_exam_attempts)
from edx_proctoring.exceptions import (
ProctoredBaseException,
ProctoredExamNotFoundException,
......@@ -38,6 +41,8 @@ from edx_proctoring.serializers import ProctoredExamSerializer
from .utils import AuthenticatedAPIView
ATTEMPTS_PER_PAGE = 25
LOG = logging.getLogger("edx_proctoring_views")
......@@ -251,7 +256,7 @@ class StudentProctoredExamAttempt(AuthenticatedAPIView):
raise StudentExamAttemptDoesNotExistsException(err_msg)
# make sure the the attempt belongs to the calling user_id
if attempt['user_id'] != request.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(
......@@ -288,7 +293,7 @@ class StudentProctoredExamAttempt(AuthenticatedAPIView):
raise StudentExamAttemptDoesNotExistsException(err_msg)
# make sure the the attempt belongs to the calling user_id
if attempt['user_id'] != request.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(
......@@ -298,7 +303,7 @@ class StudentProctoredExamAttempt(AuthenticatedAPIView):
raise ProctoredExamPermissionDenied(err_msg)
exam_attempt_id = stop_exam_attempt(
exam_id=attempt['proctored_exam_id'],
exam_id=attempt['proctored_exam']['id'],
user_id=request.user.id
)
return Response({"exam_attempt_id": exam_attempt_id})
......@@ -309,6 +314,32 @@ class StudentProctoredExamAttempt(AuthenticatedAPIView):
data={"detail": str(ex)}
)
@method_decorator(require_staff)
def delete(self, request, attempt_id): # pylint: disable=unused-argument
"""
HTTP DELETE handler. Removes an 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)
remove_exam_attempt_by_id(attempt_id)
return Response()
except ProctoredBaseException, ex:
return Response(
status=status.HTTP_400_BAD_REQUEST,
data={"detail": str(ex)}
)
class StudentProctoredExamAttemptCollection(AuthenticatedAPIView):
"""
......@@ -358,10 +389,44 @@ class StudentProctoredExamAttemptCollection(AuthenticatedAPIView):
return the status of the exam attempt
"""
def get(self, request):
def get(self, request, course_id=None, search_by=None): # pylint: disable=unused-argument
"""
HTTP GET Handler. Returns the status of the exam attempt.
"""
if course_id is not None:
if search_by is not None:
exam_attempts = get_filtered_exam_attempts(course_id, search_by)
attempt_url = reverse('edx_proctoring.proctored_exam.attempt.search', args=[course_id, search_by])
else:
exam_attempts = get_all_exam_attempts(course_id)
attempt_url = reverse('edx_proctoring.proctored_exam.attempt', args=[course_id])
paginator = Paginator(exam_attempts, ATTEMPTS_PER_PAGE)
page = request.GET.get('page')
try:
exam_attempts_page = paginator.page(page)
except PageNotAnInteger:
# If page is not an integer, deliver first page.
exam_attempts_page = paginator.page(1)
except EmptyPage:
# If page is out of range (e.g. 9999), deliver last page of results.
exam_attempts_page = paginator.page(paginator.num_pages)
data = {
'proctored_exam_attempts': exam_attempts_page.object_list,
'pagination_info': {
'has_previous': exam_attempts_page.has_previous(),
'has_next': exam_attempts_page.has_next(),
'current_page': exam_attempts_page.number,
'total_pages': exam_attempts_page.paginator.num_pages,
},
'attempt_url': attempt_url
}
return Response(
data=data,
status=status.HTTP_200_OK
)
exams = get_active_exams_for_user(request.user.id)
......
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