Skip to content
Projects
Groups
Snippets
Help
This project
Loading...
Sign in / Register
Toggle navigation
E
edx-platform
Overview
Overview
Details
Activity
Cycle Analytics
Repository
Repository
Files
Commits
Branches
Tags
Contributors
Graph
Compare
Charts
Issues
0
Issues
0
List
Board
Labels
Milestones
Merge Requests
0
Merge Requests
0
CI / CD
CI / CD
Pipelines
Jobs
Schedules
Charts
Wiki
Wiki
Snippets
Snippets
Members
Members
Collapse sidebar
Close sidebar
Activity
Graph
Charts
Create a new issue
Jobs
Commits
Issue Boards
Open sidebar
edx
edx-platform
Commits
6d5047ef
Commit
6d5047ef
authored
Dec 18, 2015
by
David Ormsbee
Browse files
Options
Browse Files
Download
Plain Diff
Merge pull request #10898 from edx/ormsbee/dashboard_thumbnails
Modify CourseOverviews to create course image thumbnails.
parents
7a287dc6
88c7d583
Hide whitespace changes
Inline
Side-by-side
Showing
10 changed files
with
742 additions
and
24 deletions
+742
-24
common/lib/xmodule/xmodule/contentstore/content.py
+34
-7
lms/djangoapps/courseware/views.py
+6
-1
lms/templates/courseware/course_about.html
+2
-2
lms/templates/dashboard/_dashboard_course_listing.html
+4
-4
openedx/core/djangoapps/content/course_overviews/admin.py
+49
-1
openedx/core/djangoapps/content/course_overviews/migrations/0006_courseoverviewimageset.py
+30
-0
openedx/core/djangoapps/content/course_overviews/migrations/0007_courseoverviewimageconfig.py
+34
-0
openedx/core/djangoapps/content/course_overviews/models.py
+224
-6
openedx/core/djangoapps/content/course_overviews/tests.py
+342
-2
openedx/core/lib/courses.py
+17
-1
No files found.
common/lib/xmodule/xmodule/contentstore/content.py
View file @
6d5047ef
...
...
@@ -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
)
...
...
lms/djangoapps/courseware/views.py
View file @
6d5047ef
...
...
@@ -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
,
})
...
...
lms/templates/courseware/course_about.html
View file @
6d5047ef
...
...
@@ -173,14 +173,14 @@ from openedx.core.lib.courses import course_image_url
% if get_course_about_section(request, course, "video"):
<a
href=
"#video-modal"
class=
"media"
rel=
"leanModal"
>
<div
class=
"hero"
>
<img
src=
"${course_image_url
(course)
}"
alt=
""
/>
<img
src=
"${course_image_url
s['large']
}"
alt=
""
/>
<div
class=
"play-intro"
></div>
</div>
</a>
%else:
<div
class=
"media"
>
<div
class=
"hero"
>
<img
src=
"${course_image_url
(course)
}"
alt=
""
/>
<img
src=
"${course_image_url
s['large']
}"
alt=
""
/>
</div>
</div>
% endif
...
...
lms/templates/dashboard/_dashboard_course_listing.html
View file @
6d5047ef
...
...
@@ -63,16 +63,16 @@ from student.helpers import (
% if show_courseware_link:
% if not is_course_blocked:
<a
href=
"${course_target}"
data-course-key=
"${enrollment.course_id}"
class=
"cover"
>
<img
src=
"${course_overview.
course_image_url
}"
class=
"course-image"
alt=
"${_('{course_number} {course_name} Home Page').format(course_number=course_overview.number, course_name=course_overview.display_name_with_default) |h}"
/>
<img
src=
"${course_overview.
image_urls['small']
}"
class=
"course-image"
alt=
"${_('{course_number} {course_name} Home Page').format(course_number=course_overview.number, course_name=course_overview.display_name_with_default) |h}"
/>
</a>
% else:
<a
class=
"fade-cover"
>
<img
src=
"${course_overview.
course_image_url
}"
class=
"course-image"
alt=
"${_('{course_number} {course_name} Cover Image').format(course_number=course_overview.number, course_name=course_overview.display_name_with_default) |h}"
/>
<img
src=
"${course_overview.
image_urls['small']
}"
class=
"course-image"
alt=
"${_('{course_number} {course_name} Cover Image').format(course_number=course_overview.number, course_name=course_overview.display_name_with_default) |h}"
/>
</a>
% endif
% else:
<a
class=
"cover"
>
<img
src=
"${course_overview.
course_image_url
}"
class=
"course-image"
alt=
"${_('{course_number} {course_name} Cover Image').format(course_number=course_overview.number, course_name=course_overview.display_name_with_default) | h}"
/>
<img
src=
"${course_overview.
image_urls['small']
}"
class=
"course-image"
alt=
"${_('{course_number} {course_name} Cover Image').format(course_number=course_overview.number, course_name=course_overview.display_name_with_default) | h}"
/>
</a>
% 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:
<div
class=
"message message-status wrapper-message-primary is-shown"
>
...
...
openedx/core/djangoapps/content/course_overviews/admin.py
View file @
6d5047ef
...
...
@@ -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
)
openedx/core/djangoapps/content/course_overviews/migrations/0006_courseoverviewimageset.py
0 → 100644
View file @
6d5047ef
# -*- 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
,
},
),
]
openedx/core/djangoapps/content/course_overviews/migrations/0007_courseoverviewimageconfig.py
0 → 100644
View file @
6d5047ef
# -*- 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
,
},
),
]
openedx/core/djangoapps/content/course_overviews/models.py
View file @
6d5047ef
...
...
@@ -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
)
openedx/core/djangoapps/content/course_overviews/tests.py
View file @
6d5047ef
"""
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
openedx/core/lib/courses.py
View file @
6d5047ef
...
...
@@ -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
)
Write
Preview
Markdown
is supported
0%
Try again
or
attach a new file
Attach a file
Cancel
You are about to add
0
people
to the discussion. Proceed with caution.
Finish editing this message first!
Cancel
Please
register
or
sign in
to comment