diff --git a/cms/static/js/models/metadata.js b/cms/static/js/models/metadata.js index 68aef82b27..923c51647b 100644 --- a/cms/static/js/models/metadata.js +++ b/cms/static/js/models/metadata.js @@ -11,7 +11,8 @@ define(['backbone'], function(Backbone) { explicitly_set: null, default_value: null, options: null, - type: null + type: null, + custom: false // Used only for non-metadata fields }, initialize: function() { @@ -24,6 +25,11 @@ define(['backbone'], function(Backbone) { * property has changed. */ isModified: function() { + // A non-metadata field will handle itself + if (this.get('custom') === true) { + return false; + } + if (!this.get('explicitly_set') && !this.original_explicitly_set) { return false; } diff --git a/cms/static/js/views/uploads.js b/cms/static/js/views/uploads.js index 8a24c7984f..9f70363cb7 100644 --- a/cms/static/js/views/uploads.js +++ b/cms/static/js/views/uploads.js @@ -13,11 +13,14 @@ define(['jquery', 'underscore', 'gettext', 'js/views/modals/base_modal', 'jquery viewSpecificClasses: 'confirm' }), - initialize: function() { + initialize: function(options) { BaseModal.prototype.initialize.call(this); this.template = this.loadTemplate('upload-dialog'); this.listenTo(this.model, 'change', this.renderContents); this.options.title = this.model.get('title'); + // `uploadData` can contain extra data that + // can be POSTed along with the file. + this.uploadData = _.extend({}, options.uploadData); }, addActionButtons: function() { @@ -73,17 +76,19 @@ define(['jquery', 'underscore', 'gettext', 'js/views/modals/base_modal', 'jquery }, upload: function(e) { + + var uploadAjaxData = _.extend({}, this.uploadData); + // don't show the generic error notification; we're in a modal, + // and we're better off modifying it instead. + uploadAjaxData.notifyOnError = false; + if (e && e.preventDefault) { e.preventDefault(); } this.model.set('uploading', true); this.$('form').ajaxSubmit({ success: _.bind(this.success, this), error: _.bind(this.error, this), uploadProgress: _.bind(this.progress, this), - data: { - // don't show the generic error notification; we're in a modal, - // and we're better off modifying it instead. - notifyOnError: false - } + data: uploadAjaxData }); }, diff --git a/cms/static/js/views/video/translations_editor.js b/cms/static/js/views/video/translations_editor.js index 2e776656f4..23c67bebe9 100644 --- a/cms/static/js/views/video/translations_editor.js +++ b/cms/static/js/views/video/translations_editor.js @@ -1,10 +1,9 @@ define( [ - 'jquery', 'underscore', - 'js/views/abstract_editor', 'js/models/uploads', 'js/views/uploads' - + 'jquery', 'underscore', 'edx-ui-toolkit/js/utils/html-utils', 'js/views/video/transcripts/utils', + 'js/views/abstract_editor', 'common/js/components/utils/view_utils', 'js/models/uploads', 'js/views/uploads' ], -function($, _, AbstractEditor, FileUpload, UploadDialog) { +function($, _, HtmlUtils, TranscriptUtils, AbstractEditor, ViewUtils, FileUpload, UploadDialog) { 'use strict'; var VideoUploadDialog = UploadDialog.extend({ @@ -19,7 +18,6 @@ function($, _, AbstractEditor, FileUpload, UploadDialog) { var Translations = AbstractEditor.extend({ events: { - 'click .setting-clear': 'clear', 'click .create-setting': 'addEntry', 'click .remove-setting': 'removeEntry', 'click .upload-setting': 'upload', @@ -31,13 +29,24 @@ function($, _, AbstractEditor, FileUpload, UploadDialog) { initialize: function() { var templateName = _.result(this, 'templateItemName'), - tpl = document.getElementById(templateName).text; + tpl = document.getElementById(templateName).text, + languageMap = {}; if (!tpl) { console.error("Couldn't load template for item: " + templateName); } this.templateItem = _.template(tpl); + + // Initialize language map. Keys in this map represent language codes present on + // server. Values will change if user changes the language from a dropdown. + // For example: Initially map will look like {'ar': 'ar', 'zh': 'zh'} and corresponding + // dropdowns will show language names `Arabic` and `Chinese`. If user changes `Chinese` + // to `Russian` then map will become {'ar': 'ar', 'zh': 'ru'} + _.each(this.model.getDisplayValue(), function(value, lang) { + languageMap[lang] = lang; + }); + TranscriptUtils.Storage.set('languageMap', languageMap); AbstractEditor.prototype.initialize.apply(this, arguments); }, @@ -111,14 +120,16 @@ function($, _, AbstractEditor, FileUpload, UploadDialog) { setValueInEditor: function(values) { var self = this, frag = document.createDocumentFragment(), - dropdown = self.getDropdown(values); + dropdown = self.getDropdown(values), + languageMap = TranscriptUtils.Storage.get('languageMap'); - _.each(values, function(value, key) { + _.each(values, function(value, newLang) { var html = $(self.templateItem({ - lang: key, + newLang: newLang, + originalLang: _.findKey(languageMap, function(lang) { return lang === newLang; }) || '', value: value, - url: self.model.get('urlRoot') + '/' + key - })).prepend(dropdown.clone().val(key))[0]; + url: self.model.get('urlRoot') + })).prepend(dropdown.clone().val(newLang))[0]; frag.appendChild(html); }); @@ -130,63 +141,166 @@ function($, _, AbstractEditor, FileUpload, UploadDialog) { event.preventDefault(); // We don't call updateModel here since it's bound to the // change event - var dict = $.extend(true, {}, this.model.get('value')); - dict[''] = ''; - this.setValueInEditor(dict); + this.setValueInEditor(this.getAllLanguageDropdownElementsData(true)); this.$el.find('.create-setting').addClass('is-disabled').attr('aria-disabled', true); }, removeEntry: function(event) { + var self = this, + $currentListItemEl = $(event.currentTarget).parent(), + originalLang = $currentListItemEl.data('original-lang'), + selectedLang = $currentListItemEl.find('select option:selected').val(), + languageMap = TranscriptUtils.Storage.get('languageMap'), + edxVideoIdField = TranscriptUtils.getField(self.model.collection, 'edx_video_id'); + event.preventDefault(); - var entry = $(event.currentTarget).data('lang'); - this.setValueInEditor(_.omit(this.model.get('value'), entry)); - this.updateModel(); + /* + There is a scenario when a user adds an empty video translation item and + removes it. In such cases, omitting will have no harm on the model + values or languages map. + */ + if (originalLang) { + ViewUtils.confirmThenRunOperation( + gettext('Are you sure you want to remove this transcript?'), + gettext('If you remove this transcript, the transcript will not be available for this component.'), + gettext('Remove Transcript'), + function() { + ViewUtils.runOperationShowingMessage( + gettext('Removing'), + function() { + return $.ajax({ + url: self.model.get('urlRoot'), + type: 'DELETE', + data: JSON.stringify({lang: originalLang, edx_video_id: edxVideoIdField.getValue()}) + }).done(function() { + self.setValueInEditor(self.getAllLanguageDropdownElementsData(false, selectedLang)); + TranscriptUtils.Storage.set('languageMap', _.omit(languageMap, originalLang)); + }); + } + ); + } + ); + } else { + this.setValueInEditor(this.getAllLanguageDropdownElementsData(false, selectedLang)); + } + this.$el.find('.create-setting').removeClass('is-disabled').attr('aria-disabled', false); }, upload: function(event) { - event.preventDefault(); - var self = this, $target = $(event.currentTarget), - lang = $target.data('lang'), - model = new FileUpload({ - title: gettext('Upload translation'), - fileFormats: ['srt'] - }), - view = new VideoUploadDialog({ - model: model, - url: self.model.get('urlRoot') + '/' + lang, - parentElement: $target.closest('.xblock-editor'), - onSuccess: function(response) { - if (!response.filename) { return; } + $listItem = $target.parents('li.list-settings-item'), + originalLang = $listItem.data('original-lang'), + newLang = $listItem.find(':selected').val(), + edxVideoIdField = TranscriptUtils.getField(self.model.collection, 'edx_video_id'), + fileUploadModel, + uploadData, + videoUploadDialog; - var dict = $.extend(true, {}, self.model.get('value')); + event.preventDefault(); - dict[lang] = response.filename; - self.model.setValue(dict); - } - }); + // That's the case when an author is + // uploading a new transcript. + if (!originalLang) { + originalLang = newLang; + } - view.show(); + // Transcript data payload + uploadData = { + edx_video_id: edxVideoIdField.getValue(), + language_code: originalLang, + new_language_code: newLang + }; + + fileUploadModel = new FileUpload({ + title: gettext('Upload translation'), + fileFormats: ['srt'] + }); + + videoUploadDialog = new VideoUploadDialog({ + model: fileUploadModel, + url: this.model.get('urlRoot'), + parentElement: $target.closest('.xblock-editor'), + uploadData: uploadData, + onSuccess: function(response) { + var languageMap = TranscriptUtils.Storage.get('languageMap'), + newLangObject = {}; + + // new language entry to be added to languageMap + newLangObject[newLang] = newLang; + + // Update edx-video-id + edxVideoIdField.setValue(response.edx_video_id); + + // Update language map by omitting original lang and adding new lang + // if languageMap is empty then newLang will be added + // if an original lang is replaced with new lang then omit the original lang and the add new lang + languageMap = _.extend(_.omit(languageMap, originalLang), newLangObject); + TranscriptUtils.Storage.set('languageMap', languageMap); + + // re-render the whole view + self.setValueInEditor(self.getAllLanguageDropdownElementsData()); + } + }); + videoUploadDialog.show(); }, enableAdd: function() { this.$el.find('.create-setting').removeClass('is-disabled').attr('aria-disabled', false); }, - clear: function() { - AbstractEditor.prototype.clear.apply(this, arguments); - if (_.isNull(this.model.getValue())) { - this.$el.find('.create-setting').removeClass('is-disabled').attr('aria-disabled', false); + onChangeHandler: function(event) { + var $target = $(event.currentTarget), + $listItem = $target.parents('li.list-settings-item'), + originalLang = $listItem.data('original-lang'), + newLang = $listItem.find('select option:selected').val(), + languageMap = TranscriptUtils.Storage.get('languageMap'); + + // To protect against any new/unsaved language code in the map. + if (originalLang in languageMap) { + languageMap[originalLang] = newLang; + TranscriptUtils.Storage.set('languageMap', languageMap); + + // an existing saved lang is changed, no need to re-render the whole view + return; } + + this.enableAdd(); + this.setValueInEditor(this.getAllLanguageDropdownElementsData()); }, - onChangeHandler: function(event) { - this.showClearButton(); - this.enableAdd(); - this.updateModel(); + /** + * Constructs data extracted from each dropdown. This will be used to re-render the whole view. + */ + getAllLanguageDropdownElementsData: function(isNew, omittedLanguage) { + var data = {}, + languageDropdownElements = this.$el.find('select'), + languageMap = TranscriptUtils.Storage.get('languageMap'); + + // data object will mirror the languageMap. `data` will contain lang to lang map as explained below + // {originalLang: originalLang}; original lang not changed + // {newLang: originalLang}; original lang changed to a new lang + // {selectedLang: ''}; new lang to be added, no entry in languageMap + _.each(languageDropdownElements, function(languageDropdown) { + var language = $(languageDropdown).find(':selected').val(); + data[language] = _.findKey(languageMap, function(lang) { return lang === language; }) || ''; + }); + + // This is needed to render an empty item that + // will be further used to upload a transcript. + if (isNew) { + data[''] = ''; + } + + // This Omits a language from the dropdown's data. It is + // needed when an item is going to be removed. + if (typeof(omittedLanguage) !== 'undefined') { + data = _.omit(data, omittedLanguage); + } + + return data; } }); diff --git a/cms/templates/js/video/metadata-translations-entry.underscore b/cms/templates/js/video/metadata-translations-entry.underscore index a7a5453f73..1afef78cd9 100644 --- a/cms/templates/js/video/metadata-translations-entry.underscore +++ b/cms/templates/js/video/metadata-translations-entry.underscore @@ -1,14 +1,11 @@
+
    <%= gettext("Add") %> <%= model.get('display_name')%>
    -
    <%= model.get('help') %> diff --git a/cms/templates/js/video/metadata-translations-item.underscore b/cms/templates/js/video/metadata-translations-item.underscore index 55e75d23f7..cb2fdd23a4 100644 --- a/cms/templates/js/video/metadata-translations-item.underscore +++ b/cms/templates/js/video/metadata-translations-item.underscore @@ -1,12 +1,13 @@ -
  1. - <%= gettext("Remove") %> +
  2. + <%= gettext("Remove") %> -
    <% if (lang) { - %><%= value ? gettext("Replace") : gettext("Upload") %> +
  3. diff --git a/common/lib/xmodule/xmodule/video_module/video_handlers.py b/common/lib/xmodule/xmodule/video_module/video_handlers.py index 7c8f1560cf..19b17f2dd9 100644 --- a/common/lib/xmodule/xmodule/video_module/video_handlers.py +++ b/common/lib/xmodule/xmodule/video_module/video_handlers.py @@ -7,9 +7,9 @@ StudioViewHandlers are handlers for video descriptor instance. import json import logging -import os import six +from django.core.files.base import ContentFile from django.utils.timezone import now from webob import Response @@ -20,11 +20,11 @@ from xmodule.exceptions import NotFoundError from xmodule.fields import RelativeTime from opaque_keys.edx.locator import CourseLocator +from edxval.api import create_or_update_video_transcript, create_external_video, delete_video_transcript from .transcripts_utils import ( - convert_video_transcript, + clean_video_id, get_or_create_sjson, generate_sjson_for_all_speeds, - get_video_transcript_content, save_to_store, subs_filename, Transcript, @@ -32,10 +32,9 @@ from .transcripts_utils import ( TranscriptsGenerationException, youtube_speed_dict, get_transcript, - get_transcript_from_contentstore -) -from .transcripts_model_utils import ( - is_val_transcript_feature_enabled_for_course + get_transcript_from_contentstore, + remove_subs_from_store, + get_html5_ids ) log = logging.getLogger(__name__) @@ -381,6 +380,38 @@ class VideoStudioViewHandlers(object): """ Handlers for Studio view. """ + def validate_transcript_upload_data(self, data): + """ + Validates video transcript file. + Arguments: + data: Transcript data to be validated. + Returns: + None or String + If there is error returns error message otherwise None. + """ + error = None + _ = self.runtime.service(self, "i18n").ugettext + # 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] + + # Get available transcript languages. + transcripts = self.get_transcripts_info() + available_translations = self.available_translations(transcripts, verify_assets=True) + + 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 available_translations + ): + error = _(u'A transcript with the "{language_code}" language code already exists.'.format( + language_code=data['new_language_code'] + )) + elif 'file' not in data: + error = _(u'A transcript file is required.') + + return error + @XBlock.handler def studio_transcript(self, request, dispatch): """ @@ -412,39 +443,104 @@ class VideoStudioViewHandlers(object): _ = self.runtime.service(self, "i18n").ugettext if dispatch.startswith('translation'): - language = dispatch.replace('translation', '').strip('/') - - if not language: - log.info("Invalid /translation request: no language.") - return Response(status=400) if request.method == 'POST': - subtitles = request.POST['file'] - try: - file_data = subtitles.file.read() - unicode(file_data, "utf-8", "strict") - except UnicodeDecodeError: - log.info("Invalid encoding type for transcript file: {}".format(subtitles.filename)) - msg = _("Invalid encoding type, transcripts should be UTF-8 encoded.") - return Response(msg, status=400) - save_to_store(file_data, unicode(subtitles.filename), 'application/x-subrip', self.location) - generate_sjson_for_all_speeds(self, unicode(subtitles.filename), {}, language) - response = {'filename': unicode(subtitles.filename), 'status': 'Success'} - return Response(json.dumps(response), status=201) + error = self.validate_transcript_upload_data(data=request.POST) + if error: + response = Response(json={'error': error}, status=400) + else: + edx_video_id = clean_video_id(request.POST['edx_video_id']) + language_code = request.POST['language_code'] + new_language_code = request.POST['new_language_code'] + transcript_file = request.POST['file'].file - elif request.method == 'GET': + if not edx_video_id: + # Back-populate the video ID for an external video. + # pylint: disable=attribute-defined-outside-init + self.edx_video_id = edx_video_id = create_external_video(display_name=u'external video') - filename = request.GET.get('filename') - if not filename: - log.info("Invalid /translation request: no filename in request.GET") + 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={ + 'file_format': Transcript.SJSON, + 'language_code': new_language_code + }, + file_data=ContentFile(sjson_subs), + ) + payload = { + 'edx_video_id': edx_video_id, + 'language_code': new_language_code + } + response = Response(json.dumps(payload), status=201) + except (TranscriptsGenerationException, UnicodeDecodeError): + response = Response( + json={ + 'error': _( + u'There is a problem with this transcript file. Try to upload a different file.' + ) + }, + status=400 + ) + elif request.method == 'DELETE': + request_data = request.json + + if 'lang' not in request_data or 'edx_video_id' not in request_data: return Response(status=400) - content = Transcript.get_asset(self.location, filename).data - response = Response(content, headerlist=[ - ('Content-Disposition', 'attachment; filename="{}"'.format(filename.encode('utf8'))), - ('Content-Language', language), - ]) - response.content_type = Transcript.mime_types['srt'] + language = request_data['lang'] + edx_video_id = clean_video_id(request_data['edx_video_id']) + + if edx_video_id: + delete_video_transcript(video_id=edx_video_id, language_code=language) + + if language == u'en': + # remove any transcript file from content store for the video ids + possible_sub_ids = [ + self.sub, # pylint: disable=access-member-before-definition + self.youtube_id_1_0 + ] + get_html5_ids(self.html5_sources) + for sub_id in possible_sub_ids: + remove_subs_from_store(sub_id, self, language) + + # update metadata as `en` can also be present in `transcripts` field + remove_subs_from_store(self.transcripts.pop(language, None), self, language) + + # also empty `sub` field + self.sub = '' # pylint: disable=attribute-defined-outside-init + else: + remove_subs_from_store(self.transcripts.pop(language, None), self, language) + + return Response(status=200) + + elif request.method == 'GET': + language = request.GET.get('language_code') + if not language: + return Response(json={'error': _(u'Language is required.')}, status=400) + + try: + transcript_content, transcript_name, mime_type = get_transcript( + video=self, lang=language, output_format=Transcript.SRT + ) + response = Response(transcript_content, headerlist=[ + ('Content-Disposition', 'attachment; filename="{}"'.format(transcript_name.encode('utf8'))), + ('Content-Language', language), + ('Content-Type', mime_type) + ]) + except (UnicodeDecodeError, TranscriptsGenerationException, NotFoundError): + response = Response(status=404) + + else: + # Any other HTTP method is not allowed. + response = Response(status=404) else: # unknown dispatch log.debug("Dispatch is not allowed") diff --git a/common/lib/xmodule/xmodule/video_module/video_module.py b/common/lib/xmodule/xmodule/video_module/video_module.py index f29df60967..4457965524 100644 --- a/common/lib/xmodule/xmodule/video_module/video_module.py +++ b/common/lib/xmodule/xmodule/video_module/video_module.py @@ -49,6 +49,7 @@ from .transcripts_utils import ( VideoTranscriptsMixin, clean_video_id, subs_filename, + get_transcript_for_video ) from .transcripts_model_utils import ( is_val_transcript_feature_enabled_for_course @@ -611,8 +612,27 @@ class VideoDescriptor(VideoFields, VideoTranscriptsMixin, VideoStudioViewHandler languages = [{'label': label, 'code': lang} for lang, label in settings.ALL_LANGUAGES] languages.sort(key=lambda l: l['label']) + editable_fields['transcripts']['custom'] = True editable_fields['transcripts']['languages'] = languages editable_fields['transcripts']['type'] = 'VideoTranslations' + + # construct transcripts info and also find if `en` subs exist + transcripts_info = self.get_transcripts_info() + possible_sub_ids = [self.sub, self.youtube_id_1_0] + get_html5_ids(self.html5_sources) + for sub_id in possible_sub_ids: + try: + get_transcript_for_video( + self.location, + subs_id=sub_id, + file_name=sub_id, + language=u'en' + ) + transcripts_info['transcripts'] = dict(transcripts_info['transcripts'], en=sub_id) + break + except NotFoundError: + continue + + editable_fields['transcripts']['value'] = transcripts_info['transcripts'] editable_fields['transcripts']['urlRoot'] = self.runtime.handler_url( self, 'studio_transcript', diff --git a/common/test/acceptance/pages/studio/video/video.py b/common/test/acceptance/pages/studio/video/video.py index 5f42a7d21f..58abc54c85 100644 --- a/common/test/acceptance/pages/studio/video/video.py +++ b/common/test/acceptance/pages/studio/video/video.py @@ -81,6 +81,11 @@ DEFAULT_SETTINGS = [ ['YouTube ID for 1.5x speed', '', False] ] +# field names without clear button +FIELDS_WO_CLEAR = [ + 'Transcript Languages' +] + # We should wait 300 ms for event handler invocation + 200ms for safety. DELAY = 0.5 @@ -346,15 +351,22 @@ class VideoComponentPage(VideoPage): """ Verify that video component has correct default settings. """ - query = '.wrapper-comp-setting' - settings = self.q(css=query).results - if len(DEFAULT_SETTINGS) != len(settings): - return False + def _check_settings_length(): + """Check video settings""" + query = '.wrapper-comp-setting' + settings = self.q(css=query).results + if len(DEFAULT_SETTINGS) == len(settings): + return True, settings + return (False, None) + + settings = Promise(_check_settings_length, 'All video fields are present').fulfill() for counter, setting in enumerate(settings): - is_verified = self._verify_setting_entry(setting, - DEFAULT_SETTINGS[counter][0], - DEFAULT_SETTINGS[counter][1]) + is_verified = self._verify_setting_entry( + setting, + DEFAULT_SETTINGS[counter][0], + DEFAULT_SETTINGS[counter][1] + ) if not is_verified: return is_verified @@ -395,9 +407,8 @@ class VideoComponentPage(VideoPage): if field_value != current_value: return False - # Clear button should be visible(active class is present) for - # every setting that don't have 'metadata-videolist-enum' class - if 'metadata-videolist-enum' not in setting.get_attribute('class'): + # Verify if clear button is active for expected video fields + if field_name not in FIELDS_WO_CLEAR and 'metadata-videolist-enum' not in setting.get_attribute('class'): setting_clear_button = setting.find_elements_by_class_name('setting-clear')[0] if 'active' not in setting_clear_button.get_attribute('class'): return False @@ -513,8 +524,8 @@ class VideoComponentPage(VideoPage): list: list of translation language codes """ - translations_selector = '.metadata-video-translations .remove-setting' - return self.q(css=translations_selector).attrs('data-lang') + translations_selector = '.metadata-video-translations .list-settings-item' + return self.q(css=translations_selector).attrs('data-original-lang') def download_translation(self, language_code, text_to_search): """ @@ -529,7 +540,7 @@ class VideoComponentPage(VideoPage): """ mime_type = 'application/x-subrip' - lang_code = '/{}?'.format(language_code) + lang_code = '?language_code={}'.format(language_code) link = [link for link in self.q(css='.download-action').attrs('href') if lang_code in link] result, headers, content = self._get_transcript(link[0]) @@ -543,7 +554,9 @@ class VideoComponentPage(VideoPage): language_code (str): language code """ - self.q(css='.remove-action').filter(lambda el: language_code == el.get_attribute('data-lang')).click() + selector = '.metadata-video-translations .list-settings-item' + translation = self.q(css=selector).filter(lambda el: language_code == el.get_attribute('data-original-lang')) + translation[0].find_element_by_class_name('remove-action').click() @property def upload_status_message(self): diff --git a/common/test/acceptance/tests/video/test_studio_video_editor.py b/common/test/acceptance/tests/video/test_studio_video_editor.py index 73c2d126b5..cd1a409a02 100644 --- a/common/test/acceptance/tests/video/test_studio_video_editor.py +++ b/common/test/acceptance/tests/video/test_studio_video_editor.py @@ -3,12 +3,14 @@ """ Acceptance tests for CMS Video Editor. """ +import ddt from nose.plugins.attrib import attr - +from common.test.acceptance.pages.common.utils import confirm_prompt from common.test.acceptance.tests.video.test_studio_video_module import CMSVideoBaseTest @attr(shard=6) +@ddt.ddt class VideoEditorTest(CMSVideoBaseTest): """ CMS Video Editor Test Class @@ -263,6 +265,7 @@ class VideoEditorTest(CMSVideoBaseTest): self.open_advanced_tab() self.assertEqual(self.video.translations(), ['zh', 'uk']) self.video.remove_translation('uk') + confirm_prompt(self.video) self.save_unit_settings() self.assertTrue(self.video.is_captions_visible()) unicode_text = "好 各位同学".decode('utf-8') @@ -271,6 +274,7 @@ class VideoEditorTest(CMSVideoBaseTest): self.open_advanced_tab() self.assertEqual(self.video.translations(), ['zh']) self.video.remove_translation('zh') + confirm_prompt(self.video) self.save_unit_settings() self.assertFalse(self.video.is_captions_visible()) @@ -292,69 +296,27 @@ class VideoEditorTest(CMSVideoBaseTest): self.video.upload_translation('uk_transcripts.srt', 'uk') self.assertEqual(self.video.translations(), ['uk']) self.video.remove_translation('uk') + confirm_prompt(self.video) self.save_unit_settings() self.assertFalse(self.video.is_captions_visible()) - def test_translations_clearing_works_w_saving(self): + def test_translations_entry_remove_works(self): """ - Scenario: Translations clearing works correctly w/ preliminary saving + Scenario: Translations entry removal works correctly when transcript is not uploaded Given I have created a Video component And I edit the component And I open tab "Advanced" - And I upload transcript files: - |lang_code|filename | - |uk |uk_transcripts.srt | - |zh |chinese_transcripts.srt| - And I save changes - Then when I view the video it does show the captions - And I see "Привіт, edX вітає вас." text in the captions - And video language menu has "uk, zh" translations - And I edit the component - And I open tab "Advanced" - And I see translations for "uk, zh" - And I click button "Clear" - And I save changes - Then when I view the video it does not show the captions + And I click on "+ Add" button for "Transcript Languages" field + Then I click on "Remove" button + And I see newly created entry is removed """ self._create_video_component() self.edit_component() self.open_advanced_tab() - self.video.upload_translation('uk_transcripts.srt', 'uk') - self.video.upload_translation('chinese_transcripts.srt', 'zh') - self.save_unit_settings() - self.assertTrue(self.video.is_captions_visible()) - unicode_text = "Привіт, edX вітає вас.".decode('utf-8') - self.assertIn(unicode_text, self.video.captions_text) - self.assertEqual(self.video.caption_languages.keys(), ['zh', 'uk']) - self.edit_component() - self.open_advanced_tab() - self.assertEqual(self.video.translations(), ['zh', 'uk']) - self.video.click_button('translations_clear') - self.save_unit_settings() - self.assertFalse(self.video.is_captions_visible()) - - def test_translations_clearing_works_wo_saving(self): - """ - Scenario: Translations clearing works correctly w/o preliminary saving - Given I have created a Video component - And I edit the component - And I open tab "Advanced" - And I upload transcript files: - |lang_code|filename | - |uk |uk_transcripts.srt | - |zh |chinese_transcripts.srt| - And I click button "Clear" - And I save changes - Then when I view the video it does not show the captions - """ - self._create_video_component() - self.edit_component() - self.open_advanced_tab() - self.video.upload_translation('uk_transcripts.srt', 'uk') - self.video.upload_translation('chinese_transcripts.srt', 'zh') - self.video.click_button('translations_clear') - self.save_unit_settings() - self.assertFalse(self.video.is_captions_visible()) + self.video.click_button("translation_add") + self.assertEqual(self.video.translations_count(), 1) + self.video.remove_translation("") + self.assertEqual(self.video.translations_count(), 0) def test_cannot_upload_sjson_translation(self): """ @@ -455,6 +417,7 @@ class VideoEditorTest(CMSVideoBaseTest): self.video.upload_translation('chinese_transcripts.srt', 'zh') self.assertEqual(self.video.translations(), ['zh']) self.video.remove_translation('zh') + confirm_prompt(self.video) self.video.upload_translation('uk_transcripts.srt', 'zh') self.save_unit_settings() self.assertTrue(self.video.is_captions_visible()) diff --git a/lms/djangoapps/courseware/tests/test_video_handlers.py b/lms/djangoapps/courseware/tests/test_video_handlers.py index 4829b0590d..adbb4d2d66 100644 --- a/lms/djangoapps/courseware/tests/test_video_handlers.py +++ b/lms/djangoapps/courseware/tests/test_video_handlers.py @@ -9,6 +9,7 @@ from datetime import timedelta import ddt import freezegun +from django.core.files.base import ContentFile from django.utils.timezone import now from mock import MagicMock, Mock, patch from nose.plugins.attrib import attr @@ -21,9 +22,15 @@ from xmodule.contentstore.django import contentstore from xmodule.exceptions import NotFoundError from xmodule.modulestore import ModuleStoreEnum from xmodule.modulestore.django import modulestore -from xmodule.video_module.transcripts_utils import TranscriptException, TranscriptsGenerationException, Transcript +from xmodule.video_module.transcripts_utils import ( + Transcript, + edxval_api, + subs_filename, +) from xmodule.x_module import STUDENT_VIEW +from edxval import api + from .helpers import BaseTestXmodule from .test_video_xml import SOURCE_XML @@ -863,42 +870,44 @@ class TestStudioTranscriptTranslationGetDispatch(TestVideo): def test_translation_fails(self): # No language - request = Request.blank('') - response = self.item_descriptor.studio_transcript(request=request, dispatch='translation') - self.assertEqual(response.status, '400 Bad Request') + request = Request.blank("") + response = self.item_descriptor.studio_transcript(request=request, dispatch="translation") + self.assertEqual(response.status, "400 Bad Request") - # No filename in request.GET - request = Request.blank('') - response = self.item_descriptor.studio_transcript(request=request, dispatch='translation/uk') - self.assertEqual(response.status, '400 Bad Request') + # No language_code param in request.GET + request = Request.blank("") + response = self.item_descriptor.studio_transcript(request=request, dispatch="translation") + self.assertEqual(response.status, "400 Bad Request") + self.assertEqual(response.json["error"], "Language is required.") # Correct case: filename = os.path.split(self.srt_file.name)[1] _upload_file(self.srt_file, self.item_descriptor.location, filename) + request = Request.blank(u"translation?language_code=uk") + response = self.item_descriptor.studio_transcript(request=request, dispatch="translation?language_code=uk") self.srt_file.seek(0) - request = Request.blank(u'translation/uk?filename={}'.format(filename)) - response = self.item_descriptor.studio_transcript(request=request, dispatch='translation/uk') self.assertEqual(response.body, self.srt_file.read()) - self.assertEqual(response.headers['Content-Type'], 'application/x-subrip; charset=utf-8') + self.assertEqual(response.headers["Content-Type"], "application/x-subrip; charset=utf-8") self.assertEqual( - response.headers['Content-Disposition'], - 'attachment; filename="{}"'.format(filename) + response.headers["Content-Disposition"], + 'attachment; filename="uk_{}"'.format(filename) ) - self.assertEqual(response.headers['Content-Language'], 'uk') + self.assertEqual(response.headers["Content-Language"], "uk") # Non ascii file name download: self.srt_file.seek(0) - _upload_file(self.srt_file, self.item_descriptor.location, u'塞.srt') + _upload_file(self.srt_file, self.item_descriptor.location, u"塞.srt") + request = Request.blank("translation?language_code=zh") + response = self.item_descriptor.studio_transcript(request=request, dispatch="translation?language_code=zh") self.srt_file.seek(0) - request = Request.blank('translation/zh?filename={}'.format(u'塞.srt'.encode('utf8'))) - response = self.item_descriptor.studio_transcript(request=request, dispatch='translation/zh') self.assertEqual(response.body, self.srt_file.read()) - self.assertEqual(response.headers['Content-Type'], 'application/x-subrip; charset=utf-8') - self.assertEqual(response.headers['Content-Disposition'], 'attachment; filename="塞.srt"') - self.assertEqual(response.headers['Content-Language'], 'zh') + self.assertEqual(response.headers["Content-Type"], "application/x-subrip; charset=utf-8") + self.assertEqual(response.headers["Content-Disposition"], 'attachment; filename="zh_塞.srt"') + self.assertEqual(response.headers["Content-Language"], "zh") @attr(shard=1) +@ddt.ddt class TestStudioTranscriptTranslationPostDispatch(TestVideo): """ Test Studio video handler that provide translation transcripts. @@ -921,42 +930,219 @@ class TestStudioTranscriptTranslationPostDispatch(TestVideo): METADATA = {} - def test_studio_transcript_post(self): - # Check for exceptons: - - # Language is passed, bad content or filename: - - # should be first, as other tests save transcrips to store. - request = Request.blank('/translation/uk', POST={'file': ('filename.srt', SRT_content)}) - with patch('xmodule.video_module.video_handlers.save_to_store'): - with self.assertRaises(TranscriptException): # transcripts were not saved to store for some reason. - response = self.item_descriptor.studio_transcript(request=request, dispatch='translation/uk') - request = Request.blank('/translation/uk', POST={'file': ('filename', 'content')}) - with self.assertRaises(TranscriptsGenerationException): # Not an srt filename - self.item_descriptor.studio_transcript(request=request, dispatch='translation/uk') - - request = Request.blank('/translation/uk', POST={'file': ('filename.srt', 'content')}) - with self.assertRaises(TranscriptsGenerationException): # Content format is not srt. - response = self.item_descriptor.studio_transcript(request=request, dispatch='translation/uk') - - request = Request.blank('/translation/uk', POST={'file': ('filename.srt', SRT_content.decode('utf8').encode('cp1251'))}) - # Non-UTF8 file content encoding. - response = self.item_descriptor.studio_transcript(request=request, dispatch='translation/uk') - self.assertEqual(response.status_code, 400) - self.assertEqual(response.body, "Invalid encoding type, transcripts should be UTF-8 encoded.") - - # No language is passed. - request = Request.blank('/translation', POST={'file': ('filename', SRT_content)}) + @ddt.data( + { + "post_data": {}, + "error_message": "The following parameters are required: edx_video_id, language_code, new_language_code." + }, + { + "post_data": {"edx_video_id": "111", "language_code": "ar", "new_language_code": "ur"}, + "error_message": 'A transcript with the "ur" language code already exists.' + }, + { + "post_data": {"edx_video_id": "111", "language_code": "ur", "new_language_code": "ur"}, + "error_message": "A transcript file is required." + }, + ) + @ddt.unpack + def test_studio_transcript_post_validations(self, post_data, error_message): + """ + Verify that POST request validations works as expected. + """ + # mock available_translations method + self.item_descriptor.available_translations = lambda transcripts, verify_assets: ['ur'] + request = Request.blank('/translation', POST=post_data) response = self.item_descriptor.studio_transcript(request=request, dispatch='translation') - self.assertEqual(response.status, '400 Bad Request') + self.assertEqual(response.json["error"], error_message) - # Language, good filename and good content. - request = Request.blank('/translation/uk', POST={'file': ('filename.srt', SRT_content)}) - response = self.item_descriptor.studio_transcript(request=request, dispatch='translation/uk') + @ddt.data( + { + "edx_video_id": "", + }, + { + "edx_video_id": "1234-5678-90", + }, + ) + @ddt.unpack + def test_studio_transcript_post_w_no_edx_video_id(self, edx_video_id): + """ + Verify that POST request works as expected + """ + post_data = { + "edx_video_id": edx_video_id, + "language_code": "ar", + "new_language_code": "uk", + "file": ("filename.srt", SRT_content) + } + + if edx_video_id: + edxval_api.create_video({ + "edx_video_id": edx_video_id, + "status": "uploaded", + "client_video_id": "a video", + "duration": 0, + "encoded_videos": [], + "courses": [] + }) + + request = Request.blank('/translation', POST=post_data) + response = self.item_descriptor.studio_transcript(request=request, dispatch='translation') self.assertEqual(response.status, '201 Created') - self.assertDictEqual(json.loads(response.body), {'filename': u'filename.srt', 'status': 'Success'}) + response = json.loads(response.body) + self.assertTrue(response["language_code"], "uk") self.assertDictEqual(self.item_descriptor.transcripts, {}) - self.assertTrue(_check_asset(self.item_descriptor.location, u'filename.srt')) + self.assertTrue(edxval_api.get_video_transcript_data(video_id=response["edx_video_id"], language_code="uk")) + + def test_studio_transcript_post_bad_content(self): + """ + Verify that transcript content encode/decode errors handled as expected + """ + post_data = { + "edx_video_id": "", + "language_code": "ar", + "new_language_code": "uk", + "file": ("filename.srt", SRT_content.decode("utf8").encode("cp1251")) + } + + request = Request.blank("/translation", POST=post_data) + response = self.item_descriptor.studio_transcript(request=request, dispatch="translation") + self.assertEqual(response.status_code, 400) + self.assertEqual( + response.json["error"], + "There is a problem with this transcript file. Try to upload a different file." + ) + + +@attr(shard=1) +@ddt.ddt +class TestStudioTranscriptTranslationDeleteDispatch(TestVideo): + """ + Test studio video handler that provide translation transcripts. + + Tests for `translation` dispatch DELETE HTTP method. + """ + EDX_VIDEO_ID, LANGUAGE_CODE_UK, LANGUAGE_CODE_EN = u'an_edx_video_id', u'uk', u'en' + REQUEST_META = {'wsgi.url_scheme': 'http', 'REQUEST_METHOD': 'DELETE'} + SRT_FILE = _create_srt_file() + + @ddt.data( + { + 'params': {'lang': 'uk'} + }, + { + 'params': {'edx_video_id': '12345'} + }, + { + 'params': {} + }, + ) + @ddt.unpack + def test_translation_missing_required_params(self, params): + """ + Verify that DELETE dispatch works as expected when required args are missing from request + """ + request = Request(self.REQUEST_META, body=json.dumps(params)) + response = self.item_descriptor.studio_transcript(request=request, dispatch='translation') + self.assertEqual(response.status_code, 400) + + def test_translation_delete_w_edx_video_id(self): + """ + Verify that DELETE dispatch works as expected when video has edx_video_id + """ + request_body = json.dumps({'lang': self.LANGUAGE_CODE_UK, 'edx_video_id': self.EDX_VIDEO_ID}) + api.create_video({ + 'edx_video_id': self.EDX_VIDEO_ID, + 'status': 'upload', + 'client_video_id': 'awesome.mp4', + 'duration': 0, + 'encoded_videos': [], + 'courses': [unicode(self.course.id)] + }) + api.create_video_transcript( + video_id=self.EDX_VIDEO_ID, + language_code=self.LANGUAGE_CODE_UK, + file_format='srt', + content=ContentFile(SRT_content) + ) + + # verify that a video transcript exists for expected data + self.assertTrue(api.get_video_transcript_data(video_id=self.EDX_VIDEO_ID, language_code=self.LANGUAGE_CODE_UK)) + + request = Request(self.REQUEST_META, body=request_body) + self.item_descriptor.edx_video_id = self.EDX_VIDEO_ID + response = self.item_descriptor.studio_transcript(request=request, dispatch='translation') + self.assertEqual(response.status_code, 200) + + # verify that a video transcript dose not exist for expected data + self.assertFalse(api.get_video_transcript_data(video_id=self.EDX_VIDEO_ID, language_code=self.LANGUAGE_CODE_UK)) + + def test_translation_delete_wo_edx_video_id(self): + """ + Verify that DELETE dispatch works as expected when video has no edx_video_id + """ + request_body = json.dumps({'lang': self.LANGUAGE_CODE_UK, 'edx_video_id': ''}) + srt_file_name_uk = subs_filename('ukrainian_translation.srt', lang=self.LANGUAGE_CODE_UK) + request = Request(self.REQUEST_META, body=request_body) + + # upload and verify that srt file exists in assets + _upload_file(self.SRT_FILE, self.item_descriptor.location, srt_file_name_uk) + self.assertTrue(_check_asset(self.item_descriptor.location, srt_file_name_uk)) + + # verify transcripts field + self.assertNotEqual(self.item_descriptor.transcripts, {}) + self.assertTrue(self.LANGUAGE_CODE_UK in self.item_descriptor.transcripts) + + # make request and verify response + response = self.item_descriptor.studio_transcript(request=request, dispatch='translation') + self.assertEqual(response.status_code, 200) + + # verify that srt file is deleted + self.assertEqual(self.item_descriptor.transcripts, {}) + self.assertFalse(_check_asset(self.item_descriptor.location, srt_file_name_uk)) + + def test_translation_delete_w_english_lang(self): + """ + Verify that DELETE dispatch works as expected for english language translation + """ + request_body = json.dumps({'lang': self.LANGUAGE_CODE_EN, 'edx_video_id': ''}) + srt_file_name_en = subs_filename('english_translation.srt', lang=self.LANGUAGE_CODE_EN) + self.item_descriptor.transcripts['en'] = 'english_translation.srt' + request = Request(self.REQUEST_META, body=request_body) + + # upload and verify that srt file exists in assets + _upload_file(self.SRT_FILE, self.item_descriptor.location, srt_file_name_en) + self.assertTrue(_check_asset(self.item_descriptor.location, srt_file_name_en)) + + # make request and verify response + response = self.item_descriptor.studio_transcript(request=request, dispatch='translation') + self.assertEqual(response.status_code, 200) + + # verify that srt file is deleted + self.assertTrue(self.LANGUAGE_CODE_EN not in self.item_descriptor.transcripts) + self.assertFalse(_check_asset(self.item_descriptor.location, srt_file_name_en)) + + def test_translation_delete_w_sub(self): + """ + Verify that DELETE dispatch works as expected when translation is present against `sub` field + """ + request_body = json.dumps({'lang': self.LANGUAGE_CODE_EN, 'edx_video_id': ''}) + sub_file_name = subs_filename(self.item_descriptor.sub, lang=self.LANGUAGE_CODE_EN) + request = Request(self.REQUEST_META, body=request_body) + + # sub should not be empy + self.assertFalse(self.item_descriptor.sub == u'') + + # upload and verify that srt file exists in assets + _upload_file(self.SRT_FILE, self.item_descriptor.location, sub_file_name) + self.assertTrue(_check_asset(self.item_descriptor.location, sub_file_name)) + + # make request and verify response + response = self.item_descriptor.studio_transcript(request=request, dispatch='translation') + self.assertEqual(response.status_code, 200) + + # verify that sub is empty and transcript is deleted also + self.assertTrue(self.item_descriptor.sub == u'') + self.assertFalse(_check_asset(self.item_descriptor.location, sub_file_name)) @attr(shard=1)