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):
""" """
......
...@@ -24,13 +24,16 @@ from contentstore.utils import reverse_course_url ...@@ -24,13 +24,16 @@ from contentstore.utils import reverse_course_url
from contentstore.views.videos import ( from contentstore.views.videos import (
_get_default_video_image_url, _get_default_video_image_url,
validate_video_image, validate_video_image,
validate_transcript_preferences,
VIDEO_IMAGE_UPLOAD_ENABLED, VIDEO_IMAGE_UPLOAD_ENABLED,
WAFFLE_SWITCHES, WAFFLE_SWITCHES,
TranscriptProvider
) )
from contentstore.views.videos import KEY_EXPIRATION_IN_SECONDS, StatusDisplayStrings, convert_video_status from contentstore.views.videos import KEY_EXPIRATION_IN_SECONDS, StatusDisplayStrings, convert_video_status
from xmodule.modulestore.tests.factories import CourseFactory from xmodule.modulestore.tests.factories import CourseFactory
from openedx.core.djangoapps.profile_images.tests.helpers import make_image_file from openedx.core.djangoapps.profile_images.tests.helpers import make_image_file
from edxval.api import create_or_update_transcript_preferences, get_transcript_preferences
def override_switch(switch, active): def override_switch(switch, active):
...@@ -551,6 +554,22 @@ class VideosHandlerTestCase(VideoUploadTestMixin, CourseTestCase): ...@@ -551,6 +554,22 @@ class VideosHandlerTestCase(VideoUploadTestMixin, CourseTestCase):
self.assert_video_status(url, edx_video_id, 'Failed') self.assert_video_status(url, edx_video_id, 'Failed')
@ddt.data(True, False)
@patch('openedx.core.djangoapps.video_config.models.VideoTranscriptEnabledFlag.feature_enabled')
def test_video_index_transcript_feature_enablement(self, is_video_transcript_enabled, video_transcript_feature):
"""
Test that when video transcript is enabled/disabled, correct response is rendered.
"""
video_transcript_feature.return_value = is_video_transcript_enabled
response = self.client.get(self.url)
self.assertEqual(response.status_code, 200)
# Verify that course video button is present in the response if videos transcript feature is enabled.
self.assertEqual(
'<button class="button course-video-settings-button">' in response.content,
is_video_transcript_enabled
)
@ddt.ddt @ddt.ddt
@patch.dict('django.conf.settings.FEATURES', {'ENABLE_VIDEO_UPLOAD_PIPELINE': True}) @patch.dict('django.conf.settings.FEATURES', {'ENABLE_VIDEO_UPLOAD_PIPELINE': True})
...@@ -842,7 +861,10 @@ class VideoImageTestCase(VideoUploadTestBase, CourseTestCase): ...@@ -842,7 +861,10 @@ class VideoImageTestCase(VideoUploadTestBase, CourseTestCase):
edx_video_id = 'test1' edx_video_id = 'test1'
video_image_upload_url = self.get_url_for_course_key(self.course.id, {'edx_video_id': edx_video_id}) video_image_upload_url = self.get_url_for_course_key(self.course.id, {'edx_video_id': edx_video_id})
with make_image_file( with make_image_file(
dimensions=(image_data.get('width', settings.VIDEO_IMAGE_MIN_WIDTH), image_data.get('height', settings.VIDEO_IMAGE_MIN_HEIGHT)), dimensions=(
image_data.get('width', settings.VIDEO_IMAGE_MIN_WIDTH),
image_data.get('height', settings.VIDEO_IMAGE_MIN_HEIGHT)
),
prefix=image_data.get('prefix', 'videoimage'), prefix=image_data.get('prefix', 'videoimage'),
extension=image_data.get('extension', '.png'), extension=image_data.get('extension', '.png'),
force_size=image_data.get('size', settings.VIDEO_IMAGE_SETTINGS['VIDEO_IMAGE_MIN_BYTES']) force_size=image_data.get('size', settings.VIDEO_IMAGE_SETTINGS['VIDEO_IMAGE_MIN_BYTES'])
...@@ -854,6 +876,323 @@ class VideoImageTestCase(VideoUploadTestBase, CourseTestCase): ...@@ -854,6 +876,323 @@ class VideoImageTestCase(VideoUploadTestBase, CourseTestCase):
self.verify_image_upload_reponse(self.course.id, edx_video_id, response) self.verify_image_upload_reponse(self.course.id, edx_video_id, response)
@ddt.ddt
@patch(
'openedx.core.djangoapps.video_config.models.VideoTranscriptEnabledFlag.feature_enabled',
Mock(return_value=True)
)
@patch.dict('django.conf.settings.FEATURES', {'ENABLE_VIDEO_UPLOAD_PIPELINE': True})
class TranscriptPreferencesTestCase(VideoUploadTestBase, CourseTestCase):
"""
Tests for video transcripts preferences.
"""
VIEW_NAME = 'transcript_preferences_handler'
def test_405_with_not_allowed_request_method(self):
"""
Verify that 405 is returned in case of not-allowed request methods.
Allowed request methods are POST and DELETE.
"""
video_transcript_url = self.get_url_for_course_key(self.course.id)
response = self.client.get(
video_transcript_url,
content_type='application/json'
)
self.assertEqual(response.status_code, 405)
@ddt.data(
# Video transcript feature disabled
(
{},
False,
'',
404,
),
# Error cases
(
{},
True,
u"Invalid provider None.",
400
),
(
{
'provider': ''
},
True,
u"Invalid provider .",
400
),
(
{
'provider': 'dummy-provider'
},
True,
u"Invalid provider dummy-provider.",
400
),
(
{
'provider': TranscriptProvider.CIELO24
},
True,
u"Invalid cielo24 fidelity None.",
400
),
(
{
'provider': TranscriptProvider.CIELO24,
'cielo24_fidelity': 'PROFESSIONAL',
},
True,
u"Invalid cielo24 turnaround None.",
400
),
(
{
'provider': TranscriptProvider.CIELO24,
'cielo24_fidelity': 'PROFESSIONAL',
'cielo24_turnaround': 'STANDARD',
'video_source_language': 'en'
},
True,
u"Invalid languages [].",
400
),
(
{
'provider': TranscriptProvider.CIELO24,
'cielo24_fidelity': 'PREMIUM',
'cielo24_turnaround': 'STANDARD',
'video_source_language': 'es'
},
True,
u"Unsupported source language es.",
400
),
(
{
'provider': TranscriptProvider.CIELO24,
'cielo24_fidelity': 'PROFESSIONAL',
'cielo24_turnaround': 'STANDARD',
'video_source_language': 'en',
'preferred_languages': ['es', 'ur']
},
True,
u"Invalid languages [u'es', u'ur'].",
400
),
(
{
'provider': TranscriptProvider.THREE_PLAY_MEDIA
},
True,
u"Invalid 3play turnaround None.",
400
),
(
{
'provider': TranscriptProvider.THREE_PLAY_MEDIA,
'three_play_turnaround': 'default',
'video_source_language': 'zh',
},
True,
u"Unsupported source language zh.",
400
),
(
{
'provider': TranscriptProvider.THREE_PLAY_MEDIA,
'three_play_turnaround': 'default',
'video_source_language': 'es',
'preferred_languages': ['es', 'ur']
},
True,
u"Invalid languages [u'es', u'ur'].",
400
),
(
{
'provider': TranscriptProvider.THREE_PLAY_MEDIA,
'three_play_turnaround': 'default',
'video_source_language': 'en',
'preferred_languages': ['es', 'ur']
},
True,
u"Invalid languages [u'es', u'ur'].",
400
),
# Success
(
{
'provider': TranscriptProvider.CIELO24,
'cielo24_fidelity': 'PROFESSIONAL',
'cielo24_turnaround': 'STANDARD',
'video_source_language': 'es',
'preferred_languages': ['en']
},
True,
'',
200
),
(
{
'provider': TranscriptProvider.THREE_PLAY_MEDIA,
'three_play_turnaround': 'default',
'preferred_languages': ['en'],
'video_source_language': 'en',
},
True,
'',
200
)
)
@ddt.unpack
def test_video_transcript(self, preferences, is_video_transcript_enabled, error_message, expected_status_code):
"""
Tests that transcript handler works correctly.
"""
video_transcript_url = self.get_url_for_course_key(self.course.id)
preferences_data = {
'provider': preferences.get('provider'),
'cielo24_fidelity': preferences.get('cielo24_fidelity'),
'cielo24_turnaround': preferences.get('cielo24_turnaround'),
'three_play_turnaround': preferences.get('three_play_turnaround'),
'preferred_languages': preferences.get('preferred_languages', []),
'video_source_language': preferences.get('video_source_language'),
}
with patch(
'openedx.core.djangoapps.video_config.models.VideoTranscriptEnabledFlag.feature_enabled'
) as video_transcript_feature:
video_transcript_feature.return_value = is_video_transcript_enabled
response = self.client.post(
video_transcript_url,
json.dumps(preferences_data),
content_type='application/json'
)
status_code = response.status_code
response = json.loads(response.content) if is_video_transcript_enabled else response
self.assertEqual(status_code, expected_status_code)
self.assertEqual(response.get('error', ''), error_message)
# Remove modified and course_id fields from the response so as to check the expected transcript preferences.
response.get('transcript_preferences', {}).pop('modified', None)
response.get('transcript_preferences', {}).pop('course_id', None)
expected_preferences = preferences_data if is_video_transcript_enabled and not error_message else {}
self.assertDictEqual(response.get('transcript_preferences', {}), expected_preferences)
def test_remove_transcript_preferences(self):
"""
Test that transcript handler removes transcript preferences correctly.
"""
# First add course wide transcript preferences.
preferences = create_or_update_transcript_preferences(unicode(self.course.id))
# Verify transcript preferences exist
self.assertIsNotNone(preferences)
response = self.client.delete(
self.get_url_for_course_key(self.course.id),
content_type='application/json'
)
self.assertEqual(response.status_code, 204)
# Verify transcript preferences no loger exist
preferences = get_transcript_preferences(unicode(self.course.id))
self.assertIsNone(preferences)
def test_remove_transcript_preferences_not_found(self):
"""
Test that transcript handler works correctly even when no preferences are found.
"""
course_id = 'course-v1:dummy+course+id'
# Verify transcript preferences do not exist
preferences = get_transcript_preferences(course_id)
self.assertIsNone(preferences)
response = self.client.delete(
self.get_url_for_course_key(course_id),
content_type='application/json'
)
self.assertEqual(response.status_code, 204)
# Verify transcript preferences do not exist
preferences = get_transcript_preferences(course_id)
self.assertIsNone(preferences)
@ddt.data(
(
None,
False
),
(
{
'provider': TranscriptProvider.CIELO24,
'cielo24_fidelity': 'PROFESSIONAL',
'cielo24_turnaround': 'STANDARD',
'preferred_languages': ['en']
},
False
),
(
{
'provider': TranscriptProvider.CIELO24,
'cielo24_fidelity': 'PROFESSIONAL',
'cielo24_turnaround': 'STANDARD',
'preferred_languages': ['en']
},
True
)
)
@ddt.unpack
@override_settings(AWS_ACCESS_KEY_ID='test_key_id', AWS_SECRET_ACCESS_KEY='test_secret')
@patch('boto.s3.key.Key')
@patch('boto.s3.connection.S3Connection')
@patch('contentstore.views.videos.get_transcript_preferences')
def test_transcript_preferences_metadata(self, transcript_preferences, is_video_transcript_enabled,
mock_transcript_preferences, mock_conn, mock_key):
"""
Tests that transcript preference metadata is only set if it is video transcript feature is enabled and
transcript preferences are already stored in the system.
"""
file_name = 'test-video.mp4'
request_data = {'files': [{'file_name': file_name, 'content_type': 'video/mp4'}]}
mock_transcript_preferences.return_value = transcript_preferences
bucket = Mock()
mock_conn.return_value = Mock(get_bucket=Mock(return_value=bucket))
mock_key_instance = Mock(
generate_url=Mock(
return_value='http://example.com/url_{file_name}'.format(file_name=file_name)
)
)
# If extra calls are made, return a dummy
mock_key.side_effect = [mock_key_instance] + [Mock()]
videos_handler_url = reverse_course_url('videos_handler', self.course.id)
with patch(
'openedx.core.djangoapps.video_config.models.VideoTranscriptEnabledFlag.feature_enabled'
) as video_transcript_feature:
video_transcript_feature.return_value = is_video_transcript_enabled
response = self.client.post(videos_handler_url, json.dumps(request_data), content_type='application/json')
self.assertEqual(response.status_code, 200)
# Ensure `transcript_preferences` was set up in Key correctly if sent through request.
if is_video_transcript_enabled and transcript_preferences:
mock_key_instance.set_metadata.assert_any_call('transcript_preferences', json.dumps(transcript_preferences))
else:
with self.assertRaises(AssertionError):
mock_key_instance.set_metadata.assert_any_call(
'transcript_preferences', json.dumps(transcript_preferences)
)
@patch.dict("django.conf.settings.FEATURES", {"ENABLE_VIDEO_UPLOAD_PIPELINE": True}) @patch.dict("django.conf.settings.FEATURES", {"ENABLE_VIDEO_UPLOAD_PIPELINE": True})
@override_settings(VIDEO_UPLOAD_PIPELINE={"BUCKET": "test_bucket", "ROOT_PATH": "test_root"}) @override_settings(VIDEO_UPLOAD_PIPELINE={"BUCKET": "test_bucket", "ROOT_PATH": "test_root"})
class VideoUrlsCsvTestCase(VideoUploadTestMixin, CourseTestCase): class VideoUrlsCsvTestCase(VideoUploadTestMixin, CourseTestCase):
......
...@@ -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,
......
...@@ -4,6 +4,7 @@ Views related to the video upload feature ...@@ -4,6 +4,7 @@ Views related to the video upload feature
from contextlib import closing from contextlib import closing
import csv import csv
import json
import logging import logging
from datetime import datetime, timedelta from datetime import datetime, timedelta
from uuid import uuid4 from uuid import uuid4
...@@ -25,9 +26,14 @@ from edxval.api import ( ...@@ -25,9 +26,14 @@ from edxval.api import (
get_videos_for_course, get_videos_for_course,
remove_video_for_course, remove_video_for_course,
update_video_status, update_video_status,
update_video_image update_video_image,
get_3rd_party_transcription_plans,
get_transcript_preferences,
create_or_update_transcript_preferences,
remove_transcript_preferences,
) )
from opaque_keys.edx.keys import CourseKey from opaque_keys.edx.keys import CourseKey
from openedx.core.djangoapps.video_config.models import VideoTranscriptEnabledFlag
from openedx.core.djangoapps.waffle_utils import WaffleSwitchNamespace from openedx.core.djangoapps.waffle_utils import WaffleSwitchNamespace
from contentstore.models import VideoUploadConfig from contentstore.models import VideoUploadConfig
...@@ -38,7 +44,7 @@ from util.json_request import JsonResponse, expect_json ...@@ -38,7 +44,7 @@ from util.json_request import JsonResponse, expect_json
from .course import get_course_and_check_access from .course import get_course_and_check_access
__all__ = ['videos_handler', 'video_encodings_download', 'video_images_handler'] __all__ = ['videos_handler', 'video_encodings_download', 'video_images_handler', 'transcript_preferences_handler']
LOGGER = logging.getLogger(__name__) LOGGER = logging.getLogger(__name__)
...@@ -63,6 +69,14 @@ VIDEO_UPLOAD_MAX_FILE_SIZE_GB = 5 ...@@ -63,6 +69,14 @@ VIDEO_UPLOAD_MAX_FILE_SIZE_GB = 5
MAX_UPLOAD_HOURS = 24 MAX_UPLOAD_HOURS = 24
class TranscriptProvider(object):
"""
3rd Party Transcription Provider Enumeration
"""
CIELO24 = 'Cielo24'
THREE_PLAY_MEDIA = '3PlayMedia'
class StatusDisplayStrings(object): class StatusDisplayStrings(object):
""" """
A class to map status strings as stored in VAL to display strings for the A class to map status strings as stored in VAL to display strings for the
...@@ -93,6 +107,10 @@ class StatusDisplayStrings(object): ...@@ -93,6 +107,10 @@ class StatusDisplayStrings(object):
_IMPORTED = ugettext_noop("Imported") _IMPORTED = ugettext_noop("Imported")
# Translators: This is the status for a video that is in an unknown state # Translators: This is the status for a video that is in an unknown state
_UNKNOWN = ugettext_noop("Unknown") _UNKNOWN = ugettext_noop("Unknown")
# Translators: This is the status for a video that is having its transcription in progress on servers
_TRANSCRIPTION_IN_PROGRESS = ugettext_noop("Transcription in Progress")
# Translators: This is the status for a video whose transcription is complete
_TRANSCRIPT_READY = ugettext_noop("Transcript Ready")
_STATUS_MAP = { _STATUS_MAP = {
"upload": _UPLOADING, "upload": _UPLOADING,
...@@ -111,6 +129,8 @@ class StatusDisplayStrings(object): ...@@ -111,6 +129,8 @@ class StatusDisplayStrings(object):
"youtube_duplicate": _YOUTUBE_DUPLICATE, "youtube_duplicate": _YOUTUBE_DUPLICATE,
"invalid_token": _INVALID_TOKEN, "invalid_token": _INVALID_TOKEN,
"imported": _IMPORTED, "imported": _IMPORTED,
"transcription_in_progress": _TRANSCRIPTION_IN_PROGRESS,
"transcript_ready": _TRANSCRIPT_READY,
} }
@staticmethod @staticmethod
...@@ -236,6 +256,130 @@ def video_images_handler(request, course_key_string, edx_video_id=None): ...@@ -236,6 +256,130 @@ def video_images_handler(request, course_key_string, edx_video_id=None):
return JsonResponse({'image_url': image_url}) return JsonResponse({'image_url': image_url})
def validate_transcript_preferences(provider, cielo24_fidelity, cielo24_turnaround,
three_play_turnaround, video_source_language, preferred_languages):
"""
Validate 3rd Party Transcription Preferences.
Arguments:
provider: Transcription provider
cielo24_fidelity: Cielo24 transcription fidelity.
cielo24_turnaround: Cielo24 transcription turnaround.
three_play_turnaround: 3PlayMedia transcription turnaround.
video_source_language: Source/Speech language of the videos that are going to be submitted to the Providers.
preferred_languages: list of language codes.
Returns:
validated preferences or a validation error.
"""
error, preferences = None, {}
# validate transcription providers
transcription_plans = get_3rd_party_transcription_plans()
if provider in transcription_plans.keys():
# Further validations for providers
if provider == TranscriptProvider.CIELO24:
# Validate transcription fidelity
if cielo24_fidelity in transcription_plans[provider]['fidelity']:
# Validate transcription turnaround
if cielo24_turnaround not in transcription_plans[provider]['turnaround']:
error = 'Invalid cielo24 turnaround {}.'.format(cielo24_turnaround)
return error, preferences
# Validate transcription languages
supported_languages = transcription_plans[provider]['fidelity'][cielo24_fidelity]['languages']
if video_source_language not in supported_languages:
error = 'Unsupported source language {}.'.format(video_source_language)
return error, preferences
if not len(preferred_languages) or not (set(preferred_languages) <= set(supported_languages.keys())):
error = 'Invalid languages {}.'.format(preferred_languages)
return error, preferences
# Validated Cielo24 preferences
preferences = {
'video_source_language': video_source_language,
'cielo24_fidelity': cielo24_fidelity,
'cielo24_turnaround': cielo24_turnaround,
'preferred_languages': preferred_languages,
}
else:
error = 'Invalid cielo24 fidelity {}.'.format(cielo24_fidelity)
elif provider == TranscriptProvider.THREE_PLAY_MEDIA:
# Validate transcription turnaround
if three_play_turnaround not in transcription_plans[provider]['turnaround']:
error = 'Invalid 3play turnaround {}.'.format(three_play_turnaround)
return error, preferences
# Validate transcription languages
valid_translations_map = transcription_plans[provider]['translations']
if video_source_language not in valid_translations_map.keys():
error = 'Unsupported source language {}.'.format(video_source_language)
return error, preferences
valid_target_languages = valid_translations_map[video_source_language]
if not len(preferred_languages) or not (set(preferred_languages) <= set(valid_target_languages)):
error = 'Invalid languages {}.'.format(preferred_languages)
return error, preferences
# Validated 3PlayMedia preferences
preferences = {
'three_play_turnaround': three_play_turnaround,
'video_source_language': video_source_language,
'preferred_languages': preferred_languages,
}
else:
error = 'Invalid provider {}.'.format(provider)
return error, preferences
@expect_json
@login_required
@require_http_methods(('POST', 'DELETE'))
def transcript_preferences_handler(request, course_key_string):
"""
JSON view handler to post the transcript preferences.
Arguments:
request: WSGI request object
course_key_string: string for course key
Returns: valid json response or 400 with error message
"""
course_key = CourseKey.from_string(course_key_string)
is_video_transcript_enabled = VideoTranscriptEnabledFlag.feature_enabled(course_key)
if not is_video_transcript_enabled:
return HttpResponseNotFound()
if request.method == 'POST':
data = request.json
provider = data.get('provider')
error, preferences = validate_transcript_preferences(
provider=provider,
cielo24_fidelity=data.get('cielo24_fidelity', ''),
cielo24_turnaround=data.get('cielo24_turnaround', ''),
three_play_turnaround=data.get('three_play_turnaround', ''),
video_source_language=data.get('video_source_language'),
preferred_languages=data.get('preferred_languages', [])
)
if error:
response = JsonResponse({'error': error}, status=400)
else:
preferences.update({'provider': provider})
transcript_preferences = create_or_update_transcript_preferences(course_key_string, **preferences)
response = JsonResponse({'transcript_preferences': transcript_preferences}, status=200)
return response
elif request.method == 'DELETE':
remove_transcript_preferences(course_key_string)
return JsonResponse()
@login_required @login_required
@require_GET @require_GET
def video_encodings_download(request, course_key_string): def video_encodings_download(request, course_key_string):
...@@ -424,28 +568,41 @@ def videos_index_html(course): ...@@ -424,28 +568,41 @@ def videos_index_html(course):
""" """
Returns an HTML page to display previous video uploads and allow new ones Returns an HTML page to display previous video uploads and allow new ones
""" """
return render_to_response( is_video_transcript_enabled = VideoTranscriptEnabledFlag.feature_enabled(course.id)
'videos_index.html', context = {
{ 'context_course': course,
'context_course': course, 'image_upload_url': reverse_course_url('video_images_handler', unicode(course.id)),
'image_upload_url': reverse_course_url('video_images_handler', unicode(course.id)), 'video_handler_url': reverse_course_url('videos_handler', unicode(course.id)),
'video_handler_url': reverse_course_url('videos_handler', unicode(course.id)), 'encodings_download_url': reverse_course_url('video_encodings_download', unicode(course.id)),
'encodings_download_url': reverse_course_url('video_encodings_download', unicode(course.id)), 'default_video_image_url': _get_default_video_image_url(),
'default_video_image_url': _get_default_video_image_url(), 'previous_uploads': _get_index_videos(course),
'previous_uploads': _get_index_videos(course), 'concurrent_upload_limit': settings.VIDEO_UPLOAD_PIPELINE.get('CONCURRENT_UPLOAD_LIMIT', 0),
'concurrent_upload_limit': settings.VIDEO_UPLOAD_PIPELINE.get('CONCURRENT_UPLOAD_LIMIT', 0), 'video_supported_file_formats': VIDEO_SUPPORTED_FILE_FORMATS.keys(),
'video_supported_file_formats': VIDEO_SUPPORTED_FILE_FORMATS.keys(), 'video_upload_max_file_size': VIDEO_UPLOAD_MAX_FILE_SIZE_GB,
'video_upload_max_file_size': VIDEO_UPLOAD_MAX_FILE_SIZE_GB, 'video_image_settings': {
'video_image_settings': { 'video_image_upload_enabled': WAFFLE_SWITCHES.is_enabled(VIDEO_IMAGE_UPLOAD_ENABLED),
'video_image_upload_enabled': WAFFLE_SWITCHES.is_enabled(VIDEO_IMAGE_UPLOAD_ENABLED), 'max_size': settings.VIDEO_IMAGE_SETTINGS['VIDEO_IMAGE_MAX_BYTES'],
'max_size': settings.VIDEO_IMAGE_SETTINGS['VIDEO_IMAGE_MAX_BYTES'], 'min_size': settings.VIDEO_IMAGE_SETTINGS['VIDEO_IMAGE_MIN_BYTES'],
'min_size': settings.VIDEO_IMAGE_SETTINGS['VIDEO_IMAGE_MIN_BYTES'], 'max_width': settings.VIDEO_IMAGE_MAX_WIDTH,
'max_width': settings.VIDEO_IMAGE_MAX_WIDTH, 'max_height': settings.VIDEO_IMAGE_MAX_HEIGHT,
'max_height': settings.VIDEO_IMAGE_MAX_HEIGHT, 'supported_file_formats': settings.VIDEO_IMAGE_SUPPORTED_FILE_FORMATS
'supported_file_formats': settings.VIDEO_IMAGE_SUPPORTED_FILE_FORMATS },
} 'is_video_transcript_enabled': is_video_transcript_enabled,
'video_transcript_settings': None,
'active_transcript_preferences': None
}
if is_video_transcript_enabled:
context['video_transcript_settings'] = {
'transcript_preferences_handler_url': reverse_course_url(
'transcript_preferences_handler',
unicode(course.id)
),
'transcription_plans': get_3rd_party_transcription_plans(),
} }
) context['active_transcript_preferences'] = get_transcript_preferences(unicode(course.id))
return render_to_response('videos_index.html', context)
def videos_index_json(course): def videos_index_json(course):
...@@ -486,16 +643,17 @@ def videos_post(course, request): ...@@ -486,16 +643,17 @@ def videos_post(course, request):
The returned array corresponds exactly to the input array. The returned array corresponds exactly to the input array.
""" """
error = None error = None
if 'files' not in request.json: data = request.json
if 'files' not in data:
error = "Request object is not JSON or does not contain 'files'" error = "Request object is not JSON or does not contain 'files'"
elif any( elif any(
'file_name' not in file or 'content_type' not in file 'file_name' not in file or 'content_type' not in file
for file in request.json['files'] for file in data['files']
): ):
error = "Request 'files' entry does not contain 'file_name' and 'content_type'" error = "Request 'files' entry does not contain 'file_name' and 'content_type'"
elif any( elif any(
file['content_type'] not in VIDEO_SUPPORTED_FILE_FORMATS.values() file['content_type'] not in VIDEO_SUPPORTED_FILE_FORMATS.values()
for file in request.json['files'] for file in data['files']
): ):
error = "Request 'files' entry contain unsupported content_type" error = "Request 'files' entry contain unsupported content_type"
...@@ -504,7 +662,7 @@ def videos_post(course, request): ...@@ -504,7 +662,7 @@ def videos_post(course, request):
bucket = storage_service_bucket() bucket = storage_service_bucket()
course_video_upload_token = course.video_upload_pipeline['course_video_upload_token'] course_video_upload_token = course.video_upload_pipeline['course_video_upload_token']
req_files = request.json['files'] req_files = data['files']
resp_files = [] resp_files = []
for req_file in req_files: for req_file in req_files:
...@@ -518,11 +676,20 @@ def videos_post(course, request): ...@@ -518,11 +676,20 @@ def videos_post(course, request):
edx_video_id = unicode(uuid4()) edx_video_id = unicode(uuid4())
key = storage_service_key(bucket, file_name=edx_video_id) key = storage_service_key(bucket, file_name=edx_video_id)
for metadata_name, value in [
('course_video_upload_token', course_video_upload_token), metadata_list = [
('client_video_id', file_name), ('course_video_upload_token', course_video_upload_token),
('course_key', unicode(course.id)), ('client_video_id', file_name),
]: ('course_key', unicode(course.id)),
]
is_video_transcript_enabled = VideoTranscriptEnabledFlag.feature_enabled(course.id)
if is_video_transcript_enabled:
transcript_preferences = get_transcript_preferences(unicode(course.id))
if transcript_preferences is not None:
metadata_list.append(('transcript_preferences', json.dumps(transcript_preferences)))
for metadata_name, value in metadata_list:
key.set_metadata(metadata_name, value) key.set_metadata(metadata_name, value)
upload_url = key.generate_url( upload_url = key.generate_url(
KEY_EXPIRATION_IN_SECONDS, KEY_EXPIRATION_IN_SECONDS,
......
...@@ -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');
......
define(
['jquery', 'underscore', 'backbone', 'edx-ui-toolkit/js/utils/spec-helpers/ajax-helpers',
'js/views/course_video_settings', 'common/js/spec_helpers/template_helpers'],
function($, _, Backbone, AjaxHelpers, CourseVideoSettingsView, TemplateHelpers) {
'use strict';
describe('CourseVideoSettingsView', function() {
var $courseVideoSettingsEl,
courseVideoSettingsView,
renderCourseVideoSettingsView,
destroyCourseVideoSettingsView,
verifyPreferanceErrorState,
selectPreference,
chooseProvider,
transcriptPreferencesUrl = '/transcript_preferences/course-v1:edX+DemoX+Demo_Course',
activeTranscriptPreferences = {
provider: 'Cielo24',
cielo24_fidelity: 'PROFESSIONAL',
cielo24_turnaround: 'PRIORITY',
three_play_turnaround: '',
video_source_language: 'en',
preferred_languages: ['fr', 'en'],
modified: '2017-08-27T12:28:17.421260Z'
},
transcriptionPlans = {
'3PlayMedia': {
languages: {
fr: 'French',
en: 'English',
ur: 'Urdu'
},
turnaround: {
default: '4-Day/Default',
same_day_service: 'Same Day',
rush_service: '24-hour/Rush',
extended_service: '10-Day/Extended',
expedited_service: '2-Day/Expedited'
},
translations: {
es: ['en'],
en: ['en', 'ur']
},
display_name: '3PlayMedia'
},
Cielo24: {
turnaround: {
PRIORITY: 'Priority, 24h',
STANDARD: 'Standard, 48h'
},
fidelity: {
PROFESSIONAL: {
languages: {
ru: 'Russian',
fr: 'French',
en: 'English'
},
display_name: 'Professional, 99% Accuracy'
},
PREMIUM: {
languages: {
en: 'English'
},
display_name: 'Premium, 95% Accuracy'
},
MECHANICAL: {
languages: {
fr: 'French',
en: 'English',
nl: 'Dutch'
},
display_name: 'Mechanical, 75% Accuracy'
}
},
display_name: 'Cielo24'
}
};
renderCourseVideoSettingsView = function(activeTranscriptPreferencesData, transcriptionPlansData) {
courseVideoSettingsView = new CourseVideoSettingsView({
activeTranscriptPreferences: activeTranscriptPreferencesData || null,
videoTranscriptSettings: {
transcript_preferences_handler_url: transcriptPreferencesUrl,
transcription_plans: transcriptionPlansData || null
}
});
$courseVideoSettingsEl = courseVideoSettingsView.render().$el;
};
destroyCourseVideoSettingsView = function() {
if (courseVideoSettingsView) {
courseVideoSettingsView.closeCourseVideoSettings();
courseVideoSettingsView = null;
}
};
verifyPreferanceErrorState = function($preferanceContainerEl, hasError) {
var $errorIconHtml = hasError ? '<span class="icon fa fa-info-circle" aria-hidden="true"></span>' : '',
requiredText = hasError ? 'Required' : '';
expect($preferanceContainerEl.hasClass('error')).toEqual(hasError);
expect($preferanceContainerEl.find('.error-icon').html()).toEqual($errorIconHtml);
expect($preferanceContainerEl.find('.error-info').html()).toEqual(requiredText);
};
selectPreference = function(preferenceSelector, preferanceValue) {
var $preference = $courseVideoSettingsEl.find(preferenceSelector);
// Select a vlaue for preference.
$preference.val(preferanceValue);
// Trigger on change event.
$preference.change();
};
chooseProvider = function(selectedProvider) {
$courseVideoSettingsEl.find('#transcript-provider-' + selectedProvider).click();
};
beforeEach(function() {
setFixtures(
'<div class="video-transcript-settings-wrapper"></div>' +
'<button class="button course-video-settings-button"></button>'
);
TemplateHelpers.installTemplate('course-video-settings');
renderCourseVideoSettingsView(activeTranscriptPreferences, transcriptionPlans);
});
afterEach(function() {
destroyCourseVideoSettingsView();
});
it('renders as expected', function() {
expect($courseVideoSettingsEl.find('.course-video-settings-container')).toExist();
});
it('closes course video settings pane when close button is clicked', function() {
expect($courseVideoSettingsEl.find('.course-video-settings-container')).toExist();
$courseVideoSettingsEl.find('.action-close-course-video-settings').click();
expect($courseVideoSettingsEl.find('.course-video-settings-container')).not.toExist();
});
it('closes course video settings pane when clicked outside course video settings pane', function() {
expect($courseVideoSettingsEl.find('.course-video-settings-container')).toExist();
$('body').click();
expect($courseVideoSettingsEl.find('.course-video-settings-container')).not.toExist();
});
it('does not close course video settings pane when clicked inside course video settings pane', function() {
expect($courseVideoSettingsEl.find('.course-video-settings-container')).toExist();
$courseVideoSettingsEl.find('.transcript-provider-group').click();
expect($courseVideoSettingsEl.find('.course-video-settings-container')).toExist();
});
it('does not populate transcription plans if transcription plans are not provided', function() {
// First detroy old referance to the view.
destroyCourseVideoSettingsView();
// Create view with empty data.
renderCourseVideoSettingsView(null, null);
expect($courseVideoSettingsEl.find('.transcript-provider-group').html()).toEqual('');
expect($courseVideoSettingsEl.find('.transcript-turnaround').html()).toEqual('');
expect($courseVideoSettingsEl.find('.transcript-fidelity').html()).toEqual('');
expect($courseVideoSettingsEl.find('.video-source-language').html()).toEqual('');
expect($courseVideoSettingsEl.find('.transcript-language-menu').html()).toEqual('');
});
it('populates transcription plans correctly', function() {
// Only check transcript-provider radio buttons for now, because other preferances are based on either
// user selection or activeTranscriptPreferences.
expect($courseVideoSettingsEl.find('.transcript-provider-group').html()).not.toEqual('');
});
it('populates active preferances correctly', function() {
// First check preferance are selected correctly in HTML.
expect($courseVideoSettingsEl.find('.transcript-provider-group input:checked').val()).toEqual(
activeTranscriptPreferences.provider
);
expect($courseVideoSettingsEl.find('.transcript-turnaround').val()).toEqual(
activeTranscriptPreferences.cielo24_turnaround
);
expect($courseVideoSettingsEl.find('.transcript-fidelity').val()).toEqual(
activeTranscriptPreferences.cielo24_fidelity
);
expect($courseVideoSettingsEl.find('.video-source-language').val()).toEqual(
activeTranscriptPreferences.video_source_language
);
expect($courseVideoSettingsEl.find('.transcript-language-container').length).toEqual(
activeTranscriptPreferences.preferred_languages.length
);
// Now check values are assigned correctly.
expect(courseVideoSettingsView.selectedProvider, activeTranscriptPreferences.provider);
expect(courseVideoSettingsView.selectedTurnaroundPlan, activeTranscriptPreferences.cielo24_turnaround);
expect(courseVideoSettingsView.selectedFidelityPlan, activeTranscriptPreferences.cielo24_fidelity);
expect(
courseVideoSettingsView.selectedSourceLanguage,
activeTranscriptPreferences.video_source_language
);
expect(courseVideoSettingsView.selectedLanguages, activeTranscriptPreferences.preferred_languages);
});
it('shows video source language directly in case of 3Play provider', function() {
var sourceLanguages,
selectedProvider = '3PlayMedia';
// Select CIELIO24 provider
chooseProvider(selectedProvider);
expect(courseVideoSettingsView.selectedProvider).toEqual(selectedProvider);
// Verify source langauges menu is shown.
sourceLanguages = courseVideoSettingsView.getSourceLanguages();
expect($courseVideoSettingsEl.find('.video-source-language option')).toExist();
expect($courseVideoSettingsEl.find('.video-source-language option').length).toEqual(
_.keys(sourceLanguages).length + 1
);
expect(_.keys(transcriptionPlans[selectedProvider].translations)).toEqual(_.keys(sourceLanguages));
});
it('shows source language when fidelity is selected', function() {
var sourceLanguages,
selectedProvider = 'Cielo24',
selectedFidelity = 'PROFESSIONAL';
renderCourseVideoSettingsView(null, transcriptionPlans);
// Select CIELIO24 provider
chooseProvider(selectedProvider);
expect(courseVideoSettingsView.selectedProvider).toEqual(selectedProvider);
// Verify source language is not shown.
sourceLanguages = courseVideoSettingsView.getSourceLanguages();
expect($courseVideoSettingsEl.find('.video-source-language option')).not.toExist();
expect(sourceLanguages).toBeUndefined();
// Select fidelity
selectPreference('.transcript-fidelity', selectedFidelity);
expect(courseVideoSettingsView.selectedFidelityPlan).toEqual(selectedFidelity);
// Verify source langauges menu is shown.
sourceLanguages = courseVideoSettingsView.getSourceLanguages();
expect($courseVideoSettingsEl.find('.video-source-language option')).toExist();
expect($courseVideoSettingsEl.find('.video-source-language option').length).toEqual(
_.keys(sourceLanguages).length + 1
);
// Verify getSourceLangaues return a list of langauges.
expect(sourceLanguages).toBeDefined();
expect(transcriptionPlans[selectedProvider].fidelity[selectedFidelity].languages).toEqual(
sourceLanguages
);
});
it('shows target language when source language is selected', function() {
var targetLanguages,
selectedSourceLanguage = 'en',
selectedProvider = 'Cielo24',
selectedFidelity = 'PROFESSIONAL';
// Select CIELIO24 provider
chooseProvider(selectedProvider);
expect(courseVideoSettingsView.selectedProvider).toEqual(selectedProvider);
// Select fidelity
selectPreference('.transcript-fidelity', selectedFidelity);
expect(courseVideoSettingsView.selectedFidelityPlan).toEqual(selectedFidelity);
// Verify target langauges not shown.
expect($courseVideoSettingsEl.find('.transcript-language-menu:visible option')).not.toExist();
// Select source language
selectPreference('.video-source-language', selectedSourceLanguage);
expect(courseVideoSettingsView.selectedVideoSourceLanguage).toEqual(selectedSourceLanguage);
// Verify target languages are shown.
targetLanguages = courseVideoSettingsView.getTargetLanguages();
expect($courseVideoSettingsEl.find('.transcript-language-menu:visible option')).toExist();
expect($courseVideoSettingsEl.find('.transcript-language-menu:visible option').length).toEqual(
_.keys(targetLanguages).length + 1
);
});
it('shows target language same as selected source language in case of mechanical fidelity', function() {
var targetLanguages,
selectedSourceLanguage = 'en',
selectedProvider = 'Cielo24',
selectedFidelity = 'MECHANICAL';
// Select CIELIO24 provider
chooseProvider(selectedProvider);
expect(courseVideoSettingsView.selectedProvider).toEqual(selectedProvider);
// Select fidelity
selectPreference('.transcript-fidelity', selectedFidelity);
expect(courseVideoSettingsView.selectedFidelityPlan).toEqual(selectedFidelity);
// Select source language
selectPreference('.video-source-language', selectedSourceLanguage);
expect(courseVideoSettingsView.selectedVideoSourceLanguage).toEqual(selectedSourceLanguage);
// Verify target languages are shown.
targetLanguages = courseVideoSettingsView.getTargetLanguages();
expect($courseVideoSettingsEl.find('.transcript-language-menu:visible option')).toExist();
expect($courseVideoSettingsEl.find('.transcript-language-menu:visible option').length).toEqual(
_.keys(targetLanguages).length + 1
);
// Also verify that target language are same as selected source language.
expect(_.keys(targetLanguages).length).toEqual(1);
expect(_.keys(targetLanguages)).toEqual([selectedSourceLanguage]);
});
it('saves transcript settings on update settings button click if preferances are selected', function() {
var requests = AjaxHelpers.requests(this);
$courseVideoSettingsEl.find('.action-update-course-video-settings').click();
AjaxHelpers.expectRequest(
requests,
'POST',
transcriptPreferencesUrl,
JSON.stringify({
provider: activeTranscriptPreferences.provider,
cielo24_fidelity: activeTranscriptPreferences.cielo24_fidelity,
cielo24_turnaround: activeTranscriptPreferences.cielo24_turnaround,
three_play_turnaround: activeTranscriptPreferences.three_play_turnaround,
preferred_languages: activeTranscriptPreferences.preferred_languages,
video_source_language: activeTranscriptPreferences.video_source_language,
global: false
})
);
// Send successful response.
AjaxHelpers.respondWithJson(requests, {
transcript_preferences: activeTranscriptPreferences
});
// Verify that success message is shown.
expect($courseVideoSettingsEl.find('.course-video-settings-message-wrapper.success').html()).toEqual(
'<div class="course-video-settings-message">' +
'<span class="icon fa fa-check-circle" aria-hidden="true"></span>' +
'<span>Settings updated</span>' +
'</div>'
);
});
it('removes transcript settings on update settings button click when no provider is selected', function() {
var requests = AjaxHelpers.requests(this);
// Set no provider selected
courseVideoSettingsView.selectedProvider = null;
$courseVideoSettingsEl.find('.action-update-course-video-settings').click();
AjaxHelpers.expectRequest(
requests,
'DELETE',
transcriptPreferencesUrl
);
// Send successful empty content response.
AjaxHelpers.respondWithJson(requests, {});
// Verify that success message is shown.
expect($courseVideoSettingsEl.find('.course-video-settings-message-wrapper.success').html()).toEqual(
'<div class="course-video-settings-message">' +
'<span class="icon fa fa-check-circle" aria-hidden="true"></span>' +
'<span>Settings updated</span>' +
'</div>'
);
});
it('shows error message if server sends error', function() {
var requests = AjaxHelpers.requests(this);
$courseVideoSettingsEl.find('.action-update-course-video-settings').click();
AjaxHelpers.expectRequest(
requests,
'POST',
transcriptPreferencesUrl,
JSON.stringify({
provider: activeTranscriptPreferences.provider,
cielo24_fidelity: activeTranscriptPreferences.cielo24_fidelity,
cielo24_turnaround: activeTranscriptPreferences.cielo24_turnaround,
three_play_turnaround: activeTranscriptPreferences.three_play_turnaround,
preferred_languages: activeTranscriptPreferences.preferred_languages,
video_source_language: activeTranscriptPreferences.video_source_language,
global: false
})
);
// Send error response.
AjaxHelpers.respondWithError(requests, 400, {
error: 'Error message'
});
// Verify that error message is shown.
expect($courseVideoSettingsEl.find('.course-video-settings-message-wrapper.error').html()).toEqual(
'<div class="course-video-settings-message">' +
'<span class="icon fa fa-info-circle" aria-hidden="true"></span>' +
'<span>Error message</span>' +
'</div>'
);
});
it('implies preferences are required if not selected when saving preferances', function() {
// Reset so that no preferance is selected.
courseVideoSettingsView.selectedTurnaroundPlan = '';
courseVideoSettingsView.selectedFidelityPlan = '';
courseVideoSettingsView.selectedLanguages = [];
$courseVideoSettingsEl.find('.action-update-course-video-settings').click();
verifyPreferanceErrorState($courseVideoSettingsEl.find('.transcript-turnaround-wrapper'), true);
verifyPreferanceErrorState($courseVideoSettingsEl.find('.transcript-fidelity-wrapper'), true);
verifyPreferanceErrorState($courseVideoSettingsEl.find('.transcript-languages-wrapper'), true);
});
it('removes error state on preferances if selected', function() {
// Provide values for preferances.
selectPreference('.transcript-turnaround', activeTranscriptPreferences.cielo24_turnaround);
selectPreference('.transcript-fidelity', activeTranscriptPreferences.cielo24_fidelity);
selectPreference('.video-source-language', activeTranscriptPreferences.video_source_language);
selectPreference('.transcript-language-menu', activeTranscriptPreferences.preferred_languages[0]);
verifyPreferanceErrorState($courseVideoSettingsEl.find('.transcript-turnaround-wrapper'), false);
verifyPreferanceErrorState($courseVideoSettingsEl.find('.transcript-fidelity-wrapper'), false);
verifyPreferanceErrorState($courseVideoSettingsEl.find('.transcript-languages-wrapper'), false);
});
// TODO: Add more tests like clicking on add language, remove and their scenarios and some other tests
// like N/A selected, specific provider selected tests, specific preferance selected tests etc.
});
}
);
...@@ -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;
}, },
......
/**
* CourseVideoSettingsView shows a sidebar containing course wide video settings which we show on Video Uploads page.
*/
define([
'jquery', 'backbone', 'underscore', 'gettext', 'moment',
'edx-ui-toolkit/js/utils/html-utils',
'edx-ui-toolkit/js/utils/string-utils',
'text!templates/course-video-settings.underscore'
],
function($, Backbone, _, gettext, moment, HtmlUtils, StringUtils, TranscriptSettingsTemplate) {
'use strict';
var CourseVideoSettingsView,
CIELO24 = 'Cielo24',
THREE_PLAY_MEDIA = '3PlayMedia';
CourseVideoSettingsView = Backbone.View.extend({
el: 'div.video-transcript-settings-wrapper',
events: {
'change .transcript-provider-group input': 'providerSelected',
'change #transcript-turnaround': 'turnaroundSelected',
'change #transcript-fidelity': 'fidelitySelected',
'change #video-source-language': 'videoSourceLanguageSelected',
'click .action-add-language': 'languageSelected',
'click .action-remove-language': 'languageRemoved',
'click .action-update-course-video-settings': 'updateCourseVideoSettings',
'click .action-close-course-video-settings': 'closeCourseVideoSettings'
},
initialize: function(options) {
var videoTranscriptSettings = options.videoTranscriptSettings;
this.activeTranscriptionPlan = options.activeTranscriptPreferences;
this.availableTranscriptionPlans = videoTranscriptSettings.transcription_plans;
this.transcriptHandlerUrl = videoTranscriptSettings.transcript_preferences_handler_url;
this.template = HtmlUtils.template(TranscriptSettingsTemplate);
this.setActiveTranscriptPlanData();
this.selectedLanguages = [];
},
registerCloseClickHandler: function() {
var self = this;
// Preventing any parent handlers from being notified of the event. This is to stop from firing the document
// level click handler to execute on course video settings pane click.
self.$el.click(function(event) {
event.stopPropagation();
});
// Click anywhere outside the course video settings pane would close the pane.
$(document).click(function(event) {
// if the target of the click isn't the container nor a descendant of the contain
if (!self.$el.is(event.target) && self.$el.has(event.target).length === 0) {
self.closeCourseVideoSettings();
}
});
},
resetPlanData: function() {
this.selectedProvider = '';
this.selectedTurnaroundPlan = '';
this.selectedFidelityPlan = '';
this.activeLanguages = [];
this.selectedVideoSourceLanguage = '';
this.selectedLanguages = [];
},
setActiveTranscriptPlanData: function() {
if (this.activeTranscriptionPlan) {
this.selectedProvider = this.activeTranscriptionPlan.provider;
this.selectedFidelityPlan = this.activeTranscriptionPlan.cielo24_fidelity;
this.selectedTurnaroundPlan = this.selectedProvider === CIELO24 ?
this.activeTranscriptionPlan.cielo24_turnaround :
this.activeTranscriptionPlan.three_play_turnaround;
this.activeLanguages = this.activeTranscriptionPlan.preferred_languages;
this.selectedVideoSourceLanguage = this.activeTranscriptionPlan.video_source_language;
} else {
this.resetPlanData();
}
},
getTurnaroundPlan: function() {
var turnaroundPlan = null;
if (this.selectedProvider) {
turnaroundPlan = this.availableTranscriptionPlans[this.selectedProvider].turnaround;
}
return turnaroundPlan;
},
getFidelityPlan: function() {
var fidelityPlan = null;
if (this.selectedProvider === CIELO24) {
fidelityPlan = this.availableTranscriptionPlans[this.selectedProvider].fidelity;
}
return fidelityPlan;
},
getTargetLanguages: function() {
var availableLanguages,
selectedPlan = this.selectedProvider ? this.availableTranscriptionPlans[this.selectedProvider] : null;
if (selectedPlan) {
if (this.selectedProvider === CIELO24 && this.selectedFidelityPlan) {
availableLanguages = selectedPlan.fidelity[this.selectedFidelityPlan].languages;
// If fidelity is mechanical then target language would be same as source language.
if (this.selectedFidelityPlan === 'MECHANICAL' && this.selectedVideoSourceLanguage) {
availableLanguages = _.pick(
availableLanguages,
this.selectedVideoSourceLanguage
);
}
} else if (this.selectedProvider === THREE_PLAY_MEDIA) {
availableLanguages = selectedPlan.languages;
}
}
return availableLanguages;
},
getSourceLanguages: function() {
var sourceLanguages = [];
if (this.selectedProvider === THREE_PLAY_MEDIA) {
sourceLanguages = this.availableTranscriptionPlans[this.selectedProvider].translations;
} else {
sourceLanguages = this.getTargetLanguages();
}
return sourceLanguages;
},
fidelitySelected: function(event) {
var $fidelityContainer = this.$el.find('.transcript-fidelity-wrapper');
this.selectedFidelityPlan = event.target.value;
// Remove any error if present already.
this.clearPreferenceErrorState($fidelityContainer);
// Clear active and selected languages.
this.selectedLanguages = this.activeLanguages = [];
// Also clear selected language.
this.selectedVideoSourceLanguage = '';
this.renderSourceLanguages();
this.renderTargetLanguages();
},
videoSourceLanguageSelected: function(event) {
var $videoSourceLanguageContainer = this.$el.find('.video-source-language-wrapper');
this.selectedVideoSourceLanguage = event.target.value;
// Remove any error if present already.
this.clearPreferenceErrorState($videoSourceLanguageContainer);
// Clear active and selected languages.
this.selectedLanguages = this.activeLanguages = [];
this.renderTargetLanguages();
},
turnaroundSelected: function(event) {
var $turnaroundContainer = this.$el.find('.transcript-turnaround-wrapper');
this.selectedTurnaroundPlan = event.target.value;
// Remove any error if present already.
this.clearPreferenceErrorState($turnaroundContainer);
},
providerSelected: function(event) {
this.resetPlanData();
this.selectedProvider = event.target.value;
this.renderPreferences();
},
languageSelected: function(event) {
var $parentEl = $(event.target.parentElement).parent(),
$languagesEl = this.$el.find('.transcript-languages-wrapper'),
selectedLanguage = $parentEl.find('select').val();
// Remove any error if present already.
this.clearPreferenceErrorState($languagesEl);
// Only add if not in the list already.
if (selectedLanguage && _.indexOf(this.selectedLanguages, selectedLanguage) === -1) {
this.selectedLanguages.push(selectedLanguage);
this.addLanguage(selectedLanguage);
// Populate language menu with latest data.
this.populateLanguageMenu();
} else {
this.addErrorState($languagesEl);
}
},
languageRemoved: function(event) {
var selectedLanguage = $(event.target).data('language-code');
$(event.target.parentElement).parent().remove();
// Remove language from selected languages.
this.selectedLanguages = _.without(this.selectedLanguages, selectedLanguage);
// Populate menu again to reflect latest changes.
this.populateLanguageMenu();
},
renderProviders: function() {
var self = this,
providerPlan = self.availableTranscriptionPlans,
$providerEl = self.$el.find('.transcript-provider-group');
if (providerPlan) {
HtmlUtils.setHtml(
$providerEl,
HtmlUtils.interpolateHtml(
HtmlUtils.HTML('<input type="radio" id="transcript-provider-none" name="transcript-provider" value="" {checked}/><label for="transcript-provider-none">{text}</label>'), // eslint-disable-line max-len
{
text: gettext('N/A'),
checked: self.selectedProvider === '' ? 'checked' : ''
}
)
);
_.each(providerPlan, function(providerObject, key) {
var checked = self.selectedProvider === key ? 'checked' : '';
HtmlUtils.append(
$providerEl,
HtmlUtils.interpolateHtml(
HtmlUtils.HTML('<input type="radio" id="transcript-provider-{value}" name="transcript-provider" value="{value}" {checked}/><label for="transcript-provider-{value}">{text}'), // eslint-disable-line max-len
{
text: providerObject.display_name,
value: key,
checked: checked
}
)
);
});
}
},
renderTurnaround: function() {
var self = this,
turnaroundPlan = self.getTurnaroundPlan(),
$turnaroundContainer = self.$el.find('.transcript-turnaround-wrapper'),
$turnaround = self.$el.find('#transcript-turnaround');
// Clear error state if present any.
this.clearPreferenceErrorState($turnaroundContainer);
if (turnaroundPlan) {
HtmlUtils.setHtml(
$turnaround,
HtmlUtils.HTML(new Option(gettext('Select turnaround'), ''))
);
_.each(turnaroundPlan, function(value, key) {
var option = new Option(value, key);
if (self.selectedTurnaroundPlan === key) {
option.selected = true;
}
HtmlUtils.append($turnaround, HtmlUtils.HTML(option));
});
$turnaroundContainer.show();
} else {
$turnaroundContainer.hide();
}
},
renderFidelity: function() {
var self = this,
fidelityPlan = self.getFidelityPlan(),
$fidelityContainer = self.$el.find('.transcript-fidelity-wrapper'),
$fidelity = self.$el.find('#transcript-fidelity');
// Clear error state if present any.
this.clearPreferenceErrorState($fidelityContainer);
// Fidelity dropdown
if (fidelityPlan) {
HtmlUtils.setHtml(
$fidelity,
HtmlUtils.HTML(new Option(gettext('Select fidelity'), ''))
);
_.each(fidelityPlan, function(fidelityObject, key) {
var option = new Option(fidelityObject.display_name, key);
if (self.selectedFidelityPlan === key) {
option.selected = true;
}
HtmlUtils.append($fidelity, HtmlUtils.HTML(option));
});
$fidelityContainer.show();
} else {
$fidelityContainer.hide();
}
},
renderTargetLanguages: function() {
var self = this,
$languagesPreferenceContainer = self.$el.find('.transcript-languages-wrapper'),
$languagesContainer = self.$el.find('.languages-container');
// Clear error state if present any.
self.clearPreferenceErrorState($languagesPreferenceContainer);
$languagesContainer.empty();
// Show language container if source language is selected .
if (self.selectedVideoSourceLanguage) {
_.each(self.activeLanguages, function(language) {
// Only add if not in the list already.
if (_.indexOf(self.selectedLanguages, language) === -1) {
self.selectedLanguages.push(language);
}
// Show active/ add language language container
self.addLanguage(language);
});
$languagesPreferenceContainer.show();
self.populateLanguageMenu();
} else {
$languagesPreferenceContainer.hide();
}
},
renderSourceLanguages: function() {
var self = this,
availableLanguages = self.getTargetLanguages(),
availableTranslations = self.getSourceLanguages(),
$videoSourceLanguageContainer = self.$el.find('.video-source-language-wrapper'),
$languageMenuEl = self.$el.find('.video-source-language'),
selectOptionEl = new Option(gettext('Select language'), '');
// Clear error state if present any.
self.clearPreferenceErrorState($videoSourceLanguageContainer);
if (!_.isEmpty(availableTranslations)) {
$videoSourceLanguageContainer.show();
// We need to set id due to a11y aria-labelledby
selectOptionEl.id = 'video-source-language-none';
HtmlUtils.setHtml(
$languageMenuEl,
HtmlUtils.HTML(selectOptionEl)
);
_.each(availableTranslations, function(translatableLanguage, key) {
var option = new Option(availableLanguages[key], key);
if (self.selectedVideoSourceLanguage === key) {
option.selected = true;
}
HtmlUtils.append(
$languageMenuEl,
HtmlUtils.HTML(option)
);
});
} else {
$videoSourceLanguageContainer.hide();
}
},
populateLanguageMenu: function() {
var availableLanguages = this.getTargetLanguages(),
availableTranslations = this.availableTranscriptionPlans[THREE_PLAY_MEDIA].translations,
$languageMenuEl = this.$el.find('.transcript-language-menu'),
$languageMenuContainerEl = this.$el.find('.transcript-language-menu-container'),
selectOptionEl = new Option(gettext('Select language'), '');
if (this.selectedProvider === THREE_PLAY_MEDIA) {
// Pick out only those languages from plan laguages that also come from video source language.
availableLanguages = _.pick(
availableLanguages,
availableTranslations[this.selectedVideoSourceLanguage]
);
}
// Omit out selected languages from selecting again.
availableLanguages = _.omit(availableLanguages, this.selectedLanguages);
// If no available language is left, then don't even show add language box.
if (_.keys(availableLanguages).length) {
$languageMenuContainerEl.show();
// We need to set id due to a11y aria-labelledby
selectOptionEl.id = 'transcript-language-none';
HtmlUtils.setHtml(
$languageMenuEl,
HtmlUtils.HTML(selectOptionEl)
);
_.each(availableLanguages, function(value, key) {
HtmlUtils.append(
$languageMenuEl,
HtmlUtils.HTML(new Option(value, key))
);
});
} else {
$languageMenuContainerEl.hide();
}
},
renderPreferences: function() {
this.renderProviders();
this.renderTurnaround();
this.renderFidelity();
this.renderSourceLanguages();
this.renderTargetLanguages();
},
addLanguage: function(language) {
var $languagesContainer = this.$el.find('.languages-container');
HtmlUtils.append(
$languagesContainer,
HtmlUtils.joinHtml(
HtmlUtils.HTML('<div class="transcript-language-container">'),
HtmlUtils.interpolateHtml(
HtmlUtils.HTML('<span>{languageDisplayName}</span>'),
{
languageDisplayName: this.getTargetLanguages()[language]
}
),
HtmlUtils.interpolateHtml(
HtmlUtils.HTML('<div class="remove-language-action"><button class="button-link action-remove-language" data-language-code="{languageCode}">{text}<span class="sr">{srText}</span></button></div>'), // eslint-disable-line max-len
{
languageCode: language,
text: gettext('Remove'),
srText: gettext('Press Remove to remove language')
}
),
HtmlUtils.HTML('</div>')
)
);
},
updateSuccessResponseStatus: function(data) {
var dateModified = data ? moment.utc(data.modified).format('ll') : '';
// Update last modified date
if (dateModified) {
HtmlUtils.setHtml(
this.$el.find('.last-updated-text'),
HtmlUtils.interpolateHtml(
HtmlUtils.HTML('{lastUpdateText} {dateModified}'),
{
lastUpdateText: gettext('Last updated'),
dateModified: dateModified
}
)
);
}
this.renderResponseStatus(gettext('Settings updated'), 'success');
// Sync ActiveUploadListView with latest active plan.
this.activeTranscriptionPlan = data;
Backbone.trigger('coursevideosettings:syncActiveTranscriptPreferences', this.activeTranscriptionPlan);
},
updateFailResponseStatus: function(data) {
var errorMessage;
// Enclose inside try-catch so that if we get erroneous data, we could still
// show some error to user
try {
errorMessage = $.parseJSON(data).error;
} catch (e) {} // eslint-disable-line no-empty
errorMessage = errorMessage || gettext('Error saving data');
this.renderResponseStatus(errorMessage, 'error');
},
renderResponseStatus: function(responseText, type) {
var addClass = type === 'error' ? 'error' : 'success',
removeClass = type === 'error' ? 'success' : 'error',
iconClass = type === 'error' ? 'fa-info-circle' : 'fa-check-circle',
$messageWrapperEl = this.$el.find('.course-video-settings-message-wrapper');
$messageWrapperEl.removeClass(removeClass);
$messageWrapperEl.addClass(addClass);
HtmlUtils.setHtml(
$messageWrapperEl,
HtmlUtils.interpolateHtml(
HtmlUtils.HTML('<div class="course-video-settings-message"><span class="icon fa {iconClass}" aria-hidden="true"></span><span>{text}</span></div>'), // eslint-disable-line max-len
{
text: responseText,
iconClass: iconClass
}
)
);
},
clearResponseStatus: function() {
// Remove parent level state.
var $messageWrapperEl = this.$el.find('.course-video-settings-message-wrapper');
$messageWrapperEl.empty();
$messageWrapperEl.removeClass('error');
$messageWrapperEl.removeClass('success');
},
clearPreferenceErrorState: function($PreferenceContainer) {
$PreferenceContainer.removeClass('error');
$PreferenceContainer.find('.error-icon').empty();
$PreferenceContainer.find('.error-info').empty();
// Also clear response status if present already
this.clearResponseStatus();
},
addErrorState: function($PreferenceContainer) {
var requiredText = gettext('Required'),
infoIconHtml = HtmlUtils.HTML('<span class="icon fa fa-info-circle" aria-hidden="true"></span>');
$PreferenceContainer.addClass('error');
HtmlUtils.setHtml(
$PreferenceContainer.find('.error-icon'),
infoIconHtml
);
HtmlUtils.setHtml(
$PreferenceContainer.find('.error-info'),
requiredText
);
},
validateCourseVideoSettings: function() {
var isValid = true,
$turnaroundEl = this.$el.find('.transcript-turnaround-wrapper'),
$fidelityEl = this.$el.find('.transcript-fidelity-wrapper'),
$languagesEl = this.$el.find('.transcript-languages-wrapper'),
$videoSourcelanguageEl = this.$el.find('.video-source-language-wrapper');
// Explicit None selected case.
if (this.selectedProvider === '') {
return true;
}
if (!this.selectedTurnaroundPlan) {
isValid = false;
this.addErrorState($turnaroundEl);
} else {
this.clearPreferenceErrorState($turnaroundEl);
}
if (this.selectedProvider === CIELO24 && !this.selectedFidelityPlan) {
isValid = false;
this.addErrorState($fidelityEl);
} else {
this.clearPreferenceErrorState($fidelityEl);
}
if (this.selectedProvider === THREE_PLAY_MEDIA && !this.selectedVideoSourceLanguage) {
isValid = false;
this.addErrorState($videoSourcelanguageEl);
} else {
this.clearPreferenceErrorState($videoSourcelanguageEl);
}
if (this.selectedLanguages.length === 0) {
isValid = false;
this.addErrorState($languagesEl);
} else {
this.clearPreferenceErrorState($languagesEl);
}
return isValid;
},
saveTranscriptPreferences: function() {
var self = this,
responseTranscriptPreferences;
// First clear response status if present already
this.clearResponseStatus();
if (self.selectedProvider) {
$.postJSON(self.transcriptHandlerUrl, {
provider: self.selectedProvider,
cielo24_fidelity: self.selectedFidelityPlan,
cielo24_turnaround: self.selectedProvider === CIELO24 ? self.selectedTurnaroundPlan : '',
three_play_turnaround: self.selectedProvider === THREE_PLAY_MEDIA ? self.selectedTurnaroundPlan : '', // eslint-disable-line max-len
preferred_languages: self.selectedLanguages,
video_source_language: self.selectedVideoSourceLanguage,
global: false // Do not trigger global AJAX error handler
}, function(data) {
responseTranscriptPreferences = data ? data.transcript_preferences : null;
self.updateSuccessResponseStatus(responseTranscriptPreferences);
}).fail(function(jqXHR) {
if (jqXHR.responseText) {
self.updateFailResponseStatus(jqXHR.responseText);
}
});
} else {
$.ajax({
type: 'DELETE',
url: self.transcriptHandlerUrl
}).done(function() {
self.updateSuccessResponseStatus(null);
}).fail(function(jqXHR) {
if (jqXHR.responseText) {
self.updateFailResponseStatus(jqXHR.responseText);
}
});
}
},
updateCourseVideoSettings: function() {
var $messageWrapperEl = this.$el.find('.course-video-settings-message-wrapper');
if (this.validateCourseVideoSettings()) {
this.saveTranscriptPreferences();
} else {
$messageWrapperEl.empty();
}
},
render: function() {
var dateModified = this.activeTranscriptionPlan ?
moment.utc(this.activeTranscriptionPlan.modified).format('ll') : '';
HtmlUtils.setHtml(
this.$el,
this.template({
dateModified: dateModified
})
);
this.renderPreferences();
this.registerCloseClickHandler();
this.setFixedCourseVideoSettingsPane();
return this;
},
setFixedCourseVideoSettingsPane: function() {
var $courseVideoSettingsButton = $('.course-video-settings-button'),
$courseVideoSettingsContainer = this.$el.find('.course-video-settings-container'),
initialPositionTop = $courseVideoSettingsContainer.offset().top,
// Button right position = width of window - button left position - button width - paddings - border.
$courseVideoSettingsButtonRight = $(window).width() -
$courseVideoSettingsButton.offset().left -
$courseVideoSettingsButton.width() -
$courseVideoSettingsButton.css('padding-left').replace('px', '') -
$courseVideoSettingsButton.css('padding-right').replace('px', '') -
$courseVideoSettingsButton.css('border-width').replace('px', '') - 5; // Extra pixles for slack;
// Set to windows total height
$courseVideoSettingsContainer.css('height', $(window).height());
// Start settings pane adjascent to 'course video settings' button.
$courseVideoSettingsContainer.css('right', $courseVideoSettingsButtonRight);
// Make sticky when scroll reaches top.
$(window).scroll(function() {
if ($(window).scrollTop() >= initialPositionTop) {
$courseVideoSettingsContainer.addClass('fixed-container');
} else {
$courseVideoSettingsContainer.removeClass('fixed-container');
}
});
},
closeCourseVideoSettings: function() {
// TODO: Slide out when closing settings pane. We may need to hide the view instead of destroying it.
// Trigger destroy transcript event.
Backbone.trigger('coursevideosettings:destroyCourseVideoSettingsView');
// Unbind any events associated
this.undelegateEvents();
this.stopListening();
// Empty this.$el content from DOM
this.$el.empty();
// Reset everything.
this.resetPlanData();
}
});
return CourseVideoSettingsView;
});
...@@ -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):
......
...@@ -9,7 +9,15 @@ import ddt ...@@ -9,7 +9,15 @@ import ddt
from django.conf import settings from django.conf import settings
from django.test import TestCase from django.test import TestCase
from django.test.utils import override_settings from django.test.utils import override_settings
from edxval.api import ValCannotCreateError, ValVideoNotFoundError, create_profile, create_video, get_video_info from edxval.api import (
ValCannotCreateError,
ValVideoNotFoundError,
create_or_update_video_transcript,
create_profile,
create_video,
get_video_info,
get_video_transcript
)
from lxml import etree from lxml import etree
from mock import MagicMock, Mock, patch from mock import MagicMock, Mock, patch
from nose.plugins.attrib import attr from nose.plugins.attrib import attr
...@@ -1307,6 +1315,7 @@ class TestVideoDescriptorStudentViewJson(TestCase): ...@@ -1307,6 +1315,7 @@ class TestVideoDescriptorStudentViewJson(TestCase):
self.transcript_url = "transcript_url" self.transcript_url = "transcript_url"
self.video = instantiate_descriptor(data=sample_xml) self.video = instantiate_descriptor(data=sample_xml)
self.video.runtime.handler_url = Mock(return_value=self.transcript_url) self.video.runtime.handler_url = Mock(return_value=self.transcript_url)
self.video.runtime.course_id = MagicMock()
def setup_val_video(self, associate_course_in_val=False): def setup_val_video(self, associate_course_in_val=False):
""" """
...@@ -1405,6 +1414,7 @@ class TestVideoDescriptorStudentViewJson(TestCase): ...@@ -1405,6 +1414,7 @@ class TestVideoDescriptorStudentViewJson(TestCase):
self.transcript_url = "transcript_url" self.transcript_url = "transcript_url"
self.video = instantiate_descriptor(data=sample_xml) self.video = instantiate_descriptor(data=sample_xml)
self.video.runtime.handler_url = Mock(return_value=self.transcript_url) self.video.runtime.handler_url = Mock(return_value=self.transcript_url)
self.video.runtime.course_id = MagicMock()
result = self.get_result() result = self.get_result()
self.verify_result_with_youtube_url(result) self.verify_result_with_youtube_url(result)
...@@ -1442,6 +1452,43 @@ class TestVideoDescriptorStudentViewJson(TestCase): ...@@ -1442,6 +1452,43 @@ class TestVideoDescriptorStudentViewJson(TestCase):
result = self.get_result(allow_cache_miss) result = self.get_result(allow_cache_miss)
self.verify_result_with_fallback_and_youtube(result) self.verify_result_with_fallback_and_youtube(result)
@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_student_view_with_val_transcripts_enabled(self, transcripts, english_sub, val_transcripts,
expected_transcripts, mock_get_transcript_languages):
"""
Test `student_view_data` with edx-val transcripts enabled.
"""
mock_get_transcript_languages.return_value = val_transcripts
self.video.transcripts = transcripts
self.video.sub = english_sub
student_view_response = self.get_result()
self.assertItemsEqual(student_view_response['transcripts'].keys(), expected_transcripts)
@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=['ro', 'es']),
)
def test_student_view_with_val_transcripts_disabled(self):
"""
Test `student_view_data` with edx-val transcripts disabled.
"""
student_view_response = self.get_result()
self.assertDictEqual(student_view_response['transcripts'], {self.TEST_LANGUAGE: self.transcript_url})
@attr(shard=1) @attr(shard=1)
class VideoDescriptorTest(TestCase, VideoDescriptorTestBase): class VideoDescriptorTest(TestCase, VideoDescriptorTestBase):
...@@ -1453,6 +1500,15 @@ class VideoDescriptorTest(TestCase, VideoDescriptorTestBase): ...@@ -1453,6 +1500,15 @@ class VideoDescriptorTest(TestCase, VideoDescriptorTestBase):
self.descriptor.runtime.handler_url = MagicMock() self.descriptor.runtime.handler_url = MagicMock()
self.descriptor.runtime.course_id = MagicMock() self.descriptor.runtime.course_id = MagicMock()
def get_video_transcript_data(self, video_id):
return dict(
video_id=video_id,
language_code='ar',
url='/media/ext101.srt',
provider='Cielo24',
file_format='srt',
)
def test_get_context(self): def test_get_context(self):
"""" """"
Test get_context. Test get_context.
...@@ -1480,7 +1536,7 @@ class VideoDescriptorTest(TestCase, VideoDescriptorTestBase): ...@@ -1480,7 +1536,7 @@ class VideoDescriptorTest(TestCase, VideoDescriptorTestBase):
self.descriptor.editable_metadata_fields['edx_video_id'] self.descriptor.editable_metadata_fields['edx_video_id']
) )
def test_export_val_data(self): def test_export_val_data_with_internal(self):
self.descriptor.edx_video_id = 'test_edx_video_id' self.descriptor.edx_video_id = 'test_edx_video_id'
create_profile('mobile') create_profile('mobile')
create_video({ create_video({
...@@ -1495,15 +1551,52 @@ class VideoDescriptorTest(TestCase, VideoDescriptorTestBase): ...@@ -1495,15 +1551,52 @@ class VideoDescriptorTest(TestCase, VideoDescriptorTestBase):
'bitrate': 333, 'bitrate': 333,
}], }],
}) })
create_or_update_video_transcript(
video_id=self.descriptor.edx_video_id,
language_code='ar',
file_name='ext101.srt',
file_format='srt',
provider='Cielo24',
)
actual = self.descriptor.definition_to_xml(resource_fs=None) actual = self.descriptor.definition_to_xml(resource_fs=None)
expected_str = """ expected_str = """
<video download_video="false" url_name="SampleProblem"> <video download_video="false" url_name="SampleProblem">
<video_asset client_video_id="test_client_video_id" duration="111.0" image=""> <video_asset client_video_id="test_client_video_id" duration="111.0" image="">
<encoded_video profile="mobile" url="http://example.com/video" file_size="222" bitrate="333"/> <encoded_video profile="mobile" url="http://example.com/video" file_size="222" bitrate="333"/>
<transcripts>
<transcript file_format="srt" file_name="ext101.srt" language_code="ar" provider="Cielo24" video_id="{video_id}"/>
</transcripts>
</video_asset> </video_asset>
</video> </video>
""".format(video_id=self.descriptor.edx_video_id)
parser = etree.XMLParser(remove_blank_text=True)
expected = etree.XML(expected_str, parser=parser)
self.assertXmlEqual(expected, actual)
def test_export_val_data_with_external(self):
""" """
Tests exported val data for external video.
"""
external_video_id = '3_yD_cEKoCk'
create_or_update_video_transcript(
video_id=external_video_id,
language_code='ar',
file_name='ext101.srt',
file_format='srt',
provider='Cielo24',
)
actual = self.descriptor.definition_to_xml(resource_fs=None)
expected_str = """
<video url_name="SampleProblem" download_video="false">
<video_asset>
<transcripts>
<transcript file_format="srt" file_name="ext101.srt" language_code="ar" provider="Cielo24" video_id="{video_id}"/>
</transcripts>
</video_asset>
</video>
""".format(video_id=external_video_id)
parser = etree.XMLParser(remove_blank_text=True) parser = etree.XMLParser(remove_blank_text=True)
expected = etree.XML(expected_str, parser=parser) expected = etree.XML(expected_str, parser=parser)
self.assertXmlEqual(expected, actual) self.assertXmlEqual(expected, actual)
...@@ -1516,7 +1609,21 @@ class VideoDescriptorTest(TestCase, VideoDescriptorTestBase): ...@@ -1516,7 +1609,21 @@ class VideoDescriptorTest(TestCase, VideoDescriptorTestBase):
expected = etree.XML(expected_str, parser=parser) expected = etree.XML(expected_str, parser=parser)
self.assertXmlEqual(expected, actual) self.assertXmlEqual(expected, actual)
def test_import_val_data(self): @patch('xmodule.video_module.transcripts_utils.get_video_ids_info')
def test_export_no_video_ids(self, mock_get_video_ids_info):
"""
Tests export when there are no video ids
"""
mock_get_video_ids_info.return_value = True, []
actual = self.descriptor.definition_to_xml(resource_fs=None)
expected_str = '<video url_name="SampleProblem" download_video="false"><video_asset/></video>'
parser = etree.XMLParser(remove_blank_text=True)
expected = etree.XML(expected_str, parser=parser)
self.assertXmlEqual(expected, actual)
def test_import_val_data_internal(self):
create_profile('mobile') create_profile('mobile')
module_system = DummySystem(load_error_modules=True) module_system = DummySystem(load_error_modules=True)
...@@ -1524,12 +1631,15 @@ class VideoDescriptorTest(TestCase, VideoDescriptorTestBase): ...@@ -1524,12 +1631,15 @@ class VideoDescriptorTest(TestCase, VideoDescriptorTestBase):
<video edx_video_id="test_edx_video_id"> <video edx_video_id="test_edx_video_id">
<video_asset client_video_id="test_client_video_id" duration="111.0"> <video_asset client_video_id="test_client_video_id" duration="111.0">
<encoded_video profile="mobile" url="http://example.com/video" file_size="222" bitrate="333"/> <encoded_video profile="mobile" url="http://example.com/video" file_size="222" bitrate="333"/>
<transcripts>
<transcript file_format="srt" file_name="ext101.srt" language_code="ar" provider="Cielo24" video_id="test_edx_video_id"/>
</transcripts>
</video_asset> </video_asset>
</video> </video>
""" """
id_generator = Mock() id_generator = Mock()
id_generator.target_course_id = "test_course_id" id_generator.target_course_id = "test_course_id"
video = VideoDescriptor.from_xml(xml_data, module_system, id_generator) video = self.descriptor.from_xml(xml_data, module_system, id_generator)
self.assertEqual(video.edx_video_id, 'test_edx_video_id') self.assertEqual(video.edx_video_id, 'test_edx_video_id')
video_data = get_video_info(video.edx_video_id) video_data = get_video_info(video.edx_video_id)
self.assertEqual(video_data['client_video_id'], 'test_client_video_id') self.assertEqual(video_data['client_video_id'], 'test_client_video_id')
...@@ -1540,6 +1650,38 @@ class VideoDescriptorTest(TestCase, VideoDescriptorTestBase): ...@@ -1540,6 +1650,38 @@ class VideoDescriptorTest(TestCase, VideoDescriptorTestBase):
self.assertEqual(video_data['encoded_videos'][0]['url'], 'http://example.com/video') self.assertEqual(video_data['encoded_videos'][0]['url'], 'http://example.com/video')
self.assertEqual(video_data['encoded_videos'][0]['file_size'], 222) self.assertEqual(video_data['encoded_videos'][0]['file_size'], 222)
self.assertEqual(video_data['encoded_videos'][0]['bitrate'], 333) self.assertEqual(video_data['encoded_videos'][0]['bitrate'], 333)
# verify transcript data
self.assertDictEqual(
get_video_transcript(video.edx_video_id, 'ar'),
self.get_video_transcript_data('test_edx_video_id')
)
def test_import_val_data_external(self):
"""
Tests video import with external video.
"""
external_video_id = 'external_video_id'
module_system = DummySystem(load_error_modules=True)
xml_data = """
<video>
<video_asset>
<transcripts>
<transcript file_format="srt" file_name="ext101.srt" language_code="ar" provider="Cielo24" video_id="{video_id}"/>
</transcripts>
</video_asset>
</video>
""".format(video_id=external_video_id)
id_generator = Mock()
id_generator.target_course_id = "test_course_id"
self.descriptor.from_xml(xml_data, module_system, id_generator)
# verify transcript data
self.assertDictEqual(
get_video_transcript(external_video_id, 'ar'),
self.get_video_transcript_data(external_video_id)
)
def test_import_val_data_invalid(self): def test_import_val_data_invalid(self):
create_profile('mobile') create_profile('mobile')
......
...@@ -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
)
...@@ -13,100 +13,198 @@ from openedx.core.djangoapps.video_config.models import CourseHLSPlaybackEnabled ...@@ -13,100 +13,198 @@ from openedx.core.djangoapps.video_config.models import CourseHLSPlaybackEnabled
@contextmanager @contextmanager
def hls_playback_feature_flags( def video_feature_flags(
all_courses_model_class, course_specific_model_class,
global_flag, enabled_for_all_courses=False, global_flag, enabled_for_all_courses=False,
course_id=None, enabled_for_course=False course_id=None, enabled_for_course=False
): ):
""" """
Yields HLS Playback Configuration records for unit tests Yields video feature configuration records for unit tests
Arguments: Arguments:
all_courses_model_class: Model class to enable feature for all courses
course_specific_model_class: Model class to nable feature for course specific
global_flag (bool): Specifies whether feature is enabled globally global_flag (bool): Specifies whether feature is enabled globally
enabled_for_all_courses (bool): Specifies whether feature is enabled for all courses enabled_for_all_courses (bool): Specifies whether feature is enabled for all courses
course_id (CourseLocator): Course locator for course specific configurations course_id (CourseLocator): Course locator for course specific configurations
enabled_for_course (bool): Specifies whether feature should be available for a course enabled_for_course (bool): Specifies whether feature should be available for a course
""" """
HLSPlaybackEnabledFlag.objects.create(enabled=global_flag, enabled_for_all_courses=enabled_for_all_courses) all_courses_model_class.objects.create(enabled=global_flag, enabled_for_all_courses=enabled_for_all_courses)
if course_id: if course_id:
CourseHLSPlaybackEnabledFlag.objects.create(course_id=course_id, enabled=enabled_for_course) course_specific_model_class.objects.create(course_id=course_id, enabled=enabled_for_course)
yield yield
@ddt.ddt class FeatureFlagTestMixin(object):
class TestHLSPlaybackFlag(TestCase):
""" """
Tests the behavior of the flags for HLS Playback feature. Adds util methods to test the behavior of the flags for video feature.
These are set via Django admin settings.
""" """
def setUp(self): course_id_1 = CourseLocator(org="edx", course="course", run="run")
super(TestHLSPlaybackFlag, self).setUp() course_id_2 = CourseLocator(org="edx", course="course2", run="run")
self.course_id_1 = CourseLocator(org="edx", course="course", run="run")
self.course_id_2 = CourseLocator(org="edx", course="course2", run="run")
@ddt.data( def verify_feature_flags(self, all_courses_model_class, course_specific_model_class,
*itertools.product( global_flag, enabled_for_all_courses, enabled_for_course_1):
(True, False),
(True, False),
(True, False),
)
)
@ddt.unpack
def test_hls_playback_feature_flags(self, global_flag, enabled_for_all_courses, enabled_for_course_1):
""" """
Tests that the feature flags works correctly on tweaking global flags in combination Verifies that the feature flags works correctly on tweaking global flags in combination
with course-specific flags. with course-specific flags.
""" """
with hls_playback_feature_flags( with video_feature_flags(
all_courses_model_class=all_courses_model_class,
course_specific_model_class=course_specific_model_class,
global_flag=global_flag, global_flag=global_flag,
enabled_for_all_courses=enabled_for_all_courses, enabled_for_all_courses=enabled_for_all_courses,
course_id=self.course_id_1, course_id=self.course_id_1,
enabled_for_course=enabled_for_course_1 enabled_for_course=enabled_for_course_1
): ):
self.assertEqual( self.assertEqual(
HLSPlaybackEnabledFlag.feature_enabled(self.course_id_1), all_courses_model_class.feature_enabled(self.course_id_1),
global_flag and (enabled_for_all_courses or enabled_for_course_1) global_flag and (enabled_for_all_courses or enabled_for_course_1)
) )
self.assertEqual( self.assertEqual(
HLSPlaybackEnabledFlag.feature_enabled(self.course_id_2), all_courses_model_class.feature_enabled(self.course_id_2),
global_flag and enabled_for_all_courses global_flag and enabled_for_all_courses
) )
def test_enable_disable_course_flag(self): def verify_enable_disable_course_flag(self, all_courses_model_class, course_specific_model_class):
""" """
Ensures that the flag, once enabled for a course, can also be disabled. Verifies that the course specific flag, once enabled for a course, can also be disabled.
""" """
with hls_playback_feature_flags( with video_feature_flags(
all_courses_model_class=all_courses_model_class,
course_specific_model_class=course_specific_model_class,
global_flag=True, global_flag=True,
enabled_for_all_courses=False, enabled_for_all_courses=False,
course_id=self.course_id_1, course_id=self.course_id_1,
enabled_for_course=True enabled_for_course=True
): ):
self.assertTrue(HLSPlaybackEnabledFlag.feature_enabled(self.course_id_1)) self.assertTrue(all_courses_model_class.feature_enabled(self.course_id_1))
with hls_playback_feature_flags( with video_feature_flags(
all_courses_model_class=all_courses_model_class,
course_specific_model_class=course_specific_model_class,
global_flag=True, global_flag=True,
enabled_for_all_courses=False, enabled_for_all_courses=False,
course_id=self.course_id_1, course_id=self.course_id_1,
enabled_for_course=False enabled_for_course=False
): ):
self.assertFalse(HLSPlaybackEnabledFlag.feature_enabled(self.course_id_1)) self.assertFalse(all_courses_model_class.feature_enabled(self.course_id_1))
def test_enable_disable_globally(self): def verify_enable_disable_globally(self, all_courses_model_class, course_specific_model_class):
""" """
Ensures that the flag, once enabled globally, can also be disabled. Verifies that global flag, once enabled globally, can also be disabled.
""" """
with hls_playback_feature_flags( with video_feature_flags(
all_courses_model_class=all_courses_model_class,
course_specific_model_class=course_specific_model_class,
global_flag=True, global_flag=True,
enabled_for_all_courses=True, enabled_for_all_courses=True,
): ):
self.assertTrue(HLSPlaybackEnabledFlag.feature_enabled(self.course_id_1)) self.assertTrue(all_courses_model_class.feature_enabled(self.course_id_1))
self.assertTrue(HLSPlaybackEnabledFlag.feature_enabled(self.course_id_2)) self.assertTrue(all_courses_model_class.feature_enabled(self.course_id_2))
with hls_playback_feature_flags( with video_feature_flags(
all_courses_model_class=all_courses_model_class,
course_specific_model_class=course_specific_model_class,
global_flag=True, global_flag=True,
enabled_for_all_courses=False, enabled_for_all_courses=False,
): ):
self.assertFalse(HLSPlaybackEnabledFlag.feature_enabled(self.course_id_1)) self.assertFalse(all_courses_model_class.feature_enabled(self.course_id_1))
self.assertFalse(HLSPlaybackEnabledFlag.feature_enabled(self.course_id_2)) self.assertFalse(all_courses_model_class.feature_enabled(self.course_id_2))
with hls_playback_feature_flags( with video_feature_flags(
all_courses_model_class=all_courses_model_class,
course_specific_model_class=course_specific_model_class,
global_flag=False, global_flag=False,
): ):
self.assertFalse(HLSPlaybackEnabledFlag.feature_enabled(self.course_id_1)) self.assertFalse(all_courses_model_class.feature_enabled(self.course_id_1))
self.assertFalse(HLSPlaybackEnabledFlag.feature_enabled(self.course_id_2)) self.assertFalse(all_courses_model_class.feature_enabled(self.course_id_2))
@ddt.ddt
class TestHLSPlaybackFlag(TestCase, FeatureFlagTestMixin):
"""
Tests the behavior of the flags for HLS Playback feature.
These are set via Django admin settings.
"""
@ddt.data(
*itertools.product(
(True, False),
(True, False),
(True, False),
)
)
@ddt.unpack
def test_hls_playback_feature_flags(self, global_flag, enabled_for_all_courses, enabled_for_course_1):
"""
Tests that the HLS Playback feature flags works correctly on tweaking global flags in combination
with course-specific flags.
"""
self.verify_feature_flags(
all_courses_model_class=HLSPlaybackEnabledFlag,
course_specific_model_class=CourseHLSPlaybackEnabledFlag,
global_flag=global_flag,
enabled_for_all_courses=enabled_for_all_courses,
enabled_for_course_1=enabled_for_course_1
)
def test_enable_disable_course_flag(self):
"""
Ensures that the flag, once enabled for a course, can also be disabled.
"""
self.verify_enable_disable_course_flag(
all_courses_model_class=HLSPlaybackEnabledFlag,
course_specific_model_class=CourseHLSPlaybackEnabledFlag
)
def test_enable_disable_globally(self):
"""
Ensures that the flag, once enabled globally, can also be disabled.
"""
self.verify_enable_disable_globally(
all_courses_model_class=HLSPlaybackEnabledFlag,
course_specific_model_class=CourseHLSPlaybackEnabledFlag
)
@ddt.ddt
class TestVideoTranscriptFlag(TestCase, FeatureFlagTestMixin):
"""
Tests the behavior of the flags for Video Transcript feature.
These are set via Django admin settings.
"""
@ddt.data(
*itertools.product(
(True, False),
(True, False),
(True, False),
)
)
@ddt.unpack
def test_video_transcript_feature_flags(self, global_flag, enabled_for_all_courses, enabled_for_course_1):
"""
Tests that Video Transcript feature flags works correctly on tweaking global flags in combination
with course-specific flags.
"""
self.verify_feature_flags(
all_courses_model_class=HLSPlaybackEnabledFlag,
course_specific_model_class=CourseHLSPlaybackEnabledFlag,
global_flag=global_flag,
enabled_for_all_courses=enabled_for_all_courses,
enabled_for_course_1=enabled_for_course_1
)
def test_enable_disable_course_flag(self):
"""
Ensures that the Video Transcript course specific flag, once enabled for a course, can also be disabled.
"""
self.verify_enable_disable_course_flag(
all_courses_model_class=HLSPlaybackEnabledFlag,
course_specific_model_class=CourseHLSPlaybackEnabledFlag
)
def test_enable_disable_globally(self):
"""
Ensures that the Video Transcript flag, once enabled globally, can also be disabled.
"""
self.verify_enable_disable_globally(
all_courses_model_class=HLSPlaybackEnabledFlag,
course_specific_model_class=CourseHLSPlaybackEnabledFlag
)
...@@ -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