Commit 50aa9505 by chrisndodge

Merge pull request #132 from edx/rc/2015-09-09

Rc/2015 09 09
parents 83de0d05 a4cf90f2
...@@ -14,7 +14,9 @@ from django.utils.translation import ugettext as _ ...@@ -14,7 +14,9 @@ from django.utils.translation import ugettext as _
from django.conf import settings from django.conf import settings
from django.template import Context, loader from django.template import Context, loader
from django.core.urlresolvers import reverse, NoReverseMatch from django.core.urlresolvers import reverse, NoReverseMatch
from django.core.mail.message import EmailMessage
from edx_proctoring import constants
from edx_proctoring.exceptions import ( from edx_proctoring.exceptions import (
ProctoredExamAlreadyExists, ProctoredExamAlreadyExists,
ProctoredExamNotFoundException, ProctoredExamNotFoundException,
...@@ -75,10 +77,10 @@ def create_exam(course_id, content_id, exam_name, time_limit_mins, ...@@ -75,10 +77,10 @@ def create_exam(course_id, content_id, exam_name, time_limit_mins,
) )
log_msg = ( log_msg = (
'Created exam ({exam_id}) with parameters: course_id={course_id}, ' u'Created exam ({exam_id}) with parameters: course_id={course_id}, '
'content_id={content_id}, exam_name={exam_name}, time_limit_mins={time_limit_mins}, ' u'content_id={content_id}, exam_name={exam_name}, time_limit_mins={time_limit_mins}, '
'is_proctored={is_proctored}, is_practice_exam={is_practice_exam}, ' u'is_proctored={is_proctored}, is_practice_exam={is_practice_exam}, '
'external_id={external_id}, is_active={is_active}'.format( u'external_id={external_id}, is_active={is_active}'.format(
exam_id=proctored_exam.id, exam_id=proctored_exam.id,
course_id=course_id, content_id=content_id, course_id=course_id, content_id=content_id,
exam_name=exam_name, time_limit_mins=time_limit_mins, exam_name=exam_name, time_limit_mins=time_limit_mins,
...@@ -101,10 +103,10 @@ def update_exam(exam_id, exam_name=None, time_limit_mins=None, ...@@ -101,10 +103,10 @@ def update_exam(exam_id, exam_name=None, time_limit_mins=None,
""" """
log_msg = ( log_msg = (
'Updating exam_id {exam_id} with parameters ' u'Updating exam_id {exam_id} with parameters '
'exam_name={exam_name}, time_limit_mins={time_limit_mins}, ' u'exam_name={exam_name}, time_limit_mins={time_limit_mins}, '
'is_proctored={is_proctored}, is_practice_exam={is_practice_exam}, ' u'is_proctored={is_proctored}, is_practice_exam={is_practice_exam}, '
'external_id={external_id}, is_active={is_active}'.format( u'external_id={external_id}, is_active={is_active}'.format(
exam_id=exam_id, exam_name=exam_name, time_limit_mins=time_limit_mins, exam_id=exam_id, exam_name=exam_name, time_limit_mins=time_limit_mins,
is_proctored=is_proctored, is_practice_exam=is_practice_exam, is_proctored=is_proctored, is_practice_exam=is_practice_exam,
external_id=external_id, is_active=is_active external_id=external_id, is_active=is_active
...@@ -234,7 +236,7 @@ def _check_for_attempt_timeout(attempt): ...@@ -234,7 +236,7 @@ def _check_for_attempt_timeout(attempt):
has_started_exam = ( has_started_exam = (
attempt and attempt and
attempt.get('started_at') and attempt.get('started_at') and
attempt.get('status') == ProctoredExamStudentAttemptStatus.started ProctoredExamStudentAttemptStatus.is_incomplete_status(attempt.get('status'))
) )
if has_started_exam: if has_started_exam:
now_utc = datetime.now(pytz.UTC) now_utc = datetime.now(pytz.UTC)
...@@ -556,6 +558,7 @@ def update_attempt_status(exam_id, user_id, to_status, raise_if_not_found=True, ...@@ -556,6 +558,7 @@ def update_attempt_status(exam_id, user_id, to_status, raise_if_not_found=True,
# see if the status transition this changes credit requirement status # see if the status transition this changes credit requirement status
if ProctoredExamStudentAttemptStatus.needs_credit_status_update(to_status): if ProctoredExamStudentAttemptStatus.needs_credit_status_update(to_status):
# trigger credit workflow, as needed # trigger credit workflow, as needed
credit_service = get_runtime_service('credit') credit_service = get_runtime_service('credit')
...@@ -643,9 +646,82 @@ def update_attempt_status(exam_id, user_id, to_status, raise_if_not_found=True, ...@@ -643,9 +646,82 @@ def update_attempt_status(exam_id, user_id, to_status, raise_if_not_found=True,
exam_attempt_obj.started_at = datetime.now(pytz.UTC) exam_attempt_obj.started_at = datetime.now(pytz.UTC)
exam_attempt_obj.save() exam_attempt_obj.save()
# email will be send when the exam is proctored and not practice exam
# and the status is verified, submitted or rejected
should_send_status_email = (
exam_attempt_obj.taking_as_proctored and
not exam_attempt_obj.is_sample_attempt and
ProctoredExamStudentAttemptStatus.needs_status_change_email(exam_attempt_obj.status)
)
if should_send_status_email:
# trigger credit workflow, as needed
credit_service = get_runtime_service('credit')
# call service to get course name.
credit_state = credit_service.get_credit_state(
exam_attempt_obj.user_id,
exam_attempt_obj.proctored_exam.course_id,
return_course_name=True
)
send_proctoring_attempt_status_email(
exam_attempt_obj,
credit_state.get('course_name', _('your course'))
)
return exam_attempt_obj.id return exam_attempt_obj.id
def send_proctoring_attempt_status_email(exam_attempt_obj, course_name):
"""
Sends an email about change in proctoring attempt status.
"""
course_info_url = ''
email_template = loader.get_template('emails/proctoring_attempt_status_email.html')
try:
course_info_url = reverse('courseware.views.course_info', args=[exam_attempt_obj.proctored_exam.course_id])
except NoReverseMatch:
# we are allowing a failure here since we can't guarantee
# that we are running in-proc with the edx-platform LMS
# (for example unit tests)
pass
scheme = 'https' if getattr(settings, 'HTTPS', 'on') == 'on' else 'http'
course_url = '{scheme}://{site_name}{course_info_url}'.format(
scheme=scheme,
site_name=constants.SITE_NAME,
course_info_url=course_info_url
)
body = email_template.render(
Context({
'course_url': course_url,
'course_name': course_name,
'exam_name': exam_attempt_obj.proctored_exam.exam_name,
'status': ProctoredExamStudentAttemptStatus.get_status_alias(exam_attempt_obj.status),
'platform': constants.PLATFORM_NAME,
'contact_email': constants.CONTACT_EMAIL,
})
)
subject = (
_('Proctoring Session Results Update for {course_name} {exam_name}').format(
course_name=course_name,
exam_name=exam_attempt_obj.proctored_exam.exam_name
)
)
email = EmailMessage(
body=body,
from_email=constants.FROM_EMAIL,
to=[exam_attempt_obj.user.email],
subject=subject
)
email.content_subtype = "html"
email.send()
def remove_exam_attempt(attempt_id): def remove_exam_attempt(attempt_id):
""" """
Removes an exam attempt given the attempt id. Removes an exam attempt given the attempt id.
...@@ -666,13 +742,28 @@ def remove_exam_attempt(attempt_id): ...@@ -666,13 +742,28 @@ def remove_exam_attempt(attempt_id):
raise StudentExamAttemptDoesNotExistsException(err_msg) raise StudentExamAttemptDoesNotExistsException(err_msg)
username = existing_attempt.user.username username = existing_attempt.user.username
user_id = existing_attempt.user.id
course_id = existing_attempt.proctored_exam.course_id course_id = existing_attempt.proctored_exam.course_id
content_id = existing_attempt.proctored_exam.content_id content_id = existing_attempt.proctored_exam.content_id
to_status = existing_attempt.status
existing_attempt.delete_exam_attempt() existing_attempt.delete_exam_attempt()
instructor_service = get_runtime_service('instructor') instructor_service = get_runtime_service('instructor')
if instructor_service: if instructor_service:
instructor_service.delete_student_attempt(username, course_id, content_id) instructor_service.delete_student_attempt(username, course_id, content_id)
# see if the status transition this changes credit requirement status
if ProctoredExamStudentAttemptStatus.needs_credit_status_update(to_status):
# trigger credit workflow, as needed
credit_service = get_runtime_service('credit')
credit_service.remove_credit_requirement_status(
user_id=user_id,
course_key_or_id=course_id,
req_namespace=u'proctored_exam',
req_name=content_id
)
def get_all_exams_for_course(course_id): def get_all_exams_for_course(course_id):
""" """
......
...@@ -174,6 +174,19 @@ class SoftwareSecureTests(TestCase): ...@@ -174,6 +174,19 @@ class SoftwareSecureTests(TestCase):
attempt_id = create_exam_attempt(exam_id, self.user.id, taking_as_proctored=True) attempt_id = create_exam_attempt(exam_id, self.user.id, taking_as_proctored=True)
self.assertIsNotNone(attempt_id) self.assertIsNotNone(attempt_id)
# try unicode exam name, also
exam_id = create_exam(
course_id='foo/bar/baz',
content_id='content_unicode_name',
exam_name=u'अआईउऊऋऌ अआईउऊऋऌ',
time_limit_mins=10,
is_proctored=True
)
with HTTMock(mock_response_content):
attempt_id = create_exam_attempt(exam_id, self.user.id, taking_as_proctored=True)
self.assertIsNotNone(attempt_id)
def test_failing_register_attempt(self): def test_failing_register_attempt(self):
""" """
Makes sure we can register an attempt Makes sure we can register an attempt
......
"""
Lists of constants that can be used in the edX proctoring
"""
from django.conf import settings
SITE_NAME = (
settings.PROCTORING_SETTINGS['SITE_NAME'] if
'SITE_NAME' in settings.PROCTORING_SETTINGS else settings.SITE_NAME
)
PLATFORM_NAME = (
settings.PROCTORING_SETTINGS['PLATFORM_NAME'] if
'PLATFORM_NAME' in settings.PROCTORING_SETTINGS else settings.PLATFORM_NAME
)
FROM_EMAIL = (
settings.PROCTORING_SETTINGS['STATUS_EMAIL_FROM_ADDRESS'] if
'STATUS_EMAIL_FROM_ADDRESS' in settings.PROCTORING_SETTINGS else settings.DEFAULT_FROM_EMAIL
)
# Note that CONTACT_EMAIL is not defined in Studio runtimes
CONTACT_EMAIL = (
settings.PROCTORING_SETTINGS['CONTACT_EMAIL'] if
'CONTACT_EMAIL' in settings.PROCTORING_SETTINGS else getattr(settings, 'CONTACT_EMAIL', FROM_EMAIL)
)
...@@ -6,6 +6,7 @@ from django.db.models import Q ...@@ -6,6 +6,7 @@ from django.db.models import Q
from django.db.models.signals import post_save, pre_delete from django.db.models.signals import post_save, pre_delete
from django.dispatch import receiver from django.dispatch import receiver
from model_utils.models import TimeStampedModel from model_utils.models import TimeStampedModel
from django.utils.translation import ugettext as _
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
...@@ -134,6 +135,13 @@ class ProctoredExamStudentAttemptStatus(object): ...@@ -134,6 +135,13 @@ class ProctoredExamStudentAttemptStatus(object):
# the exam is believed to be in error # the exam is believed to be in error
error = 'error' error = 'error'
# status alias for sending email
status_alias_mapping = {
submitted: _('pending'),
verified: _('satisfactory'),
rejected: _('unsatisfactory')
}
@classmethod @classmethod
def is_completed_status(cls, status): def is_completed_status(cls, status):
""" """
...@@ -141,10 +149,8 @@ class ProctoredExamStudentAttemptStatus(object): ...@@ -141,10 +149,8 @@ class ProctoredExamStudentAttemptStatus(object):
that it cannot go backwards in state that it cannot go backwards in state
""" """
return status in [ return status in [
ProctoredExamStudentAttemptStatus.declined, ProctoredExamStudentAttemptStatus.timed_out, cls.declined, cls.timed_out, cls.submitted, cls.verified, cls.rejected,
ProctoredExamStudentAttemptStatus.submitted, ProctoredExamStudentAttemptStatus.verified, cls.not_reviewed, cls.error
ProctoredExamStudentAttemptStatus.rejected, ProctoredExamStudentAttemptStatus.not_reviewed,
ProctoredExamStudentAttemptStatus.error
] ]
@classmethod @classmethod
...@@ -153,9 +159,7 @@ class ProctoredExamStudentAttemptStatus(object): ...@@ -153,9 +159,7 @@ class ProctoredExamStudentAttemptStatus(object):
Returns a boolean if the passed in status is in an "incomplete" state. Returns a boolean if the passed in status is in an "incomplete" state.
""" """
return status in [ return status in [
ProctoredExamStudentAttemptStatus.eligible, ProctoredExamStudentAttemptStatus.created, cls.eligible, cls.created, cls.ready_to_start, cls.started, cls.ready_to_submit
ProctoredExamStudentAttemptStatus.ready_to_start, ProctoredExamStudentAttemptStatus.started,
ProctoredExamStudentAttemptStatus.ready_to_submit
] ]
@classmethod @classmethod
...@@ -164,9 +168,8 @@ class ProctoredExamStudentAttemptStatus(object): ...@@ -164,9 +168,8 @@ class ProctoredExamStudentAttemptStatus(object):
Returns a boolean if the passed in to_status calls for an update to the credit requirement status. Returns a boolean if the passed in to_status calls for an update to the credit requirement status.
""" """
return to_status in [ return to_status in [
ProctoredExamStudentAttemptStatus.verified, ProctoredExamStudentAttemptStatus.rejected, cls.verified, cls.rejected, cls.declined, cls.not_reviewed, cls.submitted,
ProctoredExamStudentAttemptStatus.declined, ProctoredExamStudentAttemptStatus.not_reviewed, cls.error
ProctoredExamStudentAttemptStatus.submitted, ProctoredExamStudentAttemptStatus.error
] ]
@classmethod @classmethod
...@@ -176,10 +179,27 @@ class ProctoredExamStudentAttemptStatus(object): ...@@ -176,10 +179,27 @@ class ProctoredExamStudentAttemptStatus(object):
to other attempts. to other attempts.
""" """
return to_status in [ return to_status in [
ProctoredExamStudentAttemptStatus.rejected, cls.rejected, cls.declined
ProctoredExamStudentAttemptStatus.declined
] ]
@classmethod
def needs_status_change_email(cls, to_status):
"""
We need to send out emails for rejected, verified and submitted statuses.
"""
return to_status in [
cls.rejected, cls.submitted, cls.verified
]
@classmethod
def get_status_alias(cls, status):
"""
Returns status alias used in email
"""
return cls.status_alias_mapping.get(status, '')
class ProctoredExamStudentAttemptManager(models.Manager): class ProctoredExamStudentAttemptManager(models.Manager):
""" """
......
...@@ -16,32 +16,24 @@ ...@@ -16,32 +16,24 @@
course_id: null, course_id: null,
lastFetched: new Date() lastFetched: new Date()
}, },
getRemainingSeconds: function () { getFormattedRemainingTime: function (secondsLeft) {
var currentTime = (new Date()).getTime();
var lastFetched = this.get('lastFetched').getTime();
var totalSeconds = this.get('time_remaining_seconds') - (currentTime - lastFetched) / 1000;
return totalSeconds;
},
getFormattedRemainingTime: function () {
var totalSeconds = this.getRemainingSeconds();
/* since we can have a small grace period, we can end in the negative numbers */ /* since we can have a small grace period, we can end in the negative numbers */
if (totalSeconds < 0) if (secondsLeft < 0)
totalSeconds = 0; secondsLeft = 0;
var hours = parseInt(totalSeconds / 3600) % 24; var hours = parseInt(secondsLeft / 3600) % 24;
var minutes = parseInt(totalSeconds / 60) % 60; var minutes = parseInt(secondsLeft / 60) % 60;
var seconds = Math.floor(totalSeconds % 60); var seconds = Math.floor(secondsLeft % 60);
return hours + ":" + (minutes < 10 ? "0" + minutes : minutes) return hours + ":" + (minutes < 10 ? "0" + minutes : minutes)
+ ":" + (seconds < 10 ? "0" + seconds : seconds); + ":" + (seconds < 10 ? "0" + seconds : seconds);
}, },
getRemainingTimeState: function () { getRemainingTimeState: function (secondsLeft) {
var totalSeconds = this.getRemainingSeconds(); if (secondsLeft > this.get('low_threshold_sec')) {
if (totalSeconds > this.get('low_threshold_sec')) {
return ""; return "";
} }
else if (totalSeconds <= this.get('low_threshold_sec') && totalSeconds > this.get('critically_low_threshold_sec')) { else if (secondsLeft <= this.get('low_threshold_sec') && secondsLeft > this.get('critically_low_threshold_sec')) {
// returns the class name that has some css properties // returns the class name that has some css properties
// and it displays the user with the waring message if // and it displays the user with the waring message if
// total seconds is less than the low_threshold value. // total seconds is less than the low_threshold value.
......
...@@ -5,6 +5,20 @@ var edx = edx || {}; ...@@ -5,6 +5,20 @@ var edx = edx || {};
edx.instructor_dashboard = edx.instructor_dashboard || {}; edx.instructor_dashboard = edx.instructor_dashboard || {};
edx.instructor_dashboard.proctoring = edx.instructor_dashboard.proctoring || {}; edx.instructor_dashboard.proctoring = edx.instructor_dashboard.proctoring || {};
var examStatusReadableFormat = {
eligible: gettext('Eligible'),
created: gettext('Created'),
ready_to_start: gettext('Ready to start'),
started: gettext('Started'),
ready_to_submit: gettext('Ready to submit'),
declined: gettext('Declined'),
timed_out: gettext('Timed out'),
submitted: gettext('Submitted'),
verified: gettext('Verified'),
rejected: gettext('Rejected'),
not_reviewed: gettext('Not reviewed'),
error: gettext('Error')
};
var viewHelper = { var viewHelper = {
getDateFormat: function(date) { getDateFormat: function(date) {
if (date) { if (date) {
...@@ -14,6 +28,14 @@ var edx = edx || {}; ...@@ -14,6 +28,14 @@ var edx = edx || {};
return '---'; return '---';
} }
},
getExamAttemptStatus: function(status) {
if (status in examStatusReadableFormat) {
return examStatusReadableFormat[status]
}
else {
return status
}
} }
}; };
edx.instructor_dashboard.proctoring.ProctoredExamAttemptView = Backbone.View.extend({ edx.instructor_dashboard.proctoring.ProctoredExamAttemptView = Backbone.View.extend({
......
...@@ -8,12 +8,16 @@ var edx = edx || {}; ...@@ -8,12 +8,16 @@ var edx = edx || {};
edx.coursware.proctored_exam.ProctoredExamView = Backbone.View.extend({ edx.coursware.proctored_exam.ProctoredExamView = Backbone.View.extend({
initialize: function (options) { initialize: function (options) {
_.bindAll(this, "detectScroll");
this.$el = options.el; this.$el = options.el;
this.timerBarTopPosition = this.$el.position().top;
this.courseNavBarMarginTop = this.timerBarTopPosition - 3;
this.model = options.model; this.model = options.model;
this.templateId = options.proctored_template; this.templateId = options.proctored_template;
this.template = null; this.template = null;
this.timerId = null; this.timerId = null;
this.timerTick = 0; this.timerTick = 0;
this.secondsLeft = 0;
/* give an extra 5 seconds where the timer holds at 00:00 before page refreshes */ /* give an extra 5 seconds where the timer holds at 00:00 before page refreshes */
this.grace_period_secs = 5; this.grace_period_secs = 5;
...@@ -43,11 +47,23 @@ var edx = edx || {}; ...@@ -43,11 +47,23 @@ var edx = edx || {};
/* will call into the rendering */ /* will call into the rendering */
this.model.fetch(); this.model.fetch();
}, },
detectScroll: function(event) {
if ($(event.currentTarget).scrollTop() > this.timerBarTopPosition) {
$(".proctored_exam_status").addClass('is-fixed');
$(".wrapper-course-material").css('margin-top', this.courseNavBarMarginTop + 'px');
}
else {
$(".proctored_exam_status").removeClass('is-fixed');
$(".wrapper-course-material").css('margin-top', '0');
}
},
modelChanged: function () { modelChanged: function () {
// if we are a proctored exam, then we need to alert user that he/she // if we are a proctored exam, then we need to alert user that he/she
// should not be navigating around the courseware // should not be navigating around the courseware
var taking_as_proctored = this.model.get('taking_as_proctored'); var taking_as_proctored = this.model.get('taking_as_proctored');
var time_left = this.model.get('time_remaining_seconds') > 0; var time_left = this.model.get('time_remaining_seconds') > 0;
this.secondsLeft = this.model.get('time_remaining_seconds');
var status = this.model.get('attempt_status'); var status = this.model.get('attempt_status');
var in_courseware = document.location.href.indexOf('/courses/' + this.model.get('course_id') + '/courseware/') > -1; var in_courseware = document.location.href.indexOf('/courses/' + this.model.get('course_id') + '/courseware/') > -1;
...@@ -67,6 +83,9 @@ var edx = edx || {}; ...@@ -67,6 +83,9 @@ var edx = edx || {};
this.model.get('time_remaining_seconds') > 0 && this.model.get('time_remaining_seconds') > 0 &&
this.model.get('attempt_status') !== 'error' this.model.get('attempt_status') !== 'error'
) { ) {
// add callback on scroll event
$(window).bind('scroll', this.detectScroll);
var html = this.template(this.model.toJSON()); var html = this.template(this.model.toJSON());
this.$el.html(html); this.$el.html(html);
this.$el.show(); this.$el.show();
...@@ -92,6 +111,10 @@ var edx = edx || {}; ...@@ -92,6 +111,10 @@ var edx = edx || {};
}); });
}); });
} }
else {
// remove callback on scroll event
$(window).unbind('scroll', this.detectScroll);
}
} }
return this; return this;
}, },
...@@ -104,6 +127,7 @@ var edx = edx || {}; ...@@ -104,6 +127,7 @@ var edx = edx || {};
}, },
updateRemainingTime: function (self) { updateRemainingTime: function (self) {
self.timerTick ++; self.timerTick ++;
self.secondsLeft --;
if (self.timerTick % 5 === 0){ if (self.timerTick % 5 === 0){
var url = self.model.url + '/' + self.model.get('attempt_id'); var url = self.model.url + '/' + self.model.get('attempt_id');
$.ajax(url).success(function(data) { $.ajax(url).success(function(data) {
...@@ -115,12 +139,15 @@ var edx = edx || {}; ...@@ -115,12 +139,15 @@ var edx = edx || {};
// refresh the page when the timer expired // refresh the page when the timer expired
location.reload(); location.reload();
} }
else {
self.secondsLeft = data.time_remaining_seconds;
}
}); });
} }
self.$el.find('div.exam-timer').removeClass("low-time warning critical"); self.$el.find('div.exam-timer').removeClass("low-time warning critical");
self.$el.find('div.exam-timer').addClass(self.model.getRemainingTimeState()); self.$el.find('div.exam-timer').addClass(self.model.getRemainingTimeState(self.secondsLeft));
self.$el.find('span#time_remaining_id b').html(self.model.getFormattedRemainingTime()); self.$el.find('span#time_remaining_id b').html(self.model.getFormattedRemainingTime(self.secondsLeft));
if (self.model.getRemainingSeconds() <= -self.grace_period_secs) { if (self.secondsLeft <= -self.grace_period_secs) {
clearInterval(self.timerId); // stop the timer once the time finishes. clearInterval(self.timerId); // stop the timer once the time finishes.
$(window).unbind('beforeunload', this.unloadMessage); $(window).unbind('beforeunload', this.unloadMessage);
// refresh the page when the timer expired // refresh the page when the timer expired
......
...@@ -46,31 +46,36 @@ describe('ProctoredExamView', function () { ...@@ -46,31 +46,36 @@ describe('ProctoredExamView', function () {
expect(this.proctored_exam_view.$el.find('a')).toContainHtml(this.model.get('exam_display_name')); 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 () { it('changes behavior when clock time decreases low threshold', function () {
spyOn(this.model, 'getRemainingSeconds').and.callFake(function() { this.proctored_exam_view.secondsLeft = 25;
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(); this.proctored_exam_view.render();
expect(this.proctored_exam_view.$el.find('div.exam-timer')).toHaveClass('low-time warning'); 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 () { it('changes behavior when clock time decreases critically low threshold', function () {
spyOn(this.model, 'getRemainingSeconds').and.callFake(function () { this.proctored_exam_view.secondsLeft = 5;
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(); this.proctored_exam_view.render();
expect(this.proctored_exam_view.$el.find('div.exam-timer')).toHaveClass('low-time critical'); expect(this.proctored_exam_view.$el.find('div.exam-timer')).toHaveClass('low-time critical');
}); });
it("reload the page when the exam time finishes", function(){ it("reload the page when the exam time finishes", function(){
spyOn(this.model, 'getRemainingSeconds').and.callFake(function() { this.proctored_exam_view.secondsLeft = -10;
return -10;
});
expect(this.model.getRemainingSeconds()).toEqual(-10);
var reloadPage = spyOn(this.proctored_exam_view, 'reloadPage'); var reloadPage = spyOn(this.proctored_exam_view, 'reloadPage');
this.proctored_exam_view.render(); this.proctored_exam_view.updateRemainingTime(this.proctored_exam_view);
expect(reloadPage).toHaveBeenCalled();
});
it("resets the remainig exam time after the ajax response", function(){
this.server.respondWith("GET", "/api/edx_proctoring/v1/proctored_exam/attempt/" + this.proctored_exam_view.model.get('attempt_id'),
[
200,
{"Content-Type": "application/json"},
JSON.stringify({
time_remaining_seconds: -10
})
]
);
this.proctored_exam_view.timerTick = 4; // to make the ajax call.
var reloadPage = spyOn(this.proctored_exam_view, 'reloadPage');
this.proctored_exam_view.updateRemainingTime(this.proctored_exam_view);
this.server.respond();
this.proctored_exam_view.updateRemainingTime(this.proctored_exam_view);
expect(reloadPage).toHaveBeenCalled(); expect(reloadPage).toHaveBeenCalled();
}); });
}); });
<div class="wrapper-content wrapper"> <div class="wrapper-content wrapper">
<% var is_proctored_attempts = proctored_exam_attempts.length !== 0 %> <% var is_proctored_attempts = proctored_exam_attempts.length !== 0 %>
<section class="content"> <section class="content exam-attempts-content">
<div class="top-header"> <div class="top-header">
<div class='search-attempts'> <div class='search-attempts'>
<input type="text" id="search_attempt_id" placeholder="e.g johndoe or john.doe@gmail.com" <input type="text" id="search_attempt_id" placeholder="e.g johndoe or john.doe@gmail.com"
...@@ -81,7 +81,7 @@ ...@@ -81,7 +81,7 @@
<tr class="exam-attempt-headings"> <tr class="exam-attempt-headings">
<th class="username"><%- gettext("Username") %></th> <th class="username"><%- gettext("Username") %></th>
<th class="exam-name"><%- gettext("Exam Name") %></th> <th class="exam-name"><%- gettext("Exam Name") %></th>
<th class="attempt-allowed-time"><%- gettext("Allowed Time (Minutes)") %> </th> <th class="attempt-allowed-time"><%- gettext("Time Limit") %> </th>
<th class="attempt-started-at"><%- gettext("Started At") %></th> <th class="attempt-started-at"><%- gettext("Started At") %></th>
<th class="attempt-completed-at"><%- gettext("Completed At") %> </th> <th class="attempt-completed-at"><%- gettext("Completed At") %> </th>
<th class="attempt-status"><%- gettext("Status") %> </th> <th class="attempt-status"><%- gettext("Status") %> </th>
...@@ -103,7 +103,7 @@ ...@@ -103,7 +103,7 @@
<td> <%= getDateFormat(proctored_exam_attempt.completed_at) %></td> <td> <%= getDateFormat(proctored_exam_attempt.completed_at) %></td>
<td> <td>
<% if (proctored_exam_attempt.status){ %> <% if (proctored_exam_attempt.status){ %>
<%= proctored_exam_attempt.status %> <%= getExamAttemptStatus(proctored_exam_attempt.status) %>
<% } else { %> <% } else { %>
N/A N/A
<% } %> <% } %>
......
"""
We need python think this is a python module
"""
{% load i18n %}
{% blocktrans %}
This email is to let you know that the status of your proctoring session review for {{ exam_name }} in
<a href="{{ course_url }}">{{ course_name }} </a> is {{ status }}. If you have any questions about proctoring,
contact {{ platform }} support at {{ contact_email }}.
{% endblocktrans %}
\ No newline at end of file
...@@ -25,7 +25,7 @@ ...@@ -25,7 +25,7 @@
{% endblocktrans %} {% endblocktrans %}
</p> </p>
<i class="fa fa-arrow-circle-right start-timed-exam" data-ajax-url="{{enter_exam_endpoint}}" data-exam-id="{{exam_id}}" data-attempt-proctored=true data-start-immediately=false></i> <i class="fa fa-arrow-circle-right"></i>
</button> </button>
<button class="gated-sequence start-timed-exam" data-ajax-url="{{enter_exam_endpoint}}" data-exam-id="{{exam_id}}" data-attempt-proctored=false data-start-immediately=true> <button class="gated-sequence start-timed-exam" data-ajax-url="{{enter_exam_endpoint}}" data-exam-id="{{exam_id}}" data-attempt-proctored=false data-start-immediately=true>
<span><i class="fa fa-unlock"></i></span> <span><i class="fa fa-unlock"></i></span>
...@@ -38,44 +38,75 @@ ...@@ -38,44 +38,75 @@
even if you achieve a passing grade. even if you achieve a passing grade.
{% endblocktrans %} {% endblocktrans %}
</p> </p>
<i class="fa fa-arrow-circle-right start-timed-exam" data-ajax-url="{{enter_exam_endpoint}}" data-exam-id="{{exam_id}}" data-attempt-proctored=false data-start-immediately=true></i> <i class="fa fa-arrow-circle-right"></i>
</button> </button>
</div> </div>
{% include 'proctoring/seq_proctored_exam_footer.html' %} {% include 'proctoring/seq_proctored_exam_footer.html' %}
<script type="text/javascript"> <script type="text/javascript">
$('.start-timed-exam').click(
function(event) {
var action_url = $(this).data('ajax-url');
var exam_id = $(this).data('exam-id');
var attempt_proctored = $(this).data('attempt-proctored');
var start_immediately = $(this).data('start-immediately');
if (!attempt_proctored) { var inProcess = false;
var msg = gettext(
"Are you sure you want to take this exam without proctoring? " + var disableClickEvent = function () {
"You will no longer be eligible to use this course for academic credit." inProcess = true;
); $('body').css('cursor', 'wait');
if (!confirm(msg)) { $('.start-timed-exam').css('cursor', 'wait');
return; };
var enableClickEvent = function () {
inProcess = false;
$('body').css('cursor', 'auto');
$('.start-timed-exam').css('cursor', 'auto');
};
$('.start-timed-exam').click(function () {
if (!inProcess) {
disableClickEvent();
var action_url = $(this).data('ajax-url');
var exam_id = $(this).data('exam-id');
var attempt_proctored = $(this).data('attempt-proctored');
var start_immediately = $(this).data('start-immediately');
if (!attempt_proctored) {
var msg = gettext(
"Are you sure you want to take this exam without proctoring? " +
"You will no longer be eligible to use this course for academic credit."
);
if (!confirm(msg)) {
enableClickEvent();
return;
}
} }
}
if (typeof action_url === "undefined" ) { if (typeof action_url === "undefined") {
enableClickEvent();
return false;
}
var self = $(this);
$.post(
action_url,
{
"exam_id": exam_id,
"attempt_proctored": attempt_proctored,
"start_clock": start_immediately
},
function (data) {
// reload the page, because we've unlocked it
location.reload();
}
).fail(function(){
enableClickEvent();
var msg = gettext(
"There has been a problem starting your exam.\n\n" +
"Possible reasons are that your account has not been fully activated,\n" +
"you have are experiencing a network connection problem, or there has been\n" +
"a service disruption. Please check these and try again."
);
alert(msg);
});
} else {
return false; return false;
} }
$.post(
action_url,
{
"exam_id": exam_id,
"attempt_proctored": attempt_proctored,
"start_clock": start_immediately
},
function(data) {
// reload the page, because we've unlocked it
location.reload();
}
);
} }
); );
</script> </script>
...@@ -28,27 +28,58 @@ ...@@ -28,27 +28,58 @@
{% include 'proctoring/seq_proctored_exam_footer.html' %} {% include 'proctoring/seq_proctored_exam_footer.html' %}
<script type="text/javascript"> <script type="text/javascript">
$('.start-timed-exam').click(
function(event) { var inProcess = false;
var action_url = $(this).data('ajax-url');
var exam_id = $(this).data('exam-id'); var disableClickEvent = function () {
var attempt_proctored = $(this).data('attempt-proctored'); inProcess = true;
var start_immediately = $(this).data('start-immediately'); $('body').css('cursor', 'wait');
if (typeof action_url === "undefined" ) { $('.start-timed-exam').css('cursor', 'wait');
};
var enableClickEvent = function () {
inProcess = false;
$('body').css('cursor', 'auto');
$('.start-timed-exam').css('cursor', 'auto');
};
$('.start-timed-exam').click(function () {
if (!inProcess) {
disableClickEvent();
var action_url = $(this).data('ajax-url');
var exam_id = $(this).data('exam-id');
var attempt_proctored = $(this).data('attempt-proctored');
var start_immediately = $(this).data('start-immediately');
if (typeof action_url === "undefined") {
enableClickEvent();
return false;
}
var self = $(this);
$.post(
action_url,
{
"exam_id": exam_id,
"attempt_proctored": attempt_proctored,
"start_clock": start_immediately
},
function (data) {
// reload the page, because we've unlocked it
location.reload();
}
).fail(function(){
enableClickEvent();
var msg = gettext(
"There has been a problem starting your exam.\n\n" +
"Possible reasons are that your account has not been fully activated,\n" +
"you have are experiencing a network connection problem, or there has been\n" +
"a service disruption. Please check these and try again."
);
alert(msg);
});
} else {
return false; return false;
} }
$.post(
action_url,
{
"exam_id": exam_id,
"attempt_proctored": attempt_proctored,
"start_clock": start_immediately
},
function(data) {
// reload the page, because we've unlocked it
location.reload();
}
);
} }
); );
</script> </script>
...@@ -24,22 +24,50 @@ ...@@ -24,22 +24,50 @@
{% include 'proctoring/seq_timed_exam_footer.html' %} {% include 'proctoring/seq_timed_exam_footer.html' %}
<script type="text/javascript"> <script type="text/javascript">
$('.start-timed-exam').click(
function(event) {
var action_url = $(this).data('ajax-url');
var exam_id = $(this).data('exam-id');
$.post( var inProcess = false;
action_url,
{ var disableClickEvent = function () {
"exam_id": exam_id, inProcess = true;
"start_clock": true $('body').css('cursor', 'wait');
}, $('.start-timed-exam').css('cursor', 'wait');
function(data) { };
// reload the page, because we've unlocked it
location.reload(); var enableClickEvent = function () {
} inProcess = false;
); $('body').css('cursor', 'auto');
$('.start-timed-exam').css('cursor', 'auto');
};
$('.start-timed-exam').click(function () {
if (!inProcess) {
disableClickEvent();
var action_url = $(this).data('ajax-url');
var exam_id = $(this).data('exam-id');
$.post(
action_url,
{
"exam_id": exam_id,
"start_clock": true
},
function (data) {
// reload the page, because we've unlocked it
location.reload();
}
).fail(function(){
enableClickEvent();
var msg = gettext(
"There has been a problem starting your exam.\n\n" +
"Possible reasons are that your account has not been fully activated,\n" +
"you have are experiencing a network connection problem, or there has been\n" +
"a service disruption. Please check these and try again."
);
alert(msg);
});
} else {
return false;
}
} }
); );
</script> </script>
# coding=utf-8
# pylint: disable=too-many-lines, invalid-name # pylint: disable=too-many-lines, invalid-name
""" """
...@@ -5,6 +6,7 @@ All tests for the models.py ...@@ -5,6 +6,7 @@ All tests for the models.py
""" """
import ddt import ddt
from datetime import datetime, timedelta from datetime import datetime, timedelta
from django.core import mail
from mock import patch from mock import patch
import pytz import pytz
from freezegun import freeze_time from freezegun import freeze_time
...@@ -107,6 +109,8 @@ class ProctoredExamApiTests(LoggedInTestCase): ...@@ -107,6 +109,8 @@ class ProctoredExamApiTests(LoggedInTestCase):
self.practice_exam_submitted_msg = 'You have submitted this practice proctored exam' self.practice_exam_submitted_msg = 'You have submitted this practice proctored exam'
self.ready_to_start_msg = 'Your Proctoring Installation and Set Up is Complete' self.ready_to_start_msg = 'Your Proctoring Installation and Set Up is Complete'
self.practice_exam_failed_msg = 'There was a problem with your practice proctoring session' self.practice_exam_failed_msg = 'There was a problem with your practice proctoring session'
self.proctored_exam_email_subject = 'Proctoring Session Results Update'
self.proctored_exam_email_body = 'the status of your proctoring session review'
set_runtime_service('credit', MockCreditService()) set_runtime_service('credit', MockCreditService())
set_runtime_service('instructor', MockInstructorService()) set_runtime_service('instructor', MockInstructorService())
...@@ -182,6 +186,7 @@ class ProctoredExamApiTests(LoggedInTestCase): ...@@ -182,6 +186,7 @@ class ProctoredExamApiTests(LoggedInTestCase):
started_at=started_at if started_at else datetime.now(pytz.UTC), started_at=started_at if started_at else datetime.now(pytz.UTC),
status=ProctoredExamStudentAttemptStatus.started, status=ProctoredExamStudentAttemptStatus.started,
allowed_time_limit_mins=10, allowed_time_limit_mins=10,
taking_as_proctored=is_proctored,
is_sample_attempt=is_sample_attempt is_sample_attempt=is_sample_attempt
) )
...@@ -482,6 +487,43 @@ class ProctoredExamApiTests(LoggedInTestCase): ...@@ -482,6 +487,43 @@ class ProctoredExamApiTests(LoggedInTestCase):
with self.assertRaises(StudentExamAttemptDoesNotExistsException): with self.assertRaises(StudentExamAttemptDoesNotExistsException):
remove_exam_attempt(proctored_exam_student_attempt.id) remove_exam_attempt(proctored_exam_student_attempt.id)
@ddt.data(
(ProctoredExamStudentAttemptStatus.verified, 'satisfied'),
(ProctoredExamStudentAttemptStatus.submitted, 'submitted'),
(ProctoredExamStudentAttemptStatus.error, 'failed')
)
@ddt.unpack
def test_remove_exam_attempt_with_status(self, to_status, requirement_status):
"""
Test to remove the exam attempt which calls
the Credit Service method `remove_credit_requirement_status`.
"""
exam_attempt = self._create_started_exam_attempt()
update_attempt_status(
exam_attempt.proctored_exam_id,
self.user.id,
to_status
)
# make sure the credit requirement status is there
credit_service = get_runtime_service('credit')
credit_status = credit_service.get_credit_state(self.user.id, exam_attempt.proctored_exam.course_id)
self.assertEqual(len(credit_status['credit_requirement_status']), 1)
self.assertEqual(
credit_status['credit_requirement_status'][0]['status'],
requirement_status
)
# now remove exam attempt which calls the credit service method 'remove_credit_requirement_status'
remove_exam_attempt(exam_attempt.proctored_exam_id)
# make sure the credit requirement status is no longer there
credit_status = credit_service.get_credit_state(self.user.id, exam_attempt.proctored_exam.course_id)
self.assertEqual(len(credit_status['credit_requirement_status']), 0)
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.
...@@ -1472,7 +1514,7 @@ class ProctoredExamApiTests(LoggedInTestCase): ...@@ -1472,7 +1514,7 @@ class ProctoredExamApiTests(LoggedInTestCase):
""" """
Assert that we get the expected status summaries Assert that we get the expected status summaries
""" """
set_runtime_service('credit', MockCreditService(course_name=''))
expected = { expected = {
'status': ProctoredExamStudentAttemptStatus.eligible, 'status': ProctoredExamStudentAttemptStatus.eligible,
'short_description': 'Ungraded Practice Exam', 'short_description': 'Ungraded Practice Exam',
...@@ -1558,3 +1600,114 @@ class ProctoredExamApiTests(LoggedInTestCase): ...@@ -1558,3 +1600,114 @@ class ProctoredExamApiTests(LoggedInTestCase):
self.assertEquals(attempt['last_poll_timestamp'], now) self.assertEquals(attempt['last_poll_timestamp'], now)
self.assertEquals(attempt['last_poll_ipaddr'], '1.1.1.1') self.assertEquals(attempt['last_poll_ipaddr'], '1.1.1.1')
@ddt.data(
ProctoredExamStudentAttemptStatus.submitted,
ProctoredExamStudentAttemptStatus.verified,
ProctoredExamStudentAttemptStatus.rejected
)
def test_send_email(self, status):
"""
Assert that email is sent on the following statuses of proctoring attempt.
"""
exam_attempt = self._create_started_exam_attempt()
credit_state = get_runtime_service('credit').get_credit_state(self.user_id, self.course_id)
update_attempt_status(
exam_attempt.proctored_exam_id,
self.user.id,
status
)
self.assertEquals(len(mail.outbox), 1)
self.assertIn(self.proctored_exam_email_subject, mail.outbox[0].subject)
self.assertIn(self.proctored_exam_email_body, mail.outbox[0].body)
self.assertIn(ProctoredExamStudentAttemptStatus.get_status_alias(status), mail.outbox[0].body)
self.assertIn(credit_state['course_name'], mail.outbox[0].body)
def test_send_email_unicode(self):
"""
Assert that email can be sent with a unicode course name.
"""
course_name = u'अआईउऊऋऌ अआईउऊऋऌ'
set_runtime_service('credit', MockCreditService(course_name=course_name))
exam_attempt = self._create_started_exam_attempt()
credit_state = get_runtime_service('credit').get_credit_state(self.user_id, self.course_id)
update_attempt_status(
exam_attempt.proctored_exam_id,
self.user.id,
ProctoredExamStudentAttemptStatus.submitted
)
self.assertEquals(len(mail.outbox), 1)
self.assertIn(self.proctored_exam_email_subject, mail.outbox[0].subject)
self.assertIn(course_name, mail.outbox[0].subject)
self.assertIn(self.proctored_exam_email_body, mail.outbox[0].body)
self.assertIn(
ProctoredExamStudentAttemptStatus.get_status_alias(
ProctoredExamStudentAttemptStatus.submitted
),
mail.outbox[0].body
)
self.assertIn(credit_state['course_name'], mail.outbox[0].body)
@ddt.data(
ProctoredExamStudentAttemptStatus.eligible,
ProctoredExamStudentAttemptStatus.created,
ProctoredExamStudentAttemptStatus.ready_to_start,
ProctoredExamStudentAttemptStatus.started,
ProctoredExamStudentAttemptStatus.ready_to_submit,
ProctoredExamStudentAttemptStatus.declined,
ProctoredExamStudentAttemptStatus.timed_out,
ProctoredExamStudentAttemptStatus.not_reviewed,
ProctoredExamStudentAttemptStatus.error
)
@patch.dict('settings.PROCTORING_SETTINGS', {'ALLOW_TIMED_OUT_STATE': True})
def test_not_send_email(self, status):
"""
Assert that email is not sent on the following statuses of proctoring attempt.
"""
exam_attempt = self._create_started_exam_attempt()
update_attempt_status(
exam_attempt.proctored_exam_id,
self.user.id,
status
)
self.assertEquals(len(mail.outbox), 0)
@ddt.data(
ProctoredExamStudentAttemptStatus.submitted,
ProctoredExamStudentAttemptStatus.verified,
ProctoredExamStudentAttemptStatus.rejected
)
def test_not_send_email_sample_exam(self, status):
"""
Assert that email is not sent when there is practice/sample exam
"""
exam_attempt = self._create_started_exam_attempt(is_sample_attempt=True)
update_attempt_status(
exam_attempt.proctored_exam_id,
self.user.id,
status
)
self.assertEquals(len(mail.outbox), 0)
@ddt.data(
ProctoredExamStudentAttemptStatus.submitted,
ProctoredExamStudentAttemptStatus.verified,
ProctoredExamStudentAttemptStatus.rejected
)
def test_not_send_email_timed_exam(self, status):
"""
Assert that email is not sent when exam is timed/not-proctoring
"""
exam_attempt = self._create_started_exam_attempt(is_proctored=False)
update_attempt_status(
exam_attempt.proctored_exam_id,
self.user.id,
status
)
self.assertEquals(len(mail.outbox), 0)
...@@ -17,23 +17,26 @@ class MockCreditService(object): ...@@ -17,23 +17,26 @@ class MockCreditService(object):
Simple mock of the Credit Service Simple mock of the Credit Service
""" """
def __init__(self, enrollment_mode='verified', profile_fullname='Wolfgang von Strucker'): def __init__(self, enrollment_mode='verified', profile_fullname='Wolfgang von Strucker',
course_name='edx demo'):
""" """
Initializer Initializer
""" """
self.status = { self.status = {
'course_name': course_name,
'enrollment_mode': enrollment_mode, 'enrollment_mode': enrollment_mode,
'profile_fullname': profile_fullname, 'profile_fullname': profile_fullname,
'credit_requirement_status': [] 'credit_requirement_status': []
} }
def get_credit_state(self, user_id, course_key): # pylint: disable=unused-argument def get_credit_state(self, user_id, course_key, return_course_name=False): # pylint: disable=unused-argument
""" """
Mock implementation Mock implementation
""" """
return self.status return self.status
# pylint: disable=unused-argument
def set_credit_requirement_status(self, user_id, course_key_or_id, req_namespace, def set_credit_requirement_status(self, user_id, course_key_or_id, req_namespace,
req_name, status="satisfied", reason=None): req_name, status="satisfied", reason=None):
""" """
...@@ -59,6 +62,25 @@ class MockCreditService(object): ...@@ -59,6 +62,25 @@ class MockCreditService(object):
else: else:
found[0]['status'] = status found[0]['status'] = status
# pylint: disable=unused-argument
# pylint: disable=invalid-name
def remove_credit_requirement_status(self, user_id, course_key_or_id, req_namespace, req_name):
"""
Mock implementation for removing the credit requirement status.
"""
for requirement in self.status['credit_requirement_status']:
match = (
requirement['name'] == req_name and
requirement['namespace'] == req_namespace and
requirement['course_id'] == unicode(course_key_or_id)
)
if match:
self.status['credit_requirement_status'].remove(requirement)
break
return True
class MockInstructorService(object): class MockInstructorService(object):
""" """
......
...@@ -25,6 +25,7 @@ from edx_proctoring.api import ( ...@@ -25,6 +25,7 @@ from edx_proctoring.api import (
create_exam, create_exam,
create_exam_attempt, create_exam_attempt,
get_exam_attempt_by_id, get_exam_attempt_by_id,
update_attempt_status,
) )
from .utils import ( from .utils import (
...@@ -502,6 +503,37 @@ class TestStudentProctoredExamAttempt(LoggedInTestCase): ...@@ -502,6 +503,37 @@ class TestStudentProctoredExamAttempt(LoggedInTestCase):
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_attempt_ready_to_start(self):
"""
Test to get an attempt with ready_to_start status
and will return the response_data with time time_remaining_seconds to 0
"""
# 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 = ProctoredExamStudentAttempt.create_exam_attempt(
proctored_exam.id, self.user.id, 'test_user', 1,
'test_attempt_code', True, False, 'test_external_id'
)
attempt.status = ProctoredExamStudentAttemptStatus.ready_to_start
attempt.save()
response = self.client.get(
reverse('edx_proctoring.proctored_exam.attempt', args=[attempt.id])
)
self.assertEqual(response.status_code, 200)
response_data = json.loads(response.content)
self.assertEqual(response_data['id'], attempt.id)
self.assertEqual(response_data['proctored_exam']['id'], proctored_exam.id)
self.assertIsNone(response_data['started_at'])
self.assertIsNone(response_data['completed_at'])
self.assertEqual(response_data['time_remaining_seconds'], 0)
def test_attempt_status_error(self): def test_attempt_status_error(self):
""" """
Test to confirm that attempt status is marked as error, because client Test to confirm that attempt status is marked as error, because client
...@@ -557,7 +589,13 @@ class TestStudentProctoredExamAttempt(LoggedInTestCase): ...@@ -557,7 +589,13 @@ class TestStudentProctoredExamAttempt(LoggedInTestCase):
response_data = json.loads(response.content) response_data = json.loads(response.content)
self.assertEqual(response_data['status'], 'error') self.assertEqual(response_data['status'], 'error')
def test_attempt_callback_timeout(self): @ddt.data(
ProctoredExamStudentAttemptStatus.created,
ProctoredExamStudentAttemptStatus.ready_to_start,
ProctoredExamStudentAttemptStatus.started,
ProctoredExamStudentAttemptStatus.ready_to_submit
)
def test_attempt_callback_timeout(self, running_status):
""" """
Ensures that the polling from the client will cause the Ensures that the polling from the client will cause the
server to transition to timed_out if the user runs out of time server to transition to timed_out if the user runs out of time
...@@ -594,6 +632,9 @@ class TestStudentProctoredExamAttempt(LoggedInTestCase): ...@@ -594,6 +632,9 @@ class TestStudentProctoredExamAttempt(LoggedInTestCase):
self.assertEqual(response_data['status'], 'started') self.assertEqual(response_data['status'], 'started')
attempt_code = response_data['attempt_code'] attempt_code = response_data['attempt_code']
# now set status to what we want per DDT
update_attempt_status(proctored_exam.id, self.user.id, running_status)
# test the polling callback point # test the polling callback point
response = self.client.get( response = self.client.get(
reverse( reverse(
...@@ -603,7 +644,7 @@ class TestStudentProctoredExamAttempt(LoggedInTestCase): ...@@ -603,7 +644,7 @@ class TestStudentProctoredExamAttempt(LoggedInTestCase):
) )
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
response_data = json.loads(response.content) response_data = json.loads(response.content)
self.assertEqual(response_data['status'], 'started') self.assertEqual(response_data['status'], running_status)
# set time to be in future # set time to be in future
reset_time = datetime.now(pytz.UTC) + timedelta(minutes=180) reset_time = datetime.now(pytz.UTC) + timedelta(minutes=180)
......
...@@ -2,6 +2,9 @@ ...@@ -2,6 +2,9 @@
Helpers for the HTTP APIs Helpers for the HTTP APIs
""" """
import pytz
from datetime import datetime, timedelta
from django.utils.translation import ugettext as _ from django.utils.translation import ugettext as _
from rest_framework.views import APIView from rest_framework.views import APIView
from rest_framework.authentication import SessionAuthentication from rest_framework.authentication import SessionAuthentication
...@@ -16,6 +19,27 @@ class AuthenticatedAPIView(APIView): ...@@ -16,6 +19,27 @@ class AuthenticatedAPIView(APIView):
permission_classes = (IsAuthenticated,) permission_classes = (IsAuthenticated,)
def get_time_remaining_for_attempt(attempt):
"""
Returns the remaining time (in seconds) on an attempt
"""
# returns 0 if the attempt has not been started yet.
if attempt['started_at'] is None:
return 0
# need to adjust for allowances
expires_at = attempt['started_at'] + timedelta(minutes=attempt['allowed_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
return time_remaining_seconds
def humanized_time(time_in_minutes): def humanized_time(time_in_minutes):
""" """
Converts the given value in minutes to a more human readable format Converts the given value in minutes to a more human readable format
......
...@@ -4,7 +4,7 @@ Proctored Exams HTTP-based API endpoints ...@@ -4,7 +4,7 @@ Proctored Exams HTTP-based API endpoints
import logging import logging
import pytz import pytz
from datetime import datetime, timedelta from datetime import datetime
from django.utils.decorators import method_decorator from django.utils.decorators import method_decorator
from django.conf import settings from django.conf import settings
...@@ -40,7 +40,7 @@ from edx_proctoring.exceptions import ( ...@@ -40,7 +40,7 @@ from edx_proctoring.exceptions import (
from edx_proctoring.serializers import ProctoredExamSerializer, ProctoredExamStudentAttemptSerializer from edx_proctoring.serializers import ProctoredExamSerializer, ProctoredExamStudentAttemptSerializer
from edx_proctoring.models import ProctoredExamStudentAttemptStatus, ProctoredExamStudentAttempt from edx_proctoring.models import ProctoredExamStudentAttemptStatus, ProctoredExamStudentAttempt
from .utils import AuthenticatedAPIView from .utils import AuthenticatedAPIView, get_time_remaining_for_attempt
ATTEMPTS_PER_PAGE = 25 ATTEMPTS_PER_PAGE = 25
...@@ -264,7 +264,9 @@ class StudentProctoredExamAttempt(AuthenticatedAPIView): ...@@ -264,7 +264,9 @@ class StudentProctoredExamAttempt(AuthenticatedAPIView):
attempt_id=attempt_id attempt_id=attempt_id
) )
) )
raise StudentExamAttemptDoesNotExistsException(err_msg) return Response(
status=status.HTTP_400_BAD_REQUEST
)
# 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:
...@@ -289,6 +291,11 @@ class StudentProctoredExamAttempt(AuthenticatedAPIView): ...@@ -289,6 +291,11 @@ class StudentProctoredExamAttempt(AuthenticatedAPIView):
ProctoredExamStudentAttemptStatus.error ProctoredExamStudentAttemptStatus.error
) )
# add in the computed time remaining as a helper to a client app
time_remaining_seconds = get_time_remaining_for_attempt(attempt)
attempt['time_remaining_seconds'] = time_remaining_seconds
return Response( return Response(
data=attempt, data=attempt,
status=status.HTTP_200_OK status=status.HTTP_200_OK
...@@ -449,14 +456,7 @@ class StudentProctoredExamAttemptCollection(AuthenticatedAPIView): ...@@ -449,14 +456,7 @@ class StudentProctoredExamAttemptCollection(AuthenticatedAPIView):
exam = exam_info['exam'] exam = exam_info['exam']
attempt = exam_info['attempt'] attempt = exam_info['attempt']
# need to adjust for allowances time_remaining_seconds = get_time_remaining_for_attempt(attempt)
expires_at = attempt['started_at'] + timedelta(minutes=attempt['allowed_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
proctoring_settings = getattr(settings, 'PROCTORING_SETTINGS', {}) proctoring_settings = getattr(settings, 'PROCTORING_SETTINGS', {})
low_threshold_pct = proctoring_settings.get('low_threshold_pct', .2) low_threshold_pct = proctoring_settings.get('low_threshold_pct', .2)
......
# Django/Framework Packages # Django/Framework Packages
django>=1.4.12,<=1.4.22 django>=1.4.12,<=1.4.22
django-model-utils==1.4.0 django-model-utils==2.3.1
South>=0.7.6 South>=0.7.6
djangorestframework>=2.3.5,<=2.3.14 djangorestframework>=2.3.5,<=2.3.14
django-ipware==1.1.0
pytz>=2012h pytz>=2012h
pycrypto>=2.6 pycrypto>=2.6
# Empty so that we will use the versions of dependencies installed in edx-platform.
# See local_requirements.txt for the requirements needed for local development.
# Third Party
-e git+https://github.com/un33k/django-ipware.git@42cb1bb1dc680a60c6452e8bb2b843c2a0382c90#egg=django-ipware
...@@ -91,3 +91,6 @@ PROCTORING_SETTINGS = { ...@@ -91,3 +91,6 @@ PROCTORING_SETTINGS = {
}, },
'ALLOW_CALLBACK_SIMULATION': False 'ALLOW_CALLBACK_SIMULATION': False
} }
DEFAULT_FROM_EMAIL = 'no-reply@example.com'
CONTACT_EMAIL = 'info@edx.org'
...@@ -34,7 +34,7 @@ def load_requirements(*requirements_paths): ...@@ -34,7 +34,7 @@ def load_requirements(*requirements_paths):
setup( setup(
name='edx-proctoring', name='edx-proctoring',
version='0.7.2', version='0.8.3',
description='Proctoring subsystem for Open edX', description='Proctoring subsystem for Open edX',
long_description=open('README.md').read(), long_description=open('README.md').read(),
author='edX', author='edX',
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment