diff --git a/cms/djangoapps/contentstore/tests/test_transcripts_utils.py b/cms/djangoapps/contentstore/tests/test_transcripts_utils.py index 52f9993e00..603cc23246 100644 --- a/cms/djangoapps/contentstore/tests/test_transcripts_utils.py +++ b/cms/djangoapps/contentstore/tests/test_transcripts_utils.py @@ -2,6 +2,7 @@ """ Tests for transcripts_utils. """ import copy import ddt +import json import textwrap import unittest from uuid import uuid4 @@ -610,28 +611,53 @@ class TestTranscript(unittest.TestCase): self.txt_transcript = u"Elephant's Dream\nAt the left we can see..." def test_convert_srt_to_txt(self): + """ + Tests that the srt transcript is successfully converted into txt format. + """ expected = self.txt_transcript actual = transcripts_utils.Transcript.convert(self.srt_transcript, 'srt', 'txt') self.assertEqual(actual, expected) def test_convert_srt_to_srt(self): + """ + Tests that srt to srt conversion works as expected. + """ expected = self.srt_transcript actual = transcripts_utils.Transcript.convert(self.srt_transcript, 'srt', 'srt') self.assertEqual(actual, expected) def test_convert_sjson_to_txt(self): + """ + Tests that the sjson transcript is successfully converted into txt format. + """ expected = self.txt_transcript actual = transcripts_utils.Transcript.convert(self.sjson_transcript, 'sjson', 'txt') self.assertEqual(actual, expected) def test_convert_sjson_to_srt(self): + """ + Tests that the sjson transcript is successfully converted into srt format. + """ expected = self.srt_transcript actual = transcripts_utils.Transcript.convert(self.sjson_transcript, 'sjson', 'srt') self.assertEqual(actual, expected) def test_convert_srt_to_sjson(self): - with self.assertRaises(NotImplementedError): - transcripts_utils.Transcript.convert(self.srt_transcript, 'srt', 'sjson') + """ + Tests that the srt transcript is successfully converted into sjson format. + """ + expected = json.loads(self.sjson_transcript) + actual = transcripts_utils.Transcript.convert(self.srt_transcript, 'srt', 'sjson') + self.assertDictEqual(actual, expected) + + def test_convert_invalid_srt_to_sjson(self): + """ + Tests that TranscriptsGenerationException was raises on trying + to convert invalid srt transcript to sjson. + """ + invalid_srt_transcript = 'invalid SubRip file content' + with self.assertRaises(transcripts_utils.TranscriptsGenerationException): + transcripts_utils.Transcript.convert(invalid_srt_transcript, 'srt', 'sjson') def test_dummy_non_existent_transcript(self): """ diff --git a/cms/djangoapps/contentstore/views/tests/test_transcript_settings.py b/cms/djangoapps/contentstore/views/tests/test_transcript_settings.py index 32eb1a0edb..05dc62a011 100644 --- a/cms/djangoapps/contentstore/views/tests/test_transcript_settings.py +++ b/cms/djangoapps/contentstore/views/tests/test_transcript_settings.py @@ -1,12 +1,15 @@ +# -*- coding: utf-8 -*- import ddt import json -from mock import Mock, patch +from io import BytesIO +from mock import Mock, patch, ANY from django.test.testcases import TestCase from contentstore.tests.utils import CourseTestCase from contentstore.utils import reverse_course_url from contentstore.views.transcript_settings import TranscriptionProviderErrorType, validate_transcript_credentials +from openedx.core.djangoapps.profile_images.tests.helpers import make_image_file @ddt.ddt @@ -169,3 +172,299 @@ class TranscriptCredentialsValidationTest(TestCase): # Assert the results. self.assertEqual(error_message, expected_error_message) self.assertDictEqual(validated_credentials, expected_validated_credentials) + + +@ddt.ddt +@patch( + 'openedx.core.djangoapps.video_config.models.VideoTranscriptEnabledFlag.feature_enabled', + Mock(return_value=True) +) +class TranscriptDownloadTest(CourseTestCase): + """ + Tests for transcript download handler. + """ + VIEW_NAME = 'transcript_download_handler' + + def get_url_for_course_key(self, course_id): + return reverse_course_url(self.VIEW_NAME, course_id) + + def test_302_with_anonymous_user(self): + """ + Verify that redirection happens in case of unauthorized request. + """ + self.client.logout() + transcript_download_url = self.get_url_for_course_key(self.course.id) + response = self.client.get(transcript_download_url, content_type='application/json') + self.assertEqual(response.status_code, 302) + + def test_405_with_not_allowed_request_method(self): + """ + Verify that 405 is returned in case of not-allowed request methods. + Allowed request methods include GET. + """ + transcript_download_url = self.get_url_for_course_key(self.course.id) + response = self.client.post(transcript_download_url, content_type='application/json') + self.assertEqual(response.status_code, 405) + + def test_404_with_feature_disabled(self): + """ + Verify that 404 is returned if the corresponding feature is disabled. + """ + transcript_download_url = self.get_url_for_course_key(self.course.id) + with patch('openedx.core.djangoapps.video_config.models.VideoTranscriptEnabledFlag.feature_enabled') as feature: + feature.return_value = False + response = self.client.get(transcript_download_url, content_type='application/json') + self.assertEqual(response.status_code, 404) + + @patch('contentstore.views.transcript_settings.get_video_transcript_data') + def test_transcript_download_handler(self, mock_get_video_transcript_data): + """ + Tests that transcript download handler works as expected. + """ + transcript_download_url = self.get_url_for_course_key(self.course.id) + 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 transcript download handler + response = self.client.get( + transcript_download_url, + data={ + 'edx_video_id': '123', + 'language_code': 'en' + }, + content_type='application/json' + ) + + # 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.content, expected_content) + for attribute, value in expected_headers.iteritems(): + self.assertEqual(response.get(attribute), value) + + @ddt.data( + ( + {}, + u'The following parameters are required: edx_video_id, language_code.' + ), + ( + {'edx_video_id': '123'}, + u'The following parameters are required: language_code.' + ), + ( + {'language_code': 'en'}, + u'The following parameters are required: edx_video_id.' + ), + ) + @ddt.unpack + def test_transcript_download_handler_missing_attrs(self, request_payload, expected_error_message): + """ + Tests that transcript download handler with missing attributes. + """ + # Make request to transcript download handler + transcript_download_url = self.get_url_for_course_key(self.course.id) + response = self.client.get(transcript_download_url, data=request_payload) + # Assert the response + self.assertEqual(response.status_code, 400) + self.assertEqual(json.loads(response.content)['error'], expected_error_message) + + +@ddt.ddt +@patch( + 'openedx.core.djangoapps.video_config.models.VideoTranscriptEnabledFlag.feature_enabled', + Mock(return_value=True) +) +class TranscriptUploadTest(CourseTestCase): + """ + Tests for transcript upload handler. + """ + VIEW_NAME = 'transcript_upload_handler' + + def get_url_for_course_key(self, course_id): + return reverse_course_url(self.VIEW_NAME, course_id) + + def test_302_with_anonymous_user(self): + """ + Verify that redirection happens in case of unauthorized request. + """ + self.client.logout() + transcript_upload_url = self.get_url_for_course_key(self.course.id) + response = self.client.post(transcript_upload_url, content_type='application/json') + self.assertEqual(response.status_code, 302) + + def test_405_with_not_allowed_request_method(self): + """ + Verify that 405 is returned in case of not-allowed request methods. + Allowed request methods include POST. + """ + transcript_upload_url = self.get_url_for_course_key(self.course.id) + response = self.client.get(transcript_upload_url, content_type='application/json') + self.assertEqual(response.status_code, 405) + + def test_404_with_feature_disabled(self): + """ + Verify that 404 is returned if the corresponding feature is disabled. + """ + transcript_upload_url = self.get_url_for_course_key(self.course.id) + with patch('openedx.core.djangoapps.video_config.models.VideoTranscriptEnabledFlag.feature_enabled') as feature: + feature.return_value = False + response = self.client.post(transcript_upload_url, content_type='application/json') + self.assertEqual(response.status_code, 404) + + @patch('contentstore.views.transcript_settings.create_or_update_video_transcript') + @patch('contentstore.views.transcript_settings.get_available_transcript_languages', Mock(return_value=['en'])) + def test_transcript_upload_handler(self, mock_create_or_update_video_transcript): + """ + Tests that transcript upload handler works as expected. + """ + transcript_upload_url = self.get_url_for_course_key(self.course.id) + transcript_file_stream = BytesIO('0\n00:00:00,010 --> 00:00:00,100\nПривіт, edX вітає вас.\n\n') + # Make request to transcript upload handler + response = self.client.post( + transcript_upload_url, + { + 'edx_video_id': '123', + 'language_code': 'en', + 'new_language_code': 'es', + 'file': transcript_file_stream, + }, + format='multipart' + ) + + self.assertEqual(response.status_code, 201) + mock_create_or_update_video_transcript.assert_called_with( + video_id='123', + language_code='en', + metadata={ + 'language_code': u'es', + 'file_format': 'sjson', + 'provider': 'Custom' + }, + file_data=ANY, + ) + + @ddt.data( + ( + { + 'edx_video_id': '123', + 'language_code': 'en', + 'new_language_code': 'en', + }, + u'A transcript file is required.' + ), + ( + { + 'language_code': u'en', + 'file': u'0\n00:00:00,010 --> 00:00:00,100\nHi, welcome to Edx.\n\n' + }, + u'The following parameters are required: edx_video_id, new_language_code.' + ), + ( + { + 'language_code': u'en', + 'new_language_code': u'en', + 'file': u'0\n00:00:00,010 --> 00:00:00,100\nHi, welcome to Edx.\n\n' + }, + u'The following parameters are required: edx_video_id.' + ), + ( + { + 'file': u'0\n00:00:00,010 --> 00:00:00,100\nHi, welcome to Edx.\n\n' + }, + u'The following parameters are required: edx_video_id, language_code, new_language_code.' + ) + ) + @ddt.unpack + @patch('contentstore.views.transcript_settings.get_available_transcript_languages', Mock(return_value=['en'])) + def test_transcript_upload_handler_missing_attrs(self, request_payload, expected_error_message): + """ + Tests the transcript upload handler when the required attributes are missing. + """ + transcript_upload_url = self.get_url_for_course_key(self.course.id) + # Make request to transcript upload handler + response = self.client.post(transcript_upload_url, request_payload, format='multipart') + self.assertEqual(response.status_code, 400) + self.assertEqual(json.loads(response.content)['error'], expected_error_message) + + @patch('contentstore.views.transcript_settings.get_available_transcript_languages', Mock(return_value=['en', 'es'])) + def test_transcript_upload_handler_existing_transcript(self): + """ + Tests that upload handler do not update transcript's language if a transcript + with the same language already present for an edx_video_id. + """ + transcript_upload_url = self.get_url_for_course_key(self.course.id) + # Make request to transcript upload handler + request_payload = { + 'edx_video_id': '1234', + 'language_code': 'en', + 'new_language_code': 'es' + } + response = self.client.post(transcript_upload_url, request_payload, format='multipart') + self.assertEqual(response.status_code, 400) + self.assertEqual( + json.loads(response.content)['error'], + u'A transcript with the "es" language code already exists.' + ) + + @patch('contentstore.views.transcript_settings.get_available_transcript_languages', Mock(return_value=['en'])) + def test_transcript_upload_handler_with_image(self): + """ + Tests the transcript upload handler with an image file. + """ + with make_image_file() as image_file: + transcript_upload_url = self.get_url_for_course_key(self.course.id) + # Make request to transcript upload handler + response = self.client.post( + transcript_upload_url, + { + 'edx_video_id': '123', + 'language_code': 'en', + 'new_language_code': 'es', + 'file': image_file, + }, + format='multipart' + ) + + self.assertEqual(response.status_code, 400) + self.assertEqual( + json.loads(response.content)['error'], + u'There is a problem with this transcript file. Try to upload a different file.' + ) + + @patch('contentstore.views.transcript_settings.get_available_transcript_languages', Mock(return_value=['en'])) + def test_transcript_upload_handler_with_invalid_transcript(self): + """ + Tests the transcript upload handler with an invalid transcript file. + """ + transcript_upload_url = self.get_url_for_course_key(self.course.id) + transcript_file_stream = BytesIO('An invalid transcript SubRip file content') + # Make request to transcript upload handler + response = self.client.post( + transcript_upload_url, + { + 'edx_video_id': '123', + 'language_code': 'en', + 'new_language_code': 'es', + 'file': transcript_file_stream, + }, + format='multipart' + ) + + self.assertEqual(response.status_code, 400) + self.assertEqual( + json.loads(response.content)['error'], + u'There is a problem with this transcript file. Try to upload a different file.' + ) diff --git a/cms/djangoapps/contentstore/views/tests/test_videos.py b/cms/djangoapps/contentstore/views/tests/test_videos.py index 45a41c3ca2..c158b5fb8f 100644 --- a/cms/djangoapps/contentstore/views/tests/test_videos.py +++ b/cms/djangoapps/contentstore/views/tests/test_videos.py @@ -15,7 +15,13 @@ import pytz from django.conf import settings from django.core.files.uploadedfile import UploadedFile from django.test.utils import override_settings -from edxval.api import create_profile, create_video, get_video_info, get_course_video_image_url +from edxval.api import ( + create_profile, + create_video, + get_video_info, + get_course_video_image_url, + create_or_update_video_transcript +) from mock import Mock, patch from contentstore.models import VideoUploadConfig @@ -228,6 +234,86 @@ class VideosHandlerTestCase(VideoUploadTestMixin, CourseTestCase): convert_video_status(original_video) ) + @ddt.data( + ( + False, + ['edx_video_id', 'client_video_id', 'created', 'duration', 'status', 'course_video_image_url'], + [], + {} + ), + ( + True, + ['edx_video_id', 'client_video_id', 'created', 'duration', 'status', 'course_video_image_url', + 'transcripts'], + [ + { + 'video_id': 'test1', + 'language_code': 'en', + 'file_name': 'edx101.srt', + 'file_format': 'srt', + 'provider': 'Cielo24' + } + ], + { + 'en': 'English' + } + ), + ( + True, + ['edx_video_id', 'client_video_id', 'created', 'duration', 'status', 'course_video_image_url', + 'transcripts'], + [ + { + 'video_id': 'test1', + 'language_code': 'en', + 'file_name': 'edx101_en.srt', + 'file_format': 'srt', + 'provider': 'Cielo24' + }, + { + 'video_id': 'test1', + 'language_code': 'es', + 'file_name': 'edx101_es.srt', + 'file_format': 'srt', + 'provider': 'Cielo24' + } + ], + { + 'en': 'English', + 'es': 'Spanish' + } + ) + ) + @ddt.unpack + @patch('openedx.core.djangoapps.video_config.models.VideoTranscriptEnabledFlag.feature_enabled') + def test_get_json_transcripts(self, is_video_transcript_enabled, expected_video_keys, uploaded_transcripts, + expected_transcripts, video_transcript_feature): + """ + Test that transcripts are attached based on whether the video transcript feature is enabled. + """ + video_transcript_feature.return_value = is_video_transcript_enabled + + for transcript in uploaded_transcripts: + create_or_update_video_transcript( + transcript['video_id'], + transcript['language_code'], + metadata={ + 'file_name': transcript['file_name'], + 'file_format': transcript['file_format'], + 'provider': transcript['provider'] + } + ) + + response = self.client.get_json(self.url) + self.assertEqual(response.status_code, 200) + response_videos = json.loads(response.content)['videos'] + self.assertEqual(len(response_videos), len(self.previous_uploads)) + + for response_video in response_videos: + self.assertEqual(set(response_video.keys()), set(expected_video_keys)) + if response_video['edx_video_id'] == self.previous_uploads[0]['edx_video_id']: + self.assertDictEqual(response_video.get('transcripts', {}), expected_transcripts) + def test_get_html(self): response = self.client.get(self.url) self.assertEqual(response.status_code, 200) diff --git a/cms/djangoapps/contentstore/views/transcript_settings.py b/cms/djangoapps/contentstore/views/transcript_settings.py index 86374d469d..e5457aee9b 100644 --- a/cms/djangoapps/contentstore/views/transcript_settings.py +++ b/cms/djangoapps/contentstore/views/transcript_settings.py @@ -1,12 +1,20 @@ """ Views related to the transcript preferences feature """ +import os +import json +import logging + from django.contrib.auth.decorators import login_required -from django.http import HttpResponseNotFound +from django.core.files.base import ContentFile +from django.http import HttpResponseNotFound, HttpResponse from django.utils.translation import ugettext as _ -from django.views.decorators.http import require_POST +from django.views.decorators.http import require_POST, require_GET from edxval.api import ( + create_or_update_video_transcript, + get_available_transcript_languages, get_3rd_party_transcription_plans, + get_video_transcript_data, update_transcript_credentials_state_for_org, ) from opaque_keys.edx.keys import CourseKey @@ -16,8 +24,11 @@ from openedx.core.djangoapps.video_pipeline.api import update_3rd_party_transcri from util.json_request import JsonResponse, expect_json from contentstore.views.videos import TranscriptProvider +from xmodule.video_module.transcripts_utils import Transcript, TranscriptsGenerationException -__all__ = ['transcript_credentials_handler'] +__all__ = ['transcript_credentials_handler', 'transcript_download_handler', 'transcript_upload_handler'] + +LOGGER = logging.getLogger(__name__) class TranscriptionProviderErrorType: @@ -108,3 +119,138 @@ def transcript_credentials_handler(request, course_key_string): response = JsonResponse({'error': error_message}, status=400) return response + + +@login_required +@require_GET +def transcript_download_handler(request, course_key_string): + """ + JSON view handler to download a transcript. + + Arguments: + request: WSGI request object + course_key_string: course key + + Returns: + - A 200 response with SRT transcript file attached. + - A 400 if there is a validation error. + - A 404 if there is no such transcript or feature flag is disabled. + """ + course_key = CourseKey.from_string(course_key_string) + if not VideoTranscriptEnabledFlag.feature_enabled(course_key): + return HttpResponseNotFound() + + missing = [attr for attr in ['edx_video_id', 'language_code'] if attr not in request.GET] + if missing: + return JsonResponse( + {'error': _(u'The following parameters are required: {missing}.').format(missing=', '.join(missing))}, + status=400 + ) + + edx_video_id = request.GET['edx_video_id'] + language_code = request.GET['language_code'] + transcript = get_video_transcript_data(video_ids=[edx_video_id], language_code=language_code) + if transcript: + name_and_extension = os.path.splitext(transcript['file_name']) + basename, file_format = name_and_extension[0], name_and_extension[1][1:] + transcript_filename = '{base_name}.{ext}'.format(base_name=basename.encode('utf8'), ext=Transcript.SRT) + transcript_content = Transcript.convert( + content=transcript['content'], + input_format=file_format, + output_format=Transcript.SRT + ) + # Construct an HTTP response + response = HttpResponse(transcript_content, content_type=Transcript.mime_types[Transcript.SRT]) + response['Content-Disposition'] = 'attachment; filename="{filename}"'.format(filename=transcript_filename) + else: + response = HttpResponseNotFound() + + return response + + +def validate_transcript_upload_data(data, files): + """ + Validates video transcript file. + Arguments: + data: A request's data part. + files: A request's files part. + Returns: + None or String + If there is error returns error message otherwise None. + """ + error = None + # Validate the must have attributes - this error is unlikely to be faced by common users. + must_have_attrs = ['edx_video_id', 'language_code', 'new_language_code'] + missing = [attr for attr in must_have_attrs if attr not in data] + if missing: + error = _(u'The following parameters are required: {missing}.').format(missing=', '.join(missing)) + elif ( + data['language_code'] != data['new_language_code'] and + data['new_language_code'] in get_available_transcript_languages([data['edx_video_id']]) + ): + error = _(u'A transcript with the "{language_code}" language code already exists.'.format( + language_code=data['new_language_code'] + )) + elif 'file' not in files: + error = _(u'A transcript file is required.') + + return error + + +@login_required +@require_POST +def transcript_upload_handler(request, course_key_string): + """ + View to upload a transcript file. + + Arguments: + request: A WSGI request object + course_key_string: Course key identifying a course + + Transcript file, edx video id and transcript language are required. + Transcript file should be in SRT(SubRip) format. + + Returns + - A 400 if any of the validation fails + - A 404 if the corresponding feature flag is disabled + - A 200 if transcript has been uploaded successfully + """ + # Check whether the feature is available for this course. + course_key = CourseKey.from_string(course_key_string) + if not VideoTranscriptEnabledFlag.feature_enabled(course_key): + return HttpResponseNotFound() + + error = validate_transcript_upload_data(data=request.POST, files=request.FILES) + if error: + response = JsonResponse({'error': error}, status=400) + else: + edx_video_id = request.POST['edx_video_id'] + language_code = request.POST['language_code'] + new_language_code = request.POST['new_language_code'] + transcript_file = request.FILES['file'] + try: + # Convert SRT transcript into an SJSON format + # and upload it to S3. + sjson_subs = Transcript.convert( + content=transcript_file.read(), + input_format=Transcript.SRT, + output_format=Transcript.SJSON + ) + create_or_update_video_transcript( + video_id=edx_video_id, + language_code=language_code, + metadata={ + 'provider': TranscriptProvider.CUSTOM, + 'file_format': Transcript.SJSON, + 'language_code': new_language_code + }, + file_data=ContentFile(json.dumps(sjson_subs)), + ) + response = JsonResponse(status=201) + except (TranscriptsGenerationException, UnicodeDecodeError): + response = JsonResponse( + {'error': _(u'There is a problem with this transcript file. Try to upload a different file.')}, + status=400 + ) + + return response diff --git a/cms/djangoapps/contentstore/views/videos.py b/cms/djangoapps/contentstore/views/videos.py index 32070ea09d..8458f61ce2 100644 --- a/cms/djangoapps/contentstore/views/videos.py +++ b/cms/djangoapps/contentstore/views/videos.py @@ -30,9 +30,11 @@ from edxval.api import ( remove_transcript_preferences, remove_video_for_course, update_video_image, - update_video_status + update_video_status, + get_available_transcript_languages ) from opaque_keys.edx.keys import CourseKey +from xmodule.video_module.transcripts_utils import Transcript from contentstore.models import VideoUploadConfig from contentstore.utils import reverse_course_url @@ -75,10 +77,11 @@ MAX_UPLOAD_HOURS = 24 class TranscriptProvider(object): """ - 3rd Party Transcription Provider Enumeration + Transcription Provider Enumeration """ CIELO24 = 'Cielo24' THREE_PLAY_MEDIA = '3PlayMedia' + CUSTOM = 'Custom' class StatusDisplayStrings(object): @@ -528,12 +531,20 @@ def _get_videos(course): """ Retrieves the list of videos from VAL corresponding to this course. """ + is_video_transcript_enabled = VideoTranscriptEnabledFlag.feature_enabled(course.id) videos = list(get_videos_for_course(unicode(course.id), VideoSortField.created, SortDirection.desc)) # convert VAL's status to studio's Video Upload feature status. for video in videos: video["status"] = convert_video_status(video) + if is_video_transcript_enabled: + all_languages = get_all_transcript_languages() + video['transcripts'] = { + lang_code: all_languages[lang_code] + for lang_code in get_available_transcript_languages([video['edx_video_id']]) + } + return videos @@ -551,6 +562,9 @@ def _get_index_videos(course): course_id = unicode(course.id) attrs = ['edx_video_id', 'client_video_id', 'created', 'duration', 'status', 'courses'] + if VideoTranscriptEnabledFlag.feature_enabled(course.id): + attrs += ['transcripts'] + def _get_values(video): """ Get data for predefined video attributes. @@ -570,6 +584,24 @@ def _get_index_videos(course): ] +def get_all_transcript_languages(): + """ + Returns all possible languages for transcript. + """ + third_party_transcription_languages = {} + transcription_plans = get_3rd_party_transcription_plans() + cielo_fidelity = transcription_plans[TranscriptProvider.CIELO24]['fidelity'] + + # Get third party transcription languages. + third_party_transcription_languages.update(transcription_plans[TranscriptProvider.THREE_PLAY_MEDIA]['languages']) + third_party_transcription_languages.update(cielo_fidelity['MECHANICAL']['languages']) + third_party_transcription_languages.update(cielo_fidelity['PREMIUM']['languages']) + third_party_transcription_languages.update(cielo_fidelity['PROFESSIONAL']['languages']) + + # Return combined system settings and 3rd party transcript languages. + return dict(settings.ALL_LANGUAGES, **third_party_transcription_languages) + + def videos_index_html(course): """ Returns an HTML page to display previous video uploads and allow new ones @@ -596,7 +628,8 @@ def videos_index_html(course): 'is_video_transcript_enabled': is_video_transcript_enabled, 'video_transcript_settings': None, 'active_transcript_preferences': None, - 'transcript_credentials': None + 'transcript_credentials': None, + 'transcript_available_languages': None } if is_video_transcript_enabled: @@ -609,11 +642,21 @@ def videos_index_html(course): 'transcript_credentials_handler', unicode(course.id) ), + 'transcript_download_handler_url': reverse_course_url( + 'transcript_download_handler', + unicode(course.id) + ), + 'transcript_upload_handler_url': reverse_course_url( + 'transcript_upload_handler', + unicode(course.id) + ), 'transcription_plans': get_3rd_party_transcription_plans(), + 'trancript_download_file_format': Transcript.SRT } context['active_transcript_preferences'] = get_transcript_preferences(unicode(course.id)) # Cached state for transcript providers' credentials (org-specific) context['transcript_credentials'] = get_transcript_credentials_state_for_org(course.id.org) + context['transcript_available_languages'] = get_all_transcript_languages() return render_to_response('videos_index.html', context) diff --git a/cms/static/cms/js/spec/main.js b/cms/static/cms/js/spec/main.js index e7b09c1ce0..af109088aa 100644 --- a/cms/static/cms/js/spec/main.js +++ b/cms/static/cms/js/spec/main.js @@ -259,6 +259,7 @@ 'js/spec/views/previous_video_upload_spec', 'js/spec/views/video_thumbnail_spec', 'js/spec/views/course_video_settings_spec', + 'js/spec/views/video_transcripts_spec', 'js/spec/views/previous_video_upload_list_spec', 'js/spec/views/assets_spec', 'js/spec/views/baseview_spec', diff --git a/cms/static/js/factories/videos_index.js b/cms/static/js/factories/videos_index.js index 1ad46cada1..2852b74e89 100644 --- a/cms/static/js/factories/videos_index.js +++ b/cms/static/js/factories/videos_index.js @@ -18,7 +18,8 @@ define([ transcriptOrganizationCredentials, videoTranscriptSettings, isVideoTranscriptEnabled, - videoImageSettings + videoImageSettings, + transcriptAvailableLanguages ) { var activeView = new ActiveVideoUploadListView({ postUrl: videoHandlerUrl, @@ -51,7 +52,11 @@ define([ videoHandlerUrl: videoHandlerUrl, collection: updatedCollection, encodingsDownloadUrl: encodingsDownloadUrl, - videoImageSettings: videoImageSettings + videoImageSettings: videoImageSettings, + videoTranscriptSettings: videoTranscriptSettings, + transcriptAvailableLanguages: transcriptAvailableLanguages, + videoSupportedFileFormats: videoSupportedFileFormats, + isVideoTranscriptEnabled: isVideoTranscriptEnabled }); $contentWrapper.find('.wrapper-assets').replaceWith(updatedView.render().$el); }); @@ -63,7 +68,11 @@ define([ videoHandlerUrl: videoHandlerUrl, collection: new Backbone.Collection(previousUploads), encodingsDownloadUrl: encodingsDownloadUrl, - videoImageSettings: videoImageSettings + videoImageSettings: videoImageSettings, + videoTranscriptSettings: videoTranscriptSettings, + transcriptAvailableLanguages: transcriptAvailableLanguages, + videoSupportedFileFormats: videoSupportedFileFormats, + isVideoTranscriptEnabled: isVideoTranscriptEnabled }); $contentWrapper.append(activeView.render().$el); $contentWrapper.append(previousView.render().$el); diff --git a/cms/static/js/spec/views/video_transcripts_spec.js b/cms/static/js/spec/views/video_transcripts_spec.js new file mode 100644 index 0000000000..bd5766ac73 --- /dev/null +++ b/cms/static/js/spec/views/video_transcripts_spec.js @@ -0,0 +1,351 @@ +define( + ['jquery', 'underscore', 'backbone', 'js/views/video_transcripts', 'js/views/previous_video_upload_list', + 'edx-ui-toolkit/js/utils/spec-helpers/ajax-helpers', 'common/js/spec_helpers/template_helpers'], + function($, _, Backbone, VideoTranscriptsView, PreviousVideoUploadListView, AjaxHelpers, TemplateHelpers) { + 'use strict'; + describe('VideoTranscriptsView', function() { + var videoTranscriptsView, + renderView, + verifyTranscriptStateInfo, + verifyMessage, + verifyDetailedErrorMessage, + createFakeTranscriptFile, + transcripts = { + en: 'English', + es: 'Spanish', + ur: 'Urdu' + }, + edxVideoID = 'test-edx-video-id', + clientVideoID = 'Video client title name.mp4', + transcriptAvailableLanguages = { + en: 'English', + es: 'Spanish', + cn: 'Chinese', + ar: 'Arabic', + ur: 'Urdu' + }, + TRANSCRIPT_DOWNLOAD_FILE_FORMAT = 'srt', + TRANSCRIPT_DOWNLOAD_URL = 'abc.com/transcript_download/course_id', + TRANSCRIPT_UPLOAD_URL = 'abc.com/transcript_upload/course_id', + videoSupportedFileFormats = ['.mov', '.mp4'], + videoTranscriptSettings = { + trancript_download_file_format: TRANSCRIPT_DOWNLOAD_FILE_FORMAT, + transcript_download_handler_url: TRANSCRIPT_DOWNLOAD_URL, + transcript_upload_handler_url: TRANSCRIPT_UPLOAD_URL + }, + videoListView; + + verifyTranscriptStateInfo = function($transcriptEl, transcriptLanguage) { + var $transcriptActionsEl = $transcriptEl.find('.transcript-actions'), + downloadTranscriptActionEl = $transcriptActionsEl.find('.download-transcript-button'), + uploadTranscriptActionEl = $transcriptActionsEl.find('.upload-transcript-button'); + + // Verify transcript data attributes + expect($transcriptEl.data('edx-video-id')).toEqual(edxVideoID); + expect($transcriptEl.data('language-code')).toEqual(transcriptLanguage); + + // Verify transcript language dropdown has correct value set. + expect($transcriptEl.find('.transcript-language-menu').val(), transcriptLanguage); + + // Verify transcript actions + expect(downloadTranscriptActionEl.html().trim(), 'Download'); + expect( + downloadTranscriptActionEl.attr('href'), + TRANSCRIPT_DOWNLOAD_URL + '?edx_video_id=' + edxVideoID + '&language_code=' + transcriptLanguage + ); + + expect(uploadTranscriptActionEl.html().trim(), 'Replace'); + }; + + verifyMessage = function($transcriptEl, status) { + var $transcriptStatusEl = $transcriptEl.find('.transcript-upload-status-container'), + statusData = videoTranscriptsView.transcriptUploadStatuses[status]; + + expect($transcriptStatusEl.hasClass(statusData.statusClass)).toEqual(true); + expect($transcriptStatusEl.find('span.fa').hasClass(statusData.iconClasses)).toEqual(true); + expect( + $transcriptStatusEl.find('.more-details-action').hasClass('hidden') + ).toEqual(statusData.hiddenClass === 'hidden'); + expect( + $transcriptStatusEl.find('.transcript-detail-status').html().trim() + ).toEqual(statusData.shortMessage); + }; + + verifyDetailedErrorMessage = function($transcriptEl, expectedTitle, expectedMessage) { + $transcriptEl.find('.more-details-action').click(); + expect($('#prompt-warning-title').text().trim()).toEqual(expectedTitle); + expect($('#prompt-warning-description').text().trim()).toEqual(expectedMessage); + }; + + createFakeTranscriptFile = function(transcriptFileName) { + var transcriptFileName = transcriptFileName || 'test-transcript.srt', // eslint-disable-line no-redeclare, max-len + size = 100, + type = ''; + return new File([new Blob([Array(size).join('i')], {type: type})], transcriptFileName); + }; + + renderView = function(availableTranscripts, isVideoTranscriptEnabled) { + var videoViewIndex = 0, + isVideoTranscriptEnabled = isVideoTranscriptEnabled || _.isUndefined(isVideoTranscriptEnabled), // eslint-disable-line max-len, no-redeclare + videoData = { + client_video_id: clientVideoID, + edx_video_id: edxVideoID, + created: '2014-11-25T23:13:05', + transcripts: availableTranscripts + }, + videoCollection = new Backbone.Collection([new Backbone.Model(videoData)]); + + videoListView = new PreviousVideoUploadListView({ + collection: videoCollection, + videoImageSettings: {}, + videoTranscriptSettings: videoTranscriptSettings, + transcriptAvailableLanguages: transcriptAvailableLanguages, + videoSupportedFileFormats: videoSupportedFileFormats, + isVideoTranscriptEnabled: isVideoTranscriptEnabled + }); + videoListView.setElement($('.wrapper-assets')); + videoListView.render(); + + videoTranscriptsView = videoListView.itemViews[videoViewIndex].videoTranscriptsView; + }; + + beforeEach(function() { + setFixtures( + '
' + + '
' + ); + TemplateHelpers.installTemplate('previous-video-upload-list'); + renderView(transcripts); + }); + + it('renders as expected', function() { + // Verify transcript container is present. + expect(videoListView.$el.find('.show-video-transcripts-container')).toExist(); + // Veirfy transcript column header is present. + expect(videoListView.$el.find('.js-table-head .video-head-col.transcripts-col')).toExist(); + // Verify transcript data column is present. + expect(videoListView.$el.find('.js-table-body .transcripts-col')).toExist(); + // Verify view has initiallized. + expect(_.isUndefined(videoTranscriptsView)).toEqual(false); + }); + + it('does not render transcripts view if feature is disabled', function() { + renderView(transcripts, false); + // Verify transcript container is not present. + expect(videoListView.$el.find('.show-video-transcripts-container')).not.toExist(); + // Veirfy transcript column header is not present. + expect(videoListView.$el.find('.js-table-head .video-head-col.transcripts-col')).not.toExist(); + // Verify transcript data column is not present. + expect(videoListView.$el.find('.js-table-body .transcripts-col')).not.toExist(); + // Verify view has not initiallized. + expect(_.isUndefined(videoTranscriptsView)).toEqual(true); + }); + + it('does not show list of transcripts initially', function() { + expect( + videoTranscriptsView.$el.find('.show-video-transcripts-wrapper').hasClass('hidden') + ).toEqual(true); + expect(videoTranscriptsView.$el.find('.toggle-show-transcripts-button-text').html().trim()).toEqual( + 'Show transcripts (' + _.size(transcripts) + ')' + ); + }); + + it('shows list of transcripts when clicked on show transcript button', function() { + // Verify transcript container is hidden + expect( + videoTranscriptsView.$el.find('.show-video-transcripts-wrapper').hasClass('hidden') + ).toEqual(true); + + // Verify initial button text + expect(videoTranscriptsView.$el.find('.toggle-show-transcripts-button-text').html().trim()).toEqual( + 'Show transcripts (' + _.size(transcripts) + ')' + ); + videoTranscriptsView.$el.find('.toggle-show-transcripts-button').click(); + + // Verify transcript container is not hidden + expect( + videoTranscriptsView.$el.find('.show-video-transcripts-wrapper').hasClass('hidden') + ).toEqual(false); + + // Verify button text is changed. + expect(videoTranscriptsView.$el.find('.toggle-show-transcripts-button-text').html().trim()).toEqual( + 'Hide transcripts (' + _.size(transcripts) + ')' + ); + }); + + it('hides list of transcripts when clicked on hide transcripts button', function() { + // Click to show transcripts first. + videoTranscriptsView.$el.find('.toggle-show-transcripts-button').click(); + + // Verify button text. + expect(videoTranscriptsView.$el.find('.toggle-show-transcripts-button-text').html().trim()).toEqual( + 'Hide transcripts (' + _.size(transcripts) + ')' + ); + + // Verify transcript container is not hidden + expect( + videoTranscriptsView.$el.find('.show-video-transcripts-wrapper').hasClass('hidden') + ).toEqual(false); + + videoTranscriptsView.$el.find('.toggle-show-transcripts-button').click(); + + // Verify button text is changed. + expect(videoTranscriptsView.$el.find('.toggle-show-transcripts-button-text').html().trim()).toEqual( + 'Show transcripts (' + _.size(transcripts) + ')' + ); + + // Verify transcript container is hidden + expect( + videoTranscriptsView.$el.find('.show-video-transcripts-wrapper').hasClass('hidden') + ).toEqual(true); + }); + + it('renders appropriate text when no transcript is available', function() { + // Render view with no transcripts + renderView({}); + + // Verify appropriate text is shown + expect( + videoTranscriptsView.$el.find('.transcripts-empty-text').html() + ).toEqual('No transcript uploaded.'); + }); + + it('renders correct transcript attributes', function() { + var $transcriptEl; + // Show transcripts + videoTranscriptsView.$el.find('.toggle-show-transcripts-button').click(); + expect(videoTranscriptsView.$el.find('.show-video-transcript-content').length).toEqual( + _.size(transcripts) + ); + + _.each(transcripts, function(langaugeText, languageCode) { + $transcriptEl = videoTranscriptsView.$el.find('.show-video-transcript-content[data-language-code="' + languageCode + '"]'); // eslint-disable-line max-len + // Verify correct transcript title is set. + expect($transcriptEl.find('.transcript-title').html()).toEqual( + 'Video client title n_' + languageCode + '.' + TRANSCRIPT_DOWNLOAD_FILE_FORMAT + ); + // Verify transcript is rendered with correct info. + verifyTranscriptStateInfo($transcriptEl, languageCode); + }); + }); + + it('can upload transcript', function() { + var languageCode = 'en', + newLanguageCode = 'ar', + requests = AjaxHelpers.requests(this), + $transcriptEl = videoTranscriptsView.$el.find('.show-video-transcript-content[data-language-code="' + languageCode + '"]'); // eslint-disable-line max-len + + // Verify correct transcript title is set. + expect($transcriptEl.find('.transcript-title').html()).toEqual( + 'Video client title n_' + languageCode + '.' + TRANSCRIPT_DOWNLOAD_FILE_FORMAT + ); + + // Select a language + $transcriptEl.find('.transcript-language-menu').val(newLanguageCode); + + $transcriptEl.find('.upload-transcript-button').click(); + + // Add transcript to upload queue and send POST request to upload transcript. + $transcriptEl.find('.upload-transcript-input').fileupload('add', {files: [createFakeTranscriptFile()]}); + + // Verify if POST request received for image upload + AjaxHelpers.expectRequest( + requests, + 'POST', + TRANSCRIPT_UPLOAD_URL + ); + + // Send successful upload response + AjaxHelpers.respondWithJson(requests, {}); + + // Verify correct transcript title is set. + expect($transcriptEl.find('.transcript-title').html()).toEqual( + 'Video client title n_' + newLanguageCode + '.' + TRANSCRIPT_DOWNLOAD_FILE_FORMAT + ); + + verifyMessage($transcriptEl, 'uploaded'); + + // Verify transcript is rendered with correct info. + verifyTranscriptStateInfo($transcriptEl, newLanguageCode); + }); + + it('shows error state correctly', function() { + var languageCode = 'en', + requests = AjaxHelpers.requests(this), + errorMessage = 'Transcript failed error message', + $transcriptEl = videoTranscriptsView.$el.find('.show-video-transcript-content[data-language-code="' + languageCode + '"]'); // eslint-disable-line max-len + + $transcriptEl.find('.upload-transcript-button').click(); + + // Add transcript to upload queue and send POST request to upload transcript. + $transcriptEl.find('.upload-transcript-input').fileupload('add', {files: [createFakeTranscriptFile()]}); + + // Server response with bad request. + AjaxHelpers.respondWithError(requests, 400, {error: errorMessage}); + + verifyMessage($transcriptEl, 'failed'); + + // verify detailed error message + verifyDetailedErrorMessage( + $transcriptEl, + videoTranscriptsView.defaultFailureTitle, + errorMessage + ); + + // Verify transcript is rendered with correct info. + verifyTranscriptStateInfo($transcriptEl, languageCode); + }); + + it('should show error message in case of server error', function() { + var languageCode = 'en', + requests = AjaxHelpers.requests(this), + $transcriptEl = videoTranscriptsView.$el.find('.show-video-transcript-content[data-language-code="' + languageCode + '"]'); // eslint-disable-line max-len + + $transcriptEl.find('.upload-transcript-button').click(); + + // Add transcript to upload queue and send POST request to upload transcript. + $transcriptEl.find('.upload-transcript-input').fileupload('add', {files: [createFakeTranscriptFile()]}); + + AjaxHelpers.respondWithError(requests, 500); + + verifyMessage($transcriptEl, 'failed'); + + // verify detailed error message + verifyDetailedErrorMessage( + $transcriptEl, + videoTranscriptsView.defaultFailureTitle, + videoTranscriptsView.defaultFailureMessage + ); + + // Verify transcript is rendered with correct info. + verifyTranscriptStateInfo($transcriptEl, languageCode); + }); + + it('should show error message in case of unsupported transcript file format', function() { + var languageCode = 'en', + transcriptFileName = 'unsupported-transcript-file-format.txt', + errorMessage = 'This file type is not supported. Supported file type is ' + TRANSCRIPT_DOWNLOAD_FILE_FORMAT + '.', // eslint-disable-line max-len + $transcriptEl = videoTranscriptsView.$el.find('.show-video-transcript-content[data-language-code="' + languageCode + '"]'); // eslint-disable-line max-len + + $transcriptEl.find('.upload-transcript-button').click(); + + // Add transcript to upload queue and send POST request to upload transcript. + $transcriptEl.find('.upload-transcript-input').fileupload('add', { + files: [createFakeTranscriptFile(transcriptFileName)] + }); + + verifyMessage($transcriptEl, 'validationFailed'); + + // verify detailed error message + verifyDetailedErrorMessage( + $transcriptEl, + videoTranscriptsView.defaultFailureTitle, + errorMessage + ); + + // Verify transcript is rendered with correct info. + verifyTranscriptStateInfo($transcriptEl, languageCode); + }); + }); + } +); diff --git a/cms/static/js/views/previous_video_upload.js b/cms/static/js/views/previous_video_upload.js index 0e7c288f84..714e4e54ac 100644 --- a/cms/static/js/views/previous_video_upload.js +++ b/cms/static/js/views/previous_video_upload.js @@ -1,10 +1,10 @@ define( ['underscore', 'gettext', 'js/utils/date_utils', 'js/views/baseview', 'common/js/components/views/feedback_prompt', - 'common/js/components/views/feedback_notification', 'js/views/video_thumbnail', + 'common/js/components/views/feedback_notification', 'js/views/video_thumbnail', 'js/views/video_transcripts', 'common/js/components/utils/view_utils', 'edx-ui-toolkit/js/utils/html-utils', 'text!templates/previous-video-upload.underscore'], - function(_, gettext, DateUtils, BaseView, PromptView, NotificationView, VideoThumbnailView, ViewUtils, HtmlUtils, - previousVideoUploadTemplate) { + function(_, gettext, DateUtils, BaseView, PromptView, NotificationView, VideoThumbnailView, VideoTranscriptsView, + ViewUtils, HtmlUtils, previousVideoUploadTemplate) { 'use strict'; var PreviousVideoUploadView = BaseView.extend({ @@ -20,6 +20,7 @@ define( this.template = HtmlUtils.template(previousVideoUploadTemplate); this.videoHandlerUrl = options.videoHandlerUrl; this.videoImageUploadEnabled = options.videoImageSettings.video_image_upload_enabled; + this.isVideoTranscriptEnabled = options.isVideoTranscriptEnabled; if (this.videoImageUploadEnabled) { this.videoThumbnailView = new VideoThumbnailView({ @@ -29,11 +30,22 @@ define( videoImageSettings: options.videoImageSettings }); } + if (this.isVideoTranscriptEnabled) { + this.videoTranscriptsView = new VideoTranscriptsView({ + transcripts: this.model.get('transcripts'), + edxVideoID: this.model.get('edx_video_id'), + clientVideoID: this.model.get('client_video_id'), + transcriptAvailableLanguages: options.transcriptAvailableLanguages, + videoSupportedFileFormats: options.videoSupportedFileFormats, + videoTranscriptSettings: options.videoTranscriptSettings + }); + } }, render: function() { var renderedAttributes = { videoImageUploadEnabled: this.videoImageUploadEnabled, + isVideoTranscriptEnabled: this.isVideoTranscriptEnabled, created: DateUtils.renderDate(this.model.get('created')), status: this.model.get('status') }; @@ -47,6 +59,9 @@ define( if (this.videoImageUploadEnabled) { this.videoThumbnailView.setElement(this.$('.thumbnail-col')).render(); } + if (this.isVideoTranscriptEnabled) { + this.videoTranscriptsView.setElement(this.$('.transcripts-col')).render(); + } return this; }, diff --git a/cms/static/js/views/previous_video_upload_list.js b/cms/static/js/views/previous_video_upload_list.js index c09b3a0dde..c845a6e99b 100644 --- a/cms/static/js/views/previous_video_upload_list.js +++ b/cms/static/js/views/previous_video_upload_list.js @@ -1,22 +1,28 @@ define( - ['jquery', 'underscore', 'backbone', 'js/views/baseview', 'js/views/previous_video_upload'], - function($, _, Backbone, BaseView, PreviousVideoUploadView) { + ['jquery', 'underscore', 'backbone', 'js/views/baseview', 'edx-ui-toolkit/js/utils/html-utils', + 'js/views/previous_video_upload', 'text!templates/previous-video-upload-list.underscore'], + function($, _, Backbone, BaseView, HtmlUtils, PreviousVideoUploadView, previousVideoUploadListTemplate) { 'use strict'; var PreviousVideoUploadListView = BaseView.extend({ tagName: 'section', className: 'wrapper-assets', initialize: function(options) { - this.template = this.loadTemplate('previous-video-upload-list'); + this.template = HtmlUtils.template(previousVideoUploadListTemplate); this.encodingsDownloadUrl = options.encodingsDownloadUrl; this.videoImageUploadEnabled = options.videoImageSettings.video_image_upload_enabled; + this.isVideoTranscriptEnabled = options.isVideoTranscriptEnabled; this.itemViews = this.collection.map(function(model) { return new PreviousVideoUploadView({ videoImageUploadURL: options.videoImageUploadURL, defaultVideoImageURL: options.defaultVideoImageURL, videoHandlerUrl: options.videoHandlerUrl, videoImageSettings: options.videoImageSettings, - model: model + videoTranscriptSettings: options.videoTranscriptSettings, + model: model, + transcriptAvailableLanguages: options.transcriptAvailableLanguages, + videoSupportedFileFormats: options.videoSupportedFileFormats, + isVideoTranscriptEnabled: options.isVideoTranscriptEnabled }); }); }, @@ -24,10 +30,16 @@ define( render: function() { var $el = this.$el, $tabBody; - $el.html(this.template({ - encodingsDownloadUrl: this.encodingsDownloadUrl, - videoImageUploadEnabled: this.videoImageUploadEnabled - })); + + HtmlUtils.setHtml( + this.$el, + this.template({ + encodingsDownloadUrl: this.encodingsDownloadUrl, + videoImageUploadEnabled: this.videoImageUploadEnabled, + isVideoTranscriptEnabled: this.isVideoTranscriptEnabled + }) + ); + $tabBody = $el.find('.js-table-body'); _.each(this.itemViews, function(view) { $tabBody.append(view.render().$el); diff --git a/cms/static/js/views/video_transcripts.js b/cms/static/js/views/video_transcripts.js new file mode 100644 index 0000000000..9b155f5f21 --- /dev/null +++ b/cms/static/js/views/video_transcripts.js @@ -0,0 +1,292 @@ +define( + ['underscore', 'gettext', 'js/views/baseview', 'common/js/components/views/feedback_prompt', + 'edx-ui-toolkit/js/utils/html-utils', 'edx-ui-toolkit/js/utils/string-utils', + 'text!templates/video-transcripts.underscore', 'text!templates/video-transcript-upload-status.underscore'], + function(_, gettext, BaseView, PromptView, HtmlUtils, StringUtils, videoTranscriptsTemplate, + videoTranscriptUploadStatusTemplate) { + 'use strict'; + + var VideoTranscriptsView = BaseView.extend({ + tagName: 'div', + + events: { + 'click .toggle-show-transcripts-button': 'toggleShowTranscripts', + 'click .upload-transcript-button': 'chooseFile', + 'click .more-details-action': 'showUploadFailureMessage' + }, + + initialize: function(options) { + this.transcripts = options.transcripts; + this.edxVideoID = options.edxVideoID; + this.clientVideoID = options.clientVideoID; + this.transcriptAvailableLanguages = options.transcriptAvailableLanguages; + this.videoSupportedFileFormats = options.videoSupportedFileFormats; + this.videoTranscriptSettings = options.videoTranscriptSettings; + this.template = HtmlUtils.template(videoTranscriptsTemplate); + this.transcriptUploadStatusTemplate = HtmlUtils.template(videoTranscriptUploadStatusTemplate); + this.defaultFailureTitle = gettext('The file could not be uploaded.'); + this.defaultFailureMessage = gettext('This may be happening because of an error with our server or your internet connection. Try refreshing the page or making sure you are online.'); // eslint-disable-line max-len + this.transcriptUploadStatuses = { + uploaded: { + statusClass: 'success', + iconClasses: 'fa-check', + shortMessage: 'Transcript uploaded.', + hiddenClass: 'hidden' + }, + uploading: { + statusClass: '', + iconClasses: 'fa-spinner fa-pulse', + shortMessage: 'Uploading transcript.', + hiddenClass: 'hidden' + }, + failed: { + statusClass: 'error', + iconClasses: 'fa-warning', + shortMessage: 'Upload failed.', + hiddenClass: '' + }, + validationFailed: { + statusClass: 'error', + iconClasses: 'fa-warning', + shortMessage: 'Validation failed.', + hiddenClass: '' + } + }; + // This is needed to attach transcript methods to this object while uploading. + _.bindAll( + this, 'render', 'chooseFile', 'transcriptSelected', 'transcriptUploadSucceeded', + 'transcriptUploadFailed' + ); + }, + + /* + Sorts object by value and returns a sorted array. + */ + sortByValue: function(itemObject) { + var sortedArray = []; + _.each(itemObject, function(value, key) { + // Push each JSON Object entry in array by [value, key] + sortedArray.push([value, key]); + }); + return sortedArray.sort(); + }, + + /* + Returns transcript title. + */ + getTranscriptClientTitle: function() { + var clientTitle = this.clientVideoID; + // Remove video file extension for transcript title. + _.each(this.videoSupportedFileFormats, function(videoFormat) { + clientTitle = clientTitle.replace(videoFormat, ''); + }); + return clientTitle.substring(0, 20); + }, + + /* + Returns transcript download link. + */ + getTranscriptDownloadLink: function(edxVideoID, transcriptLanguageCode, transcriptDownloadHandlerUrl) { + return StringUtils.interpolate( + '{transcriptDownloadHandlerUrl}?edx_video_id={edxVideoID}&language_code={transcriptLanguageCode}', + { + transcriptDownloadHandlerUrl: transcriptDownloadHandlerUrl, + edxVideoID: edxVideoID, + transcriptLanguageCode: transcriptLanguageCode + } + ); + }, + + /* + Toggles Show/Hide transcript button and transcripts container. + */ + toggleShowTranscripts: function() { + var $transcriptsWrapperEl = this.$el.find('.show-video-transcripts-wrapper'); + + // Toggle show transcript wrapper. + $transcriptsWrapperEl.toggleClass('hidden'); + + // Toggle button text. + HtmlUtils.setHtml( + this.$el.find('.toggle-show-transcripts-button-text'), + StringUtils.interpolate( + gettext('{toggleShowTranscriptText} transcripts ({totalTranscripts})'), + { + toggleShowTranscriptText: $transcriptsWrapperEl.hasClass('hidden') ? gettext('Show') : gettext('Hide'), // eslint-disable-line max-len + totalTranscripts: _.size(this.transcripts) + } + ) + ); + + // Toggle icon class. + if ($transcriptsWrapperEl.hasClass('hidden')) { + this.$el.find('.toggle-show-transcripts-icon').removeClass('fa-caret-down').addClass('fa-caret-right'); // eslint-disable-line max-len + } else { + this.$el.find('.toggle-show-transcripts-icon').removeClass('fa-caret-right').addClass('fa-caret-down'); // eslint-disable-line max-len + } + }, + + validateTranscriptUpload: function(file) { + var errorMessage = '', + fileName = file.name, + fileType = fileName.substr(fileName.lastIndexOf('.') + 1); + + if (fileType !== this.videoTranscriptSettings.trancript_download_file_format) { + errorMessage = gettext( + 'This file type is not supported. Supported file type is {supportedFileFormat}.' + ) + .replace('{supportedFileFormat}', this.videoTranscriptSettings.trancript_download_file_format); + } + + return errorMessage; + }, + + chooseFile: function(event) { + var $transcriptContainer = $(event.target).parents('.show-video-transcript-content'), + $transcriptUploadEl = $transcriptContainer.find('.upload-transcript-input'); + + $transcriptUploadEl.fileupload({ + url: this.videoTranscriptSettings.transcript_upload_handler_url, + add: this.transcriptSelected, + done: this.transcriptUploadSucceeded, + fail: this.transcriptUploadFailed, + formData: { + edx_video_id: this.edxVideoID, + language_code: $transcriptContainer.attr('data-language-code'), + new_language_code: $transcriptContainer.find('.transcript-language-menu').val() + } + }); + + $transcriptUploadEl.click(); + }, + + transcriptSelected: function(event, data) { + var errorMessage, + $transcriptContainer = $(event.target).parents('.show-video-transcript-content'); + + errorMessage = this.validateTranscriptUpload(data.files[0]); + if (!errorMessage) { + // Do not trigger global AJAX error handler + data.global = false; // eslint-disable-line no-param-reassign + data.submit(); + this.renderMessage($transcriptContainer, 'uploading'); + } else { + // Reset transcript language back to original. + $transcriptContainer.find('.transcript-language-menu').val($transcriptContainer.attr('data-language-code')); // eslint-disable-line max-len + this.renderMessage($transcriptContainer, 'validationFailed', errorMessage); + } + }, + + transcriptUploadSucceeded: function(event, data) { + var languageCode = data.formData.language_code, + newLanguageCode = data.formData.new_language_code, + $transcriptContainer = this.$el.find('.show-video-transcript-content[data-language-code="' + languageCode + '"]'); // eslint-disable-line max-len + + $transcriptContainer.attr('data-language-code', newLanguageCode); + $transcriptContainer.find('.download-transcript-button').attr( + 'href', + this.getTranscriptDownloadLink( + this.edxVideoID, + newLanguageCode, + this.videoTranscriptSettings.transcript_download_handler_url + ) + ); + + HtmlUtils.setHtml( + $transcriptContainer.find('.transcript-title'), + StringUtils.interpolate(gettext('{transcriptClientTitle}_{transcriptLanguageCode}.{fileExtension}'), + { + transcriptClientTitle: this.getTranscriptClientTitle(), + transcriptLanguageCode: newLanguageCode, + fileExtension: this.videoTranscriptSettings.trancript_download_file_format + } + ) + ); + + this.renderMessage($transcriptContainer, 'uploaded'); + }, + + transcriptUploadFailed: function(event, data) { + var errorMessage, + languageCode = data.formData.language_code, + $transcriptContainer = this.$el.find('.show-video-transcript-content[data-language-code="' + languageCode + '"]'); // eslint-disable-line max-len + + try { + errorMessage = JSON.parse(data.jqXHR.responseText).error; + errorMessage = errorMessage || this.defaultFailureMessage; + } catch (error) { + errorMessage = this.defaultFailureMessage; + } + // Reset transcript language back to original. + $transcriptContainer.find('.transcript-language-menu').val(languageCode); + + this.renderMessage($transcriptContainer, 'failed', errorMessage); + }, + + clearMessage: function() { + var $transcriptStatusesEl = this.$el.find('.transcript-upload-status-container'); + // Clear all message containers + HtmlUtils.setHtml($transcriptStatusesEl, ''); + $transcriptStatusesEl.removeClass('success error'); + }, + + renderMessage: function($transcriptContainer, status, errorMessage) { + var statusData = this.transcriptUploadStatuses[status], + $transcriptStatusEl = $transcriptContainer.find('.transcript-upload-status-container'); + + // If a messge is already present above the video transcript element, remove it. + this.clearMessage(); + + HtmlUtils.setHtml( + $transcriptStatusEl, + this.transcriptUploadStatusTemplate({ + status: statusData.statusClass, + iconClasses: statusData.iconClasses, + shortMessage: gettext(statusData.shortMessage), + errorMessage: errorMessage || '', + hiddenClass: statusData.hiddenClass + }) + ); + + $transcriptStatusEl.addClass(statusData.statusClass); + }, + + showUploadFailureMessage: function(event) { + var errorMessage = $(event.target).data('error-message'); + return new PromptView.Warning({ + title: this.defaultFailureTitle, + message: errorMessage, + actions: { + primary: { + text: gettext('Close'), + click: function(prompt) { + return prompt.hide(); + } + } + } + }).show(); + }, + + /* + Renders transcripts view. + */ + render: function() { + HtmlUtils.setHtml( + this.$el, + this.template({ + transcripts: this.transcripts, + transcriptAvailableLanguages: this.sortByValue(this.transcriptAvailableLanguages), + edxVideoID: this.edxVideoID, + transcriptClientTitle: this.getTranscriptClientTitle(), + transcriptFileFormat: this.videoTranscriptSettings.trancript_download_file_format, + getTranscriptDownloadLink: this.getTranscriptDownloadLink, + transcriptDownloadHandlerUrl: this.videoTranscriptSettings.transcript_download_handler_url + }) + ); + return this; + } + }); + + return VideoTranscriptsView; + } +); diff --git a/cms/static/sass/views/_video-upload.scss b/cms/static/sass/views/_video-upload.scss index 3dae1a8d55..88f6259929 100644 --- a/cms/static/sass/views/_video-upload.scss +++ b/cms/static/sass/views/_video-upload.scss @@ -8,7 +8,7 @@ @extend %t-copy; vertical-align: bottom; - margin-right: ($baseline/5); + @include margin-right($baseline/45); } } @@ -17,6 +17,52 @@ top: 0 !important; } + .button-link { + background:none; + border:none; + padding:0; + color: $ui-link-color; + cursor:pointer + } + + .show-video-transcripts-wrapper { + display: block; + + .button-link { + color: $ui-link-color !important; + } + } + + .hidden { + display: none; + } + + .show-video-transcript-content { + margin-top: ($baseline/2); + + .transcript-upload-status-container { + + .video-transcript-detail-status, .more-details-action { + @include font-size(12); + @include line-height(12); + @include margin-left($baseline/4); + } + } + + .transcript-upload-status-container.error { + color: $color-error; + } + + .transcript-upload-status-container.success { + color: $color-ready; + } + + .transcript-language-menu { + display: block; + width: 200px; + } + } + .course-video-settings-container { position: absolute; overflow: scroll; @@ -30,14 +76,6 @@ -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%; @@ -416,6 +454,10 @@ width: 25%; } + .transcripts-col { + width: 17%; + } + .thumbnail-col, .video-id-col { width: 15%; } diff --git a/cms/templates/js/previous-video-upload-list.underscore b/cms/templates/js/previous-video-upload-list.underscore index 393dd95301..3248aa78f8 100644 --- a/cms/templates/js/previous-video-upload-list.underscore +++ b/cms/templates/js/previous-video-upload-list.underscore @@ -14,6 +14,9 @@
<%- gettext("Name") %>
<%- gettext("Date Added") %>
<%- gettext("Video ID") %>
+ <% if (isVideoTranscriptEnabled) { %> +
<%- gettext("Transcripts") %>
+ <% } %>
<%- gettext("Status") %>
<%- gettext("Action") %>
diff --git a/cms/templates/js/previous-video-upload.underscore b/cms/templates/js/previous-video-upload.underscore index 077292e9a0..716198f921 100644 --- a/cms/templates/js/previous-video-upload.underscore +++ b/cms/templates/js/previous-video-upload.underscore @@ -5,6 +5,9 @@
<%- client_video_id %>
<%- created %>
<%- edx_video_id %>
+ <% if (isVideoTranscriptEnabled) { %> +
+ <% } %>
<%- status %>