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 -*-
""" Tests for transcripts_utils. """
import copy
import ddt
import textwrap
import unittest
from uuid import uuid4
......@@ -632,6 +633,16 @@ class TestTranscript(unittest.TestCase):
with self.assertRaises(NotImplementedError):
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):
"""
......@@ -643,3 +654,43 @@ class TestSubsFilename(unittest.TestCase):
self.assertEqual(name, u'subs_˙∆©ƒƒƒ.srt.sjson')
name = transcripts_utils.subs_filename(u"˙∆©ƒƒƒ", 'uk')
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."""
import copy
import ddt
import json
import os
import tempfile
......@@ -10,7 +11,7 @@ from uuid import uuid4
from django.conf import settings
from django.core.urlresolvers import reverse
from django.test.utils import override_settings
from mock import patch
from mock import patch, Mock
from opaque_keys.edx.keys import UsageKey
from contentstore.tests.utils import CourseTestCase, mock_requests_get
......@@ -525,7 +526,70 @@ class TestDownloadTranscripts(BaseTranscripts):
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):
"""
Tests for '/transcripts/check' url.
......@@ -760,6 +824,58 @@ class TestCheckTranscripts(BaseTranscripts):
self.assertEqual(resp.status_code, 400)
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):
"""
......
......@@ -24,13 +24,16 @@ from contentstore.utils import reverse_course_url
from contentstore.views.videos import (
_get_default_video_image_url,
validate_video_image,
validate_transcript_preferences,
VIDEO_IMAGE_UPLOAD_ENABLED,
WAFFLE_SWITCHES,
TranscriptProvider
)
from contentstore.views.videos import KEY_EXPIRATION_IN_SECONDS, StatusDisplayStrings, convert_video_status
from xmodule.modulestore.tests.factories import CourseFactory
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):
......@@ -551,6 +554,22 @@ class VideosHandlerTestCase(VideoUploadTestMixin, CourseTestCase):
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
@patch.dict('django.conf.settings.FEATURES', {'ENABLE_VIDEO_UPLOAD_PIPELINE': True})
......@@ -842,7 +861,10 @@ class VideoImageTestCase(VideoUploadTestBase, CourseTestCase):
edx_video_id = 'test1'
video_image_upload_url = self.get_url_for_course_key(self.course.id, {'edx_video_id': edx_video_id})
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'),
extension=image_data.get('extension', '.png'),
force_size=image_data.get('size', settings.VIDEO_IMAGE_SETTINGS['VIDEO_IMAGE_MIN_BYTES'])
......@@ -854,6 +876,323 @@ class VideoImageTestCase(VideoUploadTestBase, CourseTestCase):
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})
@override_settings(VIDEO_UPLOAD_PIPELINE={"BUCKET": "test_bucket", "ROOT_PATH": "test_root"})
class VideoUrlsCsvTestCase(VideoUploadTestMixin, CourseTestCase):
......
......@@ -27,16 +27,18 @@ from xmodule.exceptions import NotFoundError
from xmodule.modulestore.django import modulestore
from xmodule.modulestore.exceptions import ItemNotFoundError
from xmodule.video_module.transcripts_utils import (
GetTranscriptsFromYouTubeException,
TranscriptsRequestValidationException,
copy_or_rename_transcript,
download_youtube_subs,
generate_srt_from_sjson,
GetTranscriptsFromYouTubeException,
get_video_transcript_content,
generate_subs_from_source,
get_transcripts_from_youtube,
is_val_transcript_feature_enabled_for_course,
manage_video_subtitles_save,
remove_subs_from_store,
youtube_video_transcript_name
Transcript,
TranscriptsRequestValidationException,
youtube_video_transcript_name,
)
__all__ = [
......@@ -144,6 +146,7 @@ def download_transcripts(request):
Raises Http404 if unsuccessful.
"""
locator = request.GET.get('locator')
subs_id = request.GET.get('subs_id')
if not locator:
log.debug('GET data without "locator" property.')
raise Http404
......@@ -154,31 +157,47 @@ def download_transcripts(request):
log.debug("Can't find item by locator.")
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':
log.debug('transcripts are supported only for video" modules.')
raise Http404
filename = 'subs_{0}.srt.sjson'.format(subs_id)
content_location = StaticContent.compute_location(item.location.course_key, filename)
try:
sjson_transcripts = contentstore().find(content_location)
log.debug("Downloading subs for %s id", subs_id)
str_subs = generate_srt_from_sjson(json.loads(sjson_transcripts.data), speed=1.0)
if not str_subs:
log.debug('generate_srt_from_sjson produces no subtitles')
raise Http404
response = HttpResponse(str_subs, content_type='application/x-subrip')
response['Content-Disposition'] = 'attachment; filename="{0}.srt"'.format(subs_id)
return response
if not subs_id:
raise NotFoundError
filename = subs_id
content_location = StaticContent.compute_location(
item.location.course_key,
'subs_{filename}.srt.sjson'.format(filename=filename),
)
sjson_transcript = contentstore().find(content_location).data
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
# 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
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])
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({
'command': command,
'subs': subs_to_use,
......
......@@ -4,6 +4,7 @@ Views related to the video upload feature
from contextlib import closing
import csv
import json
import logging
from datetime import datetime, timedelta
from uuid import uuid4
......@@ -25,9 +26,14 @@ from edxval.api import (
get_videos_for_course,
remove_video_for_course,
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 openedx.core.djangoapps.video_config.models import VideoTranscriptEnabledFlag
from openedx.core.djangoapps.waffle_utils import WaffleSwitchNamespace
from contentstore.models import VideoUploadConfig
......@@ -38,7 +44,7 @@ from util.json_request import JsonResponse, expect_json
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__)
......@@ -63,6 +69,14 @@ VIDEO_UPLOAD_MAX_FILE_SIZE_GB = 5
MAX_UPLOAD_HOURS = 24
class TranscriptProvider(object):
"""
3rd Party Transcription Provider Enumeration
"""
CIELO24 = 'Cielo24'
THREE_PLAY_MEDIA = '3PlayMedia'
class StatusDisplayStrings(object):
"""
A class to map status strings as stored in VAL to display strings for the
......@@ -93,6 +107,10 @@ class StatusDisplayStrings(object):
_IMPORTED = ugettext_noop("Imported")
# Translators: This is the status for a video that is in an unknown state
_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 = {
"upload": _UPLOADING,
......@@ -111,6 +129,8 @@ class StatusDisplayStrings(object):
"youtube_duplicate": _YOUTUBE_DUPLICATE,
"invalid_token": _INVALID_TOKEN,
"imported": _IMPORTED,
"transcription_in_progress": _TRANSCRIPTION_IN_PROGRESS,
"transcript_ready": _TRANSCRIPT_READY,
}
@staticmethod
......@@ -236,6 +256,130 @@ def video_images_handler(request, course_key_string, edx_video_id=None):
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
@require_GET
def video_encodings_download(request, course_key_string):
......@@ -424,28 +568,41 @@ def videos_index_html(course):
"""
Returns an HTML page to display previous video uploads and allow new ones
"""
return render_to_response(
'videos_index.html',
{
'context_course': course,
'image_upload_url': reverse_course_url('video_images_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)),
'default_video_image_url': _get_default_video_image_url(),
'previous_uploads': _get_index_videos(course),
'concurrent_upload_limit': settings.VIDEO_UPLOAD_PIPELINE.get('CONCURRENT_UPLOAD_LIMIT', 0),
'video_supported_file_formats': VIDEO_SUPPORTED_FILE_FORMATS.keys(),
'video_upload_max_file_size': VIDEO_UPLOAD_MAX_FILE_SIZE_GB,
'video_image_settings': {
'video_image_upload_enabled': WAFFLE_SWITCHES.is_enabled(VIDEO_IMAGE_UPLOAD_ENABLED),
'max_size': settings.VIDEO_IMAGE_SETTINGS['VIDEO_IMAGE_MAX_BYTES'],
'min_size': settings.VIDEO_IMAGE_SETTINGS['VIDEO_IMAGE_MIN_BYTES'],
'max_width': settings.VIDEO_IMAGE_MAX_WIDTH,
'max_height': settings.VIDEO_IMAGE_MAX_HEIGHT,
'supported_file_formats': settings.VIDEO_IMAGE_SUPPORTED_FILE_FORMATS
}
is_video_transcript_enabled = VideoTranscriptEnabledFlag.feature_enabled(course.id)
context = {
'context_course': course,
'image_upload_url': reverse_course_url('video_images_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)),
'default_video_image_url': _get_default_video_image_url(),
'previous_uploads': _get_index_videos(course),
'concurrent_upload_limit': settings.VIDEO_UPLOAD_PIPELINE.get('CONCURRENT_UPLOAD_LIMIT', 0),
'video_supported_file_formats': VIDEO_SUPPORTED_FILE_FORMATS.keys(),
'video_upload_max_file_size': VIDEO_UPLOAD_MAX_FILE_SIZE_GB,
'video_image_settings': {
'video_image_upload_enabled': WAFFLE_SWITCHES.is_enabled(VIDEO_IMAGE_UPLOAD_ENABLED),
'max_size': settings.VIDEO_IMAGE_SETTINGS['VIDEO_IMAGE_MAX_BYTES'],
'min_size': settings.VIDEO_IMAGE_SETTINGS['VIDEO_IMAGE_MIN_BYTES'],
'max_width': settings.VIDEO_IMAGE_MAX_WIDTH,
'max_height': settings.VIDEO_IMAGE_MAX_HEIGHT,
'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):
......@@ -486,16 +643,17 @@ def videos_post(course, request):
The returned array corresponds exactly to the input array.
"""
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'"
elif any(
'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'"
elif any(
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"
......@@ -504,7 +662,7 @@ def videos_post(course, request):
bucket = storage_service_bucket()
course_video_upload_token = course.video_upload_pipeline['course_video_upload_token']
req_files = request.json['files']
req_files = data['files']
resp_files = []
for req_file in req_files:
......@@ -518,11 +676,20 @@ def videos_post(course, request):
edx_video_id = unicode(uuid4())
key = storage_service_key(bucket, file_name=edx_video_id)
for metadata_name, value in [
('course_video_upload_token', course_video_upload_token),
('client_video_id', file_name),
('course_key', unicode(course.id)),
]:
metadata_list = [
('course_video_upload_token', course_video_upload_token),
('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)
upload_url = key.generate_url(
KEY_EXPIRATION_IN_SECONDS,
......
......@@ -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 TRANSCRIPTS STORAGE ###############
VIDEO_TRANSCRIPTS_SETTINGS = ENV_TOKENS.get('VIDEO_TRANSCRIPTS_SETTINGS', VIDEO_TRANSCRIPTS_SETTINGS)
################ PUSH NOTIFICATIONS ###############
PARSE_KEYS = AUTH_TOKENS.get("PARSE_KEYS", {})
......
......@@ -112,6 +112,7 @@ from lms.envs.common import (
DISABLE_ACCOUNT_ACTIVATION_REQUIREMENT_SWITCH,
# Video Image settings
VIDEO_IMAGE_SETTINGS,
VIDEO_TRANSCRIPTS_SETTINGS,
)
from path import Path as path
from warnings import simplefilter
......
......@@ -353,3 +353,13 @@ VIDEO_IMAGE_SETTINGS = dict(
DIRECTORY_PREFIX='video-images/',
)
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 @@
contentType: 'application/json; charset=utf-8',
dataType: 'json',
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
......
......@@ -258,6 +258,7 @@
'js/spec/views/active_video_upload_list_spec',
'js/spec/views/previous_video_upload_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/assets_spec',
'js/spec/views/baseview_spec',
......
......@@ -10,19 +10,25 @@ define([
encodingsDownloadUrl,
defaultVideoImageURL,
concurrentUploadLimit,
uploadButton,
courseVideoSettingsButton,
previousUploads,
videoSupportedFileFormats,
videoUploadMaxFileSizeInGB,
activeTranscriptPreferences,
videoTranscriptSettings,
isVideoTranscriptEnabled,
videoImageSettings
) {
var activeView = new ActiveVideoUploadListView({
postUrl: videoHandlerUrl,
concurrentUploadLimit: concurrentUploadLimit,
uploadButton: uploadButton,
courseVideoSettingsButton: courseVideoSettingsButton,
videoSupportedFileFormats: videoSupportedFileFormats,
videoUploadMaxFileSizeInGB: videoUploadMaxFileSizeInGB,
videoImageSettings: videoImageSettings,
activeTranscriptPreferences: activeTranscriptPreferences,
videoTranscriptSettings: videoTranscriptSettings,
isVideoTranscriptEnabled: isVideoTranscriptEnabled,
onFileUploadDone: function(activeVideos) {
$.ajax({
url: videoHandlerUrl,
......
......@@ -20,6 +20,10 @@ define(
fail: 'upload_failed',
success: 'upload_completed'
},
videoUploadMaxFileSizeInGB = 5,
videoSupportedFileFormats = ['.mp4', '.mov'],
createActiveUploadListView,
$courseVideoSettingsButton,
makeUploadUrl,
getSentRequests,
verifyUploadViewInfo,
......@@ -29,24 +33,35 @@ define(
verifyA11YMessage,
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() {
beforeEach(function() {
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-list');
this.postUrl = POST_URL;
this.uploadButton = $('<button>');
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
});
$courseVideoSettingsButton = $('.course-video-settings-button');
this.view = createActiveUploadListView(true);
this.view.render();
jasmine.Ajax.install();
});
......@@ -55,6 +70,10 @@ define(
afterEach(function() {
$(window).off('beforeunload');
jasmine.Ajax.uninstall();
if (this.view.courseVideoSettingsView) {
this.view.courseVideoSettingsView = null;
}
});
it('renders correct text in file drag/drop area', function() {
......@@ -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();
clickSpy.and.callFake(function(event) { event.preventDefault(); });
this.view.$('.js-file-input').on('click', clickSpy);
this.view.$('.file-drop-area').click();
expect(clickSpy).toHaveBeenCalled();
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() {
......@@ -311,7 +340,7 @@ define(
'Your file could not be uploaded',
StringUtils.interpolate(
'{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(
verifyUploadViewInfo(
uploadView,
'Your file could not be uploaded',
'file.mp4 exceeds maximum size of ' + this.videoUploadMaxFileSizeInGB + ' GB.'
'file.mp4 exceeds maximum size of ' + videoUploadMaxFileSizeInGB + ' GB.'
);
verifyA11YMessage(
StringUtils.interpolate(
......@@ -414,7 +443,7 @@ define(
expect(jasmine.Ajax.requests.count()).toEqual(caseInfo.numFiles);
_.each(_.range(caseInfo.numFiles), function(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.requestHeaders['Content-Type']).toEqual('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([
'js/models/active_video_upload',
'js/views/baseview',
'js/views/active_video_upload',
'js/views/course_video_settings',
'edx-ui-toolkit/js/utils/html-utils',
'edx-ui-toolkit/js/utils/string-utils',
'text!templates/active-video-upload-list.underscore',
'jquery.fileupload'
],
function($, _, Backbone, ActiveVideoUpload, BaseView, ActiveVideoUploadView,
function($, _, Backbone, ActiveVideoUpload, BaseView, ActiveVideoUploadView, CourseVideoSettingsView,
HtmlUtils, StringUtils, activeVideoUploadListTemplate) {
'use strict';
var ActiveVideoUploadListView,
......@@ -40,11 +41,14 @@ define([
this.listenTo(this.collection, 'add', this.addUpload);
this.concurrentUploadLimit = options.concurrentUploadLimit || 0;
this.postUrl = options.postUrl;
this.activeTranscriptPreferences = options.activeTranscriptPreferences;
this.videoTranscriptSettings = options.videoTranscriptSettings;
this.isVideoTranscriptEnabled = options.isVideoTranscriptEnabled;
this.videoSupportedFileFormats = options.videoSupportedFileFormats;
this.videoUploadMaxFileSizeInGB = options.videoUploadMaxFileSizeInGB;
this.onFileUploadDone = options.onFileUploadDone;
if (options.uploadButton) {
options.uploadButton.click(this.chooseFile.bind(this));
if (options.courseVideoSettingsButton) {
options.courseVideoSettingsButton.click(this.showCourseVideoSettingsView.bind(this));
}
this.maxSizeText = StringUtils.interpolate(
......@@ -59,6 +63,37 @@ define([
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() {
......@@ -98,7 +133,6 @@ define([
$(window).on('drop', preventDefault);
$(window).on('beforeunload', this.onBeforeUnload.bind(this));
$(window).on('unload', this.onUnload.bind(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;
$gray-d2: shade($gray, 40%) !default;
$gray-d3: shade($gray, 60%) !default;
$gray-d4: shade($gray, 80%) !default;
$gray-u1: #ECF0F1;
// These define button styles similar to LMS
// 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;
$state-danger-text: $black !default;
$state-danger-bg: #f2dede !default;
$state-danger-border: darken($state-danger-bg, 5%) !default;
$text-dark-black-blue: #2C3E50;
......@@ -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 {
@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 @@
"${encodings_download_url | n, js_escaped_string}",
"${default_video_image_url | n, js_escaped_string}",
${concurrent_upload_limit | n, dump_js_escaped_json},
$(".nav-actions .upload-button"),
$(".nav-actions .course-video-settings-button"),
$contentWrapper.data("previous-uploads"),
${video_supported_file_formats | 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}
);
});
......@@ -46,20 +49,21 @@
<%block name="content">
<div class="wrapper-mast wrapper">
<div class="video-transcript-settings-wrapper"></div>
<header class="mast has-actions has-subtitle">
<h1 class="page-header">
<small class="subtitle">${_("Content")}</small>
<span class="sr">&gt; </span>${_("Video Uploads")}
</h1>
% if is_video_transcript_enabled :
<nav class="nav-actions" aria-label="${_('Page Actions')}">
<h3 class="sr">${_("Page Actions")}</h3>
<ul>
<li class="nav-item">
<a href="#" class="button upload-button new-button"><span class="icon fa fa-plus" aria-hidden="true"></span> ${_("Upload New File")}</a>
</li>
</ul>
<div class="nav-item">
<button class="button course-video-settings-button"><span class="icon fa fa-cog" aria-hidden="true"></span> ${_("Course Video Settings")}</button>
</div>
</nav>
% endif
</header>
</div>
......
......@@ -128,6 +128,7 @@ urlpatterns += patterns(
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'^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'^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(
......@@ -210,6 +211,11 @@ if settings.DEBUG:
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:
import debug_toolbar
urlpatterns += (
......
......@@ -55,6 +55,7 @@ class CrossStoreXMLRoundtrip(CourseComparisonTest, PartitionTestCase):
self.export_dir = mkdtemp()
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)
@ddt.data(*itertools.product(
MODULESTORE_SETUPS,
......
......@@ -557,6 +557,7 @@ class TestMongoModuleStore(TestMongoModuleStoreBase):
check_xblock_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)
def test_export_course_image(self, _from_json):
"""
......@@ -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())
@patch('xmodule.video_module.video_module.edxval_api', None)
@patch('xmodule.tabs.CourseTab.from_json', side_effect=mock_tab_from_json)
def test_export_course_image_nondefault(self, _from_json):
"""
......@@ -590,6 +592,7 @@ class TestMongoModuleStore(TestMongoModuleStoreBase):
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())
@patch('xmodule.video_module.video_module.edxval_api', None)
def test_course_without_image(self):
"""
Make sure we elegantly passover our code when there isn't a static
......
......@@ -68,6 +68,7 @@ class RoundTripTestCase(unittest.TestCase):
self.temp_dir = mkdtemp()
self.addCleanup(shutil.rmtree, self.temp_dir)
@mock.patch('xmodule.video_module.video_module.edxval_api', None)
@mock.patch('xmodule.course_module.requests.get')
@ddt.data(
"toy",
......
......@@ -646,7 +646,11 @@ class VideoDescriptorImportTestCase(unittest.TestCase):
video = VideoDescriptor.from_xml(xml_data, module_system, id_generator)
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')
def test_import_val_data_invalid(self, mock_val_api):
......@@ -673,14 +677,7 @@ class VideoExportTestCase(VideoDescriptorTestBase):
"""
Test that we write the correct XML on export.
"""
def mock_val_export(edx_video_id, course_id):
"""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
mock_val_api.export_to_xml = Mock(return_value=etree.Element('video_asset'))
self.descriptor.youtube_id_0_75 = 'izygArpw-Qo'
self.descriptor.youtube_id_1_0 = 'p2Q6BrNhdh8'
self.descriptor.youtube_id_1_25 = '1EeWXzPdhSA'
......@@ -691,7 +688,7 @@ class VideoExportTestCase(VideoDescriptorTestBase):
self.descriptor.track = 'http://www.example.com/track'
self.descriptor.handout = 'http://www.example.com/handout'
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.transcripts = {'ua': 'ukrainian_translation.srt', 'ge': 'german_translation.srt'}
self.descriptor.edx_video_id = 'test_edx_video_id'
......@@ -702,16 +699,21 @@ class VideoExportTestCase(VideoDescriptorTestBase):
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">
<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"/>
<handout src="http://www.example.com/handout"/>
<transcript language="ge" src="german_translation.srt" />
<transcript language="ua" src="ukrainian_translation.srt" />
<video_asset export_edx_video_id="test_edx_video_id"/>
<video_asset />
</video>
'''
expected = etree.XML(xml_string, parser=parser)
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')
def test_export_to_xml_val_error(self, mock_val_api):
......@@ -727,6 +729,7 @@ class VideoExportTestCase(VideoDescriptorTestBase):
expected = etree.XML(xml_string, parser=parser)
self.assertXmlEqual(expected, xml)
@patch('xmodule.video_module.video_module.edxval_api', None)
def test_export_to_xml_empty_end_time(self):
"""
Test that we write the correct XML on export.
......@@ -755,6 +758,7 @@ class VideoExportTestCase(VideoDescriptorTestBase):
expected = etree.XML(xml_string, parser=parser)
self.assertXmlEqual(expected, xml)
@patch('xmodule.video_module.video_module.edxval_api', None)
def test_export_to_xml_empty_parameters(self):
"""
Test XML export with defaults.
......@@ -764,6 +768,7 @@ class VideoExportTestCase(VideoDescriptorTestBase):
expected = '<video url_name="SampleProblem" download_video="false"/>\n'
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):
"""
Test XML export with transcripts being overridden to None.
......@@ -773,6 +778,7 @@ class VideoExportTestCase(VideoDescriptorTestBase):
expected = '<video url_name="SampleProblem" download_video="false"/>\n'
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):
"""
Test XML export will *not* raise TypeError by lxml library if contains illegal characters.
......@@ -782,6 +788,7 @@ class VideoExportTestCase(VideoDescriptorTestBase):
xml = self.descriptor.definition_to_xml(None)
self.assertEqual(xml.get('display_name'), 'DisplayName')
@patch('xmodule.video_module.video_module.edxval_api', None)
def test_export_to_xml_unicode_characters(self):
"""
Test XML export handles the unicode characters.
......
......@@ -12,15 +12,23 @@ from pysrt import SubRipTime, SubRipItem, SubRipFile
from lxml import etree
from HTMLParser import HTMLParser
from openedx.core.djangoapps.video_config.models import VideoTranscriptEnabledFlag
from xmodule.exceptions import NotFoundError
from xmodule.contentstore.content import StaticContent
from xmodule.contentstore.django import contentstore
from .bumper_utils import get_bumper_settings
try:
from edxval import api as edxval_api
except ImportError:
edxval_api = None
log = logging.getLogger(__name__)
NON_EXISTENT_TRANSCRIPT = 'non_existent_dummy_file_name'
class TranscriptException(Exception): # pylint: disable=missing-docstring
pass
......@@ -469,6 +477,80 @@ def get_or_create_sjson(item, transcripts):
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):
"""
Container for transcript methods.
......@@ -518,6 +600,13 @@ class Transcript(object):
`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
return Transcript.get_asset(location, asset_filename)
......@@ -557,10 +646,11 @@ class VideoTranscriptsMixin(object):
This is necessary for both VideoModule and VideoDescriptor.
"""
def available_translations(self, transcripts, verify_assets=None):
"""Return a list of language codes for which we have transcripts.
def available_translations(self, transcripts, verify_assets=None, include_val_transcripts=None):
"""
Return a list of language codes for which we have transcripts.
Args:
Arguments:
verify_assets (boolean): If True, checks to ensure that the transcripts
really exist in the contentstore. If False, we just look at the
VideoDescriptor fields and do not query the contentstore. One reason
......@@ -570,8 +660,7 @@ class VideoTranscriptsMixin(object):
Defaults to `not FALLBACK_TO_ENGLISH_TRANSCRIPTS`.
transcripts (dict): A dict with all transcripts and a sub.
Defaults to False
include_val_transcripts(boolean): If True, adds the edx-val transcript languages as well.
"""
translations = []
if verify_assets is None:
......@@ -588,7 +677,14 @@ class VideoTranscriptsMixin(object):
return translations
# 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'.
try:
Transcript.asset(self.location, sub, 'en')
......@@ -598,18 +694,20 @@ class VideoTranscriptsMixin(object):
except NotFoundError:
pass
else:
translations += ['en']
translations.append('en')
else:
translations += ['en']
translations.append('en')
for lang in other_langs:
try:
Transcript.asset(self.location, None, None, other_langs[lang])
except NotFoundError:
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):
"""
......@@ -672,9 +770,13 @@ class VideoTranscriptsMixin(object):
transcript_language = u'en'
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.
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:
transcripts = copy.deepcopy(get_bumper_settings(self).get('transcripts', {}))
......@@ -688,6 +790,24 @@ class VideoTranscriptsMixin(object):
language_code: 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 {
"sub": sub,
"transcripts": transcripts,
......
......@@ -7,6 +7,8 @@ StudioViewHandlers are handlers for video descriptor instance.
import json
import logging
import os
from datetime import datetime
from webob import Response
......@@ -18,13 +20,15 @@ from opaque_keys.edx.locator import CourseLocator
from .transcripts_utils import (
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,
TranscriptsGenerationException,
generate_sjson_for_all_speeds,
youtube_speed_dict,
Transcript,
save_to_store,
subs_filename
)
......@@ -221,7 +225,8 @@ class VideoStudentViewHandlers(object):
For 'en' check if SJSON exists. For non-`en` check if SRT file exists.
"""
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'):
language = dispatch.replace('translation', '').strip('/')
......@@ -238,16 +243,31 @@ class VideoStudentViewHandlers(object):
try:
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)
# Try to return static URL redirection as last resort
# if no translation is required
return self.get_static_transcript(request, transcripts)
except (
TranscriptException,
UnicodeDecodeError,
TranscriptsGenerationException
) as ex:
response = self.get_static_transcript(request, transcripts)
if response.status_code == 404 and feature_enabled:
# Try to get transcript from edx-val as a last resort.
transcript = get_video_transcript_content(
language_code=self.transcript_language,
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)
response = Response(status=404)
else:
......@@ -260,8 +280,44 @@ class VideoStudentViewHandlers(object):
transcript_content, transcript_filename, transcript_mime_type = self.get_transcript(
transcripts, transcript_format=self.transcript_download_format, lang=lang
)
except (NotFoundError, ValueError, KeyError, UnicodeDecodeError):
log.debug("Video@download exception")
except (ValueError, NotFoundError):
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)
else:
response = Response(
......@@ -276,7 +332,11 @@ class VideoStudentViewHandlers(object):
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:
response = Response(json.dumps(available_translations))
response.content_type = 'application/json'
......
......@@ -24,7 +24,7 @@ from pkg_resources import resource_string
from django.conf import settings
from lxml import etree
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.license import LicenseMixin
from xblock.core import XBlock
......@@ -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 .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_utils import create_youtube_string, format_xml_exception_message, get_poster, rewrite_video_url
from .video_xfields import VideoFields
......@@ -181,13 +187,13 @@ class VideoModule(VideoFields, VideoTranscriptsMixin, VideoStudentViewHandlers,
track_url = self.runtime.handler_url(self, 'transcript', 'download').rstrip('/?')
transcript_language = self.get_default_transcript_language(transcripts)
native_languages = {lang: label for lang, label in settings.LANGUAGES if len(lang) == 2}
languages = {
lang: native_languages.get(lang, display)
for lang, display in settings.ALL_LANGUAGES
if lang in other_lang
}
if not other_lang or (other_lang and sub):
languages['en'] = 'English'
......@@ -277,7 +283,9 @@ class VideoModule(VideoFields, VideoTranscriptsMixin, VideoStudentViewHandlers,
if download_video_link and download_video_link.endswith('.m3u8'):
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
# TODO(ali@edx.org): Delete this after the CDN experiment has completed.
......@@ -595,6 +603,10 @@ class VideoDescriptor(VideoFields, VideoTranscriptsMixin, VideoStudioViewHandler
ScopeIds(None, block_type, definition_id, usage_id),
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
def definition_to_xml(self, resource_fs):
......@@ -658,14 +670,19 @@ class VideoDescriptor(VideoFields, VideoTranscriptsMixin, VideoStudioViewHandler
ele.set('src', self.transcripts[transcript_language])
xml.append(ele)
if self.edx_video_id and edxval_api:
try:
xml.append(edxval_api.export_to_xml(
self.edx_video_id,
unicode(self.runtime.course_id.for_branch(None)))
)
except edxval_api.ValVideoNotFoundError:
pass
if edxval_api:
external, video_ids = get_video_ids_info(self.edx_video_id, self.youtube_id_1_0, self.html5_sources)
if video_ids:
try:
xml.append(
edxval_api.export_to_xml(
video_ids,
unicode(self.runtime.course_id.for_branch(None)),
external=external
)
)
except edxval_api.ValVideoNotFoundError:
pass
# handle license specifically
self.add_license_to_xml(xml)
......@@ -864,24 +881,33 @@ class VideoDescriptor(VideoFields, VideoTranscriptsMixin, VideoStudioViewHandler
if 'download_track' not in field_data and track is not None:
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')
if (
edxval_api and
video_asset_elem is not None and
'edx_video_id' in field_data
):
# Allow ValCannotCreateError to escape
if edxval_api and video_asset_elem is not None:
# Always pass the edx_video_id, Whether the video is internal or external
# In case of external, we only need to import transcripts and for that
# purpose video id is already present in the xml
edxval_api.import_from_xml(
video_asset_elem,
field_data['edx_video_id'],
edx_video_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):
xblock_body = super(VideoDescriptor, self).index_dictionary()
video_body = {
......@@ -990,10 +1016,12 @@ class VideoDescriptor(VideoFields, VideoTranscriptsMixin, VideoStudioViewHandler
"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 = {
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 {
......
......@@ -11,7 +11,7 @@ import ddt
import freezegun
from mock import MagicMock, Mock, patch
from nose.plugins.attrib import attr
from webob import Request
from webob import Request, Response
from common.test.utils import normalize_repr
from openedx.core.djangoapps.contentserver.caching import del_cached_content
......@@ -189,6 +189,7 @@ class TestVideo(BaseTestXmodule):
@attr(shard=1)
@ddt.ddt
class TestTranscriptAvailableTranslationsDispatch(TestVideo):
"""
Test video handler that provide available translations info.
......@@ -247,6 +248,80 @@ class TestTranscriptAvailableTranslationsDispatch(TestVideo):
response = self.item.transcript(request=request, dispatch='available_translations')
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)
@ddt.ddt
......@@ -370,6 +445,55 @@ class TestTranscriptDownloadDispatch(TestVideo):
self.assertEqual(response.headers['Content-Type'], 'application/x-subrip; charset=utf-8')
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)
@ddt.ddt
......@@ -602,6 +726,55 @@ class TestTranscriptTranslationGetDispatch(TestVideo):
with store.branch_setting(ModuleStoreEnum.Branch.draft_preferred, self.course.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)
class TestStudioTranscriptTranslationGetDispatch(TestVideo):
......
......@@ -9,7 +9,15 @@ import ddt
from django.conf import settings
from django.test import TestCase
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 mock import MagicMock, Mock, patch
from nose.plugins.attrib import attr
......@@ -1307,6 +1315,7 @@ class TestVideoDescriptorStudentViewJson(TestCase):
self.transcript_url = "transcript_url"
self.video = instantiate_descriptor(data=sample_xml)
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):
"""
......@@ -1405,6 +1414,7 @@ class TestVideoDescriptorStudentViewJson(TestCase):
self.transcript_url = "transcript_url"
self.video = instantiate_descriptor(data=sample_xml)
self.video.runtime.handler_url = Mock(return_value=self.transcript_url)
self.video.runtime.course_id = MagicMock()
result = self.get_result()
self.verify_result_with_youtube_url(result)
......@@ -1442,6 +1452,43 @@ class TestVideoDescriptorStudentViewJson(TestCase):
result = self.get_result(allow_cache_miss)
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)
class VideoDescriptorTest(TestCase, VideoDescriptorTestBase):
......@@ -1453,6 +1500,15 @@ class VideoDescriptorTest(TestCase, VideoDescriptorTestBase):
self.descriptor.runtime.handler_url = 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):
""""
Test get_context.
......@@ -1480,7 +1536,7 @@ class VideoDescriptorTest(TestCase, VideoDescriptorTestBase):
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'
create_profile('mobile')
create_video({
......@@ -1495,15 +1551,52 @@ class VideoDescriptorTest(TestCase, VideoDescriptorTestBase):
'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)
expected_str = """
<video download_video="false" url_name="SampleProblem">
<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"/>
<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=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)
expected = etree.XML(expected_str, parser=parser)
self.assertXmlEqual(expected, actual)
......@@ -1516,7 +1609,21 @@ class VideoDescriptorTest(TestCase, VideoDescriptorTestBase):
expected = etree.XML(expected_str, parser=parser)
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')
module_system = DummySystem(load_error_modules=True)
......@@ -1524,12 +1631,15 @@ class VideoDescriptorTest(TestCase, VideoDescriptorTestBase):
<video edx_video_id="test_edx_video_id">
<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"/>
<transcripts>
<transcript file_format="srt" file_name="ext101.srt" language_code="ar" provider="Cielo24" video_id="test_edx_video_id"/>
</transcripts>
</video_asset>
</video>
"""
id_generator = Mock()
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')
video_data = get_video_info(video.edx_video_id)
self.assertEqual(video_data['client_video_id'], 'test_client_video_id')
......@@ -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]['file_size'], 222)
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):
create_profile('mobile')
......
......@@ -11,6 +11,7 @@ from courseware.module_render import get_module_for_descriptor
from util.module_utils import get_dynamic_descriptor_children
from xmodule.modulestore.django import modulestore
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):
......@@ -208,8 +209,12 @@ def video_summary(video_profiles, course_id, video_descriptor, request, local_ca
size = default_encoded_video.get('file_size', 0)
# Transcripts...
transcripts_info = video_descriptor.get_transcripts_info()
transcript_langs = video_descriptor.available_translations(transcripts_info)
feature_enabled = is_val_transcript_feature_enabled_for_course(course_id)
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 = {
lang: reverse(
......
......@@ -2,12 +2,14 @@
"""
Tests for video outline API
"""
import ddt
import itertools
import json
from collections import namedtuple
from mock import Mock
from uuid import uuid4
import ddt
from django.conf import settings
from edxval import api
from milestones.tests.utils import MilestonesTestCaseMixin
......@@ -876,6 +878,36 @@ class TestVideoSummaryList(TestVideoAPITestCase, MobileAuthTestMixin, MobileCour
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)
class TestTranscriptsDetail(TestVideoAPITestCase, MobileAuthTestMixin, MobileCourseAccessTestMixin,
......@@ -905,3 +937,57 @@ class TestTranscriptsDetail(TestVideoAPITestCase, MobileAuthTestMixin, MobileCou
self.video = self._create_video_with_subs(custom_subid=u'你好')
self.login_and_enroll()
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
optimize and reason about, and it avoids having to tackle the bigger problem of
general XBlock representation in this rather specialized formatting.
"""
import os
from functools import partial
from django.http import Http404, HttpResponse
......@@ -16,6 +17,11 @@ from rest_framework.response import Response
from mobile_api.models import MobileApiConfig
from xmodule.exceptions import NotFoundError
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 .serializers import BlockOutline, video_summary
......@@ -111,14 +117,31 @@ class VideoTranscripts(generics.RetrieveAPIView):
block_id = kwargs['block_id']
lang = kwargs['lang']
usage_key = BlockUsageLocator(
course.id, block_type="video", block_id=block_id
)
usage_key = BlockUsageLocator(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:
video_descriptor = modulestore().get_item(usage_key)
transcripts = video_descriptor.get_transcripts_info()
transcripts = video_descriptor.get_transcripts_info(include_val_transcripts=feature_enabled)
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))
response = HttpResponse(content, content_type=mimetype)
......
......@@ -795,6 +795,9 @@ XBLOCK_SETTINGS.setdefault("VideoModule", {})['YOUTUBE_API_KEY'] = AUTH_TOKENS.g
##### VIDEO IMAGE STORAGE #####
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_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)
......
......@@ -2608,6 +2608,20 @@ VIDEO_IMAGE_SETTINGS = dict(
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:
# 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.
from config_models.admin import ConfigurationModelAdmin, KeyedConfigurationModelAdmin
from django.contrib import admin
from openedx.core.djangoapps.video_config.forms import CourseHLSPlaybackFlagAdminForm
from openedx.core.djangoapps.video_config.models import CourseHLSPlaybackEnabledFlag, HLSPlaybackEnabledFlag
from openedx.core.djangoapps.video_config.forms import (
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.
"""
form = CourseHLSPlaybackFlagAdminForm
# Make abstract base class
class Meta(object):
abstract = True
search_fields = ['course_id']
fieldsets = (
(None, {
......@@ -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(CourseHLSPlaybackEnabledFlag, CourseHLSPlaybackEnabledFlagAdmin)
admin.site.register(VideoTranscriptEnabledFlag, ConfigurationModelAdmin)
admin.site.register(CourseVideoTranscriptEnabledFlag, CourseHLSPlaybackEnabledFlagAdmin)
......@@ -7,20 +7,20 @@ from django import forms
from opaque_keys import InvalidKeyError
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
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):
model = CourseHLSPlaybackEnabledFlag
fields = '__all__'
abstract = True
def clean_course_id(self):
"""
......@@ -42,3 +42,23 @@ class CourseHLSPlaybackFlagAdminForm(forms.ModelForm):
raise forms.ValidationError(msg)
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):
course_key=unicode(self.course_id),
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
@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,
course_id=None, enabled_for_course=False
):
"""
Yields HLS Playback Configuration records for unit tests
Yields video feature configuration records for unit tests
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
enabled_for_all_courses (bool): Specifies whether feature is enabled for all courses
course_id (CourseLocator): Course locator for course specific configurations
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:
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
@ddt.ddt
class TestHLSPlaybackFlag(TestCase):
class FeatureFlagTestMixin(object):
"""
Tests the behavior of the flags for HLS Playback feature.
These are set via Django admin settings.
Adds util methods to test the behavior of the flags for video feature.
"""
def setUp(self):
super(TestHLSPlaybackFlag, self).setUp()
self.course_id_1 = CourseLocator(org="edx", course="course", run="run")
self.course_id_2 = CourseLocator(org="edx", course="course2", run="run")
course_id_1 = CourseLocator(org="edx", course="course", run="run")
course_id_2 = CourseLocator(org="edx", course="course2", run="run")
@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):
def verify_feature_flags(self, all_courses_model_class, course_specific_model_class,
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 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,
enabled_for_all_courses=enabled_for_all_courses,
course_id=self.course_id_1,
enabled_for_course=enabled_for_course_1
):
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)
)
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
)
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,
enabled_for_all_courses=False,
course_id=self.course_id_1,
enabled_for_course=True
):
self.assertTrue(HLSPlaybackEnabledFlag.feature_enabled(self.course_id_1))
with hls_playback_feature_flags(
self.assertTrue(all_courses_model_class.feature_enabled(self.course_id_1))
with video_feature_flags(
all_courses_model_class=all_courses_model_class,
course_specific_model_class=course_specific_model_class,
global_flag=True,
enabled_for_all_courses=False,
course_id=self.course_id_1,
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,
enabled_for_all_courses=True,
):
self.assertTrue(HLSPlaybackEnabledFlag.feature_enabled(self.course_id_1))
self.assertTrue(HLSPlaybackEnabledFlag.feature_enabled(self.course_id_2))
with hls_playback_feature_flags(
self.assertTrue(all_courses_model_class.feature_enabled(self.course_id_1))
self.assertTrue(all_courses_model_class.feature_enabled(self.course_id_2))
with video_feature_flags(
all_courses_model_class=all_courses_model_class,
course_specific_model_class=course_specific_model_class,
global_flag=True,
enabled_for_all_courses=False,
):
self.assertFalse(HLSPlaybackEnabledFlag.feature_enabled(self.course_id_1))
self.assertFalse(HLSPlaybackEnabledFlag.feature_enabled(self.course_id_2))
with hls_playback_feature_flags(
self.assertFalse(all_courses_model_class.feature_enabled(self.course_id_1))
self.assertFalse(all_courses_model_class.feature_enabled(self.course_id_2))
with video_feature_flags(
all_courses_model_class=all_courses_model_class,
course_specific_model_class=course_specific_model_class,
global_flag=False,
):
self.assertFalse(HLSPlaybackEnabledFlag.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_1))
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
edx-rest-api-client==1.7.1
edx-search==1.1.0
edx-submissions==2.0.12
edxval==0.1.1
edxval==0.1.2
event-tracking==0.2.4
feedparser==5.1.3
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