Commit edc4d2a1 by chrisndodge

Merge pull request #27 from edx/cdodge/punchlist1

Cdodge/punchlist1
parents c272b6d8 1b1a309f
...@@ -2,6 +2,6 @@ edx-proctoring [![Build Status](https://travis-ci.org/edx/edx-proctoring.svg?bra ...@@ -2,6 +2,6 @@ edx-proctoring [![Build Status](https://travis-ci.org/edx/edx-proctoring.svg?bra
======================== ========================
This is the Exam Proctoring subsytem for the Open edX platform. This is the Exam Proctoring subsytsem for the Open edX platform.
This is a work in progress at this point in time. This is a work in progress at this point in time.
...@@ -11,7 +11,7 @@ import uuid ...@@ -11,7 +11,7 @@ import uuid
from datetime import datetime, timedelta from datetime import datetime, timedelta
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 from django.core.urlresolvers import reverse, NoReverseMatch
from edx_proctoring.exceptions import ( from edx_proctoring.exceptions import (
ProctoredExamAlreadyExists, ProctoredExamAlreadyExists,
...@@ -446,10 +446,20 @@ def get_student_view(user_id, course_id, content_id, context): ...@@ -446,10 +446,20 @@ def get_student_view(user_id, course_id, content_id, context):
template = loader.get_template(student_view_template) template = loader.get_template(student_view_template)
django_context = Context(context) django_context = Context(context)
total_time = humanized_time(context['default_time_limit_mins']) total_time = humanized_time(context['default_time_limit_mins'])
progress_page_url = ''
try:
progress_page_url = reverse(
'courseware.views.progress',
args=[course_id]
)
except NoReverseMatch:
pass
django_context.update({ django_context.update({
'platform_name': settings.PLATFORM_NAME, 'platform_name': settings.PLATFORM_NAME,
'total_time': total_time, 'total_time': total_time,
'exam_id': exam_id, 'exam_id': exam_id,
'progress_page_url': progress_page_url,
'enter_exam_endpoint': reverse('edx_proctoring.proctored_exam.attempt.collection'), 'enter_exam_endpoint': reverse('edx_proctoring.proctored_exam.attempt.collection'),
'exam_started_poll_url': reverse( 'exam_started_poll_url': reverse(
'edx_proctoring.proctored_exam.attempt', 'edx_proctoring.proctored_exam.attempt',
......
...@@ -78,7 +78,7 @@ class ProctoredExamStudentAttemptSerializer(serializers.ModelSerializer): ...@@ -78,7 +78,7 @@ class ProctoredExamStudentAttemptSerializer(serializers.ModelSerializer):
fields = ( fields = (
"id", "created", "modified", "user_id", "started_at", "completed_at", "id", "created", "modified", "user_id", "started_at", "completed_at",
"external_id", "status", "proctored_exam_id", "allowed_time_limit_mins", "external_id", "status", "proctored_exam_id", "allowed_time_limit_mins",
"attempt_code", "is_sample_attempt" "attempt_code", "is_sample_attempt", "taking_as_proctored"
) )
......
...@@ -5,12 +5,12 @@ ...@@ -5,12 +5,12 @@
defaults: { defaults: {
in_timed_exam: false, in_timed_exam: false,
is_proctored: false, taking_as_proctored: false,
exam_display_name: '', exam_display_name: '',
exam_url_path: '', exam_url_path: '',
time_remaining_seconds: 0, time_remaining_seconds: 0,
low_threshold: 0, low_threshold_sec: 0,
critically_low_threshold: 0, critically_low_threshold_sec: 0,
lastFetched: new Date() lastFetched: new Date()
}, },
getRemainingSeconds: function () { getRemainingSeconds: function () {
...@@ -35,10 +35,10 @@ ...@@ -35,10 +35,10 @@
}, },
getRemainingTimeState: function () { getRemainingTimeState: function () {
var totalSeconds = this.getRemainingSeconds(); var totalSeconds = this.getRemainingSeconds();
if (totalSeconds > this.get('low_threshold')) { if (totalSeconds > this.get('low_threshold_sec')) {
return ""; return "";
} }
else if (totalSeconds <= this.get('low_threshold') && totalSeconds > this.get('critically_low_threshold')) { else if (totalSeconds <= this.get('low_threshold_sec') && totalSeconds > this.get('critically_low_threshold_sec')) {
return "low-time warning"; return "low-time warning";
} }
else { else {
......
...@@ -16,6 +16,10 @@ var edx = edx || {}; ...@@ -16,6 +16,10 @@ var edx = edx || {};
/* 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;
// we need to keep a copy here because the model will
// get destroyed before onbeforeunload is called
this.taking_as_proctored = false;
var template_html = $(this.templateId).text(); var template_html = $(this.templateId).text();
if (template_html !== null) { if (template_html !== null) {
/* don't assume this backbone view is running on a page with the underscore templates */ /* don't assume this backbone view is running on a page with the underscore templates */
...@@ -24,12 +28,24 @@ var edx = edx || {}; ...@@ -24,12 +28,24 @@ var edx = edx || {};
/* re-render if the model changes */ /* re-render if the model changes */
this.listenTo(this.model, 'change', this.modelChanged); this.listenTo(this.model, 'change', this.modelChanged);
$(window).unbind('beforeunload', this.unloadMessage);
/* make the async call to the backend REST API */ /* make the async call to the backend REST API */
/* after it loads, the listenTo event will file and */ /* after it loads, the listenTo event will file and */
/* will call into the rendering */ /* will call into the rendering */
this.model.fetch(); this.model.fetch();
}, },
modelChanged: function () { modelChanged: function () {
// if we are a proctored exam, then we need to alert user that he/she
// should not leave the exam
if (this.model.get('taking_as_proctored') && this.model.get('time_remaining_seconds') > 0) {
$(window).bind('beforeunload', this.unloadMessage);
} else {
// remove callback on unload event
$(window).unbind('beforeunload', this.unloadMessage);
}
this.render(); this.render();
}, },
render: function () { render: function () {
...@@ -44,13 +60,20 @@ var edx = edx || {}; ...@@ -44,13 +60,20 @@ var edx = edx || {};
} }
return this; return this;
}, },
unloadMessage: function () {
return "As you are currently taking a proctored exam,\n" +
"you should not be navigation away from the exam.\n" +
"This may be considered as a violation of the \n" +
"proctored exam and you may be disqualified for \n" +
"credit eligibility in this course.\n";
},
updateRemainingTime: function (self) { updateRemainingTime: function (self) {
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.$el.find('span#time_remaining_id b').html(self.model.getFormattedRemainingTime()); self.$el.find('span#time_remaining_id b').html(self.model.getFormattedRemainingTime());
if (self.model.getRemainingSeconds() <= -self.grace_period_secs) { if (self.model.getRemainingSeconds() <= -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);
// refresh the page when the timer expired // refresh the page when the timer expired
location.reload(); location.reload();
} }
......
...@@ -17,7 +17,7 @@ ...@@ -17,7 +17,7 @@
Here is your unique exam code. You'll be asked for it during the setup. Here is your unique exam code. You'll be asked for it during the setup.
{% endblocktrans %} {% endblocktrans %}
</h3> </h3>
<h1> {{exam_code}}</h1> <h2> {{exam_code}}<span class='copy-to-clipboard'></span></h2>
<p> <p>
{% blocktrans %} {% blocktrans %}
Please do not share this code. It can only be used once and it tied to your {{platform_name}} account. Please do not share this code. It can only be used once and it tied to your {{platform_name}} account.
...@@ -43,6 +43,48 @@ ...@@ -43,6 +43,48 @@
<script type="text/javascript"> <script type="text/javascript">
$(document).ready(function(){ $(document).ready(function(){
var hasFlash = false;
try {
var fo = new ActiveXObject('ShockwaveFlash.ShockwaveFlash');
if (fo) {
hasFlash = true;
}
} catch (e) {
if (navigator.mimeTypes
&& navigator.mimeTypes['application/x-shockwave-flash'] != undefined
&& navigator.mimeTypes['application/x-shockwave-flash'].enabledPlugin) {
hasFlash = true;
}
}
if (hasFlash) {
$('.copy-to-clipboard').html(
'<object classid="clsid:d27cdb6e-ae6d-11cf-96b8-444553540000" \
width="110" \
height="14" \
id="clippy" > \
<param name="movie" value="/static/proctoring/clippy.swf"/> \
<param name="allowScriptAccess" value="always" /> \
<param name="quality" value="high" /> \
<param name="scale" value="noscale" /> \
<param NAME="FlashVars" value="text={{exam_code}}"> \
<param name="bgcolor" value="#F2F4F5"> \
<embed src="/static/proctoring/clippy.swf" \
width="110" \
height="14" \
name="clippy" \
quality="high" \
allowScriptAccess="always" \
type="application/x-shockwave-flash" \
pluginspage="http://www.macromedia.com/go/getflashplayer" \
FlashVars="text={{exam_code}}" \
bgcolor="#F2F4F5" \
/> \
</object>'
);
}
setInterval( setInterval(
poll_exam_started, poll_exam_started,
5000 5000
...@@ -53,6 +95,12 @@ ...@@ -53,6 +95,12 @@
var url = $('.instructions').data('exam-started-poll-url') var url = $('.instructions').data('exam-started-poll-url')
$.ajax(url).success(function(data){ $.ajax(url).success(function(data){
if (data.started_at !== null) { if (data.started_at !== null) {
// Let the student know exam has started and clock is running.
// this may or may not bring the browser window back to the
// foreground (depending on browser as well as user settings)
alert('{% trans "Your proctored exam has started, please click OK to enter into your exam." %}')
// Reloading page will reflect the new state of the attempt
location.reload() location.reload()
} }
}); });
......
...@@ -15,7 +15,7 @@ ...@@ -15,7 +15,7 @@
<div class="proctored-exam-message"> <div class="proctored-exam-message">
<p> <p>
{% blocktrans %} {% blocktrans %}
Please see <a href="#">your progress in this course</a> for your general course credit worthiness. Please see <a href="{{progress_page_url}}">your progress in this course</a> for your general course credit worthiness.
{% endblocktrans %} {% endblocktrans %}
</p> </p>
</div> </div>
......
...@@ -633,6 +633,8 @@ class TestStudentProctoredExamAttempt(LoggedInTestCase): ...@@ -633,6 +633,8 @@ class TestStudentProctoredExamAttempt(LoggedInTestCase):
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
data = json.loads(response.content) data = json.loads(response.content)
self.assertEqual(data['exam_display_name'], 'Test Exam') self.assertEqual(data['exam_display_name'], 'Test Exam')
self.assertEqual(data['low_threshold_sec'], 1080)
self.assertEqual(data['critically_low_threshold_sec'], 270)
def test_get_expired_attempt(self): def test_get_expired_attempt(self):
""" """
......
...@@ -7,6 +7,8 @@ import pytz ...@@ -7,6 +7,8 @@ import pytz
from datetime import datetime, timedelta from datetime import datetime, timedelta
from django.utils.decorators import method_decorator from django.utils.decorators import method_decorator
from django.conf import settings
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
...@@ -364,10 +366,13 @@ class StudentProctoredExamAttemptCollection(AuthenticatedAPIView): ...@@ -364,10 +366,13 @@ class StudentProctoredExamAttemptCollection(AuthenticatedAPIView):
exams = get_active_exams_for_user(request.user.id) exams = get_active_exams_for_user(request.user.id)
if exams: if exams:
exam = exams[0] exam_info = exams[0]
exam = exam_info['exam']
attempt = exam_info['attempt']
# need to adjust for allowances # need to adjust for allowances
expires_at = exam['attempt']['started_at'] + timedelta(minutes=exam['attempt']['allowed_time_limit_mins']) expires_at = attempt['started_at'] + timedelta(minutes=attempt['allowed_time_limit_mins'])
now_utc = datetime.now(pytz.UTC) now_utc = datetime.now(pytz.UTC)
if expires_at > now_utc: if expires_at > now_utc:
...@@ -375,14 +380,34 @@ class StudentProctoredExamAttemptCollection(AuthenticatedAPIView): ...@@ -375,14 +380,34 @@ class StudentProctoredExamAttemptCollection(AuthenticatedAPIView):
else: else:
time_remaining_seconds = 0 time_remaining_seconds = 0
proctoring_settings = getattr(settings, 'PROCTORING_SETTINGS', {})
low_threshold_pct = proctoring_settings.get('low_threshold_pct', .2)
critically_low_threshold_pct = proctoring_settings.get('critically_low_threshold_pct', .05)
low_threshold = int(low_threshold_pct * float(attempt['allowed_time_limit_mins']) * 60)
critically_low_threshold = int(
critically_low_threshold_pct * float(attempt['allowed_time_limit_mins']) * 60
)
exam_url_path = ''
try:
# resolve the LMS url, note we can't assume we're running in
# a same process as the LMS
exam_url_path = reverse(
'courseware.views.jump_to',
args=[exam['course_id'], exam['content_id']]
)
except NoReverseMatch:
pass
response_dict = { response_dict = {
'in_timed_exam': True, 'in_timed_exam': True,
'is_proctored': True, 'taking_as_proctored': attempt['taking_as_proctored'],
'exam_display_name': exam['exam']['exam_name'], 'exam_display_name': exam['exam_name'],
'exam_url_path': '', 'exam_url_path': exam_url_path,
'time_remaining_seconds': time_remaining_seconds, 'time_remaining_seconds': time_remaining_seconds,
'low_threshold': 30, 'low_threshold_sec': low_threshold,
'critically_low_threshold': 15, 'critically_low_threshold_sec': critically_low_threshold,
} }
else: else:
response_dict = { response_dict = {
......
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