Commit e177270d by Peter Fogg

Merge pull request #700 from edx/peter-fogg/course-image-upload

WIP: Peter fogg/course image upload
parents 9dce9d1e 32de5aa0
...@@ -5,6 +5,9 @@ These are notable changes in edx-platform. This is a rolling list of changes, ...@@ -5,6 +5,9 @@ These are notable changes in edx-platform. This is a rolling list of changes,
in roughly chronological order, most recent first. Add your entries at or near in roughly chronological order, most recent first. Add your entries at or near
the top. Include a label indicating the component affected. the top. Include a label indicating the component affected.
Studio: Allow course authors to set their course image on the schedule
and details page, with support for JPEG and PNG images.
Blades: Took videoalpha out of alpha, replacing the old video player Blades: Took videoalpha out of alpha, replacing the old video player
Common: Allow instructors to input complicated expressions as answers to Common: Allow instructors to input complicated expressions as answers to
......
...@@ -5,9 +5,11 @@ from lettuce import world, step ...@@ -5,9 +5,11 @@ from lettuce import world, step
from nose.tools import assert_true from nose.tools import assert_true
from auth.authz import get_user_by_email, get_course_groupname_for_role from auth.authz import get_user_by_email, get_course_groupname_for_role
from django.conf import settings
from selenium.webdriver.common.keys import Keys from selenium.webdriver.common.keys import Keys
import time import time
import os
from django.contrib.auth.models import Group from django.contrib.auth.models import Group
from logging import getLogger from logging import getLogger
...@@ -15,6 +17,8 @@ logger = getLogger(__name__) ...@@ -15,6 +17,8 @@ logger = getLogger(__name__)
from terrain.browser import reset_data from terrain.browser import reset_data
TEST_ROOT = settings.COMMON_TEST_DATA_ROOT
########### STEP HELPERS ############## ########### STEP HELPERS ##############
...@@ -257,3 +261,12 @@ def type_in_codemirror(index, text): ...@@ -257,3 +261,12 @@ def type_in_codemirror(index, text):
g._element.send_keys(text) g._element.send_keys(text)
if world.is_firefox(): if world.is_firefox():
world.trigger_event('div.CodeMirror', index=index, event='blur') world.trigger_event('div.CodeMirror', index=index, event='blur')
def upload_file(filename):
file_css = '.upload-dialog input[type=file]'
upload = world.css_find(file_css).first
path = os.path.join(TEST_ROOT, filename)
upload._element.send_keys(os.path.abspath(path))
button_css = '.upload-dialog .action-upload'
world.css_click(button_css)
...@@ -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,11 @@ Feature: Course Settings ...@@ -71,3 +72,11 @@ 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 click the "Upload Course Image" button
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
...@@ -4,10 +4,13 @@ ...@@ -4,10 +4,13 @@
from lettuce import world, step 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, upload_file
from django.conf import settings
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 +149,35 @@ def test_change_course_overview(_step): ...@@ -146,6 +149,35 @@ def test_change_course_overview(_step):
type_in_codemirror(0, "<h1>Overview</h1>") type_in_codemirror(0, "<h1>Overview</h1>")
@step('I click the "Upload Course Image" button')
def click_upload_button(_step):
button_css = '.action-upload-image'
world.css_click(button_css)
@step('I upload a new course image$')
def upload_new_course_image(_step):
upload_file('image.jpg')
@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):
......
...@@ -3,7 +3,7 @@ ...@@ -3,7 +3,7 @@
from lettuce import world, step from lettuce import world, step
from django.conf import settings from django.conf import settings
import os from common import upload_file
TEST_ROOT = settings.COMMON_TEST_DATA_ROOT TEST_ROOT = settings.COMMON_TEST_DATA_ROOT
...@@ -24,14 +24,8 @@ def assert_create_new_textbook_msg(_step): ...@@ -24,14 +24,8 @@ def assert_create_new_textbook_msg(_step):
@step(u'I upload the textbook "([^"]*)"$') @step(u'I upload the textbook "([^"]*)"$')
def upload_file(_step, file_name): def upload_textbook(_step, file_name):
file_css = '.upload-dialog input[type=file]' upload_file(file_name)
upload = world.css_find(file_css)
# uploading the file itself
path = os.path.join(TEST_ROOT, 'uploads', file_name)
upload._element.send_keys(os.path.abspath(path))
button_css = ".upload-dialog .action-upload"
world.css_click(button_css)
@step(u'I click (on )?the New Textbook button') @step(u'I click (on )?the New Textbook button')
......
...@@ -1625,6 +1625,29 @@ class ContentStoreTest(ModuleStoreTestCase): ...@@ -1625,6 +1625,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.
......
...@@ -244,6 +244,7 @@ PIPELINE_JS = { ...@@ -244,6 +244,7 @@ PIPELINE_JS = {
'js/models/course.js', 'js/models/course.js',
'js/models/section.js', 'js/views/section.js', 'js/models/section.js', 'js/views/section.js',
'js/models/metadata_model.js', 'js/views/metadata_editor_view.js', 'js/models/metadata_model.js', 'js/views/metadata_editor_view.js',
'js/models/uploads.js', 'js/views/uploads.js',
'js/models/textbook.js', 'js/views/textbook.js', 'js/models/textbook.js', 'js/views/textbook.js',
'js/views/assets.js', 'js/utility.js'], 'js/views/assets.js', 'js/utility.js'],
'output_filename': 'js/cms-application.js', 'output_filename': 'js/cms-application.js',
......
...@@ -196,32 +196,3 @@ describe "CMS.Collections.ChapterSet", -> ...@@ -196,32 +196,3 @@ describe "CMS.Collections.ChapterSet", ->
# try going back one # try going back one
@collection.remove(@collection.last()) @collection.remove(@collection.last())
expect(@collection.nextOrder()).toEqual(2) expect(@collection.nextOrder()).toEqual(2)
describe "CMS.Models.FileUpload", ->
beforeEach ->
@model = new CMS.Models.FileUpload()
it "is unfinished by default", ->
expect(@model.get("finished")).toBeFalsy()
it "is not uploading by default", ->
expect(@model.get("uploading")).toBeFalsy()
it "is valid by default", ->
expect(@model.isValid()).toBeTruthy()
it "is valid for PDF files", ->
file = {"type": "application/pdf"}
@model.set("selectedFile", file);
expect(@model.isValid()).toBeTruthy()
it "is invalid for text files", ->
file = {"type": "text/plain"}
@model.set("selectedFile", file);
expect(@model.isValid()).toBeFalsy()
it "is invalid for PNG files", ->
file = {"type": "image/png"}
@model.set("selectedFile", file);
expect(@model.isValid()).toBeFalsy()
describe "CMS.Models.FileUpload", ->
beforeEach ->
@model = new CMS.Models.FileUpload()
it "is unfinished by default", ->
expect(@model.get("finished")).toBeFalsy()
it "is not uploading by default", ->
expect(@model.get("uploading")).toBeFalsy()
it "is valid by default", ->
expect(@model.isValid()).toBeTruthy()
it "is invalid for text files by default", ->
file = {"type": "text/plain"}
@model.set("selectedFile", file);
expect(@model.isValid()).toBeFalsy()
it "is invalid for PNG files by default", ->
file = {"type": "image/png"}
@model.set("selectedFile", file);
expect(@model.isValid()).toBeFalsy()
it "can accept a file type when explicitly set", ->
file = {"type": "image/png"}
@model.set("mimeTypes": ["image/png"])
@model.set("selectedFile", file)
expect(@model.isValid()).toBeTruthy()
it "can accept multiple file types", ->
file = {"type": "image/gif"}
@model.set("mimeTypes": ["image/png", "image/jpeg", "image/gif"])
@model.set("selectedFile", file)
expect(@model.isValid()).toBeTruthy()
describe "fileTypes", ->
it "returns a list of the uploader's file types", ->
@model.set('mimeTypes', ['image/png', 'application/json'])
expect(@model.fileTypes()).toEqual(['PNG', 'JSON'])
describe "formatValidTypes", ->
it "returns a map of formatted file types and extensions", ->
@model.set('mimeTypes', ['image/png', 'image/jpeg', 'application/json'])
formatted = @model.formatValidTypes()
expect(formatted).toEqual(
fileTypes: 'PNG, JPEG or JSON',
fileExtensions: '.png, .jpeg or .json'
)
it "does not format with only one mime type", ->
@model.set('mimeTypes', ['application/pdf'])
formatted = @model.formatValidTypes()
expect(formatted).toEqual(
fileTypes: 'PDF',
fileExtensions: '.pdf'
)
...@@ -301,7 +301,7 @@ describe "CMS.Views.EditChapter", -> ...@@ -301,7 +301,7 @@ describe "CMS.Views.EditChapter", ->
@view.render().$(".action-upload").click() @view.render().$(".action-upload").click()
ctorOptions = uploadSpies.constructor.mostRecentCall.args[0] ctorOptions = uploadSpies.constructor.mostRecentCall.args[0]
expect(ctorOptions.model.get('title')).toMatch(/abcde/) expect(ctorOptions.model.get('title')).toMatch(/abcde/)
expect(ctorOptions.chapter).toBe(@model) expect(typeof ctorOptions.onSuccess).toBe('function')
expect(uploadSpies.show).toHaveBeenCalled() expect(uploadSpies.show).toHaveBeenCalled()
it "saves content when opening upload dialog", -> it "saves content when opening upload dialog", ->
...@@ -311,113 +311,3 @@ describe "CMS.Views.EditChapter", -> ...@@ -311,113 +311,3 @@ describe "CMS.Views.EditChapter", ->
@view.$(".action-upload").click() @view.$(".action-upload").click()
expect(@model.get("name")).toEqual("rainbows") expect(@model.get("name")).toEqual("rainbows")
expect(@model.get("asset_path")).toEqual("unicorns") expect(@model.get("asset_path")).toEqual("unicorns")
describe "CMS.Views.UploadDialog", ->
tpl = readFixtures("upload-dialog.underscore")
beforeEach ->
setFixtures($("<script>", {id: "upload-dialog-tpl", type: "text/template"}).text(tpl))
appendSetFixtures($("<script>", {id: "system-feedback-tpl", type: "text/template"}).text(feedbackTpl))
CMS.URL.UPLOAD_ASSET = "/upload"
@model = new CMS.Models.FileUpload()
@chapter = new CMS.Models.Chapter()
@view = new CMS.Views.UploadDialog({model: @model, chapter: @chapter})
spyOn(@view, 'remove').andCallThrough()
# create mock file input, so that we aren't subject to browser restrictions
@mockFiles = []
mockFileInput = jasmine.createSpy('mockFileInput')
mockFileInput.files = @mockFiles
jqMockFileInput = jasmine.createSpyObj('jqMockFileInput', ['get', 'replaceWith'])
jqMockFileInput.get.andReturn(mockFileInput)
realMethod = @view.$
spyOn(@view, "$").andCallFake (selector) ->
if selector == "input[type=file]"
jqMockFileInput
else
realMethod.apply(this, arguments)
afterEach ->
delete CMS.URL.UPLOAD_ASSET
describe "Basic", ->
it "should be shown by default", ->
expect(@view.options.shown).toBeTruthy()
it "should render without a file selected", ->
@view.render()
expect(@view.$el).toContain("input[type=file]")
expect(@view.$(".action-upload")).toHaveClass("disabled")
it "should render with a PDF selected", ->
file = {name: "fake.pdf", "type": "application/pdf"}
@mockFiles.push(file)
@model.set("selectedFile", file)
@view.render()
expect(@view.$el).toContain("input[type=file]")
expect(@view.$el).not.toContain("#upload_error")
expect(@view.$(".action-upload")).not.toHaveClass("disabled")
it "should render an error with an invalid file type selected", ->
file = {name: "fake.png", "type": "image/png"}
@mockFiles.push(file)
@model.set("selectedFile", file)
@view.render()
expect(@view.$el).toContain("input[type=file]")
expect(@view.$el).toContain("#upload_error")
expect(@view.$(".action-upload")).toHaveClass("disabled")
it "adds body class on show()", ->
@view.show()
expect(@view.options.shown).toBeTruthy()
# can't test: this blows up the spec runner
# expect($("body")).toHaveClass("dialog-is-shown")
it "removes body class on hide()", ->
@view.hide()
expect(@view.options.shown).toBeFalsy()
# can't test: this blows up the spec runner
# expect($("body")).not.toHaveClass("dialog-is-shown")
describe "Uploads", ->
beforeEach ->
@requests = requests = []
@xhr = sinon.useFakeXMLHttpRequest()
@xhr.onCreate = (xhr) -> requests.push(xhr)
@clock = sinon.useFakeTimers()
afterEach ->
@xhr.restore()
@clock.restore()
it "can upload correctly", ->
@view.upload()
expect(@model.get("uploading")).toBeTruthy()
expect(@requests.length).toEqual(1)
request = @requests[0]
expect(request.url).toEqual("/upload")
expect(request.method).toEqual("POST")
request.respond(200, {"Content-Type": "application/json"},
'{"displayname": "starfish", "url": "/uploaded/starfish.pdf"}')
expect(@model.get("uploading")).toBeFalsy()
expect(@model.get("finished")).toBeTruthy()
expect(@chapter.get("name")).toEqual("starfish")
expect(@chapter.get("asset_path")).toEqual("/uploaded/starfish.pdf")
it "can handle upload errors", ->
@view.upload()
@requests[0].respond(500)
expect(@model.get("title")).toMatch(/error/)
expect(@view.remove).not.toHaveBeenCalled()
it "removes itself after two seconds on successful upload", ->
@view.upload()
@requests[0].respond(200, {"Content-Type": "application/json"},
'{"displayname": "starfish", "url": "/uploaded/starfish.pdf"}')
expect(@view.remove).not.toHaveBeenCalled()
@clock.tick(2001)
expect(@view.remove).toHaveBeenCalled()
feedbackTpl = readFixtures('system-feedback.underscore')
describe "CMS.Views.UploadDialog", ->
tpl = readFixtures("upload-dialog.underscore")
beforeEach ->
setFixtures($("<script>", {id: "upload-dialog-tpl", type: "text/template"}).text(tpl))
appendSetFixtures($("<script>", {id: "system-feedback-tpl", type: "text/template"}).text(feedbackTpl))
CMS.URL.UPLOAD_ASSET = "/upload"
@model = new CMS.Models.FileUpload(
mimeTypes: ['application/pdf']
)
@chapter = new CMS.Models.Chapter()
@view = new CMS.Views.UploadDialog(
model: @model,
onSuccess: (response) =>
options = {}
if !@chapter.get('name')
options.name = response.displayname
options.asset_path = response.url
@chapter.set(options)
)
spyOn(@view, 'remove').andCallThrough()
# create mock file input, so that we aren't subject to browser restrictions
@mockFiles = []
mockFileInput = jasmine.createSpy('mockFileInput')
mockFileInput.files = @mockFiles
jqMockFileInput = jasmine.createSpyObj('jqMockFileInput', ['get', 'replaceWith'])
jqMockFileInput.get.andReturn(mockFileInput)
realMethod = @view.$
spyOn(@view, "$").andCallFake (selector) ->
if selector == "input[type=file]"
jqMockFileInput
else
realMethod.apply(this, arguments)
afterEach ->
delete CMS.URL.UPLOAD_ASSET
describe "Basic", ->
it "should be shown by default", ->
expect(@view.options.shown).toBeTruthy()
it "should render without a file selected", ->
@view.render()
expect(@view.$el).toContain("input[type=file]")
expect(@view.$(".action-upload")).toHaveClass("disabled")
it "should render with a PDF selected", ->
file = {name: "fake.pdf", "type": "application/pdf"}
@mockFiles.push(file)
@model.set("selectedFile", file)
@view.render()
expect(@view.$el).toContain("input[type=file]")
expect(@view.$el).not.toContain("#upload_error")
expect(@view.$(".action-upload")).not.toHaveClass("disabled")
it "should render an error with an invalid file type selected", ->
file = {name: "fake.png", "type": "image/png"}
@mockFiles.push(file)
@model.set("selectedFile", file)
@view.render()
expect(@view.$el).toContain("input[type=file]")
expect(@view.$el).toContain("#upload_error")
expect(@view.$(".action-upload")).toHaveClass("disabled")
it "adds body class on show()", ->
@view.show()
expect(@view.options.shown).toBeTruthy()
# can't test: this blows up the spec runner
# expect($("body")).toHaveClass("dialog-is-shown")
it "removes body class on hide()", ->
@view.hide()
expect(@view.options.shown).toBeFalsy()
# can't test: this blows up the spec runner
# expect($("body")).not.toHaveClass("dialog-is-shown")
describe "Uploads", ->
beforeEach ->
@requests = requests = []
@xhr = sinon.useFakeXMLHttpRequest()
@xhr.onCreate = (xhr) -> requests.push(xhr)
@clock = sinon.useFakeTimers()
afterEach ->
@xhr.restore()
@clock.restore()
it "can upload correctly", ->
@view.upload()
expect(@model.get("uploading")).toBeTruthy()
expect(@requests.length).toEqual(1)
request = @requests[0]
expect(request.url).toEqual("/upload")
expect(request.method).toEqual("POST")
request.respond(200, {"Content-Type": "application/json"},
'{"displayname": "starfish", "url": "/uploaded/starfish.pdf"}')
expect(@model.get("uploading")).toBeFalsy()
expect(@model.get("finished")).toBeTruthy()
expect(@chapter.get("name")).toEqual("starfish")
expect(@chapter.get("asset_path")).toEqual("/uploaded/starfish.pdf")
it "can handle upload errors", ->
@view.upload()
@requests[0].respond(500)
expect(@model.get("title")).toMatch(/error/)
expect(@view.remove).not.toHaveBeenCalled()
it "removes itself after two seconds on successful upload", ->
@view.upload()
@requests[0].respond(200, {"Content-Type": "application/json"},
'{"displayname": "starfish", "url": "/uploaded/starfish.pdf"}')
expect(@view.remove).not.toHaveBeenCalled()
@clock.tick(2001)
expect(@view.remove).toHaveBeenCalled()
...@@ -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)
......
...@@ -155,24 +155,4 @@ CMS.Collections.ChapterSet = Backbone.Collection.extend({ ...@@ -155,24 +155,4 @@ CMS.Collections.ChapterSet = Backbone.Collection.extend({
return this.length === 0 || this.every(function(m) { return m.isEmpty(); }); return this.length === 0 || this.every(function(m) { return m.isEmpty(); });
} }
}); });
CMS.Models.FileUpload = Backbone.Model.extend({
defaults: {
"title": "",
"message": "",
"selectedFile": null,
"uploading": false,
"uploadedBytes": 0,
"totalBytes": 0,
"finished": false
},
// NOTE: validation functions should return non-internationalized error
// messages. The messages will be passed through gettext in the template.
validate: function(attrs, options) {
if(attrs.selectedFile && attrs.selectedFile.type !== "application/pdf") {
return {
message: "Only PDF files can be uploaded. Please select a file ending in .pdf to upload.",
attributes: {selectedFile: true}
};
}
}
});
CMS.Models.FileUpload = Backbone.Model.extend({
defaults: {
"title": "",
"message": "",
"selectedFile": null,
"uploading": false,
"uploadedBytes": 0,
"totalBytes": 0,
"finished": false,
"mimeTypes": []
},
validate: function(attrs, options) {
if(attrs.selectedFile && !_.contains(this.attributes.mimeTypes, attrs.selectedFile.type)) {
return {
message: _.template(
gettext("Only <%= fileTypes %> files can be uploaded. Please select a file ending in <%= fileExtensions %> to upload."),
this.formatValidTypes()
),
attributes: {selectedFile: true}
};
}
},
// Return a list of this uploader's valid file types
fileTypes: function() {
return _.map(
this.attributes.mimeTypes,
function(type) {
return type.split('/')[1].toUpperCase();
}
);
},
// Return strings for the valid file types and extensions this
// uploader accepts, formatted as natural language
formatValidTypes: function() {
if(this.attributes.mimeTypes.length === 1) {
return {
fileTypes: this.fileTypes()[0],
fileExtensions: '.' + this.fileTypes()[0].toLowerCase()
};
}
var or = gettext('or');
var formatTypes = function(types) {
return _.template('<%= initial %> <%= or %> <%= last %>', {
initial: _.initial(types).join(', '),
or: or,
last: _.last(types)
});
};
return {
fileTypes: formatTypes(this.fileTypes()),
fileExtensions: formatTypes(
_.map(this.fileTypes(),
function(type) {
return '.' + type.toLowerCase();
})
)
};
}
});
...@@ -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() {
...@@ -25,6 +25,14 @@ CMS.Views.Settings.Details = CMS.Views.ValidatingView.extend({ ...@@ -25,6 +25,14 @@ CMS.Views.Settings.Details = CMS.Views.ValidatingView.extend({
this.$el.find("#course-number").val(this.model.get('location').get('course')); this.$el.find("#course-number").val(this.model.get('location').get('course'));
this.$el.find('.set-date').datepicker({ 'dateFormat': 'm/d/yy' }); this.$el.find('.set-date').datepicker({ 'dateFormat': 'm/d/yy' });
// Avoid showing broken image on mistyped/nonexistent image
this.$el.find('img.course-image').error(function() {
$(this).hide();
});
this.$el.find('img.course-image').load(function() {
$(this).show();
});
var dateIntrospect = new Date(); var dateIntrospect = new Date();
this.$el.find('#timezone').html("(" + dateIntrospect.getTimezone() + ")"); this.$el.find('#timezone').html("(" + dateIntrospect.getTimezone() + ")");
...@@ -51,6 +59,10 @@ CMS.Views.Settings.Details = CMS.Views.ValidatingView.extend({ ...@@ -51,6 +59,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 +72,8 @@ CMS.Views.Settings.Details = CMS.Views.ValidatingView.extend({ ...@@ -60,7 +72,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 +134,17 @@ CMS.Views.Settings.Details = CMS.Views.ValidatingView.extend({ ...@@ -121,6 +134,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 +240,29 @@ CMS.Views.Settings.Details = CMS.Views.ValidatingView.extend({ ...@@ -216,6 +240,29 @@ 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 JPEG or PNG format."),
mimeTypes: ['image/jpeg', 'image/png']
});
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);
} }
}); });
...@@ -243,120 +243,21 @@ CMS.Views.EditChapter = Backbone.View.extend({ ...@@ -243,120 +243,21 @@ CMS.Views.EditChapter = Backbone.View.extend({
var msg = new CMS.Models.FileUpload({ var msg = new CMS.Models.FileUpload({
title: _.template(gettext("Upload a new PDF to “<%= name %>”"), title: _.template(gettext("Upload a new PDF to “<%= name %>”"),
{name: section.escape('name')}), {name: section.escape('name')}),
message: "Files must be in PDF format." message: "Files must be in PDF format.",
mimeTypes: ['application/pdf']
}); });
var view = new CMS.Views.UploadDialog({model: msg, chapter: this.model});
$(".wrapper-view").after(view.show().el);
}
});
CMS.Views.UploadDialog = Backbone.View.extend({
options: {
shown: true,
successMessageTimeout: 2000 // 2 seconds
},
initialize: function() {
this.template = _.template($("#upload-dialog-tpl").text());
this.listenTo(this.model, "change", this.render);
},
render: function() {
var isValid = this.model.isValid();
var selectedFile = this.model.get('selectedFile');
var oldInput = this.$("input[type=file]").get(0);
this.$el.html(this.template({
shown: this.options.shown,
url: CMS.URL.UPLOAD_ASSET,
title: this.model.escape('title'),
message: this.model.escape('message'),
selectedFile: selectedFile,
uploading: this.model.get('uploading'),
uploadedBytes: this.model.get('uploadedBytes'),
totalBytes: this.model.get('totalBytes'),
finished: this.model.get('finished'),
error: this.model.validationError
}));
// Ideally, we'd like to tell the browser to pre-populate the
// <input type="file"> with the selectedFile if we have one -- but
// browser security prohibits that. So instead, we'll swap out the
// new input (that has no file selected) with the old input (that
// already has the selectedFile selected). However, we only want to do
// this if the selected file is valid: if it isn't, we want to render
// a blank input to prompt the user to upload a different (valid) file.
if (selectedFile && isValid) {
$(oldInput).removeClass("error");
this.$('input[type=file]').replaceWith(oldInput);
}
return this;
},
events: {
"change input[type=file]": "selectFile",
"click .action-cancel": "hideAndRemove",
"click .action-upload": "upload"
},
selectFile: function(e) {
this.model.set({
selectedFile: e.target.files[0] || null
});
},
show: function(e) {
if(e && e.preventDefault) { e.preventDefault(); }
this.options.shown = true;
$body.addClass('dialog-is-shown');
return this.render();
},
hide: function(e) {
if(e && e.preventDefault) { e.preventDefault(); }
this.options.shown = false;
$body.removeClass('dialog-is-shown');
return this.render();
},
hideAndRemove: function(e) {
if(e && e.preventDefault) { e.preventDefault(); }
return this.hide().remove();
},
upload: function(e) {
this.model.set('uploading', true);
this.$("form").ajaxSubmit({
success: _.bind(this.success, this),
error: _.bind(this.error, this),
uploadProgress: _.bind(this.progress, this),
data: {
// don't show the generic error notification; we're in a modal,
// and we're better off modifying it instead.
notifyOnError: false
}
});
},
progress: function(event, position, total, percentComplete) {
this.model.set({
"uploadedBytes": position,
"totalBytes": total
});
},
success: function(response, statusText, xhr, form) {
this.model.set({
uploading: false,
finished: true
});
var chapter = this.options.chapter;
if(chapter) {
var options = {};
if(!chapter.get("name")) {
options.name = response.displayname;
}
options.asset_path = response.url;
chapter.set(options);
}
var that = this; var that = this;
this.removalTimeout = setTimeout(function() { var view = new CMS.Views.UploadDialog({
that.hide().remove(); model: msg,
}, this.options.successMessageTimeout); onSuccess: function(response) {
}, var options = {};
error: function() { if(!that.model.get('name')) {
this.model.set({ options.name = response.displayname;
"uploading": false, }
"uploadedBytes": 0, options.asset_path = response.url;
"title": gettext("We're sorry, there was an error") that.model.set(options);
},
}); });
$(".wrapper-view").after(view.show().el);
} }
}); });
CMS.Views.UploadDialog = Backbone.View.extend({
options: {
shown: true,
successMessageTimeout: 2000 // 2 seconds
},
initialize: function() {
this.template = _.template($("#upload-dialog-tpl").text());
this.listenTo(this.model, "change", this.render);
},
render: function() {
var isValid = this.model.isValid();
var selectedFile = this.model.get('selectedFile');
var oldInput = this.$("input[type=file]").get(0);
this.$el.html(this.template({
shown: this.options.shown,
url: CMS.URL.UPLOAD_ASSET,
title: this.model.escape('title'),
message: this.model.escape('message'),
selectedFile: selectedFile,
uploading: this.model.get('uploading'),
uploadedBytes: this.model.get('uploadedBytes'),
totalBytes: this.model.get('totalBytes'),
finished: this.model.get('finished'),
error: this.model.validationError
}));
// Ideally, we'd like to tell the browser to pre-populate the
// <input type="file"> with the selectedFile if we have one -- but
// browser security prohibits that. So instead, we'll swap out the
// new input (that has no file selected) with the old input (that
// already has the selectedFile selected). However, we only want to do
// this if the selected file is valid: if it isn't, we want to render
// a blank input to prompt the user to upload a different (valid) file.
if (selectedFile && isValid) {
$(oldInput).removeClass("error");
this.$('input[type=file]').replaceWith(oldInput);
}
return this;
},
events: {
"change input[type=file]": "selectFile",
"click .action-cancel": "hideAndRemove",
"click .action-upload": "upload"
},
selectFile: function(e) {
this.model.set({
selectedFile: e.target.files[0] || null
});
},
show: function(e) {
if(e && e.preventDefault) { e.preventDefault(); }
this.options.shown = true;
$body.addClass('dialog-is-shown');
return this.render();
},
hide: function(e) {
if(e && e.preventDefault) { e.preventDefault(); }
this.options.shown = false;
$body.removeClass('dialog-is-shown');
return this.render();
},
hideAndRemove: function(e) {
if(e && e.preventDefault) { e.preventDefault(); }
return this.hide().remove();
},
upload: function(e) {
if(e && e.preventDefault) { e.preventDefault(); }
this.model.set('uploading', true);
this.$("form").ajaxSubmit({
success: _.bind(this.success, this),
error: _.bind(this.error, this),
uploadProgress: _.bind(this.progress, this),
data: {
// don't show the generic error notification; we're in a modal,
// and we're better off modifying it instead.
notifyOnError: false
}
});
},
progress: function(event, position, total, percentComplete) {
this.model.set({
"uploadedBytes": position,
"totalBytes": total
});
},
success: function(response, statusText, xhr, form) {
this.model.set({
uploading: false,
finished: true
});
if(this.options.onSuccess) {
this.options.onSuccess(response, statusText, xhr, form);
}
var that = this;
this.removalTimeout = setTimeout(function() {
that.hide().remove();
}, this.options.successMessageTimeout);
},
error: function() {
this.model.set({
"uploading": false,
"uploadedBytes": 0,
"title": gettext("We're sorry, there was an error")
});
}
});
...@@ -42,6 +42,7 @@ ...@@ -42,6 +42,7 @@
@import 'elements/system-help'; // help UI @import 'elements/system-help'; // help UI
@import 'elements/modal'; // interstitial UI, dialogs, modal windows @import 'elements/modal'; // interstitial UI, dialogs, modal windows
@import 'elements/vendor'; // overrides to vendor-provided styling @import 'elements/vendor'; // overrides to vendor-provided styling
@import 'elements/uploads';
// base - specific views // base - specific views
@import 'views/account'; @import 'views/account';
......
// studio - elements - uploads
// ========================
body.course.feature-upload {
// dialog
.wrapper-dialog {
@extend .ui-depth5;
@include transition(all 0.05s ease-in-out);
position: fixed;
top: 0;
background: $black-t2;
width: 100%;
height: 100%;
text-align: center;
&:before {
content: '';
display: inline-block;
height: 100%;
vertical-align: middle;
margin-right: -0.25em; /* Adjusts for spacing */
}
.dialog {
@include box-sizing(border-box);
box-shadow: 0px 0px 7px $shadow-d1;
border-radius: ($baseline/5);
background-color: $gray-l4;
display: inline-block;
vertical-align: middle;
width: $baseline*23;
padding: 7px;
text-align: left;
.title {
@extend .t-title5;
margin-bottom: ($baseline/2);
font-weight: 600;
color: $black;
}
.message {
@extend .t-copy-sub2;
color: $gray;
}
.error {
color: $white;
}
form {
padding: 0;
.form-content {
box-shadow: 0 0 3px $shadow-d1;
padding: ($baseline*1.5);
background-color: $white;
}
input[type="file"] {
@extend .t-copy-sub2;
}
.status-upload {
height: 30px;
margin-top: $baseline;
.wrapper-progress {
box-shadow: inset 0 0 3px $shadow-d1;
display: block;
border-radius: ($baseline*0.75);
background-color: $gray-l5;
padding: 1px 8px 2px 8px;
height: 25px;
progress {
display: inline-block;
vertical-align: middle;
width: 100%;
border: none;
border-radius: ($baseline*0.75);
background-color: $gray-l5;
&::-webkit-progress-bar {
background-color: transparent;
border-radius: ($baseline*0.75);
}
&::-webkit-progress-value {
background-color: $pink;
border-radius: ($baseline*0.75);
}
&::-moz-progress-bar {
background-color: $pink;
border-radius: ($baseline*0.75);
}
}
}
.message-status {
@include border-top-radius(2px);
@include box-sizing(border-box);
@include font-size(14);
display: none;
border-bottom: 2px solid $yellow;
margin: 0 0 20px 0;
padding: 10px 20px;
font-weight: 500;
background: $paleYellow;
.text {
display: inline-block;
}
&.error {
border-color: $red-d2;
background: $red-l1;
color: $white;
}
&.confirm {
border-color: $green-d2;
background: $green-l1;
color: $white;
}
&.is-shown {
display: block;
}
}
}
.actions {
padding: ($baseline*0.75) $baseline ($baseline/2) $baseline;
.action-item {
@extend .t-action4;
display: inline-block;
margin-right: ($baseline*0.75);
&:last-child {
margin-right: 0;
}
}
.action-primary {
@include blue-button();
@include font-size(12); // needed due to bad button mixins for now
border-color: $blue-d1;
color: $white;
}
a {
color: $blue;
&:hover {
color: $blue-s2;
}
}
}
}
}
}
// ====================
// js enabled
.js {
// dialog set-up
.wrapper-dialog {
visibility: hidden;
pointer-events: none;
.dialog {
opacity: 0;
}
}
// dialog showing/hiding
&.dialog-is-shown {
.wrapper-dialog {
-webkit-filter: blur(2px) grayscale(25%);
filter: blur(2px) grayscale(25%);
}
.wrapper-dialog.is-shown {
visibility: visible;
pointer-events: auto;
.dialog {
opacity: 1.0;
}
}
}
}
}
...@@ -131,7 +131,7 @@ body.course.settings { ...@@ -131,7 +131,7 @@ body.course.settings {
list-style: none; list-style: none;
.field { .field {
margin: 0 0 $baseline 0; margin: 0 0 ($baseline*2) 0;
&:last-child { &:last-child {
margin-bottom: 0; margin-bottom: 0;
...@@ -432,6 +432,61 @@ body.course.settings { ...@@ -432,6 +432,61 @@ body.course.settings {
} }
} }
// specific fields - course image
#field-course-image {
.current-course-image {
margin-bottom: ($baseline/2);
padding: ($baseline/2) $baseline;
background: $gray-l5;
text-align: center;
.wrapper-course-image {
display: block;
width: 375px;
height: 200px;
overflow: hidden;
margin: 0 auto;
border: 1px solid $gray-l4;
box-shadow: 0 1px 1px $shadow-l1;
padding: ($baseline/2);
background: $white;
}
.course-image {
display: block;
width: 100%;
min-height: 100%;
}
.msg {
@extend .t-copy-sub2;
display: block;
margin-top: ($baseline/2);
color: $gray-l3;
}
}
.wrapper-input {
@include clearfix();
width: flex-grid(9,9);
.input {
float: left;
width: flex-grid(6,9);
margin-right: flex-gutter();
}
.action-upload-image {
@extend .ui-btn-flat-outline;
float: right;
width: flex-grid(2,9);
margin-top: ($baseline/4);
padding: ($baseline/2) $baseline;
}
}
}
// specific fields - requirements // specific fields - requirements
&.requirements { &.requirements {
...@@ -445,7 +500,7 @@ body.course.settings { ...@@ -445,7 +500,7 @@ body.course.settings {
margin-bottom: ($baseline*3); margin-bottom: ($baseline*3);
.grade-controls { .grade-controls {
@include clearfix; @include clearfix();
width: flex-grid(9,9); width: flex-grid(9,9);
} }
......
...@@ -370,213 +370,4 @@ body.course.textbooks { ...@@ -370,213 +370,4 @@ body.course.textbooks {
.content-supplementary { .content-supplementary {
width: flex-grid(3, 12); width: flex-grid(3, 12);
} }
// dialog
.wrapper-dialog {
@extend .ui-depth5;
@include transition(all 0.05s ease-in-out);
position: fixed;
top: 0;
background: $black-t2;
width: 100%;
height: 100%;
text-align: center;
&:before {
content: '';
display: inline-block;
height: 100%;
vertical-align: middle;
margin-right: -0.25em; /* Adjusts for spacing */
}
.dialog {
@include box-sizing(border-box);
box-shadow: 0px 0px 7px $shadow-d1;
border-radius: ($baseline/5);
background-color: $gray-l4;
display: inline-block;
vertical-align: middle;
width: $baseline*23;
padding: 7px;
text-align: left;
.title {
@extend .t-title5;
margin-bottom: ($baseline/2);
font-weight: 600;
color: $black;
}
.message {
@extend .t-copy-sub2;
color: $gray;
}
.error {
color: $white;
}
form {
padding: 0;
.form-content {
box-shadow: 0 0 3px $shadow-d1;
padding: ($baseline*1.5);
background-color: $white;
}
input[type="file"] {
@extend .t-copy-sub2;
}
.status-upload {
height: 30px;
margin-top: $baseline;
.wrapper-progress {
box-shadow: inset 0 0 3px $shadow-d1;
display: block;
border-radius: ($baseline*0.75);
background-color: $gray-l5;
padding: 1px 8px 2px 8px;
height: 25px;
progress {
display: inline-block;
vertical-align: middle;
width: 100%;
border: none;
border-radius: ($baseline*0.75);
background-color: $gray-l5;
&::-webkit-progress-bar {
background-color: transparent;
border-radius: ($baseline*0.75);
}
&::-webkit-progress-value {
background-color: $pink;
border-radius: ($baseline*0.75);
}
&::-moz-progress-bar {
background-color: $pink;
border-radius: ($baseline*0.75);
}
}
}
.message-status {
@include border-top-radius(2px);
@include box-sizing(border-box);
@include font-size(14);
display: none;
border-bottom: 2px solid $yellow;
margin: 0 0 20px 0;
padding: 10px 20px;
font-weight: 500;
background: $paleYellow;
.text {
display: inline-block;
}
&.error {
border-color: $red-d2;
background: $red-l1;
color: $white;
}
&.confirm {
border-color: $green-d2;
background: $green-l1;
color: $white;
}
&.is-shown {
display: block;
}
}
}
.actions {
padding: ($baseline*0.75) $baseline ($baseline/2) $baseline;
.action-item {
@extend .t-action4;
display: inline-block;
margin-right: ($baseline*0.75);
&:last-child {
margin-right: 0;
}
}
.action-primary {
@include blue-button();
@include font-size(12); // needed due to bad button mixins for now
border-color: $blue-d1;
color: $white;
}
a {
color: $blue;
&:hover {
color: $blue-s2;
}
}
}
}
}
}
// ====================
// js enabled
.js {
// dialog set-up
.wrapper-dialog {
visibility: hidden;
pointer-events: none;
.dialog {
opacity: 0;
}
}
// dialog showing/hiding
&.dialog-is-shown {
.wrapper-dialog {
-webkit-filter: blur(2px) grayscale(25%);
filter: blur(2px) grayscale(25%);
}
.wrapper-dialog.is-shown {
visibility: visible;
pointer-events: auto;
.dialog {
opacity: 1.0;
}
}
}
}
} }
...@@ -11,7 +11,7 @@ ...@@ -11,7 +11,7 @@
<h2 class="title"><%= title %></h2> <h2 class="title"><%= title %></h2>
<% if(error) {%> <% if(error) {%>
<div id="upload_error" class="message message-status message-status error is-shown" name="upload_error"> <div id="upload_error" class="message message-status message-status error is-shown" name="upload_error">
<p><%= gettext(error.message) %></p> <p><%= error.message %></p>
</div> </div>
<% } %> <% } %>
<p id="dialog-assetupload-description" class="message"><%= message %></p> <p id="dialog-assetupload-description" class="message"><%= message %></p>
......
...@@ -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 feature-upload</%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,34 @@ from contentstore import utils ...@@ -208,6 +214,34 @@ 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:
<span class="wrapper-course-image">
<img class="course-image" id="course-image" src="${utils.course_image_url(context_course)}" alt="${_('Course Image')}"/>
</span>
<% ctx_loc = context_course.location %>
<span class="msg msg-help">${_("You can manage this image along with all of your other")} <a href="${reverse('asset_index', kwargs=dict(org=ctx_loc.org, course=ctx_loc.course, name=ctx_loc.name))}">${_("files &amp; uploads")}</a></span>
% else:
<span class="wrapper-course-image">
<img class="course-image placeholder" id="course-image" src="${utils.course_image_url(context_course)}" alt="${_('Course Image')}"/>
</span>
<span class="msg msg-empty">${_("Your course currently does not have an image. Please upload one (JPEG or PNG format, and minimum suggested dimensions are 375px wide by 200px tall)")}</span>
% endif
</div>
<div class="wrapper-input">
<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">${_("Please provide a valid path and name to your course image (Note: only JPEG or PNG format supported)")}</span>
</div>
<button type="button" class="action action-upload-image">${_("Upload Course Image")}</button>
</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">
......
...@@ -4,7 +4,7 @@ ...@@ -4,7 +4,7 @@
<%! from django.utils.translation import ugettext as _ %> <%! from django.utils.translation import ugettext as _ %>
<%block name="title">${_("Textbooks")}</%block> <%block name="title">${_("Textbooks")}</%block>
<%block name="bodyclass">is-signedin course textbooks</%block> <%block name="bodyclass">is-signedin course textbooks feature-upload</%block>
<%block name="header_extras"> <%block name="header_extras">
% for template_name in ["edit-textbook", "show-textbook", "edit-chapter", "no-textbooks", "upload-dialog"]: % for template_name in ["edit-textbook", "show-textbook", "edit-chapter", "no-textbooks", "upload-dialog"]:
......
...@@ -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