Skip to content

Commit

Permalink
Merge pull request #471 from edx/dahlia/proctoring-master
Browse files Browse the repository at this point in the history
Dahlia/proctoring master
  • Loading branch information
matthugs committed Nov 30, 2018
2 parents f1029a6 + a3fb942 commit 1c14f67
Show file tree
Hide file tree
Showing 18 changed files with 224 additions and 65 deletions.
31 changes: 31 additions & 0 deletions docs/backends.rst
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,10 @@

Proctoring services (PS) who wish to integrate with Open edX should implement a `REST API`_ and a thin `Python wrapper`_, as described below.

Proctoring services integrated with Open edX may also optionally
implement a `Javascript API`_ to hook into specific browser-level
events.

REST API
--------

Expand Down Expand Up @@ -212,4 +216,31 @@ Manual way
.. _JWT: https://jwt.io/
.. _pypi: https://pypi.org/

Javascript API
--------------

Several browser-level events are exposed from the LMS to proctoring
services via javascript. Proctoring services may optionally provide
handlers for these events as methods on an ES2015 class, e.g.::
class ProctoringServiceHandler {
onStartExamAttempt() {
return Promise.resolve();
}
onEndExamAttempt() {
return Promise.resolve();
}
onPing() {
return Promise.resolve();
}
}

Each handler method should return a Promise which resolves upon
successful communication with the desktop application.
This class should be wrapped in ``@edx/edx-proctoring``'s
``handlerWrapper``, with the result exported as the main export of your
``npm`` package::
import { handlerWrapper } from '@edx/edx-proctoring';
...
export default handlerWrapper(ProctoringServiceHandler);


6 changes: 3 additions & 3 deletions edx_proctoring/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -1807,7 +1807,7 @@ def _get_practice_exam_view(exam, context, exam_id, user_id, course_id):
student_view_template = 'proctored_exam/ready_to_submit.html'

if student_view_template:
context['backend_js'] = provider.get_javascript()
context['backend_js_bundle'] = provider.get_javascript()
template = loader.get_template(student_view_template)
context.update(_get_proctored_exam_context(exam, attempt, user_id, course_id, is_practice_exam=True))
return template.render(context)
Expand Down Expand Up @@ -1955,7 +1955,7 @@ def _get_proctored_exam_view(exam, context, exam_id, user_id, course_id):
student_view_template = 'proctored_exam/ready_to_submit.html'

if student_view_template:
context['backend_js'] = provider.get_javascript()
context['backend_js_bundle'] = provider.get_javascript()
template = loader.get_template(student_view_template)
context.update(_get_proctored_exam_context(exam, attempt, user_id, course_id))
return template.render(context)
Expand Down Expand Up @@ -2012,7 +2012,7 @@ def get_student_view(user_id, course_id, content_id,
is_proctored=context.get('is_proctored', False),
is_practice_exam=context.get('is_practice_exam', False),
due_date=context.get('due_date', None),
hide_after_due=context.get('hide_after_due', None),
hide_after_due=context.get('hide_after_due', False),
)
exam = get_exam_by_content_id(course_id, content_id)

Expand Down
10 changes: 7 additions & 3 deletions edx_proctoring/backends/rest.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,8 @@
import logging
import time
import uuid
import pkg_resources

from webpack_loader.utils import get_files

from edx_proctoring.backends.backend import ProctoringBackendProvider
from edx_proctoring.statuses import ProctoredExamStudentAttemptStatus
Expand Down Expand Up @@ -72,10 +73,13 @@ def __init__(self, client_id=None, client_secret=None, **kwargs):

def get_javascript(self):
"""
Returns the backend javascript to embed on each proctoring page
Returns the url of the javascript bundle into which the provider's JS will be loaded
"""
package = self.__class__.__module__.split('.')[0]
return pkg_resources.resource_string(package, 'backend.js')
bundle_chunks = get_files(package, config="WORKERS")
if bundle_chunks:
return bundle_chunks[0]["url"]
return ''

def get_software_download_url(self):
"""
Expand Down
16 changes: 15 additions & 1 deletion edx_proctoring/backends/tests/test_rest.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
import jwt

import responses
from mock import patch

from django.test import TestCase
from django.utils import translation
Expand Down Expand Up @@ -216,10 +217,23 @@ def test_on_review_callback(self):
self.assertEqual(payload, new_payload)

def test_get_javascript(self):
# A real backend would return real javascript from backend.js
# A real backend would return a real bundle url from webpack
# but in this context we'll fail looking up webpack's stats file
with self.assertRaises(IOError):
self.provider.get_javascript()

@patch('edx_proctoring.backends.rest.get_files')
def test_get_javascript_bundle(self, get_files_mock):
get_files_mock.return_value = [{'name': 'rest', 'url': '/there/it/is'}]
javascript_url = self.provider.get_javascript()
self.assertEqual(javascript_url, '/there/it/is')

@patch('edx_proctoring.backends.rest.get_files')
def test_get_javascript_empty_bundle(self, get_files_mock):
get_files_mock.return_value = []
javascript_url = self.provider.get_javascript()
self.assertEqual(javascript_url, '')

def test_instructor_url(self):
user = {
'id': 1,
Expand Down
32 changes: 32 additions & 0 deletions edx_proctoring/static/index.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
export const handlerWrapper = (Handler) => {
let handler = new Handler({});

self.addEventListener("message", (message) => {
switch(message.data.type) {
case 'config': {
handler = new Handler(message.data.options);
break;
}
case 'startExamAttempt': {
if(handler.onStartExamAttempt) {
handler.onStartExamAttempt().then(() => self.postMessage({type: 'examAttemptStarted'}))
}
break;
}
case 'endExamAttempt': {
if(handler.onEndExamAttempt) {
handler.onEndExamAttempt().then(() => self.postMessage({type: 'examAttemptEnded'}))
}
break;
}
case 'ping': {
if(handler.onPing) {
handler.onPing().then(() => self.postMessage({type: 'echo'}))
}
break;
}
}
});

}
export default handlerWrapper;
95 changes: 95 additions & 0 deletions edx_proctoring/static/proctoring/js/exam_action_handler.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
var edx = edx || {};

(function($) {
'use strict';

var actionToMessageTypesMap = {
'submit': {
promptEventName: 'endExamAttempt',
responseEventName: 'examAttemptEnded'
},
'start': {
promptEventName: 'startExamAttempt',
responseEventName: 'examAttemptStarted'
}
};

function workerPromiseForEventNames(eventNames) {
return function() {
var proctoringBackendWorker = new Worker(edx.courseware.proctored_exam.configuredWorkerURL);
return new Promise(function(resolve) {
var responseHandler = function(e) {
if (e.data.type === eventNames.responseEventName) {
proctoringBackendWorker.removeEventListener('message', responseHandler);
proctoringBackendWorker.terminate();
resolve();
}
};
proctoringBackendWorker.addEventListener('message', responseHandler);
proctoringBackendWorker.postMessage({ type: eventNames.promptEventName});
});
};
}

// Update the state of the attempt
function updateExamAttemptStatusPromise(actionUrl, action) {
return function() {
return Promise.resolve($.ajax({
url: actionUrl,
type: 'PUT',
data: {
action: action
}
}));
};
}

function reloadPage() {
location.reload();
}


edx.courseware = edx.courseware || {};
edx.courseware.proctored_exam = edx.courseware.proctored_exam || {};
edx.courseware.proctored_exam.examStartHandler = function(e) {
e.preventDefault();
e.stopPropagation();

var $this = $(this);
var actionUrl = $this.data('change-state-url');
var action = $this.data('action');

var shouldUseWorker = window.Worker && edx.courseware.proctored_exam.configuredWorkerURL;
if(shouldUseWorker) {
workerPromiseForEventNames(actionToMessageTypesMap[action])()
.then(updateExamAttemptStatusPromise(actionUrl, action))
.then(reloadPage);
} else {
updateExamAttemptStatusPromise(actionUrl, action)()
.then(reloadPage);
}
};
edx.courseware.proctored_exam.examEndHandler = function() {

$(window).unbind('beforeunload');

var $this = $(this);
var actionUrl = $this.data('change-state-url');
var action = $this.data('action');

var shouldUseWorker = window.Worker &&
edx.courseware.proctored_exam.configuredWorkerURL &&
action === "submit";
if(shouldUseWorker) {

updateExamAttemptStatusPromise(actionUrl, action)()
.then(workerPromiseForEventNames(actionToMessageTypesMap[action]))
.then(reloadPage);
} else {
updateExamAttemptStatusPromise(actionUrl, action)()
.then(reloadPage);
}
}


}).call(this, $);
2 changes: 1 addition & 1 deletion edx_proctoring/static/proctoring/js/proctored_app.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
$(function() {
var proctored_exam_view = new edx.coursware.proctored_exam.ProctoredExamView({
var proctored_exam_view = new edx.courseware.proctored_exam.ProctoredExamView({
el: $(".proctored_exam_status"),
proctored_template: '#proctored-exam-status-tpl',
model: new ProctoredExamModel()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,10 @@ var edx = edx || {};
(function (Backbone, $, _, gettext) {
'use strict';

edx.coursware = edx.coursware || {};
edx.coursware.proctored_exam = edx.coursware.proctored_exam || {};
edx.courseware = edx.courseware || {};
edx.courseware.proctored_exam = edx.courseware.proctored_exam || {};

edx.coursware.proctored_exam.ProctoredExamView = Backbone.View.extend({
edx.courseware.proctored_exam.ProctoredExamView = Backbone.View.extend({
initialize: function (options) {
_.bindAll(this, "detectScroll");
this.$el = options.el;
Expand Down Expand Up @@ -192,5 +192,5 @@ var edx = edx || {};
event.preventDefault();
}
});
this.edx.coursware.proctored_exam.ProctoredExamView = edx.coursware.proctored_exam.ProctoredExamView;
this.edx.courseware.proctored_exam.ProctoredExamView = edx.courseware.proctored_exam.ProctoredExamView;
}).call(this, Backbone, $, _, gettext);
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ describe('ProctoredExamView', function () {
lastFetched: new Date()
});

this.proctored_exam_view = new edx.coursware.proctored_exam.ProctoredExamView(
this.proctored_exam_view = new edx.courseware.proctored_exam.ProctoredExamView(
{
model: this.model,
el: $(".proctored_exam_status"),
Expand Down
27 changes: 6 additions & 21 deletions edx_proctoring/templates/proctored_exam/ready_to_start.html
Original file line number Diff line number Diff line change
Expand Up @@ -56,28 +56,13 @@ <h3>
{% include 'proctored_exam/footer.html' %}

<script type="text/javascript">
var edx = edx || {};
edx.courseware = edx.courseware || {};
edx.courseware.proctored_exam = edx.courseware.proctored_exam || {};
edx.courseware.proctored_exam.configuredWorkerURL = "{{ backend_js_bundle }}";

$('.proctored-enter-exam').click(
function(e) {
e.preventDefault();
e.stopPropagation();

var action_url = $(this).data('change-state-url');
var exam_id = $(this).data('exam-id');
var action = $(this).data('action');

// Update the state of the attempt
$.ajax({
url: action_url,
type: 'PUT',
data: {
action: action
},
success: function() {
// Reloading page will reflect the new state of the attempt
location.reload();
}
});
}
$('.proctored-enter-exam').click(
edx.courseware.proctored_exam.examStartHandler
);
</script>
35 changes: 7 additions & 28 deletions edx_proctoring/templates/proctored_exam/ready_to_submit.html
Original file line number Diff line number Diff line change
Expand Up @@ -29,33 +29,12 @@ <h3>
{% endif %}
</div>
<script type="text/javascript">
$('.exam-action-button').click(
function(event) {

// cancel any warning messages to end user about leaving proctored exam
$(window).unbind('beforeunload');
var edx = edx || {};
edx.courseware = edx.courseware || {};
edx.courseware.proctored_exam = edx.courseware.proctored_exam || {};
edx.courseware.proctored_exam.configuredWorkerURL = "{{ backend_js_bundle }}";

if ((backend_submission_callback !== undefined && backend_submission_callback()) || (backend_submission_callback == undefined)) {
var action_url = $(this).data('change-state-url');
var exam_id = $(this).data('exam-id');
var action = $(this).data('action');

// Update the state of the attempt
$.ajax({
url: action_url,
type: 'PUT',
data: {
action: action
},
success: function() {
// Reloading page will reflect the new state of the attempt
location.reload()
}
});
} else {
// TODO: better error message
alert('There was an error shutting off the proctoring software.');
}
});
{{backend_js|safe}}
$('.exam-action-button').click(
edx.courseware.proctored_exam.examEndHandler
);
</script>
11 changes: 9 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
@@ -1,9 +1,14 @@
{
"name": "edx-proctoring",
"name": "@edx/edx-proctoring",
"version": "1.0.0",
"main": "edx_proctoring/static/index.js",
"repository": {
"type": "git",
"url": "git://github.com/edx/edx-proctoring"
},
"files": [
"/edx_proctoring/static"
],
"devDependencies": {
"gulp": "^3.9.0",
"gulp-karma": "0.0.1",
Expand All @@ -17,5 +22,7 @@
"karma-sinon": "^1.0.5",
"phantomjs-prebuilt": "^2.1.14",
"sinon": "^3.2.1"
}
},
"dependencies": {},
"license": "GNU Affero GPLv3"
}
Loading

0 comments on commit 1c14f67

Please sign in to comment.