diff --git a/docs/backends.rst b/docs/backends.rst index 5eb731dd718..52c4f3a7109 100644 --- a/docs/backends.rst +++ b/docs/backends.rst @@ -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 -------- @@ -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); + diff --git a/edx_proctoring/api.py b/edx_proctoring/api.py index c7675480f63..d0aa51e9f39 100644 --- a/edx_proctoring/api.py +++ b/edx_proctoring/api.py @@ -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) @@ -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) @@ -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) diff --git a/edx_proctoring/backends/rest.py b/edx_proctoring/backends/rest.py index 1d54153ca78..cc74803219a 100644 --- a/edx_proctoring/backends/rest.py +++ b/edx_proctoring/backends/rest.py @@ -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 @@ -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): """ diff --git a/edx_proctoring/backends/tests/test_rest.py b/edx_proctoring/backends/tests/test_rest.py index f456eb1e9a9..f0994c36fd4 100644 --- a/edx_proctoring/backends/tests/test_rest.py +++ b/edx_proctoring/backends/tests/test_rest.py @@ -6,6 +6,7 @@ import jwt import responses +from mock import patch from django.test import TestCase from django.utils import translation @@ -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, diff --git a/edx_proctoring/static/index.js b/edx_proctoring/static/index.js new file mode 100644 index 00000000000..1f7577d9c5b --- /dev/null +++ b/edx_proctoring/static/index.js @@ -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; diff --git a/edx_proctoring/static/proctoring/js/exam_action_handler.js b/edx_proctoring/static/proctoring/js/exam_action_handler.js new file mode 100644 index 00000000000..3418e85d724 --- /dev/null +++ b/edx_proctoring/static/proctoring/js/exam_action_handler.js @@ -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, $); diff --git a/edx_proctoring/static/proctoring/js/proctored_app.js b/edx_proctoring/static/proctoring/js/proctored_app.js index db31c010399..c313912e42c 100644 --- a/edx_proctoring/static/proctoring/js/proctored_app.js +++ b/edx_proctoring/static/proctoring/js/proctored_app.js @@ -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() diff --git a/edx_proctoring/static/proctoring/js/views/proctored_exam_view.js b/edx_proctoring/static/proctoring/js/views/proctored_exam_view.js index 0cab6c07d9f..b3336cf28e9 100644 --- a/edx_proctoring/static/proctoring/js/views/proctored_exam_view.js +++ b/edx_proctoring/static/proctoring/js/views/proctored_exam_view.js @@ -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; @@ -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); diff --git a/edx_proctoring/static/proctoring/spec/proctored_exam_spec.js b/edx_proctoring/static/proctoring/spec/proctored_exam_spec.js index 44d08e92de7..46ab3a5a7d9 100644 --- a/edx_proctoring/static/proctoring/spec/proctored_exam_spec.js +++ b/edx_proctoring/static/proctoring/spec/proctored_exam_spec.js @@ -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"), diff --git a/edx_proctoring/templates/proctored_exam/ready_to_start.html b/edx_proctoring/templates/proctored_exam/ready_to_start.html index da3bb9af610..be074522d69 100644 --- a/edx_proctoring/templates/proctored_exam/ready_to_start.html +++ b/edx_proctoring/templates/proctored_exam/ready_to_start.html @@ -56,28 +56,13 @@