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 @@

{% include 'proctored_exam/footer.html' %} diff --git a/edx_proctoring/templates/proctored_exam/ready_to_submit.html b/edx_proctoring/templates/proctored_exam/ready_to_submit.html index 40b7ce60450..44526386e40 100644 --- a/edx_proctoring/templates/proctored_exam/ready_to_submit.html +++ b/edx_proctoring/templates/proctored_exam/ready_to_submit.html @@ -29,33 +29,12 @@

{% endif %} diff --git a/package.json b/package.json index 4800005c034..8e49a9c51fc 100644 --- a/package.json +++ b/package.json @@ -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", @@ -17,5 +22,7 @@ "karma-sinon": "^1.0.5", "phantomjs-prebuilt": "^2.1.14", "sinon": "^3.2.1" - } + }, + "dependencies": {}, + "license": "GNU Affero GPLv3" } diff --git a/requirements/base.in b/requirements/base.in index 07ff5557150..40a0fc06141 100644 --- a/requirements/base.in +++ b/requirements/base.in @@ -9,6 +9,7 @@ jsonfield>=2.0.2 pytz>=2018 pycryptodomex>=3.4.7 python-dateutil>=2.1 +django-webpack-loader>=0.6.0 # edX packages event-tracking>=0.2.5 diff --git a/requirements/base.txt b/requirements/base.txt index e3ccf9b3f57..884a9b2151b 100644 --- a/requirements/base.txt +++ b/requirements/base.txt @@ -9,6 +9,7 @@ chardet==3.0.4 # via requests django-ipware==2.1.0 django-model-utils==3.1.2 django-waffle==0.15.0 # via edx-django-utils, edx-drf-extensions +django-webpack-loader==0.6.0 django==1.11.16 djangorestframework-jwt==1.11.0 # via edx-drf-extensions djangorestframework==3.6.4 diff --git a/requirements/dev.txt b/requirements/dev.txt index 710132ecc04..4ae76a2a71c 100644 --- a/requirements/dev.txt +++ b/requirements/dev.txt @@ -6,7 +6,7 @@ # argparse==1.4.0 # via caniusepython3 astroid==1.5.2 # via edx-lint, pylint, pylint-celery -backports.functools-lru-cache==1.5 # via caniusepython3 +backports.functools-lru-cache==1.5 # via astroid, caniusepython3, pylint bleach==3.0.2 # via readme-renderer caniusepython3==7.0.0 certifi==2018.10.15 # via requests @@ -20,6 +20,7 @@ docutils==0.14 # via readme-renderer edx-i18n-tools==0.4.8 edx_lint==0.5.5 filelock==3.0.10 # via tox +futures==3.2.0 # via caniusepython3, isort idna==2.7 # via requests importlib-metadata==0.6 # via path.py inflect==2.1.0 # via jinja2-pluralize diff --git a/requirements/doc.txt b/requirements/doc.txt index 74e8a183363..5bf96609422 100644 --- a/requirements/doc.txt +++ b/requirements/doc.txt @@ -12,6 +12,7 @@ chardet==3.0.4 # via doc8, requests django-ipware==2.1.0 django-model-utils==3.1.2 django-waffle==0.15.0 # via edx-django-utils, edx-drf-extensions +django-webpack-loader==0.6.0 django==1.11.16 djangorestframework-jwt==1.11.0 # via edx-drf-extensions djangorestframework==3.6.4 diff --git a/requirements/test.txt b/requirements/test.txt index 837ad4d7df9..ab2f4b817c9 100644 --- a/requirements/test.txt +++ b/requirements/test.txt @@ -15,6 +15,7 @@ ddt==1.2.0 django-ipware==2.1.0 django-model-utils==3.1.2 django-waffle==0.15.0 # via edx-django-utils, edx-drf-extensions +django-webpack-loader==0.6.0 djangorestframework-jwt==1.11.0 # via edx-drf-extensions djangorestframework==3.6.4 edx-django-utils==1.0.1 # via edx-drf-extensions diff --git a/test_settings.py b/test_settings.py index d5928b6f2f2..a007ab91638 100644 --- a/test_settings.py +++ b/test_settings.py @@ -106,6 +106,13 @@ 'ALLOW_CALLBACK_SIMULATION': False } +WEBPACK_LOADER={ + 'WORKERS': { + 'BUNDLE_DIR_NAME': 'bundles/', + 'STATS_FILE': 'webpack-worker-stats.json' + } +} + DEFAULT_FROM_EMAIL = 'no-reply@example.com' CONTACT_EMAIL = 'info@edx.org' TECH_SUPPORT_EMAIL = 'technical@example.com'