Commit a5f0f316 by Chris Dodge

Initial integration into the LMS

parent 5d790ab4
......@@ -7,7 +7,10 @@ In-Proc API (aka Library) for the edx_proctoring subsystem. This is not to be co
API which is in the views.py file, per edX coding standards
"""
import pytz
from datetime import datetime
from datetime import datetime, timedelta
from django.template import Context, Template, loader
from django.core.urlresolvers import reverse
from edx_proctoring.exceptions import (
ProctoredExamAlreadyExists, ProctoredExamNotFoundException, StudentExamAttemptAlreadyExistsException,
StudentExamAttemptDoesNotExistsException)
......@@ -131,6 +134,14 @@ def remove_allowance_for_user(exam_id, user_id, key):
student_allowance.delete()
def get_exam_attempt(exam_id, user_id):
"""
Return an existing exam attempt for the given student
"""
exam_attempt_obj = ProctoredExamStudentAttempt.get_student_exam_attempt(exam_id, user_id)
return exam_attempt_obj.__dict__ if exam_attempt_obj else None
def start_exam_attempt(exam_id, user_id, external_id):
"""
Signals the beginning of an exam attempt for a given
......@@ -195,4 +206,58 @@ def get_active_exams_for_user(user_id, course_id=None):
'attempt': active_exam_serialized_data,
'allowances': allowance_serialized_data
})
return result
def get_student_view(user_id, course_id, content_id, context):
"""
Helper method that will return the view HTML related to the exam control
flow (i.e. entering, expired, completed, etc.) If there is no specific
content to display, then None will be returned and the caller should
render it's own view
"""
has_started_exam = False
has_finished_exam = False
has_time_expired = False
student_view_template = None
exam_id = None
try:
exam = get_exam_by_content_id(course_id, content_id)
print '**** exam = {}'.format(exam)
exam_id = exam['id']
except Exception, ex:
print '*** exception = {}'.format(unicode(ex))
exam_id = create_exam(
course_id=course_id,
content_id=unicode(content_id),
exam_name=context['display_name'],
time_limit_mins=context['default_time_limit_mins']
)
attempt = get_exam_attempt(exam_id, user_id)
has_started_exam = attempt is not None
if attempt:
now_utc = datetime.now(pytz.UTC)
expires_at = attempt['started_at'] + timedelta(minutes=context['default_time_limit_mins'])
has_time_expired = now_utc > expires_at
if not has_started_exam:
student_view_template = 'proctoring/seq_timed_exam_entrance.html'
elif has_finished_exam:
student_view_template = 'proctoring/seq_timed_exam_completed.html'
elif has_time_expired:
student_view_template = 'proctoring/seq_timed_exam_expired.html'
if student_view_template:
template = loader.get_template(student_view_template)
django_context = Context(context)
django_context.update({
'exam_id': exam_id,
'enter_exam_endpoint': reverse('edx_proctoring.proctored_exam.attempt'),
})
return template.render(django_context)
return None
......@@ -21,7 +21,8 @@ class ProctoredExamSerializer(serializers.ModelSerializer):
"""
Serializer for the ProctoredExam Model.
"""
course_id = serializers.RegexField(settings.COURSE_ID_REGEX, required=True)
id = serializers.IntegerField(required=True)
course_id = serializers.CharField(required=True)
content_id = serializers.CharField(required=True)
external_id = serializers.CharField(required=True)
exam_name = serializers.CharField(required=True)
......@@ -37,7 +38,7 @@ class ProctoredExamSerializer(serializers.ModelSerializer):
model = ProctoredExam
fields = (
"course_id", "content_id", "external_id", "exam_name",
"id", "course_id", "content_id", "external_id", "exam_name",
"time_limit_mins", "is_proctored", "is_active"
)
......@@ -52,7 +53,7 @@ class ProctoredExamStudentAttemptSerializer(serializers.ModelSerializer):
"""
model = ProctoredExamStudentAttempt
fields = (
"created", "modified", "user_id", "started_at", "completed_at",
"id", "created", "modified", "user_id", "started_at", "completed_at",
"external_id", "status"
)
......@@ -67,5 +68,5 @@ class ProctoredExamStudentAllowanceSerializer(serializers.ModelSerializer):
"""
model = ProctoredExamStudentAllowance
fields = (
"created", "modified", "user_id", "key", "value"
"id", "created", "modified", "user_id", "key", "value"
)
"""
A wrapper class around all methods exposed in api.py
"""
from edx_proctoring import api as edx_proctoring_api
import types
class ProctoringService(object):
"""
An xBlock service for xBlocks to talk to the Proctoring subsystem. This class basically introspects
and exposes all functions in the api libraries, so it is a direct pass through.
NOTE: This is a Singleton class. We should only have one instance of it!
"""
_instance = None
def __new__(cls, *args, **kwargs):
"""
This is the class factory to make sure this is a Singleton
"""
if not cls._instance:
cls._instance = super(ProctoringService, cls).__new__(cls, *args, **kwargs)
return cls._instance
def __init__(self):
"""
Class initializer, which just inspects the libraries and exposes the same functions
as a direct pass through
"""
self._bind_to_module_functions(edx_proctoring_api)
def _bind_to_module_functions(self, module):
"""
bind module functions. Since we use underscores to mean private methods, let's exclude those.
"""
for attr_name in dir(module):
attr = getattr(module, attr_name, None)
if isinstance(attr, types.FunctionType) and not attr_name.startswith('_'):
if not hasattr(self, attr_name):
setattr(self, attr_name, attr)
$(function() {
var proctored_exam_view = new edx.coursware.proctored_exam.ProctoredExamView({
el: $(".proctored_exam_status"),
proctored_template: '#proctored-exam-status-tpl',
model: new ProctoredExamModel()
});
proctored_exam_view.render();
});
(function(Backbone) {
var ProctoredExamModel = Backbone.Model.extend({
/* we should probably pull this from a data attribute on the HTML */
url: '/api/edx_proctoring/v1/proctored_exam/attempt',
defaults: {
in_timed_exam: false,
is_proctored: false,
exam_display_name: '',
exam_url_path: '',
time_remaining_seconds: 0,
low_threshold: 0,
critically_low_threshold: 0,
lastFetched: new Date()
},
getRemainingSeconds: function () {
var currentTime = (new Date()).getTime();
var lastFetched = this.get('lastFetched').getTime();
var totalSeconds = this.get('time_remaining_seconds') - (currentTime - lastFetched) / 1000;
return (totalSeconds > 0) ? totalSeconds : 0;
},
getFormattedRemainingTime: function () {
var totalSeconds = this.getRemainingSeconds();
var hours = parseInt(totalSeconds / 3600) % 24;
var minutes = parseInt(totalSeconds / 60) % 60;
var seconds = Math.floor(totalSeconds % 60);
return hours + ":" + (minutes < 10 ? "0" + minutes : minutes)
+ ":" + (seconds < 10 ? "0" + seconds : seconds);
},
getRemainingTimeState: function () {
var totalSeconds = this.getRemainingSeconds();
if (totalSeconds > this.get('low_threshold')) {
return "";
}
else if (totalSeconds <= this.get('low_threshold') && totalSeconds > this.get('critically_low_threshold')) {
return "low-time warning";
}
else {
return "low-time critical";
}
}
});
this.ProctoredExamModel = ProctoredExamModel;
}).call(this, Backbone);
var edx = edx || {};
(function(Backbone, $, _) {
'use strict';
edx.coursware = edx.coursware || {};
edx.coursware.proctored_exam = {};
edx.coursware.proctored_exam.ProctoredExamView = Backbone.View.extend({
initialize: function (options) {
this.$el = options.el;
this.model = options.model;
this.templateId = options.proctored_template;
this.template = null;
this.timerId = null;
var template_html = $(this.templateId).text();
if (template_html !== null) {
/* don't assume this backbone view is running on a page with the underscore templates */
this.template = _.template(template_html);
}
/* re-render if the model changes */
this.listenTo(this.model,'change', this.modelChanged);
/* make the async call to the backend REST API */
/* after it loads, the listenTo event will file and */
/* will call into the rendering */
this.model.fetch();
},
modelChanged: function() {
this.render();
},
render: function () {
if (this.template !== null) {
if (this.model.get('in_timed_exam') && this.model.get('time_remaining_seconds') > 0) {
var html = this.template(this.model.toJSON());
this.$el.html(html);
this.$el.show();
this.updateRemainingTime(this);
this.timerId = setInterval(this.updateRemainingTime, 1000, this);
}
}
return this;
},
updateRemainingTime: function (self) {
self.$el.find('div.exam-timer').removeClass("low-time warning critical");
self.$el.find('div.exam-timer').addClass(self.model.getRemainingTimeState());
self.$el.find('span#time_remaining_id b').html(self.model.getFormattedRemainingTime());
if (self.model.getRemainingSeconds() <= 0) {
clearInterval(self.timerId); // stop the timer once the time finishes.
// refresh the page when the timer expired
location.reload();
}
}
});
this.edx.coursware.proctored_exam.ProctoredExamView = edx.coursware.proctored_exam.ProctoredExamView;
}).call(this, Backbone, $, _);
define(['jquery', 'backbone', 'common/js/spec_helpers/template_helpers', 'js/courseware/base/models/proctored_exam_model', 'js/courseware/base/views/proctored_exam_view'
], function($, Backbone, TemplateHelpers, ProctoredExamModel, ProctoredExamView) {
'use strict';
describe('Proctored Exam', function () {
beforeEach(function () {
this.model = new ProctoredExamModel();
});
it('model has properties', function () {
expect(this.model.get('in_timed_exam')).toBeDefined();
expect(this.model.get('is_proctored')).toBeDefined();
expect(this.model.get('exam_display_name')).toBeDefined();
expect(this.model.get('exam_url_path')).toBeDefined();
expect(this.model.get('time_remaining_seconds')).toBeDefined();
expect(this.model.get('low_threshold')).toBeDefined();
expect(this.model.get('critically_low_threshold')).toBeDefined();
expect(this.model.get('lastFetched')).toBeDefined();
});
});
describe('ProctoredExamView', function () {
beforeEach(function () {
TemplateHelpers.installTemplate('templates/courseware/proctored-exam-status', true, 'proctored-exam-status-tpl');
appendSetFixtures('<div class="proctored_exam_status"></div>');
this.model = new ProctoredExamModel({
in_timed_exam: true,
is_proctored: true,
exam_display_name: 'Midterm',
exam_url_path: '/test_url',
time_remaining_seconds: 45, //2 * 60 + 15,
low_threshold: 30,
critically_low_threshold: 15,
lastFetched: new Date()
});
this.proctored_exam_view = new edx.coursware.proctored_exam.ProctoredExamView(
{
model: this.model,
el: $(".proctored_exam_status"),
proctored_template: '#proctored-exam-status-tpl'
}
);
this.proctored_exam_view.render();
});
it('renders items correctly', function () {
expect(this.proctored_exam_view.$el.find('a')).toHaveAttr('href', this.model.get("exam_url_path"));
expect(this.proctored_exam_view.$el.find('a')).toContainHtml(this.model.get('exam_display_name'));
});
it('changes behavior when clock time decreases low threshold', function () {
spyOn(this.model, 'getRemainingSeconds').andCallFake(function () {
return 25;
});
expect(this.model.getRemainingSeconds()).toEqual(25);
expect(this.proctored_exam_view.$el.find('div.exam-timer')).not.toHaveClass('low-time warning');
this.proctored_exam_view.render();
expect(this.proctored_exam_view.$el.find('div.exam-timer')).toHaveClass('low-time warning');
});
it('changes behavior when clock time decreases critically low threshold', function () {
spyOn(this.model, 'getRemainingSeconds').andCallFake(function () {
return 5;
});
expect(this.model.getRemainingSeconds()).toEqual(5);
expect(this.proctored_exam_view.$el.find('div.exam-timer')).not.toHaveClass('low-time critical');
this.proctored_exam_view.render();
expect(this.proctored_exam_view.$el.find('div.exam-timer')).toHaveClass('low-time critical');
});
});
});
<div class="exam-timer">
<%- gettext("You are taking") %>
<a href="<%- interpolate(gettext('%(exam_url_path)s'), { exam_url_path: exam_url_path }, true)%>" >
<%- interpolate(gettext(' %(exam_display_name)s '), { exam_display_name: exam_display_name }, true) %>
</a>
<%- gettext(" exam as a proctored exam. Good Luck!") %>
<span id="time_remaining_id" class="pull-right">
<b>
</b>
</span>
</div>
<div class="sequence" >
<div class="gated-sequence">
All done!
</div>
</div>
<div class="sequence" data-exam-id="{{exam_id}}">
<div class="gated-sequence">
This is a timed exam. Would you like to <a class='start-timed-exam' data-ajax-url="{{enter_exam_endpoint}}">enter</a> it?
</div>
</div>
<script type="text/javascript">
$('.start-timed-exam').click(
function(event) {
var target = $(event.target);
var action_url = target.data('ajax-url');
var exam_id = target.parent().parent().data('exam-id');
$.post(
action_url,
{
"exam_id": exam_id,
},
function(data) {
// reload the page, because we've unlocked it
location.reload();
}
);
}
);
</script>
<div class="sequence">
<div class="gated-sequence">
You have run out of time!
</div>
</div>
......@@ -3,11 +3,25 @@ Proctored Exams HTTP-based API endpoints
"""
import logging
import pytz
from datetime import datetime, timedelta
from django.utils.decorators import method_decorator
from django.db import IntegrityError
from rest_framework import status
from rest_framework.response import Response
from edx_proctoring.api import create_exam, update_exam, get_exam_by_id, get_exam_by_content_id, start_exam_attempt, \
stop_exam_attempt, add_allowance_for_user, remove_allowance_for_user, get_active_exams_for_user
from edx_proctoring.api import (
create_exam,
update_exam,
get_exam_by_id,
get_exam_by_content_id,
start_exam_attempt,
stop_exam_attempt,
add_allowance_for_user,
remove_allowance_for_user,
get_active_exams_for_user
)
from edx_proctoring.exceptions import ProctoredExamNotFoundException, \
StudentExamAttemptAlreadyExistsException, StudentExamAttemptDoesNotExistsException
from edx_proctoring.serializers import ProctoredExamSerializer
......@@ -187,22 +201,40 @@ class StudentProctoredExamAttempt(AuthenticatedAPIView):
HTTP GET Handler. Returns the status of the exam attempt.
"""
response_dict = {
'in_timed_exam': True,
'is_proctored': True,
'exam_display_name': 'Midterm',
'exam_url_path': '',
'time_remaining_seconds': 45,
'low_threshold': 30,
'critically_low_threshold': 15,
}
exams = get_active_exams_for_user(request.user.id)
if exams:
exam = exams[0]
# need to adjust for allowances
expires_at = exam['attempt']['started_at'] + timedelta(minutes=exam['exam']['time_limit_mins'])
now_utc = datetime.now(pytz.UTC)
if expires_at > now_utc:
time_remaining_seconds = (expires_at - now_utc).seconds
else:
time_remaining_seconds = 0
response_dict = {
'in_timed_exam': True,
'is_proctored': True,
'exam_display_name': exam['exam']['exam_name'],
'exam_url_path': '',
'time_remaining_seconds': time_remaining_seconds,
'low_threshold': 30,
'critically_low_threshold': 15,
}
else:
response_dict = {
'in_timed_exam': False,
'is_proctored': False,
}
return Response(
data=response_dict,
status=status.HTTP_200_OK
)
@method_decorator(require_staff)
def post(self, request):
"""
HTTP POST handler. To start an exam.
......@@ -210,7 +242,7 @@ class StudentProctoredExamAttempt(AuthenticatedAPIView):
try:
exam_attempt_id = start_exam_attempt(
exam_id=request.DATA.get('exam_id', None),
user_id=request.DATA.get('user_id', None),
user_id=request.user.id,
external_id=request.DATA.get('external_id', None)
)
return Response({'exam_attempt_id': exam_attempt_id})
......@@ -221,7 +253,6 @@ class StudentProctoredExamAttempt(AuthenticatedAPIView):
data={"detail": "Error. Trying to start an exam that has already started."}
)
@method_decorator(require_staff)
def put(self, request):
"""
HTTP POST handler. To stop an exam.
......@@ -229,7 +260,7 @@ class StudentProctoredExamAttempt(AuthenticatedAPIView):
try:
exam_attempt_id = stop_exam_attempt(
exam_id=request.DATA.get('exam_id', None),
user_id=request.DATA.get('user_id', None)
user_id=request.user.id
)
return Response({"exam_attempt_id": exam_attempt_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