Skip to content

Commit ec7b446

Browse files
authored
Merge pull request #237 from edx/schen/ECOM-5031
ECOM-5031 Incorporate the stdImage library for the program banner image
2 parents 8540060 + ebb4292 commit ec7b446

File tree

14 files changed

+199
-9
lines changed

14 files changed

+199
-9
lines changed

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,4 +85,5 @@ private.py
8585
docs/_build/
8686
course_discovery/static/bower_components/
8787
node_modules/
88+
course_discovery/media/
8889
docker/volumes/

course_discovery/apps/api/fields.py

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
from rest_framework import serializers
2+
3+
4+
class StdImageSerializerField(serializers.Field):
5+
"""
6+
Custom serializer field to render out proper JSON representation of the StdImage field on model
7+
"""
8+
def to_representation(self, obj):
9+
serialized = {}
10+
for size_key in obj.field.variations:
11+
# Get different sizes specs from the model field
12+
# Then get the file path from the available files
13+
sized_file = getattr(obj, size_key, None)
14+
if sized_file:
15+
path = sized_file.url
16+
serialized_image = serialized.setdefault(size_key, {})
17+
# In case MEDIA_URL does not include scheme+host, ensure that the URLs are absolute and not relative
18+
serialized_image['url'] = self.context['request'].build_absolute_uri(path)
19+
serialized_image['width'] = obj.field.variations[size_key]['width']
20+
serialized_image['height'] = obj.field.variations[size_key]['height']
21+
22+
return serialized
23+
24+
def to_internal_value(self, obj):
25+
""" We do not need to save/edit this banner image through serializer yet """
26+
pass

course_discovery/apps/api/serializers.py

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
from rest_framework.fields import DictField
1010
from taggit_serializer.serializers import TagListSerializerField, TaggitSerializer
1111

12+
from course_discovery.apps.api.fields import StdImageSerializerField
1213
from course_discovery.apps.catalogs.models import Catalog
1314
from course_discovery.apps.course_metadata.models import (
1415
Course, CourseRun, Image, Organization, Person, Prerequisite, Seat, Subject, Video, Program, ProgramType,
@@ -279,6 +280,7 @@ class ProgramSerializer(serializers.ModelSerializer):
279280
courses = serializers.SerializerMethodField()
280281
authoring_organizations = OrganizationSerializer(many=True)
281282
type = serializers.SlugRelatedField(slug_field='name', queryset=ProgramType.objects.all())
283+
banner_image = StdImageSerializerField()
282284

283285
def get_courses(self, program):
284286
course_serializer = ProgramCourseSerializer(
@@ -294,8 +296,8 @@ def get_courses(self, program):
294296
class Meta:
295297
model = Program
296298
fields = ('uuid', 'title', 'subtitle', 'type', 'marketing_slug', 'marketing_url', 'card_image_url',
297-
'banner_image_url', 'authoring_organizations', 'courses',)
298-
read_only_fields = ('uuid', 'marketing_url',)
299+
'banner_image', 'banner_image_url', 'authoring_organizations', 'courses',)
300+
read_only_fields = ('uuid', 'marketing_url', 'banner_image')
299301

300302

301303
class AffiliateWindowSerializer(serializers.ModelSerializer):

course_discovery/apps/api/tests/test_serializers.py

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
from course_discovery.apps.catalogs.tests.factories import CatalogFactory
1717
from course_discovery.apps.core.models import User
1818
from course_discovery.apps.core.tests.factories import UserFactory
19+
from course_discovery.apps.core.tests.helpers import make_image_file
1920
from course_discovery.apps.course_metadata.models import CourseRun, Program
2021
from course_discovery.apps.course_metadata.search_indexes import OrganizationsMixin
2122
from course_discovery.apps.course_metadata.tests.factories import (
@@ -251,7 +252,20 @@ def test_data(self):
251252
org_list = OrganizationFactory.create_batch(1)
252253
course_list = CourseFactory.create_batch(3)
253254
program = ProgramFactory(authoring_organizations=org_list, courses=course_list)
255+
program.banner_image = make_image_file('test_banner.jpg')
256+
program.save()
254257
serializer = ProgramSerializer(program, context={'request': request})
258+
expected_banner_image_urls = {
259+
size_key: {
260+
'url': '{}{}'.format(
261+
'http://testserver',
262+
getattr(program.banner_image, size_key).url
263+
),
264+
'width': program.banner_image.field.variations[size_key]['width'],
265+
'height': program.banner_image.field.variations[size_key]['height']
266+
}
267+
for size_key in program.banner_image.field.variations
268+
}
255269

256270
expected = {
257271
'uuid': str(program.uuid),
@@ -262,6 +276,7 @@ def test_data(self):
262276
'marketing_url': program.marketing_url,
263277
'card_image_url': program.card_image_url,
264278
'banner_image_url': program.banner_image_url,
279+
'banner_image': expected_banner_image_urls,
265280
'authoring_organizations': OrganizationSerializer(program.authoring_organizations, many=True).data,
266281
'courses': ProgramCourseSerializer(
267282
program.courses,
@@ -300,6 +315,7 @@ def test_with_exclusions(self):
300315
'marketing_slug': program.marketing_slug,
301316
'marketing_url': program.marketing_url,
302317
'card_image_url': program.card_image_url,
318+
'banner_image': {},
303319
'banner_image_url': program.banner_image_url,
304320
'authoring_organizations': OrganizationSerializer(program.authoring_organizations, many=True).data,
305321
'courses': ProgramCourseSerializer(
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
"""
2+
Helper methods for testing the processing of image files.
3+
"""
4+
from io import BytesIO
5+
from PIL import Image
6+
7+
from django.core.files.uploadedfile import SimpleUploadedFile
8+
9+
10+
def make_image_stream():
11+
"""
12+
Helper to generate values for program banner_image
13+
"""
14+
image = Image.new('RGB', (1440, 900), 'green')
15+
bio = BytesIO()
16+
image.save(bio, format='JPEG')
17+
return bio
18+
19+
20+
def make_image_file(name):
21+
image_stream = make_image_stream()
22+
return SimpleUploadedFile(name, image_stream.getvalue(), content_type='image/jpeg')

course_discovery/apps/core/tests/utils.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@
55
BaseFuzzyAttribute, FuzzyText, FuzzyChoice
66
)
77

8+
from course_discovery.apps.core.tests.helpers import make_image_stream
9+
810

911
class FuzzyDomain(BaseFuzzyAttribute):
1012
def fuzz(self):
@@ -81,3 +83,12 @@ def request_callback(request):
8183
return 200, {}, json.dumps(body)
8284

8385
return request_callback
86+
87+
88+
def mock_jpeg_callback():
89+
def request_callback(request): # pylint: disable=unused-argument
90+
image_stream = make_image_stream()
91+
92+
return 200, {}, image_stream.getvalue()
93+
94+
return request_callback

course_discovery/apps/course_metadata/data_loaders/api.py

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,10 @@
11
import logging
22
from decimal import Decimal
3+
from io import BytesIO
4+
import requests
35

46
from opaque_keys.edx.keys import CourseKey
7+
from django.core.files import File
58

69
from course_discovery.apps.core.models import Currency
710
from course_discovery.apps.course_metadata.data_loaders import AbstractDataLoader
@@ -299,6 +302,7 @@ def update_program(self, body):
299302
program, __ = Program.objects.update_or_create(uuid=uuid, defaults=defaults)
300303
self._update_program_organizations(body, program)
301304
self._update_program_courses_and_runs(body, program)
305+
self._update_program_banner_image(body, program)
302306
program.save()
303307
except Exception: # pylint: disable=broad-except
304308
logger.exception('Failed to load program %s', uuid)
@@ -335,3 +339,20 @@ def _get_banner_image_url(self, body):
335339
image_key = 'w{width}h{height}'.format(width=self.image_width, height=self.image_height)
336340
image_url = body.get('banner_image_urls', {}).get(image_key)
337341
return image_url
342+
343+
def _update_program_banner_image(self, body, program):
344+
image_url = self._get_banner_image_url(body)
345+
if not image_url:
346+
logger.warning('There are no banner image url for program %s', program.title)
347+
return
348+
349+
r = requests.get(image_url)
350+
if r.status_code == 200:
351+
banner_downloaded = File(BytesIO(r.content))
352+
program.banner_image.save(
353+
'banner.jpg',
354+
banner_downloaded
355+
)
356+
program.save()
357+
else:
358+
logger.exception('Loading the banner image %s for program %s failed', image_url, program.title)
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1 +1,2 @@
11
JSON = 'application/json'
2+
JPEG = 'image/jpeg'

course_discovery/apps/course_metadata/data_loaders/tests/test_api.py

Lines changed: 41 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,11 +7,11 @@
77
from django.test import TestCase
88
from pytz import UTC
99

10-
from course_discovery.apps.core.tests.utils import mock_api_callback
10+
from course_discovery.apps.core.tests.utils import mock_api_callback, mock_jpeg_callback
1111
from course_discovery.apps.course_metadata.data_loaders.api import (
1212
OrganizationsApiDataLoader, CoursesApiDataLoader, EcommerceApiDataLoader, AbstractDataLoader, ProgramsApiDataLoader
1313
)
14-
from course_discovery.apps.course_metadata.data_loaders.tests import JSON
14+
from course_discovery.apps.course_metadata.data_loaders.tests import JSON, JPEG
1515
from course_discovery.apps.course_metadata.data_loaders.tests.mixins import ApiClientTestMixin, DataLoaderTestMixin
1616
from course_discovery.apps.course_metadata.models import (
1717
Course, CourseRun, Organization, Seat, Program, ProgramType,
@@ -418,6 +418,22 @@ def assert_program_loaded(self, body):
418418
# Verify the additional course runs added in create_mock_courses_and_runs are excluded.
419419
self.assertEqual(program.excluded_course_runs.count(), len(course_codes))
420420

421+
def assert_program_banner_image_loaded(self, body):
422+
""" Assert a program corresponding to the specified data body has banner image loaded into DB """
423+
program = Program.objects.get(uuid=AbstractDataLoader.clean_string(body['uuid']), partner=self.partner)
424+
banner_image_url = body.get('banner_image_urls', {}).get('w1440h480')
425+
if banner_image_url:
426+
for size_key in program.banner_image.field.variations:
427+
# Get different sizes specs from the model field
428+
# Then get the file path from the available files
429+
sized_image = getattr(program.banner_image, size_key, None)
430+
self.assertIsNotNone(sized_image)
431+
if sized_image:
432+
path = getattr(program.banner_image, size_key).url
433+
self.assertIsNotNone(path)
434+
self.assertIsNotNone(program.banner_image.field.variations[size_key]['width'])
435+
self.assertIsNotNone(program.banner_image.field.variations[size_key]['height'])
436+
421437
@responses.activate
422438
def test_ingest(self):
423439
""" Verify the method ingests data from the Organizations API. """
@@ -427,7 +443,7 @@ def test_ingest(self):
427443
self.loader.ingest()
428444

429445
# Verify the API was called with the correct authorization header
430-
self.assert_api_called(1)
446+
self.assert_api_called(2)
431447

432448
# Verify the Programs were created correctly
433449
self.assertEqual(Program.objects.count(), len(api_data))
@@ -452,3 +468,25 @@ def test_ingest_with_missing_organizations(self):
452468

453469
self.assertEqual(Program.objects.count(), len(api_data))
454470
self.assertEqual(Organization.objects.count(), 0)
471+
472+
@responses.activate
473+
def test_ingest_with_existing_banner_image(self):
474+
programs = self.mock_api()
475+
476+
for program_data in programs:
477+
banner_image_url = program_data.get('banner_image_urls', {}).get('w1440h480')
478+
if banner_image_url:
479+
responses.add_callback(
480+
responses.GET,
481+
banner_image_url,
482+
callback=mock_jpeg_callback(),
483+
content_type=JPEG
484+
)
485+
486+
self.loader.ingest()
487+
# Verify the API was called with the correct authorization header
488+
self.assert_api_called(2)
489+
490+
for program in programs:
491+
self.assert_program_loaded(program)
492+
self.assert_program_banner_image_loaded(program)
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
# -*- coding: utf-8 -*-
2+
from __future__ import unicode_literals
3+
4+
from django.db import migrations, models
5+
import stdimage.models
6+
import stdimage.utils
7+
8+
9+
class Migration(migrations.Migration):
10+
11+
dependencies = [
12+
('course_metadata', '0018_auto_20160815_2252'),
13+
]
14+
15+
operations = [
16+
migrations.AddField(
17+
model_name='program',
18+
name='banner_image',
19+
field=stdimage.models.StdImageField(upload_to=stdimage.utils.UploadToAutoSlugClassNameDir('uuid', path='/media/programs/banner_images'), null=True, blank=True),
20+
),
21+
]

course_discovery/apps/course_metadata/models.py

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,8 @@
1414
from haystack.query import SearchQuerySet
1515
from simple_history.models import HistoricalRecords
1616
from sortedm2m.fields import SortedManyToManyField
17+
from stdimage.models import StdImageField
18+
from stdimage.utils import UploadToAutoSlugClassNameDir
1719
from taggit.managers import TaggableManager
1820

1921
from course_discovery.apps.core.models import Currency, Partner
@@ -584,7 +586,16 @@ class ProgramStatus(DjangoChoices):
584586
min_hours_effort_per_week = models.PositiveSmallIntegerField(null=True, blank=True)
585587
max_hours_effort_per_week = models.PositiveSmallIntegerField(null=True, blank=True)
586588
authoring_organizations = SortedManyToManyField(Organization, blank=True, related_name='authored_programs')
587-
589+
banner_image = StdImageField(
590+
upload_to=UploadToAutoSlugClassNameDir(path='/media/programs/banner_images', populate_from='uuid'),
591+
blank=True,
592+
null=True,
593+
variations={
594+
'large': (1440, 480),
595+
'medium': (726, 242),
596+
'small': (435, 145),
597+
'x-small': (348, 116)}
598+
)
588599
banner_image_url = models.URLField(null=True, blank=True, help_text=_('Image used atop detail pages'))
589600
card_image_url = models.URLField(null=True, blank=True, help_text=_('Image used for discovery cards'))
590601
video = models.ForeignKey(Video, default=None, null=True, blank=True)

course_discovery/apps/course_metadata/tests/test_models.py

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
import ddt
66
import pytz
77
from dateutil.parser import parse
8+
from django.conf import settings
89
from django.db import IntegrityError
910
from django.test import TestCase
1011
from freezegun import freeze_time
@@ -15,6 +16,7 @@
1516
AbstractNamedModel, AbstractMediaModel, AbstractValueModel, CourseOrganization, Course, CourseRun,
1617
SeatType)
1718
from course_discovery.apps.course_metadata.tests import factories
19+
from course_discovery.apps.core.tests.helpers import make_image_file
1820
from course_discovery.apps.ietf_language_tags.models import LanguageTag
1921

2022

@@ -357,6 +359,18 @@ def test_instructors(self):
357359

358360
self.assertEqual(self.program.instructors, set(instructors))
359361

362+
def test_banner_image(self):
363+
self.program.banner_image = make_image_file('test_banner.jpg')
364+
self.program.save()
365+
image_url_prefix = '{}program/'.format(settings.MEDIA_URL)
366+
self.assertIn(image_url_prefix, self.program.banner_image.url)
367+
for size_key in self.program.banner_image.field.variations:
368+
# Get different sizes specs from the model field
369+
# Then get the file path from the available files
370+
sized_file = getattr(self.program.banner_image, size_key, None)
371+
self.assertIsNotNone(sized_file)
372+
self.assertIn(image_url_prefix, sized_file.url)
373+
360374

361375
class PersonSocialNetworkTests(TestCase):
362376
"""Tests of the PersonSocialNetwork model."""

course_discovery/urls.py

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
from auth_backends.urls import auth_urlpatterns
1919
from django.conf import settings
2020
from django.conf.urls import include, url
21+
from django.conf.urls.static import static
2122
from django.contrib import admin
2223

2324
from course_discovery.apps.core import views as core_views
@@ -43,7 +44,11 @@
4344
url(r'^comments/', include('django_comments.urls')),
4445
]
4546

46-
if settings.DEBUG and os.environ.get('ENABLE_DJANGO_TOOLBAR', False): # pragma: no cover
47-
import debug_toolbar # pylint: disable=wrong-import-order,wrong-import-position,import-error
47+
if settings.DEBUG: # pragma: no cover
48+
# We need this url pattern to serve user uploaded assets according to
49+
# https://docs.djangoproject.com/en/1.10/howto/static-files/#serving-files-uploaded-by-a-user-during-development
50+
urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)
51+
if os.environ.get('ENABLE_DJANGO_TOOLBAR', False):
52+
import debug_toolbar # pylint: disable=wrong-import-order,wrong-import-position,import-error
4853

49-
urlpatterns.append(url(r'^__debug__/', include(debug_toolbar.urls)))
54+
urlpatterns.append(url(r'^__debug__/', include(debug_toolbar.urls)))

requirements/base.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ django-haystack==2.5.0
1212
django-libsass==0.7
1313
django-simple-history==1.8.1
1414
django-sortedm2m==1.3.2
15+
django-stdimage==2.3.3
1516
django-storages==1.5.0
1617
django-taggit==0.20.2
1718
django-taggit-serializer==0.1.5

0 commit comments

Comments
 (0)