diff --git a/common/lib/xmodule/xmodule/contentstore/content.py b/common/lib/xmodule/xmodule/contentstore/content.py
index 544cef6b61..9337b176e8 100644
--- a/common/lib/xmodule/xmodule/contentstore/content.py
+++ b/common/lib/xmodule/xmodule/contentstore/content.py
@@ -39,13 +39,23 @@ class StaticContent(object):
return self.location.category == 'thumbnail'
@staticmethod
- def generate_thumbnail_name(original_name):
+ def generate_thumbnail_name(original_name, dimensions=None):
+ """
+ - original_name: Name of the asset (typically its location.name)
+ - dimensions: `None` or a tuple of (width, height) in pixels
+ """
name_root, ext = os.path.splitext(original_name)
if not ext == XASSET_THUMBNAIL_TAIL_NAME:
name_root = name_root + ext.replace(u'.', u'-')
+
+ if dimensions:
+ width, height = dimensions # pylint: disable=unpacking-non-sequence
+ name_root += "-{}x{}".format(width, height)
+
return u"{name_root}{extension}".format(
name_root=name_root,
- extension=XASSET_THUMBNAIL_TAIL_NAME,)
+ extension=XASSET_THUMBNAIL_TAIL_NAME,
+ )
@staticmethod
def compute_location(course_key, path, revision=None, is_thumbnail=False):
@@ -248,11 +258,25 @@ class ContentStore(object):
"""
raise NotImplementedError
- def generate_thumbnail(self, content, tempfile_path=None):
+ def generate_thumbnail(self, content, tempfile_path=None, dimensions=None):
+ """Create a thumbnail for a given image.
+
+ Returns a tuple of (StaticContent, AssetKey)
+
+ `content` is the StaticContent representing the image you want to make a
+ thumbnail out of.
+
+ `tempfile_path` is a string path to the location of a file to read from
+ in order to grab the image data, instead of relying on `content.data`
+
+ `dimensions` is an optional param that represents (width, height) in
+ pixels. It defaults to None.
+ """
thumbnail_content = None
# use a naming convention to associate originals with the thumbnail
- thumbnail_name = StaticContent.generate_thumbnail_name(content.location.name)
-
+ thumbnail_name = StaticContent.generate_thumbnail_name(
+ content.location.name, dimensions=dimensions
+ )
thumbnail_file_location = StaticContent.compute_location(
content.location.course_key, thumbnail_name, is_thumbnail=True
)
@@ -273,8 +297,11 @@ class ContentStore(object):
# I've seen some exceptions from the PIL library when trying to save palletted
# PNG files to JPEG. Per the google-universe, they suggest converting to RGB first.
im = im.convert('RGB')
- size = 128, 128
- im.thumbnail(size, Image.ANTIALIAS)
+
+ if not dimensions:
+ dimensions = (128, 128)
+
+ im.thumbnail(dimensions, Image.ANTIALIAS)
thumbnail_file = StringIO.StringIO()
im.save(thumbnail_file, 'JPEG')
thumbnail_file.seek(0)
diff --git a/lms/djangoapps/courseware/views.py b/lms/djangoapps/courseware/views.py
index 72c47b6592..f061e8e937 100644
--- a/lms/djangoapps/courseware/views.py
+++ b/lms/djangoapps/courseware/views.py
@@ -54,6 +54,7 @@ from openedx.core.djangoapps.credit.api import (
is_user_eligible_for_credit,
is_credit_course
)
+from openedx.core.djangoapps.content.course_overviews.models import CourseOverview
from courseware.models import StudentModuleHistory
from courseware.model_data import FieldDataCache, ScoresClient
from .module_render import toc_for_course, get_module_for_descriptor, get_module, get_module_by_usage_id
@@ -866,6 +867,9 @@ def course_about(request, course_id):
# get prerequisite courses display names
pre_requisite_courses = get_prerequisite_courses_display(course)
+ # Overview
+ overview = CourseOverview.get_from_id(course.id)
+
return render_to_response('courseware/course_about.html', {
'course': course,
'staff_access': staff_access,
@@ -887,7 +891,8 @@ def course_about(request, course_id):
'disable_courseware_header': True,
'can_add_course_to_cart': can_add_course_to_cart,
'cart_link': reverse('shoppingcart.views.show_cart'),
- 'pre_requisite_courses': pre_requisite_courses
+ 'pre_requisite_courses': pre_requisite_courses,
+ 'course_image_urls': overview.image_urls,
})
diff --git a/lms/templates/courseware/course_about.html b/lms/templates/courseware/course_about.html
index 1d009726d4..e16259e3e4 100644
--- a/lms/templates/courseware/course_about.html
+++ b/lms/templates/courseware/course_about.html
@@ -173,14 +173,14 @@ from openedx.core.lib.courses import course_image_url
% if get_course_about_section(request, course, "video"):
-
})
+
%else:
% endif
diff --git a/lms/templates/dashboard/_dashboard_course_listing.html b/lms/templates/dashboard/_dashboard_course_listing.html
index 0f1337700a..cab9f83c66 100644
--- a/lms/templates/dashboard/_dashboard_course_listing.html
+++ b/lms/templates/dashboard/_dashboard_course_listing.html
@@ -63,16 +63,16 @@ from student.helpers import (
% if show_courseware_link:
% if not is_course_blocked:
-
+
% else:
-
+
% endif
% else:
-
+
% endif
% if settings.FEATURES.get('ENABLE_VERIFIED_CERTIFICATES') and course_verified_certs.get('display_mode') != 'audit':
@@ -281,7 +281,7 @@ from student.helpers import (
% if credit_status is not None:
<%include file="_dashboard_credit_info.html" args="credit_status=credit_status"/>
- % endif
+ % endif
% if verification_status.get('status') in [VERIFY_STATUS_NEED_TO_VERIFY, VERIFY_STATUS_SUBMITTED, VERIFY_STATUS_APPROVED, VERIFY_STATUS_NEED_TO_REVERIFY] and not is_course_blocked:
diff --git a/openedx/core/djangoapps/content/course_overviews/admin.py b/openedx/core/djangoapps/content/course_overviews/admin.py
index 17f8fa5e77..7b1c90355a 100644
--- a/openedx/core/djangoapps/content/course_overviews/admin.py
+++ b/openedx/core/djangoapps/content/course_overviews/admin.py
@@ -5,7 +5,8 @@ name, and start dates, but don't actually need to crawl into course content.
"""
from django.contrib import admin
-from .models import CourseOverview
+from config_models.admin import ConfigurationModelAdmin
+from .models import CourseOverview, CourseOverviewImageConfig, CourseOverviewImageSet
class CourseOverviewAdmin(admin.ModelAdmin):
@@ -35,4 +36,51 @@ class CourseOverviewAdmin(admin.ModelAdmin):
search_fields = ['id', 'display_name']
+class CourseOverviewImageConfigAdmin(ConfigurationModelAdmin):
+ """
+ Basic configuration for CourseOverview Image thumbnails.
+
+ By default this is disabled. If you change the dimensions of the images with
+ a new config after thumbnails have already been generated, you need to clear
+ the entries in CourseOverviewImageSet manually for new entries to be
+ created.
+ """
+ list_display = [
+ 'change_date',
+ 'changed_by',
+ 'enabled',
+ 'large_width',
+ 'large_height',
+ 'small_width',
+ 'small_height'
+ ]
+
+ def get_list_display(self, request):
+ """
+ Restore default list_display behavior.
+
+ ConfigurationModelAdmin overrides this, but in a way that doesn't
+ respect the ordering. This lets us customize it the usual Django admin
+ way.
+ """
+ return self.list_display
+
+
+class CourseOverviewImageSetAdmin(admin.ModelAdmin):
+ """
+ Thumbnail images associated with CourseOverviews. This should be used for
+ debugging purposes only -- e.g. don't edit these values.
+ """
+ list_display = [
+ 'course_overview',
+ 'small_url',
+ 'large_url',
+ ]
+ search_fields = ['course_overview__id']
+ readonly_fields = ['course_overview_id']
+ fields = ('course_overview_id', 'small_url', 'large_url')
+
+
admin.site.register(CourseOverview, CourseOverviewAdmin)
+admin.site.register(CourseOverviewImageConfig, CourseOverviewImageConfigAdmin)
+admin.site.register(CourseOverviewImageSet, CourseOverviewImageSetAdmin)
diff --git a/openedx/core/djangoapps/content/course_overviews/migrations/0006_courseoverviewimageset.py b/openedx/core/djangoapps/content/course_overviews/migrations/0006_courseoverviewimageset.py
new file mode 100644
index 0000000000..609950bbbb
--- /dev/null
+++ b/openedx/core/djangoapps/content/course_overviews/migrations/0006_courseoverviewimageset.py
@@ -0,0 +1,30 @@
+# -*- coding: utf-8 -*-
+from __future__ import unicode_literals
+
+from django.db import migrations, models
+import django.utils.timezone
+import model_utils.fields
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('course_overviews', '0005_delete_courseoverviewgeneratedhistory'),
+ ]
+
+ operations = [
+ migrations.CreateModel(
+ name='CourseOverviewImageSet',
+ fields=[
+ ('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)),
+ ('created', model_utils.fields.AutoCreatedField(default=django.utils.timezone.now, verbose_name='created', editable=False)),
+ ('modified', model_utils.fields.AutoLastModifiedField(default=django.utils.timezone.now, verbose_name='modified', editable=False)),
+ ('small_url', models.TextField(default=b'', blank=True)),
+ ('large_url', models.TextField(default=b'', blank=True)),
+ ('course_overview', models.OneToOneField(related_name='image_set', to='course_overviews.CourseOverview')),
+ ],
+ options={
+ 'abstract': False,
+ },
+ ),
+ ]
diff --git a/openedx/core/djangoapps/content/course_overviews/migrations/0007_courseoverviewimageconfig.py b/openedx/core/djangoapps/content/course_overviews/migrations/0007_courseoverviewimageconfig.py
new file mode 100644
index 0000000000..8185fc1783
--- /dev/null
+++ b/openedx/core/djangoapps/content/course_overviews/migrations/0007_courseoverviewimageconfig.py
@@ -0,0 +1,34 @@
+# -*- coding: utf-8 -*-
+from __future__ import unicode_literals
+
+from django.db import migrations, models
+import django.db.models.deletion
+from django.conf import settings
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ migrations.swappable_dependency(settings.AUTH_USER_MODEL),
+ ('course_overviews', '0006_courseoverviewimageset'),
+ ]
+
+ operations = [
+ migrations.CreateModel(
+ name='CourseOverviewImageConfig',
+ fields=[
+ ('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)),
+ ('change_date', models.DateTimeField(auto_now_add=True, verbose_name='Change date')),
+ ('enabled', models.BooleanField(default=False, verbose_name='Enabled')),
+ ('small_width', models.IntegerField(default=375)),
+ ('small_height', models.IntegerField(default=200)),
+ ('large_width', models.IntegerField(default=750)),
+ ('large_height', models.IntegerField(default=400)),
+ ('changed_by', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, editable=False, to=settings.AUTH_USER_MODEL, null=True, verbose_name='Changed by')),
+ ],
+ options={
+ 'ordering': ('-change_date',),
+ 'abstract': False,
+ },
+ ),
+ ]
diff --git a/openedx/core/djangoapps/content/course_overviews/models.py b/openedx/core/djangoapps/content/course_overviews/models.py
index a4149820d5..659bf21ee2 100644
--- a/openedx/core/djangoapps/content/course_overviews/models.py
+++ b/openedx/core/djangoapps/content/course_overviews/models.py
@@ -9,11 +9,14 @@ from django.db.models.fields import BooleanField, DateTimeField, DecimalField, T
from django.db.utils import IntegrityError
from django.template import defaultfilters
from django.utils.translation import ugettext
-from lms.djangoapps import django_comment_client
+
+from ccx_keys.locator import CCXLocator
from model_utils.models import TimeStampedModel
from opaque_keys.edx.keys import CourseKey
-from openedx.core.djangoapps.models.course_details import CourseDetails
+from config_models.models import ConfigurationModel
+from lms.djangoapps import django_comment_client
+from openedx.core.djangoapps.models.course_details import CourseDetails
from util.date_utils import strftime_localized
from xmodule import course_metadata_utils
from xmodule.course_module import CourseDescriptor, DEFAULT_START_DATE
@@ -21,9 +24,6 @@ from xmodule.error_module import ErrorDescriptor
from xmodule.modulestore.django import modulestore
from xmodule_django.models import CourseKeyField, UsageKeyField
-from ccx_keys.locator import CCXLocator
-
-
log = logging.getLogger(__name__)
@@ -213,6 +213,8 @@ class CourseOverview(TimeStampedModel):
CourseOverviewTab(tab_id=tab.tab_id, course_overview=course_overview)
for tab in course.tabs
])
+ CourseOverviewImageSet.create_for_course(course_overview, course)
+
except IntegrityError:
# There is a rare race condition that will occur if
# CourseOverview.get_from_id is called while a
@@ -256,13 +258,20 @@ class CourseOverview(TimeStampedModel):
course from the module store.
"""
try:
- course_overview = cls.objects.get(id=course_id)
+ course_overview = cls.objects.select_related('image_set').get(id=course_id)
if course_overview.version < cls.VERSION:
# Throw away old versions of CourseOverview, as they might contain stale data.
course_overview.delete()
course_overview = None
except cls.DoesNotExist:
course_overview = None
+
+ # Regenerate the thumbnail images if they're missing (either because
+ # they were never generated, or because they were flushed out after
+ # a change to CourseOverviewImageConfig.
+ if course_overview and not hasattr(course_overview, 'image_set'):
+ CourseOverviewImageSet.create_for_course(course_overview)
+
return course_overview or cls.load_from_module_store(course_id)
def clean_id(self, padding_char='='):
@@ -489,6 +498,45 @@ class CourseOverview(TimeStampedModel):
return True
return False
+ @property
+ def image_urls(self):
+ """
+ Return a dict with all known URLs for this course image.
+
+ Current resolutions are:
+ raw = original upload from the user
+ small = thumbnail with dimensions CourseOverviewImageConfig.current().small
+ large = thumbnail with dimensions CourseOverviewImageConfig.current().large
+
+ If no thumbnails exist, the raw (originally uploaded) image will be
+ returned for all resolutions.
+ """
+ # This is either the raw image that the course team uploaded, or the
+ # settings.DEFAULT_COURSE_ABOUT_IMAGE_URL if they didn't specify one.
+ raw_image_url = self.course_image_url
+
+ # Default all sizes to return the raw image if there is no
+ # CourseOverviewImageSet associated with this CourseOverview. This can
+ # happen because we're disabled via CourseOverviewImageConfig.
+ urls = {
+ 'raw': raw_image_url,
+ 'small': raw_image_url,
+ 'large': raw_image_url,
+ }
+
+ # If we do have a CourseOverviewImageSet, we still default to the raw
+ # images if our thumbnails are blank (might indicate that there was a
+ # processing error of some sort while trying to generate thumbnails).
+ if hasattr(self, 'image_set') and CourseOverviewImageConfig.current().enabled:
+ urls['small'] = self.image_set.small_url or raw_image_url
+ urls['large'] = self.image_set.large_url or raw_image_url
+
+ return urls
+
+ def __unicode__(self):
+ """Represent ourselves with the course key."""
+ return unicode(self.id)
+
class CourseOverviewTab(models.Model):
"""
@@ -496,3 +544,173 @@ class CourseOverviewTab(models.Model):
"""
tab_id = models.CharField(max_length=50)
course_overview = models.ForeignKey(CourseOverview, db_index=True, related_name="tabs")
+
+
+class CourseOverviewImageSet(TimeStampedModel):
+ """
+ Model for Course overview images. Each column is an image type/size.
+
+ You should basically never use this class directly. Read from
+ CourseOverview.image_urls instead.
+
+ Special Notes on Deployment/Rollback/Changes:
+
+ 1. By default, this functionality is disabled. To turn it on, you have to
+ create a CourseOverviewImageConfig entry via Django Admin and select
+ enabled=True.
+
+ 2. If it is enabled in configuration, it will lazily create thumbnails as
+ individual CourseOverviews are requested. This is independent of the
+ CourseOverview's cls.VERSION scheme. This is to better support the use
+ case where someone might want to change the thumbnail resolutions for
+ their theme -- we didn't want to tie the code-based data schema of
+ CourseOverview to configuration changes.
+
+ 3. A CourseOverviewImageSet is automatically deleted when the CourseOverview
+ it belongs to is deleted. So it will be regenerated whenever there's a
+ new publish or the CourseOverview schema version changes. It's not
+ particularly smart about this, and will just re-write the same thumbnails
+ over and over to the same location without checking to see if there were
+ changes.
+
+ 4. Just because a CourseOverviewImageSet is successfully created does not
+ mean that any thumbnails exist. There might have been a processing error,
+ or there might simply be no source image to create a thumbnail out of.
+ In this case, accessing CourseOverview.image_urls will return the value
+ for course.course_image_url for all resolutions. CourseOverviewImageSet
+ will *not* try to regenerate if there is a model entry with blank values
+ for the URLs -- the assumption is that either there's no data there or
+ something has gone wrong and needs fixing in code.
+
+ 5. If you want to change thumbnail resolutions, you need to create a new
+ CourseOverviewImageConfig with the desired dimensions and then wipe the
+ values in CourseOverviewImageSet.
+
+ Logical next steps that I punted on for this first cut:
+
+ 1. Converting other parts of the app to use this.
+
+ Our first cut only affects About Pages and the Student Dashboard. But
+ most places that use course_image_url() should be converted -- e.g.
+ course discovery, mobile, etc.
+
+ 2. Center cropping the image before scaling.
+
+ This is desirable, but it involves a few edge cases (what the rounding
+ policy is, what to do with undersized images, etc.) The behavior that
+ we implemented is at least no worse than what was already there in terms
+ of distorting images.
+
+ 3. Automatically invalidating entries based on CourseOverviewImageConfig.
+
+ There are two basic paths I can think of for this. The first is to
+ completely wipe this table when the config changes. The second is to
+ actually tie the config as a foreign key from this model -- so you could
+ do the comparison to see if the image_set's config_id matched
+ CourseOverviewImageConfig.current() and invalidate it if they didn't
+ match. I punted on this mostly because it's just not something that
+ happens much at all in practice, there is an understood (if manual)
+ process to do it, and it can happen in a follow-on PR if anyone is
+ interested in extending this functionality.
+
+ """
+ course_overview = models.OneToOneField(CourseOverview, db_index=True, related_name="image_set")
+ small_url = models.TextField(blank=True, default="")
+ large_url = models.TextField(blank=True, default="")
+
+ @classmethod
+ def create_for_course(cls, course_overview, course=None):
+ """
+ Create thumbnail images for this CourseOverview.
+
+ This will save the CourseOverviewImageSet it creates before it returns.
+ """
+ from openedx.core.lib.courses import create_course_image_thumbnail
+
+ # If image thumbnails are not enabled, do nothing.
+ config = CourseOverviewImageConfig.current()
+ if not config.enabled:
+ return
+
+ # If a course object was provided, use that. Otherwise, pull it from
+ # CourseOverview's course_id. This happens because sometimes we are
+ # generated as part of the CourseOverview creation (course is available
+ # and passed in), and sometimes the CourseOverview already exists.
+ if not course:
+ course = modulestore().get_course(course_overview.id)
+
+ image_set = CourseOverviewImageSet(course_overview=course_overview)
+ if course.course_image:
+ # Try to create a thumbnails of the course image. If this fails for any
+ # reason (weird format, non-standard URL, etc.), the URLs will default
+ # to being blank. No matter what happens, we don't want to bubble up
+ # a 500 -- an image_set is always optional.
+ try:
+ image_set.small_url = create_course_image_thumbnail(course, config.small)
+ image_set.large_url = create_course_image_thumbnail(course, config.large)
+ except Exception: # pylint: disable=broad-except
+ log.exception(
+ "Could not create thumbnail for course %s with image %s (small=%s), (large=%s)",
+ course.id,
+ course.course_image,
+ config.small,
+ config.large
+ )
+
+ # Regardless of whether we created thumbnails or not, we need to save
+ # this record before returning. If no thumbnails were created (there was
+ # an error or the course has no source course_image), our url fields
+ # just keep their blank defaults.
+ try:
+ image_set.save()
+ course_overview.image_set = image_set
+ except (IntegrityError, ValueError):
+ # In the event of a race condition that tries to save two image sets
+ # to the same CourseOverview, we'll just silently pass on the one
+ # that fails. They should be the same data anyway.
+ #
+ # The ValueError above is to catch the following error that can
+ # happen in Django 1.8.4+ if the CourseOverview object fails to save
+ # (again, due to race condition).
+ #
+ # Example: ValueError: save() prohibited to prevent data loss due
+ # to unsaved related object 'course_overview'.")
+ pass
+
+ def __unicode__(self):
+ return u"CourseOverviewImageSet({}, small_url={}, large_url={})".format(
+ self.course_overview_id, self.small_url, self.large_url
+ )
+
+
+class CourseOverviewImageConfig(ConfigurationModel):
+ """
+ This sets the size of the thumbnail images that Course Overviews will generate
+ to display on the about, info, and student dashboard pages. If you make any
+ changes to this, you will have to regenerate CourseOverviews in order for it
+ to take effect. You might want to do this if you're doing precise theming of
+ your install of edx-platform... but really, you probably don't want to do this
+ at all at the moment, given how new this is. :-P
+ """
+ # Small thumbnail, for things like the student dashboard
+ small_width = models.IntegerField(default=375)
+ small_height = models.IntegerField(default=200)
+
+ # Large thumbnail, for things like the about page
+ large_width = models.IntegerField(default=750)
+ large_height = models.IntegerField(default=400)
+
+ @property
+ def small(self):
+ """Tuple for small image dimensions in pixels -- (width, height)"""
+ return (self.small_width, self.small_height)
+
+ @property
+ def large(self):
+ """Tuple for large image dimensions in pixels -- (width, height)"""
+ return (self.large_width, self.large_height)
+
+ def __unicode__(self):
+ return u"CourseOverviewImageConfig(enabled={}, small={}, large={})".format(
+ self.enabled, self.small, self.large
+ )
diff --git a/openedx/core/djangoapps/content/course_overviews/tests.py b/openedx/core/djangoapps/content/course_overviews/tests.py
index 51666bad93..a7a15c4b0a 100644
--- a/openedx/core/djangoapps/content/course_overviews/tests.py
+++ b/openedx/core/djangoapps/content/course_overviews/tests.py
@@ -1,6 +1,7 @@
"""
Tests for course_overviews app.
"""
+from cStringIO import StringIO
import datetime
import ddt
import itertools
@@ -8,11 +9,17 @@ import math
import mock
import pytz
+from django.conf import settings
+from django.test.utils import override_settings
from django.utils import timezone
+from PIL import Image
from lms.djangoapps.certificates.api import get_active_web_certificate
from openedx.core.djangoapps.models.course_details import CourseDetails
from openedx.core.lib.courses import course_image_url
+from xmodule.assetstore.assetmgr import AssetManager
+from xmodule.contentstore.django import contentstore
+from xmodule.contentstore.content import StaticContent
from xmodule.course_metadata_utils import DEFAULT_START_DATE
from xmodule.course_module import (
CATALOG_VISIBILITY_CATALOG_AND_ABOUT,
@@ -25,7 +32,7 @@ from xmodule.modulestore.django import modulestore
from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase
from xmodule.modulestore.tests.factories import CourseFactory, check_mongo_calls, check_mongo_calls_range
-from .models import CourseOverview
+from .models import CourseOverview, CourseOverviewImageConfig
@ddt.ddt
@@ -340,7 +347,7 @@ class CourseOverviewTestCase(ModuleStoreTestCase):
course_overview = CourseOverview._create_from_course(course) # pylint: disable=protected-access
self.assertEqual(course_overview.lowest_passing_grade, None)
- @ddt.data((ModuleStoreEnum.Type.mongo, 4, 4), (ModuleStoreEnum.Type.split, 3, 4))
+ @ddt.data((ModuleStoreEnum.Type.mongo, 5, 5), (ModuleStoreEnum.Type.split, 3, 4))
@ddt.unpack
def test_versioning(self, modulestore_type, min_mongo_calls, max_mongo_calls):
"""
@@ -485,3 +492,336 @@ class CourseOverviewTestCase(ModuleStoreTestCase):
{c.id for c in CourseOverview.get_all_courses(org='TEST_ORG_1')},
{c.id for c in org_courses[1]},
)
+
+
+@ddt.ddt
+class CourseOverviewImageSetTestCase(ModuleStoreTestCase):
+ """
+ Course thumbnail generation tests.
+ """
+
+ def setUp(self):
+ """Create an active CourseOverviewImageConfig with non-default values."""
+ self.set_config(True)
+ super(CourseOverviewImageSetTestCase, self).setUp()
+
+ def set_config(self, enabled):
+ """
+ Enable or disable thumbnail generation config.
+
+ Config models pick the most recent by date created, descending. I delete
+ entries here because that can sometimes screw up on MySQL, which only
+ has second-level granularity in this timestamp.
+
+ This uses non-default values for the dimensions.
+ """
+ CourseOverviewImageConfig.objects.all().delete()
+ CourseOverviewImageConfig.objects.create(
+ enabled=enabled,
+ small_width=200,
+ small_height=100,
+ large_width=400,
+ large_height=200
+ )
+
+ @override_settings(DEFAULT_COURSE_ABOUT_IMAGE_URL='default_course.png')
+ @override_settings(STATIC_URL='static/')
+ @ddt.data(
+ *itertools.product(
+ [ModuleStoreEnum.Type.mongo, ModuleStoreEnum.Type.split],
+ [None, '']
+ )
+ )
+ @ddt.unpack
+ def test_no_source_image(self, modulestore_type, course_image):
+ """
+ Tests that we behave as expected if no source image was specified.
+ """
+ # Because we're sending None and '', we expect to get the generic
+ # fallback URL for course images.
+ fallback_url = settings.STATIC_URL + settings.DEFAULT_COURSE_ABOUT_IMAGE_URL
+ course_overview = self._assert_image_urls_all_default(modulestore_type, course_image, fallback_url)
+
+ # Even though there was no source image to generate, we should still
+ # have a CourseOverviewImageSet object associated with this overview.
+ self.assertTrue(hasattr(course_overview, 'image_set'))
+
+ @ddt.data(ModuleStoreEnum.Type.mongo, ModuleStoreEnum.Type.split)
+ def test_disabled_no_prior_data(self, modulestore_type):
+ """
+ Test behavior when we are disabled and no entries exist.
+
+ 1. No CourseOverviewImageSet will be created.
+ 2. All resolutions should return the URL of the raw source image.
+ """
+ # Disable model generation using config models...
+ self.set_config(enabled=False)
+
+ # Since we're disabled, we should just return the raw source image back
+ # for every resolution in image_urls.
+ fake_course_image = 'sample_image.png'
+ course_overview = self._assert_image_urls_all_default(modulestore_type, fake_course_image)
+
+ # Because we are disabled, no image set should have been generated.
+ self.assertFalse(hasattr(course_overview, 'image_set'))
+
+ @ddt.data(ModuleStoreEnum.Type.mongo, ModuleStoreEnum.Type.split)
+ def test_disabled_with_prior_data(self, modulestore_type):
+ """
+ Test behavior when entries have been created but we are disabled.
+
+ This might happen because a strange bug was introduced -- e.g. we
+ corrupt the images somehow when making thumbnails. Expectations:
+
+ 1. We ignore whatever was created for the thumbnails, and image_urls
+ returns the same as if no thumbnails had ever been generated. So
+ basically, we return the raw source image for every resolution.
+ 2. We keep the CourseOverviewImageSet data around for debugging
+ purposes.
+ """
+ course_image = "my_course.jpg"
+ broken_small_url = "I am small!"
+ broken_large_url = "I am big!"
+ with self.store.default_store(modulestore_type):
+ course = CourseFactory.create(
+ default_store=modulestore_type, course_image=course_image
+ )
+ course_overview_before = CourseOverview.get_from_id(course.id)
+
+ # This initial seeding should create an entry for the image_set.
+ self.assertTrue(hasattr(course_overview_before, 'image_set'))
+
+ # Now just throw in some fake data to this image set, something that
+ # couldn't possibly work.
+ course_overview_before.image_set.small_url = broken_small_url
+ course_overview_before.image_set.large_url = broken_large_url
+ course_overview_before.image_set.save()
+
+ # Now disable the thumbnail feature
+ self.set_config(False)
+
+ # Fetch a new CourseOverview
+ course_overview_after = CourseOverview.get_from_id(course.id)
+
+ # Assert that the data still exists for debugging purposes
+ self.assertTrue(hasattr(course_overview_after, 'image_set'))
+ image_set = course_overview_after.image_set
+ self.assertEqual(image_set.small_url, broken_small_url)
+ self.assertEqual(image_set.large_url, broken_large_url)
+
+ # But because we've disabled it, asking for image_urls should give us
+ # the raw source image for all resolutions, and not our broken images.
+ expected_url = course_image_url(course)
+ self.assertEqual(
+ course_overview_after.image_urls,
+ {
+ 'raw': expected_url,
+ 'small': expected_url,
+ 'large': expected_url
+ }
+ )
+
+ @ddt.data(ModuleStoreEnum.Type.mongo, ModuleStoreEnum.Type.split)
+ def test_error_generating_thumbnails(self, modulestore_type):
+ """
+ Test a scenario where thumbnails cannot be generated.
+
+ We need to make sure that:
+
+ 1. We don't cause any 500s to leak out. A failure to generate thumbnails
+ should never cause CourseOverview generation to fail.
+ 2. We return the raw course image for all resolutions.
+ 3. We don't kill our CPU by trying over and over again.
+ """
+ with mock.patch('openedx.core.lib.courses.create_course_image_thumbnail') as patched_create_thumbnail:
+ # Strictly speaking, this would fail anyway because there's no data
+ # backing sample_image.png, but we're going to make the side-effect
+ # more dramatic. ;-)
+ fake_course_image = 'sample_image.png'
+ patched_create_thumbnail.side_effect = Exception("Kaboom!")
+
+ # This will generate a CourseOverview and verify that we get the
+ # source image back for all resolutions.
+ course_overview = self._assert_image_urls_all_default(modulestore_type, fake_course_image)
+
+ # Make sure we were called (i.e. we tried to create the thumbnail)
+ patched_create_thumbnail.assert_called()
+
+ # Now an image set does exist, even though it only has blank values for
+ # the small and large urls.
+ self.assertTrue(hasattr(course_overview, 'image_set'))
+ self.assertEqual(course_overview.image_set.small_url, '')
+ self.assertEqual(course_overview.image_set.large_url, '')
+
+ # The next time we create a CourseOverview, the images are explicitly
+ # *not* regenerated.
+ with mock.patch('openedx.core.lib.courses.create_course_image_thumbnail') as patched_create_thumbnail:
+ course_overview = CourseOverview.get_from_id(course_overview.id)
+ patched_create_thumbnail.assert_not_called()
+
+ @ddt.data(
+ *itertools.product(
+ [ModuleStoreEnum.Type.mongo, ModuleStoreEnum.Type.split],
+ [True, False],
+ )
+ )
+ @ddt.unpack
+ def test_happy_path(self, modulestore_type, create_after_overview):
+ """
+ What happens when everything works like we expect it to.
+
+ If `create_after_overview` is True, we will temporarily disable
+ thumbnail creation so that the initial CourseOverview is created without
+ an image_set, and the CourseOverviewImageSet is created afterwards. If
+ `create_after_overview` is False, we'll create the CourseOverviewImageSet
+ at the same time as the CourseOverview.
+ """
+ # Create a real (oversized) image...
+ image = Image.new("RGB", (800, 400), "blue")
+ image_buff = StringIO()
+ image.save(image_buff, format="JPEG")
+ image_buff.seek(0)
+ image_name = "big_course_image.jpeg"
+
+ with self.store.default_store(modulestore_type):
+ course = CourseFactory.create(
+ default_store=modulestore_type, course_image=image_name
+ )
+
+ # Save a real image here...
+ course_image_asset_key = StaticContent.compute_location(course.id, course.course_image)
+ course_image_content = StaticContent(course_image_asset_key, image_name, 'image/jpeg', image_buff)
+ contentstore().save(course_image_content)
+
+ # If create_after_overview is True, disable thumbnail generation so
+ # that the CourseOverview object is created and saved without an
+ # image_set at first (it will be lazily created later).
+ if create_after_overview:
+ self.set_config(enabled=False)
+
+ # Now generate the CourseOverview...
+ course_overview = CourseOverview.get_from_id(course.id)
+
+ # If create_after_overview is True, no image_set exists yet. Verify
+ # that, then switch config back over to True and it should lazily
+ # create the image_set on the next get_from_id() call.
+ if create_after_overview:
+ self.assertFalse(hasattr(course_overview, 'image_set'))
+ self.set_config(enabled=True)
+ course_overview = CourseOverview.get_from_id(course.id)
+
+ self.assertTrue(hasattr(course_overview, 'image_set'))
+ image_urls = course_overview.image_urls
+ config = CourseOverviewImageConfig.current()
+
+ # Make sure the thumbnail names come out as expected...
+ self.assertTrue(image_urls['raw'].endswith('big_course_image.jpeg'))
+ self.assertTrue(image_urls['small'].endswith('big_course_image-jpeg-{}x{}.jpg'.format(*config.small)))
+ self.assertTrue(image_urls['large'].endswith('big_course_image-jpeg-{}x{}.jpg'.format(*config.large)))
+
+ # Now make sure our thumbnails are of the sizes we expect...
+ for image_url, expected_size in [(image_urls['small'], config.small), (image_urls['large'], config.large)]:
+ image_key = StaticContent.get_location_from_path(image_url)
+ image_content = AssetManager.find(image_key)
+ image = Image.open(StringIO(image_content.data))
+ self.assertEqual(image.size, expected_size)
+
+ @ddt.data(
+ (800, 400), # Larger than both, correct ratio
+ (800, 600), # Larger than both, incorrect ratio
+ (300, 150), # In between small and large, correct ratio
+ (300, 180), # In between small and large, incorrect ratio
+ (100, 50), # Smaller than both, correct ratio
+ (100, 80), # Smaller than both, incorrect ratio
+ (800, 20), # Bizarrely wide
+ (20, 800), # Bizarrely tall
+ )
+ def test_different_resolutions(self, src_dimensions):
+ """
+ Test various resolutions of images to make thumbnails of.
+
+ Note that our test sizes are small=(200, 100) and large=(400, 200).
+
+ 1. Images should won't be blown up if it's too small, so a (100, 50)
+ resolution image will remain (100, 50).
+ 2. However, images *will* be converted using our format and quality
+ settings (JPEG, 75% -- the PIL default). This is because images with
+ relatively small dimensions not compressed properly.
+ 3. Image thumbnail naming will maintain the naming convention of the
+ target resolution, even if the image was not actually scaled to that
+ size (i.e. it was already smaller). This is mostly because it's
+ simpler to be consistent, but it also lets us more easily tell which
+ configuration a thumbnail was created under.
+ """
+ # Create a source image...
+ image = Image.new("RGB", src_dimensions, "blue")
+ image_buff = StringIO()
+ image.save(image_buff, format="PNG")
+ image_buff.seek(0)
+ image_name = "src_course_image.png"
+
+ course = CourseFactory.create(course_image=image_name)
+
+ # Save the image to the contentstore...
+ course_image_asset_key = StaticContent.compute_location(course.id, course.course_image)
+ course_image_content = StaticContent(course_image_asset_key, image_name, 'image/png', image_buff)
+ contentstore().save(course_image_content)
+
+ # Now generate the CourseOverview...
+ config = CourseOverviewImageConfig.current()
+ course_overview = CourseOverview.get_from_id(course.id)
+ image_urls = course_overview.image_urls
+
+ for image_url, target in [(image_urls['small'], config.small), (image_urls['large'], config.large)]:
+ image_key = StaticContent.get_location_from_path(image_url)
+ image_content = AssetManager.find(image_key)
+ image = Image.open(StringIO(image_content.data))
+
+ # Naming convention for thumbnail
+ self.assertTrue(image_url.endswith('src_course_image-png-{}x{}.jpg'.format(*target)))
+
+ # Actual thumbnail data
+ src_x, src_y = src_dimensions
+ target_x, target_y = target
+ image_x, image_y = image.size
+
+ # I'm basically going to assume the image library knows how to do
+ # the right thing in terms of handling aspect ratio. We're just
+ # going to make sure that small images aren't blown up, and that
+ # we never exceed our target sizes
+ self.assertLessEqual(image_x, target_x)
+ self.assertLessEqual(image_y, target_y)
+
+ if src_x < target_x and src_y < target_y:
+ self.assertEqual(src_x, image_x)
+ self.assertEqual(src_y, image_y)
+
+ def _assert_image_urls_all_default(self, modulestore_type, raw_course_image_name, expected_url=None):
+ """
+ Helper for asserting that all image_urls are defaulting to a particular value.
+
+ Returns the CourseOverview created. This function is useful when you
+ know that the thumbnail generation process is going to fail in some way
+ (e.g. unspecified source image, disabled config, runtime error) and want
+ to verify that all the image URLs are a certain expected value (either
+ the source image, or the fallback URL).
+ """
+ with self.store.default_store(modulestore_type):
+ course = CourseFactory.create(
+ default_store=modulestore_type, course_image=raw_course_image_name
+ )
+ if expected_url is None:
+ expected_url = course_image_url(course)
+
+ course_overview = CourseOverview.get_from_id(course.id)
+
+ # All the URLs that come back should be for the expected_url
+ self.assertEqual(
+ course_overview.image_urls,
+ {
+ 'raw': expected_url,
+ 'small': expected_url,
+ 'large': expected_url,
+ }
+ )
+ return course_overview
diff --git a/openedx/core/lib/courses.py b/openedx/core/lib/courses.py
index f420c1f54f..2936cc93d8 100644
--- a/openedx/core/lib/courses.py
+++ b/openedx/core/lib/courses.py
@@ -3,8 +3,10 @@ Common utility functions related to courses.
"""
from django.conf import settings
-from xmodule.modulestore.django import modulestore
+from xmodule.assetstore.assetmgr import AssetManager
from xmodule.contentstore.content import StaticContent
+from xmodule.contentstore.django import contentstore
+from xmodule.modulestore.django import modulestore
from xmodule.modulestore import ModuleStoreEnum
@@ -27,4 +29,18 @@ def course_image_url(course):
else:
loc = StaticContent.compute_location(course.id, course.course_image)
url = StaticContent.serialize_asset_key_with_slash(loc)
+
return url
+
+
+def create_course_image_thumbnail(course, dimensions):
+ """Create a course image thumbnail and return the URL.
+
+ - dimensions is a tuple of (width, height)
+ """
+ course_image_asset_key = StaticContent.compute_location(course.id, course.course_image)
+ course_image = AssetManager.find(course_image_asset_key) # a StaticContent obj
+
+ _content, thumb_loc = contentstore().generate_thumbnail(course_image, dimensions=dimensions)
+
+ return StaticContent.serialize_asset_key_with_slash(thumb_loc)