Commit b34b5d9a by Muzaffar yousaf Committed by GitHub

Merge pull request #15872 from edx/video-transcript-preferences

Video transcript preferences
parents f520f9bd d4147e7d
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
""" Tests for transcripts_utils. """ """ Tests for transcripts_utils. """
import copy import copy
import ddt
import textwrap import textwrap
import unittest import unittest
from uuid import uuid4 from uuid import uuid4
...@@ -632,6 +633,16 @@ class TestTranscript(unittest.TestCase): ...@@ -632,6 +633,16 @@ class TestTranscript(unittest.TestCase):
with self.assertRaises(NotImplementedError): with self.assertRaises(NotImplementedError):
transcripts_utils.Transcript.convert(self.srt_transcript, 'srt', 'sjson') transcripts_utils.Transcript.convert(self.srt_transcript, 'srt', 'sjson')
def test_dummy_non_existent_transcript(self):
"""
Test `Transcript.asset` raises `NotFoundError` for dummy non-existent transcript.
"""
with self.assertRaises(NotFoundError):
transcripts_utils.Transcript.asset(None, transcripts_utils.NON_EXISTENT_TRANSCRIPT)
with self.assertRaises(NotFoundError):
transcripts_utils.Transcript.asset(None, None, filename=transcripts_utils.NON_EXISTENT_TRANSCRIPT)
class TestSubsFilename(unittest.TestCase): class TestSubsFilename(unittest.TestCase):
""" """
...@@ -643,3 +654,43 @@ class TestSubsFilename(unittest.TestCase): ...@@ -643,3 +654,43 @@ class TestSubsFilename(unittest.TestCase):
self.assertEqual(name, u'subs_˙∆©ƒƒƒ.srt.sjson') self.assertEqual(name, u'subs_˙∆©ƒƒƒ.srt.sjson')
name = transcripts_utils.subs_filename(u"˙∆©ƒƒƒ", 'uk') name = transcripts_utils.subs_filename(u"˙∆©ƒƒƒ", 'uk')
self.assertEqual(name, u'uk_subs_˙∆©ƒƒƒ.srt.sjson') self.assertEqual(name, u'uk_subs_˙∆©ƒƒƒ.srt.sjson')
@ddt.ddt
class TestVideoIdsInfo(unittest.TestCase):
"""
Tests for `get_video_ids_info`.
"""
@ddt.data(
{
'edx_video_id': '000-000-000',
'youtube_id_1_0': '12as34',
'html5_sources': [
'www.abc.com/foo.mp4', 'www.abc.com/bar.webm', 'foo/bar/baz.m3u8'
],
'expected_result': (False, ['000-000-000', '12as34', 'foo', 'bar', 'baz'])
},
{
'edx_video_id': '',
'youtube_id_1_0': '12as34',
'html5_sources': [
'www.abc.com/foo.mp4', 'www.abc.com/bar.webm', 'foo/bar/baz.m3u8'
],
'expected_result': (True, ['12as34', 'foo', 'bar', 'baz'])
},
{
'edx_video_id': '',
'youtube_id_1_0': '',
'html5_sources': [
'www.abc.com/foo.mp4', 'www.abc.com/bar.webm',
],
'expected_result': (True, ['foo', 'bar'])
},
)
@ddt.unpack
def test_get_video_ids_info(self, edx_video_id, youtube_id_1_0, html5_sources, expected_result):
"""
Verify that `get_video_ids_info` works as expected.
"""
actual_result = transcripts_utils.get_video_ids_info(edx_video_id, youtube_id_1_0, html5_sources)
self.assertEqual(actual_result, expected_result)
"""Tests for items views.""" """Tests for items views."""
import copy import copy
import ddt
import json import json
import os import os
import tempfile import tempfile
...@@ -10,7 +11,7 @@ from uuid import uuid4 ...@@ -10,7 +11,7 @@ from uuid import uuid4
from django.conf import settings from django.conf import settings
from django.core.urlresolvers import reverse from django.core.urlresolvers import reverse
from django.test.utils import override_settings from django.test.utils import override_settings
from mock import patch from mock import patch, Mock
from opaque_keys.edx.keys import UsageKey from opaque_keys.edx.keys import UsageKey
from contentstore.tests.utils import CourseTestCase, mock_requests_get from contentstore.tests.utils import CourseTestCase, mock_requests_get
...@@ -525,7 +526,70 @@ class TestDownloadTranscripts(BaseTranscripts): ...@@ -525,7 +526,70 @@ class TestDownloadTranscripts(BaseTranscripts):
self.assertEqual(resp.status_code, 404) self.assertEqual(resp.status_code, 404)
@patch('xmodule.video_module.transcripts_utils.VideoTranscriptEnabledFlag.feature_enabled', Mock(return_value=True))
@patch('xmodule.video_module.transcripts_utils.edxval_api.get_video_transcript_data')
def test_download_fallback_transcript(self, mock_get_video_transcript_data):
"""
Verify that the val transcript is returned if its not found in content-store.
"""
mock_get_video_transcript_data.return_value = {
'content': json.dumps({
"start": [10],
"end": [100],
"text": ["Hi, welcome to Edx."],
}),
'file_name': 'edx.sjson'
}
self.item.data = textwrap.dedent("""
<video youtube="" sub="">
<source src="http://www.quirksmode.org/html5/videos/big_buck_bunny.mp4"/>
<source src="http://www.quirksmode.org/html5/videos/big_buck_bunny.webm"/>
<source src="http://www.quirksmode.org/html5/videos/big_buck_bunny.ogv"/>
</video>
""")
modulestore().update_item(self.item, self.user.id)
download_transcripts_url = reverse('download_transcripts')
response = self.client.get(download_transcripts_url, {'locator': self.video_usage_key})
# Expected response
expected_content = u'0\n00:00:00,010 --> 00:00:00,100\nHi, welcome to Edx.\n\n'
expected_headers = {
'content-disposition': 'attachment; filename="edx.srt"',
'content-type': 'application/x-subrip; charset=utf-8'
}
# Assert the actual response
self.assertEqual(response.status_code, 200)
self.assertEqual(response.content, expected_content)
for attribute, value in expected_headers.iteritems():
self.assertEqual(response.get(attribute), value)
@patch(
'xmodule.video_module.transcripts_utils.VideoTranscriptEnabledFlag.feature_enabled',
Mock(return_value=False),
)
def test_download_fallback_transcript_feature_disabled(self):
"""
Verify the transcript download when feature is disabled.
"""
self.item.data = textwrap.dedent("""
<video youtube="" sub="">
<source src="http://www.quirksmode.org/html5/videos/big_buck_bunny.mp4"/>
<source src="http://www.quirksmode.org/html5/videos/big_buck_bunny.webm"/>
<source src="http://www.quirksmode.org/html5/videos/big_buck_bunny.ogv"/>
</video>
""")
modulestore().update_item(self.item, self.user.id)
download_transcripts_url = reverse('download_transcripts')
response = self.client.get(download_transcripts_url, {'locator': self.video_usage_key})
# Assert the actual response
self.assertEqual(response.status_code, 404)
@ddt.ddt
class TestCheckTranscripts(BaseTranscripts): class TestCheckTranscripts(BaseTranscripts):
""" """
Tests for '/transcripts/check' url. Tests for '/transcripts/check' url.
...@@ -760,6 +824,58 @@ class TestCheckTranscripts(BaseTranscripts): ...@@ -760,6 +824,58 @@ class TestCheckTranscripts(BaseTranscripts):
self.assertEqual(resp.status_code, 400) self.assertEqual(resp.status_code, 400)
self.assertEqual(json.loads(resp.content).get('status'), 'Transcripts are supported only for "video" modules.') self.assertEqual(json.loads(resp.content).get('status'), 'Transcripts are supported only for "video" modules.')
@ddt.data(
(True, 'found'),
(False, 'not_found')
)
@ddt.unpack
@patch('xmodule.video_module.transcripts_utils.VideoTranscriptEnabledFlag.feature_enabled')
@patch('xmodule.video_module.transcripts_utils.edxval_api.get_video_transcript_data', Mock(return_value=True))
def test_command_for_fallback_transcript(self, feature_enabled, expected_command, video_transcript_feature):
"""
Verify the command if a transcript is not found in content-store but
its there in edx-val.
"""
video_transcript_feature.return_value = feature_enabled
self.item.data = textwrap.dedent("""
<video youtube="" sub="">
<source src="http://www.quirksmode.org/html5/videos/big_buck_bunny.mp4"/>
<source src="http://www.quirksmode.org/html5/videos/big_buck_bunny.webm"/>
<source src="http://www.quirksmode.org/html5/videos/big_buck_bunny.ogv"/>
</video>
""")
modulestore().update_item(self.item, self.user.id)
# Make request to check transcript view
data = {
'locator': unicode(self.video_usage_key),
'videos': [{
'type': 'html5',
'video': "",
'mode': 'mp4',
}]
}
check_transcripts_url = reverse('check_transcripts')
response = self.client.get(check_transcripts_url, {'data': json.dumps(data)})
# Assert the response
self.assertEqual(response.status_code, 200)
self.assertDictEqual(
json.loads(response.content),
{
u'status': u'Success',
u'subs': u'',
u'youtube_local': False,
u'is_youtube_mode': False,
u'youtube_server': False,
u'command': expected_command,
u'current_item_subs': None,
u'youtube_diff': True,
u'html5_local': [],
u'html5_equal': False,
}
)
class TestSaveTranscripts(BaseTranscripts): class TestSaveTranscripts(BaseTranscripts):
""" """
......
...@@ -27,16 +27,18 @@ from xmodule.exceptions import NotFoundError ...@@ -27,16 +27,18 @@ from xmodule.exceptions import NotFoundError
from xmodule.modulestore.django import modulestore from xmodule.modulestore.django import modulestore
from xmodule.modulestore.exceptions import ItemNotFoundError from xmodule.modulestore.exceptions import ItemNotFoundError
from xmodule.video_module.transcripts_utils import ( from xmodule.video_module.transcripts_utils import (
GetTranscriptsFromYouTubeException,
TranscriptsRequestValidationException,
copy_or_rename_transcript, copy_or_rename_transcript,
download_youtube_subs, download_youtube_subs,
generate_srt_from_sjson, GetTranscriptsFromYouTubeException,
get_video_transcript_content,
generate_subs_from_source, generate_subs_from_source,
get_transcripts_from_youtube, get_transcripts_from_youtube,
is_val_transcript_feature_enabled_for_course,
manage_video_subtitles_save, manage_video_subtitles_save,
remove_subs_from_store, remove_subs_from_store,
youtube_video_transcript_name Transcript,
TranscriptsRequestValidationException,
youtube_video_transcript_name,
) )
__all__ = [ __all__ = [
...@@ -144,6 +146,7 @@ def download_transcripts(request): ...@@ -144,6 +146,7 @@ def download_transcripts(request):
Raises Http404 if unsuccessful. Raises Http404 if unsuccessful.
""" """
locator = request.GET.get('locator') locator = request.GET.get('locator')
subs_id = request.GET.get('subs_id')
if not locator: if not locator:
log.debug('GET data without "locator" property.') log.debug('GET data without "locator" property.')
raise Http404 raise Http404
...@@ -154,31 +157,47 @@ def download_transcripts(request): ...@@ -154,31 +157,47 @@ def download_transcripts(request):
log.debug("Can't find item by locator.") log.debug("Can't find item by locator.")
raise Http404 raise Http404
subs_id = request.GET.get('subs_id')
if not subs_id:
log.debug('GET data without "subs_id" property.')
raise Http404
if item.category != 'video': if item.category != 'video':
log.debug('transcripts are supported only for video" modules.') log.debug('transcripts are supported only for video" modules.')
raise Http404 raise Http404
filename = 'subs_{0}.srt.sjson'.format(subs_id)
content_location = StaticContent.compute_location(item.location.course_key, filename)
try: try:
sjson_transcripts = contentstore().find(content_location) if not subs_id:
log.debug("Downloading subs for %s id", subs_id) raise NotFoundError
str_subs = generate_srt_from_sjson(json.loads(sjson_transcripts.data), speed=1.0)
if not str_subs: filename = subs_id
log.debug('generate_srt_from_sjson produces no subtitles') content_location = StaticContent.compute_location(
raise Http404 item.location.course_key,
response = HttpResponse(str_subs, content_type='application/x-subrip') 'subs_{filename}.srt.sjson'.format(filename=filename),
response['Content-Disposition'] = 'attachment; filename="{0}.srt"'.format(subs_id) )
return response sjson_transcript = contentstore().find(content_location).data
except NotFoundError: except NotFoundError:
log.debug("Can't find content in storage for %s subs", subs_id) # Try searching in VAL for the transcript as a last resort
transcript = None
if is_val_transcript_feature_enabled_for_course(item.location.course_key):
transcript = get_video_transcript_content(
language_code=u'en',
edx_video_id=item.edx_video_id,
youtube_id_1_0=item.youtube_id_1_0,
html5_sources=item.html5_sources,
)
if not transcript:
raise Http404
filename = os.path.splitext(os.path.basename(transcript['file_name']))[0].encode('utf8')
sjson_transcript = transcript['content']
# convert sjson content into srt format.
transcript_content = Transcript.convert(sjson_transcript, input_format='sjson', output_format='srt')
if not transcript_content:
raise Http404 raise Http404
# Construct an HTTP response
response = HttpResponse(transcript_content, content_type='application/x-subrip; charset=utf-8')
response['Content-Disposition'] = 'attachment; filename="{filename}.srt"'.format(filename=filename)
return response
@login_required @login_required
def check_transcripts(request): def check_transcripts(request):
...@@ -284,6 +303,17 @@ def check_transcripts(request): ...@@ -284,6 +303,17 @@ def check_transcripts(request):
transcripts_presence['html5_equal'] = json.loads(html5_subs[0]) == json.loads(html5_subs[1]) transcripts_presence['html5_equal'] = json.loads(html5_subs[0]) == json.loads(html5_subs[1])
command, subs_to_use = _transcripts_logic(transcripts_presence, videos) command, subs_to_use = _transcripts_logic(transcripts_presence, videos)
if command == 'not_found':
# Try searching in VAL for the transcript as a last resort
if is_val_transcript_feature_enabled_for_course(item.location.course_key):
video_transcript = get_video_transcript_content(
language_code=u'en',
edx_video_id=item.edx_video_id,
youtube_id_1_0=item.youtube_id_1_0,
html5_sources=item.html5_sources,
)
command = 'found' if video_transcript else command
transcripts_presence.update({ transcripts_presence.update({
'command': command, 'command': command,
'subs': subs_to_use, 'subs': subs_to_use,
......
...@@ -453,6 +453,10 @@ VIDEO_UPLOAD_PIPELINE = ENV_TOKENS.get('VIDEO_UPLOAD_PIPELINE', VIDEO_UPLOAD_PIP ...@@ -453,6 +453,10 @@ VIDEO_UPLOAD_PIPELINE = ENV_TOKENS.get('VIDEO_UPLOAD_PIPELINE', VIDEO_UPLOAD_PIP
VIDEO_IMAGE_SETTINGS = ENV_TOKENS.get('VIDEO_IMAGE_SETTINGS', VIDEO_IMAGE_SETTINGS) VIDEO_IMAGE_SETTINGS = ENV_TOKENS.get('VIDEO_IMAGE_SETTINGS', VIDEO_IMAGE_SETTINGS)
################ VIDEO TRANSCRIPTS STORAGE ###############
VIDEO_TRANSCRIPTS_SETTINGS = ENV_TOKENS.get('VIDEO_TRANSCRIPTS_SETTINGS', VIDEO_TRANSCRIPTS_SETTINGS)
################ PUSH NOTIFICATIONS ############### ################ PUSH NOTIFICATIONS ###############
PARSE_KEYS = AUTH_TOKENS.get("PARSE_KEYS", {}) PARSE_KEYS = AUTH_TOKENS.get("PARSE_KEYS", {})
......
...@@ -112,6 +112,7 @@ from lms.envs.common import ( ...@@ -112,6 +112,7 @@ from lms.envs.common import (
DISABLE_ACCOUNT_ACTIVATION_REQUIREMENT_SWITCH, DISABLE_ACCOUNT_ACTIVATION_REQUIREMENT_SWITCH,
# Video Image settings # Video Image settings
VIDEO_IMAGE_SETTINGS, VIDEO_IMAGE_SETTINGS,
VIDEO_TRANSCRIPTS_SETTINGS,
) )
from path import Path as path from path import Path as path
from warnings import simplefilter from warnings import simplefilter
......
...@@ -353,3 +353,13 @@ VIDEO_IMAGE_SETTINGS = dict( ...@@ -353,3 +353,13 @@ VIDEO_IMAGE_SETTINGS = dict(
DIRECTORY_PREFIX='video-images/', DIRECTORY_PREFIX='video-images/',
) )
VIDEO_IMAGE_DEFAULT_FILENAME = 'default_video_image.png' VIDEO_IMAGE_DEFAULT_FILENAME = 'default_video_image.png'
########################## VIDEO TRANSCRIPTS STORAGE ############################
VIDEO_TRANSCRIPTS_SETTINGS = dict(
VIDEO_TRANSCRIPTS_MAX_BYTES=3 * 1024 * 1024, # 3 MB
STORAGE_KWARGS=dict(
location=MEDIA_ROOT,
base_url=MEDIA_URL,
),
DIRECTORY_PREFIX='video-transcripts/',
)
...@@ -64,7 +64,8 @@ ...@@ -64,7 +64,8 @@
contentType: 'application/json; charset=utf-8', contentType: 'application/json; charset=utf-8',
dataType: 'json', dataType: 'json',
data: JSON.stringify(data), data: JSON.stringify(data),
success: callback success: callback,
global: data ? data.global : true // Trigger global AJAX error handler or not
}); });
}; };
$.postJSON = function(url, data, callback) { // eslint-disable-line no-param-reassign $.postJSON = function(url, data, callback) { // eslint-disable-line no-param-reassign
......
...@@ -258,6 +258,7 @@ ...@@ -258,6 +258,7 @@
'js/spec/views/active_video_upload_list_spec', 'js/spec/views/active_video_upload_list_spec',
'js/spec/views/previous_video_upload_spec', 'js/spec/views/previous_video_upload_spec',
'js/spec/views/video_thumbnail_spec', 'js/spec/views/video_thumbnail_spec',
'js/spec/views/course_video_settings_spec',
'js/spec/views/previous_video_upload_list_spec', 'js/spec/views/previous_video_upload_list_spec',
'js/spec/views/assets_spec', 'js/spec/views/assets_spec',
'js/spec/views/baseview_spec', 'js/spec/views/baseview_spec',
......
...@@ -10,19 +10,25 @@ define([ ...@@ -10,19 +10,25 @@ define([
encodingsDownloadUrl, encodingsDownloadUrl,
defaultVideoImageURL, defaultVideoImageURL,
concurrentUploadLimit, concurrentUploadLimit,
uploadButton, courseVideoSettingsButton,
previousUploads, previousUploads,
videoSupportedFileFormats, videoSupportedFileFormats,
videoUploadMaxFileSizeInGB, videoUploadMaxFileSizeInGB,
activeTranscriptPreferences,
videoTranscriptSettings,
isVideoTranscriptEnabled,
videoImageSettings videoImageSettings
) { ) {
var activeView = new ActiveVideoUploadListView({ var activeView = new ActiveVideoUploadListView({
postUrl: videoHandlerUrl, postUrl: videoHandlerUrl,
concurrentUploadLimit: concurrentUploadLimit, concurrentUploadLimit: concurrentUploadLimit,
uploadButton: uploadButton, courseVideoSettingsButton: courseVideoSettingsButton,
videoSupportedFileFormats: videoSupportedFileFormats, videoSupportedFileFormats: videoSupportedFileFormats,
videoUploadMaxFileSizeInGB: videoUploadMaxFileSizeInGB, videoUploadMaxFileSizeInGB: videoUploadMaxFileSizeInGB,
videoImageSettings: videoImageSettings, videoImageSettings: videoImageSettings,
activeTranscriptPreferences: activeTranscriptPreferences,
videoTranscriptSettings: videoTranscriptSettings,
isVideoTranscriptEnabled: isVideoTranscriptEnabled,
onFileUploadDone: function(activeVideos) { onFileUploadDone: function(activeVideos) {
$.ajax({ $.ajax({
url: videoHandlerUrl, url: videoHandlerUrl,
......
...@@ -20,6 +20,10 @@ define( ...@@ -20,6 +20,10 @@ define(
fail: 'upload_failed', fail: 'upload_failed',
success: 'upload_completed' success: 'upload_completed'
}, },
videoUploadMaxFileSizeInGB = 5,
videoSupportedFileFormats = ['.mp4', '.mov'],
createActiveUploadListView,
$courseVideoSettingsButton,
makeUploadUrl, makeUploadUrl,
getSentRequests, getSentRequests,
verifyUploadViewInfo, verifyUploadViewInfo,
...@@ -29,24 +33,35 @@ define( ...@@ -29,24 +33,35 @@ define(
verifyA11YMessage, verifyA11YMessage,
verifyUploadPostRequest; verifyUploadPostRequest;
createActiveUploadListView = function(isVideoTranscriptEnabled) {
return new ActiveVideoUploadListView({
concurrentUploadLimit: concurrentUploadLimit,
postUrl: POST_URL,
courseVideoSettingsButton: $courseVideoSettingsButton,
videoSupportedFileFormats: videoSupportedFileFormats,
videoUploadMaxFileSizeInGB: videoUploadMaxFileSizeInGB,
activeTranscriptPreferences: {},
videoTranscriptSettings: {
transcript_preferences_handler_url: '',
transcription_plans: {}
},
isVideoTranscriptEnabled: isVideoTranscriptEnabled
});
};
describe('ActiveVideoUploadListView', function() { describe('ActiveVideoUploadListView', function() {
beforeEach(function() { beforeEach(function() {
setFixtures( setFixtures(
'<div id="page-prompt"></div><div id="page-notification"></div><div id="reader-feedback"></div>' '<div id="page-prompt"></div>' +
'<div id="page-notification"></div>' +
'<div id="reader-feedback"></div>' +
'<div class="video-transcript-settings-wrapper"></div>' +
'<button class="button course-video-settings-button"></button>'
); );
TemplateHelpers.installTemplate('active-video-upload'); TemplateHelpers.installTemplate('active-video-upload');
TemplateHelpers.installTemplate('active-video-upload-list'); TemplateHelpers.installTemplate('active-video-upload-list');
this.postUrl = POST_URL; $courseVideoSettingsButton = $('.course-video-settings-button');
this.uploadButton = $('<button>'); this.view = createActiveUploadListView(true);
this.videoSupportedFileFormats = ['.mp4', '.mov'];
this.videoUploadMaxFileSizeInGB = 5;
this.view = new ActiveVideoUploadListView({
concurrentUploadLimit: concurrentUploadLimit,
postUrl: this.postUrl,
uploadButton: this.uploadButton,
videoSupportedFileFormats: this.videoSupportedFileFormats,
videoUploadMaxFileSizeInGB: this.videoUploadMaxFileSizeInGB
});
this.view.render(); this.view.render();
jasmine.Ajax.install(); jasmine.Ajax.install();
}); });
...@@ -55,6 +70,10 @@ define( ...@@ -55,6 +70,10 @@ define(
afterEach(function() { afterEach(function() {
$(window).off('beforeunload'); $(window).off('beforeunload');
jasmine.Ajax.uninstall(); jasmine.Ajax.uninstall();
if (this.view.courseVideoSettingsView) {
this.view.courseVideoSettingsView = null;
}
}); });
it('renders correct text in file drag/drop area', function() { it('renders correct text in file drag/drop area', function() {
...@@ -71,15 +90,25 @@ define( ...@@ -71,15 +90,25 @@ define(
}); });
}); });
it('should trigger file selection when either the upload button or the drop zone is clicked', function() { it('should trigger file selection when the drop zone is clicked', function() {
var clickSpy = jasmine.createSpy(); var clickSpy = jasmine.createSpy();
clickSpy.and.callFake(function(event) { event.preventDefault(); }); clickSpy.and.callFake(function(event) { event.preventDefault(); });
this.view.$('.js-file-input').on('click', clickSpy); this.view.$('.js-file-input').on('click', clickSpy);
this.view.$('.file-drop-area').click(); this.view.$('.file-drop-area').click();
expect(clickSpy).toHaveBeenCalled(); expect(clickSpy).toHaveBeenCalled();
clickSpy.calls.reset(); clickSpy.calls.reset();
this.uploadButton.click(); });
expect(clickSpy).toHaveBeenCalled();
it('shows course video settings pane when course video settings button is clicked', function() {
$courseVideoSettingsButton.click();
expect(this.view.courseVideoSettingsView).toBeDefined();
expect(this.view.courseVideoSettingsView.$el.find('.course-video-settings-container')).toExist();
});
it('should not initiate course video settings view when video transcript is disabled', function() {
this.view = createActiveUploadListView(false);
$courseVideoSettingsButton.click();
expect(this.view.courseVideoSettingsView).toBeUndefined();
}); });
it('should not show a notification message if there are no active video uploads', function() { it('should not show a notification message if there are no active video uploads', function() {
...@@ -311,7 +340,7 @@ define( ...@@ -311,7 +340,7 @@ define(
'Your file could not be uploaded', 'Your file could not be uploaded',
StringUtils.interpolate( StringUtils.interpolate(
'{fileName} is not in a supported file format. Supported file formats are {supportedFormats}.', // eslint-disable-line max-len '{fileName} is not in a supported file format. Supported file formats are {supportedFormats}.', // eslint-disable-line max-len
{fileName: files[index].name, supportedFormats: self.videoSupportedFileFormats.join(' and ')} // eslint-disable-line max-len {fileName: files[index].name, supportedFormats: videoSupportedFileFormats.join(' and ')} // eslint-disable-line max-len
) )
); );
}); });
...@@ -344,7 +373,7 @@ define( ...@@ -344,7 +373,7 @@ define(
verifyUploadViewInfo( verifyUploadViewInfo(
uploadView, uploadView,
'Your file could not be uploaded', 'Your file could not be uploaded',
'file.mp4 exceeds maximum size of ' + this.videoUploadMaxFileSizeInGB + ' GB.' 'file.mp4 exceeds maximum size of ' + videoUploadMaxFileSizeInGB + ' GB.'
); );
verifyA11YMessage( verifyA11YMessage(
StringUtils.interpolate( StringUtils.interpolate(
...@@ -414,7 +443,7 @@ define( ...@@ -414,7 +443,7 @@ define(
expect(jasmine.Ajax.requests.count()).toEqual(caseInfo.numFiles); expect(jasmine.Ajax.requests.count()).toEqual(caseInfo.numFiles);
_.each(_.range(caseInfo.numFiles), function(index) { _.each(_.range(caseInfo.numFiles), function(index) {
request = jasmine.Ajax.requests.at(index); request = jasmine.Ajax.requests.at(index);
expect(request.url).toEqual(self.postUrl); expect(request.url).toEqual(POST_URL);
expect(request.method).toEqual('POST'); expect(request.method).toEqual('POST');
expect(request.requestHeaders['Content-Type']).toEqual('application/json'); expect(request.requestHeaders['Content-Type']).toEqual('application/json');
expect(request.requestHeaders.Accept).toContain('application/json'); expect(request.requestHeaders.Accept).toContain('application/json');
......
...@@ -5,12 +5,13 @@ define([ ...@@ -5,12 +5,13 @@ define([
'js/models/active_video_upload', 'js/models/active_video_upload',
'js/views/baseview', 'js/views/baseview',
'js/views/active_video_upload', 'js/views/active_video_upload',
'js/views/course_video_settings',
'edx-ui-toolkit/js/utils/html-utils', 'edx-ui-toolkit/js/utils/html-utils',
'edx-ui-toolkit/js/utils/string-utils', 'edx-ui-toolkit/js/utils/string-utils',
'text!templates/active-video-upload-list.underscore', 'text!templates/active-video-upload-list.underscore',
'jquery.fileupload' 'jquery.fileupload'
], ],
function($, _, Backbone, ActiveVideoUpload, BaseView, ActiveVideoUploadView, function($, _, Backbone, ActiveVideoUpload, BaseView, ActiveVideoUploadView, CourseVideoSettingsView,
HtmlUtils, StringUtils, activeVideoUploadListTemplate) { HtmlUtils, StringUtils, activeVideoUploadListTemplate) {
'use strict'; 'use strict';
var ActiveVideoUploadListView, var ActiveVideoUploadListView,
...@@ -40,11 +41,14 @@ define([ ...@@ -40,11 +41,14 @@ define([
this.listenTo(this.collection, 'add', this.addUpload); this.listenTo(this.collection, 'add', this.addUpload);
this.concurrentUploadLimit = options.concurrentUploadLimit || 0; this.concurrentUploadLimit = options.concurrentUploadLimit || 0;
this.postUrl = options.postUrl; this.postUrl = options.postUrl;
this.activeTranscriptPreferences = options.activeTranscriptPreferences;
this.videoTranscriptSettings = options.videoTranscriptSettings;
this.isVideoTranscriptEnabled = options.isVideoTranscriptEnabled;
this.videoSupportedFileFormats = options.videoSupportedFileFormats; this.videoSupportedFileFormats = options.videoSupportedFileFormats;
this.videoUploadMaxFileSizeInGB = options.videoUploadMaxFileSizeInGB; this.videoUploadMaxFileSizeInGB = options.videoUploadMaxFileSizeInGB;
this.onFileUploadDone = options.onFileUploadDone; this.onFileUploadDone = options.onFileUploadDone;
if (options.uploadButton) { if (options.courseVideoSettingsButton) {
options.uploadButton.click(this.chooseFile.bind(this)); options.courseVideoSettingsButton.click(this.showCourseVideoSettingsView.bind(this));
} }
this.maxSizeText = StringUtils.interpolate( this.maxSizeText = StringUtils.interpolate(
...@@ -59,6 +63,37 @@ define([ ...@@ -59,6 +63,37 @@ define([
supportedVideoTypes: this.videoSupportedFileFormats.join(', ') supportedVideoTypes: this.videoSupportedFileFormats.join(', ')
} }
); );
if (this.isVideoTranscriptEnabled) {
this.listenTo(
Backbone,
'coursevideosettings:syncActiveTranscriptPreferences',
this.syncActiveTranscriptPreferences
);
this.listenTo(
Backbone,
'coursevideosettings:destroyCourseVideoSettingsView',
this.destroyCourseVideoSettingsView
);
}
},
syncActiveTranscriptPreferences: function(activeTranscriptPreferences) {
this.activeTranscriptPreferences = activeTranscriptPreferences;
},
showCourseVideoSettingsView: function(event) {
if (this.isVideoTranscriptEnabled) {
this.courseVideoSettingsView = new CourseVideoSettingsView({
activeTranscriptPreferences: this.activeTranscriptPreferences,
videoTranscriptSettings: this.videoTranscriptSettings
});
this.courseVideoSettingsView.render();
event.stopPropagation();
}
},
destroyCourseVideoSettingsView: function() {
this.courseVideoSettingsView = null;
}, },
render: function() { render: function() {
...@@ -98,7 +133,6 @@ define([ ...@@ -98,7 +133,6 @@ define([
$(window).on('drop', preventDefault); $(window).on('drop', preventDefault);
$(window).on('beforeunload', this.onBeforeUnload.bind(this)); $(window).on('beforeunload', this.onBeforeUnload.bind(this));
$(window).on('unload', this.onUnload.bind(this)); $(window).on('unload', this.onUnload.bind(this));
return this; return this;
}, },
......
...@@ -83,6 +83,8 @@ $gray-d1: shade($gray, 20%) !default; ...@@ -83,6 +83,8 @@ $gray-d1: shade($gray, 20%) !default;
$gray-d2: shade($gray, 40%) !default; $gray-d2: shade($gray, 40%) !default;
$gray-d3: shade($gray, 60%) !default; $gray-d3: shade($gray, 60%) !default;
$gray-d4: shade($gray, 80%) !default; $gray-d4: shade($gray, 80%) !default;
$gray-u1: #ECF0F1;
// These define button styles similar to LMS // These define button styles similar to LMS
// The goal here is consistency (until we can overhaul all of this...) // The goal here is consistency (until we can overhaul all of this...)
...@@ -302,3 +304,5 @@ $state-warning-border: darken($state-warning-bg, 5%) !default; ...@@ -302,3 +304,5 @@ $state-warning-border: darken($state-warning-bg, 5%) !default;
$state-danger-text: $black !default; $state-danger-text: $black !default;
$state-danger-bg: #f2dede !default; $state-danger-bg: #f2dede !default;
$state-danger-border: darken($state-danger-bg, 5%) !default; $state-danger-border: darken($state-danger-bg, 5%) !default;
$text-dark-black-blue: #2C3E50;
...@@ -12,6 +12,173 @@ ...@@ -12,6 +12,173 @@
} }
} }
.fixed-container {
position: fixed !important;
top: 0 !important;
}
.course-video-settings-container {
position: absolute;
overflow: scroll;
top: 0;
right: -100%;
z-index: 1000;
width: 352px;
transition: all 0.3s ease;
background-color: $white;
-webkit-box-shadow: -3px 0px 3px 0px rgba(153,153,153,0.3);
-moz-box-shadow: -3px 0px 3px 0px rgba(153,153,153,0.3);
box-shadow: -3px 0px 3px 0px rgba(153,153,153,0.3);
.button-link {
background:none;
border:none;
padding:0;
color: $ui-link-color;
cursor:pointer
}
.action-close-wrapper {
.action-close-course-video-settings {
width: 100%;
padding: ($baseline/2) ($baseline*0.8);
background-color: $gray-u1;
border: transparent;
height: ($baseline*2.4);
color: $text-dark-black-blue;
@include font-size(16);
@include text-align(left);
}
}
.course-video-settings-wrapper {
margin-top: ($baseline*1.60);
padding: ($baseline) ($baseline*0.8);
.course-video-settings-title {
color: $black-t4;
margin: ($baseline*1.6) 0 ($baseline*0.8) 0;
font-weight: font-weight(semi-bold);
@include font-size(24);
}
.course-video-settings-message {
padding: ($baseline/2);
margin-bottom: ($baseline*0.8);
max-height: ($baseline*2.4);
color: $black;
@include font-size(16);
.icon {
@include margin-right($baseline/4);
}
}
.course-video-settings-message-wrapper.success .course-video-settings-message {
background-color: $state-success-bg;
border: solid 1px $state-success-border;
}
.course-video-settings-message-wrapper.error .course-video-settings-message {
background-color: $state-danger-bg;
border: solid 1px $state-danger-border;
}
.transcript-preferance-wrapper {
margin-top: ($baseline*1.6);
.icon.fa-info-circle {
@include margin-left($baseline*0.75);
}
}
.transcript-preferance-wrapper.error .transcript-preferance-label {
color: $color-error;
}
.error-info, .error-icon .fa-info-circle {
color: $color-error;
}
.error-info {
@include font-size(16);
}
.transcript-preferance-label {
color: $black-t4;
@include font-size(15);
font-weight: font-weight(semi-bold);
display: block;
}
.transcript-provider-group, .transcript-turnaround, .transcript-fidelity, .video-source-language {
margin-top: ($baseline*0.8);
}
.transcript-provider-group {
input[type=radio] {
margin: 0 ($baseline/2);
}
label {
font-weight: normal;
color: $black-t4;
@include font-size(15);
}
}
.transcript-turnaround-wrapper, .transcript-fidelity-wrapper, .video-source-language-wrapper, .transcript-languages-wrapper {
display: none;
}
.transcript-languages-container .languages-container {
margin-top: ($baseline*0.8);
.transcript-language-container {
padding: ($baseline/4);
background-color: $gray-l6;
border-top: solid 1px $gray-l4;
border-bottom: solid 1px $gray-l4;
.remove-language-action {
display: inline-block;
@include float(right);
}
}
}
.transcript-language-menu-container {
margin-top: ($baseline*0.8);
.add-language-action {
display: inline-block;
.action-add-language {
@include margin-left($baseline/4);
}
}
}
.transcript-language-menu, .video-source-language {
width: 60%;
}
}
.course-video-settings-footer {
margin-top: ($baseline*1.6);
.last-updated-text {
@include font-size(12);
@include margin-left($baseline/4);
}
}
.button {
@extend %btn-primary-blue;
@extend %sizing;
.action-button-text {
display: inline-block;
vertical-align: baseline;
}
.icon {
display: inline-block;
vertical-align: baseline;
}
}
}
.file-upload-form { .file-upload-form {
@include clearfix(); @include clearfix();
......
<div class='course-video-settings-container'>
<div class="course-video-settings-header">
<div class="action-close-wrapper">
<button class="action-close-course-video-settings">
<span class="icon fa fa-times" aria-hidden="true"></span>
<%-gettext('Close') %>
<span class='sr'><%-gettext('Press close to hide course video settings') %></span>
</button>
</div>
</div>
<div class='course-video-settings-wrapper'>
<div class='course-video-settings-message-wrapper'></div>
<span class="course-video-settings-title"><%- gettext('Course Video Settings') %></span>
<div class='transcript-preferance-wrapper transcript-provider-wrapper'>
<label class='transcript-preferance-label' for='transcript-provider'><%- gettext('Transcript Provider') %><span class='error-icon' aria-hidden="true"></span></label>
<div class='transcript-provider-group' id='transcript-provider'></div>
<span class='error-info' aria-hidden="true"></span>
</div>
<div class='transcript-preferance-wrapper transcript-turnaround-wrapper'>
<label class='transcript-preferance-label' for='transcript-turnaround'><%- gettext('Transcript Turnaround') %><span class='error-icon' aria-hidden="true"></span></label>
<select id='transcript-turnaround' class='transcript-turnaround'></select>
<span class='error-info' aria-hidden="true"></span>
</div>
<div class='transcript-preferance-wrapper transcript-fidelity-wrapper'>
<label class='transcript-preferance-label' for='transcript-fidelity'><%- gettext('Transcript Fidelity') %><span class='error-icon' aria-hidden="true"></span></label>
<select id='transcript-fidelity' class='transcript-fidelity'></select>
<span class='error-info' aria-hidden="true"></span>
</div>
<div class='transcript-preferance-wrapper video-source-language-wrapper'>
<label class='transcript-preferance-label' for='video-source-language'><%- gettext('Video Source Language') %><span class='error-icon' aria-hidden="true"></span></label>
<select id='video-source-language' class='video-source-language' aria-labelledby="video-source-language-none"></select>
<span class='error-info' aria-hidden="true"></span>
</div>
<div class='transcript-preferance-wrapper transcript-languages-wrapper'>
<span class='transcript-preferance-label'><%- gettext('Transcript Languages') %><span class='error-icon' aria-hidden="true"></span></span>
<div class='transcript-languages-container'>
<div class='languages-container'></div>
<div class="transcript-language-menu-container">
<select class="transcript-language-menu" id="transcript-language" aria-labelledby="transcript-language-none"></select>
<div class="add-language-action">
<button class="button-link action-add-language"><%- gettext('Add') %><span class="sr"><%- gettext('Press Add to language') %></span></button>
<span class="error-info" aria-hidden="true"></span>
</div>
</div>
</div>
</div>
<div class='course-video-settings-footer'>
<button class="button button action-update-course-video-settings" aria-describedby='update-button-text'>
<%- gettext('Update Settings') %>
<span id='update-button-text' class='sr'><%-gettext('Press update settings to update course video settings') %></span>
</button>
<span class='last-updated-text'>
<%if (dateModified) { %>
<%- gettext('Last updated')%> <%- dateModified %>
<% } %>
</span>
</div>
</div>
</div>
...@@ -34,10 +34,13 @@ ...@@ -34,10 +34,13 @@
"${encodings_download_url | n, js_escaped_string}", "${encodings_download_url | n, js_escaped_string}",
"${default_video_image_url | n, js_escaped_string}", "${default_video_image_url | n, js_escaped_string}",
${concurrent_upload_limit | n, dump_js_escaped_json}, ${concurrent_upload_limit | n, dump_js_escaped_json},
$(".nav-actions .upload-button"), $(".nav-actions .course-video-settings-button"),
$contentWrapper.data("previous-uploads"), $contentWrapper.data("previous-uploads"),
${video_supported_file_formats | n, dump_js_escaped_json}, ${video_supported_file_formats | n, dump_js_escaped_json},
${video_upload_max_file_size | n, dump_js_escaped_json}, ${video_upload_max_file_size | n, dump_js_escaped_json},
${active_transcript_preferences | n, dump_js_escaped_json},
${video_transcript_settings | n, dump_js_escaped_json},
${is_video_transcript_enabled | n, dump_js_escaped_json},
${video_image_settings | n, dump_js_escaped_json} ${video_image_settings | n, dump_js_escaped_json}
); );
}); });
...@@ -46,20 +49,21 @@ ...@@ -46,20 +49,21 @@
<%block name="content"> <%block name="content">
<div class="wrapper-mast wrapper"> <div class="wrapper-mast wrapper">
<div class="video-transcript-settings-wrapper"></div>
<header class="mast has-actions has-subtitle"> <header class="mast has-actions has-subtitle">
<h1 class="page-header"> <h1 class="page-header">
<small class="subtitle">${_("Content")}</small> <small class="subtitle">${_("Content")}</small>
<span class="sr">&gt; </span>${_("Video Uploads")} <span class="sr">&gt; </span>${_("Video Uploads")}
</h1> </h1>
% if is_video_transcript_enabled :
<nav class="nav-actions" aria-label="${_('Page Actions')}"> <nav class="nav-actions" aria-label="${_('Page Actions')}">
<h3 class="sr">${_("Page Actions")}</h3> <h3 class="sr">${_("Page Actions")}</h3>
<ul> <div class="nav-item">
<li class="nav-item"> <button class="button course-video-settings-button"><span class="icon fa fa-cog" aria-hidden="true"></span> ${_("Course Video Settings")}</button>
<a href="#" class="button upload-button new-button"><span class="icon fa fa-plus" aria-hidden="true"></span> ${_("Upload New File")}</a> </div>
</li>
</ul>
</nav> </nav>
% endif
</header> </header>
</div> </div>
......
...@@ -128,6 +128,7 @@ urlpatterns += patterns( ...@@ -128,6 +128,7 @@ urlpatterns += patterns(
url(r'^textbooks/{}/(?P<textbook_id>\d[^/]*)$'.format(settings.COURSE_KEY_PATTERN), 'textbooks_detail_handler'), url(r'^textbooks/{}/(?P<textbook_id>\d[^/]*)$'.format(settings.COURSE_KEY_PATTERN), 'textbooks_detail_handler'),
url(r'^videos/{}(?:/(?P<edx_video_id>[-\w]+))?$'.format(settings.COURSE_KEY_PATTERN), 'videos_handler'), url(r'^videos/{}(?:/(?P<edx_video_id>[-\w]+))?$'.format(settings.COURSE_KEY_PATTERN), 'videos_handler'),
url(r'^video_images/{}(?:/(?P<edx_video_id>[-\w]+))?$'.format(settings.COURSE_KEY_PATTERN), 'video_images_handler'), url(r'^video_images/{}(?:/(?P<edx_video_id>[-\w]+))?$'.format(settings.COURSE_KEY_PATTERN), 'video_images_handler'),
url(r'^transcript_preferences/{}$'.format(settings.COURSE_KEY_PATTERN), 'transcript_preferences_handler'),
url(r'^video_encodings_download/{}$'.format(settings.COURSE_KEY_PATTERN), 'video_encodings_download'), url(r'^video_encodings_download/{}$'.format(settings.COURSE_KEY_PATTERN), 'video_encodings_download'),
url(r'^group_configurations/{}$'.format(settings.COURSE_KEY_PATTERN), 'group_configurations_list_handler'), url(r'^group_configurations/{}$'.format(settings.COURSE_KEY_PATTERN), 'group_configurations_list_handler'),
url(r'^group_configurations/{}/(?P<group_configuration_id>\d+)(/)?(?P<group_id>\d+)?$'.format( url(r'^group_configurations/{}/(?P<group_configuration_id>\d+)(/)?(?P<group_id>\d+)?$'.format(
...@@ -210,6 +211,11 @@ if settings.DEBUG: ...@@ -210,6 +211,11 @@ if settings.DEBUG:
document_root=settings.VIDEO_IMAGE_SETTINGS['STORAGE_KWARGS']['location'] document_root=settings.VIDEO_IMAGE_SETTINGS['STORAGE_KWARGS']['location']
) )
urlpatterns += static(
settings.VIDEO_TRANSCRIPTS_SETTINGS['STORAGE_KWARGS']['base_url'],
document_root=settings.VIDEO_TRANSCRIPTS_SETTINGS['STORAGE_KWARGS']['location']
)
if 'debug_toolbar' in settings.INSTALLED_APPS: if 'debug_toolbar' in settings.INSTALLED_APPS:
import debug_toolbar import debug_toolbar
urlpatterns += ( urlpatterns += (
......
...@@ -55,6 +55,7 @@ class CrossStoreXMLRoundtrip(CourseComparisonTest, PartitionTestCase): ...@@ -55,6 +55,7 @@ class CrossStoreXMLRoundtrip(CourseComparisonTest, PartitionTestCase):
self.export_dir = mkdtemp() self.export_dir = mkdtemp()
self.addCleanup(rmtree, self.export_dir, ignore_errors=True) self.addCleanup(rmtree, self.export_dir, ignore_errors=True)
@patch('xmodule.video_module.video_module.edxval_api', None)
@patch('xmodule.tabs.CourseTab.from_json', side_effect=mock_tab_from_json) @patch('xmodule.tabs.CourseTab.from_json', side_effect=mock_tab_from_json)
@ddt.data(*itertools.product( @ddt.data(*itertools.product(
MODULESTORE_SETUPS, MODULESTORE_SETUPS,
......
...@@ -557,6 +557,7 @@ class TestMongoModuleStore(TestMongoModuleStoreBase): ...@@ -557,6 +557,7 @@ class TestMongoModuleStore(TestMongoModuleStoreBase):
check_xblock_fields() check_xblock_fields()
check_mongo_fields() check_mongo_fields()
@patch('xmodule.video_module.video_module.edxval_api', None)
@patch('xmodule.tabs.CourseTab.from_json', side_effect=mock_tab_from_json) @patch('xmodule.tabs.CourseTab.from_json', side_effect=mock_tab_from_json)
def test_export_course_image(self, _from_json): def test_export_course_image(self, _from_json):
""" """
...@@ -575,6 +576,7 @@ class TestMongoModuleStore(TestMongoModuleStoreBase): ...@@ -575,6 +576,7 @@ class TestMongoModuleStore(TestMongoModuleStoreBase):
self.assertTrue(path(root_dir / 'test_export/static/images/course_image.jpg').isfile()) self.assertTrue(path(root_dir / 'test_export/static/images/course_image.jpg').isfile())
self.assertTrue(path(root_dir / 'test_export/static/images_course_image.jpg').isfile()) self.assertTrue(path(root_dir / 'test_export/static/images_course_image.jpg').isfile())
@patch('xmodule.video_module.video_module.edxval_api', None)
@patch('xmodule.tabs.CourseTab.from_json', side_effect=mock_tab_from_json) @patch('xmodule.tabs.CourseTab.from_json', side_effect=mock_tab_from_json)
def test_export_course_image_nondefault(self, _from_json): def test_export_course_image_nondefault(self, _from_json):
""" """
...@@ -590,6 +592,7 @@ class TestMongoModuleStore(TestMongoModuleStoreBase): ...@@ -590,6 +592,7 @@ class TestMongoModuleStore(TestMongoModuleStoreBase):
self.assertTrue(path(root_dir / 'test_export/static/just_a_test.jpg').isfile()) self.assertTrue(path(root_dir / 'test_export/static/just_a_test.jpg').isfile())
self.assertFalse(path(root_dir / 'test_export/static/images/course_image.jpg').isfile()) self.assertFalse(path(root_dir / 'test_export/static/images/course_image.jpg').isfile())
@patch('xmodule.video_module.video_module.edxval_api', None)
def test_course_without_image(self): def test_course_without_image(self):
""" """
Make sure we elegantly passover our code when there isn't a static Make sure we elegantly passover our code when there isn't a static
......
...@@ -68,6 +68,7 @@ class RoundTripTestCase(unittest.TestCase): ...@@ -68,6 +68,7 @@ class RoundTripTestCase(unittest.TestCase):
self.temp_dir = mkdtemp() self.temp_dir = mkdtemp()
self.addCleanup(shutil.rmtree, self.temp_dir) self.addCleanup(shutil.rmtree, self.temp_dir)
@mock.patch('xmodule.video_module.video_module.edxval_api', None)
@mock.patch('xmodule.course_module.requests.get') @mock.patch('xmodule.course_module.requests.get')
@ddt.data( @ddt.data(
"toy", "toy",
......
...@@ -646,7 +646,11 @@ class VideoDescriptorImportTestCase(unittest.TestCase): ...@@ -646,7 +646,11 @@ class VideoDescriptorImportTestCase(unittest.TestCase):
video = VideoDescriptor.from_xml(xml_data, module_system, id_generator) video = VideoDescriptor.from_xml(xml_data, module_system, id_generator)
self.assert_attributes_equal(video, {'edx_video_id': 'test_edx_video_id'}) self.assert_attributes_equal(video, {'edx_video_id': 'test_edx_video_id'})
mock_val_api.import_from_xml.assert_called_once_with(ANY, 'test_edx_video_id', course_id='test_course_id') mock_val_api.import_from_xml.assert_called_once_with(
ANY,
'test_edx_video_id',
course_id='test_course_id'
)
@patch('xmodule.video_module.video_module.edxval_api') @patch('xmodule.video_module.video_module.edxval_api')
def test_import_val_data_invalid(self, mock_val_api): def test_import_val_data_invalid(self, mock_val_api):
...@@ -673,14 +677,7 @@ class VideoExportTestCase(VideoDescriptorTestBase): ...@@ -673,14 +677,7 @@ class VideoExportTestCase(VideoDescriptorTestBase):
""" """
Test that we write the correct XML on export. Test that we write the correct XML on export.
""" """
def mock_val_export(edx_video_id, course_id): mock_val_api.export_to_xml = Mock(return_value=etree.Element('video_asset'))
"""Mock edxval.api.export_to_xml"""
return etree.Element(
'video_asset',
attrib={'export_edx_video_id': edx_video_id}
)
mock_val_api.export_to_xml = mock_val_export
self.descriptor.youtube_id_0_75 = 'izygArpw-Qo' self.descriptor.youtube_id_0_75 = 'izygArpw-Qo'
self.descriptor.youtube_id_1_0 = 'p2Q6BrNhdh8' self.descriptor.youtube_id_1_0 = 'p2Q6BrNhdh8'
self.descriptor.youtube_id_1_25 = '1EeWXzPdhSA' self.descriptor.youtube_id_1_25 = '1EeWXzPdhSA'
...@@ -691,7 +688,7 @@ class VideoExportTestCase(VideoDescriptorTestBase): ...@@ -691,7 +688,7 @@ class VideoExportTestCase(VideoDescriptorTestBase):
self.descriptor.track = 'http://www.example.com/track' self.descriptor.track = 'http://www.example.com/track'
self.descriptor.handout = 'http://www.example.com/handout' self.descriptor.handout = 'http://www.example.com/handout'
self.descriptor.download_track = True self.descriptor.download_track = True
self.descriptor.html5_sources = ['http://www.example.com/source.mp4', 'http://www.example.com/source.ogg'] self.descriptor.html5_sources = ['http://www.example.com/source.mp4', 'http://www.example.com/source1.ogg']
self.descriptor.download_video = True self.descriptor.download_video = True
self.descriptor.transcripts = {'ua': 'ukrainian_translation.srt', 'ge': 'german_translation.srt'} self.descriptor.transcripts = {'ua': 'ukrainian_translation.srt', 'ge': 'german_translation.srt'}
self.descriptor.edx_video_id = 'test_edx_video_id' self.descriptor.edx_video_id = 'test_edx_video_id'
...@@ -702,16 +699,21 @@ class VideoExportTestCase(VideoDescriptorTestBase): ...@@ -702,16 +699,21 @@ class VideoExportTestCase(VideoDescriptorTestBase):
xml_string = '''\ xml_string = '''\
<video url_name="SampleProblem" 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_video="true" download_track="true"> <video url_name="SampleProblem" 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_video="true" download_track="true">
<source src="http://www.example.com/source.mp4"/> <source src="http://www.example.com/source.mp4"/>
<source src="http://www.example.com/source.ogg"/> <source src="http://www.example.com/source1.ogg"/>
<track src="http://www.example.com/track"/> <track src="http://www.example.com/track"/>
<handout src="http://www.example.com/handout"/> <handout src="http://www.example.com/handout"/>
<transcript language="ge" src="german_translation.srt" /> <transcript language="ge" src="german_translation.srt" />
<transcript language="ua" src="ukrainian_translation.srt" /> <transcript language="ua" src="ukrainian_translation.srt" />
<video_asset export_edx_video_id="test_edx_video_id"/> <video_asset />
</video> </video>
''' '''
expected = etree.XML(xml_string, parser=parser) expected = etree.XML(xml_string, parser=parser)
self.assertXmlEqual(expected, xml) self.assertXmlEqual(expected, xml)
mock_val_api.export_to_xml.assert_called_once_with(
[u'test_edx_video_id', u'p2Q6BrNhdh8', 'source', 'source1'],
ANY,
external=False
)
@patch('xmodule.video_module.video_module.edxval_api') @patch('xmodule.video_module.video_module.edxval_api')
def test_export_to_xml_val_error(self, mock_val_api): def test_export_to_xml_val_error(self, mock_val_api):
...@@ -727,6 +729,7 @@ class VideoExportTestCase(VideoDescriptorTestBase): ...@@ -727,6 +729,7 @@ class VideoExportTestCase(VideoDescriptorTestBase):
expected = etree.XML(xml_string, parser=parser) expected = etree.XML(xml_string, parser=parser)
self.assertXmlEqual(expected, xml) self.assertXmlEqual(expected, xml)
@patch('xmodule.video_module.video_module.edxval_api', None)
def test_export_to_xml_empty_end_time(self): def test_export_to_xml_empty_end_time(self):
""" """
Test that we write the correct XML on export. Test that we write the correct XML on export.
...@@ -755,6 +758,7 @@ class VideoExportTestCase(VideoDescriptorTestBase): ...@@ -755,6 +758,7 @@ class VideoExportTestCase(VideoDescriptorTestBase):
expected = etree.XML(xml_string, parser=parser) expected = etree.XML(xml_string, parser=parser)
self.assertXmlEqual(expected, xml) self.assertXmlEqual(expected, xml)
@patch('xmodule.video_module.video_module.edxval_api', None)
def test_export_to_xml_empty_parameters(self): def test_export_to_xml_empty_parameters(self):
""" """
Test XML export with defaults. Test XML export with defaults.
...@@ -764,6 +768,7 @@ class VideoExportTestCase(VideoDescriptorTestBase): ...@@ -764,6 +768,7 @@ class VideoExportTestCase(VideoDescriptorTestBase):
expected = '<video url_name="SampleProblem" download_video="false"/>\n' expected = '<video url_name="SampleProblem" download_video="false"/>\n'
self.assertEquals(expected, etree.tostring(xml, pretty_print=True)) self.assertEquals(expected, etree.tostring(xml, pretty_print=True))
@patch('xmodule.video_module.video_module.edxval_api', None)
def test_export_to_xml_with_transcripts_as_none(self): def test_export_to_xml_with_transcripts_as_none(self):
""" """
Test XML export with transcripts being overridden to None. Test XML export with transcripts being overridden to None.
...@@ -773,6 +778,7 @@ class VideoExportTestCase(VideoDescriptorTestBase): ...@@ -773,6 +778,7 @@ class VideoExportTestCase(VideoDescriptorTestBase):
expected = '<video url_name="SampleProblem" download_video="false"/>\n' expected = '<video url_name="SampleProblem" download_video="false"/>\n'
self.assertEquals(expected, etree.tostring(xml, pretty_print=True)) self.assertEquals(expected, etree.tostring(xml, pretty_print=True))
@patch('xmodule.video_module.video_module.edxval_api', None)
def test_export_to_xml_invalid_characters_in_attributes(self): def test_export_to_xml_invalid_characters_in_attributes(self):
""" """
Test XML export will *not* raise TypeError by lxml library if contains illegal characters. Test XML export will *not* raise TypeError by lxml library if contains illegal characters.
...@@ -782,6 +788,7 @@ class VideoExportTestCase(VideoDescriptorTestBase): ...@@ -782,6 +788,7 @@ class VideoExportTestCase(VideoDescriptorTestBase):
xml = self.descriptor.definition_to_xml(None) xml = self.descriptor.definition_to_xml(None)
self.assertEqual(xml.get('display_name'), 'DisplayName') self.assertEqual(xml.get('display_name'), 'DisplayName')
@patch('xmodule.video_module.video_module.edxval_api', None)
def test_export_to_xml_unicode_characters(self): def test_export_to_xml_unicode_characters(self):
""" """
Test XML export handles the unicode characters. Test XML export handles the unicode characters.
......
...@@ -12,15 +12,23 @@ from pysrt import SubRipTime, SubRipItem, SubRipFile ...@@ -12,15 +12,23 @@ from pysrt import SubRipTime, SubRipItem, SubRipFile
from lxml import etree from lxml import etree
from HTMLParser import HTMLParser from HTMLParser import HTMLParser
from openedx.core.djangoapps.video_config.models import VideoTranscriptEnabledFlag
from xmodule.exceptions import NotFoundError from xmodule.exceptions import NotFoundError
from xmodule.contentstore.content import StaticContent from xmodule.contentstore.content import StaticContent
from xmodule.contentstore.django import contentstore from xmodule.contentstore.django import contentstore
from .bumper_utils import get_bumper_settings from .bumper_utils import get_bumper_settings
try:
from edxval import api as edxval_api
except ImportError:
edxval_api = None
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
NON_EXISTENT_TRANSCRIPT = 'non_existent_dummy_file_name'
class TranscriptException(Exception): # pylint: disable=missing-docstring class TranscriptException(Exception): # pylint: disable=missing-docstring
pass pass
...@@ -469,6 +477,80 @@ def get_or_create_sjson(item, transcripts): ...@@ -469,6 +477,80 @@ def get_or_create_sjson(item, transcripts):
return sjson_transcript return sjson_transcript
def get_video_ids_info(edx_video_id, youtube_id_1_0, html5_sources):
"""
Returns list internal or external video ids.
Arguments:
edx_video_id (unicode): edx_video_id
youtube_id_1_0 (unicode): youtube id
html5_sources (list): html5 video ids
Returns:
tuple: external or internal, video ids list
"""
clean = lambda item: item.strip() if isinstance(item, basestring) else item
external = not bool(clean(edx_video_id))
video_ids = [edx_video_id, youtube_id_1_0] + get_html5_ids(html5_sources)
# video_ids cleanup
video_ids = filter(lambda item: bool(clean(item)), video_ids)
return external, video_ids
def is_val_transcript_feature_enabled_for_course(course_id):
"""
Get edx-val transcript feature flag
Arguments:
course_id(CourseKey): Course key identifying a course whose feature flag is being inspected.
"""
return VideoTranscriptEnabledFlag.feature_enabled(course_id=course_id)
def get_video_transcript_content(language_code, edx_video_id, youtube_id_1_0, html5_sources):
"""
Gets video transcript content, only if the corresponding feature flag is enabled for the given `course_id`.
Arguments:
language_code(unicode): Language code of the requested transcript
edx_video_id(unicode): edx-val's video identifier
youtube_id_1_0(unicode): A youtube source identifier
html5_sources(list): A list containing html5 sources
Returns:
A dict containing transcript's file name and its sjson content.
"""
transcript = None
if edxval_api:
__, video_candidate_ids = get_video_ids_info(edx_video_id, youtube_id_1_0, html5_sources)
transcript = edxval_api.get_video_transcript_data(video_candidate_ids, language_code)
return transcript
def get_available_transcript_languages(edx_video_id, youtube_id_1_0, html5_sources):
"""
Gets available transcript languages from edx-val.
Arguments:
edx_video_id(unicode): edx-val's video identifier
youtube_id_1_0(unicode): A youtube source identifier
html5_sources(list): A list containing html5 sources
Returns:
A list containing distinct transcript language codes against all the passed video ids.
"""
available_languages = []
if edxval_api:
__, video_candidate_ids = get_video_ids_info(edx_video_id, youtube_id_1_0, html5_sources)
available_languages = edxval_api.get_available_transcript_languages(video_candidate_ids)
return available_languages
class Transcript(object): class Transcript(object):
""" """
Container for transcript methods. Container for transcript methods.
...@@ -518,6 +600,13 @@ class Transcript(object): ...@@ -518,6 +600,13 @@ class Transcript(object):
`location` is module location. `location` is module location.
""" """
# HACK Warning! this is temporary and will be removed once edx-val take over the
# transcript module and contentstore will only function as fallback until all the
# data is migrated to edx-val. It will be saving a contentstore hit for a hardcoded
# dummy-non-existent-transcript name.
if NON_EXISTENT_TRANSCRIPT in [subs_id, filename]:
raise NotFoundError
asset_filename = subs_filename(subs_id, lang) if not filename else filename asset_filename = subs_filename(subs_id, lang) if not filename else filename
return Transcript.get_asset(location, asset_filename) return Transcript.get_asset(location, asset_filename)
...@@ -557,10 +646,11 @@ class VideoTranscriptsMixin(object): ...@@ -557,10 +646,11 @@ class VideoTranscriptsMixin(object):
This is necessary for both VideoModule and VideoDescriptor. This is necessary for both VideoModule and VideoDescriptor.
""" """
def available_translations(self, transcripts, verify_assets=None): def available_translations(self, transcripts, verify_assets=None, include_val_transcripts=None):
"""Return a list of language codes for which we have transcripts. """
Return a list of language codes for which we have transcripts.
Args: Arguments:
verify_assets (boolean): If True, checks to ensure that the transcripts verify_assets (boolean): If True, checks to ensure that the transcripts
really exist in the contentstore. If False, we just look at the really exist in the contentstore. If False, we just look at the
VideoDescriptor fields and do not query the contentstore. One reason VideoDescriptor fields and do not query the contentstore. One reason
...@@ -570,8 +660,7 @@ class VideoTranscriptsMixin(object): ...@@ -570,8 +660,7 @@ class VideoTranscriptsMixin(object):
Defaults to `not FALLBACK_TO_ENGLISH_TRANSCRIPTS`. Defaults to `not FALLBACK_TO_ENGLISH_TRANSCRIPTS`.
transcripts (dict): A dict with all transcripts and a sub. transcripts (dict): A dict with all transcripts and a sub.
include_val_transcripts(boolean): If True, adds the edx-val transcript languages as well.
Defaults to False
""" """
translations = [] translations = []
if verify_assets is None: if verify_assets is None:
...@@ -588,7 +677,14 @@ class VideoTranscriptsMixin(object): ...@@ -588,7 +677,14 @@ class VideoTranscriptsMixin(object):
return translations return translations
# If we've gotten this far, we're going to verify that the transcripts # If we've gotten this far, we're going to verify that the transcripts
# being referenced are actually in the contentstore. # being referenced are actually either in the contentstore or in edx-val.
if include_val_transcripts:
translations = get_available_transcript_languages(
edx_video_id=self.edx_video_id,
youtube_id_1_0=self.youtube_id_1_0,
html5_sources=self.html5_sources
)
if sub: # check if sjson exists for 'en'. if sub: # check if sjson exists for 'en'.
try: try:
Transcript.asset(self.location, sub, 'en') Transcript.asset(self.location, sub, 'en')
...@@ -598,18 +694,20 @@ class VideoTranscriptsMixin(object): ...@@ -598,18 +694,20 @@ class VideoTranscriptsMixin(object):
except NotFoundError: except NotFoundError:
pass pass
else: else:
translations += ['en'] translations.append('en')
else: else:
translations += ['en'] translations.append('en')
for lang in other_langs: for lang in other_langs:
try: try:
Transcript.asset(self.location, None, None, other_langs[lang]) Transcript.asset(self.location, None, None, other_langs[lang])
except NotFoundError: except NotFoundError:
continue continue
translations += [lang]
return translations translations.append(lang)
# to clean redundant language codes.
return list(set(translations))
def get_transcript(self, transcripts, transcript_format='srt', lang=None): def get_transcript(self, transcripts, transcript_format='srt', lang=None):
""" """
...@@ -672,9 +770,13 @@ class VideoTranscriptsMixin(object): ...@@ -672,9 +770,13 @@ class VideoTranscriptsMixin(object):
transcript_language = u'en' transcript_language = u'en'
return transcript_language return transcript_language
def get_transcripts_info(self, is_bumper=False): def get_transcripts_info(self, is_bumper=False, include_val_transcripts=False):
""" """
Returns a transcript dictionary for the video. Returns a transcript dictionary for the video.
Arguments:
is_bumper(bool): If True, the request is for the bumper transcripts
include_val_transcripts(bool): If True, include edx-val transcripts as well
""" """
if is_bumper: if is_bumper:
transcripts = copy.deepcopy(get_bumper_settings(self).get('transcripts', {})) transcripts = copy.deepcopy(get_bumper_settings(self).get('transcripts', {}))
...@@ -688,6 +790,24 @@ class VideoTranscriptsMixin(object): ...@@ -688,6 +790,24 @@ class VideoTranscriptsMixin(object):
language_code: transcript_file language_code: transcript_file
for language_code, transcript_file in transcripts.items() if transcript_file != '' for language_code, transcript_file in transcripts.items() if transcript_file != ''
} }
# For phase 2, removing `include_val_transcripts` will make edx-val
# taking over the control for transcripts.
if include_val_transcripts:
transcript_languages = get_available_transcript_languages(
edx_video_id=self.edx_video_id,
youtube_id_1_0=self.youtube_id_1_0,
html5_sources=self.html5_sources
)
# HACK Warning! this is temporary and will be removed once edx-val take over the
# transcript module and contentstore will only function as fallback until all the
# data is migrated to edx-val.
for language_code in transcript_languages:
if language_code == 'en' and not sub:
sub = NON_EXISTENT_TRANSCRIPT
elif not transcripts.get(language_code):
transcripts[language_code] = NON_EXISTENT_TRANSCRIPT
return { return {
"sub": sub, "sub": sub,
"transcripts": transcripts, "transcripts": transcripts,
......
...@@ -7,6 +7,8 @@ StudioViewHandlers are handlers for video descriptor instance. ...@@ -7,6 +7,8 @@ StudioViewHandlers are handlers for video descriptor instance.
import json import json
import logging import logging
import os
from datetime import datetime from datetime import datetime
from webob import Response from webob import Response
...@@ -18,13 +20,15 @@ from opaque_keys.edx.locator import CourseLocator ...@@ -18,13 +20,15 @@ from opaque_keys.edx.locator import CourseLocator
from .transcripts_utils import ( from .transcripts_utils import (
get_or_create_sjson, get_or_create_sjson,
generate_sjson_for_all_speeds,
get_video_transcript_content,
is_val_transcript_feature_enabled_for_course,
save_to_store,
subs_filename,
Transcript,
TranscriptException, TranscriptException,
TranscriptsGenerationException, TranscriptsGenerationException,
generate_sjson_for_all_speeds,
youtube_speed_dict, youtube_speed_dict,
Transcript,
save_to_store,
subs_filename
) )
...@@ -221,7 +225,8 @@ class VideoStudentViewHandlers(object): ...@@ -221,7 +225,8 @@ class VideoStudentViewHandlers(object):
For 'en' check if SJSON exists. For non-`en` check if SRT file exists. For 'en' check if SJSON exists. For non-`en` check if SRT file exists.
""" """
is_bumper = request.GET.get('is_bumper', False) is_bumper = request.GET.get('is_bumper', False)
transcripts = self.get_transcripts_info(is_bumper) feature_enabled = is_val_transcript_feature_enabled_for_course(self.course_id)
transcripts = self.get_transcripts_info(is_bumper, include_val_transcripts=feature_enabled)
if dispatch.startswith('translation'): if dispatch.startswith('translation'):
language = dispatch.replace('translation', '').strip('/') language = dispatch.replace('translation', '').strip('/')
...@@ -238,16 +243,31 @@ class VideoStudentViewHandlers(object): ...@@ -238,16 +243,31 @@ class VideoStudentViewHandlers(object):
try: try:
transcript = self.translation(request.GET.get('videoId', None), transcripts) transcript = self.translation(request.GET.get('videoId', None), transcripts)
except (TypeError, NotFoundError) as ex: except (TypeError, TranscriptException, NotFoundError) as ex:
# Catching `TranscriptException` because its also getting raised at places
# when transcript is not found in contentstore.
log.debug(ex.message) log.debug(ex.message)
# Try to return static URL redirection as last resort # Try to return static URL redirection as last resort
# if no translation is required # if no translation is required
return self.get_static_transcript(request, transcripts) response = self.get_static_transcript(request, transcripts)
except ( if response.status_code == 404 and feature_enabled:
TranscriptException, # Try to get transcript from edx-val as a last resort.
UnicodeDecodeError, transcript = get_video_transcript_content(
TranscriptsGenerationException language_code=self.transcript_language,
) as ex: edx_video_id=self.edx_video_id,
youtube_id_1_0=self.youtube_id_1_0,
html5_sources=self.html5_sources,
)
if transcript:
response = Response(
transcript['content'],
headerlist=[('Content-Language', self.transcript_language)],
charset='utf8',
)
response.content_type = Transcript.mime_types['sjson']
return response
except (UnicodeDecodeError, TranscriptsGenerationException) as ex:
log.info(ex.message) log.info(ex.message)
response = Response(status=404) response = Response(status=404)
else: else:
...@@ -260,8 +280,44 @@ class VideoStudentViewHandlers(object): ...@@ -260,8 +280,44 @@ class VideoStudentViewHandlers(object):
transcript_content, transcript_filename, transcript_mime_type = self.get_transcript( transcript_content, transcript_filename, transcript_mime_type = self.get_transcript(
transcripts, transcript_format=self.transcript_download_format, lang=lang transcripts, transcript_format=self.transcript_download_format, lang=lang
) )
except (NotFoundError, ValueError, KeyError, UnicodeDecodeError): except (ValueError, NotFoundError):
log.debug("Video@download exception") response = Response(status=404)
# Check for transcripts in edx-val as a last resort if corresponding feature is enabled.
if feature_enabled:
# Make sure the language is set.
if not lang:
lang = self.get_default_transcript_language(transcripts)
transcript = get_video_transcript_content(
language_code=lang,
edx_video_id=self.edx_video_id,
youtube_id_1_0=self.youtube_id_1_0,
html5_sources=self.html5_sources,
)
if transcript:
transcript_content = Transcript.convert(
transcript['content'],
input_format='sjson',
output_format=self.transcript_download_format
)
# Construct the response
base_name, __ = os.path.splitext(os.path.basename(transcript['file_name']))
filename = '{base_name}.{ext}'.format(
base_name=base_name.encode('utf8'),
ext=self.transcript_download_format
)
response = Response(
transcript_content,
headerlist=[
('Content-Disposition', 'attachment; filename="{filename}"'.format(filename=filename)),
('Content-Language', lang),
],
charset='utf8',
)
response.content_type = Transcript.mime_types[self.transcript_download_format]
return response
except (KeyError, UnicodeDecodeError):
return Response(status=404) return Response(status=404)
else: else:
response = Response( response = Response(
...@@ -276,7 +332,11 @@ class VideoStudentViewHandlers(object): ...@@ -276,7 +332,11 @@ class VideoStudentViewHandlers(object):
elif dispatch.startswith('available_translations'): elif dispatch.startswith('available_translations'):
available_translations = self.available_translations(transcripts, verify_assets=True) available_translations = self.available_translations(
transcripts,
verify_assets=True,
include_val_transcripts=feature_enabled,
)
if available_translations: if available_translations:
response = Response(json.dumps(available_translations)) response = Response(json.dumps(available_translations))
response.content_type = 'application/json' response.content_type = 'application/json'
......
...@@ -24,7 +24,7 @@ from pkg_resources import resource_string ...@@ -24,7 +24,7 @@ from pkg_resources import resource_string
from django.conf import settings from django.conf import settings
from lxml import etree from lxml import etree
from opaque_keys.edx.locator import AssetLocator from opaque_keys.edx.locator import AssetLocator
from openedx.core.djangoapps.video_config.models import HLSPlaybackEnabledFlag from openedx.core.djangoapps.video_config.models import HLSPlaybackEnabledFlag, VideoTranscriptEnabledFlag
from openedx.core.lib.cache_utils import memoize_in_request_cache from openedx.core.lib.cache_utils import memoize_in_request_cache
from openedx.core.lib.license import LicenseMixin from openedx.core.lib.license import LicenseMixin
from xblock.core import XBlock from xblock.core import XBlock
...@@ -41,7 +41,13 @@ from xmodule.x_module import XModule, module_attr ...@@ -41,7 +41,13 @@ from xmodule.x_module import XModule, module_attr
from xmodule.xml_module import deserialize_field, is_pointer_tag, name_to_pathname from xmodule.xml_module import deserialize_field, is_pointer_tag, name_to_pathname
from .bumper_utils import bumperize from .bumper_utils import bumperize
from .transcripts_utils import Transcript, VideoTranscriptsMixin, get_html5_ids from .transcripts_utils import (
get_html5_ids,
get_video_ids_info,
is_val_transcript_feature_enabled_for_course,
Transcript,
VideoTranscriptsMixin,
)
from .video_handlers import VideoStudentViewHandlers, VideoStudioViewHandlers from .video_handlers import VideoStudentViewHandlers, VideoStudioViewHandlers
from .video_utils import create_youtube_string, format_xml_exception_message, get_poster, rewrite_video_url from .video_utils import create_youtube_string, format_xml_exception_message, get_poster, rewrite_video_url
from .video_xfields import VideoFields from .video_xfields import VideoFields
...@@ -181,13 +187,13 @@ class VideoModule(VideoFields, VideoTranscriptsMixin, VideoStudentViewHandlers, ...@@ -181,13 +187,13 @@ class VideoModule(VideoFields, VideoTranscriptsMixin, VideoStudentViewHandlers,
track_url = self.runtime.handler_url(self, 'transcript', 'download').rstrip('/?') track_url = self.runtime.handler_url(self, 'transcript', 'download').rstrip('/?')
transcript_language = self.get_default_transcript_language(transcripts) transcript_language = self.get_default_transcript_language(transcripts)
native_languages = {lang: label for lang, label in settings.LANGUAGES if len(lang) == 2} native_languages = {lang: label for lang, label in settings.LANGUAGES if len(lang) == 2}
languages = { languages = {
lang: native_languages.get(lang, display) lang: native_languages.get(lang, display)
for lang, display in settings.ALL_LANGUAGES for lang, display in settings.ALL_LANGUAGES
if lang in other_lang if lang in other_lang
} }
if not other_lang or (other_lang and sub): if not other_lang or (other_lang and sub):
languages['en'] = 'English' languages['en'] = 'English'
...@@ -277,7 +283,9 @@ class VideoModule(VideoFields, VideoTranscriptsMixin, VideoStudentViewHandlers, ...@@ -277,7 +283,9 @@ class VideoModule(VideoFields, VideoTranscriptsMixin, VideoStudentViewHandlers,
if download_video_link and download_video_link.endswith('.m3u8'): if download_video_link and download_video_link.endswith('.m3u8'):
download_video_link = None download_video_link = None
track_url, transcript_language, sorted_languages = self.get_transcripts_for_student(self.get_transcripts_info()) feature_enabled = is_val_transcript_feature_enabled_for_course(self.course_id)
transcripts = self.get_transcripts_info(include_val_transcripts=feature_enabled)
track_url, transcript_language, sorted_languages = self.get_transcripts_for_student(transcripts=transcripts)
# CDN_VIDEO_URLS is only to be used here and will be deleted # CDN_VIDEO_URLS is only to be used here and will be deleted
# TODO(ali@edx.org): Delete this after the CDN experiment has completed. # TODO(ali@edx.org): Delete this after the CDN experiment has completed.
...@@ -595,6 +603,10 @@ class VideoDescriptor(VideoFields, VideoTranscriptsMixin, VideoStudioViewHandler ...@@ -595,6 +603,10 @@ class VideoDescriptor(VideoFields, VideoTranscriptsMixin, VideoStudioViewHandler
ScopeIds(None, block_type, definition_id, usage_id), ScopeIds(None, block_type, definition_id, usage_id),
field_data, field_data,
) )
# update val with info extracted from `xml_object`
video.import_video_info_into_val(xml_object, getattr(id_generator, 'target_course_id', None))
return video return video
def definition_to_xml(self, resource_fs): def definition_to_xml(self, resource_fs):
...@@ -658,14 +670,19 @@ class VideoDescriptor(VideoFields, VideoTranscriptsMixin, VideoStudioViewHandler ...@@ -658,14 +670,19 @@ class VideoDescriptor(VideoFields, VideoTranscriptsMixin, VideoStudioViewHandler
ele.set('src', self.transcripts[transcript_language]) ele.set('src', self.transcripts[transcript_language])
xml.append(ele) xml.append(ele)
if self.edx_video_id and edxval_api: if edxval_api:
try: external, video_ids = get_video_ids_info(self.edx_video_id, self.youtube_id_1_0, self.html5_sources)
xml.append(edxval_api.export_to_xml( if video_ids:
self.edx_video_id, try:
unicode(self.runtime.course_id.for_branch(None))) xml.append(
) edxval_api.export_to_xml(
except edxval_api.ValVideoNotFoundError: video_ids,
pass unicode(self.runtime.course_id.for_branch(None)),
external=external
)
)
except edxval_api.ValVideoNotFoundError:
pass
# handle license specifically # handle license specifically
self.add_license_to_xml(xml) self.add_license_to_xml(xml)
...@@ -864,24 +881,33 @@ class VideoDescriptor(VideoFields, VideoTranscriptsMixin, VideoStudioViewHandler ...@@ -864,24 +881,33 @@ class VideoDescriptor(VideoFields, VideoTranscriptsMixin, VideoStudioViewHandler
if 'download_track' not in field_data and track is not None: if 'download_track' not in field_data and track is not None:
field_data['download_track'] = True field_data['download_track'] = True
# load license if it exists
field_data = LicenseMixin.parse_license_from_xml(field_data, xml)
return field_data
def import_video_info_into_val(self, xml, course_id):
"""
Import parsed video info from `xml` into edxval.
Arguments:
xml (lxml object): xml representation of video to be imported
course_id (str): course id
"""
if self.edx_video_id is not None:
edx_video_id = self.edx_video_id.strip()
video_asset_elem = xml.find('video_asset') video_asset_elem = xml.find('video_asset')
if ( if edxval_api and video_asset_elem is not None:
edxval_api and # Always pass the edx_video_id, Whether the video is internal or external
video_asset_elem is not None and # In case of external, we only need to import transcripts and for that
'edx_video_id' in field_data # purpose video id is already present in the xml
):
# Allow ValCannotCreateError to escape
edxval_api.import_from_xml( edxval_api.import_from_xml(
video_asset_elem, video_asset_elem,
field_data['edx_video_id'], edx_video_id,
course_id=course_id course_id=course_id
) )
# load license if it exists
field_data = LicenseMixin.parse_license_from_xml(field_data, xml)
return field_data
def index_dictionary(self): def index_dictionary(self):
xblock_body = super(VideoDescriptor, self).index_dictionary() xblock_body = super(VideoDescriptor, self).index_dictionary()
video_body = { video_body = {
...@@ -990,10 +1016,12 @@ class VideoDescriptor(VideoFields, VideoTranscriptsMixin, VideoStudioViewHandler ...@@ -990,10 +1016,12 @@ class VideoDescriptor(VideoFields, VideoTranscriptsMixin, VideoStudioViewHandler
"file_size": 0, # File size is not relevant for external link "file_size": 0, # File size is not relevant for external link
} }
transcripts_info = self.get_transcripts_info() feature_enabled = is_val_transcript_feature_enabled_for_course(self.runtime.course_id.for_branch(None))
transcripts_info = self.get_transcripts_info(include_val_transcripts=feature_enabled)
available_translations = self.available_translations(transcripts_info, include_val_transcripts=feature_enabled)
transcripts = { transcripts = {
lang: self.runtime.handler_url(self, 'transcript', 'download', query="lang=" + lang, thirdparty=True) lang: self.runtime.handler_url(self, 'transcript', 'download', query="lang=" + lang, thirdparty=True)
for lang in self.available_translations(transcripts_info) for lang in available_translations
} }
return { return {
......
...@@ -11,7 +11,7 @@ import ddt ...@@ -11,7 +11,7 @@ import ddt
import freezegun import freezegun
from mock import MagicMock, Mock, patch from mock import MagicMock, Mock, patch
from nose.plugins.attrib import attr from nose.plugins.attrib import attr
from webob import Request from webob import Request, Response
from common.test.utils import normalize_repr from common.test.utils import normalize_repr
from openedx.core.djangoapps.contentserver.caching import del_cached_content from openedx.core.djangoapps.contentserver.caching import del_cached_content
...@@ -189,6 +189,7 @@ class TestVideo(BaseTestXmodule): ...@@ -189,6 +189,7 @@ class TestVideo(BaseTestXmodule):
@attr(shard=1) @attr(shard=1)
@ddt.ddt
class TestTranscriptAvailableTranslationsDispatch(TestVideo): class TestTranscriptAvailableTranslationsDispatch(TestVideo):
""" """
Test video handler that provide available translations info. Test video handler that provide available translations info.
...@@ -247,6 +248,80 @@ class TestTranscriptAvailableTranslationsDispatch(TestVideo): ...@@ -247,6 +248,80 @@ class TestTranscriptAvailableTranslationsDispatch(TestVideo):
response = self.item.transcript(request=request, dispatch='available_translations') response = self.item.transcript(request=request, dispatch='available_translations')
self.assertEqual(json.loads(response.body), ['en', 'uk']) self.assertEqual(json.loads(response.body), ['en', 'uk'])
@patch('xmodule.video_module.transcripts_utils.VideoTranscriptEnabledFlag.feature_enabled', Mock(return_value=True))
@patch('xmodule.video_module.transcripts_utils.get_available_transcript_languages')
@ddt.data(
(
['en', 'uk', 'ro'],
'',
{},
['en', 'uk', 'ro']
),
(
['uk', 'ro'],
True,
{},
['en', 'uk', 'ro']
),
(
['de', 'ro'],
True,
{
'uk': True,
'ro': False,
},
['en', 'uk', 'de', 'ro']
),
(
['de'],
True,
{
'uk': True,
'ro': False,
},
['en', 'uk', 'de']
),
)
@ddt.unpack
def test_val_available_translations(self, val_transcripts, sub, transcripts, result, mock_get_transcript_languages):
"""
Tests available translations with video component's and val's transcript languages
while the feature is enabled.
"""
for lang_code, in_content_store in dict(transcripts).iteritems():
if in_content_store:
file_name, __ = os.path.split(self.srt_file.name)
_upload_file(self.srt_file, self.item_descriptor.location, file_name)
transcripts[lang_code] = file_name
else:
transcripts[lang_code] = 'non_existent.srt.sjson'
if sub:
sjson_transcript = _create_file(json.dumps(self.subs))
_upload_sjson_file(sjson_transcript, self.item_descriptor.location)
sub = _get_subs_id(sjson_transcript.name)
mock_get_transcript_languages.return_value = val_transcripts
self.item.transcripts = transcripts
self.item.sub = sub
# Make request to available translations dispatch.
request = Request.blank('/available_translations')
response = self.item.transcript(request=request, dispatch='available_translations')
self.assertItemsEqual(json.loads(response.body), result)
@patch(
'xmodule.video_module.transcripts_utils.VideoTranscriptEnabledFlag.feature_enabled',
Mock(return_value=False),
)
@patch('xmodule.video_module.transcripts_utils.edxval_api.get_available_transcript_languages')
def test_val_available_translations_feature_disabled(self, mock_get_available_transcript_languages):
"""
Tests available translations with val transcript languages when feature is disabled.
"""
mock_get_available_transcript_languages.return_value = ['en', 'de', 'ro']
request = Request.blank('/available_translations')
response = self.item.transcript(request=request, dispatch='available_translations')
self.assertEqual(response.status_code, 404)
@attr(shard=1) @attr(shard=1)
@ddt.ddt @ddt.ddt
...@@ -370,6 +445,55 @@ class TestTranscriptDownloadDispatch(TestVideo): ...@@ -370,6 +445,55 @@ class TestTranscriptDownloadDispatch(TestVideo):
self.assertEqual(response.headers['Content-Type'], 'application/x-subrip; charset=utf-8') self.assertEqual(response.headers['Content-Type'], 'application/x-subrip; charset=utf-8')
self.assertEqual(response.headers['Content-Disposition'], 'attachment; filename="塞.srt"') self.assertEqual(response.headers['Content-Disposition'], 'attachment; filename="塞.srt"')
@patch('xmodule.video_module.transcripts_utils.edxval_api.get_video_transcript_data')
@patch('xmodule.video_module.transcripts_utils.VideoTranscriptEnabledFlag.feature_enabled', Mock(return_value=True))
@patch('xmodule.video_module.VideoModule.get_transcript', Mock(side_effect=NotFoundError))
def test_download_fallback_transcript(self, mock_get_video_transcript_data):
"""
Verify val transcript is returned as a fallback if it is not found in the content store.
"""
mock_get_video_transcript_data.return_value = {
'content': json.dumps({
"start": [10],
"end": [100],
"text": ["Hi, welcome to Edx."],
}),
'file_name': 'edx.sjson'
}
# Make request to XModule transcript handler
request = Request.blank('/download')
response = self.item.transcript(request=request, dispatch='download')
# Expected response
expected_content = u'0\n00:00:00,010 --> 00:00:00,100\nHi, welcome to Edx.\n\n'
expected_headers = {
'Content-Disposition': 'attachment; filename="edx.srt"',
'Content-Language': u'en',
'Content-Type': 'application/x-subrip; charset=utf-8'
}
# Assert the actual response
self.assertEqual(response.status_code, 200)
self.assertEqual(response.text, expected_content)
for attribute, value in expected_headers.iteritems():
self.assertEqual(response.headers[attribute], value)
@patch(
'xmodule.video_module.transcripts_utils.VideoTranscriptEnabledFlag.feature_enabled',
Mock(return_value=False),
)
@patch('xmodule.video_module.VideoModule.get_transcript', Mock(side_effect=NotFoundError))
def test_download_fallback_transcript_feature_disabled(self):
"""
Verify val transcript if its feature is disabled.
"""
# Make request to XModule transcript handler
request = Request.blank('/download')
response = self.item.transcript(request=request, dispatch='download')
# Assert the actual response
self.assertEqual(response.status_code, 404)
@attr(shard=1) @attr(shard=1)
@ddt.ddt @ddt.ddt
...@@ -602,6 +726,55 @@ class TestTranscriptTranslationGetDispatch(TestVideo): ...@@ -602,6 +726,55 @@ class TestTranscriptTranslationGetDispatch(TestVideo):
with store.branch_setting(ModuleStoreEnum.Branch.draft_preferred, self.course.id): with store.branch_setting(ModuleStoreEnum.Branch.draft_preferred, self.course.id):
store.update_item(self.course, self.user.id) store.update_item(self.course, self.user.id)
@patch('xmodule.video_module.transcripts_utils.edxval_api.get_video_transcript_data')
@patch('xmodule.video_module.transcripts_utils.VideoTranscriptEnabledFlag.feature_enabled', Mock(return_value=True))
@patch('xmodule.video_module.VideoModule.translation', Mock(side_effect=NotFoundError))
@patch('xmodule.video_module.VideoModule.get_static_transcript', Mock(return_value=Response(status=404)))
def test_translation_fallback_transcript(self, mock_get_video_transcript_data):
"""
Verify that the val transcript is returned as a fallback,
if it is not found in the content store.
"""
transcript = {
'content': json.dumps({
"start": [10],
"end": [100],
"text": ["Hi, welcome to Edx."],
}),
'file_name': 'edx.sjson'
}
mock_get_video_transcript_data.return_value = transcript
# Make request to XModule transcript handler
response = self.item.transcript(request=Request.blank('/translation/en'), dispatch='translation/en')
# Expected headers
expected_headers = {
'Content-Language': 'en',
'Content-Type': 'application/json'
}
# Assert the actual response
self.assertEqual(response.status_code, 200)
self.assertEqual(response.text, transcript['content'])
for attribute, value in expected_headers.iteritems():
self.assertEqual(response.headers[attribute], value)
@patch(
'xmodule.video_module.transcripts_utils.VideoTranscriptEnabledFlag.feature_enabled',
Mock(return_value=False),
)
@patch('xmodule.video_module.VideoModule.translation', Mock(side_effect=NotFoundError))
@patch('xmodule.video_module.VideoModule.get_static_transcript', Mock(return_value=Response(status=404)))
def test_translation_fallback_transcript_feature_disabled(self):
"""
Verify that val transcript is not returned when its feature is disabled.
"""
# Make request to XModule transcript handler
response = self.item.transcript(request=Request.blank('/translation/en'), dispatch='translation/en')
# Assert the actual response
self.assertEqual(response.status_code, 404)
@attr(shard=1) @attr(shard=1)
class TestStudioTranscriptTranslationGetDispatch(TestVideo): class TestStudioTranscriptTranslationGetDispatch(TestVideo):
......
...@@ -11,6 +11,7 @@ from courseware.module_render import get_module_for_descriptor ...@@ -11,6 +11,7 @@ from courseware.module_render import get_module_for_descriptor
from util.module_utils import get_dynamic_descriptor_children from util.module_utils import get_dynamic_descriptor_children
from xmodule.modulestore.django import modulestore from xmodule.modulestore.django import modulestore
from xmodule.modulestore.mongo.base import BLOCK_TYPES_WITH_CHILDREN from xmodule.modulestore.mongo.base import BLOCK_TYPES_WITH_CHILDREN
from xmodule.video_module.transcripts_utils import is_val_transcript_feature_enabled_for_course
class BlockOutline(object): class BlockOutline(object):
...@@ -208,8 +209,12 @@ def video_summary(video_profiles, course_id, video_descriptor, request, local_ca ...@@ -208,8 +209,12 @@ def video_summary(video_profiles, course_id, video_descriptor, request, local_ca
size = default_encoded_video.get('file_size', 0) size = default_encoded_video.get('file_size', 0)
# Transcripts... # Transcripts...
transcripts_info = video_descriptor.get_transcripts_info() feature_enabled = is_val_transcript_feature_enabled_for_course(course_id)
transcript_langs = video_descriptor.available_translations(transcripts_info) transcripts_info = video_descriptor.get_transcripts_info(include_val_transcripts=feature_enabled)
transcript_langs = video_descriptor.available_translations(
transcripts=transcripts_info,
include_val_transcripts=feature_enabled
)
transcripts = { transcripts = {
lang: reverse( lang: reverse(
......
...@@ -2,12 +2,14 @@ ...@@ -2,12 +2,14 @@
""" """
Tests for video outline API Tests for video outline API
""" """
import ddt
import itertools import itertools
import json
from collections import namedtuple from collections import namedtuple
from mock import Mock
from uuid import uuid4 from uuid import uuid4
import ddt
from django.conf import settings from django.conf import settings
from edxval import api from edxval import api
from milestones.tests.utils import MilestonesTestCaseMixin from milestones.tests.utils import MilestonesTestCaseMixin
...@@ -876,6 +878,36 @@ class TestVideoSummaryList(TestVideoAPITestCase, MobileAuthTestMixin, MobileCour ...@@ -876,6 +878,36 @@ class TestVideoSummaryList(TestVideoAPITestCase, MobileAuthTestMixin, MobileCour
set(case.expected_transcripts) set(case.expected_transcripts)
) )
@ddt.data(
({}, '', [], ['en']),
({}, '', ['de'], ['de']),
({}, '', ['en', 'de'], ['en', 'de']),
({}, 'en-subs', ['de'], ['en', 'de']),
({'uk': 1}, 'en-subs', ['de'], ['en', 'uk', 'de']),
({'uk': 1, 'de': 1}, 'en-subs', ['de', 'en'], ['en', 'uk', 'de']),
)
@ddt.unpack
@patch('xmodule.video_module.transcripts_utils.VideoTranscriptEnabledFlag.feature_enabled', Mock(return_value=True))
@patch('xmodule.video_module.transcripts_utils.edxval_api.get_available_transcript_languages')
def test_val_transcripts_with_feature_enabled(self, transcripts, english_sub, val_transcripts,
expected_transcripts, mock_get_transcript_languages):
self.login_and_enroll()
video = ItemFactory.create(
parent=self.nameless_unit,
category="video",
edx_video_id=self.edx_video_id,
display_name=u"test draft video omega 2 \u03a9"
)
mock_get_transcript_languages.return_value = val_transcripts
video.transcripts = transcripts
video.sub = english_sub
modulestore().update_item(video, self.user.id)
course_outline = self.api_response().data
self.assertEqual(len(course_outline), 1)
self.assertItemsEqual(course_outline[0]['summary']['transcripts'].keys(), expected_transcripts)
@attr(shard=2) @attr(shard=2)
class TestTranscriptsDetail(TestVideoAPITestCase, MobileAuthTestMixin, MobileCourseAccessTestMixin, class TestTranscriptsDetail(TestVideoAPITestCase, MobileAuthTestMixin, MobileCourseAccessTestMixin,
...@@ -905,3 +937,57 @@ class TestTranscriptsDetail(TestVideoAPITestCase, MobileAuthTestMixin, MobileCou ...@@ -905,3 +937,57 @@ class TestTranscriptsDetail(TestVideoAPITestCase, MobileAuthTestMixin, MobileCou
self.video = self._create_video_with_subs(custom_subid=u'你好') self.video = self._create_video_with_subs(custom_subid=u'你好')
self.login_and_enroll() self.login_and_enroll()
self.api_response(expected_response_code=200, lang='en') self.api_response(expected_response_code=200, lang='en')
@patch(
'xmodule.video_module.transcripts_utils.VideoTranscriptEnabledFlag.feature_enabled',
Mock(return_value=True),
)
@patch(
'xmodule.video_module.transcripts_utils.edxval_api.get_available_transcript_languages',
Mock(return_value=['uk']),
)
@patch('xmodule.video_module.transcripts_utils.edxval_api.get_video_transcript_data')
def test_val_transcript(self, mock_get_video_transcript_content):
"""
Tests transcript retrieval view with val transcripts.
"""
mock_get_video_transcript_content.return_value = {
'content': json.dumps({
'start': [10],
'end': [100],
'text': [u'Hi, welcome to Edx.'],
}),
'file_name': 'edx.sjson'
}
self.login_and_enroll()
# Now, make request to retrieval endpoint
response = self.api_response(expected_response_code=200, lang='uk')
# Expected headers
expected_content = u'0\n00:00:00,010 --> 00:00:00,100\nHi, welcome to Edx.\n\n'
expected_headers = {
'Content-Disposition': 'attachment; filename="edx.srt"',
'Content-Type': 'application/x-subrip; charset=utf-8'
}
# Assert the actual response
self.assertEqual(response.content, expected_content)
for attribute, value in expected_headers.iteritems():
self.assertEqual(response.get(attribute), value)
@patch(
'xmodule.video_module.transcripts_utils.VideoTranscriptEnabledFlag.feature_enabled',
Mock(return_value=False),
)
@patch(
'xmodule.video_module.transcripts_utils.edxval_api.get_available_transcript_languages',
Mock(return_value=['uk']),
)
def test_val_transcript_feature_disabled(self):
"""
Tests transcript retrieval view with val transcripts when
the corresponding feature is disabled.
"""
self.login_and_enroll()
# request to retrieval endpoint will result in 404 as val transcripts are disabled.
self.api_response(expected_response_code=404, lang='uk')
...@@ -6,6 +6,7 @@ only displayed at the course level. This is because it makes it a lot easier to ...@@ -6,6 +6,7 @@ only displayed at the course level. This is because it makes it a lot easier to
optimize and reason about, and it avoids having to tackle the bigger problem of optimize and reason about, and it avoids having to tackle the bigger problem of
general XBlock representation in this rather specialized formatting. general XBlock representation in this rather specialized formatting.
""" """
import os
from functools import partial from functools import partial
from django.http import Http404, HttpResponse from django.http import Http404, HttpResponse
...@@ -16,6 +17,11 @@ from rest_framework.response import Response ...@@ -16,6 +17,11 @@ from rest_framework.response import Response
from mobile_api.models import MobileApiConfig from mobile_api.models import MobileApiConfig
from xmodule.exceptions import NotFoundError from xmodule.exceptions import NotFoundError
from xmodule.modulestore.django import modulestore from xmodule.modulestore.django import modulestore
from xmodule.video_module.transcripts_utils import (
get_video_transcript_content,
is_val_transcript_feature_enabled_for_course,
Transcript,
)
from ..decorators import mobile_course_access, mobile_view from ..decorators import mobile_course_access, mobile_view
from .serializers import BlockOutline, video_summary from .serializers import BlockOutline, video_summary
...@@ -111,14 +117,31 @@ class VideoTranscripts(generics.RetrieveAPIView): ...@@ -111,14 +117,31 @@ class VideoTranscripts(generics.RetrieveAPIView):
block_id = kwargs['block_id'] block_id = kwargs['block_id']
lang = kwargs['lang'] lang = kwargs['lang']
usage_key = BlockUsageLocator( usage_key = BlockUsageLocator(course.id, block_type='video', block_id=block_id)
course.id, block_type="video", block_id=block_id video_descriptor = modulestore().get_item(usage_key)
) feature_enabled = is_val_transcript_feature_enabled_for_course(usage_key.course_key)
try: try:
video_descriptor = modulestore().get_item(usage_key) transcripts = video_descriptor.get_transcripts_info(include_val_transcripts=feature_enabled)
transcripts = video_descriptor.get_transcripts_info()
content, filename, mimetype = video_descriptor.get_transcript(transcripts, lang=lang) content, filename, mimetype = video_descriptor.get_transcript(transcripts, lang=lang)
except (NotFoundError, ValueError, KeyError): except (ValueError, NotFoundError):
# Fallback mechanism for edx-val transcripts
transcript = None
if feature_enabled:
transcript = get_video_transcript_content(
language_code=lang,
edx_video_id=video_descriptor.edx_video_id,
youtube_id_1_0=video_descriptor.youtube_id_1_0,
html5_sources=video_descriptor.html5_sources,
)
if not transcript:
raise Http404(u'Transcript not found for {}, lang: {}'.format(block_id, lang))
base_name, __ = os.path.splitext(os.path.basename(transcript['file_name']))
filename = '{base_name}.srt'.format(base_name=base_name)
content = Transcript.convert(transcript['content'], 'sjson', 'srt')
mimetype = Transcript.mime_types['srt']
except KeyError:
raise Http404(u"Transcript not found for {}, lang: {}".format(block_id, lang)) raise Http404(u"Transcript not found for {}, lang: {}".format(block_id, lang))
response = HttpResponse(content, content_type=mimetype) response = HttpResponse(content, content_type=mimetype)
......
...@@ -795,6 +795,9 @@ XBLOCK_SETTINGS.setdefault("VideoModule", {})['YOUTUBE_API_KEY'] = AUTH_TOKENS.g ...@@ -795,6 +795,9 @@ XBLOCK_SETTINGS.setdefault("VideoModule", {})['YOUTUBE_API_KEY'] = AUTH_TOKENS.g
##### VIDEO IMAGE STORAGE ##### ##### VIDEO IMAGE STORAGE #####
VIDEO_IMAGE_SETTINGS = ENV_TOKENS.get('VIDEO_IMAGE_SETTINGS', VIDEO_IMAGE_SETTINGS) VIDEO_IMAGE_SETTINGS = ENV_TOKENS.get('VIDEO_IMAGE_SETTINGS', VIDEO_IMAGE_SETTINGS)
##### VIDEO TRANSCRIPTS STORAGE #####
VIDEO_TRANSCRIPTS_SETTINGS = ENV_TOKENS.get('VIDEO_TRANSCRIPTS_SETTINGS', VIDEO_TRANSCRIPTS_SETTINGS)
##### CDN EXPERIMENT/MONITORING FLAGS ##### ##### CDN EXPERIMENT/MONITORING FLAGS #####
CDN_VIDEO_URLS = ENV_TOKENS.get('CDN_VIDEO_URLS', CDN_VIDEO_URLS) CDN_VIDEO_URLS = ENV_TOKENS.get('CDN_VIDEO_URLS', CDN_VIDEO_URLS)
ONLOAD_BEACON_SAMPLE_RATE = ENV_TOKENS.get('ONLOAD_BEACON_SAMPLE_RATE', ONLOAD_BEACON_SAMPLE_RATE) ONLOAD_BEACON_SAMPLE_RATE = ENV_TOKENS.get('ONLOAD_BEACON_SAMPLE_RATE', ONLOAD_BEACON_SAMPLE_RATE)
......
...@@ -2608,6 +2608,20 @@ VIDEO_IMAGE_SETTINGS = dict( ...@@ -2608,6 +2608,20 @@ VIDEO_IMAGE_SETTINGS = dict(
DIRECTORY_PREFIX='video-images/', DIRECTORY_PREFIX='video-images/',
) )
########################## VIDEO TRANSCRIPTS STORAGE ############################
VIDEO_TRANSCRIPTS_SETTINGS = dict(
VIDEO_TRANSCRIPTS_MAX_BYTES=3 * 1024 * 1024, # 3 MB
# Backend storage
# STORAGE_CLASS='storages.backends.s3boto.S3BotoStorage',
# STORAGE_KWARGS=dict(bucket='video-transcripts-bucket'),
STORAGE_KWARGS=dict(
location=MEDIA_ROOT,
base_url=MEDIA_URL,
),
DIRECTORY_PREFIX='video-transcripts/',
)
# Source: # Source:
# http://loc.gov/standards/iso639-2/ISO-639-2_utf-8.txt according to http://en.wikipedia.org/wiki/ISO_639-1 # http://loc.gov/standards/iso639-2/ISO-639-2_utf-8.txt according to http://en.wikipedia.org/wiki/ISO_639-1
......
...@@ -5,16 +5,24 @@ Django admin dashboard configuration for Video XModule. ...@@ -5,16 +5,24 @@ Django admin dashboard configuration for Video XModule.
from config_models.admin import ConfigurationModelAdmin, KeyedConfigurationModelAdmin from config_models.admin import ConfigurationModelAdmin, KeyedConfigurationModelAdmin
from django.contrib import admin from django.contrib import admin
from openedx.core.djangoapps.video_config.forms import CourseHLSPlaybackFlagAdminForm from openedx.core.djangoapps.video_config.forms import (
from openedx.core.djangoapps.video_config.models import CourseHLSPlaybackEnabledFlag, HLSPlaybackEnabledFlag CourseHLSPlaybackFlagAdminForm, CourseVideoTranscriptFlagAdminForm
)
from openedx.core.djangoapps.video_config.models import (
CourseHLSPlaybackEnabledFlag, HLSPlaybackEnabledFlag,
CourseVideoTranscriptEnabledFlag, VideoTranscriptEnabledFlag
)
class CourseHLSPlaybackEnabledFlagAdmin(KeyedConfigurationModelAdmin): class CourseSpecificEnabledFlagBaseAdmin(KeyedConfigurationModelAdmin):
""" """
Admin of HLS Playback feature on course-by-course basis. Admin of course specific feature on course-by-course basis.
Allows searching by course id. Allows searching by course id.
""" """
form = CourseHLSPlaybackFlagAdminForm # Make abstract base class
class Meta(object):
abstract = True
search_fields = ['course_id'] search_fields = ['course_id']
fieldsets = ( fieldsets = (
(None, { (None, {
...@@ -23,5 +31,23 @@ class CourseHLSPlaybackEnabledFlagAdmin(KeyedConfigurationModelAdmin): ...@@ -23,5 +31,23 @@ class CourseHLSPlaybackEnabledFlagAdmin(KeyedConfigurationModelAdmin):
}), }),
) )
class CourseHLSPlaybackEnabledFlagAdmin(CourseSpecificEnabledFlagBaseAdmin):
"""
Admin of HLS Playback feature on course-by-course basis.
Allows searching by course id.
"""
form = CourseHLSPlaybackFlagAdminForm
class CourseVideoTranscriptEnabledFlagAdmin(CourseSpecificEnabledFlagBaseAdmin):
"""
Admin of Video Transcript feature on course-by-course basis.
Allows searching by course id.
"""
form = CourseHLSPlaybackFlagAdminForm
admin.site.register(HLSPlaybackEnabledFlag, ConfigurationModelAdmin) admin.site.register(HLSPlaybackEnabledFlag, ConfigurationModelAdmin)
admin.site.register(CourseHLSPlaybackEnabledFlag, CourseHLSPlaybackEnabledFlagAdmin) admin.site.register(CourseHLSPlaybackEnabledFlag, CourseHLSPlaybackEnabledFlagAdmin)
admin.site.register(VideoTranscriptEnabledFlag, ConfigurationModelAdmin)
admin.site.register(CourseVideoTranscriptEnabledFlag, CourseHLSPlaybackEnabledFlagAdmin)
...@@ -7,20 +7,20 @@ from django import forms ...@@ -7,20 +7,20 @@ from django import forms
from opaque_keys import InvalidKeyError from opaque_keys import InvalidKeyError
from opaque_keys.edx.locator import CourseLocator from opaque_keys.edx.locator import CourseLocator
from openedx.core.djangoapps.video_config.models import CourseHLSPlaybackEnabledFlag from openedx.core.djangoapps.video_config.models import CourseHLSPlaybackEnabledFlag, CourseVideoTranscriptEnabledFlag
from xmodule.modulestore.django import modulestore from xmodule.modulestore.django import modulestore
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
class CourseHLSPlaybackFlagAdminForm(forms.ModelForm): class CourseSpecificFlagAdminBaseForm(forms.ModelForm):
""" """
Form for course-specific HLS Playback configuration. Form for course-specific feature configuration.
""" """
# Make abstract base class
class Meta(object): class Meta(object):
model = CourseHLSPlaybackEnabledFlag abstract = True
fields = '__all__'
def clean_course_id(self): def clean_course_id(self):
""" """
...@@ -42,3 +42,23 @@ class CourseHLSPlaybackFlagAdminForm(forms.ModelForm): ...@@ -42,3 +42,23 @@ class CourseHLSPlaybackFlagAdminForm(forms.ModelForm):
raise forms.ValidationError(msg) raise forms.ValidationError(msg)
return course_key return course_key
class CourseHLSPlaybackFlagAdminForm(CourseSpecificFlagAdminBaseForm):
"""
Form for course-specific HLS Playback configuration.
"""
class Meta(object):
model = CourseHLSPlaybackEnabledFlag
fields = '__all__'
class CourseVideoTranscriptFlagAdminForm(CourseSpecificFlagAdminBaseForm):
"""
Form for course-specific Video Transcript configuration.
"""
class Meta(object):
model = CourseVideoTranscriptEnabledFlag
fields = '__all__'
# -*- coding: utf-8 -*-
from __future__ import unicode_literals
from django.db import migrations, models
from django.conf import settings
import django.db.models.deletion
import openedx.core.djangoapps.xmodule_django.models
class Migration(migrations.Migration):
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
('video_config', '0001_initial'),
]
operations = [
migrations.CreateModel(
name='CourseVideoTranscriptEnabledFlag',
fields=[
('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)),
('change_date', models.DateTimeField(auto_now_add=True, verbose_name='Change date')),
('enabled', models.BooleanField(default=False, verbose_name='Enabled')),
('course_id', openedx.core.djangoapps.xmodule_django.models.CourseKeyField(max_length=255, db_index=True)),
('changed_by', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, editable=False, to=settings.AUTH_USER_MODEL, null=True, verbose_name='Changed by')),
],
options={
'ordering': ('-change_date',),
'abstract': False,
},
),
migrations.CreateModel(
name='VideoTranscriptEnabledFlag',
fields=[
('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)),
('change_date', models.DateTimeField(auto_now_add=True, verbose_name='Change date')),
('enabled', models.BooleanField(default=False, verbose_name='Enabled')),
('enabled_for_all_courses', models.BooleanField(default=False)),
('changed_by', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, editable=False, to=settings.AUTH_USER_MODEL, null=True, verbose_name='Changed by')),
],
options={
'ordering': ('-change_date',),
'abstract': False,
},
),
]
...@@ -66,3 +66,68 @@ class CourseHLSPlaybackEnabledFlag(ConfigurationModel): ...@@ -66,3 +66,68 @@ class CourseHLSPlaybackEnabledFlag(ConfigurationModel):
course_key=unicode(self.course_id), course_key=unicode(self.course_id),
not_enabled=not_en not_enabled=not_en
) )
class VideoTranscriptEnabledFlag(ConfigurationModel):
"""
Enables Video Transcript across the platform.
When this feature flag is set to true, individual courses
must also have Video Transcript enabled for this feature to
take effect.
When this feature is enabled, 3rd party transcript integration functionality would be available accross all
courses or some specific courses and S3 video transcript would be served (currently as a fallback).
"""
# this field overrides course-specific settings
enabled_for_all_courses = BooleanField(default=False)
@classmethod
def feature_enabled(cls, course_id):
"""
Looks at the currently active configuration model to determine whether
the Video Transcript feature is available.
If the feature flag is not enabled, the feature is not available.
If the flag is enabled for all the courses, feature is available.
If the flag is enabled and the provided course_id is for an course
with Video Transcript enabled, the feature is available.
Arguments:
course_id (CourseKey): course id for whom feature will be checked.
"""
if not VideoTranscriptEnabledFlag.is_enabled():
return False
elif not VideoTranscriptEnabledFlag.current().enabled_for_all_courses:
feature = (CourseVideoTranscriptEnabledFlag.objects
.filter(course_id=course_id)
.order_by('-change_date')
.first())
return feature.enabled if feature else False
return True
def __unicode__(self):
current_model = VideoTranscriptEnabledFlag.current()
return u"VideoTranscriptEnabledFlag: enabled {is_enabled}".format(
is_enabled=current_model.is_enabled()
)
class CourseVideoTranscriptEnabledFlag(ConfigurationModel):
"""
Enables Video Transcript for a specific course. Global feature must be
enabled for this to take effect.
When this feature is enabled, 3rd party transcript integration functionality would be available for the
specific course and S3 video transcript would be served (currently as a fallback).
"""
KEY_FIELDS = ('course_id',)
course_id = CourseKeyField(max_length=255, db_index=True)
def __unicode__(self):
not_en = "Not "
if self.enabled:
not_en = ""
return u"Course '{course_key}': Video Transcript {not_enabled}Enabled".format(
course_key=unicode(self.course_id),
not_enabled=not_en
)
...@@ -54,7 +54,7 @@ edx-organizations==0.4.7 ...@@ -54,7 +54,7 @@ edx-organizations==0.4.7
edx-rest-api-client==1.7.1 edx-rest-api-client==1.7.1
edx-search==1.1.0 edx-search==1.1.0
edx-submissions==2.0.12 edx-submissions==2.0.12
edxval==0.1.1 edxval==0.1.2
event-tracking==0.2.4 event-tracking==0.2.4
feedparser==5.1.3 feedparser==5.1.3
firebase-token-generator==1.3.2 firebase-token-generator==1.3.2
......
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