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): ...@@ -160,7 +160,7 @@ def get_exam_attempt(exam_id, user_id):
""" """
Return an existing exam attempt for the given student 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) serialized_attempt_obj = ProctoredExamStudentAttemptSerializer(exam_attempt_obj)
return serialized_attempt_obj.data if exam_attempt_obj else None return serialized_attempt_obj.data if exam_attempt_obj else None
...@@ -169,7 +169,7 @@ def get_exam_attempt_by_id(attempt_id): ...@@ -169,7 +169,7 @@ def get_exam_attempt_by_id(attempt_id):
""" """
Return an existing exam attempt for the given student 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) serialized_attempt_obj = ProctoredExamStudentAttemptSerializer(exam_attempt_obj)
return serialized_attempt_obj.data if exam_attempt_obj else None 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): ...@@ -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 one exam_attempt per user per exam. Multiple attempts by user will be archived
in a separate table 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 = ( err_msg = (
'Cannot create new exam attempt for exam_id = {exam_id} and ' 'Cannot create new exam attempt for exam_id = {exam_id} and '
'user_id = {user_id} because it already exists!' 'user_id = {user_id} because it already exists!'
...@@ -245,7 +245,7 @@ def start_exam_attempt(exam_id, user_id): ...@@ -245,7 +245,7 @@ def start_exam_attempt(exam_id, user_id):
Returns: exam_attempt_id (PK) 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: if not existing_attempt:
err_msg = ( err_msg = (
...@@ -264,7 +264,7 @@ def start_exam_attempt_by_code(attempt_code): ...@@ -264,7 +264,7 @@ def start_exam_attempt_by_code(attempt_code):
an 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: if not existing_attempt:
err_msg = ( err_msg = (
...@@ -298,7 +298,7 @@ def stop_exam_attempt(exam_id, user_id): ...@@ -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) 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: if exam_attempt_obj is None:
raise StudentExamAttemptDoesNotExistsException('Error. Trying to stop an exam that is not in progress.') raise StudentExamAttemptDoesNotExistsException('Error. Trying to stop an exam that is not in progress.')
else: else:
...@@ -307,6 +307,24 @@ def stop_exam_attempt(exam_id, user_id): ...@@ -307,6 +307,24 @@ def stop_exam_attempt(exam_id, user_id):
return exam_attempt_obj.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): def get_all_exams_for_course(course_id):
""" """
This method will return all exams for a course. This will return a list 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): ...@@ -336,6 +354,22 @@ def get_all_exams_for_course(course_id):
return [ProctoredExamSerializer(proctored_exam).data for proctored_exam in exams] 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): def get_active_exams_for_user(user_id, course_id=None):
""" """
This method will return a list of active exams for the user, 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): ...@@ -357,7 +391,7 @@ def get_active_exams_for_user(user_id, course_id=None):
""" """
result = [] 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: for active_exam in student_active_exams:
# convert the django orm objects # convert the django orm objects
# into the serialized form. # into the serialized form.
......
...@@ -11,6 +11,7 @@ from model_utils.models import TimeStampedModel ...@@ -11,6 +11,7 @@ from model_utils.models import TimeStampedModel
from django.contrib.auth.models import User from django.contrib.auth.models import User
from edx_proctoring.exceptions import UserNotFoundException from edx_proctoring.exceptions import UserNotFoundException
from django.db.models.base import ObjectDoesNotExist
class ProctoredExam(TimeStampedModel): class ProctoredExam(TimeStampedModel):
...@@ -76,11 +77,77 @@ class ProctoredExam(TimeStampedModel): ...@@ -76,11 +77,77 @@ class ProctoredExam(TimeStampedModel):
return cls.objects.filter(course_id=course_id) 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): class ProctoredExamStudentAttempt(TimeStampedModel):
""" """
Information about the Student Attempt on a Information about the Student Attempt on a
Proctored Exam. Proctored Exam.
""" """
objects = ProctoredExamStudentAttemptManager()
user = models.ForeignKey(User, db_index=True) user = models.ForeignKey(User, db_index=True)
proctored_exam = models.ForeignKey(ProctoredExam, db_index=True) proctored_exam = models.ForeignKey(ProctoredExam, db_index=True)
...@@ -148,51 +215,72 @@ class ProctoredExamStudentAttempt(TimeStampedModel): ...@@ -148,51 +215,72 @@ class ProctoredExamStudentAttempt(TimeStampedModel):
self.started_at = datetime.now(pytz.UTC) self.started_at = datetime.now(pytz.UTC)
self.save() self.save()
@classmethod def delete_exam_attempt(self):
def get_exam_attempt(cls, exam_id, user_id):
""" """
Returns the Student Exam Attempt object if found deletes the exam attempt object.
else Returns None.
""" """
try: self.delete()
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_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 class ProctoredExamStudentAttemptHistory(TimeStampedModel):
def get_exam_attempt_by_code(cls, attempt_code): """
""" This should be the same schema as ProctoredExamStudentAttempt
Returns the Student Exam Attempt object if found but will record (for audit history) all entries that have been updated.
else Returns None. """
"""
try:
exam_attempt_obj = cls.objects.get(attempt_code=attempt_code)
except cls.DoesNotExist: # pylint: disable=no-member
exam_attempt_obj = None
return exam_attempt_obj
@classmethod user = models.ForeignKey(User, db_index=True)
def get_active_student_attempts(cls, user_id, course_id=None):
""" proctored_exam = models.ForeignKey(ProctoredExam, db_index=True)
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) # 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): class QuerySetWithUpdateOverride(models.query.QuerySet):
......
...@@ -66,8 +66,8 @@ class ProctoredExamStudentAttemptSerializer(serializers.ModelSerializer): ...@@ -66,8 +66,8 @@ class ProctoredExamStudentAttemptSerializer(serializers.ModelSerializer):
""" """
Serializer for the ProctoredExamStudentAttempt Model. Serializer for the ProctoredExamStudentAttempt Model.
""" """
proctored_exam_id = serializers.IntegerField(source="proctored_exam_id") proctored_exam = ProctoredExamSerializer()
user_id = serializers.IntegerField(required=False) user = UserSerializer()
class Meta: class Meta:
""" """
...@@ -76,8 +76,8 @@ class ProctoredExamStudentAttemptSerializer(serializers.ModelSerializer): ...@@ -76,8 +76,8 @@ class ProctoredExamStudentAttemptSerializer(serializers.ModelSerializer):
model = ProctoredExamStudentAttempt model = ProctoredExamStudentAttempt
fields = ( fields = (
"id", "created", "modified", "user_id", "started_at", "completed_at", "id", "created", "modified", "user", "started_at", "completed_at",
"external_id", "status", "proctored_exam_id", "allowed_time_limit_mins", "external_id", "status", "proctored_exam", "allowed_time_limit_mins",
"attempt_code", "is_sample_attempt", "taking_as_proctored" "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() { ...@@ -5,4 +5,10 @@ $(function() {
model: new ProctoredExamModel() model: new ProctoredExamModel()
}); });
proctored_exam_view.render(); 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 ( ...@@ -21,8 +21,10 @@ from edx_proctoring.api import (
get_student_view, get_student_view,
get_allowances_for_course, get_allowances_for_course,
get_all_exams_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 ( from edx_proctoring.exceptions import (
ProctoredExamAlreadyExists, ProctoredExamAlreadyExists,
ProctoredExamNotFoundException, ProctoredExamNotFoundException,
...@@ -288,8 +290,8 @@ class ProctoredExamApiTests(LoggedInTestCase): ...@@ -288,8 +290,8 @@ class ProctoredExamApiTests(LoggedInTestCase):
self._create_unstarted_exam_attempt() self._create_unstarted_exam_attempt()
exam_attempt = get_exam_attempt(self.proctored_exam_id, self.user_id) 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['proctored_exam']['id'], self.proctored_exam_id)
self.assertEqual(exam_attempt['user_id'], self.user_id) self.assertEqual(exam_attempt['user']['id'], self.user_id)
def test_start_uncreated_attempt(self): def test_start_uncreated_attempt(self):
""" """
...@@ -336,6 +338,19 @@ class ProctoredExamApiTests(LoggedInTestCase): ...@@ -336,6 +338,19 @@ class ProctoredExamApiTests(LoggedInTestCase):
) )
self.assertEqual(proctored_exam_student_attempt.id, proctored_exam_attempt_id) 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): def test_stop_a_non_started_exam(self):
""" """
Stop an exam attempt that had not started yet. Stop an exam attempt that had not started yet.
...@@ -371,6 +386,47 @@ class ProctoredExamApiTests(LoggedInTestCase): ...@@ -371,6 +386,47 @@ class ProctoredExamApiTests(LoggedInTestCase):
self.assertEqual(len(student_active_exams[0]['allowances']), 2) self.assertEqual(len(student_active_exams[0]['allowances']), 2)
self.assertEqual(len(student_active_exams[1]['allowances']), 0) 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): def test_get_student_view(self):
""" """
Test for get_student_view promting the user to take the exam Test for get_student_view promting the user to take the exam
......
""" """
All tests for the models.py 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 ( from .utils import (
LoggedInTestCase LoggedInTestCase
...@@ -104,3 +105,40 @@ class ProctoredExamModelTests(LoggedInTestCase): ...@@ -104,3 +105,40 @@ class ProctoredExamModelTests(LoggedInTestCase):
proctored_exam_student_history = ProctoredExamStudentAllowanceHistory.objects.filter(user_id=1) proctored_exam_student_history = ProctoredExamStudentAllowanceHistory.objects.filter(user_id=1)
self.assertEqual(len(proctored_exam_student_history), 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 All tests for the proctored_exams.py
""" """
...@@ -429,10 +430,50 @@ class TestStudentProctoredExamAttempt(LoggedInTestCase): ...@@ -429,10 +430,50 @@ class TestStudentProctoredExamAttempt(LoggedInTestCase):
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
response_data = json.loads(response.content) response_data = json.loads(response.content)
self.assertEqual(response_data['id'], attempt_id) 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.assertIsNotNone(response_data['started_at'])
self.assertIsNone(response_data['completed_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): def test_read_others_attempt(self):
""" """
Confirms that we cnanot read someone elses attempt Confirms that we cnanot read someone elses attempt
...@@ -547,6 +588,88 @@ class TestStudentProctoredExamAttempt(LoggedInTestCase): ...@@ -547,6 +588,88 @@ class TestStudentProctoredExamAttempt(LoggedInTestCase):
response_data = json.loads(response.content) response_data = json.loads(response.content)
self.assertEqual(response_data['exam_attempt_id'], old_attempt_id) 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): def test_stop_others_attempt(self):
""" """
Start an exam (create an exam attempt) Start an exam (create an exam attempt)
......
...@@ -36,6 +36,17 @@ urlpatterns = patterns( # pylint: disable=invalid-name ...@@ -36,6 +36,17 @@ urlpatterns = patterns( # pylint: disable=invalid-name
name='edx_proctoring.proctored_exam.attempt' name='edx_proctoring.proctored_exam.attempt'
), ),
url( 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$', r'edx_proctoring/v1/proctored_exam/attempt$',
views.StudentProctoredExamAttemptCollection.as_view(), views.StudentProctoredExamAttemptCollection.as_view(),
name='edx_proctoring.proctored_exam.attempt.collection' name='edx_proctoring.proctored_exam.attempt.collection'
......
...@@ -12,6 +12,7 @@ from django.core.urlresolvers import reverse, NoReverseMatch ...@@ -12,6 +12,7 @@ from django.core.urlresolvers import reverse, NoReverseMatch
from rest_framework import status from rest_framework import status
from rest_framework.response import Response from rest_framework.response import Response
from django.core.paginator import Paginator, EmptyPage, PageNotAnInteger
from edx_proctoring.api import ( from edx_proctoring.api import (
create_exam, create_exam,
update_exam, update_exam,
...@@ -26,7 +27,9 @@ from edx_proctoring.api import ( ...@@ -26,7 +27,9 @@ from edx_proctoring.api import (
get_allowances_for_course, get_allowances_for_course,
get_all_exams_for_course, get_all_exams_for_course,
get_exam_attempt_by_id, get_exam_attempt_by_id,
) get_all_exam_attempts,
remove_exam_attempt_by_id,
get_filtered_exam_attempts)
from edx_proctoring.exceptions import ( from edx_proctoring.exceptions import (
ProctoredBaseException, ProctoredBaseException,
ProctoredExamNotFoundException, ProctoredExamNotFoundException,
...@@ -38,6 +41,8 @@ from edx_proctoring.serializers import ProctoredExamSerializer ...@@ -38,6 +41,8 @@ from edx_proctoring.serializers import ProctoredExamSerializer
from .utils import AuthenticatedAPIView from .utils import AuthenticatedAPIView
ATTEMPTS_PER_PAGE = 25
LOG = logging.getLogger("edx_proctoring_views") LOG = logging.getLogger("edx_proctoring_views")
...@@ -251,7 +256,7 @@ class StudentProctoredExamAttempt(AuthenticatedAPIView): ...@@ -251,7 +256,7 @@ class StudentProctoredExamAttempt(AuthenticatedAPIView):
raise StudentExamAttemptDoesNotExistsException(err_msg) raise StudentExamAttemptDoesNotExistsException(err_msg)
# make sure the the attempt belongs to the calling user_id # 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 = ( err_msg = (
'Attempted to access attempt_id {attempt_id} but ' 'Attempted to access attempt_id {attempt_id} but '
'does not have access to it.'.format( 'does not have access to it.'.format(
...@@ -288,7 +293,7 @@ class StudentProctoredExamAttempt(AuthenticatedAPIView): ...@@ -288,7 +293,7 @@ class StudentProctoredExamAttempt(AuthenticatedAPIView):
raise StudentExamAttemptDoesNotExistsException(err_msg) raise StudentExamAttemptDoesNotExistsException(err_msg)
# make sure the the attempt belongs to the calling user_id # 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 = ( err_msg = (
'Attempted to access attempt_id {attempt_id} but ' 'Attempted to access attempt_id {attempt_id} but '
'does not have access to it.'.format( 'does not have access to it.'.format(
...@@ -298,7 +303,7 @@ class StudentProctoredExamAttempt(AuthenticatedAPIView): ...@@ -298,7 +303,7 @@ class StudentProctoredExamAttempt(AuthenticatedAPIView):
raise ProctoredExamPermissionDenied(err_msg) raise ProctoredExamPermissionDenied(err_msg)
exam_attempt_id = stop_exam_attempt( exam_attempt_id = stop_exam_attempt(
exam_id=attempt['proctored_exam_id'], exam_id=attempt['proctored_exam']['id'],
user_id=request.user.id user_id=request.user.id
) )
return Response({"exam_attempt_id": exam_attempt_id}) return Response({"exam_attempt_id": exam_attempt_id})
...@@ -309,6 +314,32 @@ class StudentProctoredExamAttempt(AuthenticatedAPIView): ...@@ -309,6 +314,32 @@ class StudentProctoredExamAttempt(AuthenticatedAPIView):
data={"detail": str(ex)} 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): class StudentProctoredExamAttemptCollection(AuthenticatedAPIView):
""" """
...@@ -358,10 +389,44 @@ class StudentProctoredExamAttemptCollection(AuthenticatedAPIView): ...@@ -358,10 +389,44 @@ class StudentProctoredExamAttemptCollection(AuthenticatedAPIView):
return the status of the exam attempt 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. 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) 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