Commit e8fee2e0 by Mushtaq Ali

Add backend video image validations - EDUCATOR-45

parent 626f2896
...@@ -12,6 +12,7 @@ import dateutil.parser ...@@ -12,6 +12,7 @@ import dateutil.parser
import ddt import ddt
import pytz import pytz
from django.conf import settings from django.conf import settings
from django.core.files.uploadedfile import UploadedFile
from django.test.utils import override_settings from django.test.utils import override_settings
from edxval.api import create_profile, create_video, get_video_info, get_course_video_image_url from edxval.api import create_profile, create_video, get_video_info, get_course_video_image_url
from mock import Mock, patch from mock import Mock, patch
...@@ -23,7 +24,8 @@ from contentstore.views.videos import ( ...@@ -23,7 +24,8 @@ from contentstore.views.videos import (
KEY_EXPIRATION_IN_SECONDS, KEY_EXPIRATION_IN_SECONDS,
StatusDisplayStrings, StatusDisplayStrings,
convert_video_status, convert_video_status,
_get_default_video_image_url _get_default_video_image_url,
validate_video_image
) )
from contentstore.views.videos import KEY_EXPIRATION_IN_SECONDS, StatusDisplayStrings, convert_video_status from contentstore.views.videos import KEY_EXPIRATION_IN_SECONDS, StatusDisplayStrings, convert_video_status
from xmodule.modulestore.tests.factories import CourseFactory from xmodule.modulestore.tests.factories import CourseFactory
...@@ -532,6 +534,7 @@ class VideosHandlerTestCase(VideoUploadTestMixin, CourseTestCase): ...@@ -532,6 +534,7 @@ class VideosHandlerTestCase(VideoUploadTestMixin, CourseTestCase):
self.assert_video_status(url, edx_video_id, 'Failed') self.assert_video_status(url, edx_video_id, 'Failed')
@ddt.ddt
@patch.dict('django.conf.settings.FEATURES', {'ENABLE_VIDEO_UPLOAD_PIPELINE': True}) @patch.dict('django.conf.settings.FEATURES', {'ENABLE_VIDEO_UPLOAD_PIPELINE': True})
@override_settings(VIDEO_UPLOAD_PIPELINE={'BUCKET': 'test_bucket', 'ROOT_PATH': 'test_root'}) @override_settings(VIDEO_UPLOAD_PIPELINE={'BUCKET': 'test_bucket', 'ROOT_PATH': 'test_root'})
class VideoImageTestCase(VideoUploadTestBase, CourseTestCase): class VideoImageTestCase(VideoUploadTestBase, CourseTestCase):
...@@ -560,18 +563,35 @@ class VideoImageTestCase(VideoUploadTestBase, CourseTestCase): ...@@ -560,18 +563,35 @@ class VideoImageTestCase(VideoUploadTestBase, CourseTestCase):
return 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)
def test_video_image(self): def test_video_image(self):
""" """
Test video image is saved. Test video image is saved.
""" """
edx_video_id = 'test1' edx_video_id = 'test1'
video_image_upload_url = self.get_url_for_course_key(self.course.id, {'edx_video_id': edx_video_id}) video_image_upload_url = self.get_url_for_course_key(self.course.id, {'edx_video_id': edx_video_id})
with make_image_file() as image_file: 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') 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) image_url1 = self.verify_image_upload_reponse(self.course.id, edx_video_id, response)
# upload again to verify that new image is uploaded successfully # upload again to verify that new image is uploaded successfully
with make_image_file() as image_file: 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') 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) image_url2 = self.verify_image_upload_reponse(self.course.id, edx_video_id, response)
...@@ -583,9 +603,27 @@ class VideoImageTestCase(VideoUploadTestBase, CourseTestCase): ...@@ -583,9 +603,27 @@ class VideoImageTestCase(VideoUploadTestBase, CourseTestCase):
""" """
video_image_upload_url = self.get_url_for_course_key(self.course.id, {'edx_video_id': 'test1'}) 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, {}) response = self.client.post(video_image_upload_url, {})
self.assertEqual(response.status_code, 400) self.verify_error_message(response, 'No file provided for video image')
response = json.loads(response.content)
self.assertEqual(response['error'], 'No file provided for video image') 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, 'This image file is corrupted.')
def test_no_video_image(self): def test_no_video_image(self):
""" """
...@@ -594,7 +632,9 @@ class VideoImageTestCase(VideoUploadTestBase, CourseTestCase): ...@@ -594,7 +632,9 @@ class VideoImageTestCase(VideoUploadTestBase, CourseTestCase):
edx_video_id = 'test1' edx_video_id = 'test1'
get_videos_url = reverse_course_url('videos_handler', self.course.id) 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}) video_image_upload_url = self.get_url_for_course_key(self.course.id, {'edx_video_id': edx_video_id})
with make_image_file() as image_file: 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') 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) val_image_url = get_course_video_image_url(course_id=self.course.id, edx_video_id=edx_video_id)
...@@ -608,6 +648,171 @@ class VideoImageTestCase(VideoUploadTestBase, CourseTestCase): ...@@ -608,6 +648,171 @@ class VideoImageTestCase(VideoUploadTestBase, CourseTestCase):
else: else:
self.assertEqual(response_video['course_video_image_url'], None) 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 resolution 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': 1200, # not 16:9
'height': 100
},
'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
)
),
(
{
'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 minimum width / height
(
{
'width': 16, # 16x9
'height': 9
},
'The minimum allowed image resolution is {image_file_min_width}x{image_file_min_height}.'.format(
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
},
'The minimum allowed image resolution is {image_file_min_width}x{image_file_min_height}.'.format(
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
},
'The minimum allowed image resolution is {image_file_min_width}x{image_file_min_height}.'.format(
image_file_min_width=settings.VIDEO_IMAGE_MIN_WIDTH,
image_file_min_height=settings.VIDEO_IMAGE_MIN_HEIGHT
)
),
# Image file name validation
(
{
'prefix': u'nøn-åßç¡¡'
},
'The image file name can only contain letters, numbers, hyphens (-), and underscores (_).'
)
)
@ddt.unpack
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}) @patch.dict("django.conf.settings.FEATURES", {"ENABLE_VIDEO_UPLOAD_PIPELINE": True})
@override_settings(VIDEO_UPLOAD_PIPELINE={"BUCKET": "test_bucket", "ROOT_PATH": "test_root"}) @override_settings(VIDEO_UPLOAD_PIPELINE={"BUCKET": "test_bucket", "ROOT_PATH": "test_root"})
......
...@@ -16,6 +16,7 @@ from boto import s3 ...@@ -16,6 +16,7 @@ from boto import s3
from django.conf import settings from django.conf import settings
from django.contrib.auth.decorators import login_required from django.contrib.auth.decorators import login_required
from django.contrib.staticfiles.storage import staticfiles_storage 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.http import HttpResponse, HttpResponseNotFound
from django.utils.translation import ugettext as _ from django.utils.translation import ugettext as _
from django.utils.translation import ugettext_noop from django.utils.translation import ugettext_noop
...@@ -153,19 +154,69 @@ def videos_handler(request, course_key_string, edx_video_id=None): ...@@ -153,19 +154,69 @@ def videos_handler(request, course_key_string, edx_video_id=None):
return videos_post(course, request) 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 _('This image file is corrupted.')
image_file_aspect_ratio = abs(image_file_width / float(image_file_height) - settings.VIDEO_IMAGE_ASPECT_RATIO)
if 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
)
elif image_file_width < settings.VIDEO_IMAGE_MIN_WIDTH or image_file_height < settings.VIDEO_IMAGE_MIN_HEIGHT:
error = _('The minimum allowed image resolution is {image_file_min_width}x{image_file_min_height}.').format(
image_file_min_width=settings.VIDEO_IMAGE_MIN_WIDTH,
image_file_min_height=settings.VIDEO_IMAGE_MIN_HEIGHT
)
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 @expect_json
@login_required @login_required
@require_POST @require_POST
def video_images_handler(request, course_key_string, edx_video_id=None): def video_images_handler(request, course_key_string, edx_video_id=None):
if 'file' not in request.FILES: if 'file' not in request.FILES:
return JsonResponse({"error": _(u'No file provided for video image')}, status=400) return JsonResponse({'error': _(u'No file provided for video image')}, status=400)
image_file = request.FILES['file'] image_file = request.FILES['file']
file_name = request.FILES['file'].name error = validate_video_image(image_file)
if error:
return JsonResponse({'error': error}, status=400)
# TODO: Image file validation
with closing(image_file): with closing(image_file):
image_url = update_video_image(edx_video_id, course_key_string, image_file, file_name) image_url = update_video_image(edx_video_id, course_key_string, image_file, image_file.name)
LOGGER.info( LOGGER.info(
'VIDEOS: Video image uploaded for edx_video_id [%s] in course [%s]', edx_video_id, course_key_string 'VIDEOS: Video image uploaded for edx_video_id [%s] in course [%s]', edx_video_id, course_key_string
) )
......
...@@ -1350,3 +1350,20 @@ PROFILE_IMAGE_SIZES_MAP = { ...@@ -1350,3 +1350,20 @@ PROFILE_IMAGE_SIZES_MAP = {
###################### VIDEO IMAGE STORAGE ###################### ###################### VIDEO IMAGE STORAGE ######################
VIDEO_IMAGE_DEFAULT_FILENAME = 'images/video-images/default_video_image.png' 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
...@@ -338,6 +338,8 @@ INSTALLED_APPS += ('openedx.core.djangoapps.api_admin',) ...@@ -338,6 +338,8 @@ INSTALLED_APPS += ('openedx.core.djangoapps.api_admin',)
########################## VIDEO IMAGE STORAGE ############################ ########################## VIDEO IMAGE STORAGE ############################
VIDEO_IMAGE_SETTINGS = dict( VIDEO_IMAGE_SETTINGS = dict(
VIDEO_IMAGE_MAX_BYTES=2 * 1024 * 1024, # 2 MB
VIDEO_IMAGE_MIN_BYTES=2 * 1024, # 2 KB
STORAGE_KWARGS=dict( STORAGE_KWARGS=dict(
location=MEDIA_ROOT, location=MEDIA_ROOT,
base_url=MEDIA_URL, base_url=MEDIA_URL,
......
...@@ -2567,6 +2567,8 @@ TIME_ZONE_DISPLAYED_FOR_DEADLINES = 'UTC' ...@@ -2567,6 +2567,8 @@ TIME_ZONE_DISPLAYED_FOR_DEADLINES = 'UTC'
########################## VIDEO IMAGE STORAGE ############################ ########################## VIDEO IMAGE STORAGE ############################
VIDEO_IMAGE_SETTINGS = dict( VIDEO_IMAGE_SETTINGS = dict(
VIDEO_IMAGE_MAX_BYTES=2 * 1024 * 1024, # 2 MB
VIDEO_IMAGE_MIN_BYTES=2 * 1024, # 2 KB
# Backend storage # Backend storage
# STORAGE_CLASS='storages.backends.s3boto.S3BotoStorage', # STORAGE_CLASS='storages.backends.s3boto.S3BotoStorage',
# STORAGE_KWARGS=dict(bucket='video-image-bucket'), # STORAGE_KWARGS=dict(bucket='video-image-bucket'),
......
...@@ -11,7 +11,7 @@ from PIL import Image ...@@ -11,7 +11,7 @@ from PIL import Image
@contextmanager @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 Yields a named temporary file created with the specified image type and
options. options.
...@@ -21,9 +21,13 @@ def make_image_file(dimensions=(320, 240), extension=".jpeg", force_size=None, o ...@@ -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 temporary file will be closed and deleted automatically upon exiting
the `with` block. 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 = Image.new('RGB', dimensions, "green")
image_file = NamedTemporaryFile(suffix=extension) image_file = NamedTemporaryFile(prefix=prefix, suffix=extension)
try: try:
if orientation and orientation in xrange(1, 9): if orientation and orientation in xrange(1, 9):
exif_bytes = piexif.dump({'0th': {piexif.ImageIFD.Orientation: orientation}}) exif_bytes = piexif.dump({'0th': {piexif.ImageIFD.Orientation: orientation}})
......
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