Merge pull request #2149 from edx/anton/video-download-transcript
Allows students to download the transcript of the video without timecodes
This commit is contained in:
@@ -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=""http://download_video""
|
||||
sub=""html5_subtitles""
|
||||
track=""http://download_track""
|
||||
download_track="true"
|
||||
youtube_id_0_75=""OEoXaMPEzf65""
|
||||
youtube_id_1_25=""OEoXaMPEzf125""
|
||||
youtube_id_1_5=""OEoXaMPEzf15""
|
||||
@@ -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,37 +74,13 @@ 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.course = CourseFactory.create(data=self.COURSE_DATA)
|
||||
|
||||
# Turn off cache.
|
||||
modulestore().request_cache = None
|
||||
modulestore().metadata_inheritance_cache_subsystem = None
|
||||
|
||||
chapter = ItemFactory.create(
|
||||
parent_location=self.course.location,
|
||||
category="sequential",
|
||||
)
|
||||
section = ItemFactory.create(
|
||||
parent_location=chapter.location,
|
||||
category="sequential"
|
||||
)
|
||||
|
||||
# username = robot{0}, password = 'test'
|
||||
self.users = [
|
||||
UserFactory.create()
|
||||
for i in range(self.USER_COUNT)
|
||||
]
|
||||
|
||||
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.item_descriptor = ItemFactory.create(**kwargs)
|
||||
|
||||
self.runtime = self.new_descriptor_runtime()
|
||||
|
||||
@@ -115,6 +94,31 @@ class BaseTestXmodule(ModuleStoreTestCase):
|
||||
|
||||
self.item_url = Location(self.item_module.location).url()
|
||||
|
||||
def setup_course(self):
|
||||
self.course = CourseFactory.create(data=self.COURSE_DATA)
|
||||
|
||||
# Turn off cache.
|
||||
modulestore().request_cache = None
|
||||
modulestore().metadata_inheritance_cache_subsystem = None
|
||||
|
||||
chapter = ItemFactory.create(
|
||||
parent_location=self.course.location,
|
||||
category="sequential",
|
||||
)
|
||||
self.section = ItemFactory.create(
|
||||
parent_location=chapter.location,
|
||||
category="sequential"
|
||||
)
|
||||
|
||||
# username = robot{0}, password = 'test'
|
||||
self.users = [
|
||||
UserFactory.create()
|
||||
for i in range(self.USER_COUNT)
|
||||
]
|
||||
|
||||
for user in self.users:
|
||||
CourseEnrollmentFactory.create(user=user, course_id=self.course.id)
|
||||
|
||||
# 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(
|
||||
|
||||
@@ -1,17 +1,27 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""Video xmodule tests in mongo."""
|
||||
|
||||
from mock import patch, PropertyMock
|
||||
import os
|
||||
import tempfile
|
||||
import textwrap
|
||||
from functools import partial
|
||||
|
||||
from xmodule.contentstore.content import StaticContent
|
||||
from xmodule.modulestore import Location
|
||||
from xmodule.contentstore.django import contentstore
|
||||
from . import BaseTestXmodule
|
||||
from .test_video_xml import SOURCE_XML
|
||||
from django.conf import settings
|
||||
from xmodule.video_module import _create_youtube_string
|
||||
|
||||
from cache_toolbox.core import del_cached_content
|
||||
from xmodule.exceptions import NotFoundError
|
||||
|
||||
class TestVideo(BaseTestXmodule):
|
||||
"""Integration tests: web client + mongo."""
|
||||
|
||||
CATEGORY = "video"
|
||||
DATA = SOURCE_XML
|
||||
METADATA = {}
|
||||
|
||||
def test_handle_ajax_dispatch(self):
|
||||
responses = {
|
||||
@@ -29,16 +39,21 @@ class TestVideo(BaseTestXmodule):
|
||||
]).pop(),
|
||||
404)
|
||||
|
||||
def tearDown(self):
|
||||
_clear_assets(self.item_module.location)
|
||||
|
||||
|
||||
class TestVideoYouTube(TestVideo):
|
||||
METADATA = {}
|
||||
|
||||
def test_video_constructor(self):
|
||||
"""Make sure that all parameters extracted correclty from xml"""
|
||||
|
||||
context = self.item_module.render('student_view').content
|
||||
|
||||
sources = {
|
||||
'main': u'example.mp4',
|
||||
u'mp4': u'example.mp4',
|
||||
u'webm': u'example.webm',
|
||||
u'ogv': u'example.ogv'
|
||||
}
|
||||
|
||||
expected_context = {
|
||||
@@ -51,7 +66,7 @@ class TestVideo(BaseTestXmodule):
|
||||
'sources': sources,
|
||||
'start': 3603.0,
|
||||
'sub': u'a_sub_file.srt.sjson',
|
||||
'track': '',
|
||||
'track': None,
|
||||
'youtube_streams': _create_youtube_string(self.item_module),
|
||||
'autoplay': settings.FEATURES.get('AUTOPLAY_VIDEOS', False),
|
||||
'yt_test_timeout': 1500,
|
||||
@@ -75,12 +90,12 @@ class TestVideoNonYouTube(TestVideo):
|
||||
>
|
||||
<source src="example.mp4"/>
|
||||
<source src="example.webm"/>
|
||||
<source src="example.ogv"/>
|
||||
</video>
|
||||
"""
|
||||
MODEL_DATA = {
|
||||
'data': DATA
|
||||
}
|
||||
METADATA = {}
|
||||
|
||||
def test_video_constructor(self):
|
||||
"""Make sure that if the 'youtube' attribute is omitted in XML, then
|
||||
@@ -90,7 +105,6 @@ class TestVideoNonYouTube(TestVideo):
|
||||
'main': u'example.mp4',
|
||||
u'mp4': u'example.mp4',
|
||||
u'webm': u'example.webm',
|
||||
u'ogv': u'example.ogv'
|
||||
}
|
||||
|
||||
context = self.item_module.render('student_view').content
|
||||
@@ -105,7 +119,7 @@ class TestVideoNonYouTube(TestVideo):
|
||||
'sources': sources,
|
||||
'start': 3603.0,
|
||||
'sub': u'a_sub_file.srt.sjson',
|
||||
'track': '',
|
||||
'track': None,
|
||||
'youtube_streams': '1.00:OEoXaMPEzfM',
|
||||
'autoplay': settings.FEATURES.get('AUTOPLAY_VIDEOS', True),
|
||||
'yt_test_timeout': 1500,
|
||||
@@ -116,3 +130,319 @@ class TestVideoNonYouTube(TestVideo):
|
||||
context,
|
||||
self.item_module.xmodule_runtime.render_template('video.html', expected_context)
|
||||
)
|
||||
|
||||
|
||||
class TestVideoGetTranscriptsMethod(TestVideo):
|
||||
"""
|
||||
Make sure that `get_transcript` method works correctly
|
||||
"""
|
||||
|
||||
DATA = """
|
||||
<video show_captions="true"
|
||||
display_name="A Name"
|
||||
>
|
||||
<source src="example.mp4"/>
|
||||
<source src="example.webm"/>
|
||||
</video>
|
||||
"""
|
||||
MODEL_DATA = {
|
||||
'data': DATA
|
||||
}
|
||||
METADATA = {}
|
||||
|
||||
def test_good_transcript(self):
|
||||
self.item_module.render('student_view')
|
||||
item = self.item_descriptor.xmodule_runtime.xmodule_instance
|
||||
|
||||
good_sjson = _create_file(content="""
|
||||
{
|
||||
"start": [
|
||||
270,
|
||||
2720
|
||||
],
|
||||
"end": [
|
||||
2720,
|
||||
5430
|
||||
],
|
||||
"text": [
|
||||
"Hi, welcome to Edx.",
|
||||
"Let's start with what is on your screen right now."
|
||||
]
|
||||
}
|
||||
""")
|
||||
|
||||
_upload_file(good_sjson, self.item_module.location)
|
||||
subs_id = _get_subs_id(good_sjson.name)
|
||||
|
||||
text = item.get_transcript(subs_id)
|
||||
expected_text = "Hi, welcome to Edx.\nLet's start with what is on your screen right now."
|
||||
|
||||
self.assertEqual(
|
||||
text, expected_text
|
||||
)
|
||||
|
||||
def test_not_found_error(self):
|
||||
self.item_module.render('student_view')
|
||||
item = self.item_descriptor.xmodule_runtime.xmodule_instance
|
||||
|
||||
with self.assertRaises(NotFoundError):
|
||||
item.get_transcript('wrong')
|
||||
|
||||
def test_value_error(self):
|
||||
self.item_module.render('student_view')
|
||||
item = self.item_descriptor.xmodule_runtime.xmodule_instance
|
||||
|
||||
good_sjson = _create_file(content="""
|
||||
bad content
|
||||
""")
|
||||
|
||||
_upload_file(good_sjson, self.item_module.location)
|
||||
subs_id = _get_subs_id(good_sjson.name)
|
||||
|
||||
with self.assertRaises(ValueError):
|
||||
item.get_transcript(subs_id)
|
||||
|
||||
def test_key_error(self):
|
||||
self.item_module.render('student_view')
|
||||
item = self.item_descriptor.xmodule_runtime.xmodule_instance
|
||||
|
||||
good_sjson = _create_file(content="""
|
||||
{
|
||||
"start": [
|
||||
270,
|
||||
2720
|
||||
],
|
||||
"end": [
|
||||
2720,
|
||||
5430
|
||||
]
|
||||
}
|
||||
""")
|
||||
|
||||
_upload_file(good_sjson, self.item_module.location)
|
||||
subs_id = _get_subs_id(good_sjson.name)
|
||||
|
||||
with self.assertRaises(KeyError):
|
||||
item.get_transcript(subs_id)
|
||||
|
||||
|
||||
class TestGetHtmlMethod(BaseTestXmodule):
|
||||
"""
|
||||
Make sure that `get_html` works correctly.
|
||||
"""
|
||||
CATEGORY = "video"
|
||||
DATA = SOURCE_XML
|
||||
METADATA = {}
|
||||
|
||||
def setUp(self):
|
||||
self.setup_course();
|
||||
|
||||
def test_get_html_track(self):
|
||||
SOURCE_XML = """
|
||||
<video show_captions="true"
|
||||
display_name="A Name"
|
||||
sub="{sub}" download_track="{download_track}"
|
||||
start_time="01:00:03" end_time="01:00:10"
|
||||
>
|
||||
<source src="example.mp4"/>
|
||||
<source src="example.webm"/>
|
||||
{track}
|
||||
</video>
|
||||
"""
|
||||
|
||||
cases = [
|
||||
{
|
||||
'download_track': u'true',
|
||||
'track': u'<track src="http://www.example.com/track"/>',
|
||||
'sub': u'a_sub_file.srt.sjson',
|
||||
'expected_track_url': u'http://www.example.com/track',
|
||||
},
|
||||
{
|
||||
'download_track': u'true',
|
||||
'track': u'',
|
||||
'sub': u'a_sub_file.srt.sjson',
|
||||
'expected_track_url': u'a_sub_file.srt.sjson',
|
||||
},
|
||||
{
|
||||
'download_track': u'true',
|
||||
'track': u'',
|
||||
'sub': u'',
|
||||
'expected_track_url': None
|
||||
},
|
||||
{
|
||||
'download_track': u'false',
|
||||
'track': u'<track src="http://www.example.com/track"/>',
|
||||
'sub': u'a_sub_file.srt.sjson',
|
||||
'expected_track_url': None,
|
||||
}
|
||||
]
|
||||
|
||||
expected_context = {
|
||||
'data_dir': getattr(self, 'data_dir', None),
|
||||
'caption_asset_path': '/static/subs/',
|
||||
'show_captions': 'true',
|
||||
'display_name': u'A Name',
|
||||
'end': 3610.0,
|
||||
'id': None,
|
||||
'sources': {
|
||||
'main': u'example.mp4',
|
||||
u'mp4': u'example.mp4',
|
||||
u'webm': u'example.webm'
|
||||
},
|
||||
'start': 3603.0,
|
||||
'sub': u'a_sub_file.srt.sjson',
|
||||
'track': '',
|
||||
'youtube_streams': '1.00:OEoXaMPEzfM',
|
||||
'autoplay': settings.FEATURES.get('AUTOPLAY_VIDEOS', True),
|
||||
'yt_test_timeout': 1500,
|
||||
'yt_test_url': 'https://gdata.youtube.com/feeds/api/videos/'
|
||||
}
|
||||
|
||||
for data in cases:
|
||||
DATA = SOURCE_XML.format(
|
||||
download_track=data['download_track'],
|
||||
track=data['track'],
|
||||
sub=data['sub'],
|
||||
)
|
||||
|
||||
self.initialize_module(data=DATA)
|
||||
track_url = self.item_descriptor.xmodule_runtime.handler_url(self.item_module, 'download_transcript')
|
||||
|
||||
expected_context.update({
|
||||
'track': track_url if data['expected_track_url'] == u'a_sub_file.srt.sjson' else data['expected_track_url'],
|
||||
'sub': data['sub'],
|
||||
'id': self.item_module.location.html_id(),
|
||||
})
|
||||
|
||||
context = self.item_module.render('student_view').content
|
||||
self.assertEqual(
|
||||
context,
|
||||
self.item_module.xmodule_runtime.render_template('video.html', expected_context)
|
||||
)
|
||||
|
||||
|
||||
class TestVideoDescriptorInitialization(BaseTestXmodule):
|
||||
"""
|
||||
Make sure that module initialization works correctly.
|
||||
"""
|
||||
CATEGORY = "video"
|
||||
DATA = SOURCE_XML
|
||||
METADATA = {}
|
||||
|
||||
def setUp(self):
|
||||
self.setup_course();
|
||||
|
||||
def test_track_is_not_empty(self):
|
||||
metatdata = {
|
||||
'track': 'http://example.org/track',
|
||||
}
|
||||
|
||||
self.initialize_module(metadata=metatdata)
|
||||
fields = self.item_descriptor.editable_metadata_fields
|
||||
|
||||
self.assertIn('track', fields)
|
||||
self.assertEqual(self.item_module.track, 'http://example.org/track')
|
||||
self.assertTrue(self.item_module.download_track)
|
||||
self.assertTrue(self.item_module.track_visible)
|
||||
|
||||
@patch('xmodule.x_module.XModuleDescriptor.editable_metadata_fields', new_callable=PropertyMock)
|
||||
def test_download_track_is_explicitly_set(self, mock_editable_fields):
|
||||
mock_editable_fields.return_value = {
|
||||
'download_track': {
|
||||
'default_value': False,
|
||||
'explicitly_set': True,
|
||||
'display_name': 'Transcript Download Allowed',
|
||||
'help': 'Show a link beneath the video to allow students to download the transcript.',
|
||||
'type': 'Boolean',
|
||||
'value': False,
|
||||
'field_name': 'download_track',
|
||||
'options': [
|
||||
{'display_name': "True", "value": True},
|
||||
{'display_name': "False", "value": False}
|
||||
]
|
||||
},
|
||||
'track': {
|
||||
'default_value': '',
|
||||
'explicitly_set': False,
|
||||
'display_name': 'Download Transcript',
|
||||
'help': 'The external URL to download the timed transcript track.',
|
||||
'type': 'Generic',
|
||||
'value': u'http://example.org/track',
|
||||
'field_name': 'track',
|
||||
'options': []
|
||||
},
|
||||
}
|
||||
metadata = {
|
||||
'track': 'http://example.org/track',
|
||||
}
|
||||
|
||||
self.initialize_module(metadata=metadata)
|
||||
fields = self.item_descriptor.editable_metadata_fields
|
||||
|
||||
self.assertIn('track', fields)
|
||||
self.assertEqual(self.item_module.track, 'http://example.org/track')
|
||||
self.assertFalse(self.item_module.download_track)
|
||||
self.assertTrue(self.item_module.track_visible)
|
||||
|
||||
|
||||
def test_track_is_empty(self):
|
||||
metatdata = {
|
||||
'track': '',
|
||||
}
|
||||
|
||||
self.initialize_module(metadata=metatdata)
|
||||
fields = self.item_descriptor.editable_metadata_fields
|
||||
|
||||
self.assertNotIn('track', fields)
|
||||
self.assertEqual(self.item_module.track, '')
|
||||
self.assertFalse(self.item_module.download_track)
|
||||
self.assertFalse(self.item_module.track_visible)
|
||||
|
||||
|
||||
def _clear_assets(location):
|
||||
store = contentstore()
|
||||
|
||||
content_location = StaticContent.compute_location(
|
||||
location.org, location.course, location.name
|
||||
)
|
||||
|
||||
assets, __ = store.get_all_content_for_course(content_location)
|
||||
for asset in assets:
|
||||
asset_location = Location(asset["_id"])
|
||||
id = StaticContent.get_id_from_location(asset_location)
|
||||
store.delete(id)
|
||||
|
||||
def _get_subs_id(filename):
|
||||
basename = os.path.splitext(os.path.basename(filename))[0]
|
||||
return basename.replace('subs_', '').replace('.srt', '')
|
||||
|
||||
def _create_file(content=''):
|
||||
sjson_file = tempfile.NamedTemporaryFile(prefix="subs_", suffix=".srt.sjson")
|
||||
sjson_file.content_type = 'application/json'
|
||||
sjson_file.write(textwrap.dedent(content))
|
||||
sjson_file.seek(0)
|
||||
|
||||
return sjson_file
|
||||
|
||||
def _upload_file(file, location):
|
||||
filename = 'subs_{}.srt.sjson'.format(_get_subs_id(file.name))
|
||||
mime_type = file.content_type
|
||||
|
||||
content_location = StaticContent.compute_location(
|
||||
location.org, location.course, filename
|
||||
)
|
||||
|
||||
sc_partial = partial(StaticContent, content_location, filename, mime_type)
|
||||
content = sc_partial(file.read())
|
||||
|
||||
(thumbnail_content, thumbnail_location) = contentstore().generate_thumbnail(
|
||||
content,
|
||||
tempfile_path=None
|
||||
)
|
||||
del_cached_content(thumbnail_location)
|
||||
|
||||
if thumbnail_content is not None:
|
||||
content.thumbnail_location = thumbnail_location
|
||||
|
||||
contentstore().save(content)
|
||||
del_cached_content(content.location)
|
||||
|
||||
@@ -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/'
|
||||
|
||||
Reference in New Issue
Block a user