diff --git a/cms/djangoapps/contentstore/views/videos.py b/cms/djangoapps/contentstore/views/videos.py index 4b77bc619c..c9e5c1f0e9 100644 --- a/cms/djangoapps/contentstore/views/videos.py +++ b/cms/djangoapps/contentstore/views/videos.py @@ -423,7 +423,14 @@ def videos_index_html(course): 'previous_uploads': _get_index_videos(course), 'concurrent_upload_limit': settings.VIDEO_UPLOAD_PIPELINE.get('CONCURRENT_UPLOAD_LIMIT', 0), 'video_supported_file_formats': VIDEO_SUPPORTED_FILE_FORMATS.keys(), - 'video_upload_max_file_size': VIDEO_UPLOAD_MAX_FILE_SIZE_GB + 'video_upload_max_file_size': VIDEO_UPLOAD_MAX_FILE_SIZE_GB, + 'video_image_settings': { + 'max_size': settings.VIDEO_IMAGE_SETTINGS['VIDEO_IMAGE_MAX_BYTES'], + 'min_size': settings.VIDEO_IMAGE_SETTINGS['VIDEO_IMAGE_MIN_BYTES'], + 'max_width': settings.VIDEO_IMAGE_MAX_WIDTH, + 'max_height': settings.VIDEO_IMAGE_MAX_HEIGHT, + 'supported_file_formats': settings.VIDEO_IMAGE_SUPPORTED_FILE_FORMATS + } } ) diff --git a/cms/static/js/factories/videos_index.js b/cms/static/js/factories/videos_index.js index 07a193e7bb..f3e735fba7 100644 --- a/cms/static/js/factories/videos_index.js +++ b/cms/static/js/factories/videos_index.js @@ -13,7 +13,8 @@ define([ uploadButton, previousUploads, videoSupportedFileFormats, - videoUploadMaxFileSizeInGB + videoUploadMaxFileSizeInGB, + videoImageSettings ) { var activeView = new ActiveVideoUploadListView({ postUrl: videoHandlerUrl, @@ -21,6 +22,7 @@ define([ uploadButton: uploadButton, videoSupportedFileFormats: videoSupportedFileFormats, videoUploadMaxFileSizeInGB: videoUploadMaxFileSizeInGB, + videoImageSettings: videoImageSettings, onFileUploadDone: function(activeVideos) { $.ajax({ url: videoHandlerUrl, @@ -40,7 +42,8 @@ define([ defaultVideoImageURL: defaultVideoImageURL, videoHandlerUrl: videoHandlerUrl, collection: updatedCollection, - encodingsDownloadUrl: encodingsDownloadUrl + encodingsDownloadUrl: encodingsDownloadUrl, + videoImageSettings: videoImageSettings }); $contentWrapper.find('.wrapper-assets').replaceWith(updatedView.render().$el); }); @@ -51,7 +54,8 @@ define([ defaultVideoImageURL: defaultVideoImageURL, videoHandlerUrl: videoHandlerUrl, collection: new Backbone.Collection(previousUploads), - encodingsDownloadUrl: encodingsDownloadUrl + encodingsDownloadUrl: encodingsDownloadUrl, + videoImageSettings: videoImageSettings }); $contentWrapper.append(activeView.render().$el); $contentWrapper.append(previousView.render().$el); diff --git a/cms/static/js/spec/views/previous_video_upload_list_spec.js b/cms/static/js/spec/views/previous_video_upload_list_spec.js index bb89be22e7..0cfa18b15c 100644 --- a/cms/static/js/spec/views/previous_video_upload_list_spec.js +++ b/cms/static/js/spec/views/previous_video_upload_list_spec.js @@ -25,7 +25,8 @@ define( ); var view = new PreviousVideoUploadListView({ collection: collection, - videoHandlerUrl: videoHandlerUrl + videoHandlerUrl: videoHandlerUrl, + videoImageSettings: {} }); return view.render().$el; }, diff --git a/cms/static/js/spec/views/previous_video_upload_spec.js b/cms/static/js/spec/views/previous_video_upload_spec.js index 2b0c164863..6698b2dda3 100644 --- a/cms/static/js/spec/views/previous_video_upload_spec.js +++ b/cms/static/js/spec/views/previous_video_upload_spec.js @@ -14,7 +14,8 @@ define( }, view = new PreviousVideoUploadView({ model: new Backbone.Model($.extend({}, defaultData, modelData)), - videoHandlerUrl: '/videos/course-v1:org.0+course_0+Run_0' + videoHandlerUrl: '/videos/course-v1:org.0+course_0+Run_0', + videoImageSettings: {} }); return view.render().$el; }; diff --git a/cms/static/js/spec/views/video_thumbnail_spec.js b/cms/static/js/spec/views/video_thumbnail_spec.js index c62eae63de..4cdb8b086c 100644 --- a/cms/static/js/spec/views/video_thumbnail_spec.js +++ b/cms/static/js/spec/views/video_thumbnail_spec.js @@ -1,36 +1,77 @@ define( ['jquery', 'underscore', 'backbone', 'edx-ui-toolkit/js/utils/spec-helpers/ajax-helpers', - 'js/views/video_thumbnail', 'common/js/spec_helpers/template_helpers'], - function($, _, Backbone, AjaxHelpers, VideoThumbnailView, TemplateHelpers) { + 'js/views/video_thumbnail', 'js/views/previous_video_upload_list', 'common/js/spec_helpers/template_helpers'], + function($, _, Backbone, AjaxHelpers, VideoThumbnailView, PreviousVideoUploadListView, TemplateHelpers) { 'use strict'; describe('VideoThumbnailView', function() { var IMAGE_UPLOAD_URL = '/videos/upload/image', UPLOADED_IMAGE_URL = 'images/upload_success.jpg', + VIDEO_IMAGE_MAX_BYTES = 2 * 1024 * 1024, + VIDEO_IMAGE_MIN_BYTES = 2 * 1024, + VIDEO_IMAGE_MAX_WIDTH = 1280, + VIDEO_IMAGE_MAX_HEIGHT = 720, + VIDEO_IMAGE_SUPPORTED_FILE_FORMATS = { + '.bmp': 'image/bmp', + '.bmp2': 'image/x-ms-bmp', // PIL gives x-ms-bmp format + '.gif': 'image/gif', + '.jpg': 'image/jpeg', + '.jpeg': 'image/jpeg', + '.png': 'image/png' + }, + videoListView, videoThumbnailView, + $videoListEl, + $videoThumbnailEl, + createVideoListView, createFakeImageFile, - verifyStateInfo, - render = function(modelData) { - var defaultData = { + verifyStateInfo; + + /** + * Creates a list of video records. + * + * @param {Object} modelData Model data for video records. + * @param {Integer} numVideos Number of video elements to create. + * @param {Integer} videoViewIndex Index of video on which videoThumbnailView would be based. + */ + createVideoListView = function(modelData, numVideos, videoViewIndex) { + var modelData = modelData || {}, // eslint-disable-line no-redeclare + numVideos = numVideos || 1, // eslint-disable-line no-redeclare + videoViewIndex = videoViewIndex || 0, // eslint-disable-line no-redeclare, + defaultData = { client_video_id: 'foo.mp4', duration: 42, created: '2014-11-25T23:13:05', edx_video_id: 'dummy_id', - status: 'uploading', - thumbnail_url: null - }; - videoThumbnailView = new VideoThumbnailView({ - model: new Backbone.Model($.extend({}, defaultData, modelData)), - imageUploadURL: IMAGE_UPLOAD_URL - }); - return videoThumbnailView.render().$el; - }; + status: 'uploading' + }, + collection = new Backbone.Collection(_.map(_.range(numVideos), function(num, index) { + return new Backbone.Model( + _.extend({}, defaultData, {edx_video_id: 'dummy_id_' + index}, modelData) + ); + })); + videoListView = new PreviousVideoUploadListView({ + collection: collection, + videoHandlerUrl: '/videos/course-v1:org.0+course_0+Run_0', + videoImageUploadURL: IMAGE_UPLOAD_URL, + videoImageSettings: { + max_size: VIDEO_IMAGE_MAX_BYTES, + min_size: VIDEO_IMAGE_MIN_BYTES, + max_width: VIDEO_IMAGE_MAX_WIDTH, + max_height: VIDEO_IMAGE_MAX_HEIGHT, + supported_file_formats: VIDEO_IMAGE_SUPPORTED_FILE_FORMATS + } + }); + $videoListEl = videoListView.render().$el; + videoThumbnailView = videoListView.itemViews[videoViewIndex].videoThumbnailView; + $videoThumbnailEl = videoThumbnailView.render().$el; + return videoListView; + }; - createFakeImageFile = function(size) { - var fileFakeData = 'i63ljc6giwoskyb9x5sw0169bdcmcxr3cdz8boqv0lik971972cmd6yknvcxr5sw0nvc169bdcmcxsdf'; - return new Blob( - [fileFakeData.substr(0, size)], - {type: 'image/jpg'} - ); + + createFakeImageFile = function(size, type) { + var size = size || VIDEO_IMAGE_MIN_BYTES, // eslint-disable-line no-redeclare + type = type || 'image/jpeg'; // eslint-disable-line no-redeclare + return new Blob([Array(size + 1).join('i')], {type: type}); }; verifyStateInfo = function($thumbnail, state, onHover, additionalSRText) { @@ -50,9 +91,12 @@ define( ).toEqual(additionalSRText); } - expect($thumbnail.find('.action-icon').html().trim()).toEqual( - videoThumbnailView.actionsInfo[state].icon - ); + if (state !== 'error') { + expect($thumbnail.find('.action-icon').html().trim()).toEqual( + videoThumbnailView.actionsInfo[state].icon + ); + } + expect($thumbnail.find('.action-text').html().trim()).toEqual( videoThumbnailView.actionsInfo[state].text ); @@ -68,22 +112,22 @@ define( beforeEach(function() { setFixtures('
'); TemplateHelpers.installTemplate('video-thumbnail'); + TemplateHelpers.installTemplate('previous-video-upload-list'); + createVideoListView(); }); it('renders as expected', function() { - var $el = render({}); - expect($el.find('.thumbnail-wrapper')).toExist(); - expect($el.find('.upload-image-input')).toExist(); + expect($videoThumbnailEl.find('.thumbnail-wrapper')).toExist(); + expect($videoThumbnailEl.find('.upload-image-input')).toExist(); }); it('does not show duration if not available', function() { - var $el = render({duration: 0}); - expect($el.find('.thumbnail-wrapper .video-duration')).not.toExist(); + createVideoListView({duration: 0}); + expect($videoThumbnailEl.find('.thumbnail-wrapper .video-duration')).not.toExist(); }); it('shows the duration if available', function() { - var $el = render({}), - $duration = $el.find('.thumbnail-wrapper .video-duration'); + var $duration = $videoThumbnailEl.find('.thumbnail-wrapper .video-duration'); expect($duration).toExist(); expect($duration.find('.duration-text-machine').text().trim()).toEqual('0:42'); expect($duration.find('.duration-text-human').text().trim()).toEqual('Video duration is 42 seconds'); @@ -114,8 +158,8 @@ define( }); it('can upload image', function() { - var $el = render({}), - $thumbnail = $el.find('.thumbnail-wrapper'), + var videoViewIndex = 0, + $thumbnail = $videoThumbnailEl.find('.thumbnail-wrapper'), requests = AjaxHelpers.requests(this), additionalSRText = videoThumbnailView.getSRText(); @@ -125,12 +169,17 @@ define( verifyStateInfo($thumbnail, 'requirements', true, additionalSRText); // Add image to upload queue and send POST request to upload image - $el.find('.upload-image-input').fileupload('add', {files: [createFakeImageFile(60)]}); + $videoThumbnailEl.find('.upload-image-input').fileupload('add', {files: [createFakeImageFile()]}); verifyStateInfo($thumbnail, 'progress'); // Verify if POST request received for image upload - AjaxHelpers.expectRequest(requests, 'POST', IMAGE_UPLOAD_URL + '/dummy_id', new FormData()); + AjaxHelpers.expectRequest( + requests, + 'POST', + IMAGE_UPLOAD_URL + '/dummy_id_' + videoViewIndex, + new FormData() + ); // Send successful upload response AjaxHelpers.respondWithJson(requests, {image_url: UPLOADED_IMAGE_URL}); @@ -142,45 +191,136 @@ define( }); it('shows error state correctly', function() { - var $el = render({}), - $thumbnail = $el.find('.thumbnail-wrapper'), + var $thumbnail = $videoThumbnailEl.find('.thumbnail-wrapper'), requests = AjaxHelpers.requests(this); videoThumbnailView.chooseFile(); // Add image to upload queue and send POST request to upload image - $el.find('.upload-image-input').fileupload('add', {files: [createFakeImageFile(60)]}); + $videoThumbnailEl.find('.upload-image-input').fileupload('add', {files: [createFakeImageFile()]}); AjaxHelpers.respondWithError(requests, 400); verifyStateInfo($thumbnail, 'error'); }); - it('should show error notification in case of server error', function() { - var $el = render({}), - requests = AjaxHelpers.requests(this); + it('calls readMessage with correct message', function() { + var errorMessage = 'This image file type is not supported. Supported file types are ' + + videoThumbnailView.getVideoImageSupportedFileFormats().humanize + '.', + successData = { + files: [createFakeImageFile()], + submit: function() {} + }, + failureData = { + jqXHR: { + responseText: JSON.stringify({ + error: errorMessage + }) + } + }; + + spyOn(videoThumbnailView, 'readMessages'); + + videoThumbnailView.imageSelected({}, successData); + expect(videoThumbnailView.readMessages).toHaveBeenCalledWith(['Video image upload started']); + videoThumbnailView.imageUploadSucceeded({}, {result: {image_url: UPLOADED_IMAGE_URL}}); + expect(videoThumbnailView.readMessages).toHaveBeenCalledWith(['Video image upload completed']); + videoThumbnailView.imageUploadFailed({}, failureData); + expect(videoThumbnailView.readMessages).toHaveBeenCalledWith( + ['Could not upload the video image file', errorMessage] + ); + }); + + it('should show error message in case of server error', function() { + var requests = AjaxHelpers.requests(this); videoThumbnailView.chooseFile(); // Add image to upload queue and send POST request to upload image - $el.find('.upload-image-input').fileupload('add', {files: [createFakeImageFile(60)]}); + $videoThumbnailEl.find('.upload-image-input').fileupload('add', {files: [createFakeImageFile()]}); AjaxHelpers.respondWithError(requests); - expect($('#notification-error-title').text().trim()).toEqual( - "Studio's having trouble saving your work" + // Verify error message is present + expect($videoListEl.find('.thumbnail-error-wrapper')).toExist(); + }); + + it('should show error message when file is smaller than minimum size', function() { + videoThumbnailView.chooseFile(); + + // Add image to upload queue and send POST request to upload image + $videoThumbnailEl.find('.upload-image-input') + .fileupload('add', {files: [createFakeImageFile(VIDEO_IMAGE_MIN_BYTES - 10)]}); + + // Verify error message + expect($videoListEl.find('.thumbnail-error-wrapper').find('.action-text').html() + .trim()).toEqual( + 'The selected image must be larger than ' + + videoThumbnailView.getVideoImageMinSize().humanize + '.' ); }); - it('calls readMessage with correct message', function() { - spyOn(videoThumbnailView, 'readMessages'); + it('should show error message when file is larger than maximum size', function() { + videoThumbnailView.chooseFile(); - videoThumbnailView.imageSelected({}, {submit: function() {}}); - expect(videoThumbnailView.readMessages).toHaveBeenCalledWith(['Video image upload started']); - videoThumbnailView.imageUploadSucceeded({}, {result: {image_url: UPLOADED_IMAGE_URL}}); - expect(videoThumbnailView.readMessages).toHaveBeenCalledWith(['Video image upload completed']); - videoThumbnailView.imageUploadFailed(); - expect(videoThumbnailView.readMessages).toHaveBeenCalledWith(['Video image upload failed']); + // Add image to upload queue and send POST request to upload image + $videoThumbnailEl.find('.upload-image-input') + .fileupload('add', {files: [createFakeImageFile(VIDEO_IMAGE_MAX_BYTES + 10)]}); + + // Verify error message + expect($videoListEl.find('.thumbnail-error-wrapper').find('.action-text').html() + .trim()).toEqual( + 'The selected image must be smaller than ' + + videoThumbnailView.getVideoImageMaxSize().humanize + '.' + ); + }); + + it('should not show error message when file size is equals to minimum file size', function() { + videoThumbnailView.chooseFile(); + + // Add image to upload queue and send POST request to upload image + $videoThumbnailEl.find('.upload-image-input') + .fileupload('add', {files: [createFakeImageFile(VIDEO_IMAGE_MIN_BYTES)]}); + + // Verify error not present. + expect($videoListEl.find('.thumbnail-error-wrapper')).not.toExist(); + }); + + it('should not show error message when file size is equals to maximum file size', function() { + videoThumbnailView.chooseFile(); + + // Add image to upload queue and send POST request to upload image + $videoThumbnailEl.find('.upload-image-input') + .fileupload('add', {files: [createFakeImageFile(VIDEO_IMAGE_MAX_BYTES)]}); + + // Verify error not present. + expect($videoListEl.find('.thumbnail-error-wrapper')).not.toExist(); + }); + + it('should show error message when file has unsupported content type', function() { + videoThumbnailView.chooseFile(); + + // Add image to upload queue and send POST request to upload image + $videoThumbnailEl.find('.upload-image-input') + .fileupload('add', {files: [createFakeImageFile(VIDEO_IMAGE_MIN_BYTES, 'mov/mp4')]}); + + // Verify error message + expect($videoListEl.find('.thumbnail-error-wrapper').find('.action-text').html() + .trim()).toEqual( + 'This image file type is not supported. Supported file types are ' + + videoThumbnailView.getVideoImageSupportedFileFormats().humanize + '.' + ); + }); + + it('should not show error message when file has supported content type', function() { + videoThumbnailView.chooseFile(); + + // Add image to upload queue and send POST request to upload image + $videoThumbnailEl.find('.upload-image-input') + .fileupload('add', {files: [createFakeImageFile(VIDEO_IMAGE_MIN_BYTES)]}); + + // Verify error message is not present + expect($videoListEl.find('.thumbnail-error-wrapper')).not.toExist(); }); }); } diff --git a/cms/static/js/views/previous_video_upload.js b/cms/static/js/views/previous_video_upload.js index bdea422af1..7961dc196d 100644 --- a/cms/static/js/views/previous_video_upload.js +++ b/cms/static/js/views/previous_video_upload.js @@ -20,7 +20,8 @@ define( this.videoThumbnailView = new VideoThumbnailView({ model: this.model, imageUploadURL: options.videoImageUploadURL, - defaultVideoImageURL: options.defaultVideoImageURL + defaultVideoImageURL: options.defaultVideoImageURL, + videoImageSettings: options.videoImageSettings }); }, diff --git a/cms/static/js/views/previous_video_upload_list.js b/cms/static/js/views/previous_video_upload_list.js index 31e9b543f6..c842a2a3c8 100644 --- a/cms/static/js/views/previous_video_upload_list.js +++ b/cms/static/js/views/previous_video_upload_list.js @@ -14,6 +14,7 @@ define( videoImageUploadURL: options.videoImageUploadURL, defaultVideoImageURL: options.defaultVideoImageURL, videoHandlerUrl: options.videoHandlerUrl, + videoImageSettings: options.videoImageSettings, model: model }); }); diff --git a/cms/static/js/views/video_thumbnail.js b/cms/static/js/views/video_thumbnail.js index ee94c27978..40c08a4e45 100644 --- a/cms/static/js/views/video_thumbnail.js +++ b/cms/static/js/views/video_thumbnail.js @@ -1,41 +1,14 @@ define( ['underscore', 'gettext', 'moment', 'js/utils/date_utils', 'js/views/baseview', 'common/js/components/utils/view_utils', 'edx-ui-toolkit/js/utils/html-utils', - 'edx-ui-toolkit/js/utils/string-utils', 'text!templates/video-thumbnail.underscore'], - function(_, gettext, moment, DateUtils, BaseView, ViewUtils, HtmlUtils, StringUtils, VideoThumbnailTemplate) { + 'edx-ui-toolkit/js/utils/string-utils', 'text!templates/video-thumbnail.underscore', + 'text!templates/video-thumbnail-error.underscore'], + function(_, gettext, moment, DateUtils, BaseView, ViewUtils, HtmlUtils, StringUtils, VideoThumbnailTemplate, + VideoThumbnailErrorTemplate) { 'use strict'; var VideoThumbnailView = BaseView.extend({ - actionsInfo: { - upload: { - icon: '', - text: gettext('Add Thumbnail') - }, - edit: { - icon: '', - text: gettext('Edit Thumbnail') - }, - error: { - icon: '', - text: gettext('Image upload failed') - }, - progress: { - icon: '', - text: gettext('Uploading') - }, - requirements: { - icon: '', - text: HtmlUtils.interpolateHtml( - // Translators: This is a 3 part text which tells the image requirements. - gettext('Image requirements: {lineBreak} 1280px by 720px {lineBreak} .jpg, .png, or .gif'), - { - lineBreak: HtmlUtils.HTML('