From 1e8aac2c13030a9c9eaa4c52908d90f10d6d4c45 Mon Sep 17 00:00:00 2001 From: Giulio Gratta Date: Thu, 14 Jul 2016 12:55:39 -0700 Subject: [PATCH 1/2] Initial commit with cookie cutter content --- .gitignore | 2 + .travis.yml | 26 +++++++++ README.md | 2 +- invideoquiz/__init__.py | 1 + invideoquiz/invideoquiz.py | 71 ++++++++++++++++++++++++ invideoquiz/settings.py | 14 +++++ invideoquiz/static/README.txt | 19 +++++++ invideoquiz/static/css/invideoquiz.css | 9 +++ invideoquiz/static/html/invideoquiz.html | 5 ++ invideoquiz/static/js/src/invideoquiz.js | 22 ++++++++ invideoquiz/tests.py | 0 invideoquiz/translations/README.txt | 4 ++ manage.py | 12 ++++ pylintrc | 8 +++ setup.cfg | 6 ++ setup.py | 58 +++++++++++++++++++ tox.ini | 67 ++++++++++++++++++++++ 17 files changed, 325 insertions(+), 1 deletion(-) create mode 100644 .gitignore create mode 100644 .travis.yml create mode 100644 invideoquiz/__init__.py create mode 100644 invideoquiz/invideoquiz.py create mode 100644 invideoquiz/settings.py create mode 100644 invideoquiz/static/README.txt create mode 100644 invideoquiz/static/css/invideoquiz.css create mode 100644 invideoquiz/static/html/invideoquiz.html create mode 100644 invideoquiz/static/js/src/invideoquiz.js create mode 100644 invideoquiz/tests.py create mode 100644 invideoquiz/translations/README.txt create mode 100644 manage.py create mode 100644 pylintrc create mode 100644 setup.cfg create mode 100644 setup.py create mode 100644 tox.ini diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..4386b15 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +.coverage +.tox/ \ No newline at end of file diff --git a/.travis.yml b/.travis.yml new file mode 100644 index 0000000..596249b --- /dev/null +++ b/.travis.yml @@ -0,0 +1,26 @@ +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-dj14 + - TOXENV=py27-dj18 + - TOXENV=coveralls + - TOXENV=pep8 + - TOXENV=pyflakes + - TOXENV=pylint \ No newline at end of file diff --git a/README.md b/README.md index e04f22e..b23b9bf 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,2 @@ -New XBlock +In Video Quiz XBlock ==================== diff --git a/invideoquiz/__init__.py b/invideoquiz/__init__.py new file mode 100644 index 0000000..8ff71bf --- /dev/null +++ b/invideoquiz/__init__.py @@ -0,0 +1 @@ +from .invideoquiz import InVideoQuizXBlock diff --git a/invideoquiz/invideoquiz.py b/invideoquiz/invideoquiz.py new file mode 100644 index 0000000..7558616 --- /dev/null +++ b/invideoquiz/invideoquiz.py @@ -0,0 +1,71 @@ +"""TO-DO: Write a description of what this XBlock is.""" + +import pkg_resources + +from xblock.core import XBlock +from xblock.fields import Scope, Integer +from xblock.fragment import Fragment + + +class InVideoQuizXBlock(XBlock): + """ + TO-DO: document what your XBlock does. + """ + + # Fields are defined on the class. You can access them in your code as + # self.. + + # TO-DO: delete count, and define your own fields. + count = Integer( + default=0, scope=Scope.user_state, + help="A simple counter, to show something happening", + ) + + def resource_string(self, path): + """Handy helper for getting resources from our kit.""" + data = pkg_resources.resource_string(__name__, path) + return data.decode("utf8") + + # TO-DO: change this view to display your data your own way. + def student_view(self, context=None): + """ + The primary view of the InVideoQuizXBlock, shown to students + when viewing courses. + """ + html = self.resource_string("static/html/invideoquiz.html") + frag = Fragment(html.format(self=self)) + frag.add_css(self.resource_string("static/css/invideoquiz.css")) + frag.add_javascript(self.resource_string("static/js/src/invideoquiz.js")) + frag.initialize_js('InVideoQuizXBlock') + return frag + + # TO-DO: change this handler to perform your own actions. You may need + # more than one handler, or you may not need any handlers at all. + @XBlock.json_handler + def increment_count(self, data, suffix=''): + """ + An example handler, which increments the data. + """ + # Just to show data coming in... + assert data['hello'] == 'world' + + self.count += 1 + return {"count": self.count} + + # TO-DO: change this to create the scenarios you'd like to see in the + # workbench while developing your XBlock. + @staticmethod + def workbench_scenarios(): + """A canned scenario for display in the workbench.""" + return [ + ("InVideoQuizXBlock", + """ + """), + ("Multiple InVideoQuizXBlock", + """ + + + + + """), + ] diff --git a/invideoquiz/settings.py b/invideoquiz/settings.py new file mode 100644 index 0000000..297c772 --- /dev/null +++ b/invideoquiz/settings.py @@ -0,0 +1,14 @@ +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/static/README.txt b/invideoquiz/static/README.txt new file mode 100644 index 0000000..0472ef6 --- /dev/null +++ b/invideoquiz/static/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/static/css/invideoquiz.css b/invideoquiz/static/css/invideoquiz.css new file mode 100644 index 0000000..452cc0b --- /dev/null +++ b/invideoquiz/static/css/invideoquiz.css @@ -0,0 +1,9 @@ +/* CSS for InVideoQuizXBlock */ + +.invideoquiz_block .count { + font-weight: bold; +} + +.invideoquiz_block p { + cursor: pointer; +} diff --git a/invideoquiz/static/html/invideoquiz.html b/invideoquiz/static/html/invideoquiz.html new file mode 100644 index 0000000..83233f5 --- /dev/null +++ b/invideoquiz/static/html/invideoquiz.html @@ -0,0 +1,5 @@ +
+

InVideoQuizXBlock: count is now + {self.count} (click me to increment). +

+
diff --git a/invideoquiz/static/js/src/invideoquiz.js b/invideoquiz/static/js/src/invideoquiz.js new file mode 100644 index 0000000..ee98665 --- /dev/null +++ b/invideoquiz/static/js/src/invideoquiz.js @@ -0,0 +1,22 @@ +/* Javascript for InVideoQuizXBlock. */ +function InVideoQuizXBlock(runtime, element) { + + function updateCount(result) { + $('.count', element).text(result.count); + } + + var handlerUrl = runtime.handlerUrl(element, 'increment_count'); + + $('p', element).click(function(eventObject) { + $.ajax({ + type: "POST", + url: handlerUrl, + data: JSON.stringify({"hello": "world"}), + success: updateCount + }); + }); + + $(function ($) { + /* Here's where you'd do things on page load. */ + }); +} diff --git a/invideoquiz/tests.py b/invideoquiz/tests.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/manage.py b/manage.py new file mode 100644 index 0000000..392fbae --- /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) \ No newline at end of file diff --git a/pylintrc b/pylintrc new file mode 100644 index 0000000..dd00f80 --- /dev/null +++ b/pylintrc @@ -0,0 +1,8 @@ +[VARIABLES] +dummy-variables-rgx=_|dummy + +[REPORTS] +reports=no + +[MESSAGES CONTROL] +disable=locally-disabled \ No newline at end of file diff --git a/setup.cfg b/setup.cfg new file mode 100644 index 0000000..cdb9a4b --- /dev/null +++ b/setup.cfg @@ -0,0 +1,6 @@ +[metadata] +description-file = README.markdown + +[nosetests] +cover-package=invideoquiz +cover-tests=1 \ No newline at end of file diff --git a/setup.py b/setup.py new file mode 100644 index 0000000..cf033f7 --- /dev/null +++ b/setup.py @@ -0,0 +1,58 @@ +"""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='invideoquiz XBlock', # TODO: write a better description. + license='UNKNOWN', # TODO: choose a license: 'AGPL v3' and 'Apache 2.0' are popular. + packages=[ + 'invideoquiz', + ], + install_requires=[ + 'XBlock', + ], + 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..e3ae3f1 --- /dev/null +++ b/tox.ini @@ -0,0 +1,67 @@ + +[tox] +downloadcache = {toxworkdir}/_download/ +envlist = py27-dj{14,18},coverage,pep8,pylint,pyflakes + +[testenv] +commands = {envpython} manage.py test + +[testenv:py27-dj14] +deps = + django == 1.4.22 + edx-opaque-keys + mock + django_nose>=1.4 + +[testenv:py27-dj18] +deps = + django >= 1.8, < 1.9 + edx-opaque-keys + mock + django_nose>=1.4 + +[testenv:pep8] +deps = pep8 +commands = {envbindir}/pep8 invideoquiz/ + +[testenv:pylint] +deps = pylint +commands = {envbindir}/pylint invideoquiz/ + +[testenv:pyflakes] +deps = pyflakes +commands = {envbindir}/pyflakes invideoquiz/ + +[testenv:coverage] +deps = + coverage + django == 1.4.22 + edx-opaque-keys + mock + django_nose>=1.4 + +setenv = + NOSE_COVER_TESTS=1 + NOSE_WITH_COVERAGE=1 + +commands = + {envpython} manage.py test + +[testenv:coveralls] +deps = + coverage + coveralls + django == 1.4.22 + edx-opaque-keys + mock + django_nose>=1.4 + +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 \ No newline at end of file From ed49d78151c8e23b17727dd78ddaf58c53cd0b5a Mon Sep 17 00:00:00 2001 From: Giulio Gratta Date: Thu, 14 Jul 2016 13:21:52 -0700 Subject: [PATCH 2/2] Add basic In Video Quiz functionality - Leverages default video and problem components to take advantage of grading and theming - Basic "in-video" learner view where components are displayed inside of video at the set time - Instructor view where problems are displayed as normal problems with a message about when they appear in the video so instructors can still use all the problem data features - Simple Studio view in which the video and problem component IDs can be set, as well as the timemap - use xblock user_service to check for staff access --- .gitignore | 2 +- .travis.yml | 4 +- README.md | 2 - README.rst | 47 ++++++ invideoquiz/__init__.py | 3 + invideoquiz/invideoquiz.py | 184 +++++++++++++++++----- invideoquiz/{static => public}/README.txt | 0 invideoquiz/public/css/invideoquiz.css | 52 ++++++ invideoquiz/public/html/invideoquiz.html | 3 + invideoquiz/public/js/src/config.js | 12 ++ invideoquiz/public/js/src/invideoquiz.js | 110 +++++++++++++ invideoquiz/settings.py | 4 + invideoquiz/static/css/invideoquiz.css | 9 -- invideoquiz/static/html/invideoquiz.html | 5 - invideoquiz/static/js/src/invideoquiz.js | 22 --- invideoquiz/tests/test_travis.py | 0 invideoquiz/utils.py | 11 ++ manage.py | 2 +- pylintrc | 2 +- requirements.txt | 3 + setup.cfg | 2 +- setup.py | 24 ++- tox.ini | 44 ++---- 23 files changed, 422 insertions(+), 125 deletions(-) delete mode 100644 README.md create mode 100644 README.rst rename invideoquiz/{static => public}/README.txt (100%) create mode 100644 invideoquiz/public/css/invideoquiz.css create mode 100644 invideoquiz/public/html/invideoquiz.html create mode 100644 invideoquiz/public/js/src/config.js create mode 100644 invideoquiz/public/js/src/invideoquiz.js delete mode 100644 invideoquiz/static/css/invideoquiz.css delete mode 100644 invideoquiz/static/html/invideoquiz.html delete mode 100644 invideoquiz/static/js/src/invideoquiz.js create mode 100644 invideoquiz/tests/test_travis.py create mode 100644 invideoquiz/utils.py create mode 100644 requirements.txt diff --git a/.gitignore b/.gitignore index 4386b15..f9c0ec8 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,2 @@ .coverage -.tox/ \ No newline at end of file +.tox/ diff --git a/.travis.yml b/.travis.yml index 596249b..b651071 100644 --- a/.travis.yml +++ b/.travis.yml @@ -18,9 +18,7 @@ branches: only: - 'master' env: - - TOXENV=py27-dj14 - TOXENV=py27-dj18 - TOXENV=coveralls - TOXENV=pep8 - - TOXENV=pyflakes - - TOXENV=pylint \ No newline at end of file + - TOXENV=pylint diff --git a/README.md b/README.md deleted file mode 100644 index b23b9bf..0000000 --- a/README.md +++ /dev/null @@ -1,2 +0,0 @@ -In Video Quiz 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 index 8ff71bf..a35159a 100644 --- a/invideoquiz/__init__.py +++ b/invideoquiz/__init__.py @@ -1 +1,4 @@ +""" +Runtime will load the XBlock class from here. +""" from .invideoquiz import InVideoQuizXBlock diff --git a/invideoquiz/invideoquiz.py b/invideoquiz/invideoquiz.py index 7558616..85869df 100644 --- a/invideoquiz/invideoquiz.py +++ b/invideoquiz/invideoquiz.py @@ -1,71 +1,171 @@ -"""TO-DO: Write a description of what this XBlock is.""" +""" +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, Integer +from xblock.fields import Scope +from xblock.fields import String from xblock.fragment import Fragment +from xblockutils.studio_editable import StudioEditableXBlockMixin +from .utils import _ -class InVideoQuizXBlock(XBlock): + +def get_resource_string(path): """ - TO-DO: document what your XBlock does. + 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') - # Fields are defined on the class. You can access them in your code as - # self.. - # TO-DO: delete count, and define your own fields. - count = Integer( - default=0, scope=Scope.user_state, - help="A simple counter, to show something happening", +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, ) - def resource_string(self, path): - """Handy helper for getting resources from our kit.""" - data = pkg_resources.resource_string(__name__, path) - return data.decode("utf8") + 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.' + ), + ) - # TO-DO: change this view to display your data your own way. - def student_view(self, context=None): + 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 """ - The primary view of the InVideoQuizXBlock, shown to students - when viewing courses. + Show to students when viewing courses """ - html = self.resource_string("static/html/invideoquiz.html") - frag = Fragment(html.format(self=self)) - frag.add_css(self.resource_string("static/css/invideoquiz.css")) - frag.add_javascript(self.resource_string("static/js/src/invideoquiz.js")) - frag.initialize_js('InVideoQuizXBlock') - return frag - - # TO-DO: change this handler to perform your own actions. You may need - # more than one handler, or you may not need any handlers at all. - @XBlock.json_handler - def increment_count(self, data, suffix=''): + 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): """ - An example handler, which increments the data. + Check user's permission mode for this XBlock. + Returns: + user permission mode """ - # Just to show data coming in... - assert data['hello'] == 'world' + try: + if self.xmodule_runtime.user_is_staff: + return 'staff' + except AttributeError: + pass + return 'student' - self.count += 1 - return {"count": self.count} - - # TO-DO: change this to create the scenarios you'd like to see in the - # workbench while developing your XBlock. @staticmethod def workbench_scenarios(): - """A canned scenario for display in the workbench.""" + """ + 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/static/README.txt b/invideoquiz/public/README.txt similarity index 100% rename from invideoquiz/static/README.txt rename to invideoquiz/public/README.txt 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 index 297c772..ce43723 100644 --- a/invideoquiz/settings.py +++ b/invideoquiz/settings.py @@ -1,3 +1,7 @@ +""" +Settings for in-video-quiz xblock +""" + DATABASES = { 'default': { 'ENGINE': 'django.db.backends.sqlite3', diff --git a/invideoquiz/static/css/invideoquiz.css b/invideoquiz/static/css/invideoquiz.css deleted file mode 100644 index 452cc0b..0000000 --- a/invideoquiz/static/css/invideoquiz.css +++ /dev/null @@ -1,9 +0,0 @@ -/* CSS for InVideoQuizXBlock */ - -.invideoquiz_block .count { - font-weight: bold; -} - -.invideoquiz_block p { - cursor: pointer; -} diff --git a/invideoquiz/static/html/invideoquiz.html b/invideoquiz/static/html/invideoquiz.html deleted file mode 100644 index 83233f5..0000000 --- a/invideoquiz/static/html/invideoquiz.html +++ /dev/null @@ -1,5 +0,0 @@ -
-

InVideoQuizXBlock: count is now - {self.count} (click me to increment). -

-
diff --git a/invideoquiz/static/js/src/invideoquiz.js b/invideoquiz/static/js/src/invideoquiz.js deleted file mode 100644 index ee98665..0000000 --- a/invideoquiz/static/js/src/invideoquiz.js +++ /dev/null @@ -1,22 +0,0 @@ -/* Javascript for InVideoQuizXBlock. */ -function InVideoQuizXBlock(runtime, element) { - - function updateCount(result) { - $('.count', element).text(result.count); - } - - var handlerUrl = runtime.handlerUrl(element, 'increment_count'); - - $('p', element).click(function(eventObject) { - $.ajax({ - type: "POST", - url: handlerUrl, - data: JSON.stringify({"hello": "world"}), - success: updateCount - }); - }); - - $(function ($) { - /* Here's where you'd do things on page load. */ - }); -} 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/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 index 392fbae..af2e2d3 100644 --- a/manage.py +++ b/manage.py @@ -9,4 +9,4 @@ if __name__ == '__main__': os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'invideoquiz.settings') - execute_from_command_line(sys.argv) \ No newline at end of file + execute_from_command_line(sys.argv) diff --git a/pylintrc b/pylintrc index dd00f80..9256b31 100644 --- a/pylintrc +++ b/pylintrc @@ -5,4 +5,4 @@ dummy-variables-rgx=_|dummy reports=no [MESSAGES CONTROL] -disable=locally-disabled \ No newline at end of file +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 index cdb9a4b..73797c9 100644 --- a/setup.cfg +++ b/setup.cfg @@ -3,4 +3,4 @@ description-file = README.markdown [nosetests] cover-package=invideoquiz -cover-tests=1 \ No newline at end of file +cover-tests=1 diff --git a/setup.py b/setup.py index cf033f7..d5d044f 100644 --- a/setup.py +++ b/setup.py @@ -4,6 +4,7 @@ from setuptools import setup from setuptools.command.test import test as TestCommand + def package_data(pkg, roots): """Generic function to find package_data. @@ -19,15 +20,19 @@ def package_data(pkg, roots): return {pkg: data} + class Tox(TestCommand): - user_options = [('tox-args=', 'a', "Arguments to pass to tox")] + 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 @@ -41,18 +46,27 @@ def run_tests(self): setup( name='invideoquiz-xblock', version='0.1', - description='invideoquiz XBlock', # TODO: write a better description. - license='UNKNOWN', # TODO: choose a license: 'AGPL v3' and 'Apache 2.0' are popular. + 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"]), + package_data=package_data('invideoquiz', ['static', 'public']), ) diff --git a/tox.ini b/tox.ini index e3ae3f1..7ff857d 100644 --- a/tox.ini +++ b/tox.ini @@ -1,67 +1,45 @@ [tox] downloadcache = {toxworkdir}/_download/ -envlist = py27-dj{14,18},coverage,pep8,pylint,pyflakes +envlist = py27-dj18,coverage,pep8,pylint [testenv] commands = {envpython} manage.py test -[testenv:py27-dj14] -deps = - django == 1.4.22 - edx-opaque-keys - mock - django_nose>=1.4 - [testenv:py27-dj18] deps = - django >= 1.8, < 1.9 - edx-opaque-keys - mock - django_nose>=1.4 + -rrequirements.txt [testenv:pep8] -deps = pep8 +deps = + -rrequirements.txt + pep8 commands = {envbindir}/pep8 invideoquiz/ [testenv:pylint] -deps = pylint +deps = + -rrequirements.txt + pylint commands = {envbindir}/pylint invideoquiz/ -[testenv:pyflakes] -deps = pyflakes -commands = {envbindir}/pyflakes invideoquiz/ - [testenv:coverage] deps = + -rrequirements.txt coverage - django == 1.4.22 - edx-opaque-keys - mock - django_nose>=1.4 - setenv = NOSE_COVER_TESTS=1 NOSE_WITH_COVERAGE=1 - commands = {envpython} manage.py test [testenv:coveralls] deps = - coverage + -rrequirements.txt coveralls - django == 1.4.22 - edx-opaque-keys - mock - django_nose>=1.4 - 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 \ No newline at end of file + {envbindir}/coveralls