Merge remote-tracking branch 'edx/master' into opaque-keys
Conflicts: cms/djangoapps/contentstore/tests/test_contentstore.py cms/djangoapps/contentstore/views/component.py cms/djangoapps/contentstore/views/item.py cms/djangoapps/contentstore/views/preview.py cms/djangoapps/contentstore/views/tests/test_container.py cms/static/js/spec/views/unit_spec.js cms/static/js/utils/module.js cms/templates/container.html cms/templates/studio_vertical_wrapper.html cms/templates/studio_xblock_wrapper.html common/djangoapps/student/views.py lms/templates/notes.html lms/templates/textannotation.html lms/templates/videoannotation.html
This commit is contained in:
32
common/lib/xmodule/xmodule/annotator_token.py
Normal file
32
common/lib/xmodule/xmodule/annotator_token.py
Normal file
@@ -0,0 +1,32 @@
|
||||
"""
|
||||
This file contains a function used to retrieve the token for the annotation backend
|
||||
without having to create a view, but just returning a string instead.
|
||||
|
||||
It can be called from other files by using the following:
|
||||
from xmodule.annotator_token import retrieve_token
|
||||
"""
|
||||
import datetime
|
||||
from firebase_token_generator import create_token
|
||||
|
||||
|
||||
def retrieve_token(userid, secret):
|
||||
'''
|
||||
Return a token for the backend of annotations.
|
||||
It uses the course id to retrieve a variable that contains the secret
|
||||
token found in inheritance.py. It also contains information of when
|
||||
the token was issued. This will be stored with the user along with
|
||||
the id for identification purposes in the backend.
|
||||
'''
|
||||
|
||||
# the following five lines of code allows you to include the default timezone in the iso format
|
||||
# for more information: http://stackoverflow.com/questions/3401428/how-to-get-an-isoformat-datetime-string-including-the-default-timezone
|
||||
dtnow = datetime.datetime.now()
|
||||
dtutcnow = datetime.datetime.utcnow()
|
||||
delta = dtnow - dtutcnow
|
||||
newhour, newmin = divmod((delta.days * 24 * 60 * 60 + delta.seconds + 30) // 60, 60)
|
||||
newtime = "%s%+02d:%02d" % (dtnow.isoformat(), newhour, newmin)
|
||||
# uses the issued time (UTC plus timezone), the consumer key and the user's email to maintain a
|
||||
# federated system in the annotation backend server
|
||||
custom_data = {"issuedAt": newtime, "consumerKey": secret, "userId": userid, "ttl": 86400}
|
||||
newtoken = create_token(secret, custom_data)
|
||||
return newtoken
|
||||
@@ -301,6 +301,7 @@ function (VideoPlayer, VideoStorage) {
|
||||
// TODO: use 1 class to work with.
|
||||
state.el.find('.video-player div').addClass('hidden');
|
||||
state.el.find('.video-player h3').removeClass('hidden');
|
||||
_hideWaitPlaceholder(state);
|
||||
|
||||
console.log(
|
||||
'[Video info]: Non-youtube video sources aren\'t available.'
|
||||
@@ -673,6 +674,15 @@ function (VideoPlayer, VideoStorage) {
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// None of the supported video formats can be played. Hide the spinner.
|
||||
if (!(_.compact(_.values(this.html5Sources)))) {
|
||||
_hideWaitPlaceholder(state);
|
||||
console.log(
|
||||
'[Video info]: This browser cannot play .mp4, .ogg, or .webm ' +
|
||||
'files'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// function fetchMetadata()
|
||||
|
||||
@@ -216,7 +216,10 @@ function () {
|
||||
// video element via jquery (http://bugs.jquery.com/ticket/9174) we
|
||||
// create it using native JS.
|
||||
this.video = document.createElement('video');
|
||||
this.video.innerHTML = _.values(sourceStr).join('');
|
||||
errorMessage = gettext('This browser cannot play .mp4, .ogg, or ' +
|
||||
'.webm files. Try using a different browser, such as Google ' +
|
||||
'Chrome.');
|
||||
this.video.innerHTML = _.values(sourceStr).join('') + errorMessage;
|
||||
|
||||
// Get the jQuery object, and set the player state to UNSTARTED.
|
||||
// The player state is used by other parts of the VideoPlayer to
|
||||
|
||||
30
common/lib/xmodule/xmodule/studio_editable.py
Normal file
30
common/lib/xmodule/xmodule/studio_editable.py
Normal file
@@ -0,0 +1,30 @@
|
||||
"""
|
||||
Mixin to support editing in Studio.
|
||||
"""
|
||||
|
||||
|
||||
class StudioEditableModule(object):
|
||||
"""
|
||||
Helper methods for supporting Studio editing of xblocks.
|
||||
"""
|
||||
|
||||
def render_reorderable_children(self, context, fragment):
|
||||
"""
|
||||
Renders children with the appropriate HTML structure for drag and drop.
|
||||
"""
|
||||
contents = []
|
||||
|
||||
for child in self.get_display_items():
|
||||
context['reorderable_items'].add(child.location)
|
||||
rendered_child = child.render('student_view', context)
|
||||
fragment.add_frag_resources(rendered_child)
|
||||
|
||||
contents.append({
|
||||
'id': child.id,
|
||||
'content': rendered_child.content
|
||||
})
|
||||
|
||||
fragment.add_content(self.system.render_template("studio_render_children_view.html", {
|
||||
'items': contents,
|
||||
'xblock_context': context,
|
||||
}))
|
||||
20
common/lib/xmodule/xmodule/tests/test_annotator_token.py
Normal file
20
common/lib/xmodule/xmodule/tests/test_annotator_token.py
Normal file
@@ -0,0 +1,20 @@
|
||||
"""
|
||||
This test will run for annotator_token.py
|
||||
"""
|
||||
import unittest
|
||||
|
||||
from xmodule.annotator_token import retrieve_token
|
||||
|
||||
|
||||
class TokenRetriever(unittest.TestCase):
|
||||
"""
|
||||
Tests to make sure that when passed in a username and secret token, that it will be encoded correctly
|
||||
"""
|
||||
def test_token(self):
|
||||
"""
|
||||
Test for the token generator. Give an a random username and secret token, it should create the properly encoded string of text.
|
||||
"""
|
||||
expected = "eyJhbGciOiAiSFMyNTYiLCAidHlwIjogIkpXVCJ9.eyJpc3N1ZWRBdCI6ICIyMDE0LTAyLTI3VDE3OjAwOjQyLjQwNjQ0MSswOjAwIiwgImNvbnN1bWVyS2V5IjogImZha2Vfc2VjcmV0IiwgInVzZXJJZCI6ICJ1c2VybmFtZSIsICJ0dGwiOiA4NjQwMH0.Dx1PoF-7mqBOOSGDMZ9R_s3oaaLRPnn6CJgGGF2A5CQ"
|
||||
response = retrieve_token("username", "fake_secret")
|
||||
self.assertEqual(expected.split('.')[0], response.split('.')[0])
|
||||
self.assertNotEqual(expected.split('.')[2], response.split('.')[2])
|
||||
25
common/lib/xmodule/xmodule/tests/test_studio_editable.py
Normal file
25
common/lib/xmodule/xmodule/tests/test_studio_editable.py
Normal file
@@ -0,0 +1,25 @@
|
||||
"""
|
||||
Tests for StudioEditableModule.
|
||||
"""
|
||||
|
||||
from xmodule.tests.test_vertical import BaseVerticalModuleTest
|
||||
|
||||
|
||||
class StudioEditableModuleTestCase(BaseVerticalModuleTest):
|
||||
def test_render_reorderable_children(self):
|
||||
"""
|
||||
Test the behavior of render_reorderable_children.
|
||||
"""
|
||||
reorderable_items = set()
|
||||
context = {
|
||||
'runtime_type': 'studio',
|
||||
'container_view': True,
|
||||
'reorderable_items': reorderable_items,
|
||||
'read_only': False,
|
||||
'root_xblock': self.vertical,
|
||||
}
|
||||
|
||||
# Both children of the vertical should be rendered as reorderable
|
||||
self.module_system.render(self.vertical, 'student_view', context).content
|
||||
self.assertIn(self.vertical.get_children()[0].location, reorderable_items)
|
||||
self.assertIn(self.vertical.get_children()[1].location, reorderable_items)
|
||||
@@ -38,17 +38,6 @@ class TextAnnotationModuleTestCase(unittest.TestCase):
|
||||
ScopeIds(None, None, None, None)
|
||||
)
|
||||
|
||||
def test_render_content(self):
|
||||
"""
|
||||
Tests to make sure the sample xml is rendered and that it forms a valid xmltree
|
||||
that does not contain a display_name.
|
||||
"""
|
||||
content = self.mod._render_content() # pylint: disable=W0212
|
||||
self.assertIsNotNone(content)
|
||||
element = etree.fromstring(content)
|
||||
self.assertIsNotNone(element)
|
||||
self.assertFalse('display_name' in element.attrib, "Display Name should have been deleted from Content")
|
||||
|
||||
def test_extract_instructions(self):
|
||||
"""
|
||||
Tests to make sure that the instructions are correctly pulled from the sample xml above.
|
||||
@@ -70,5 +59,5 @@ class TextAnnotationModuleTestCase(unittest.TestCase):
|
||||
Tests the function that passes in all the information in the context that will be used in templates/textannotation.html
|
||||
"""
|
||||
context = self.mod.get_html()
|
||||
for key in ['display_name', 'tag', 'source', 'instructions_html', 'content_html', 'annotation_storage']:
|
||||
for key in ['display_name', 'tag', 'source', 'instructions_html', 'content_html', 'annotation_storage', 'token']:
|
||||
self.assertIn(key, context)
|
||||
|
||||
65
common/lib/xmodule/xmodule/tests/test_vertical.py
Normal file
65
common/lib/xmodule/xmodule/tests/test_vertical.py
Normal file
@@ -0,0 +1,65 @@
|
||||
"""
|
||||
Tests for vertical module.
|
||||
"""
|
||||
|
||||
from fs.memoryfs import MemoryFS
|
||||
from xmodule.tests import get_test_system
|
||||
from xmodule.tests.xml import XModuleXmlImportTest
|
||||
from xmodule.tests.xml import factories as xml
|
||||
|
||||
|
||||
class BaseVerticalModuleTest(XModuleXmlImportTest):
|
||||
test_html_1 = 'Test HTML 1'
|
||||
test_html_2 = 'Test HTML 2'
|
||||
|
||||
def setUp(self):
|
||||
self.course_id = 'test_org/test_course_number/test_run'
|
||||
# construct module
|
||||
course = xml.CourseFactory.build()
|
||||
sequence = xml.SequenceFactory.build(parent=course)
|
||||
vertical = xml.VerticalFactory.build(parent=sequence)
|
||||
|
||||
self.course = self.process_xml(course)
|
||||
xml.HtmlFactory(parent=vertical, url_name='test-html-1', text=self.test_html_1)
|
||||
xml.HtmlFactory(parent=vertical, url_name='test-html-2', text=self.test_html_2)
|
||||
|
||||
self.course = self.process_xml(course)
|
||||
course_seq = self.course.get_children()[0]
|
||||
self.module_system = get_test_system()
|
||||
|
||||
def get_module(descriptor):
|
||||
"""Mocks module_system get_module function"""
|
||||
module_system = get_test_system()
|
||||
module_system.get_module = get_module
|
||||
descriptor.bind_for_student(module_system, descriptor._field_data) # pylint: disable=protected-access
|
||||
return descriptor
|
||||
|
||||
self.module_system.get_module = get_module
|
||||
self.module_system.descriptor_system = self.course.runtime
|
||||
self.course.runtime.export_fs = MemoryFS()
|
||||
|
||||
self.vertical = course_seq.get_children()[0]
|
||||
self.vertical.xmodule_runtime = self.module_system
|
||||
|
||||
|
||||
class VerticalModuleTestCase(BaseVerticalModuleTest):
|
||||
def test_render_student_view(self):
|
||||
"""
|
||||
Test the rendering of the student view.
|
||||
"""
|
||||
html = self.module_system.render(self.vertical, 'student_view', {}).content
|
||||
self.assertIn(self.test_html_1, html)
|
||||
self.assertIn(self.test_html_2, html)
|
||||
|
||||
def test_render_studio_view(self):
|
||||
"""
|
||||
Test the rendering of the Studio view
|
||||
"""
|
||||
reorderable_items = set()
|
||||
context = {
|
||||
'runtime_type': 'studio',
|
||||
'reorderable_items': reorderable_items,
|
||||
}
|
||||
html = self.module_system.render(self.vertical, 'student_view', context).content
|
||||
self.assertIn(self.test_html_1, html)
|
||||
self.assertIn(self.test_html_2, html)
|
||||
@@ -34,100 +34,6 @@ class VideoAnnotationModuleTestCase(unittest.TestCase):
|
||||
ScopeIds(None, None, None, None)
|
||||
)
|
||||
|
||||
def test_annotation_class_attr_default(self):
|
||||
"""
|
||||
Makes sure that it can detect annotation values in text-form if user
|
||||
decides to add text to the area below video, video functionality is completely
|
||||
found in javascript.
|
||||
"""
|
||||
xml = '<annotation title="x" body="y" problem="0">test</annotation>'
|
||||
element = etree.fromstring(xml)
|
||||
|
||||
expected_attr = {'class': {'value': 'annotatable-span highlight'}}
|
||||
actual_attr = self.mod._get_annotation_class_attr(element) # pylint: disable=W0212
|
||||
|
||||
self.assertIsInstance(actual_attr, dict)
|
||||
self.assertDictEqual(expected_attr, actual_attr)
|
||||
|
||||
def test_annotation_class_attr_with_valid_highlight(self):
|
||||
"""
|
||||
Same as above but more specific to an area that is highlightable in the appropriate
|
||||
color designated.
|
||||
"""
|
||||
xml = '<annotation title="x" body="y" problem="0" highlight="{highlight}">test</annotation>'
|
||||
|
||||
for color in self.mod.highlight_colors:
|
||||
element = etree.fromstring(xml.format(highlight=color))
|
||||
value = 'annotatable-span highlight highlight-{highlight}'.format(highlight=color)
|
||||
|
||||
expected_attr = {'class': {
|
||||
'value': value,
|
||||
'_delete': 'highlight'}
|
||||
}
|
||||
actual_attr = self.mod._get_annotation_class_attr(element) # pylint: disable=W0212
|
||||
|
||||
self.assertIsInstance(actual_attr, dict)
|
||||
self.assertDictEqual(expected_attr, actual_attr)
|
||||
|
||||
def test_annotation_class_attr_with_invalid_highlight(self):
|
||||
"""
|
||||
Same as above, but checked with invalid colors.
|
||||
"""
|
||||
xml = '<annotation title="x" body="y" problem="0" highlight="{highlight}">test</annotation>'
|
||||
|
||||
for invalid_color in ['rainbow', 'blink', 'invisible', '', None]:
|
||||
element = etree.fromstring(xml.format(highlight=invalid_color))
|
||||
expected_attr = {'class': {
|
||||
'value': 'annotatable-span highlight',
|
||||
'_delete': 'highlight'}
|
||||
}
|
||||
actual_attr = self.mod._get_annotation_class_attr(element) # pylint: disable=W0212
|
||||
|
||||
self.assertIsInstance(actual_attr, dict)
|
||||
self.assertDictEqual(expected_attr, actual_attr)
|
||||
|
||||
def test_annotation_data_attr(self):
|
||||
"""
|
||||
Test that each highlight contains the data information from the annotation itself.
|
||||
"""
|
||||
element = etree.fromstring('<annotation title="bar" body="foo" problem="0">test</annotation>')
|
||||
|
||||
expected_attr = {
|
||||
'data-comment-body': {'value': 'foo', '_delete': 'body'},
|
||||
'data-comment-title': {'value': 'bar', '_delete': 'title'},
|
||||
'data-problem-id': {'value': '0', '_delete': 'problem'}
|
||||
}
|
||||
|
||||
actual_attr = self.mod._get_annotation_data_attr(element) # pylint: disable=W0212
|
||||
|
||||
self.assertIsInstance(actual_attr, dict)
|
||||
self.assertDictEqual(expected_attr, actual_attr)
|
||||
|
||||
def test_render_annotation(self):
|
||||
"""
|
||||
Tests to make sure that the spans designating annotations acutally visually render as annotations.
|
||||
"""
|
||||
expected_html = '<span class="annotatable-span highlight highlight-yellow" data-comment-title="x" data-comment-body="y" data-problem-id="0">z</span>'
|
||||
expected_el = etree.fromstring(expected_html)
|
||||
|
||||
actual_el = etree.fromstring('<annotation title="x" body="y" problem="0" highlight="yellow">z</annotation>')
|
||||
self.mod._render_annotation(actual_el) # pylint: disable=W0212
|
||||
|
||||
self.assertEqual(expected_el.tag, actual_el.tag)
|
||||
self.assertEqual(expected_el.text, actual_el.text)
|
||||
self.assertDictEqual(dict(expected_el.attrib), dict(actual_el.attrib))
|
||||
|
||||
def test_render_content(self):
|
||||
"""
|
||||
Like above, but using the entire text, it makes sure that display_name is removed and that there is only one
|
||||
div encompassing the annotatable area.
|
||||
"""
|
||||
content = self.mod._render_content() # pylint: disable=W0212
|
||||
element = etree.fromstring(content)
|
||||
self.assertIsNotNone(element)
|
||||
self.assertEqual('div', element.tag, 'root tag is a div')
|
||||
self.assertFalse('display_name' in element.attrib, "Display Name should have been deleted from Content")
|
||||
|
||||
def test_extract_instructions(self):
|
||||
"""
|
||||
This test ensures that if an instruction exists it is pulled and
|
||||
@@ -160,6 +66,6 @@ class VideoAnnotationModuleTestCase(unittest.TestCase):
|
||||
"""
|
||||
Tests to make sure variables passed in truly exist within the html once it is all rendered.
|
||||
"""
|
||||
context = self.mod.get_html()
|
||||
for key in ['display_name', 'content_html', 'instructions_html', 'sourceUrl', 'typeSource', 'poster', 'alert', 'annotation_storage']:
|
||||
context = self.mod.get_html() # pylint: disable=W0212
|
||||
for key in ['display_name', 'instructions_html', 'sourceUrl', 'typeSource', 'poster', 'annotation_storage']:
|
||||
self.assertIn(key, context)
|
||||
|
||||
@@ -6,6 +6,7 @@ from pkg_resources import resource_string
|
||||
from xmodule.x_module import XModule
|
||||
from xmodule.raw_module import RawDescriptor
|
||||
from xblock.core import Scope, String
|
||||
from xmodule.annotator_token import retrieve_token
|
||||
|
||||
import textwrap
|
||||
|
||||
@@ -30,7 +31,7 @@ class AnnotatableFields(object):
|
||||
scope=Scope.settings,
|
||||
default='Text Annotation',
|
||||
)
|
||||
tags = String(
|
||||
instructor_tags = String(
|
||||
display_name="Tags for Assignments",
|
||||
help="Add tags that automatically highlight in a certain color using the comma-separated form, i.e. imagery:red,parallelism:blue",
|
||||
scope=Scope.settings,
|
||||
@@ -43,6 +44,7 @@ class AnnotatableFields(object):
|
||||
default='None',
|
||||
)
|
||||
annotation_storage_url = String(help="Location of Annotation backend", scope=Scope.settings, default="http://your_annotation_storage.com", display_name="Url for Annotation Storage")
|
||||
annotation_token_secret = String(help="Secret string for annotation storage", scope=Scope.settings, default="xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx", display_name="Secret Token String for Annotation")
|
||||
|
||||
|
||||
class TextAnnotationModule(AnnotatableFields, XModule):
|
||||
@@ -59,15 +61,9 @@ class TextAnnotationModule(AnnotatableFields, XModule):
|
||||
|
||||
self.instructions = self._extract_instructions(xmltree)
|
||||
self.content = etree.tostring(xmltree, encoding='unicode')
|
||||
self.highlight_colors = ['yellow', 'orange', 'purple', 'blue', 'green']
|
||||
|
||||
def _render_content(self):
|
||||
""" Renders annotatable content with annotation spans and returns HTML. """
|
||||
xmltree = etree.fromstring(self.content)
|
||||
if 'display_name' in xmltree.attrib:
|
||||
del xmltree.attrib['display_name']
|
||||
|
||||
return etree.tostring(xmltree, encoding='unicode')
|
||||
self.user_email = ""
|
||||
if self.runtime.get_real_user is not None:
|
||||
self.user_email = self.runtime.get_real_user(self.runtime.anonymous_student_id).email
|
||||
|
||||
def _extract_instructions(self, xmltree):
|
||||
""" Removes <instructions> from the xmltree and returns them as a string, otherwise None. """
|
||||
@@ -83,13 +79,13 @@ class TextAnnotationModule(AnnotatableFields, XModule):
|
||||
context = {
|
||||
'course_key': self.runtime.course_id,
|
||||
'display_name': self.display_name_with_default,
|
||||
'tag': self.tags,
|
||||
'tag': self.instructor_tags,
|
||||
'source': self.source,
|
||||
'instructions_html': self.instructions,
|
||||
'content_html': self._render_content(),
|
||||
'annotation_storage': self.annotation_storage_url
|
||||
'content_html': self.content,
|
||||
'annotation_storage': self.annotation_storage_url,
|
||||
'token': retrieve_token(self.user_email, self.annotation_token_secret),
|
||||
}
|
||||
|
||||
return self.system.render_template('textannotation.html', context)
|
||||
|
||||
|
||||
@@ -102,6 +98,7 @@ class TextAnnotationDescriptor(AnnotatableFields, RawDescriptor):
|
||||
def non_editable_metadata_fields(self):
|
||||
non_editable_fields = super(TextAnnotationDescriptor, self).non_editable_metadata_fields
|
||||
non_editable_fields.extend([
|
||||
TextAnnotationDescriptor.annotation_storage_url
|
||||
TextAnnotationDescriptor.annotation_storage_url,
|
||||
TextAnnotationDescriptor.annotation_token_secret,
|
||||
])
|
||||
return non_editable_fields
|
||||
|
||||
@@ -2,6 +2,7 @@ from xblock.fragment import Fragment
|
||||
from xmodule.x_module import XModule
|
||||
from xmodule.seq_module import SequenceDescriptor
|
||||
from xmodule.progress import Progress
|
||||
from xmodule.studio_editable import StudioEditableModule
|
||||
from pkg_resources import resource_string
|
||||
from copy import copy
|
||||
|
||||
@@ -14,7 +15,7 @@ class VerticalFields(object):
|
||||
has_children = True
|
||||
|
||||
|
||||
class VerticalModule(VerticalFields, XModule):
|
||||
class VerticalModule(VerticalFields, XModule, StudioEditableModule):
|
||||
''' Layout module for laying out submodules vertically.'''
|
||||
|
||||
def student_view(self, context):
|
||||
@@ -28,7 +29,9 @@ class VerticalModule(VerticalFields, XModule):
|
||||
"""
|
||||
Renders the Studio preview view, which supports drag and drop.
|
||||
"""
|
||||
return self.render_view(context, 'vert_module_studio_view.html')
|
||||
fragment = Fragment()
|
||||
self.render_reorderable_children(context, fragment)
|
||||
return fragment
|
||||
|
||||
def render_view(self, context, template_name):
|
||||
"""
|
||||
|
||||
@@ -44,6 +44,7 @@ def get_ext(filename):
|
||||
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
_ = lambda text: text
|
||||
|
||||
|
||||
class VideoModule(VideoFields, VideoStudentViewHandlers, XModule):
|
||||
@@ -177,12 +178,12 @@ class VideoDescriptor(VideoFields, VideoStudioViewHandlers, TabsEditingDescripto
|
||||
|
||||
tabs = [
|
||||
{
|
||||
'name': "Basic",
|
||||
'name': _("Basic"),
|
||||
'template': "video/transcripts.html",
|
||||
'current': True
|
||||
},
|
||||
{
|
||||
'name': "Advanced",
|
||||
'name': _("Advanced"),
|
||||
'template': "tabs/metadata-edit-tab.html"
|
||||
}
|
||||
]
|
||||
@@ -358,7 +359,7 @@ class VideoDescriptor(VideoFields, VideoStudioViewHandlers, TabsEditingDescripto
|
||||
_ = self.runtime.service(self, "i18n").ugettext
|
||||
video_url.update({
|
||||
'help': _('The URL for your video. This can be a YouTube URL or a link to an .mp4, .ogg, or .webm video file hosted elsewhere on the Internet.'),
|
||||
'display_name': 'Default Video URL',
|
||||
'display_name': _('Default Video URL'),
|
||||
'field_name': 'video_url',
|
||||
'type': 'VideoList',
|
||||
'default_value': [get_youtube_link(youtube_id_1_0['default_value'])]
|
||||
|
||||
@@ -14,51 +14,52 @@ _ = lambda text: text
|
||||
class VideoFields(object):
|
||||
"""Fields for `VideoModule` and `VideoDescriptor`."""
|
||||
display_name = String(
|
||||
display_name="Component Display Name", help="The name students see. This name appears in the course ribbon and as a header for the video.",
|
||||
help=_("The name students see. This name appears in the course ribbon and as a header for the video."),
|
||||
display_name=_("Component Display Name"),
|
||||
default="Video",
|
||||
scope=Scope.settings
|
||||
)
|
||||
|
||||
saved_video_position = RelativeTime(
|
||||
help="Current position in the video.",
|
||||
help=_("Current position in the video."),
|
||||
scope=Scope.user_state,
|
||||
default=datetime.timedelta(seconds=0)
|
||||
)
|
||||
# TODO: This should be moved to Scope.content, but this will
|
||||
# require data migration to support the old video module.
|
||||
youtube_id_1_0 = String(
|
||||
help="Optional, for older browsers: the YouTube ID for the normal speed video.",
|
||||
display_name="YouTube ID",
|
||||
help=_("Optional, for older browsers: the YouTube ID for the normal speed video."),
|
||||
display_name=_("YouTube ID"),
|
||||
scope=Scope.settings,
|
||||
default="OEoXaMPEzfM"
|
||||
)
|
||||
youtube_id_0_75 = String(
|
||||
help="Optional, for older browsers: the YouTube ID for the .75x speed video.",
|
||||
display_name="YouTube ID for .75x speed",
|
||||
help=_("Optional, for older browsers: the YouTube ID for the .75x speed video."),
|
||||
display_name=_("YouTube ID for .75x speed"),
|
||||
scope=Scope.settings,
|
||||
default=""
|
||||
)
|
||||
youtube_id_1_25 = String(
|
||||
help="Optional, for older browsers: the YouTube ID for the 1.25x speed video.",
|
||||
display_name="YouTube ID for 1.25x speed",
|
||||
help=_("Optional, for older browsers: the YouTube ID for the 1.25x speed video."),
|
||||
display_name=_("YouTube ID for 1.25x speed"),
|
||||
scope=Scope.settings,
|
||||
default=""
|
||||
)
|
||||
youtube_id_1_5 = String(
|
||||
help="Optional, for older browsers: the YouTube ID for the 1.5x speed video.",
|
||||
display_name="YouTube ID for 1.5x speed",
|
||||
help=_("Optional, for older browsers: the YouTube ID for the 1.5x speed video."),
|
||||
display_name=_("YouTube ID for 1.5x speed"),
|
||||
scope=Scope.settings,
|
||||
default=""
|
||||
)
|
||||
start_time = RelativeTime( # datetime.timedelta object
|
||||
help="Time you want the video to start if you don't want the entire video to play. Formatted as HH:MM:SS. The maximum value is 23:59:59.",
|
||||
display_name="Video Start Time",
|
||||
help=_("Time you want the video to start if you don't want the entire video to play. Formatted as HH:MM:SS. The maximum value is 23:59:59."),
|
||||
display_name=_("Video Start Time"),
|
||||
scope=Scope.settings,
|
||||
default=datetime.timedelta(seconds=0)
|
||||
)
|
||||
end_time = RelativeTime( # datetime.timedelta object
|
||||
help="Time you want the video to stop if you don't want the entire video to play. Formatted as HH:MM:SS. The maximum value is 23:59:59.",
|
||||
display_name="Video Stop Time",
|
||||
help=_("Time you want the video to stop if you don't want the entire video to play. Formatted as HH:MM:SS. The maximum value is 23:59:59."),
|
||||
display_name=_("Video Stop Time"),
|
||||
scope=Scope.settings,
|
||||
default=datetime.timedelta(seconds=0)
|
||||
)
|
||||
@@ -67,61 +68,61 @@ class VideoFields(object):
|
||||
# `source` is deprecated field and should not be used in future.
|
||||
# `download_video` is used instead.
|
||||
source = String(
|
||||
help="The external URL to download the video.",
|
||||
display_name="Download Video",
|
||||
help=_("The external URL to download the video."),
|
||||
display_name=_("Download Video"),
|
||||
scope=Scope.settings,
|
||||
default=""
|
||||
)
|
||||
download_video = Boolean(
|
||||
help="Allow students to download versions of this video in different formats if they cannot use the edX video player or do not have access to YouTube. You must add at least one non-YouTube URL in the Video File URLs field.",
|
||||
display_name="Video Download Allowed",
|
||||
help=_("Allow students to download versions of this video in different formats if they cannot use the edX video player or do not have access to YouTube. You must add at least one non-YouTube URL in the Video File URLs field."),
|
||||
display_name=_("Video Download Allowed"),
|
||||
scope=Scope.settings,
|
||||
default=False
|
||||
)
|
||||
html5_sources = List(
|
||||
help="The URL or URLs where you've posted non-YouTube versions of the video. Each URL must end in .mpeg, .mp4, .ogg, or .webm and cannot be a YouTube URL. Students will be able to view the first listed video that's compatible with the student's computer. To allow students to download these videos, set Video Download Allowed to True.",
|
||||
display_name="Video File URLs",
|
||||
help=_("The URL or URLs where you've posted non-YouTube versions of the video. Each URL must end in .mpeg, .mp4, .ogg, or .webm and cannot be a YouTube URL. Students will be able to view the first listed video that's compatible with the student's computer. To allow students to download these videos, set Video Download Allowed to True."),
|
||||
display_name=_("Video File URLs"),
|
||||
scope=Scope.settings,
|
||||
)
|
||||
track = String(
|
||||
help="By default, students can download an .srt or .txt transcript when you set Download Transcript Allowed to True. If you want to provide a downloadable transcript in a different format, we recommend that you upload a handout by using the Upload a Handout field. If this isn't possible, you can post a transcript file on the Files & Uploads page or on the Internet, and then add the URL for the transcript here. Students see a link to download that transcript below the video.",
|
||||
display_name="Downloadable Transcript URL",
|
||||
help=_("By default, students can download an .srt or .txt transcript when you set Download Transcript Allowed to True. If you want to provide a downloadable transcript in a different format, we recommend that you upload a handout by using the Upload a Handout field. If this isn't possible, you can post a transcript file on the Files & Uploads page or on the Internet, and then add the URL for the transcript here. Students see a link to download that transcript below the video."),
|
||||
display_name=_("Downloadable Transcript URL"),
|
||||
scope=Scope.settings,
|
||||
default=''
|
||||
)
|
||||
download_track = Boolean(
|
||||
help="Allow students to download the timed transcript. A link to download the file appears below the video. By default, the transcript is an .srt or .txt file. If you want to provide the transcript for download in a different format, upload a file by using the Upload Handout field.",
|
||||
display_name="Download Transcript Allowed",
|
||||
help=_("Allow students to download the timed transcript. A link to download the file appears below the video. By default, the transcript is an .srt or .txt file. If you want to provide the transcript for download in a different format, upload a file by using the Upload Handout field."),
|
||||
display_name=_("Download Transcript Allowed"),
|
||||
scope=Scope.settings,
|
||||
default=False
|
||||
)
|
||||
sub = String(
|
||||
help="The default transcript for the video, from the Default Timed Transcript field on the Basic tab. This transcript should be in English. You don't have to change this setting.",
|
||||
display_name="Default Timed Transcript",
|
||||
help=_("The default transcript for the video, from the Default Timed Transcript field on the Basic tab. This transcript should be in English. You don't have to change this setting."),
|
||||
display_name=_("Default Timed Transcript"),
|
||||
scope=Scope.settings,
|
||||
default=""
|
||||
)
|
||||
show_captions = Boolean(
|
||||
help="Specify whether the transcripts appear with the video by default.",
|
||||
display_name="Show Transcript",
|
||||
help=_("Specify whether the transcripts appear with the video by default."),
|
||||
display_name=_("Show Transcript"),
|
||||
scope=Scope.settings,
|
||||
default=True
|
||||
)
|
||||
# Data format: {'de': 'german_translation', 'uk': 'ukrainian_translation'}
|
||||
transcripts = Dict(
|
||||
help="Add transcripts in different languages. Click below to specify a language and upload an .srt transcript file for that language.",
|
||||
display_name="Transcript Languages",
|
||||
help=_("Add transcripts in different languages. Click below to specify a language and upload an .srt transcript file for that language."),
|
||||
display_name=_("Transcript Languages"),
|
||||
scope=Scope.settings,
|
||||
default={}
|
||||
)
|
||||
transcript_language = String(
|
||||
help="Preferred language for transcript.",
|
||||
display_name="Preferred language for transcript",
|
||||
help=_("Preferred language for transcript."),
|
||||
display_name=_("Preferred language for transcript"),
|
||||
scope=Scope.preferences,
|
||||
default="en"
|
||||
)
|
||||
transcript_download_format = String(
|
||||
help="Transcript file format to download by user.",
|
||||
help=_("Transcript file format to download by user."),
|
||||
scope=Scope.preferences,
|
||||
values=[
|
||||
# Translators: This is a type of file used for captioning in the video player.
|
||||
@@ -131,22 +132,22 @@ class VideoFields(object):
|
||||
default='srt',
|
||||
)
|
||||
speed = Float(
|
||||
help="The last speed that the user specified for the video.",
|
||||
help=_("The last speed that the user specified for the video."),
|
||||
scope=Scope.user_state,
|
||||
)
|
||||
global_speed = Float(
|
||||
help="The default speed for the video.",
|
||||
help=_("The default speed for the video."),
|
||||
scope=Scope.preferences,
|
||||
default=1.0
|
||||
)
|
||||
youtube_is_available = Boolean(
|
||||
help="Specify whether YouTube is available for the user.",
|
||||
help=_("Specify whether YouTube is available for the user."),
|
||||
scope=Scope.user_info,
|
||||
default=True
|
||||
)
|
||||
|
||||
handout = String(
|
||||
help="Upload a handout to accompany this video. Students can download the handout by clicking Download Handout under the video.",
|
||||
display_name="Upload Handout",
|
||||
help=_("Upload a handout to accompany this video. Students can download the handout by clicking Download Handout under the video."),
|
||||
display_name=_("Upload Handout"),
|
||||
scope=Scope.settings,
|
||||
)
|
||||
|
||||
@@ -7,6 +7,7 @@ from pkg_resources import resource_string
|
||||
from xmodule.x_module import XModule
|
||||
from xmodule.raw_module import RawDescriptor
|
||||
from xblock.core import Scope, String
|
||||
from xmodule.annotator_token import retrieve_token
|
||||
|
||||
import textwrap
|
||||
|
||||
@@ -31,7 +32,7 @@ class AnnotatableFields(object):
|
||||
sourceurl = String(help="The external source URL for the video.", display_name="Source URL", scope=Scope.settings, default="http://video-js.zencoder.com/oceans-clip.mp4")
|
||||
poster_url = String(help="Poster Image URL", display_name="Poster URL", scope=Scope.settings, default="")
|
||||
annotation_storage_url = String(help="Location of Annotation backend", scope=Scope.settings, default="http://your_annotation_storage.com", display_name="Url for Annotation Storage")
|
||||
|
||||
annotation_token_secret = String(help="Secret string for annotation storage", scope=Scope.settings, default="xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx", display_name="Secret Token String for Annotation")
|
||||
|
||||
class VideoAnnotationModule(AnnotatableFields, XModule):
|
||||
'''Video Annotation Module'''
|
||||
@@ -55,73 +56,9 @@ class VideoAnnotationModule(AnnotatableFields, XModule):
|
||||
|
||||
self.instructions = self._extract_instructions(xmltree)
|
||||
self.content = etree.tostring(xmltree, encoding='unicode')
|
||||
self.highlight_colors = ['yellow', 'orange', 'purple', 'blue', 'green']
|
||||
|
||||
def _get_annotation_class_attr(self, element):
|
||||
""" Returns a dict with the CSS class attribute to set on the annotation
|
||||
and an XML key to delete from the element.
|
||||
"""
|
||||
|
||||
attr = {}
|
||||
cls = ['annotatable-span', 'highlight']
|
||||
highlight_key = 'highlight'
|
||||
color = element.get(highlight_key)
|
||||
|
||||
if color is not None:
|
||||
if color in self.highlight_colors:
|
||||
cls.append('highlight-' + color)
|
||||
attr['_delete'] = highlight_key
|
||||
attr['value'] = ' '.join(cls)
|
||||
|
||||
return {'class': attr}
|
||||
|
||||
def _get_annotation_data_attr(self, element):
|
||||
""" Returns a dict in which the keys are the HTML data attributes
|
||||
to set on the annotation element. Each data attribute has a
|
||||
corresponding 'value' and (optional) '_delete' key to specify
|
||||
an XML attribute to delete.
|
||||
"""
|
||||
|
||||
data_attrs = {}
|
||||
attrs_map = {
|
||||
'body': 'data-comment-body',
|
||||
'title': 'data-comment-title',
|
||||
'problem': 'data-problem-id'
|
||||
}
|
||||
|
||||
for xml_key in attrs_map.keys():
|
||||
if xml_key in element.attrib:
|
||||
value = element.get(xml_key, '')
|
||||
html_key = attrs_map[xml_key]
|
||||
data_attrs[html_key] = {'value': value, '_delete': xml_key}
|
||||
|
||||
return data_attrs
|
||||
|
||||
def _render_annotation(self, element):
|
||||
""" Renders an annotation element for HTML output. """
|
||||
attr = {}
|
||||
attr.update(self._get_annotation_class_attr(element))
|
||||
attr.update(self._get_annotation_data_attr(element))
|
||||
|
||||
element.tag = 'span'
|
||||
|
||||
for key in attr.keys():
|
||||
element.set(key, attr[key]['value'])
|
||||
if '_delete' in attr[key] and attr[key]['_delete'] is not None:
|
||||
delete_key = attr[key]['_delete']
|
||||
del element.attrib[delete_key]
|
||||
|
||||
def _render_content(self):
|
||||
""" Renders annotatable content with annotation spans and returns HTML. """
|
||||
xmltree = etree.fromstring(self.content)
|
||||
xmltree.tag = 'div'
|
||||
if 'display_name' in xmltree.attrib:
|
||||
del xmltree.attrib['display_name']
|
||||
|
||||
for element in xmltree.findall('.//annotation'):
|
||||
self._render_annotation(element)
|
||||
|
||||
return etree.tostring(xmltree, encoding='unicode')
|
||||
self.user_email = ""
|
||||
if self.runtime.get_real_user is not None:
|
||||
self.user_email = self.runtime.get_real_user(self.runtime.anonymous_student_id).email
|
||||
|
||||
def _extract_instructions(self, xmltree):
|
||||
""" Removes <instructions> from the xmltree and returns them as a string, otherwise None. """
|
||||
@@ -155,9 +92,9 @@ class VideoAnnotationModule(AnnotatableFields, XModule):
|
||||
'sourceUrl': self.sourceurl,
|
||||
'typeSource': extension,
|
||||
'poster': self.poster_url,
|
||||
'alert': self,
|
||||
'content_html': self._render_content(),
|
||||
'annotation_storage': self.annotation_storage_url
|
||||
'content_html': self.content,
|
||||
'annotation_storage': self.annotation_storage_url,
|
||||
'token': retrieve_token(self.user_email, self.annotation_token_secret),
|
||||
}
|
||||
|
||||
return self.system.render_template('videoannotation.html', context)
|
||||
@@ -172,6 +109,7 @@ class VideoAnnotationDescriptor(AnnotatableFields, RawDescriptor):
|
||||
def non_editable_metadata_fields(self):
|
||||
non_editable_fields = super(VideoAnnotationDescriptor, self).non_editable_metadata_fields
|
||||
non_editable_fields.extend([
|
||||
VideoAnnotationDescriptor.annotation_storage_url
|
||||
VideoAnnotationDescriptor.annotation_storage_url,
|
||||
VideoAnnotationDescriptor.annotation_token_secret,
|
||||
])
|
||||
return non_editable_fields
|
||||
|
||||
@@ -763,6 +763,13 @@ class XModuleDescriptor(XModuleMixin, HTMLSnippet, ResourceTemplates, XBlock):
|
||||
json_choice = field.to_json(json_choice)
|
||||
return json_choice
|
||||
|
||||
def get_text(value):
|
||||
"""Localize a text value that might be None."""
|
||||
if value is None:
|
||||
return None
|
||||
else:
|
||||
return self.runtime.service(self, "i18n").ugettext(value)
|
||||
|
||||
metadata_fields = {}
|
||||
|
||||
# Only use the fields from this class, not mixins
|
||||
@@ -776,8 +783,8 @@ class XModuleDescriptor(XModuleMixin, HTMLSnippet, ResourceTemplates, XBlock):
|
||||
# gets the 'default_value' and 'explicitly_set' attrs
|
||||
metadata_fields[field.name] = self.runtime.get_field_provenance(self, field)
|
||||
metadata_fields[field.name]['field_name'] = field.name
|
||||
metadata_fields[field.name]['display_name'] = field.display_name
|
||||
metadata_fields[field.name]['help'] = field.help
|
||||
metadata_fields[field.name]['display_name'] = get_text(field.display_name)
|
||||
metadata_fields[field.name]['help'] = get_text(field.help)
|
||||
metadata_fields[field.name]['value'] = field.read_json(self)
|
||||
|
||||
# We support the following editors:
|
||||
|
||||
Reference in New Issue
Block a user