diff --git a/cms/djangoapps/contentstore/views/tests/test_videos.py b/cms/djangoapps/contentstore/views/tests/test_videos.py index 0b3ea1214f..d02835a7a2 100644 --- a/cms/djangoapps/contentstore/views/tests/test_videos.py +++ b/cms/djangoapps/contentstore/views/tests/test_videos.py @@ -210,6 +210,7 @@ class VideosHandlerTestCase(VideoUploadTestMixin, CourseTestCase): response = self.client.get(self.url) self.assertEqual(response.status_code, 200) self.assertRegexpMatches(response["Content-Type"], "^text/html(;.*)?$") + self.assertIn(_get_default_video_image_url(), response.content) # Crude check for presence of data in returned HTML for video in self.previous_uploads: self.assertIn(video["edx_video_id"], response.content) @@ -586,12 +587,11 @@ class VideoImageTestCase(VideoUploadTestBase, CourseTestCase): response = json.loads(response.content) self.assertEqual(response['error'], 'No file provided for video image') - def test_default_video_image(self): + def test_no_video_image(self): """ - Test default video image. + Test image url is set to None if no video image. """ edx_video_id = 'test1' - default_video_image_url = _get_default_video_image_url() get_videos_url = reverse_course_url('videos_handler', self.course.id) video_image_upload_url = self.get_url_for_course_key(self.course.id, {'edx_video_id': edx_video_id}) with make_image_file() as image_file: @@ -606,7 +606,7 @@ class VideoImageTestCase(VideoUploadTestBase, CourseTestCase): if response_video['edx_video_id'] == edx_video_id: self.assertEqual(response_video['course_video_image_url'], val_image_url) else: - self.assertEqual(response_video['course_video_image_url'], default_video_image_url) + self.assertEqual(response_video['course_video_image_url'], None) @patch.dict("django.conf.settings.FEATURES", {"ENABLE_VIDEO_UPLOAD_PIPELINE": True}) diff --git a/cms/djangoapps/contentstore/views/videos.py b/cms/djangoapps/contentstore/views/videos.py index 64e1d254fa..81b8f4e73c 100644 --- a/cms/djangoapps/contentstore/views/videos.py +++ b/cms/djangoapps/contentstore/views/videos.py @@ -336,7 +336,6 @@ def _get_index_videos(course): Returns the information about each video upload required for the video list """ course_id = unicode(course.id) - default_video_image_url = _get_default_video_image_url() attrs = ['edx_video_id', 'client_video_id', 'created', 'duration', 'status', 'courses'] def _get_values(video): @@ -347,8 +346,7 @@ def _get_index_videos(course): for attr in attrs: if attr == 'courses': course = filter(lambda c: course_id in c, video['courses']) - (__, image_url), = course[0].items() - values['course_video_image_url'] = image_url or default_video_image_url + (__, values['course_video_image_url']), = course[0].items() else: values[attr] = video[attr] @@ -370,6 +368,7 @@ def videos_index_html(course): 'image_upload_url': reverse_course_url('video_images_handler', unicode(course.id)), 'video_handler_url': reverse_course_url('videos_handler', unicode(course.id)), 'encodings_download_url': reverse_course_url('video_encodings_download', unicode(course.id)), + 'default_video_image_url': _get_default_video_image_url(), 'previous_uploads': _get_index_videos(course), 'concurrent_upload_limit': settings.VIDEO_UPLOAD_PIPELINE.get('CONCURRENT_UPLOAD_LIMIT', 0), 'video_supported_file_formats': VIDEO_SUPPORTED_FILE_FORMATS.keys(), diff --git a/cms/envs/common.py b/cms/envs/common.py index 484d216db9..fa74242274 100644 --- a/cms/envs/common.py +++ b/cms/envs/common.py @@ -1349,4 +1349,4 @@ PROFILE_IMAGE_SIZES_MAP = { ###################### VIDEO IMAGE STORAGE ###################### -VIDEO_IMAGE_DEFAULT_FILENAME = 'default_video_image.png' +VIDEO_IMAGE_DEFAULT_FILENAME = 'images/video-images/default_video_image.png' diff --git a/cms/envs/test.py b/cms/envs/test.py index 332a369891..0e6b5ad465 100644 --- a/cms/envs/test.py +++ b/cms/envs/test.py @@ -342,6 +342,6 @@ VIDEO_IMAGE_SETTINGS = dict( location=MEDIA_ROOT, base_url=MEDIA_URL, ), - DIRECTORY_PREFIX='videoimage/', + DIRECTORY_PREFIX='video-images/', ) VIDEO_IMAGE_DEFAULT_FILENAME = 'default_video_image.png' diff --git a/cms/static/cms/js/spec/main.js b/cms/static/cms/js/spec/main.js index 93298c1470..0085de004e 100644 --- a/cms/static/cms/js/spec/main.js +++ b/cms/static/cms/js/spec/main.js @@ -258,6 +258,7 @@ 'js/spec/utils/module_spec', 'js/spec/views/active_video_upload_list_spec', 'js/spec/views/previous_video_upload_spec', + 'js/spec/views/video_thumbnail_spec', 'js/spec/views/previous_video_upload_list_spec', 'js/spec/views/assets_spec', 'js/spec/views/baseview_spec', diff --git a/cms/static/images/video-images/default_video_image.png b/cms/static/images/video-images/default_video_image.png new file mode 100644 index 0000000000..f331805aec Binary files /dev/null and b/cms/static/images/video-images/default_video_image.png differ diff --git a/cms/static/js/factories/videos_index.js b/cms/static/js/factories/videos_index.js index 3db118037f..07a193e7bb 100644 --- a/cms/static/js/factories/videos_index.js +++ b/cms/static/js/factories/videos_index.js @@ -5,8 +5,10 @@ define([ 'use strict'; var VideosIndexFactory = function( $contentWrapper, + videoImageUploadURL, videoHandlerUrl, encodingsDownloadUrl, + defaultVideoImageURL, concurrentUploadLimit, uploadButton, previousUploads, @@ -34,6 +36,8 @@ define([ isActive[0].get('status') === ActiveVideoUpload.STATUS_COMPLETE; }), updatedView = new PreviousVideoUploadListView({ + videoImageUploadURL: videoImageUploadURL, + defaultVideoImageURL: defaultVideoImageURL, videoHandlerUrl: videoHandlerUrl, collection: updatedCollection, encodingsDownloadUrl: encodingsDownloadUrl @@ -43,6 +47,8 @@ define([ } }), previousView = new PreviousVideoUploadListView({ + videoImageUploadURL: videoImageUploadURL, + defaultVideoImageURL: defaultVideoImageURL, videoHandlerUrl: videoHandlerUrl, collection: new Backbone.Collection(previousUploads), encodingsDownloadUrl: encodingsDownloadUrl 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 e6155b7572..2b0c164863 100644 --- a/cms/static/js/spec/views/previous_video_upload_spec.js +++ b/cms/static/js/spec/views/previous_video_upload_spec.js @@ -30,24 +30,6 @@ define( expect($el.find('.name-col').text()).toEqual(testName); }); - _.each( - [ - {desc: 'zero as pending', seconds: 0, expected: 'Pending'}, - {desc: 'less than one second as zero', seconds: 0.75, expected: '0:00'}, - {desc: 'with minutes and without seconds', seconds: 900, expected: '15:00'}, - {desc: 'with seconds and without minutes', seconds: 15, expected: '0:15'}, - {desc: 'with minutes and seconds', seconds: 915, expected: '15:15'}, - {desc: 'with seconds padded', seconds: 5, expected: '0:05'}, - {desc: 'longer than an hour as many minutes', seconds: 7425, expected: '123:45'} - ], - function(caseInfo) { - it('should render duration ' + caseInfo.desc, function() { - var $el = render({duration: caseInfo.seconds}); - expect($el.find('.duration-col').text()).toEqual(caseInfo.expected); - }); - } - ); - it('should render created timestamp correctly', function() { var fakeDate = 'fake formatted date'; spyOn(Date.prototype, 'toLocaleString').and.callFake( diff --git a/cms/static/js/spec/views/video_thumbnail_spec.js b/cms/static/js/spec/views/video_thumbnail_spec.js new file mode 100644 index 0000000000..c62eae63de --- /dev/null +++ b/cms/static/js/spec/views/video_thumbnail_spec.js @@ -0,0 +1,187 @@ +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) { + 'use strict'; + describe('VideoThumbnailView', function() { + var IMAGE_UPLOAD_URL = '/videos/upload/image', + UPLOADED_IMAGE_URL = 'images/upload_success.jpg', + videoThumbnailView, + createFakeImageFile, + verifyStateInfo, + render = function(modelData) { + var 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; + }; + + createFakeImageFile = function(size) { + var fileFakeData = 'i63ljc6giwoskyb9x5sw0169bdcmcxr3cdz8boqv0lik971972cmd6yknvcxr5sw0nvc169bdcmcxsdf'; + return new Blob( + [fileFakeData.substr(0, size)], + {type: 'image/jpg'} + ); + }; + + verifyStateInfo = function($thumbnail, state, onHover, additionalSRText) { + var beforeIcon, + beforeText; + + // Verify hover message, save the text before hover to verify later + if (onHover) { + beforeIcon = $thumbnail.find('.action-icon').html().trim(); + beforeText = $thumbnail.find('.action-text').html().trim(); + $thumbnail.trigger('mouseover'); + } + + if (additionalSRText) { + expect( + $thumbnail.find('.thumbnail-action .action-text-sr').text().trim() + ).toEqual(additionalSRText); + } + + expect($thumbnail.find('.action-icon').html().trim()).toEqual( + videoThumbnailView.actionsInfo[state].icon + ); + expect($thumbnail.find('.action-text').html().trim()).toEqual( + videoThumbnailView.actionsInfo[state].text + ); + + // Verify if messages are restored after focus moved away + if (onHover) { + $thumbnail.trigger('mouseout'); + expect($thumbnail.find('.action-icon').html().trim()).toEqual(beforeIcon); + expect($thumbnail.find('.action-text').html().trim()).toEqual(beforeText); + } + }; + + beforeEach(function() { + setFixtures('
'); + TemplateHelpers.installTemplate('video-thumbnail'); + }); + + it('renders as expected', function() { + var $el = render({}); + expect($el.find('.thumbnail-wrapper')).toExist(); + expect($el.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(); + }); + + it('shows the duration if available', function() { + var $el = render({}), + $duration = $el.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'); + }); + + it('calculates duration correctly', function() { + var durations = [ + {duration: -1}, + {duration: 0}, + {duration: 0.75, machine: '0:00', humanize: ''}, + {duration: 5, machine: '0:05', humanize: 'Video duration is 5 seconds'}, + {duration: 103, machine: '1:43', humanize: 'Video duration is 1 minute and 43 seconds'}, + {duration: 120, machine: '2:00', humanize: 'Video duration is 2 minutes'}, + {duration: 500, machine: '8:20', humanize: 'Video duration is 8 minutes and 20 seconds'}, + {duration: 7425, machine: '123:45', humanize: 'Video duration is 123 minutes and 45 seconds'} + ], + expectedDuration; + + durations.forEach(function(item) { + expectedDuration = videoThumbnailView.getDuration(item.duration); + if (item.duration <= 0) { + expect(expectedDuration).toEqual(null); + } else { + expect(expectedDuration.machine).toEqual(item.machine); + expect(expectedDuration.humanize).toEqual(item.humanize); + } + }); + }); + + it('can upload image', function() { + var $el = render({}), + $thumbnail = $el.find('.thumbnail-wrapper'), + requests = AjaxHelpers.requests(this), + additionalSRText = videoThumbnailView.getSRText(); + + videoThumbnailView.chooseFile(); + + verifyStateInfo($thumbnail, 'upload'); + 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)]}); + + verifyStateInfo($thumbnail, 'progress'); + + // Verify if POST request received for image upload + AjaxHelpers.expectRequest(requests, 'POST', IMAGE_UPLOAD_URL + '/dummy_id', new FormData()); + + // Send successful upload response + AjaxHelpers.respondWithJson(requests, {image_url: UPLOADED_IMAGE_URL}); + + verifyStateInfo($thumbnail, 'edit', true); + + // Verify uploaded image src + expect($thumbnail.find('img').attr('src')).toEqual(UPLOADED_IMAGE_URL); + }); + + it('shows error state correctly', function() { + var $el = render({}), + $thumbnail = $el.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)]}); + + 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); + + videoThumbnailView.chooseFile(); + + // Add image to upload queue and send POST request to upload image + $el.find('.upload-image-input').fileupload('add', {files: [createFakeImageFile(60)]}); + + AjaxHelpers.respondWithError(requests); + + expect($('#notification-error-title').text().trim()).toEqual( + "Studio's having trouble saving your work" + ); + }); + + it('calls readMessage with correct message', function() { + spyOn(videoThumbnailView, 'readMessages'); + + 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']); + }); + }); + } +); diff --git a/cms/static/js/views/previous_video_upload.js b/cms/static/js/views/previous_video_upload.js index 430d7e680c..bdea422af1 100644 --- a/cms/static/js/views/previous_video_upload.js +++ b/cms/static/js/views/previous_video_upload.js @@ -1,8 +1,9 @@ define( ['underscore', 'gettext', 'js/utils/date_utils', 'js/views/baseview', 'common/js/components/views/feedback_prompt', - 'common/js/components/views/feedback_notification', '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, ViewUtils, HtmlUtils, + 'common/js/components/views/feedback_notification', 'js/views/video_thumbnail', + '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) { 'use strict'; @@ -16,22 +17,15 @@ define( initialize: function(options) { this.template = HtmlUtils.template(previousVideoUploadTemplate); this.videoHandlerUrl = options.videoHandlerUrl; - }, - - renderDuration: function(seconds) { - var minutes = Math.floor(seconds / 60); - var seconds = Math.floor(seconds - minutes * 60); - - return minutes + ':' + (seconds < 10 ? '0' : '') + seconds; + this.videoThumbnailView = new VideoThumbnailView({ + model: this.model, + imageUploadURL: options.videoImageUploadURL, + defaultVideoImageURL: options.defaultVideoImageURL + }); }, render: function() { - var duration = this.model.get('duration'); var renderedAttributes = { - // Translators: This is listed as the duration for a video - // that has not yet reached the point in its processing by - // the servers where its duration is determined. - duration: duration > 0 ? this.renderDuration(duration) : gettext('Pending'), created: DateUtils.renderDate(this.model.get('created')), status: this.model.get('status') }; @@ -41,6 +35,7 @@ define( _.extend({}, this.model.attributes, renderedAttributes) ) ); + this.videoThumbnailView.setElement(this.$('.thumbnail-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 819e66695c..31e9b543f6 100644 --- a/cms/static/js/views/previous_video_upload_list.js +++ b/cms/static/js/views/previous_video_upload_list.js @@ -11,6 +11,8 @@ define( this.encodingsDownloadUrl = options.encodingsDownloadUrl; this.itemViews = this.collection.map(function(model) { return new PreviousVideoUploadView({ + videoImageUploadURL: options.videoImageUploadURL, + defaultVideoImageURL: options.defaultVideoImageURL, videoHandlerUrl: options.videoHandlerUrl, model: model }); diff --git a/cms/static/js/views/video_thumbnail.js b/cms/static/js/views/video_thumbnail.js new file mode 100644 index 0000000000..ec757a891e --- /dev/null +++ b/cms/static/js/views/video_thumbnail.js @@ -0,0 +1,225 @@ +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) { + '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 | .gif'), + { + lineBreak: HtmlUtils.HTML('| <%- gettext("Name") %> | -<%- gettext("Duration") %> | -<%- gettext("Date Added") %> | -<%- gettext("Video ID") %> | -<%- gettext("Status") %> | -<%- gettext("Action") %> | +<%- gettext("Thumbnail") %> | +<%- gettext("Name") %> | +<%- gettext("Date Added") %> | +<%- gettext("Video ID") %> | +<%- gettext("Status") %> | +<%- gettext("Action") %> | <%- client_video_id %> | -<%- duration %> | <%- created %> | <%- edx_video_id %> | <%- status %> | diff --git a/cms/templates/js/video-thumbnail.underscore b/cms/templates/js/video-thumbnail.underscore new file mode 100644 index 0000000000..dae21fe398 --- /dev/null +++ b/cms/templates/js/video-thumbnail.underscore @@ -0,0 +1,21 @@ +
|---|