Commit 95b3055f by Mushtaq Ali Committed by GitHub

Merge pull request #15178 from edx/course-video-image

Course video image
parents 7b910953 d2b420a9
......@@ -1975,7 +1975,7 @@ class RerunCourseTest(ContentStoreTestCase):
create_video(
dict(
edx_video_id="tree-hugger",
courses=[source_course.id],
courses=[unicode(source_course.id)],
status='test',
duration=2,
encoded_videos=[]
......
......@@ -6,24 +6,52 @@ import csv
import json
import re
from datetime import datetime
from functools import wraps
from StringIO import StringIO
import dateutil.parser
import ddt
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
from edxval.api import create_profile, create_video, get_video_info, get_course_video_image_url
from mock import Mock, patch
from contentstore.models import VideoUploadConfig
from contentstore.tests.utils import CourseTestCase
from contentstore.utils import reverse_course_url
from contentstore.views.videos import (
_get_default_video_image_url,
validate_video_image,
VIDEO_IMAGE_UPLOAD_ENABLED,
WAFFLE_SWITCHES,
)
from contentstore.views.videos import KEY_EXPIRATION_IN_SECONDS, StatusDisplayStrings, convert_video_status
from xmodule.modulestore.tests.factories import CourseFactory
from openedx.core.djangoapps.profile_images.tests.helpers import make_image_file
class VideoUploadTestMixin(object):
def override_switch(switch, active):
"""
Overrides the given waffle switch to `active` boolean.
Arguments:
switch(str): switch name
active(bool): A boolean representing (to be overridden) value
"""
def decorate(function):
@wraps(function)
def inner(*args, **kwargs):
with WAFFLE_SWITCHES.override(switch, active=active):
function(*args, **kwargs)
return inner
return decorate
class VideoUploadTestBase(object):
"""
Test cases for the video upload feature
"""
......@@ -32,7 +60,7 @@ class VideoUploadTestMixin(object):
return reverse_course_url(self.VIEW_NAME, course_key, kwargs)
def setUp(self):
super(VideoUploadTestMixin, self).setUp()
super(VideoUploadTestBase, self).setUp()
self.url = self.get_url_for_course_key(self.course.id)
self.test_token = "test_token"
self.course.video_upload_pipeline = {
......@@ -131,6 +159,11 @@ class VideoUploadTestMixin(object):
if video["edx_video_id"] == edx_video_id
)
class VideoUploadTestMixin(VideoUploadTestBase):
"""
Test cases for the video upload feature
"""
def test_anon_user(self):
self.client.logout()
response = self.client.get(self.url)
......@@ -171,25 +204,25 @@ class VideoUploadTestMixin(object):
class VideosHandlerTestCase(VideoUploadTestMixin, CourseTestCase):
"""Test cases for the main video upload endpoint"""
VIEW_NAME = "videos_handler"
VIEW_NAME = 'videos_handler'
def test_get_json(self):
response = self.client.get_json(self.url)
self.assertEqual(response.status_code, 200)
response_videos = json.loads(response.content)["videos"]
response_videos = json.loads(response.content)['videos']
self.assertEqual(len(response_videos), len(self.previous_uploads))
for i, response_video in enumerate(response_videos):
# Videos should be returned by creation date descending
original_video = self.previous_uploads[-(i + 1)]
self.assertEqual(
set(response_video.keys()),
set(["edx_video_id", "client_video_id", "created", "duration", "status"])
set(['edx_video_id', 'client_video_id', 'created', 'duration', 'status', 'course_video_image_url'])
)
dateutil.parser.parse(response_video["created"])
for field in ["edx_video_id", "client_video_id", "duration"]:
dateutil.parser.parse(response_video['created'])
for field in ['edx_video_id', 'client_video_id', 'duration']:
self.assertEqual(response_video[field], original_video[field])
self.assertEqual(
response_video["status"],
response_video['status'],
convert_video_status(original_video)
)
......@@ -197,6 +230,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)
......@@ -313,26 +347,26 @@ class VideosHandlerTestCase(VideoUploadTestMixin, CourseTestCase):
response = json.loads(response.content)
self.assertEqual(response['error'], 'The file name for %s must contain only ASCII characters.' % file_name)
@override_settings(AWS_ACCESS_KEY_ID="test_key_id", AWS_SECRET_ACCESS_KEY="test_secret")
@patch("boto.s3.key.Key")
@patch("boto.s3.connection.S3Connection")
@override_settings(AWS_ACCESS_KEY_ID='test_key_id', AWS_SECRET_ACCESS_KEY='test_secret')
@patch('boto.s3.key.Key')
@patch('boto.s3.connection.S3Connection')
def test_post_success(self, mock_conn, mock_key):
files = [
{
"file_name": "first.mp4",
"content_type": "video/mp4",
'file_name': 'first.mp4',
'content_type': 'video/mp4',
},
{
"file_name": "second.mp4",
"content_type": "video/mp4",
'file_name': 'second.mp4',
'content_type': 'video/mp4',
},
{
"file_name": "third.mov",
"content_type": "video/quicktime",
'file_name': 'third.mov',
'content_type': 'video/quicktime',
},
{
"file_name": "fourth.mp4",
"content_type": "video/mp4",
'file_name': 'fourth.mp4',
'content_type': 'video/mp4',
},
]
......@@ -341,7 +375,7 @@ class VideosHandlerTestCase(VideoUploadTestMixin, CourseTestCase):
mock_key_instances = [
Mock(
generate_url=Mock(
return_value="http://example.com/url_{}".format(file_info["file_name"])
return_value='http://example.com/url_{}'.format(file_info['file_name'])
)
)
for file_info in files
......@@ -351,14 +385,14 @@ class VideosHandlerTestCase(VideoUploadTestMixin, CourseTestCase):
response = self.client.post(
self.url,
json.dumps({"files": files}),
content_type="application/json"
json.dumps({'files': files}),
content_type='application/json'
)
self.assertEqual(response.status_code, 200)
response_obj = json.loads(response.content)
mock_conn.assert_called_once_with(settings.AWS_ACCESS_KEY_ID, settings.AWS_SECRET_ACCESS_KEY)
self.assertEqual(len(response_obj["files"]), len(files))
self.assertEqual(len(response_obj['files']), len(files))
self.assertEqual(mock_key.call_count, len(files))
for i, file_info in enumerate(files):
# Ensure Key was set up correctly and extract id
......@@ -366,8 +400,8 @@ class VideosHandlerTestCase(VideoUploadTestMixin, CourseTestCase):
self.assertEqual(key_call_args[0], bucket)
path_match = re.match(
(
settings.VIDEO_UPLOAD_PIPELINE["ROOT_PATH"] +
"/([a-f0-9]{8}-[a-f0-9]{4}-4[a-f0-9]{3}-[89ab][a-f0-9]{3}-[a-f0-9]{12})$"
settings.VIDEO_UPLOAD_PIPELINE['ROOT_PATH'] +
'/([a-f0-9]{8}-[a-f0-9]{4}-4[a-f0-9]{3}-[89ab][a-f0-9]{3}-[a-f0-9]{12})$'
),
key_call_args[1]
)
......@@ -375,32 +409,32 @@ class VideosHandlerTestCase(VideoUploadTestMixin, CourseTestCase):
video_id = path_match.group(1)
mock_key_instance = mock_key_instances[i]
mock_key_instance.set_metadata.assert_any_call(
"course_video_upload_token",
'course_video_upload_token',
self.test_token
)
mock_key_instance.set_metadata.assert_any_call(
"client_video_id",
file_info["file_name"]
'client_video_id',
file_info['file_name']
)
mock_key_instance.set_metadata.assert_any_call("course_key", unicode(self.course.id))
mock_key_instance.set_metadata.assert_any_call('course_key', unicode(self.course.id))
mock_key_instance.generate_url.assert_called_once_with(
KEY_EXPIRATION_IN_SECONDS,
"PUT",
headers={"Content-Type": file_info["content_type"]}
'PUT',
headers={'Content-Type': file_info['content_type']}
)
# Ensure VAL was updated
val_info = get_video_info(video_id)
self.assertEqual(val_info["status"], "upload")
self.assertEqual(val_info["client_video_id"], file_info["file_name"])
self.assertEqual(val_info["status"], "upload")
self.assertEqual(val_info["duration"], 0)
self.assertEqual(val_info["courses"], [unicode(self.course.id)])
self.assertEqual(val_info['status'], 'upload')
self.assertEqual(val_info['client_video_id'], file_info['file_name'])
self.assertEqual(val_info['status'], 'upload')
self.assertEqual(val_info['duration'], 0)
self.assertEqual(val_info['courses'], [{unicode(self.course.id): None}])
# Ensure response is correct
response_file = response_obj["files"][i]
self.assertEqual(response_file["file_name"], file_info["file_name"])
self.assertEqual(response_file["upload_url"], mock_key_instance.generate_url())
response_file = response_obj['files'][i]
self.assertEqual(response_file['file_name'], file_info['file_name'])
self.assertEqual(response_file['upload_url'], mock_key_instance.generate_url())
def _assert_video_removal(self, url, edx_video_id, deleted_videos):
"""
......@@ -518,6 +552,308 @@ class VideosHandlerTestCase(VideoUploadTestMixin, CourseTestCase):
self.assert_video_status(url, edx_video_id, 'Failed')
@ddt.ddt
@patch.dict('django.conf.settings.FEATURES', {'ENABLE_VIDEO_UPLOAD_PIPELINE': True})
@override_settings(VIDEO_UPLOAD_PIPELINE={'BUCKET': 'test_bucket', 'ROOT_PATH': 'test_root'})
class VideoImageTestCase(VideoUploadTestBase, CourseTestCase):
"""
Tests for video image.
"""
VIEW_NAME = "video_images_handler"
def verify_image_upload_reponse(self, course_id, edx_video_id, upload_response):
"""
Verify that image is uploaded successfully.
Arguments:
course_id: ID of course
edx_video_id: ID of video
upload_response: Upload response object
Returns:
uploaded image url
"""
self.assertEqual(upload_response.status_code, 200)
response = json.loads(upload_response.content)
val_image_url = get_course_video_image_url(course_id=course_id, edx_video_id=edx_video_id)
self.assertEqual(response['image_url'], val_image_url)
return val_image_url
def verify_error_message(self, response, error_message):
"""
Verify that image upload failure gets proper error message.
Arguments:
response: Response object.
error_message: Expected error message.
"""
self.assertEqual(response.status_code, 400)
response = json.loads(response.content)
self.assertIn('error', response)
self.assertEqual(response['error'], error_message)
@override_switch(VIDEO_IMAGE_UPLOAD_ENABLED, False)
def test_video_image_upload_disabled(self):
"""
Tests the video image upload when the feature is disabled.
"""
video_image_upload_url = self.get_url_for_course_key(self.course.id, {'edx_video_id': 'test_vid_id'})
response = self.client.post(video_image_upload_url, {'file': 'dummy_file'}, format='multipart')
self.assertEqual(response.status_code, 404)
@override_switch(VIDEO_IMAGE_UPLOAD_ENABLED, True)
def test_video_image(self):
"""
Test video image is saved.
"""
edx_video_id = 'test1'
video_image_upload_url = self.get_url_for_course_key(self.course.id, {'edx_video_id': edx_video_id})
with make_image_file(
dimensions=(settings.VIDEO_IMAGE_MIN_WIDTH, settings.VIDEO_IMAGE_MIN_HEIGHT),
) as image_file:
response = self.client.post(video_image_upload_url, {'file': image_file}, format='multipart')
image_url1 = self.verify_image_upload_reponse(self.course.id, edx_video_id, response)
# upload again to verify that new image is uploaded successfully
with make_image_file(
dimensions=(settings.VIDEO_IMAGE_MIN_WIDTH, settings.VIDEO_IMAGE_MIN_HEIGHT),
) as image_file:
response = self.client.post(video_image_upload_url, {'file': image_file}, format='multipart')
image_url2 = self.verify_image_upload_reponse(self.course.id, edx_video_id, response)
self.assertNotEqual(image_url1, image_url2)
@override_switch(VIDEO_IMAGE_UPLOAD_ENABLED, True)
def test_video_image_no_file(self):
"""
Test that an error error message is returned if upload request is incorrect.
"""
video_image_upload_url = self.get_url_for_course_key(self.course.id, {'edx_video_id': 'test1'})
response = self.client.post(video_image_upload_url, {})
self.verify_error_message(response, 'An image file is required.')
def test_invalid_image_file_info(self):
"""
Test that when no file information is provided to validate_video_image, it gives proper error message.
"""
error = validate_video_image({})
self.assertEquals(error, 'The image must have name, content type, and size information.')
def test_currupt_image_file(self):
"""
Test that when corrupt file is provided to validate_video_image, it gives proper error message.
"""
with open(settings.MEDIA_ROOT + '/test-corrupt-image.png', 'w+') as file:
image_file = UploadedFile(
file,
content_type='image/png',
size=settings.VIDEO_IMAGE_SETTINGS['VIDEO_IMAGE_MIN_BYTES']
)
error = validate_video_image(image_file)
self.assertEquals(error, 'There is a problem with this image file. Try to upload a different file.')
@override_switch(VIDEO_IMAGE_UPLOAD_ENABLED, True)
def test_no_video_image(self):
"""
Test image url is set to None if no video image.
"""
edx_video_id = 'test1'
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(
dimensions=(settings.VIDEO_IMAGE_MIN_WIDTH, settings.VIDEO_IMAGE_MIN_HEIGHT),
) as image_file:
self.client.post(video_image_upload_url, {'file': image_file}, format='multipart')
val_image_url = get_course_video_image_url(course_id=self.course.id, edx_video_id=edx_video_id)
response = self.client.get_json(get_videos_url)
self.assertEqual(response.status_code, 200)
response_videos = json.loads(response.content)["videos"]
for response_video in response_videos:
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'], None)
@ddt.data(
# Image file type validation
(
{
'extension': '.png'
},
None
),
(
{
'extension': '.gif'
},
None
),
(
{
'extension': '.bmp'
},
None
),
(
{
'extension': '.jpg'
},
None
),
(
{
'extension': '.jpeg'
},
None
),
(
{
'extension': '.PNG'
},
None
),
(
{
'extension': '.tiff'
},
'This image file type is not supported. Supported file types are {supported_file_formats}.'.format(
supported_file_formats=settings.VIDEO_IMAGE_SUPPORTED_FILE_FORMATS.keys()
)
),
# Image file size validation
(
{
'size': settings.VIDEO_IMAGE_SETTINGS['VIDEO_IMAGE_MAX_BYTES'] + 10
},
'This image file must be smaller than {image_max_size}.'.format(
image_max_size=settings.VIDEO_IMAGE_MAX_FILE_SIZE_MB
)
),
(
{
'size': settings.VIDEO_IMAGE_SETTINGS['VIDEO_IMAGE_MIN_BYTES'] - 10
},
'This image file must be larger than {image_min_size}.'.format(
image_min_size=settings.VIDEO_IMAGE_MIN_FILE_SIZE_KB
)
),
# Image file minimum width / height
(
{
'width': 16, # 16x9
'height': 9
},
'Recommended image resolution is {image_file_max_width}x{image_file_max_height}. The minimum resolution is {image_file_min_width}x{image_file_min_height}.'.format(
image_file_max_width=settings.VIDEO_IMAGE_MAX_WIDTH,
image_file_max_height=settings.VIDEO_IMAGE_MAX_HEIGHT,
image_file_min_width=settings.VIDEO_IMAGE_MIN_WIDTH,
image_file_min_height=settings.VIDEO_IMAGE_MIN_HEIGHT
)
),
(
{
'width': settings.VIDEO_IMAGE_MIN_WIDTH - 10,
'height': settings.VIDEO_IMAGE_MIN_HEIGHT
},
'Recommended image resolution is {image_file_max_width}x{image_file_max_height}. The minimum resolution is {image_file_min_width}x{image_file_min_height}.'.format(
image_file_max_width=settings.VIDEO_IMAGE_MAX_WIDTH,
image_file_max_height=settings.VIDEO_IMAGE_MAX_HEIGHT,
image_file_min_width=settings.VIDEO_IMAGE_MIN_WIDTH,
image_file_min_height=settings.VIDEO_IMAGE_MIN_HEIGHT
)
),
(
{
'width': settings.VIDEO_IMAGE_MIN_WIDTH,
'height': settings.VIDEO_IMAGE_MIN_HEIGHT - 10
},
'Recommended image resolution is {image_file_max_width}x{image_file_max_height}. The minimum resolution is {image_file_min_width}x{image_file_min_height}.'.format(
image_file_max_width=settings.VIDEO_IMAGE_MAX_WIDTH,
image_file_max_height=settings.VIDEO_IMAGE_MAX_HEIGHT,
image_file_min_width=settings.VIDEO_IMAGE_MIN_WIDTH,
image_file_min_height=settings.VIDEO_IMAGE_MIN_HEIGHT
)
),
(
{
'width': 1200, # not 16:9, but width/height check first.
'height': 100
},
'Recommended image resolution is {image_file_max_width}x{image_file_max_height}. The minimum resolution is {image_file_min_width}x{image_file_min_height}.'.format(
image_file_max_width=settings.VIDEO_IMAGE_MAX_WIDTH,
image_file_max_height=settings.VIDEO_IMAGE_MAX_HEIGHT,
image_file_min_width=settings.VIDEO_IMAGE_MIN_WIDTH,
image_file_min_height=settings.VIDEO_IMAGE_MIN_HEIGHT
)
),
# Image file aspect ratio validation
(
{
'width': settings.VIDEO_IMAGE_MAX_WIDTH, # 1280x720
'height': settings.VIDEO_IMAGE_MAX_HEIGHT
},
None
),
(
{
'width': 850, # 16:9
'height': 478
},
None
),
(
{
'width': 940, # 1.67 ratio, applicable aspect ratio margin of .01
'height': 560
},
None
),
(
{
'width': settings.VIDEO_IMAGE_MIN_WIDTH + 100,
'height': settings.VIDEO_IMAGE_MIN_HEIGHT + 200
},
'This image file must have an aspect ratio of {video_image_aspect_ratio_text}.'.format(
video_image_aspect_ratio_text=settings.VIDEO_IMAGE_ASPECT_RATIO_TEXT
)
),
# Image file name validation
(
{
'prefix': u'nøn-åßç¡¡'
},
'The image file name can only contain letters, numbers, hyphens (-), and underscores (_).'
)
)
@ddt.unpack
@override_switch(VIDEO_IMAGE_UPLOAD_ENABLED, True)
def test_video_image_validation_message(self, image_data, error_message):
"""
Test video image validation gives proper error message.
Arguments:
image_data (Dict): Specific data to create image file.
error_message (String): Error message
"""
edx_video_id = 'test1'
video_image_upload_url = self.get_url_for_course_key(self.course.id, {'edx_video_id': edx_video_id})
with make_image_file(
dimensions=(image_data.get('width', settings.VIDEO_IMAGE_MIN_WIDTH), image_data.get('height', settings.VIDEO_IMAGE_MIN_HEIGHT)),
prefix=image_data.get('prefix', 'videoimage'),
extension=image_data.get('extension', '.png'),
force_size=image_data.get('size', settings.VIDEO_IMAGE_SETTINGS['VIDEO_IMAGE_MIN_BYTES'])
) as image_file:
response = self.client.post(video_image_upload_url, {'file': image_file}, format='multipart')
if error_message:
self.verify_error_message(response, error_message)
else:
self.verify_image_upload_reponse(self.course.id, edx_video_id, response)
@patch.dict("django.conf.settings.FEATURES", {"ENABLE_VIDEO_UPLOAD_PIPELINE": True})
@override_settings(VIDEO_UPLOAD_PIPELINE={"BUCKET": "test_bucket", "ROOT_PATH": "test_root"})
class VideoUrlsCsvTestCase(VideoUploadTestMixin, CourseTestCase):
......
"""
Views related to the video upload feature
"""
from contextlib import closing
import csv
import logging
from datetime import datetime, timedelta
......@@ -10,19 +12,23 @@ import rfc6266
from boto import s3
from django.conf import settings
from django.contrib.auth.decorators import login_required
from django.contrib.staticfiles.storage import staticfiles_storage
from django.core.files.images import get_image_dimensions
from django.http import HttpResponse, HttpResponseNotFound
from django.utils.translation import ugettext as _
from django.utils.translation import ugettext_noop
from django.views.decorators.http import require_GET, require_http_methods
from django.views.decorators.http import require_GET, require_POST, require_http_methods
from edxval.api import (
SortDirection,
VideoSortField,
create_video,
get_videos_for_course,
remove_video_for_course,
update_video_status
update_video_status,
update_video_image
)
from opaque_keys.edx.keys import CourseKey
from openedx.core.djangoapps.waffle_utils import WaffleSwitchNamespace
from contentstore.models import VideoUploadConfig
from contentstore.utils import reverse_course_url
......@@ -31,10 +37,17 @@ from util.json_request import JsonResponse, expect_json
from .course import get_course_and_check_access
__all__ = ["videos_handler", "video_encodings_download"]
__all__ = ['videos_handler', 'video_encodings_download', 'video_images_handler']
LOGGER = logging.getLogger(__name__)
# Waffle switches namespace for videos
WAFFLE_NAMESPACE = 'videos'
WAFFLE_SWITCHES = WaffleSwitchNamespace(name=WAFFLE_NAMESPACE)
# Waffle switch for enabling/disabling video image upload feature
VIDEO_IMAGE_UPLOAD_ENABLED = 'video_image_upload_enabled'
# Default expiration, in seconds, of one-time URLs used for uploading videos.
KEY_EXPIRATION_IN_SECONDS = 86400
......@@ -145,6 +158,84 @@ def videos_handler(request, course_key_string, edx_video_id=None):
return videos_post(course, request)
def validate_video_image(image_file):
"""
Validates video image file.
Arguments:
image_file: The selected image file.
Returns:
error (String or None): If there is error returns error message otherwise None.
"""
error = None
if not all(hasattr(image_file, attr) for attr in ['name', 'content_type', 'size']):
error = _('The image must have name, content type, and size information.')
elif image_file.content_type not in settings.VIDEO_IMAGE_SUPPORTED_FILE_FORMATS.values():
error = _('This image file type is not supported. Supported file types are {supported_file_formats}.').format(
supported_file_formats=settings.VIDEO_IMAGE_SUPPORTED_FILE_FORMATS.keys()
)
elif image_file.size > settings.VIDEO_IMAGE_SETTINGS['VIDEO_IMAGE_MAX_BYTES']:
error = _('This image file must be smaller than {image_max_size}.').format(
image_max_size=settings.VIDEO_IMAGE_MAX_FILE_SIZE_MB
)
elif image_file.size < settings.VIDEO_IMAGE_SETTINGS['VIDEO_IMAGE_MIN_BYTES']:
error = _('This image file must be larger than {image_min_size}.').format(
image_min_size=settings.VIDEO_IMAGE_MIN_FILE_SIZE_KB
)
else:
try:
image_file_width, image_file_height = get_image_dimensions(image_file)
except TypeError:
return _('There is a problem with this image file. Try to upload a different file.')
image_file_aspect_ratio = abs(image_file_width / float(image_file_height) - settings.VIDEO_IMAGE_ASPECT_RATIO)
if image_file_width < settings.VIDEO_IMAGE_MIN_WIDTH or image_file_height < settings.VIDEO_IMAGE_MIN_HEIGHT:
error = _('Recommended image resolution is {image_file_max_width}x{image_file_max_height}. '
'The minimum resolution is {image_file_min_width}x{image_file_min_height}.').format(
image_file_max_width=settings.VIDEO_IMAGE_MAX_WIDTH,
image_file_max_height=settings.VIDEO_IMAGE_MAX_HEIGHT,
image_file_min_width=settings.VIDEO_IMAGE_MIN_WIDTH,
image_file_min_height=settings.VIDEO_IMAGE_MIN_HEIGHT
)
elif image_file_aspect_ratio > settings.VIDEO_IMAGE_ASPECT_RATIO_ERROR_MARGIN:
error = _('This image file must have an aspect ratio of {video_image_aspect_ratio_text}.').format(
video_image_aspect_ratio_text=settings.VIDEO_IMAGE_ASPECT_RATIO_TEXT
)
else:
try:
image_file.name.encode('ascii')
except UnicodeEncodeError:
error = _('The image file name can only contain letters, numbers, hyphens (-), and underscores (_).')
return error
@expect_json
@login_required
@require_POST
def video_images_handler(request, course_key_string, edx_video_id=None):
# respond with a 404 if image upload is not enabled.
if not WAFFLE_SWITCHES.is_enabled(VIDEO_IMAGE_UPLOAD_ENABLED):
return HttpResponseNotFound()
if 'file' not in request.FILES:
return JsonResponse({'error': _(u'An image file is required.')}, status=400)
image_file = request.FILES['file']
error = validate_video_image(image_file)
if error:
return JsonResponse({'error': error}, status=400)
with closing(image_file):
image_url = update_video_image(edx_video_id, course_key_string, image_file, image_file.name)
LOGGER.info(
'VIDEOS: Video image uploaded for edx_video_id [%s] in course [%s]', edx_video_id, course_key_string
)
return JsonResponse({'image_url': image_url})
@login_required
@require_GET
def video_encodings_download(request, course_key_string):
......@@ -296,17 +387,37 @@ def _get_videos(course):
return videos
def _get_default_video_image_url():
"""
Returns default video image url
"""
return staticfiles_storage.url(settings.VIDEO_IMAGE_DEFAULT_FILENAME)
def _get_index_videos(course):
"""
Returns the information about each video upload required for the video list
"""
return list(
{
attr: video[attr]
for attr in ["edx_video_id", "client_video_id", "created", "duration", "status"]
}
for video in _get_videos(course)
)
course_id = unicode(course.id)
attrs = ['edx_video_id', 'client_video_id', 'created', 'duration', 'status', 'courses']
def _get_values(video):
"""
Get data for predefined video attributes.
"""
values = {}
for attr in attrs:
if attr == 'courses':
course = filter(lambda c: course_id in c, video['courses'])
(__, values['course_video_image_url']), = course[0].items()
else:
values[attr] = video[attr]
return values
return [
_get_values(video) for video in _get_videos(course)
]
def videos_index_html(course):
......@@ -314,15 +425,25 @@ def videos_index_html(course):
Returns an HTML page to display previous video uploads and allow new ones
"""
return render_to_response(
"videos_index.html",
'videos_index.html',
{
"context_course": course,
"video_handler_url": reverse_course_url("videos_handler", unicode(course.id)),
"encodings_download_url": reverse_course_url("video_encodings_download", unicode(course.id)),
"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
'context_course': course,
'image_upload_url': reverse_course_url('video_images_handler', unicode(course.id)),
'video_handler_url': reverse_course_url('videos_handler', unicode(course.id)),
'encodings_download_url': reverse_course_url('video_encodings_download', unicode(course.id)),
'default_video_image_url': _get_default_video_image_url(),
'previous_uploads': _get_index_videos(course),
'concurrent_upload_limit': settings.VIDEO_UPLOAD_PIPELINE.get('CONCURRENT_UPLOAD_LIMIT', 0),
'video_supported_file_formats': VIDEO_SUPPORTED_FILE_FORMATS.keys(),
'video_upload_max_file_size': VIDEO_UPLOAD_MAX_FILE_SIZE_GB,
'video_image_settings': {
'video_image_upload_enabled': WAFFLE_SWITCHES.is_enabled(VIDEO_IMAGE_UPLOAD_ENABLED),
'max_size': settings.VIDEO_IMAGE_SETTINGS['VIDEO_IMAGE_MAX_BYTES'],
'min_size': settings.VIDEO_IMAGE_SETTINGS['VIDEO_IMAGE_MIN_BYTES'],
'max_width': settings.VIDEO_IMAGE_MAX_WIDTH,
'max_height': settings.VIDEO_IMAGE_MAX_HEIGHT,
'supported_file_formats': settings.VIDEO_IMAGE_SUPPORTED_FILE_FORMATS
}
}
)
......@@ -331,12 +452,13 @@ def videos_index_json(course):
"""
Returns JSON in the following format:
{
"videos": [{
"edx_video_id": "aaaaaaaa-aaaa-4aaa-aaaa-aaaaaaaaaaaa",
"client_video_id": "video.mp4",
"created": "1970-01-01T00:00:00Z",
"duration": 42.5,
"status": "upload"
'videos': [{
'edx_video_id': 'aaaaaaaa-aaaa-4aaa-aaaa-aaaaaaaaaaaa',
'client_video_id': 'video.mp4',
'created': '1970-01-01T00:00:00Z',
'duration': 42.5,
'status': 'upload',
'course_video_image_url': 'https://video/images/1234.jpg'
}]
}
"""
......@@ -364,29 +486,29 @@ def videos_post(course, request):
The returned array corresponds exactly to the input array.
"""
error = None
if "files" not in request.json:
if 'files' not in request.json:
error = "Request object is not JSON or does not contain 'files'"
elif any(
"file_name" not in file or "content_type" not in file
for file in request.json["files"]
'file_name' not in file or 'content_type' not in file
for file in request.json['files']
):
error = "Request 'files' entry does not contain 'file_name' and 'content_type'"
elif any(
file['content_type'] not in VIDEO_SUPPORTED_FILE_FORMATS.values()
for file in request.json["files"]
for file in request.json['files']
):
error = "Request 'files' entry contain unsupported content_type"
if error:
return JsonResponse({"error": error}, status=400)
return JsonResponse({'error': error}, status=400)
bucket = storage_service_bucket()
course_video_upload_token = course.video_upload_pipeline["course_video_upload_token"]
req_files = request.json["files"]
course_video_upload_token = course.video_upload_pipeline['course_video_upload_token']
req_files = request.json['files']
resp_files = []
for req_file in req_files:
file_name = req_file["file_name"]
file_name = req_file['file_name']
try:
file_name.encode('ascii')
......@@ -397,30 +519,30 @@ def videos_post(course, request):
edx_video_id = unicode(uuid4())
key = storage_service_key(bucket, file_name=edx_video_id)
for metadata_name, value in [
("course_video_upload_token", course_video_upload_token),
("client_video_id", file_name),
("course_key", unicode(course.id)),
('course_video_upload_token', course_video_upload_token),
('client_video_id', file_name),
('course_key', unicode(course.id)),
]:
key.set_metadata(metadata_name, value)
upload_url = key.generate_url(
KEY_EXPIRATION_IN_SECONDS,
"PUT",
headers={"Content-Type": req_file["content_type"]}
'PUT',
headers={'Content-Type': req_file['content_type']}
)
# persist edx_video_id in VAL
create_video({
"edx_video_id": edx_video_id,
"status": "upload",
"client_video_id": file_name,
"duration": 0,
"encoded_videos": [],
"courses": [course.id]
'edx_video_id': edx_video_id,
'status': 'upload',
'client_video_id': file_name,
'duration': 0,
'encoded_videos': [],
'courses': [unicode(course.id)]
})
resp_files.append({"file_name": file_name, "upload_url": upload_url, "edx_video_id": edx_video_id})
resp_files.append({'file_name': file_name, 'upload_url': upload_url, 'edx_video_id': edx_video_id})
return JsonResponse({"files": resp_files}, status=200)
return JsonResponse({'files': resp_files}, status=200)
def storage_service_bucket():
......
......@@ -440,6 +440,10 @@ ADVANCED_PROBLEM_TYPES = ENV_TOKENS.get('ADVANCED_PROBLEM_TYPES', ADVANCED_PROBL
VIDEO_UPLOAD_PIPELINE = ENV_TOKENS.get('VIDEO_UPLOAD_PIPELINE', VIDEO_UPLOAD_PIPELINE)
################ VIDEO IMAGE STORAGE ###############
VIDEO_IMAGE_SETTINGS = ENV_TOKENS.get('VIDEO_IMAGE_SETTINGS', VIDEO_IMAGE_SETTINGS)
################ PUSH NOTIFICATIONS ###############
PARSE_KEYS = AUTH_TOKENS.get("PARSE_KEYS", {})
......
......@@ -103,6 +103,8 @@ from lms.envs.common import (
CONTACT_EMAIL,
DISABLE_ACCOUNT_ACTIVATION_REQUIREMENT_SWITCH,
# Video Image settings
VIDEO_IMAGE_SETTINGS,
)
from path import Path as path
from warnings import simplefilter
......@@ -1344,3 +1346,24 @@ PROFILE_IMAGE_SIZES_MAP = {
'medium': 50,
'small': 30
}
###################### VIDEO IMAGE STORAGE ######################
VIDEO_IMAGE_DEFAULT_FILENAME = 'images/video-images/default_video_image.png'
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'
}
VIDEO_IMAGE_MAX_FILE_SIZE_MB = '2 MB'
VIDEO_IMAGE_MIN_FILE_SIZE_KB = '2 KB'
VIDEO_IMAGE_MAX_WIDTH = 1280
VIDEO_IMAGE_MAX_HEIGHT = 720
VIDEO_IMAGE_MIN_WIDTH = 640
VIDEO_IMAGE_MIN_HEIGHT = 360
VIDEO_IMAGE_ASPECT_RATIO = 16 / 9.0
VIDEO_IMAGE_ASPECT_RATIO_TEXT = '16:9'
VIDEO_IMAGE_ASPECT_RATIO_ERROR_MARGIN = 0.1
......@@ -335,3 +335,15 @@ FEATURES['CUSTOM_COURSES_EDX'] = True
# API access management -- needed for simple-history to run.
INSTALLED_APPS += ('openedx.core.djangoapps.api_admin',)
########################## VIDEO IMAGE STORAGE ############################
VIDEO_IMAGE_SETTINGS = dict(
VIDEO_IMAGE_MAX_BYTES=2 * 1024 * 1024, # 2 MB
VIDEO_IMAGE_MIN_BYTES=2 * 1024, # 2 KB
STORAGE_KWARGS=dict(
location=MEDIA_ROOT,
base_url=MEDIA_URL,
),
DIRECTORY_PREFIX='video-images/',
)
VIDEO_IMAGE_DEFAULT_FILENAME = 'default_video_image.png'
......@@ -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',
......
......@@ -5,13 +5,16 @@ define([
'use strict';
var VideosIndexFactory = function(
$contentWrapper,
videoImageUploadURL,
videoHandlerUrl,
encodingsDownloadUrl,
defaultVideoImageURL,
concurrentUploadLimit,
uploadButton,
previousUploads,
videoSupportedFileFormats,
videoUploadMaxFileSizeInGB
videoUploadMaxFileSizeInGB,
videoImageSettings
) {
var activeView = new ActiveVideoUploadListView({
postUrl: videoHandlerUrl,
......@@ -19,6 +22,7 @@ define([
uploadButton: uploadButton,
videoSupportedFileFormats: videoSupportedFileFormats,
videoUploadMaxFileSizeInGB: videoUploadMaxFileSizeInGB,
videoImageSettings: videoImageSettings,
onFileUploadDone: function(activeVideos) {
$.ajax({
url: videoHandlerUrl,
......@@ -34,18 +38,24 @@ define([
isActive[0].get('status') === ActiveVideoUpload.STATUS_COMPLETE;
}),
updatedView = new PreviousVideoUploadListView({
videoImageUploadURL: videoImageUploadURL,
defaultVideoImageURL: defaultVideoImageURL,
videoHandlerUrl: videoHandlerUrl,
collection: updatedCollection,
encodingsDownloadUrl: encodingsDownloadUrl
encodingsDownloadUrl: encodingsDownloadUrl,
videoImageSettings: videoImageSettings
});
$contentWrapper.find('.wrapper-assets').replaceWith(updatedView.render().$el);
});
}
}),
previousView = new PreviousVideoUploadListView({
videoImageUploadURL: videoImageUploadURL,
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);
......
......@@ -25,7 +25,8 @@ define(
);
var view = new PreviousVideoUploadListView({
collection: collection,
videoHandlerUrl: videoHandlerUrl
videoHandlerUrl: videoHandlerUrl,
videoImageSettings: {}
});
return view.render().$el;
},
......@@ -43,10 +44,10 @@ define(
$el = render(numVideos),
firstVideoId = 'dummy_id_0',
requests = AjaxHelpers.requests(test),
firstVideoSelector = '.js-table-body tr:first-child';
firstVideoSelector = '.js-table-body .video-row:first-child';
// total number of videos should be 5 before remove
expect($el.find('.js-table-body tr').length).toEqual(numVideos);
expect($el.find('.js-table-body .video-row').length).toEqual(numVideos);
// get first video element
firstVideo = $el.find(firstVideoSelector);
......@@ -71,7 +72,7 @@ define(
}
// verify total number of videos after Remove/Cancel
expect($el.find('.js-table-body tr').length).toEqual(numVideos);
expect($el.find('.js-table-body .video-row').length).toEqual(numVideos);
// verify first video id after Remove/Cancel
firstVideo = $el.find(firstVideoSelector);
......@@ -81,13 +82,13 @@ define(
it('should render an empty collection', function() {
var $el = render(0);
expect($el.find('.js-table-body').length).toEqual(1);
expect($el.find('.js-table-body tr').length).toEqual(0);
expect($el.find('.js-table-body .video-row').length).toEqual(0);
});
it('should render a non-empty collection', function() {
var $el = render(5);
expect($el.find('.js-table-body').length).toEqual(1);
expect($el.find('.js-table-body tr').length).toEqual(5);
expect($el.find('.js-table-body .video-row').length).toEqual(5);
});
it('removes video upon click on Remove button', function() {
......
......@@ -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;
};
......@@ -30,24 +31,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(
......
define(
['jquery', 'underscore', 'backbone', 'edx-ui-toolkit/js/utils/spec-helpers/ajax-helpers',
'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;
/**
* Creates a list of video records.
*
* @param {Boolean} videoImageUploadEnabled Boolean indicating if the feature is enabled.
* @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(videoImageUploadEnabled, 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'
},
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,
video_image_upload_enabled: videoImageUploadEnabled
}
});
$videoListEl = videoListView.render().$el;
if (videoImageUploadEnabled) {
videoThumbnailView = videoListView.itemViews[videoViewIndex].videoThumbnailView;
$videoThumbnailEl = videoThumbnailView.render().$el;
}
return videoListView;
};
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) {
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);
}
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
);
// 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('<div id="page-prompt"></div><div id="page-notification"></div>');
TemplateHelpers.installTemplate('video-thumbnail');
TemplateHelpers.installTemplate('previous-video-upload-list');
createVideoListView(true);
});
it('Verifies that the ThumbnailView is not initialized on disabling the feature', function() {
createVideoListView(false);
expect(videoListView.itemViews[0].videoThumbnailView).toEqual(undefined);
});
it('renders as expected', function() {
expect($videoThumbnailEl.find('.thumbnail-wrapper')).toExist();
expect($videoThumbnailEl.find('.upload-image-input')).toExist();
});
it('does not show duration if not available', function() {
createVideoListView(true, {duration: 0});
expect($videoThumbnailEl.find('.thumbnail-wrapper .video-duration')).not.toExist();
});
it('shows the duration if available', function() {
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');
});
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 videoViewIndex = 0,
$thumbnail = $videoThumbnailEl.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
$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_' + videoViewIndex,
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 $thumbnail = $videoThumbnailEl.find('.thumbnail-wrapper'),
requests = AjaxHelpers.requests(this);
videoThumbnailView.chooseFile();
// Add image to upload queue and send POST request to upload image
$videoThumbnailEl.find('.upload-image-input').fileupload('add', {files: [createFakeImageFile()]});
AjaxHelpers.respondWithError(requests, 400);
verifyStateInfo($thumbnail, 'edit');
});
it('calls readMessage with correct message', function() {
var errorMessage = 'Image upload failed. 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
$videoThumbnailEl.find('.upload-image-input').fileupload('add', {files: [createFakeImageFile()]});
AjaxHelpers.respondWithError(requests);
// 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(
'Image upload failed. The selected image must be larger than ' +
videoThumbnailView.getVideoImageMinSize().humanize + '.'
);
});
it('should show error message when file is larger than maximum 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 + 10)]});
// Verify error message
expect($videoListEl.find('.thumbnail-error-wrapper').find('.action-text').html()
.trim()).toEqual(
'Image upload failed. 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(
'Image upload failed. 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();
});
});
}
);
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';
var PreviousVideoUploadView = BaseView.extend({
tagName: 'tr',
tagName: 'div',
className: 'video-row',
events: {
'click .remove-video-button.action-button': 'removeVideo'
......@@ -16,22 +19,21 @@ 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);
this.videoImageUploadEnabled = options.videoImageSettings.video_image_upload_enabled;
return minutes + ':' + (seconds < 10 ? '0' : '') + seconds;
if (this.videoImageUploadEnabled) {
this.videoThumbnailView = new VideoThumbnailView({
model: this.model,
imageUploadURL: options.videoImageUploadURL,
defaultVideoImageURL: options.defaultVideoImageURL,
videoImageSettings: options.videoImageSettings
});
}
},
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'),
videoImageUploadEnabled: this.videoImageUploadEnabled,
created: DateUtils.renderDate(this.model.get('created')),
status: this.model.get('status')
};
......@@ -41,12 +43,15 @@ define(
_.extend({}, this.model.attributes, renderedAttributes)
)
);
if (this.videoImageUploadEnabled) {
this.videoThumbnailView.setElement(this.$('.thumbnail-col')).render();
}
return this;
},
removeVideo: function(event) {
var videoView = this;
event.preventDefault();
ViewUtils.confirmThenRunOperation(
......
......@@ -9,9 +9,13 @@ define(
initialize: function(options) {
this.template = this.loadTemplate('previous-video-upload-list');
this.encodingsDownloadUrl = options.encodingsDownloadUrl;
this.videoImageUploadEnabled = options.videoImageSettings.video_image_upload_enabled;
this.itemViews = this.collection.map(function(model) {
return new PreviousVideoUploadView({
videoImageUploadURL: options.videoImageUploadURL,
defaultVideoImageURL: options.defaultVideoImageURL,
videoHandlerUrl: options.videoHandlerUrl,
videoImageSettings: options.videoImageSettings,
model: model
});
});
......@@ -20,7 +24,10 @@ define(
render: function() {
var $el = this.$el,
$tabBody;
$el.html(this.template({encodingsDownloadUrl: this.encodingsDownloadUrl}));
$el.html(this.template({
encodingsDownloadUrl: this.encodingsDownloadUrl,
videoImageUploadEnabled: this.videoImageUploadEnabled
}));
$tabBody = $el.find('.js-table-body');
_.each(this.itemViews, function(view) {
$tabBody.append(view.render().$el);
......
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',
'text!templates/video-thumbnail-error.underscore'],
function(_, gettext, moment, DateUtils, BaseView, ViewUtils, HtmlUtils, StringUtils, VideoThumbnailTemplate,
VideoThumbnailErrorTemplate) {
'use strict';
var VideoThumbnailView = BaseView.extend({
events: {
'click .thumbnail-wrapper': 'chooseFile',
'mouseover .thumbnail-wrapper': 'showHoverState',
'mouseout .thumbnail-wrapper': 'hideHoverState',
'focus .thumbnail-wrapper': 'showHoverState',
'blur .thumbnail-wrapper': 'hideHoverState'
},
initialize: function(options) {
this.template = HtmlUtils.template(VideoThumbnailTemplate);
this.thumbnailErrorTemplate = HtmlUtils.template(VideoThumbnailErrorTemplate);
this.imageUploadURL = options.imageUploadURL;
this.defaultVideoImageURL = options.defaultVideoImageURL;
this.action = this.model.get('course_video_image_url') ? 'edit' : 'upload';
this.videoImageSettings = options.videoImageSettings;
this.actionsInfo = {
upload: {
name: 'upload',
icon: '',
text: gettext('Add Thumbnail')
},
edit: {
name: 'edit',
actionText: gettext('Edit Thumbnail'),
icon: '<span class="icon fa fa-pencil" aria-hidden="true"></span>',
text: HtmlUtils.interpolateHtml(
// Translators: This is a 2 part text which tells the image requirements.
gettext('{InstructionsSpanStart}{videoImageResoultion}{lineBreak} {videoImageSupportedFileFormats}{spanEnd}'), // eslint-disable-line max-len
{
videoImageResoultion: this.getVideoImageResolution(),
videoImageSupportedFileFormats: this.getVideoImageSupportedFileFormats().humanize,
lineBreak: HtmlUtils.HTML('<br>'),
InstructionsSpanStart: HtmlUtils.HTML('<span class="requirements-instructions">'),
spanEnd: HtmlUtils.HTML('</span>')
}
).toString()
},
error: {
name: 'error',
icon: '',
text: gettext('Image upload failed')
},
progress: {
name: 'progress-action',
icon: '<span class="icon fa fa-spinner fa-pulse fa-spin" aria-hidden="true"></span>',
text: gettext('Uploading')
},
requirements: {
name: 'requirements',
icon: '',
text: HtmlUtils.interpolateHtml(
// Translators: This is a 3 part text which tells the image requirements.
gettext('{ReqTextSpanStart}Requirements{spanEnd}{lineBreak}{InstructionsSpanStart}{videoImageResoultion}{lineBreak} {videoImageSupportedFileFormats}{spanEnd}'), // eslint-disable-line max-len
{
videoImageResoultion: this.getVideoImageResolution(),
videoImageSupportedFileFormats: this.getVideoImageSupportedFileFormats().humanize,
lineBreak: HtmlUtils.HTML('<br>'),
ReqTextSpanStart: HtmlUtils.HTML('<span class="requirements-text">'),
InstructionsSpanStart: HtmlUtils.HTML('<span class="requirements-instructions">'),
spanEnd: HtmlUtils.HTML('</span>')
}
).toString()
}
};
_.bindAll(
this, 'render', 'chooseFile', 'imageSelected', 'imageUploadSucceeded', 'imageUploadFailed',
'showHoverState', 'hideHoverState'
);
},
render: function() {
HtmlUtils.setHtml(
this.$el,
this.template({
action: this.action,
imageAltText: this.getImageAltText(),
videoId: this.model.get('edx_video_id'),
actionInfo: this.actionsInfo[this.action],
thumbnailURL: this.model.get('course_video_image_url') || this.defaultVideoImageURL,
duration: this.getDuration(this.model.get('duration')),
videoImageSupportedFileFormats: this.getVideoImageSupportedFileFormats(),
videoImageMaxSize: this.getVideoImageMaxSize(),
videoImageResolution: this.getVideoImageResolution()
})
);
this.hideHoverState();
return this;
},
getVideoImageSupportedFileFormats: function() {
var supportedFormats = _.reject(_.keys(this.videoImageSettings.supported_file_formats), function(item) {
// Don't show redundant extensions to end user.
return item === '.bmp2' || item === '.jpeg';
}).sort();
return {
humanize: supportedFormats.slice(0, -1).join(', ') + ' or ' + supportedFormats.slice(-1),
machine: _.values(this.videoImageSettings.supported_file_formats)
};
},
getVideoImageMaxSize: function() {
return {
humanize: this.videoImageSettings.max_size / (1024 * 1024) + ' MB',
machine: this.videoImageSettings.max_size
};
},
getVideoImageMinSize: function() {
return {
humanize: this.videoImageSettings.min_size / 1024 + ' KB',
machine: this.videoImageSettings.min_size
};
},
getVideoImageResolution: function() {
return StringUtils.interpolate(
// Translators: message will be like 1280x720 pixels
gettext('{maxWidth}x{maxHeight} pixels'),
{maxWidth: this.videoImageSettings.max_width, maxHeight: this.videoImageSettings.max_height}
);
},
getImageAltText: function() {
return StringUtils.interpolate(
// Translators: message will be like Thumbnail for Arrow.mp4
gettext('Thumbnail for {videoName}'),
{videoName: this.model.get('client_video_id')}
);
},
getSRText: function() {
return StringUtils.interpolate(
// Translators: message will be like Add Thumbnail - Arrow.mp4
gettext('Add Thumbnail - {videoName}'),
{videoName: this.model.get('client_video_id')}
);
},
getDuration: function(durationSeconds) {
if (durationSeconds <= 0) {
return null;
}
return {
humanize: this.getDurationTextHuman(durationSeconds),
machine: this.getDurationTextMachine(durationSeconds)
};
},
getDurationTextHuman: function(durationSeconds) {
var humanize = this.getHumanizeDuration(durationSeconds);
// This case is specifically to handle values between 0 and 1 seconds excluding upper bound
if (humanize.length === 0) {
return '';
}
return StringUtils.interpolate(
// Translators: humanizeDuration will be like 10 minutes, an hour and 20 minutes etc
gettext('Video duration is {humanizeDuration}'),
{
humanizeDuration: humanize
}
);
},
getHumanizeDuration: function(durationSeconds) {
var minutes,
seconds,
minutesText = null,
secondsText = null;
minutes = Math.trunc(moment.duration(durationSeconds, 'seconds').asMinutes());
seconds = moment.duration(durationSeconds, 'seconds').seconds();
if (minutes) {
minutesText = minutes > 1 ? gettext('minutes') : gettext('minute');
minutesText = StringUtils.interpolate(
// Translators: message will be like 15 minutes, 1 minute
gettext('{minutes} {unit}'),
{minutes: minutes, unit: minutesText}
);
}
if (seconds) {
secondsText = seconds > 1 ? gettext('seconds') : gettext('second');
secondsText = StringUtils.interpolate(
// Translators: message will be like 20 seconds, 1 second
gettext('{seconds} {unit}'),
{seconds: seconds, unit: secondsText}
);
}
// Translators: `and` will be used to combine both miuntes and seconds like `13 minutes and 45 seconds`
return _.filter([minutesText, secondsText]).join(gettext(' and '));
},
getDurationTextMachine: function(durationSeconds) {
var minutes = Math.floor(durationSeconds / 60),
seconds = Math.floor(durationSeconds - minutes * 60);
return minutes + ':' + (seconds < 10 ? '0' : '') + seconds;
},
chooseFile: function() {
this.$('.upload-image-input').fileupload({
url: this.imageUploadURL + '/' + encodeURIComponent(this.model.get('edx_video_id')),
add: this.imageSelected,
done: this.imageUploadSucceeded,
fail: this.imageUploadFailed
});
},
imageSelected: function(event, data) {
var errorMessage;
// If an error is already present above the video element, remove it.
this.clearErrorMessage(this.model.get('edx_video_id'));
errorMessage = this.validateImageFile(data.files[0]);
if (!errorMessage) {
// Do not trigger global AJAX error handler
data.global = false; // eslint-disable-line no-param-reassign
this.readMessages([gettext('Video image upload started')]);
this.showUploadInProgressMessage();
data.submit();
} else {
this.showErrorMessage(errorMessage);
}
},
imageUploadSucceeded: function(event, data) {
this.action = 'edit';
this.setActionInfo(this.action, false);
this.$('img').attr('src', data.result.image_url);
this.readMessages([gettext('Video image upload completed')]);
},
imageUploadFailed: function(event, data) {
var errorText = JSON.parse(data.jqXHR.responseText).error;
this.showErrorMessage(errorText);
},
showUploadInProgressMessage: function() {
this.action = 'progress';
this.setActionInfo(this.action, true);
},
showHoverState: function() {
if (this.action === 'upload') {
this.setActionInfo('requirements', true, this.getSRText());
} else if (this.action === 'edit') {
this.setActionInfo(this.action, true);
}
this.$('.thumbnail-wrapper').addClass('focused');
},
hideHoverState: function() {
if (this.action === 'upload') {
this.setActionInfo(this.action, true);
} else if (this.action === 'edit') {
this.setActionInfo(this.action, false);
}
this.$('.thumbnail-wrapper').removeClass('focused');
},
setActionInfo: function(action, showText, additionalSRText) {
var hasError = this.$('.thumbnail-wrapper').hasClass('error');
this.$('.thumbnail-action').toggle(showText);
HtmlUtils.setHtml(
this.$('.thumbnail-action .action-icon'),
HtmlUtils.HTML(this.actionsInfo[action].icon)
);
HtmlUtils.setHtml(
this.$('.thumbnail-action .action-text'),
HtmlUtils.HTML(this.actionsInfo[action].text)
);
this.$('.thumbnail-action .action-text-sr').text(additionalSRText || '');
this.$('.thumbnail-wrapper').attr('class', 'thumbnail-wrapper {action}'.replace('{action}', action));
this.$('.thumbnail-action .action-icon')
.attr('class', 'action-icon {action}'.replace('{action}', action));
// Add error class if it was already present.
if (hasError) {
this.$('.thumbnail-wrapper').addClass('error');
}
// Don't show edit-container layout on progress action.
if (action === 'progress') {
this.$('.thumbnail-action .edit-container').toggle(false);
} else if (action === 'edit') {
this.$('.thumbnail-action .edit-container').toggle(true);
HtmlUtils.setHtml(
this.$('.thumbnail-action .edit-container .action-icon'),
HtmlUtils.HTML(this.actionsInfo[action].icon)
);
HtmlUtils.setHtml(
this.$('.thumbnail-action .edit-container .edit-action-text'),
HtmlUtils.HTML(this.actionsInfo[action].actionText)
);
}
},
validateImageFile: function(imageFile) {
var errorMessage = '';
if (!_.contains(this.getVideoImageSupportedFileFormats().machine, imageFile.type)) {
errorMessage = StringUtils.interpolate(
// Translators: supportedFileFormats will be like .bmp, gif, .jpg or .png.
gettext(
'This image file type is not supported. Supported file types are {supportedFileFormats}.'
),
{
supportedFileFormats: this.getVideoImageSupportedFileFormats().humanize
}
);
} else if (imageFile.size > this.getVideoImageMaxSize().machine) {
errorMessage = StringUtils.interpolate(
// Translators: maxFileSizeInMB will be like 2 MB.
gettext('The selected image must be smaller than {maxFileSizeInMB}.'),
{
maxFileSizeInMB: this.getVideoImageMaxSize().humanize
}
);
} else if (imageFile.size < this.getVideoImageMinSize().machine) {
errorMessage = StringUtils.interpolate(
// Translators: minFileSizeInKB will be like 2 KB.
gettext('The selected image must be larger than {minFileSizeInKB}.'),
{
minFileSizeInKB: this.getVideoImageMinSize().humanize
}
);
}
return errorMessage;
},
clearErrorMessage: function(videoId) {
var $thumbnailWrapperEl = $('.thumbnail-error-wrapper[data-video-id="' + videoId + '"]');
if ($thumbnailWrapperEl.length) {
$thumbnailWrapperEl.remove();
}
// Remove error class from thumbnail wrapper as well.
$('.thumbnail-wrapper').removeClass('error');
},
showErrorMessage: function(errorText) {
var videoId = this.model.get('edx_video_id'),
$parentRowEl = $(this.$el.parent());
// If image url is not this.defaultVideoImageURL then it means image is uploaded
// so we should treat it as edit action otherwise default upload action.
this.action = this.$('.thumbnail-wrapper img').attr('src') !== this.defaultVideoImageURL
? 'edit' : 'upload';
this.setActionInfo(this.action, true);
this.readMessages([gettext('Could not upload the video image file'), errorText]);
errorText = gettext('Image upload failed. ') + errorText; // eslint-disable-line no-param-reassign
// Add error wrapper html to current video element row.
$parentRowEl.before( // xss-lint: disable=javascript-jquery-insertion
HtmlUtils.ensureHtml(
this.thumbnailErrorTemplate({videoId: videoId, errorText: errorText})
).toString()
);
this.$el.find('.thumbnail-wrapper').addClass('error');
},
readMessages: function(messages) {
if ($(window).prop('SR') !== undefined) {
$(window).prop('SR').readTexts(messages);
}
}
});
return VideoThumbnailView;
}
);
......@@ -36,7 +36,7 @@
font-size: 80%;
word-wrap: break-word;
th {
th, .video-head-col {
@extend %t-copy-sub2;
background-color: $gray-l5;
padding: 0 ($baseline/2) ($baseline*0.75) ($baseline/2);
......@@ -60,18 +60,18 @@
}
}
td {
td, .video-col {
padding: ($baseline/2);
vertical-align: middle;
text-align: left;
}
tbody {
tbody, .js-table-body {
box-shadow: 0 2px 2px $shadow-l1;
border: 1px solid $gray-l4;
background: $white;
tr {
tr, .video-row {
@include transition(all $tmg-f2 ease-in-out 0s);
border-top: 1px solid $gray-l4;
......@@ -106,7 +106,7 @@
}
&:hover {
background-color: $blue-l5;
background-color: $blue-l5 !important;
.date-col,
.embed-col,
......
......@@ -147,6 +147,11 @@
}
.assets-library {
.js-table-body .video-id-col {
word-break: break-all;
}
.assets-title {
display: inline-block;
width: flex-grid(5, 9);
......@@ -163,4 +168,185 @@
@extend %actions-list;
}
}
.video-table {
.video-row {
display: table;
table-layout: fixed;
width: 100%;
.video-col {
display: table-cell;
}
.name-col {
width: 25%;
}
.thumbnail-col, .video-id-col {
width: 15%;
}
.date-col, .status-col {
width: 10%;
}
.actions-col {
width: 5%;
}
.video-head-col.thumbnail-col {
width: 17% !important;
}
}
}
.thumbnail-error-wrapper {
display: table-row;
white-space: nowrap;
color: $red;
.icon {
margin: ($baseline*0.75) ($baseline/4) 0 ($baseline/2);
}
}
$thumbnail-width: ($baseline*7.5);
$thumbnail-height: ($baseline*5);
.thumbnail-wrapper {
position: relative;
max-width: $thumbnail-width;
max-height: $thumbnail-height;
img {
width: $thumbnail-width;
height: $thumbnail-height;
}
* {
cursor: pointer;
}
&.upload,
&.requirements {
border: 1px dashed $gray-l3;
}
&.requirements {
.requirements-text {
font-weight: 600;
}
.requirements-instructions {
font-size: 15px;
font-family: "Open Sans";
text-align: left;
color: $gray-d2;
line-height: 1.5;
}
.video-duration {
opacity: 0;
}
}
&.edit {
background: black;
&:hover,
&:focus,
&.focused {
img, .video-duration {
@include transition(all 0.3s linear);
opacity: 0.1;
}
}
}
&.progress {
background: white;
img {
@include transition(all 0.5s linear);
opacity: 0.15;
}
.action-icon {
display: block;
}
}
&.upload .thumbnail-action {
color: $blue;
}
&.progress .thumbnail-action {
.action-icon {
@include font-size(20);
}
}
&.edit {
background-color: #4e4e4e;
}
&.edit .thumbnail-action .action-icon.edit {
display: none;
}
&.edit .thumbnail-action .edit-container {
background-color: $white;
padding: ($baseline/4);
border-radius: ($baseline/5);
margin-top: ($baseline/2);
display: none;
}
&.edit .action-text {
color: $white;
}
.thumbnail-action {
@include font-size(14);
}
.thumbnail-overlay > :not(.upload-image-input) {
position: absolute;
text-align: center;
top: 50%;
left: 5px;;
right: 5px;
@include transform(translateY(-50%));
z-index: 1;
}
.upload-image-input {
position: absolute;
top: 0;
left: 0;
right: 0;
opacity: 0;
z-index: 6;
width: $thumbnail-width;
height: $thumbnail-height;
}
.video-duration {
position: absolute;
text-align: center;
bottom: 1px;
@include right(1px);
width: auto;
min-width: 25%;
color: white;
padding: ($baseline/10) ($baseline/5);
background-color: black;
}
&.focused {
box-shadow: 0 0 ($baseline/5) 1px $blue;
}
&.error {
box-shadow: 0 0 ($baseline/5) 1px $red;
}
}
}
......@@ -5,17 +5,19 @@
<%- gettext("Download available encodings (.csv)") %>
</a>
</div>
<table class="assets-table">
<thead>
<tr>
<th><%- gettext("Name") %></th>
<th><%- gettext("Duration") %></th>
<th><%- gettext("Date Added") %></th>
<th><%- gettext("Video ID") %></th>
<th><%- gettext("Status") %></th>
<th><%- gettext("Action") %></th>
</tr>
</thead>
<tbody class="js-table-body"></tbody>
</table>
<div class="assets-table video-table">
<div class="js-table-head">
<div class="video-row">
<% if (videoImageUploadEnabled) { %>
<div class="video-head-col video-col thumbnail-col"><%- gettext("Thumbnail") %></div>
<% } %>
<div class="video-head-col video-col name-col"><%- gettext("Name") %></div>
<div class="video-head-col video-col date-col"><%- gettext("Date Added") %></div>
<div class="video-head-col video-col video-id-col"><%- gettext("Video ID") %></div>
<div class="video-head-col video-col status-col"><%- gettext("Status") %></div>
<div class="video-head-col video-col actions-col"><%- gettext("Action") %></div>
</div>
</div>
<div class="js-table-body"></div>
</div>
</div>
<td class="name-col"><%- client_video_id %></td>
<td class="duration-col"><%- duration %></td>
<td class="date-col"><%- created %></td>
<td class="video-id-col"><%- edx_video_id %></td>
<td class="status-col"><%- status %></td>
<td class="actions-col">
<ul class="actions-list">
<li class="action-item action-remove">
<a href="#" data-tooltip="<%- gettext('Remove this video') %>" class="remove-video-button action-button">
<span class="icon fa fa-times-circle" aria-hidden="true"></span>
<span class="sr"><%- StringUtils.interpolate(gettext("Remove {video_name} video"), {video_name: client_video_id}) %></span>
</a>
</li>
</ul>
</td>
<div class="video-row-container">
<% if (videoImageUploadEnabled) { %>
<div class="video-col thumbnail-col"></div>
<% } %>
<div class="video-col name-col"><%- client_video_id %></div>
<div class="video-col date-col"><%- created %></div>
<div class="video-col video-id-col"><%- edx_video_id %></div>
<div class="video-col status-col"><%- status %></div>
<div class="video-col actions-col">
<ul class="actions-list">
<li class="action-item action-remove">
<a href="#" data-tooltip="<%- gettext('Remove this video') %>" class="remove-video-button action-button">
<span class="icon fa fa-times-circle" aria-hidden="true"></span>
<span class="sr"><%- StringUtils.interpolate(gettext("Remove {video_name} video"), {video_name: client_video_id}) %></span>
</a>
</li>
</ul>
</div>
</div>
<div class="thumbnail-error-wrapper thumbnail-error" data-video-id="<%- videoId %>">
<span class="icon fa fa-exclamation-triangle" aria-hidden="true"></span>
<span class="action-text"><%- errorText %></span>
</div>
<div class="thumbnail-wrapper <%- action === 'upload' ? 'upload' : '' %>" tabindex="-1">
<img src="<%- thumbnailURL %>" alt="<%- imageAltText %>">
<div class="thumbnail-overlay">
<input id="thumb-<%- videoId %>" class="upload-image-input" type="file" name="file" accept=".bmp, .jpg, .jpeg, .png, .gif"/>
<label for="thumb-<%- videoId %>" class="thumbnail-action">
<span class="main-icon action-icon <%- actionInfo.name %>" aria-hidden="true"><%- actionInfo.icon %></span>
<span class="action-text-sr sr"></span>
<span class="action-text"><%- actionInfo.text %></span>
<div class="edit-container">
<span class="action-icon" aria-hidden="true"><%- actionInfo.icon %></span>
<span class="edit-action-text"><%- actionInfo.actionText %></span>
</div>
</label>
<span class="requirements-text-sr sr">
<%- edx.StringUtils.interpolate(
gettext("Recommended image resolution is {imageResolution}, maximum image file size should be {maxFileSize} and format must be one of {supportedImageFormats}."),
{imageResolution: videoImageResolution, maxFileSize: videoImageMaxSize.humanize, supportedImageFormats: videoImageSupportedFileFormats.humanize}
) %>
</span>
</div>
<% if(duration) { %>
<div class="video-duration">
<span class="duration-text-human sr"><%- duration.humanize %></span>
<span class="duration-text-machine" aria-hidden="true"><%- duration.machine %></span>
</div>
<% } %>
</div>
......@@ -29,13 +29,16 @@
var $contentWrapper = $(".content-primary");
VideosIndexFactory(
$contentWrapper,
"${image_upload_url | n, js_escaped_string}",
"${video_handler_url | n, js_escaped_string}",
"${encodings_download_url | n, js_escaped_string}",
"${default_video_image_url | n, js_escaped_string}",
${concurrent_upload_limit | n, dump_js_escaped_json},
$(".nav-actions .upload-button"),
$contentWrapper.data("previous-uploads"),
${video_supported_file_formats | n, dump_js_escaped_json},
${video_upload_max_file_size | n, dump_js_escaped_json}
${video_upload_max_file_size | n, dump_js_escaped_json},
${video_image_settings | n, dump_js_escaped_json}
);
});
</%block>
......
from django.conf import settings
from django.conf.urls import include, patterns, url
from django.conf.urls.static import static
# There is a course creators admin table.
from ratelimitbackend import admin
......@@ -112,6 +113,7 @@ urlpatterns += patterns(
url(r'^textbooks/{}$'.format(settings.COURSE_KEY_PATTERN), 'textbooks_list_handler'),
url(r'^textbooks/{}/(?P<textbook_id>\d[^/]*)$'.format(settings.COURSE_KEY_PATTERN), 'textbooks_detail_handler'),
url(r'^videos/{}(?:/(?P<edx_video_id>[-\w]+))?$'.format(settings.COURSE_KEY_PATTERN), 'videos_handler'),
url(r'^video_images/{}(?:/(?P<edx_video_id>[-\w]+))?$'.format(settings.COURSE_KEY_PATTERN), 'video_images_handler'),
url(r'^video_encodings_download/{}$'.format(settings.COURSE_KEY_PATTERN), 'video_encodings_download'),
url(r'^group_configurations/{}$'.format(settings.COURSE_KEY_PATTERN), 'group_configurations_list_handler'),
url(r'^group_configurations/{}/(?P<group_configuration_id>\d+)(/)?(?P<group_id>\d+)?$'.format(
......@@ -189,6 +191,11 @@ if settings.DEBUG:
except ImportError:
pass
urlpatterns += static(
settings.VIDEO_IMAGE_SETTINGS['STORAGE_KWARGS']['base_url'],
document_root=settings.VIDEO_IMAGE_SETTINGS['STORAGE_KWARGS']['location']
)
if 'debug_toolbar' in settings.INSTALLED_APPS:
import debug_toolbar
urlpatterns += (
......
......@@ -4,7 +4,7 @@
<div
id="video_id"
class="video closed"
data-metadata='{"autohideHtml5": "true", "autoplay": "false", "captionDataDir": "", "endTime": "", "generalSpeed": "1.0", "saveStateUrl": "/save_user_state", "savedVideoPosition": "0", "showCaptions": "true", "sources": ["/base/fixtures/hls/hls.m3u8", "/base/fixtures/test.mp4","/base/fixtures/test.webm"], "speed": "1.5", "startTime": "", "streams": "", "sub": "Z5KLxerq05Y", "transcriptAvailableTranslationsUrl": "/transcript/available_translations", "transcriptLanguage": "en", "transcriptLanguages": {"en": "English", "de": "Deutsch", "zh": "普通话"}, "transcriptTranslationUrl": "/transcript/translation/__lang__", "ytApiUrl": "/base/fixtures/youtube_iframe_api.js", "ytImageUrl": "", "ytTestTimeout": "1500", "ytMetadataUrl": "www.googleapis.com/youtube/v3/videos/", "source": ""}'
data-metadata='{"autohideHtml5": "true", "autoplay": "false", "captionDataDir": "", "endTime": "", "generalSpeed": "1.0", "saveStateUrl": "/save_user_state", "savedVideoPosition": "0", "showCaptions": "true", "sources": ["/base/fixtures/hls/hls.m3u8", "/base/fixtures/test.mp4","/base/fixtures/test.webm"], "speed": "1.5", "startTime": "", "streams": "", "sub": "Z5KLxerq05Y", "transcriptAvailableTranslationsUrl": "/transcript/available_translations", "transcriptLanguage": "en", "transcriptLanguages": {"en": "English", "de": "Deutsch", "zh": "普通话"}, "transcriptTranslationUrl": "/transcript/translation/__lang__", "ytApiUrl": "/base/fixtures/youtube_iframe_api.js", "ytImageUrl": "", "ytTestTimeout": "1500", "ytMetadataUrl": "www.googleapis.com/youtube/v3/videos/", "source": "", "poster": "/media/video-images/poster.png"}'
>
<div class="focus_grabber first"></div>
......
......@@ -4,7 +4,7 @@
<div
id="video_id"
class="video closed"
data-metadata='{"autohideHtml5": "true", "autoplay": "false", "captionDataDir": "", "endTime": "", "generalSpeed": "1.0", "saveStateUrl": "/save_user_state", "savedVideoPosition": "0", "showCaptions": "true", "sources": ["/base/fixtures/test.mp4","/base/fixtures/test.webm","/base/fixtures/test.ogv"], "speed": "1.5", "startTime": "", "streams": "", "sub": "Z5KLxerq05Y", "transcriptAvailableTranslationsUrl": "/transcript/available_translations", "transcriptLanguage": "en", "transcriptLanguages": {"en": "English", "de": "Deutsch", "zh": "普通话"}, "transcriptTranslationUrl": "/transcript/translation/__lang__", "ytApiUrl": "/base/fixtures/youtube_iframe_api.js", "ytImageUrl": "", "ytTestTimeout": "1500", "ytMetadataUrl": "www.googleapis.com/youtube/v3/videos/", "source": "", "html5_sources": ["http://youtu.be/3_yD_cEKoCk.mp4"]}'
data-metadata='{"autohideHtml5": "true", "autoplay": "false", "captionDataDir": "", "endTime": "", "generalSpeed": "1.0", "saveStateUrl": "/save_user_state", "savedVideoPosition": "0", "showCaptions": "true", "sources": ["/base/fixtures/test.mp4","/base/fixtures/test.webm","/base/fixtures/test.ogv"], "speed": "1.5", "startTime": "", "streams": "", "sub": "Z5KLxerq05Y", "transcriptAvailableTranslationsUrl": "/transcript/available_translations", "transcriptLanguage": "en", "transcriptLanguages": {"en": "English", "de": "Deutsch", "zh": "普通话"}, "transcriptTranslationUrl": "/transcript/translation/__lang__", "ytApiUrl": "/base/fixtures/youtube_iframe_api.js", "ytImageUrl": "", "ytTestTimeout": "1500", "ytMetadataUrl": "www.googleapis.com/youtube/v3/videos/", "source": "", "html5_sources": ["http://youtu.be/3_yD_cEKoCk.mp4"], "poster": "/media/video-images/poster.png"}'
>
<div class="focus_grabber first"></div>
......
......@@ -4,7 +4,8 @@
var state,
oldOTBD,
playbackRates = [0.75, 1.0, 1.25, 1.5],
describeInfo;
describeInfo,
POSTER_URL = '/media/video-images/poster.png';
beforeEach(function() {
oldOTBD = window.onTouchBasedDevice;
......@@ -320,6 +321,15 @@
}).done(done);
});
});
describe('poster', function() {
it('has url in player config', function() {
expect(state.videoPlayer.player.config.poster).toEqual(POSTER_URL);
expect(state.videoPlayer.player.videoEl).toHaveAttrs({
poster: POSTER_URL
});
});
});
});
describe('non-hls encoding', function() {
......@@ -338,6 +348,28 @@
jasmine.getEnv().describe(describeInfo.description, describeInfo.specDefinitions);
});
it('does not show poster for html5 video if url is not present', function() {
state = jasmine.initializePlayer(
'video_html5.html',
{
poster: null
}
);
expect(state.videoPlayer.player.config.poster).toEqual(null);
expect(state.videoPlayer.player.videoEl).not.toHaveAttr('poster');
});
it('does not show poster for hls video if url is not present', function() {
state = jasmine.initializePlayer(
'video_hls.html',
{
poster: null
}
);
expect(state.videoPlayer.player.config.poster).toEqual(null);
expect(state.videoPlayer.player.videoEl).not.toHaveAttr('poster');
});
it('native controls are used on iPhone', function() {
window.onTouchBasedDevice.and.returnValue(['iPhone']);
......
......@@ -44,8 +44,11 @@ function(_) {
* // video format of the source. Supported
* // video formats are: 'mp4', 'webm', and
* // 'ogg'.
* poster: Video poster URL
*
* events: { // Object's properties identify the
* browserIsSafari: Flag to tell if current browser is Safari
*
* events: { // Object's properties identify the
* // events that the API fires, and the
* // functions (event listeners) that the
* // API will call when those events occur.
......@@ -320,6 +323,11 @@ function(_) {
this.videoEl.prop('controls', true);
}
// Set video poster
if (this.config.poster) {
this.videoEl.prop('poster', this.config.poster);
}
// Place the <video> element on the page.
this.videoEl.appendTo(el.find('.video-player > div:first-child'));
};
......
......@@ -162,6 +162,7 @@ function(HTML5Video, HTML5HLSVideo, Resizer, HLS, _) {
commonPlayerConfig = {
playerVars: state.videoPlayer.playerVars,
videoSources: state.config.sources,
poster: state.config.poster,
browserIsSafari: state.browserIsSafari,
events: {
onReady: state.videoPlayer.onReady,
......
......@@ -18,7 +18,7 @@ import datetime
from uuid import uuid4
from lxml import etree
from mock import ANY, Mock, patch
from mock import ANY, Mock, patch, MagicMock
import ddt
from django.conf import settings
......@@ -673,7 +673,7 @@ class VideoExportTestCase(VideoDescriptorTestBase):
"""
Test that we write the correct XML on export.
"""
def mock_val_export(edx_video_id):
def mock_val_export(edx_video_id, course_id):
"""Mock edxval.api.export_to_xml"""
return etree.Element(
'video_asset',
......@@ -695,6 +695,7 @@ class VideoExportTestCase(VideoDescriptorTestBase):
self.descriptor.download_video = True
self.descriptor.transcripts = {'ua': 'ukrainian_translation.srt', 'ge': 'german_translation.srt'}
self.descriptor.edx_video_id = 'test_edx_video_id'
self.descriptor.runtime.course_id = MagicMock()
xml = self.descriptor.definition_to_xml(None) # We don't use the `resource_fs` parameter
parser = etree.XMLParser(remove_blank_text=True)
......@@ -718,6 +719,7 @@ class VideoExportTestCase(VideoDescriptorTestBase):
mock_val_api.ValVideoNotFoundError = _MockValVideoNotFoundError
mock_val_api.export_to_xml = Mock(side_effect=mock_val_api.ValVideoNotFoundError)
self.descriptor.edx_video_id = 'test_edx_video_id'
self.descriptor.runtime.course_id = MagicMock()
xml = self.descriptor.definition_to_xml(None)
parser = etree.XMLParser(remove_blank_text=True)
......
......@@ -310,7 +310,10 @@ class VideoModule(VideoFields, VideoTranscriptsMixin, VideoStudentViewHandlers,
'streams': self.youtube_streams,
'sub': self.sub,
'sources': sources,
'poster': edxval_api and edxval_api.get_course_video_image_url(
course_id=self.runtime.course_id.for_branch(None),
edx_video_id=self.edx_video_id.strip()
),
# This won't work when we move to data that
# isn't on the filesystem
'captionDataDir': getattr(self, 'data_dir', None),
......@@ -653,7 +656,10 @@ class VideoDescriptor(VideoFields, VideoTranscriptsMixin, VideoStudioViewHandler
if self.edx_video_id and edxval_api:
try:
xml.append(edxval_api.export_to_xml(self.edx_video_id))
xml.append(edxval_api.export_to_xml(
self.edx_video_id,
unicode(self.runtime.course_id.for_branch(None)))
)
except edxval_api.ValVideoNotFoundError:
pass
......
......@@ -50,32 +50,33 @@ class TestVideoYouTube(TestVideo):
'handout': None,
'id': self.item_descriptor.location.html_id(),
'metadata': json.dumps(OrderedDict({
"saveStateUrl": self.item_descriptor.xmodule_runtime.ajax_url + "/save_user_state",
"autoplay": False,
"streams": "0.75:jNCf2gIqpeE,1.00:ZwkTiUPN0mg,1.25:rsq9auxASqI,1.50:kMyNdzVHHgg",
"sub": "a_sub_file.srt.sjson",
"sources": sources,
"captionDataDir": None,
"showCaptions": "true",
"generalSpeed": 1.0,
"speed": None,
"savedVideoPosition": 0.0,
"start": 3603.0,
"end": 3610.0,
"transcriptLanguage": "en",
"transcriptLanguages": OrderedDict({"en": "English", "uk": u"Українська"}),
"ytTestTimeout": 1500,
"ytApiUrl": "https://www.youtube.com/iframe_api",
"ytMetadataUrl": "https://www.googleapis.com/youtube/v3/videos/",
"ytKey": None,
"transcriptTranslationUrl": self.item_descriptor.xmodule_runtime.handler_url(
'saveStateUrl': self.item_descriptor.xmodule_runtime.ajax_url + '/save_user_state',
'autoplay': False,
'streams': '0.75:jNCf2gIqpeE,1.00:ZwkTiUPN0mg,1.25:rsq9auxASqI,1.50:kMyNdzVHHgg',
'sub': 'a_sub_file.srt.sjson',
'sources': sources,
'poster': None,
'captionDataDir': None,
'showCaptions': 'true',
'generalSpeed': 1.0,
'speed': None,
'savedVideoPosition': 0.0,
'start': 3603.0,
'end': 3610.0,
'transcriptLanguage': 'en',
'transcriptLanguages': OrderedDict({'en': 'English', 'uk': u'Українська'}),
'ytTestTimeout': 1500,
'ytApiUrl': 'https://www.youtube.com/iframe_api',
'ytMetadataUrl': 'https://www.googleapis.com/youtube/v3/videos/',
'ytKey': None,
'transcriptTranslationUrl': self.item_descriptor.xmodule_runtime.handler_url(
self.item_descriptor, 'transcript', 'translation/__lang__'
).rstrip('/?'),
"transcriptAvailableTranslationsUrl": self.item_descriptor.xmodule_runtime.handler_url(
'transcriptAvailableTranslationsUrl': self.item_descriptor.xmodule_runtime.handler_url(
self.item_descriptor, 'transcript', 'available_translations'
).rstrip('/?'),
"autohideHtml5": False,
"recordedYoutubeIsAvailable": True,
'autohideHtml5': False,
'recordedYoutubeIsAvailable': True,
})),
'track': None,
'transcript_download_format': u'srt',
......@@ -129,32 +130,33 @@ class TestVideoNonYouTube(TestVideo):
'handout': None,
'id': self.item_descriptor.location.html_id(),
'metadata': json.dumps(OrderedDict({
"saveStateUrl": self.item_descriptor.xmodule_runtime.ajax_url + "/save_user_state",
"autoplay": False,
"streams": "1.00:3_yD_cEKoCk",
"sub": "a_sub_file.srt.sjson",
"sources": sources,
"captionDataDir": None,
"showCaptions": "true",
"generalSpeed": 1.0,
"speed": None,
"savedVideoPosition": 0.0,
"start": 3603.0,
"end": 3610.0,
"transcriptLanguage": "en",
"transcriptLanguages": OrderedDict({"en": "English"}),
"ytTestTimeout": 1500,
"ytApiUrl": "https://www.youtube.com/iframe_api",
"ytMetadataUrl": "https://www.googleapis.com/youtube/v3/videos/",
"ytKey": None,
"transcriptTranslationUrl": self.item_descriptor.xmodule_runtime.handler_url(
'saveStateUrl': self.item_descriptor.xmodule_runtime.ajax_url + '/save_user_state',
'autoplay': False,
'streams': '1.00:3_yD_cEKoCk',
'sub': 'a_sub_file.srt.sjson',
'sources': sources,
'poster': None,
'captionDataDir': None,
'showCaptions': 'true',
'generalSpeed': 1.0,
'speed': None,
'savedVideoPosition': 0.0,
'start': 3603.0,
'end': 3610.0,
'transcriptLanguage': 'en',
'transcriptLanguages': OrderedDict({'en': 'English'}),
'ytTestTimeout': 1500,
'ytApiUrl': 'https://www.youtube.com/iframe_api',
'ytMetadataUrl': 'https://www.googleapis.com/youtube/v3/videos/',
'ytKey': None,
'transcriptTranslationUrl': self.item_descriptor.xmodule_runtime.handler_url(
self.item_descriptor, 'transcript', 'translation/__lang__'
).rstrip('/?'),
"transcriptAvailableTranslationsUrl": self.item_descriptor.xmodule_runtime.handler_url(
'transcriptAvailableTranslationsUrl': self.item_descriptor.xmodule_runtime.handler_url(
self.item_descriptor, 'transcript', 'available_translations'
).rstrip('/?'),
"autohideHtml5": False,
"recordedYoutubeIsAvailable": True,
'autohideHtml5': False,
'recordedYoutubeIsAvailable': True,
})),
'track': None,
'transcript_download_format': u'srt',
......@@ -185,32 +187,33 @@ class TestGetHtmlMethod(BaseTestXmodule):
super(TestGetHtmlMethod, self).setUp()
self.setup_course()
self.default_metadata_dict = OrderedDict({
"saveStateUrl": "",
"autoplay": settings.FEATURES.get('AUTOPLAY_VIDEOS', True),
"streams": "1.00:3_yD_cEKoCk",
"sub": "a_sub_file.srt.sjson",
"sources": '[]',
"captionDataDir": None,
"showCaptions": "true",
"generalSpeed": 1.0,
"speed": None,
"savedVideoPosition": 0.0,
"start": 3603.0,
"end": 3610.0,
"transcriptLanguage": "en",
"transcriptLanguages": OrderedDict({"en": "English"}),
"ytTestTimeout": 1500,
"ytApiUrl": "https://www.youtube.com/iframe_api",
"ytMetadataUrl": "https://www.googleapis.com/youtube/v3/videos/",
"ytKey": None,
"transcriptTranslationUrl": self.item_descriptor.xmodule_runtime.handler_url(
'saveStateUrl': '',
'autoplay': settings.FEATURES.get('AUTOPLAY_VIDEOS', True),
'streams': '1.00:3_yD_cEKoCk',
'sub': 'a_sub_file.srt.sjson',
'sources': '[]',
'poster': None,
'captionDataDir': None,
'showCaptions': 'true',
'generalSpeed': 1.0,
'speed': None,
'savedVideoPosition': 0.0,
'start': 3603.0,
'end': 3610.0,
'transcriptLanguage': 'en',
'transcriptLanguages': OrderedDict({'en': 'English'}),
'ytTestTimeout': 1500,
'ytApiUrl': 'https://www.youtube.com/iframe_api',
'ytMetadataUrl': 'https://www.googleapis.com/youtube/v3/videos/',
'ytKey': None,
'transcriptTranslationUrl': self.item_descriptor.xmodule_runtime.handler_url(
self.item_descriptor, 'transcript', 'translation/__lang__'
).rstrip('/?'),
"transcriptAvailableTranslationsUrl": self.item_descriptor.xmodule_runtime.handler_url(
'transcriptAvailableTranslationsUrl': self.item_descriptor.xmodule_runtime.handler_url(
self.item_descriptor, 'transcript', 'available_translations'
).rstrip('/?'),
"autohideHtml5": False,
"recordedYoutubeIsAvailable": True,
'autohideHtml5': False,
'recordedYoutubeIsAvailable': True,
})
def test_get_html_track(self):
......@@ -918,6 +921,19 @@ class TestGetHtmlMethod(BaseTestXmodule):
context = self.item_descriptor.render(STUDENT_VIEW).content
self.assertIn("'download_video_link': None", context)
@patch('xmodule.video_module.video_module.edxval_api.get_course_video_image_url')
def test_poster_image(self, get_course_video_image_url):
"""
Verify that poster image functionality works as expected.
"""
video_xml = '<video display_name="Video" download_video="true" edx_video_id="12345-67890">[]</video>'
get_course_video_image_url.return_value = '/media/video-images/poster.png'
self.initialize_module(data=video_xml)
context = self.item_descriptor.render(STUDENT_VIEW).content
self.assertIn('"poster": "/media/video-images/poster.png"', context)
@attr(shard=1)
class TestVideoCDNRewriting(BaseTestXmodule):
......@@ -1261,7 +1277,7 @@ class TestVideoDescriptorStudentViewJson(TestCase):
'duration': self.TEST_DURATION,
'status': 'dummy',
'encoded_videos': [self.TEST_ENCODED_VIDEO],
'courses': [self.video.location.course_key] if associate_course_in_val else [],
'courses': [unicode(self.video.location.course_key)] if associate_course_in_val else [],
})
self.val_video = get_video_info(self.TEST_EDX_VIDEO_ID) # pylint: disable=attribute-defined-outside-init
......@@ -1391,6 +1407,7 @@ class VideoDescriptorTest(TestCase, VideoDescriptorTestBase):
def setUp(self):
super(VideoDescriptorTest, self).setUp()
self.descriptor.runtime.handler_url = MagicMock()
self.descriptor.runtime.course_id = MagicMock()
def test_get_context(self):
""""
......@@ -1438,7 +1455,7 @@ class VideoDescriptorTest(TestCase, VideoDescriptorTestBase):
actual = self.descriptor.definition_to_xml(resource_fs=None)
expected_str = """
<video download_video="false" url_name="SampleProblem">
<video_asset client_video_id="test_client_video_id" duration="111.0">
<video_asset client_video_id="test_client_video_id" duration="111.0" image="">
<encoded_video profile="mobile" url="http://example.com/video" file_size="222" bitrate="333"/>
</video_asset>
</video>
......@@ -1474,7 +1491,7 @@ class VideoDescriptorTest(TestCase, VideoDescriptorTestBase):
self.assertEqual(video_data['client_video_id'], 'test_client_video_id')
self.assertEqual(video_data['duration'], 111)
self.assertEqual(video_data['status'], 'imported')
self.assertEqual(video_data['courses'], [id_generator.target_course_id])
self.assertEqual(video_data['courses'], [{id_generator.target_course_id: None}])
self.assertEqual(video_data['encoded_videos'][0]['profile'], 'mobile')
self.assertEqual(video_data['encoded_videos'][0]['url'], 'http://example.com/video')
self.assertEqual(video_data['encoded_videos'][0]['file_size'], 222)
......@@ -1538,13 +1555,13 @@ class TestVideoWithBumper(TestVideo):
Test content with rendered bumper metadata.
"""
get_url_for_profiles.return_value = {
"desktop_mp4": "http://test_bumper.mp4",
"desktop_webm": "",
'desktop_mp4': 'http://test_bumper.mp4',
'desktop_webm': '',
}
get_bumper_settings.return_value = {
"video_id": "edx_video_id",
"transcripts": {},
'video_id': 'edx_video_id',
'transcripts': {},
}
is_bumper_enabled.return_value = True
......@@ -1556,17 +1573,17 @@ class TestVideoWithBumper(TestVideo):
'license': None,
'bumper_metadata': json.dumps(OrderedDict({
'saveStateUrl': self.item_descriptor.xmodule_runtime.ajax_url + '/save_user_state',
"showCaptions": "true",
"sources": ["http://test_bumper.mp4"],
'showCaptions': 'true',
'sources': ['http://test_bumper.mp4'],
'streams': '',
"transcriptLanguage": "en",
"transcriptLanguages": {"en": "English"},
"transcriptTranslationUrl": video_utils.set_query_parameter(
'transcriptLanguage': 'en',
'transcriptLanguages': {'en': 'English'},
'transcriptTranslationUrl': video_utils.set_query_parameter(
self.item_descriptor.xmodule_runtime.handler_url(
self.item_descriptor, 'transcript', 'translation/__lang__'
).rstrip('/?'), 'is_bumper', 1
),
"transcriptAvailableTranslationsUrl": video_utils.set_query_parameter(
'transcriptAvailableTranslationsUrl': video_utils.set_query_parameter(
self.item_descriptor.xmodule_runtime.handler_url(
self.item_descriptor, 'transcript', 'available_translations'
).rstrip('/?'), 'is_bumper', 1
......@@ -1579,32 +1596,33 @@ class TestVideoWithBumper(TestVideo):
'handout': None,
'id': self.item_descriptor.location.html_id(),
'metadata': json.dumps(OrderedDict({
"saveStateUrl": self.item_descriptor.xmodule_runtime.ajax_url + "/save_user_state",
"autoplay": False,
"streams": "0.75:jNCf2gIqpeE,1.00:ZwkTiUPN0mg,1.25:rsq9auxASqI,1.50:kMyNdzVHHgg",
"sub": "a_sub_file.srt.sjson",
"sources": sources,
"captionDataDir": None,
"showCaptions": "true",
"generalSpeed": 1.0,
"speed": None,
"savedVideoPosition": 0.0,
"start": 3603.0,
"end": 3610.0,
"transcriptLanguage": "en",
"transcriptLanguages": OrderedDict({"en": "English", "uk": u"Українська"}),
"ytTestTimeout": 1500,
"ytApiUrl": "https://www.youtube.com/iframe_api",
"ytMetadataUrl": "https://www.googleapis.com/youtube/v3/videos/",
"ytKey": None,
"transcriptTranslationUrl": self.item_descriptor.xmodule_runtime.handler_url(
'saveStateUrl': self.item_descriptor.xmodule_runtime.ajax_url + '/save_user_state',
'autoplay': False,
'streams': '0.75:jNCf2gIqpeE,1.00:ZwkTiUPN0mg,1.25:rsq9auxASqI,1.50:kMyNdzVHHgg',
'sub': 'a_sub_file.srt.sjson',
'sources': sources,
'poster': None,
'captionDataDir': None,
'showCaptions': 'true',
'generalSpeed': 1.0,
'speed': None,
'savedVideoPosition': 0.0,
'start': 3603.0,
'end': 3610.0,
'transcriptLanguage': 'en',
'transcriptLanguages': OrderedDict({'en': 'English', 'uk': u'Українська'}),
'ytTestTimeout': 1500,
'ytApiUrl': 'https://www.youtube.com/iframe_api',
'ytMetadataUrl': 'https://www.googleapis.com/youtube/v3/videos/',
'ytKey': None,
'transcriptTranslationUrl': self.item_descriptor.xmodule_runtime.handler_url(
self.item_descriptor, 'transcript', 'translation/__lang__'
).rstrip('/?'),
"transcriptAvailableTranslationsUrl": self.item_descriptor.xmodule_runtime.handler_url(
'transcriptAvailableTranslationsUrl': self.item_descriptor.xmodule_runtime.handler_url(
self.item_descriptor, 'transcript', 'available_translations'
).rstrip('/?'),
"autohideHtml5": False,
"recordedYoutubeIsAvailable": True,
'autohideHtml5': False,
'recordedYoutubeIsAvailable': True,
})),
'track': None,
'transcript_download_format': u'srt',
......@@ -1613,8 +1631,8 @@ class TestVideoWithBumper(TestVideo):
{'display_name': 'Text (.txt) file', 'value': 'txt'}
],
'poster': json.dumps(OrderedDict({
"url": "http://img.youtube.com/vi/ZwkTiUPN0mg/0.jpg",
"type": "youtube"
'url': 'http://img.youtube.com/vi/ZwkTiUPN0mg/0.jpg',
'type': 'youtube'
}))
}
......
......@@ -796,6 +796,9 @@ XBLOCK_SETTINGS = ENV_TOKENS.get('XBLOCK_SETTINGS', {})
XBLOCK_SETTINGS.setdefault("VideoDescriptor", {})["licensing_enabled"] = FEATURES.get("LICENSING", False)
XBLOCK_SETTINGS.setdefault("VideoModule", {})['YOUTUBE_API_KEY'] = AUTH_TOKENS.get('YOUTUBE_API_KEY', YOUTUBE_API_KEY)
##### VIDEO IMAGE STORAGE #####
VIDEO_IMAGE_SETTINGS = ENV_TOKENS.get('VIDEO_IMAGE_SETTINGS', VIDEO_IMAGE_SETTINGS)
##### CDN EXPERIMENT/MONITORING FLAGS #####
CDN_VIDEO_URLS = ENV_TOKENS.get('CDN_VIDEO_URLS', CDN_VIDEO_URLS)
ONLOAD_BEACON_SAMPLE_RATE = ENV_TOKENS.get('ONLOAD_BEACON_SAMPLE_RATE', ONLOAD_BEACON_SAMPLE_RATE)
......
......@@ -2564,6 +2564,22 @@ MAX_FAILED_LOGIN_ATTEMPTS_LOCKOUT_PERIOD_SECS = 15 * 60
TIME_ZONE_DISPLAYED_FOR_DEADLINES = 'UTC'
########################## VIDEO IMAGE STORAGE ############################
VIDEO_IMAGE_SETTINGS = dict(
VIDEO_IMAGE_MAX_BYTES=2 * 1024 * 1024, # 2 MB
VIDEO_IMAGE_MIN_BYTES=2 * 1024, # 2 KB
# Backend storage
# STORAGE_CLASS='storages.backends.s3boto.S3BotoStorage',
# STORAGE_KWARGS=dict(bucket='video-image-bucket'),
STORAGE_KWARGS=dict(
location=MEDIA_ROOT,
base_url=MEDIA_URL,
),
DIRECTORY_PREFIX='video-images/',
)
# Source:
# http://loc.gov/standards/iso639-2/ISO-639-2_utf-8.txt according to http://en.wikipedia.org/wiki/ISO_639-1
# Note that this is used as the set of choices to the `code` field of the
......
......@@ -7,7 +7,6 @@
<input class="upload-button-input" type="file" name="<%= inputName %>"/>
</label>
<button class="upload-submit" type="button" hidden="true"><%= uploadButtonTitle %></button>
<button class="u-field-remove-button" type="button">
<span class="remove-button-icon" aria-hidden="true"><%= removeButtonIcon %></span>
<span class="remove-button-title" aria-live="polite"><%= removeButtonTitle %></span>
......
......@@ -11,7 +11,7 @@ from PIL import Image
@contextmanager
def make_image_file(dimensions=(320, 240), extension=".jpeg", force_size=None, orientation=None):
def make_image_file(dimensions=(320, 240), prefix='tmp', extension='.jpeg', force_size=None, orientation=None):
"""
Yields a named temporary file created with the specified image type and
options.
......@@ -21,9 +21,13 @@ def make_image_file(dimensions=(320, 240), extension=".jpeg", force_size=None, o
The temporary file will be closed and deleted automatically upon exiting
the `with` block.
prefix - To add prefix to random image file name, after adding will be like <custom-prefix><random-name>.png
otherwise by default `tmp` is added making file name tmp<random-name>.png.
"""
image = Image.new('RGB', dimensions, "green")
image_file = NamedTemporaryFile(suffix=extension)
image_file = NamedTemporaryFile(prefix=prefix, suffix=extension)
try:
if orientation and orientation in xrange(1, 9):
exif_bytes = piexif.dump({'0th': {piexif.ImageIFD.Orientation: orientation}})
......
......@@ -77,7 +77,7 @@ git+https://github.com/edx/lettuce.git@0.2.20.002#egg=lettuce==0.2.20.002
git+https://github.com/edx/edx-ora2.git@1.4.3#egg=ora2==1.4.3
-e git+https://github.com/edx/edx-submissions.git@2.0.0#egg=edx-submissions==2.0.0
git+https://github.com/edx/ease.git@release-2015-07-14#egg=ease==0.1.3
git+https://github.com/edx/edx-val.git@0.0.13#egg=edxval==0.0.13
git+https://github.com/edx/edx-val.git@0.0.16#egg=edxval==0.0.16
git+https://github.com/pmitros/RecommenderXBlock.git@v1.2#egg=recommender-xblock==1.2
git+https://github.com/solashirai/crowdsourcehinter.git@518605f0a95190949fe77bd39158450639e2e1dc#egg=crowdsourcehinter-xblock==0.1
-e git+https://github.com/pmitros/RateXBlock.git@367e19c0f6eac8a5f002fd0f1559555f8e74bfff#egg=rate-xblock
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment