diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..f9c0ec8 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +.coverage +.tox/ diff --git a/.travis.yml b/.travis.yml new file mode 100644 index 0000000..b651071 --- /dev/null +++ b/.travis.yml @@ -0,0 +1,24 @@ +sudo: false +language: python +cache: pip +python: + - '2.7' +before_install: + - 'uname -a' + - 'python --version' +install: + - 'pip install tox' + - 'virtualenv --version' + - 'easy_install --version' + - 'pip --version' + - 'tox --version' +script: + - 'tox -v' +branches: + only: + - 'master' +env: + - TOXENV=py27-dj18 + - TOXENV=coveralls + - TOXENV=pep8 + - TOXENV=pylint diff --git a/README.md b/README.md deleted file mode 100644 index e04f22e..0000000 --- a/README.md +++ /dev/null @@ -1,2 +0,0 @@ -New XBlock -==================== diff --git a/README.rst b/README.rst new file mode 100644 index 0000000..f34edd7 --- /dev/null +++ b/README.rst @@ -0,0 +1,47 @@ +In Video Quiz XBlock |BS| |CA| +============================== + +This XBlock allows for edX components to be displayed to users inside of videos at specific time points. + +Installation +------------ + +Install the requirements into the python virtual environment of your +``edx-platform`` installation by running the following command from the +root folder: + +.. code:: bash + + $ pip install -r requirements.txt + +Enabling in Studio +------------------ + +You can enable the In Video Quiz XBlock in Studio through the +advanced settings. + +1. From the main page of a specific course, navigate to + ``Settings -> Advanced Settings`` from the top menu. +2. Check for the ``advanced_modules`` policy key, and add + ``"invideoquiz"`` to the policy value list. +3. Click the "Save changes" button. + +Package Requirements +-------------------- + +setup.py contains a list of package dependencies which are required for this XBlock package. +This list is what is used to resolve dependencies when an upstream project is consuming +this XBlock package. requirements.txt is used to install the same dependencies when running +the tests for this package. + +License +------- + +The In Video Quiz XBlock is available under the AGPL Version 3.0 License. + + +.. |BS| image:: https://travis-ci.org/Stanford-Online/xblock-in-video-quiz.svg + :target: https://travis-ci.org/Stanford-Online/xblock-in-video-quiz + +.. |CA| image:: https://coveralls.io/repos/Stanford-Online/xblock-in-video-quiz/badge.svg?branch=master&service=github + :target: https://coveralls.io/github/Stanford-Online/xblock-in-video-quiz?branch=master diff --git a/invideoquiz/__init__.py b/invideoquiz/__init__.py new file mode 100644 index 0000000..a35159a --- /dev/null +++ b/invideoquiz/__init__.py @@ -0,0 +1,4 @@ +""" +Runtime will load the XBlock class from here. +""" +from .invideoquiz import InVideoQuizXBlock diff --git a/invideoquiz/invideoquiz.py b/invideoquiz/invideoquiz.py new file mode 100644 index 0000000..85869df --- /dev/null +++ b/invideoquiz/invideoquiz.py @@ -0,0 +1,171 @@ +""" +This XBlock allows for edX components to be displayed to users inside of +videos at specific time points. +""" + +import os +import pkg_resources + +from xblock.core import XBlock +from xblock.fields import Scope +from xblock.fields import String +from xblock.fragment import Fragment +from xblockutils.studio_editable import StudioEditableXBlockMixin + +from .utils import _ + + +def get_resource_string(path): + """ + Retrieve string contents for the file path + """ + path = os.path.join('public', path) + resource_string = pkg_resources.resource_string(__name__, path) + return resource_string.decode('utf8') + + +class InVideoQuizXBlock(StudioEditableXBlockMixin, XBlock): + # pylint: disable=too-many-ancestors + """ + Display CAPA problems within a video component at a specified time. + """ + + display_name = String( + display_name=_('Display Name'), + default=_('In-Video Quiz XBlock'), + scope=Scope.settings, + ) + + video_id = String( + display_name=_('Video ID'), + default='', + scope=Scope.settings, + help=_( + 'This is the component ID for the video in which ' + 'you want to insert your quiz question.' + ), + ) + + timemap = String( + display_name=_('Problem Timemap'), + default='', + scope=Scope.settings, + help=_( + 'A simple string field to define problem IDs ' + 'and their time maps (in seconds) as JSON. ' + 'Example: {"60": "50srvqlii4ru9gonprp35gkcfyd5weju"}' + ), + multiline_editor=True, + ) + + editable_fields = [ + 'video_id', + 'timemap', + ] + + def student_view(self, context=None): # pylint: disable=unused-argument + """ + Show to students when viewing courses + """ + fragment = self.build_fragment( + path_html='html/invideoquiz.html', + paths_css=[ + 'css/invideoquiz.css', + ], + paths_js=[ + 'js/src/invideoquiz.js', + ], + fragment_js='InVideoQuizXBlock', + context={ + 'video_id': self.video_id, + 'user_mode': self.user_mode, + }, + ) + config = get_resource_string('js/src/config.js') + config = config.format( + video_id=self.video_id, + timemap=self.timemap, + ) + fragment.add_javascript(config) + return fragment + + @property + def user_mode(self): + """ + Check user's permission mode for this XBlock. + Returns: + user permission mode + """ + try: + if self.xmodule_runtime.user_is_staff: + return 'staff' + except AttributeError: + pass + return 'student' + + @staticmethod + def workbench_scenarios(): + """ + A canned scenario for display in the workbench. + """ + return [ + ("InVideoQuizXBlock", + """ + """), + ("Multiple InVideoQuizXBlock", + """ + + + + + """), + ] + + def get_resource_url(self, path): + """ + Retrieve a public URL for the file path + """ + path = os.path.join('public', path) + resource_url = self.runtime.local_resource_url(self, path) + return resource_url + + def build_fragment( + self, + path_html='', + paths_css=None, + paths_js=None, + urls_css=None, + urls_js=None, + fragment_js=None, + context=None, + ): # pylint: disable=too-many-arguments + """ + Assemble the HTML, JS, and CSS for an XBlock fragment + """ + paths_css = paths_css or [] + paths_js = paths_js or [] + urls_css = urls_css or [] + urls_js = urls_js or [] + # If no context is provided, convert self.fields into a dict + context = context or { + key: getattr(self, key) + for key in self.editable_fields + } + html_source = get_resource_string(path_html) + html_source = html_source.format( + **context + ) + fragment = Fragment(html_source) + for path in paths_css: + url = self.get_resource_url(path) + fragment.add_css_url(url) + for path in paths_js: + url = self.get_resource_url(path) + fragment.add_javascript_url(url) + for url in urls_css: + fragment.add_css_url(url) + for url in urls_js: + fragment.add_javascript_url(url) + if fragment_js: + fragment.initialize_js(fragment_js) + return fragment diff --git a/invideoquiz/public/README.txt b/invideoquiz/public/README.txt new file mode 100644 index 0000000..0472ef6 --- /dev/null +++ b/invideoquiz/public/README.txt @@ -0,0 +1,19 @@ +This static directory is for files that should be included in your kit as plain +static files. + +You can ask the runtime for a URL that will retrieve these files with: + + url = self.runtime.local_resource_url(self, "static/js/lib.js") + +The default implementation is very strict though, and will not serve files from +the static directory. It will serve files from a directory named "public". +Create a directory alongside this one named "public", and put files there. +Then you can get a url with code like this: + + url = self.runtime.local_resource_url(self, "public/js/lib.js") + +The sample code includes a function you can use to read the content of files +in the static directory, like this: + + frag.add_javascript(self.resource_string("static/js/my_block.js")) + diff --git a/invideoquiz/public/css/invideoquiz.css b/invideoquiz/public/css/invideoquiz.css new file mode 100644 index 0000000..e6b2883 --- /dev/null +++ b/invideoquiz/public/css/invideoquiz.css @@ -0,0 +1,52 @@ +/* CSS for InVideoQuizXBlock */ + +#seq_content .vert-mod { + position: relative; +} +#seq_content .vert-mod .in-video-alert { + padding: 5px 10px; + background: #eee; + border-radius: 5px; +} + +#seq_content .vert-mod .vert.in-video-problem-wrapper { + padding-bottom: 0; + margin-bottom: 0; + border-bottom: none; +} +#seq_content .vert-mod .vert.in-video-problem-wrapper .in-video-problem { + position: absolute; + top: 50px; + padding: 25px 25px 0 25px; + background: white; + box-sizing: border-box; + width: 100%; + height: 461px; + overflow-y: scroll; + z-index: 99; +} +.video-fullscreen #seq_content .vert-mod .vert.in-video-problem-wrapper .in-video-problem { + position: fixed; + height: auto; + top: 0; + left: 0; + right: 0; + bottom: 53px; + z-index: 10000; +} +.video-controls { + z-index: 100; +} +.video-fullscreen #seq_content .vert-mod .video-controls .slider { + height: 13px; +} +.video-fullscreen #seq_content .vert-mod .video-controls .slider .ui-slider-handle { + height: 13px; + width: 13px; +} +.in-video-continue { + float: right; + height: 40px; + margin-bottom: 25px; + text-transform: uppercase; +} diff --git a/invideoquiz/public/html/invideoquiz.html b/invideoquiz/public/html/invideoquiz.html new file mode 100644 index 0000000..a957bd5 --- /dev/null +++ b/invideoquiz/public/html/invideoquiz.html @@ -0,0 +1,3 @@ +
+

This is an XBlock to display problem components inside of videos.

+
diff --git a/invideoquiz/public/js/src/config.js b/invideoquiz/public/js/src/config.js new file mode 100644 index 0000000..98ef875 --- /dev/null +++ b/invideoquiz/public/js/src/config.js @@ -0,0 +1,12 @@ +// Curly braces are all doubled because this file gets called and formatted by python + +var InVideoQuizXBlock = InVideoQuizXBlock || {{}}; + +(function () {{ + InVideoQuizXBlock.config = InVideoQuizXBlock.config || {{}}; + + var videoId = '{video_id}'; + if (videoId) {{ + InVideoQuizXBlock.config[videoId] = {timemap}; + }} +}}()); diff --git a/invideoquiz/public/js/src/invideoquiz.js b/invideoquiz/public/js/src/invideoquiz.js new file mode 100644 index 0000000..adfaa86 --- /dev/null +++ b/invideoquiz/public/js/src/invideoquiz.js @@ -0,0 +1,110 @@ +/* Javascript for InVideoQuizXBlock. */ +function InVideoQuizXBlock(runtime, element) { + var videoId = $('.in-video-quiz-block').data('videoid'); + var studentMode = $('.in-video-quiz-block').data('mode') !== 'staff'; + var problemTimesMap = InVideoQuizXBlock.config[videoId] || {}; + var extraVideoButton = ''; + var video; + var videoState; + + // Interval at which to check for problems to display + // Checking every 0.5 seconds to make sure we check at least once per actual second of video + var intervalTime = 500; + + // Timeout to wait before checking for problems again after "play" is clicked + // Waiting 1.5 seconds to make sure we are moved to the next second and we don't get a double firing + var intervalTimeout = 1500; + + $(function () { + $('.in-video-quiz-block').closest('.vert').hide(); + + if (!videoId || !problemTimesMap) { + return; + } + + $('#seq_content .vert-mod .vert').each(function () { + var component = $(this); + + if (studentMode) { + setUpStudentView(component); + } else { + showProblemTimesToInstructor(component); + } + }); + + if (studentMode) { + bindVideoEvents(); + } + }); + + function setUpStudentView(component) { + var componentIsVideo = component.data('id').indexOf(videoId) !== -1; + if (componentIsVideo) { + video = $('.video', component); + videoState = video.data('video-player-state'); + } else { + $.each(problemTimesMap, function (time, componentId) { + if (component.data('id').indexOf(componentId) !== -1) { + component.addClass('in-video-problem-wrapper'); + $('.xblock-student_view', component).append(extraVideoButton).addClass('in-video-problem').hide(); + } + }); + } + } + + function showProblemTimesToInstructor(component) { + $.each(problemTimesMap, function (time, componentId) { + var isInVideoComponent = component.data('id').indexOf(componentId) !== -1; + if (isInVideoComponent) { + var minutes = parseInt(time / 60, 10); + var seconds = ('0' + (time % 60)).slice(-2); + var timeParagraph = '

This component will appear in the video at ' + minutes + ':' + seconds + '

'; + component.prepend(timeParagraph); + } + }); + } + + // Bind In Video Quiz display to video time, as well as play and pause buttons + function bindVideoEvents() { + var canDisplayProblem = true; + var intervalObject; + var problemToDisplay; + + video.on('play', function () { + if (problemToDisplay) { + window.setTimeout(function () { + canDisplayProblem = true; + }, intervalTimeout); + problemToDisplay.hide(); + problemToDisplay = null; + } + + intervalObject = setInterval(function () { + var videoTime = parseInt(videoState.videoPlayer.currentTime, 10); + var problemToDisplayId = problemTimesMap[videoTime]; + if (problemToDisplayId && canDisplayProblem) { + $('.wrapper-downloads, .video-controls', video).hide(); + var hasProblemToDisplay = $(this).data('id').indexOf(problemToDisplayId) !== -1; + $('#seq_content .vert-mod .vert').each(function () { + if (hasProblemToDisplay) { + problemToDisplay = $('.xblock-student_view', this) + videoState.videoPlayer.pause(); + problemToDisplay.show(); + canDisplayProblem = false; + } + }); + } + }, intervalTime); + }); + + video.on('pause', function () { + clearInterval(intervalObject); + if (problemToDisplay) { + $('.in-video-continue', problemToDisplay).on('click', function () { + $('.wrapper-downloads, .video-controls', video).show(); + videoState.videoPlayer.play(); + }); + } + }); + } +} diff --git a/invideoquiz/settings.py b/invideoquiz/settings.py new file mode 100644 index 0000000..ce43723 --- /dev/null +++ b/invideoquiz/settings.py @@ -0,0 +1,18 @@ +""" +Settings for in-video-quiz xblock +""" + +DATABASES = { + 'default': { + 'ENGINE': 'django.db.backends.sqlite3', + # 'NAME': 'intentionally-omitted', + }, +} + +TEST_RUNNER = 'django_nose.NoseTestSuiteRunner' + +INSTALLED_APPS = ( + 'django_nose', +) + +SECRET_KEY = 'invideoquiz_SECRET_KEY' diff --git a/invideoquiz/tests.py b/invideoquiz/tests.py new file mode 100644 index 0000000..e69de29 diff --git a/invideoquiz/tests/test_travis.py b/invideoquiz/tests/test_travis.py new file mode 100644 index 0000000..e69de29 diff --git a/invideoquiz/translations/README.txt b/invideoquiz/translations/README.txt new file mode 100644 index 0000000..0493bcc --- /dev/null +++ b/invideoquiz/translations/README.txt @@ -0,0 +1,4 @@ +Use this translations directory to provide internationalized strings for your XBlock project. + +For more information on how to enable translations, visit the Open edX XBlock tutorial on Internationalization: +http://edx.readthedocs.org/projects/xblock-tutorial/en/latest/edx_platform/edx_lms.html diff --git a/invideoquiz/utils.py b/invideoquiz/utils.py new file mode 100644 index 0000000..75cc9cd --- /dev/null +++ b/invideoquiz/utils.py @@ -0,0 +1,11 @@ +# -*- coding: utf-8 -*- +""" +Make '_' a no-op so we can scrape strings +""" + + +def _(text): + """ + :return text + """ + return text diff --git a/manage.py b/manage.py new file mode 100644 index 0000000..af2e2d3 --- /dev/null +++ b/manage.py @@ -0,0 +1,12 @@ +#!/usr/bin/env python +""" +Manage the djangoapp +""" +import os +import sys + +from django.core.management import execute_from_command_line + +if __name__ == '__main__': + os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'invideoquiz.settings') + execute_from_command_line(sys.argv) diff --git a/pylintrc b/pylintrc new file mode 100644 index 0000000..9256b31 --- /dev/null +++ b/pylintrc @@ -0,0 +1,8 @@ +[VARIABLES] +dummy-variables-rgx=_|dummy + +[REPORTS] +reports=no + +[MESSAGES CONTROL] +disable=locally-disabled diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..469ba0a --- /dev/null +++ b/requirements.txt @@ -0,0 +1,3 @@ +git+https://github.com/edx/XBlock.git#egg=XBlock +git+https://github.com/edx/xblock-utils.git@v1.0.0#egg=xblock-utils==v1.0.0 +-e . diff --git a/setup.cfg b/setup.cfg new file mode 100644 index 0000000..73797c9 --- /dev/null +++ b/setup.cfg @@ -0,0 +1,6 @@ +[metadata] +description-file = README.markdown + +[nosetests] +cover-package=invideoquiz +cover-tests=1 diff --git a/setup.py b/setup.py new file mode 100644 index 0000000..d5d044f --- /dev/null +++ b/setup.py @@ -0,0 +1,72 @@ +"""Setup for invideoquiz XBlock.""" + +import os +from setuptools import setup +from setuptools.command.test import test as TestCommand + + +def package_data(pkg, roots): + """Generic function to find package_data. + + All of the files under each of the `roots` will be declared as package + data for package `pkg`. + + """ + data = [] + for root in roots: + for dirname, _, files in os.walk(os.path.join(pkg, root)): + for fname in files: + data.append(os.path.relpath(os.path.join(dirname, fname), pkg)) + + return {pkg: data} + + +class Tox(TestCommand): + user_options = [('tox-args=', 'a', 'Arguments to pass to tox')] + + def initialize_options(self): + TestCommand.initialize_options(self) + self.tox_args = None + + def finalize_options(self): + TestCommand.finalize_options(self) + self.test_args = [] + self.test_suite = True + + def run_tests(self): + # import here, cause outside the eggs aren't loaded + import tox + import shlex + args = self.tox_args + if args: + args = shlex.split(self.tox_args) + errno = tox.cmdline(args=args) + sys.exit(errno) + +setup( + name='invideoquiz-xblock', + version='0.1', + description='Helper XBlock to locate CAPA problems within videos.', + license='AGPL v3', + packages=[ + 'invideoquiz', + ], + install_requires=[ + 'django >= 1.8, < 1.9', + 'django_nose', + 'mock', + 'coverage', + 'mako', + 'XBlock', + 'xblock-utils', + ], + dependency_links=[ + 'https://github.com/edx/xblock-utils/tarball/c39bf653e4f27fb3798662ef64cde99f57603f79#egg=xblock-utils', + ], + entry_points={ + 'xblock.v1': [ + 'invideoquiz = invideoquiz:InVideoQuizXBlock', + ], + }, + package_data=package_data('invideoquiz', ['static', 'public']), +) diff --git a/tox.ini b/tox.ini new file mode 100644 index 0000000..7ff857d --- /dev/null +++ b/tox.ini @@ -0,0 +1,45 @@ + +[tox] +downloadcache = {toxworkdir}/_download/ +envlist = py27-dj18,coverage,pep8,pylint + +[testenv] +commands = {envpython} manage.py test + +[testenv:py27-dj18] +deps = + -rrequirements.txt + +[testenv:pep8] +deps = + -rrequirements.txt + pep8 +commands = {envbindir}/pep8 invideoquiz/ + +[testenv:pylint] +deps = + -rrequirements.txt + pylint +commands = {envbindir}/pylint invideoquiz/ + +[testenv:coverage] +deps = + -rrequirements.txt + coverage +setenv = + NOSE_COVER_TESTS=1 + NOSE_WITH_COVERAGE=1 +commands = + {envpython} manage.py test + +[testenv:coveralls] +deps = + -rrequirements.txt + coveralls +setenv = + NOSE_COVER_TESTS=1 + NOSE_WITH_COVERAGE=1 +passenv = TRAVIS TRAVIS_JOB_ID TRAVIS_BRANCH +commands = + {envbindir}/coverage run --source=invideoquiz manage.py test + {envbindir}/coveralls