Commit e4302e62 by Peter Fogg

Allow course image uploads in the settings page.

Authors can upload an image (or choose an existing one) from the
settings page, using the in-context uploader from PDF
textbooks. Includes tests for backwards compatibility with XML courses
-- they used a magic filename (images/course_image.jpg) which is
mapped to a location in the Mongo contentstore.

Still needs some UX work, though the backend plumbing is there.
parent ce1a13f3
...@@ -57,6 +57,7 @@ Feature: Course Settings ...@@ -57,6 +57,7 @@ Feature: Course Settings
| Course Start Time | 11:00 | | Course Start Time | 11:00 |
| Course Introduction Video | 4r7wHMg5Yjg | | Course Introduction Video | 4r7wHMg5Yjg |
| Course Effort | 200:00 | | Course Effort | 200:00 |
| Course Image URL | image.jpg |
# Special case because we have to type in code mirror # Special case because we have to type in code mirror
Scenario: Changes in Course Overview show a confirmation Scenario: Changes in Course Overview show a confirmation
...@@ -71,3 +72,10 @@ Feature: Course Settings ...@@ -71,3 +72,10 @@ Feature: Course Settings
When I select Schedule and Details When I select Schedule and Details
And I change the "Course Start Date" field to "" And I change the "Course Start Date" field to ""
Then the save button is disabled Then the save button is disabled
Scenario: User can upload course image
Given I have opened a new course in Studio
When I select Schedule and Details
And I upload a new course image
Then I should see the new course image
And the image URL should be present in the field
...@@ -5,9 +5,13 @@ from lettuce import world, step ...@@ -5,9 +5,13 @@ from lettuce import world, step
from terrain.steps import reload_the_page from terrain.steps import reload_the_page
from selenium.webdriver.common.keys import Keys from selenium.webdriver.common.keys import Keys
from common import type_in_codemirror from common import type_in_codemirror
from django.conf import settings
import os
from nose.tools import assert_true, assert_false, assert_equal from nose.tools import assert_true, assert_false, assert_equal
TEST_ROOT = settings.COMMON_TEST_DATA_ROOT
COURSE_START_DATE_CSS = "#course-start-date" COURSE_START_DATE_CSS = "#course-start-date"
COURSE_END_DATE_CSS = "#course-end-date" COURSE_END_DATE_CSS = "#course-end-date"
ENROLLMENT_START_DATE_CSS = "#course-enrollment-start-date" ENROLLMENT_START_DATE_CSS = "#course-enrollment-start-date"
...@@ -146,6 +150,36 @@ def test_change_course_overview(_step): ...@@ -146,6 +150,36 @@ def test_change_course_overview(_step):
type_in_codemirror(0, "<h1>Overview</h1>") type_in_codemirror(0, "<h1>Overview</h1>")
@step('I upload a new course image$')
def upload_new_course_image(_step):
upload_css = '.action-upload-image'
world.css_click(upload_css)
file_css = '.upload-dialog input[type=file]'
upload = world.css_find(file_css)
path = os.path.join(TEST_ROOT, 'image.jpg')
upload._element.send_keys(os.path.abspath(path))
button_css = '.upload-dialog .action-upload'
world.css_click(button_css)
@step('I should see the new course image$')
def i_see_new_course_image(_step):
img_css = '#course-image'
images = world.css_find(img_css)
assert len(images) == 1
img = images[0]
expected_src = '/c4x/MITx/999/asset/image.jpg'
# Don't worry about the domain in the URL
assert img['src'].endswith(expected_src)
@step('the image URL should be present in the field')
def image_url_present(_step):
field_css = '#course-image-url'
field = world.css_find(field_css).first
expected_value = '/c4x/MITx/999/asset/image.jpg'
assert field.value == expected_value
############### HELPER METHODS #################### ############### HELPER METHODS ####################
def set_date_or_time(css, date_or_time): def set_date_or_time(css, date_or_time):
......
...@@ -1608,6 +1608,29 @@ class ContentStoreTest(ModuleStoreTestCase): ...@@ -1608,6 +1608,29 @@ class ContentStoreTest(ModuleStoreTestCase):
# is this test too strict? i.e., it requires the dicts to be == # is this test too strict? i.e., it requires the dicts to be ==
self.assertEqual(course.checklists, fetched_course.checklists) self.assertEqual(course.checklists, fetched_course.checklists)
def test_image_import(self):
"""Test backwards compatibilty of course image."""
module_store = modulestore('direct')
content_store = contentstore()
# Use conditional_and_poll, as it's got an image already
import_from_xml(
module_store,
'common/test/data/',
['conditional_and_poll'],
static_content_store=content_store
)
course = module_store.get_courses()[0]
# Make sure the course image is set to the right place
self.assertEqual(course.course_image, 'images_course_image.jpg')
# Ensure that the imported course image is present -- this shouldn't raise an exception
location = course.location._replace(tag='c4x', category='asset', name=course.course_image)
content_store.find(location)
class MetadataSaveTestCase(ModuleStoreTestCase): class MetadataSaveTestCase(ModuleStoreTestCase):
"""Test that metadata is correctly cached and decached.""" """Test that metadata is correctly cached and decached."""
......
...@@ -30,6 +30,7 @@ class CourseDetailsTestCase(CourseTestCase): ...@@ -30,6 +30,7 @@ class CourseDetailsTestCase(CourseTestCase):
def test_virgin_fetch(self): def test_virgin_fetch(self):
details = CourseDetails.fetch(self.course.location) details = CourseDetails.fetch(self.course.location)
self.assertEqual(details.course_location, self.course.location, "Location not copied into") self.assertEqual(details.course_location, self.course.location, "Location not copied into")
self.assertEqual(details.course_image_name, self.course.course_image)
self.assertIsNotNone(details.start_date.tzinfo) self.assertIsNotNone(details.start_date.tzinfo)
self.assertIsNone(details.end_date, "end date somehow initialized " + str(details.end_date)) self.assertIsNone(details.end_date, "end date somehow initialized " + str(details.end_date))
self.assertIsNone(details.enrollment_start, "enrollment_start date somehow initialized " + str(details.enrollment_start)) self.assertIsNone(details.enrollment_start, "enrollment_start date somehow initialized " + str(details.enrollment_start))
...@@ -43,6 +44,7 @@ class CourseDetailsTestCase(CourseTestCase): ...@@ -43,6 +44,7 @@ class CourseDetailsTestCase(CourseTestCase):
jsondetails = json.dumps(details, cls=CourseSettingsEncoder) jsondetails = json.dumps(details, cls=CourseSettingsEncoder)
jsondetails = json.loads(jsondetails) jsondetails = json.loads(jsondetails)
self.assertTupleEqual(Location(jsondetails['course_location']), self.course.location, "Location !=") self.assertTupleEqual(Location(jsondetails['course_location']), self.course.location, "Location !=")
self.assertEqual(jsondetails['course_image_name'], self.course.course_image)
self.assertIsNone(jsondetails['end_date'], "end date somehow initialized ") self.assertIsNone(jsondetails['end_date'], "end date somehow initialized ")
self.assertIsNone(jsondetails['enrollment_start'], "enrollment_start date somehow initialized ") self.assertIsNone(jsondetails['enrollment_start'], "enrollment_start date somehow initialized ")
self.assertIsNone(jsondetails['enrollment_end'], "enrollment_end date somehow initialized ") self.assertIsNone(jsondetails['enrollment_end'], "enrollment_end date somehow initialized ")
...@@ -97,6 +99,11 @@ class CourseDetailsTestCase(CourseTestCase): ...@@ -97,6 +99,11 @@ class CourseDetailsTestCase(CourseTestCase):
CourseDetails.update_from_json(jsondetails.__dict__).start_date, CourseDetails.update_from_json(jsondetails.__dict__).start_date,
jsondetails.start_date jsondetails.start_date
) )
jsondetails.course_image_name = "an_image.jpg"
self.assertEqual(
CourseDetails.update_from_json(jsondetails.__dict__).course_image_name,
jsondetails.course_image_name
)
@override_settings(MKTG_URLS={'ROOT': 'dummy-root'}) @override_settings(MKTG_URLS={'ROOT': 'dummy-root'})
def test_marketing_site_fetch(self): def test_marketing_site_fetch(self):
...@@ -188,6 +195,7 @@ class CourseDetailsViewTest(CourseTestCase): ...@@ -188,6 +195,7 @@ class CourseDetailsViewTest(CourseTestCase):
self.alter_field(url, details, 'overview', "Overview") self.alter_field(url, details, 'overview', "Overview")
self.alter_field(url, details, 'intro_video', "intro_video") self.alter_field(url, details, 'intro_video', "intro_video")
self.alter_field(url, details, 'effort', "effort") self.alter_field(url, details, 'effort', "effort")
self.alter_field(url, details, 'course_image_name', "course_image_name")
def compare_details_with_encoding(self, encoded, details, context): def compare_details_with_encoding(self, encoded, details, context):
self.compare_date_fields(details, encoded, context, 'start_date') self.compare_date_fields(details, encoded, context, 'start_date')
...@@ -197,6 +205,7 @@ class CourseDetailsViewTest(CourseTestCase): ...@@ -197,6 +205,7 @@ class CourseDetailsViewTest(CourseTestCase):
self.assertEqual(details['overview'], encoded['overview'], context + " overviews not ==") self.assertEqual(details['overview'], encoded['overview'], context + " overviews not ==")
self.assertEqual(details['intro_video'], encoded.get('intro_video', None), context + " intro_video not ==") self.assertEqual(details['intro_video'], encoded.get('intro_video', None), context + " intro_video not ==")
self.assertEqual(details['effort'], encoded['effort'], context + " efforts not ==") self.assertEqual(details['effort'], encoded['effort'], context + " efforts not ==")
self.assertEqual(details['course_image_name'], encoded['course_image_name'], context + " images not ==")
def compare_date_fields(self, details, encoded, context, field): def compare_date_fields(self, details, encoded, context, field):
if details[field] is not None: if details[field] is not None:
......
...@@ -5,6 +5,7 @@ import collections ...@@ -5,6 +5,7 @@ import collections
import copy import copy
from django.test import TestCase from django.test import TestCase
from django.test.utils import override_settings from django.test.utils import override_settings
from xmodule.modulestore.tests.factories import CourseFactory
class LMSLinksTestCase(TestCase): class LMSLinksTestCase(TestCase):
...@@ -150,3 +151,13 @@ class ExtraPanelTabTestCase(TestCase): ...@@ -150,3 +151,13 @@ class ExtraPanelTabTestCase(TestCase):
changed, actual_tabs = utils.remove_extra_panel_tab(tab_type, course) changed, actual_tabs = utils.remove_extra_panel_tab(tab_type, course)
self.assertFalse(changed) self.assertFalse(changed)
self.assertEqual(actual_tabs, expected_tabs) self.assertEqual(actual_tabs, expected_tabs)
class CourseImageTestCase(TestCase):
"""Tests for course image URLs."""
def test_get_image_url(self):
"""Test image URL formatting."""
course = CourseFactory.create(org='edX', course='999')
url = utils.course_image_url(course)
self.assertEquals(url, '/c4x/edX/999/asset/{0}'.format(course.course_image))
...@@ -4,6 +4,7 @@ from django.conf import settings ...@@ -4,6 +4,7 @@ from django.conf import settings
from xmodule.modulestore import Location from xmodule.modulestore import Location
from xmodule.modulestore.django import modulestore from xmodule.modulestore.django import modulestore
from xmodule.modulestore.exceptions import ItemNotFoundError from xmodule.modulestore.exceptions import ItemNotFoundError
from xmodule.contentstore.content import StaticContent
from django.core.urlresolvers import reverse from django.core.urlresolvers import reverse
import copy import copy
import logging import logging
...@@ -153,6 +154,13 @@ def get_lms_link_for_about_page(location): ...@@ -153,6 +154,13 @@ def get_lms_link_for_about_page(location):
return lms_link return lms_link
def course_image_url(course):
"""Returns the image url for the course."""
loc = course.location._replace(tag='c4x', category='asset', name=course.course_image)
path = StaticContent.get_url_path_from_location(loc)
return path
class UnitState(object): class UnitState(object):
draft = 'draft' draft = 'draft'
private = 'private' private = 'private'
......
...@@ -276,7 +276,12 @@ def get_course_settings(request, org, course, name): ...@@ -276,7 +276,12 @@ def get_course_settings(request, org, course, name):
"section": "details"}), "section": "details"}),
'about_page_editable': not settings.MITX_FEATURES.get( 'about_page_editable': not settings.MITX_FEATURES.get(
'ENABLE_MKTG_SITE', False 'ENABLE_MKTG_SITE', False
) ),
'upload_asset_url': reverse('upload_asset', kwargs={
'org': org,
'course': course,
'coursename': name,
})
}) })
......
...@@ -3,7 +3,7 @@ from xmodule.modulestore.exceptions import ItemNotFoundError ...@@ -3,7 +3,7 @@ from xmodule.modulestore.exceptions import ItemNotFoundError
from xmodule.modulestore.inheritance import own_metadata from xmodule.modulestore.inheritance import own_metadata
import json import json
from json.encoder import JSONEncoder from json.encoder import JSONEncoder
from contentstore.utils import get_modulestore from contentstore.utils import get_modulestore, course_image_url
from models.settings import course_grading from models.settings import course_grading
from contentstore.utils import update_item from contentstore.utils import update_item
from xmodule.fields import Date from xmodule.fields import Date
...@@ -23,6 +23,8 @@ class CourseDetails(object): ...@@ -23,6 +23,8 @@ class CourseDetails(object):
self.overview = "" # html to render as the overview self.overview = "" # html to render as the overview
self.intro_video = None # a video pointer self.intro_video = None # a video pointer
self.effort = None # int hours/week self.effort = None # int hours/week
self.course_image_name = ""
self.course_image_asset_path = "" # URL of the course image
@classmethod @classmethod
def fetch(cls, course_location): def fetch(cls, course_location):
...@@ -40,6 +42,8 @@ class CourseDetails(object): ...@@ -40,6 +42,8 @@ class CourseDetails(object):
course.end_date = descriptor.end course.end_date = descriptor.end
course.enrollment_start = descriptor.enrollment_start course.enrollment_start = descriptor.enrollment_start
course.enrollment_end = descriptor.enrollment_end course.enrollment_end = descriptor.enrollment_end
course.course_image_name = descriptor.course_image
course.course_image_asset_path = course_image_url(descriptor)
temploc = course_location.replace(category='about', name='syllabus') temploc = course_location.replace(category='about', name='syllabus')
try: try:
...@@ -121,6 +125,10 @@ class CourseDetails(object): ...@@ -121,6 +125,10 @@ class CourseDetails(object):
dirty = True dirty = True
descriptor.enrollment_end = converted descriptor.enrollment_end = converted
if 'course_image_name' in jsondict and jsondict['course_image_name'] != descriptor.course_image:
descriptor.course_image = jsondict['course_image_name']
dirty = True
if dirty: if dirty:
# Save the data that we've just changed to the underlying # Save the data that we've just changed to the underlying
# MongoKeyValueStore before we update the mongo datastore. # MongoKeyValueStore before we update the mongo datastore.
......
...@@ -10,7 +10,9 @@ CMS.Models.Settings.CourseDetails = Backbone.Model.extend({ ...@@ -10,7 +10,9 @@ CMS.Models.Settings.CourseDetails = Backbone.Model.extend({
syllabus: null, syllabus: null,
overview: "", overview: "",
intro_video: null, intro_video: null,
effort: null // an int or null effort: null, // an int or null,
course_image_name: '', // the filename
course_image_asset_path: '' // the full URL (/c4x/org/course/num/asset/filename)
}, },
// When init'g from html script, ensure you pass {parse: true} as an option (2nd arg to reset) // When init'g from html script, ensure you pass {parse: true} as an option (2nd arg to reset)
......
...@@ -13,8 +13,8 @@ CMS.Views.Settings.Details = CMS.Views.ValidatingView.extend({ ...@@ -13,8 +13,8 @@ CMS.Views.Settings.Details = CMS.Views.ValidatingView.extend({
'mouseover #timezone' : "updateTime", 'mouseover #timezone' : "updateTime",
// would love to move to a general superclass, but event hashes don't inherit in backbone :-( // would love to move to a general superclass, but event hashes don't inherit in backbone :-(
'focus :input' : "inputFocus", 'focus :input' : "inputFocus",
'blur :input' : "inputUnfocus" 'blur :input' : "inputUnfocus",
'click .action-upload-image': "uploadImage"
}, },
initialize : function() { initialize : function() {
...@@ -51,6 +51,10 @@ CMS.Views.Settings.Details = CMS.Views.ValidatingView.extend({ ...@@ -51,6 +51,10 @@ CMS.Views.Settings.Details = CMS.Views.ValidatingView.extend({
this.$el.find('#' + this.fieldToSelectorMap['effort']).val(this.model.get('effort')); this.$el.find('#' + this.fieldToSelectorMap['effort']).val(this.model.get('effort'));
var imageURL = this.model.get('course_image_asset_path');
this.$el.find('#course-image-url').val(imageURL)
this.$el.find('#course-image').attr('src', imageURL);
return this; return this;
}, },
fieldToSelectorMap : { fieldToSelectorMap : {
...@@ -60,7 +64,8 @@ CMS.Views.Settings.Details = CMS.Views.ValidatingView.extend({ ...@@ -60,7 +64,8 @@ CMS.Views.Settings.Details = CMS.Views.ValidatingView.extend({
'enrollment_end' : 'enrollment-end', 'enrollment_end' : 'enrollment-end',
'overview' : 'course-overview', 'overview' : 'course-overview',
'intro_video' : 'course-introduction-video', 'intro_video' : 'course-introduction-video',
'effort' : "course-effort" 'effort' : "course-effort",
'course_image_asset_path': 'course-image-url'
}, },
updateTime : function(e) { updateTime : function(e) {
...@@ -121,6 +126,17 @@ CMS.Views.Settings.Details = CMS.Views.ValidatingView.extend({ ...@@ -121,6 +126,17 @@ CMS.Views.Settings.Details = CMS.Views.ValidatingView.extend({
updateModel: function(event) { updateModel: function(event) {
switch (event.currentTarget.id) { switch (event.currentTarget.id) {
case 'course-image-url':
this.setField(event);
var url = $(event.currentTarget).val();
var image_name = _.last(url.split('/'));
this.model.set('course_image_name', image_name);
// Wait to set the image src until the user stops typing
clearTimeout(this.imageTimer);
this.imageTimer = setTimeout(function() {
$('#course-image').attr('src', $(event.currentTarget).val());
}, 1000);
break;
case 'course-effort': case 'course-effort':
this.setField(event); this.setField(event);
break; break;
...@@ -216,6 +232,30 @@ CMS.Views.Settings.Details = CMS.Views.ValidatingView.extend({ ...@@ -216,6 +232,30 @@ CMS.Views.Settings.Details = CMS.Views.ValidatingView.extend({
this.save_message, this.save_message,
_.bind(this.saveView, this), _.bind(this.saveView, this),
_.bind(this.revertView, this)); _.bind(this.revertView, this));
},
uploadImage: function(event) {
event.preventDefault();
var upload = new CMS.Models.FileUpload({
title: gettext("Upload your course image."),
message: gettext("Files must be in JPG format."),
mimeType: "image/jpeg",
fileType: "JPG"
});
var self = this;
var modal = new CMS.Views.UploadDialog({
model: upload,
onSuccess: function(response) {
var options = {
'course_image_name': response.displayname,
'course_image_asset_path': response.url
}
self.model.set(options);
self.render();
$('#course-image').attr('src', self.model.get('course_image_asset_path'))
}
});
$('.wrapper-view').after(modal.show().el);
} }
}); });
...@@ -432,6 +432,20 @@ body.course.settings { ...@@ -432,6 +432,20 @@ body.course.settings {
} }
} }
// specific fields - course image
#field-course-image {
.current-course-image {
position: relative;
.action-upload-image {
@extend .ui-btn-flat-outline;
position: absolute;
bottom: 3px;
right: 0;
}
}
}
// specific fields - requirements // specific fields - requirements
&.requirements { &.requirements {
......
...@@ -2,7 +2,7 @@ ...@@ -2,7 +2,7 @@
<%inherit file="base.html" /> <%inherit file="base.html" />
<%block name="title">${_("Schedule &amp; Details Settings")}</%block> <%block name="title">${_("Schedule &amp; Details Settings")}</%block>
<%block name="bodyclass">is-signedin course schedule settings</%block> <%block name="bodyclass">is-signedin course schedule settings file-upload-dialog</%block>
<%namespace name='static' file='static_content.html'/> <%namespace name='static' file='static_content.html'/>
<%! <%!
...@@ -22,6 +22,10 @@ from contentstore import utils ...@@ -22,6 +22,10 @@ from contentstore import utils
<script type="text/javascript" src="${static.url('js/views/settings/main_settings_view.js')}"></script> <script type="text/javascript" src="${static.url('js/views/settings/main_settings_view.js')}"></script>
<script type="text/javascript" src="${static.url('js/models/settings/course_details.js')}"></script> <script type="text/javascript" src="${static.url('js/models/settings/course_details.js')}"></script>
<script type="text/template" id="upload-dialog-tpl">
<%static:include path="js/upload-dialog.underscore" />
</script>
<script type="text/javascript"> <script type="text/javascript">
$(document).ready(function(){ $(document).ready(function(){
...@@ -43,6 +47,8 @@ from contentstore import utils ...@@ -43,6 +47,8 @@ from contentstore import utils
}, },
reset: true reset: true
}); });
CMS.URL.UPLOAD_ASSET = '${upload_asset_url}';
}); });
</script> </script>
...@@ -208,6 +214,21 @@ from contentstore import utils ...@@ -208,6 +214,21 @@ from contentstore import utils
<span class="tip tip-stacked">${overview_text()}</span> <span class="tip tip-stacked">${overview_text()}</span>
</li> </li>
<li class="field image" id="field-course-image">
<label>${_("Course Image")}</label>
<div class="current current-course-image">
% if context_course.course_image:
<img id="course-image" width="200" src="${utils.course_image_url(context_course)}" alt="${_('Course Image')}"/>
% endif
<button type="button" class="action action-upload-image">${_("Upload Course Image")}</button>
</div>
<div class="input">
<input type="text" class="long new-course-image-url" id="course-image-url" value="" placeholder="Your course image URL" autocomplete="off" />
<span class="tip tip-stacked">${_("Enter your course image's filename.")}</span>
</div>
</li>
<li class="field video" id="field-course-introduction-video"> <li class="field video" id="field-course-introduction-video">
<label for="course-overview">${_("Course Introduction Video")}</label> <label for="course-overview">${_("Course Introduction Video")}</label>
<div class="input input-existing"> <div class="input input-existing">
......
...@@ -338,6 +338,12 @@ class CourseFields(object): ...@@ -338,6 +338,12 @@ class CourseFields(object):
show_timezone = Boolean(help="True if timezones should be shown on dates in the courseware", scope=Scope.settings, default=True) show_timezone = Boolean(help="True if timezones should be shown on dates in the courseware", scope=Scope.settings, default=True)
enrollment_domain = String(help="External login method associated with user accounts allowed to register in course", enrollment_domain = String(help="External login method associated with user accounts allowed to register in course",
scope=Scope.settings) scope=Scope.settings)
course_image = String(
help="Filename of the course image",
scope=Scope.settings,
# Ensure that courses imported from XML keep their image
default="images_course_image.jpg"
)
# An extra property is used rather than the wiki_slug/number because # An extra property is used rather than the wiki_slug/number because
# there are courses that change the number for different runs. This allows # there are courses that change the number for different runs. This allows
......
...@@ -84,7 +84,7 @@ def course_image_url(course): ...@@ -84,7 +84,7 @@ def course_image_url(course):
if modulestore().get_modulestore_type(course.location.course_id) == XML_MODULESTORE_TYPE: if modulestore().get_modulestore_type(course.location.course_id) == XML_MODULESTORE_TYPE:
return '/static/' + course.data_dir + "/images/course_image.jpg" return '/static/' + course.data_dir + "/images/course_image.jpg"
else: else:
loc = course.location._replace(tag='c4x', category='asset', name='images_course_image.jpg') loc = course.location._replace(tag='c4x', category='asset', name=course.course_image)
_path = StaticContent.get_url_path_from_location(loc) _path = StaticContent.get_url_path_from_location(loc)
return _path return _path
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment