Skip to content
This repository has been archived by the owner on Jun 6, 2019. It is now read-only.

Commit

Permalink
Merge pull request #108 from edx/jsa/ecom-3196
Browse files Browse the repository at this point in the history
Add support for program banner images
  • Loading branch information
Jim Abramson committed Mar 28, 2016
2 parents 0021242 + 7342d28 commit 30d12a0
Show file tree
Hide file tree
Showing 16 changed files with 976 additions and 6 deletions.
4 changes: 3 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -73,8 +73,9 @@ coverage/
override.cfg
private.py

# JetBrains
# IDEs
.idea
.ropeproject

# OS X
.DS_Store
Expand All @@ -87,3 +88,4 @@ private.py
docs/_build/
node_modules/
programs/static/bower_components/
programs/media/
14 changes: 12 additions & 2 deletions programs/apps/api/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -261,13 +261,23 @@ class Meta(object): # pylint: disable=missing-docstring
model = models.Program
fields = (
'id', 'name', 'subtitle', 'category', 'status', 'marketing_slug', 'organizations', 'course_codes',
'created', 'modified'
'created', 'modified', 'banner_image_urls'
)
read_only_fields = ('id', 'created', 'modified')
read_only_fields = ('id', 'created', 'modified', 'banner_image_urls')

banner_image_urls = serializers.SerializerMethodField()
organizations = ProgramOrganizationSerializer(many=True, source='programorganization_set')
course_codes = ProgramCourseCodeSerializer(many=True, source='programcoursecode_set', required=False)

def get_banner_image_urls(self, instance):
"""
Render public-facing URLs for the available banner images.
"""
url_items = instance.banner_image.resized_urls.items()
# in case MEDIA_URL does not include scheme+host, ensure that the URLs are absolute and not relative
url_items = [[size, self.context['request'].build_absolute_uri(url)] for size, url in url_items]
return {'{}x{}'.format(*size): url for size, url in url_items}

def create(self, validated_data):
"""
Create a Program and link it with the provided organization.
Expand Down
52 changes: 51 additions & 1 deletion programs/apps/api/v1/tests/test_views.py
Original file line number Diff line number Diff line change
@@ -1,14 +1,17 @@
"""
Tests for Programs API views (v1).
"""
from cStringIO import StringIO
import datetime
import itertools
import json

import ddt
from django.core.urlresolvers import reverse
from django.test import TestCase
from django.core.files.uploadedfile import SimpleUploadedFile
from django.test import override_settings, TestCase
from mock import ANY
from PIL import Image
import pytz

from programs.apps.api.v1.tests.mixins import AuthClientMixin, JwtMixin
Expand Down Expand Up @@ -188,6 +191,7 @@ def test_create(self):
u"created": ANY,
u"modified": ANY,
u'marketing_slug': '',
u'banner_image_urls': {}
}
)

Expand Down Expand Up @@ -287,6 +291,7 @@ def test_view_admin(self, status):
u"created": program.created.strftime(DRF_DATE_FORMAT),
u"modified": program.modified.strftime(DRF_DATE_FORMAT),
u'marketing_slug': program.marketing_slug,
u'banner_image_urls': {}
}
)

Expand Down Expand Up @@ -314,9 +319,53 @@ def test_view_learner(self, status):
u"created": program.created.strftime(DRF_DATE_FORMAT),
u"modified": program.modified.strftime(DRF_DATE_FORMAT),
u'marketing_slug': program.marketing_slug,
u'banner_image_urls': {}
}
)

def make_banner_image_file(self, name):
"""
Helper to generate values for Program.banner_image
"""
image = Image.new('RGB', (1440, 900), 'green')
sio = StringIO()
image.save(sio, format='JPEG')
return SimpleUploadedFile(name, sio.getvalue(), content_type='image/jpeg')

def assert_correct_banner_image_urls(self, url_prepend=''):
"""
DRY test helper. Ensure that the serializer generates a complete set
of absolute URLs for the banner_image when one is set.
"""
program = ProgramFactory.create(status=ProgramStatus.ACTIVE)
program.banner_image = self.make_banner_image_file('test_filename.jpg')
program.save()

response = self._make_request(program_id=program.id)
self.assertEqual(response.status_code, 200)

expected_urls = {
'{}x{}'.format(*size): '{}{}__{}x{}.jpg'.format(url_prepend, program.banner_image.url, *size)
for size in program.banner_image.field.sizes
}
self.assertEqual(response.data[u'banner_image_urls'], expected_urls)

@override_settings(MEDIA_URL='/test/media/url/')
def test_banner_image_urls(self):
"""
Ensure that the request is used to generate absolute URLs for banner
images, when MEDIA_ROOT does not specify an explicit host.
"""
self.assert_correct_banner_image_urls(url_prepend='http://testserver')

@override_settings(MEDIA_URL='https://example.com/test/media/url/')
def test_banner_image_urls_with_absolute_media_url(self):
"""
Ensure that banner image URLs are correctly presented when storage
is configured to use absolute URLs.
"""
self.assert_correct_banner_image_urls()

def test_view_with_nested(self):
"""
Ensure that nested serializers are working in program detail views.
Expand Down Expand Up @@ -380,6 +429,7 @@ def test_view_with_nested(self):
u"created": program.created.strftime(DRF_DATE_FORMAT),
u"modified": program.modified.strftime(DRF_DATE_FORMAT),
u'marketing_slug': program.marketing_slug,
u'banner_image_urls': {}
}
)

Expand Down
13 changes: 13 additions & 0 deletions programs/apps/core/s3utils.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
"""
Custom S3 storage backends.
"""
from functools import partial

from django.conf import settings
from storages.backends.s3boto import S3BotoStorage


MediaS3BotoStorage = partial(
S3BotoStorage,
location=settings.MEDIA_ROOT.strip('/')
)
23 changes: 23 additions & 0 deletions programs/apps/core/tests/test_s3utils.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
"""
Test s3 utilities
"""
from django.test import TestCase
from django.conf import settings
from storages.backends.s3boto import S3BotoStorage

from programs.apps.core.s3utils import MediaS3BotoStorage


class MediaS3BotoStorageTestCase(TestCase):
"""
Test the MediaS3BotoStorage django storage driver
"""

def test_storage_init(self):
"""
The object is just a partial to S3BotoStorage from django-storages,
with some settings piped in. Ensure this works as expected.
"""
storage = MediaS3BotoStorage()
self.assertIsInstance(storage, S3BotoStorage)
self.assertEqual(storage.location, settings.MEDIA_ROOT.strip('/'))
2 changes: 1 addition & 1 deletion programs/apps/programs/admin.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ class ProgramAdmin(admin.ModelAdmin):
list_display = ('name', 'status', 'category')
list_filter = ('status', 'category')
search_fields = ('name',)
fields = ('name', 'category', 'status', 'subtitle', 'marketing_slug',)
fields = ('name', 'category', 'status', 'subtitle', 'marketing_slug', 'banner_image')
inlines = (ProgramOrganizationInline,)


Expand Down
206 changes: 206 additions & 0 deletions programs/apps/programs/fields.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,206 @@
"""
Custom fields used in models in the programs django app.
"""
from contextlib import closing

from django.db import models
from django.db.models.fields.files import ImageFieldFile
from PIL import Image

from .image_helpers import (
create_image_file,
crop_image_to_aspect_ratio,
scale_image,
set_color_mode_to_rgb,
validate_image_size,
validate_image_type,
)


class ResizingImageFieldFile(ImageFieldFile):
"""
Custom field value behavior for images stored in `ResizingImageField`
fields on model instances. See `ResizingImageField` docs for more info.
"""
@property
def resized_names(self):
"""
Return the names of the resized copies of this image (if any), in a
dictionary keyed by tuples of (width, height).
Returns:
dict
"""
if not self.name:
return {}
else:
return {
(width, height): '{}__{}x{}.jpg'.format(self.name, width, height)
for width, height in self.field.sizes
}

@property
def resized_urls(self):
"""
Return the URLs of the resized copies of this image (if any), in a
dictionary keyed by tuples of (width, height).
Returns:
dict
"""
if not self.name:
return {}
else:
return {size: self.storage.url(name) for size, name in self.resized_names.items()}

@property
def minimum_original_size(self):
"""
Return the minimum acceptable width and height of an uploaded image
(which is the same as the greatest (width, height) pair in
self.field.sizes).
Returns:
tuple(int, int)
"""
return sorted(self.field.sizes)[-1]

def create_resized_copies(self):
"""
Generate and store resized copies of the original image, using the
django storage API.
Returns:
None
"""
original = Image.open(self.file)
image = set_color_mode_to_rgb(original)
ref_width, ref_height = self.minimum_original_size
image = crop_image_to_aspect_ratio(image, float(ref_width) / float(ref_height))

for size, name in self.resized_names.items():
scaled = scale_image(image, *size)
with closing(create_image_file(scaled)) as scaled_image_file:
self.storage.save(name, scaled_image_file)


class ResizingImageField(models.ImageField):
"""
Customized ImageField that automatically generates and stores a set of
resized copies along with the original image files.
WARNING: this does not presently correct for orientation - processed images
taken directly from digital cameras may appear with unexpected rotation.
TODO: purge stale copies.
"""
attr_class = ResizingImageFieldFile

def __init__(self, path_template, sizes, *a, **kw):
"""
Arguments:
path_template (basestring):
A format string that will be templated against model
instances to produce a directory name for stored files.
For example, if your model has a unique "name" field, you
could use '/mymodel/{name}/' as the path template.
To facilitate management/cleanup of stale copies, it's
important to use a template that will result in a unique and
immutable value for each model object.
Note that using the primary key ('id') in your template is
dangerous, however, because this will evaluate to `None` when
initially storing a new model instance which has not yet been
assigned an id by the database. Therefore, choose a value or
values which can be assigned before the object is physically
saved, for example a UUID or an application-generated timestamp.
sizes:
A sequence of tuples of (width, height) at which to resize
copies of the original image.
The largest of the sizes will be used as the minimum allowed
dimensions of a newly-stored file.
WARNING: presently, all of the sizes must have the same aspect
ratio.
"""
if callable(kw.get('upload_to')):
# if an upload_to kwarg is passed with a callable value, the
# superclass will use it to overwrite the value of
# self.generate_filename (which is redefined below).
# Since that will lead to unexpected behavior, prevent it from
# happening.
raise Exception(
'ResizingImageField does not support passing a custom callable '
'for the `upload_to` keyword arg.'
)
super(ResizingImageField, self).__init__(*a, **kw)
self.path_template = path_template.rstrip('/')
self.sizes = sizes

def generate_filename(self, model_instance, filename): # pylint: disable=method-hidden
"""
Join our path template with the filename assigned by django storage to
generate a filename for newly-uploaded file.
Arguments:
model_instance (Model):
The model instance whose value is about to be saved.
filename (basestring):
The filename assigned to a newly uploaded file by django.
Returns:
ResizingImageFieldFile
"""
pathname = self.path_template.format(**model_instance.__dict__) # pylint: disable=no-member
return '{}/{}'.format(pathname, filename)

def pre_save(self, model_instance, add):
"""
Override pre_save to create resized copies of the original upload
when necessary (i.e. a newly stored file).
Arguments:
model_instance (Model):
The model instance whose value is about to be saved.
add (bool):
Whether the model instance is being added (inserted) for the
first time.
Returns:
ResizingImageFieldFile
"""
# before invoking super, determine if we are dealing with a file that has previously been saved to storage.
# we have to check this before calling super since that will store a new file and set _committed to True.
original_field_value = getattr(model_instance, self.attname)
originally_committed = getattr(original_field_value, '_committed', False)

field_value = super(ResizingImageField, self).pre_save(model_instance, add)

# if we just stored a new file, do additional validation, then generate and save resized copies.
if not originally_committed:
validate_image_type(field_value.file)
validate_image_size(field_value.file, *field_value.minimum_original_size)
field_value.create_resized_copies()

return field_value

def deconstruct(self):
"""
Provide instantiation metadata for the migrations framework.
"""
name, path, args, kwargs = super(ResizingImageField, self).deconstruct()
kwargs['sizes'] = self.sizes
kwargs['path_template'] = self.path_template
return name, path, args, kwargs
Loading

0 comments on commit 30d12a0

Please sign in to comment.