Commit 2ef095d9 by Anton Stupak

Merge pull request #2149 from edx/anton/video-download-transcript

Allows students to download the transcript of the video without timecodes
parents 4a436a77 4a36fa89
......@@ -5,6 +5,9 @@ These are notable changes in edx-platform. This is a rolling list of changes,
in roughly chronological order, most recent first. Add your entries at or near
the top. Include a label indicating the component affected.
Blades: Change the track field to a dropdown that will allow students
to download the transcript of the video without timecodes. BLD-368.
Blades: Video player start-end time range is now shown even before Play is
clicked. Video player VCR time shows correct non-zero total time for YouTube
videos even before Play is clicked. BLD-529.
......
......@@ -140,7 +140,7 @@ def verify_setting_entry(setting, display_name, value, explicitly_set):
for the problem, rather than derived from the defaults. This is verified
by the existence of a "Clear" button next to the field value.
"""
assert_equal(display_name, setting.find_by_css('.setting-label')[0].html)
assert_equal(display_name, setting.find_by_css('.setting-label')[0].html.strip())
# Check if the web object is a list type
# If so, we use a slightly different mechanism for determining its value
......
......@@ -40,12 +40,12 @@ def correct_video_settings(_step):
# advanced
['Display Name', 'Video', False],
['Download Transcript', '', False],
['Download Video', '', False],
['End Time', '00:00:00', False],
['HTML5 Transcript', '', False],
['Show Transcript', 'True', False],
['Start Time', '00:00:00', False],
['Transcript Download Allowed', 'False', False],
['Video Sources', '', False],
['Youtube ID', 'OEoXaMPEzfM', False],
['Youtube ID for .75x speed', '', False],
......
......@@ -94,6 +94,19 @@ function(BaseView, _, MetadataModel, AbstractEditor, VideoList) {
templateName: "metadata-string-entry",
render: function () {
AbstractEditor.prototype.render.apply(this);
// If the model has property `non editable` equals `true`,
// the field is disabled, but user is able to clear it.
if (this.model.get('non_editable')) {
this.$el.find('#' + this.uniqueId)
.prop('readonly', true)
.addClass('is-disabled');
}
},
getValueFromEditor : function () {
return this.$el.find('#' + this.uniqueId).val();
},
......
......@@ -708,6 +708,12 @@ body.course.unit,.view-unit {
text-overflow: ellipsis;
}
//Allows users to copy full value of disabled inputs.
input.is-disabled{
text-overflow: clip;
opacity: .5;
}
input[type="number"] {
width: 38.5%;
......
......@@ -25,7 +25,6 @@ from .test_import import DummySystem
from xblock.field_data import DictFieldData
from xblock.fields import ScopeIds
from textwrap import dedent
from xmodule.tests import get_test_descriptor_system
......@@ -187,6 +186,7 @@ class VideoDescriptorImportTestCase(unittest.TestCase):
<video display_name="Test Video"
youtube="1.0:p2Q6BrNhdh8,0.75:izygArpw-Qo,1.25:1EeWXzPdhSA,1.5:rABDYkeK0x8"
show_captions="false"
download_track="true"
start_time="00:00:01"
end_time="00:01:00">
<source src="http://www.example.com/source.mp4"/>
......@@ -211,6 +211,7 @@ class VideoDescriptorImportTestCase(unittest.TestCase):
'start_time': datetime.timedelta(seconds=1),
'end_time': datetime.timedelta(seconds=60),
'track': 'http://www.example.com/track',
'download_track': True,
'html5_sources': ['http://www.example.com/source.mp4', 'http://www.example.com/source.ogg'],
'data': ''
})
......@@ -221,6 +222,7 @@ class VideoDescriptorImportTestCase(unittest.TestCase):
<video display_name="Test Video"
youtube="1.0:p2Q6BrNhdh8,0.75:izygArpw-Qo,1.25:1EeWXzPdhSA,1.5:rABDYkeK0x8"
show_captions="false"
download_track="false"
start_time="00:00:01"
end_time="00:01:00">
<source src="http://www.example.com/source.mp4"/>
......@@ -237,6 +239,7 @@ class VideoDescriptorImportTestCase(unittest.TestCase):
'start_time': datetime.timedelta(seconds=1),
'end_time': datetime.timedelta(seconds=60),
'track': 'http://www.example.com/track',
'download_track': False,
'source': 'http://www.example.com/source.mp4',
'html5_sources': ['http://www.example.com/source.mp4'],
'data': ''
......@@ -253,7 +256,6 @@ class VideoDescriptorImportTestCase(unittest.TestCase):
youtube="1.0:p2Q6BrNhdh8,1.25:1EeWXzPdhSA"
show_captions="true">
<source src="http://www.example.com/source.mp4"/>
<track src="http://www.example.com/track"/>
</video>
'''
output = VideoDescriptor.from_xml(xml_data, module_system, Mock())
......@@ -265,7 +267,8 @@ class VideoDescriptorImportTestCase(unittest.TestCase):
'show_captions': True,
'start_time': datetime.timedelta(seconds=0.0),
'end_time': datetime.timedelta(seconds=0.0),
'track': 'http://www.example.com/track',
'track': '',
'download_track': False,
'source': 'http://www.example.com/source.mp4',
'html5_sources': ['http://www.example.com/source.mp4'],
'data': ''
......@@ -287,6 +290,7 @@ class VideoDescriptorImportTestCase(unittest.TestCase):
'start_time': datetime.timedelta(seconds=0.0),
'end_time': datetime.timedelta(seconds=0.0),
'track': '',
'download_track': False,
'source': '',
'html5_sources': [],
'data': ''
......@@ -305,6 +309,7 @@ class VideoDescriptorImportTestCase(unittest.TestCase):
source="&quot;http://download_video&quot;"
sub="&quot;html5_subtitles&quot;"
track="&quot;http://download_track&quot;"
download_track="true"
youtube_id_0_75="&quot;OEoXaMPEzf65&quot;"
youtube_id_1_25="&quot;OEoXaMPEzf125&quot;"
youtube_id_1_5="&quot;OEoXaMPEzf15&quot;"
......@@ -321,6 +326,7 @@ class VideoDescriptorImportTestCase(unittest.TestCase):
'start_time': datetime.timedelta(seconds=0.0),
'end_time': datetime.timedelta(seconds=0.0),
'track': 'http://download_track',
'download_track': True,
'source': 'http://download_video',
'html5_sources': ["source_1", "source_2"],
'data': ''
......@@ -343,6 +349,7 @@ class VideoDescriptorImportTestCase(unittest.TestCase):
'start_time': datetime.timedelta(seconds=0.0),
'end_time': datetime.timedelta(seconds=0.0),
'track': '',
'download_track': False,
'source': '',
'html5_sources': [],
'data': ''
......@@ -373,6 +380,7 @@ class VideoDescriptorImportTestCase(unittest.TestCase):
'start_time': datetime.timedelta(seconds=1),
'end_time': datetime.timedelta(seconds=60),
'track': 'http://www.example.com/track',
'download_track': True,
'html5_sources': ['http://www.example.com/source.mp4'],
'data': ''
})
......@@ -402,6 +410,7 @@ class VideoDescriptorImportTestCase(unittest.TestCase):
'start_time': datetime.timedelta(seconds=1),
'end_time': datetime.timedelta(seconds=60),
'track': 'http://www.example.com/track',
'download_track': True,
'html5_sources': ['http://www.example.com/source.mp4'],
'data': ''
})
......@@ -431,6 +440,7 @@ class VideoDescriptorImportTestCase(unittest.TestCase):
'start_time': datetime.timedelta(seconds=1),
'end_time': datetime.timedelta(seconds=60),
'track': 'http://www.example.com/track',
'download_track': True,
'html5_sources': ['http://www.example.com/source.mp4'],
'data': ''
})
......@@ -461,11 +471,12 @@ class VideoExportTestCase(unittest.TestCase):
desc.start_time = datetime.timedelta(seconds=1.0)
desc.end_time = datetime.timedelta(seconds=60)
desc.track = 'http://www.example.com/track'
desc.download_track = True
desc.html5_sources = ['http://www.example.com/source.mp4', 'http://www.example.com/source.ogg']
xml = desc.definition_to_xml(None) # We don't use the `resource_fs` parameter
expected = etree.fromstring('''\
<video url_name="SampleProblem1" start_time="0:00:01" youtube="0.75:izygArpw-Qo,1.00:p2Q6BrNhdh8,1.25:1EeWXzPdhSA,1.50:rABDYkeK0x8" show_captions="false" end_time="0:01:00">
<video url_name="SampleProblem1" start_time="0:00:01" youtube="0.75:izygArpw-Qo,1.00:p2Q6BrNhdh8,1.25:1EeWXzPdhSA,1.50:rABDYkeK0x8" show_captions="false" end_time="0:01:00" download_track="true">
<source src="http://www.example.com/source.mp4"/>
<source src="http://www.example.com/source.ogg"/>
<track src="http://www.example.com/track"/>
......@@ -488,11 +499,12 @@ class VideoExportTestCase(unittest.TestCase):
desc.start_time = datetime.timedelta(seconds=5.0)
desc.end_time = datetime.timedelta(seconds=0.0)
desc.track = 'http://www.example.com/track'
desc.download_track = True
desc.html5_sources = ['http://www.example.com/source.mp4', 'http://www.example.com/source.ogg']
xml = desc.definition_to_xml(None) # We don't use the `resource_fs` parameter
expected = etree.fromstring('''\
<video url_name="SampleProblem1" start_time="0:00:05" youtube="0.75:izygArpw-Qo,1.00:p2Q6BrNhdh8,1.25:1EeWXzPdhSA,1.50:rABDYkeK0x8" show_captions="false">
<video url_name="SampleProblem1" start_time="0:00:05" youtube="0.75:izygArpw-Qo,1.00:p2Q6BrNhdh8,1.25:1EeWXzPdhSA,1.50:rABDYkeK0x8" show_captions="false" download_track="true">
<source src="http://www.example.com/source.mp4"/>
<source src="http://www.example.com/source.ogg"/>
<track src="http://www.example.com/track"/>
......
......@@ -13,18 +13,24 @@ in XML.
import json
import logging
from HTMLParser import HTMLParser
from lxml import etree
from pkg_resources import resource_string
import datetime
import copy
from webob import Response
from django.http import Http404
from django.conf import settings
from xmodule.x_module import XModule
from xmodule.x_module import XModule, module_attr
from xmodule.editing_module import TabsEditingDescriptor
from xmodule.raw_module import EmptyDataRawDescriptor
from xmodule.xml_module import is_pointer_tag, name_to_pathname, deserialize_field
from xmodule.contentstore.django import contentstore
from xmodule.contentstore.content import StaticContent
from xmodule.exceptions import NotFoundError
from xblock.core import XBlock
from xblock.fields import Scope, String, Boolean, List, Integer, ScopeIds
from xmodule.fields import RelativeTime
......@@ -103,11 +109,19 @@ class VideoFields(object):
display_name="Video Sources",
scope=Scope.settings,
)
# `track` is deprecated field and should not be used in future.
# `download_track` is used instead.
track = String(
help="The external URL to download the timed transcript track. This appears as a link beneath the video.",
help="The external URL to download the timed transcript track.",
display_name="Download Transcript",
scope=Scope.settings,
default=""
default=''
)
download_track = Boolean(
help="Show a link beneath the video to allow students to download the transcript. Note: You must add a link to the HTML5 Transcript field above.",
display_name="Transcript Download Allowed",
scope=Scope.settings,
default=False
)
sub = String(
help="The name of the timed transcript track (for non-Youtube videos).",
......@@ -162,18 +176,25 @@ class VideoModule(VideoFields, XModule):
raise Http404()
def get_html(self):
track_url = None
caption_asset_path = "/static/subs/"
get_ext = lambda filename: filename.rpartition('.')[-1]
sources = {get_ext(src): src for src in self.html5_sources}
sources['main'] = self.source
if self.download_track:
if self.track:
track_url = self.track
elif self.sub:
track_url = self.runtime.handler_url(self, 'download_transcript')
return self.system.render_template('video.html', {
'youtube_streams': _create_youtube_string(self),
'id': self.location.html_id(),
'sub': self.sub,
'sources': sources,
'track': self.track,
'track': track_url,
'display_name': self.display_name_with_default,
# This won't work when we move to data that
# isn't on the filesystem
......@@ -189,10 +210,58 @@ class VideoModule(VideoFields, XModule):
'yt_test_url': settings.YOUTUBE_TEST_URL
})
def get_transcript(self, subs_id):
'''
Returns transcript without timecodes.
Args:
`subs_id`: str, subtitles id
Raises:
- NotFoundError if cannot find transcript file in storage.
- ValueError if transcript file is incorrect JSON.
- KeyError if transcript file has incorrect format.
'''
filename = 'subs_{0}.srt.sjson'.format(subs_id)
content_location = StaticContent.compute_location(
self.location.org, self.location.course, filename
)
data = contentstore().find(content_location).data
text = json.loads(data)['text']
return HTMLParser().unescape("\n".join(text))
@XBlock.handler
def download_transcript(self, __, ___):
"""
This is called to get transcript file without timecodes to student.
"""
try:
subs = self.get_transcript(self.sub)
except (NotFoundError):
log.debug("Can't find content in storage for %s transcript", self.sub)
return Response(status=404)
except (ValueError, KeyError):
log.debug("Invalid transcript JSON.")
return Response(status=400)
response = Response(
subs,
headerlist=[
('Content-Disposition', 'attachment; filename="{0}.txt"'.format(self.sub)),
])
response.content_type="text/plain; charset=utf-8"
return response
class VideoDescriptor(VideoFields, TabsEditingDescriptor, EmptyDataRawDescriptor):
"""Descriptor for `VideoModule`."""
module_class = VideoModule
download_transcript = module_attr('download_transcript')
tabs = [
{
......@@ -207,6 +276,12 @@ class VideoDescriptor(VideoFields, TabsEditingDescriptor, EmptyDataRawDescriptor
]
def __init__(self, *args, **kwargs):
'''
`track` is deprecated field.
If `track` field exists show `track` field on front-end as not-editable
but clearable. Dropdown `download_track` is a new field and it has value
True.
'''
super(VideoDescriptor, self).__init__(*args, **kwargs)
# For backwards compatibility -- if we've got XML data, parse
# it out and set the metadata fields
......@@ -215,6 +290,24 @@ class VideoDescriptor(VideoFields, TabsEditingDescriptor, EmptyDataRawDescriptor
self._field_data.set_many(self, field_data)
del self.data
self.track_visible = False
if self.track:
self.track_visible = True
download_track = self.editable_metadata_fields['download_track']
if not download_track['explicitly_set']:
self.download_track = True
@property
def editable_metadata_fields(self):
editable_fields = super(VideoDescriptor, self).editable_metadata_fields
if self.track_visible:
editable_fields['track']['non_editable'] = True
else:
editable_fields.pop('track')
return editable_fields
@classmethod
def from_xml(cls, xml_data, system, id_generator):
"""
......@@ -265,6 +358,7 @@ class VideoDescriptor(VideoFields, TabsEditingDescriptor, EmptyDataRawDescriptor
'start_time': self.start_time,
'end_time': self.end_time,
'sub': self.sub,
'download_track': json.dumps(self.download_track),
}
for key, value in attrs.items():
# Mild workaround to ensure that tests pass -- if a field
......@@ -282,6 +376,7 @@ class VideoDescriptor(VideoFields, TabsEditingDescriptor, EmptyDataRawDescriptor
ele = etree.Element('track')
ele.set('src', self.track)
xml.append(ele)
return xml
def get_context(self):
......
......@@ -15,7 +15,6 @@ from edxmako.shortcuts import render_to_string
from student.tests.factories import UserFactory, CourseEnrollmentFactory
from courseware.tests.modulestore_config import TEST_DATA_MIXED_MODULESTORE
from xblock.field_data import DictFieldData
from xblock.fields import Scope
from xmodule.tests import get_test_system, get_test_descriptor_system
from xmodule.modulestore import Location
from xmodule.modulestore.django import modulestore
......@@ -35,7 +34,7 @@ class BaseTestXmodule(ModuleStoreTestCase):
Any xmodule should overwrite only next parameters for test:
1. CATEGORY
2. DATA
2. DATA or METADATA
3. MODEL_DATA
4. COURSE_DATA and USER_COUNT if needed
......@@ -48,6 +47,10 @@ class BaseTestXmodule(ModuleStoreTestCase):
# Data from YAML common/lib/xmodule/xmodule/templates/NAME/default.yaml
CATEGORY = "vertical"
DATA = ''
# METADATA must be overwritten for every instance that uses it. Otherwise,
# if we'll change it in the tests, it will be changed for all other instances
# of parent class.
METADATA = {}
MODEL_DATA = {'data': '<some_module></some_module>'}
def new_module_runtime(self):
......@@ -71,8 +74,27 @@ class BaseTestXmodule(ModuleStoreTestCase):
runtime.get_block = modulestore().get_item
return runtime
def setUp(self):
def initialize_module(self, **kwargs):
kwargs.update({
'parent_location': self.section.location,
'category': self.CATEGORY
})
self.item_descriptor = ItemFactory.create(**kwargs)
self.runtime = self.new_descriptor_runtime()
field_data = {}
field_data.update(self.MODEL_DATA)
student_data = DictFieldData(field_data)
self.item_descriptor._field_data = LmsFieldData(self.item_descriptor._field_data, student_data)
self.item_descriptor.xmodule_runtime = self.new_module_runtime()
self.item_module = self.item_descriptor
self.item_url = Location(self.item_module.location).url()
def setup_course(self):
self.course = CourseFactory.create(data=self.COURSE_DATA)
# Turn off cache.
......@@ -83,7 +105,7 @@ class BaseTestXmodule(ModuleStoreTestCase):
parent_location=self.course.location,
category="sequential",
)
section = ItemFactory.create(
self.section = ItemFactory.create(
parent_location=chapter.location,
category="sequential"
)
......@@ -97,24 +119,6 @@ class BaseTestXmodule(ModuleStoreTestCase):
for user in self.users:
CourseEnrollmentFactory.create(user=user, course_id=self.course.id)
self.item_descriptor = ItemFactory.create(
parent_location=section.location,
category=self.CATEGORY,
data=self.DATA
)
self.runtime = self.new_descriptor_runtime()
field_data = {}
field_data.update(self.MODEL_DATA)
student_data = DictFieldData(field_data)
self.item_descriptor._field_data = LmsFieldData(self.item_descriptor._field_data, student_data)
self.item_descriptor.xmodule_runtime = self.new_module_runtime()
self.item_module = self.item_descriptor
self.item_url = Location(self.item_module.location).url()
# login all users for acces to Xmodule
self.clients = {user.username: Client() for user in self.users}
self.login_statuses = [
......@@ -125,6 +129,10 @@ class BaseTestXmodule(ModuleStoreTestCase):
self.assertTrue(all(self.login_statuses))
def setUp(self):
self.setup_course();
self.initialize_module(metadata=self.METADATA, data=self.DATA)
def get_url(self, dispatch):
"""Return item url with dispatch."""
return reverse(
......
......@@ -35,7 +35,6 @@ SOURCE_XML = """
>
<source src="example.mp4"/>
<source src="example.webm"/>
<source src="example.ogv"/>
</video>
"""
......@@ -68,12 +67,10 @@ class VideoModuleUnitTest(unittest.TestCase):
def test_video_get_html(self):
"""Make sure that all parameters extracted correclty from xml"""
module = VideoFactory.create()
sources = {
'main': 'example.mp4',
'mp4': 'example.mp4',
'webm': 'example.webm',
'ogv': 'example.ogv'
}
expected_context = {
......@@ -87,7 +84,7 @@ class VideoModuleUnitTest(unittest.TestCase):
'show_captions': 'true',
'sources': sources,
'youtube_streams': _create_youtube_string(module),
'track': '',
'track': None,
'autoplay': settings.FEATURES.get('AUTOPLAY_VIDEOS', False),
'yt_test_timeout': 1500,
'yt_test_url': 'https://gdata.youtube.com/feeds/api/videos/'
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment