From ce1a13f32c5d261ceb4054d39676429f3c089849 Mon Sep 17 00:00:00 2001 From: Peter Fogg Date: Tue, 13 Aug 2013 13:22:55 -0400 Subject: [PATCH 1/8] Generalize file uploader. Previously the file upload dialog was PDF- and textbook-specific. The changes are adding parameters to the FileUpload model for the file type, and adding an onSuccess callback to the UploadDialog view. Also moved upload-specific SASS into its own file. --- cms/envs/common.py | 1 + .../coffee/spec/views/textbook_spec.coffee | 12 +- cms/static/js/models/textbook.js | 22 +- cms/static/js/models/uploads.js | 23 ++ cms/static/js/views/textbook.js | 124 +---------- cms/static/js/views/uploads.js | 105 +++++++++ cms/static/sass/base-style.scss | 1 + cms/static/sass/views/_textbooks.scss | 209 ------------------ cms/static/sass/views/_uploads.scss | 209 ++++++++++++++++++ cms/templates/textbooks.html | 2 +- 10 files changed, 363 insertions(+), 345 deletions(-) create mode 100644 cms/static/js/models/uploads.js create mode 100644 cms/static/js/views/uploads.js create mode 100644 cms/static/sass/views/_uploads.scss diff --git a/cms/envs/common.py b/cms/envs/common.py index d91e2aeeba..86b3d3f1df 100644 --- a/cms/envs/common.py +++ b/cms/envs/common.py @@ -244,6 +244,7 @@ PIPELINE_JS = { 'js/models/course.js', 'js/models/section.js', 'js/views/section.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/views/assets.js', 'js/utility.js'], 'output_filename': 'js/cms-application.js', diff --git a/cms/static/coffee/spec/views/textbook_spec.coffee b/cms/static/coffee/spec/views/textbook_spec.coffee index 981659abfa..5185c9fb47 100644 --- a/cms/static/coffee/spec/views/textbook_spec.coffee +++ b/cms/static/coffee/spec/views/textbook_spec.coffee @@ -301,7 +301,7 @@ describe "CMS.Views.EditChapter", -> @view.render().$(".action-upload").click() ctorOptions = uploadSpies.constructor.mostRecentCall.args[0] expect(ctorOptions.model.get('title')).toMatch(/abcde/) - expect(ctorOptions.chapter).toBe(@model) + expect(typeof ctorOptions.onSuccess).toBe('function') expect(uploadSpies.show).toHaveBeenCalled() it "saves content when opening upload dialog", -> @@ -323,7 +323,15 @@ describe "CMS.Views.UploadDialog", -> @model = new CMS.Models.FileUpload() @chapter = new CMS.Models.Chapter() - @view = new CMS.Views.UploadDialog({model: @model, chapter: @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 diff --git a/cms/static/js/models/textbook.js b/cms/static/js/models/textbook.js index cdd86023dc..72c9dfb0be 100644 --- a/cms/static/js/models/textbook.js +++ b/cms/static/js/models/textbook.js @@ -155,24 +155,4 @@ CMS.Collections.ChapterSet = Backbone.Collection.extend({ 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} - }; - } - } -}); + diff --git a/cms/static/js/models/uploads.js b/cms/static/js/models/uploads.js new file mode 100644 index 0000000000..ba557306eb --- /dev/null +++ b/cms/static/js/models/uploads.js @@ -0,0 +1,23 @@ +CMS.Models.FileUpload = Backbone.Model.extend({ + defaults: { + "title": "", + "message": "", + "selectedFile": null, + "uploading": false, + "uploadedBytes": 0, + "totalBytes": 0, + "finished": false, + "mimeType": "application/pdf", + "fileType": "PDF" + }, + // 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 !== this.attributes.mimeType) { + return { + message: "Only " + this.attributes.fileType + " files can be uploaded. Please select a file ending in ." + this.attributes.fileType.toLowerCase() + " to upload.", + attributes: {selectedFile: true} + }; + } + } +}); diff --git a/cms/static/js/views/textbook.js b/cms/static/js/views/textbook.js index f666143c0a..992ac5cd53 100644 --- a/cms/static/js/views/textbook.js +++ b/cms/static/js/views/textbook.js @@ -245,118 +245,18 @@ CMS.Views.EditChapter = Backbone.View.extend({ {name: section.escape('name')}), message: "Files must be in PDF format." }); - var view = new CMS.Views.UploadDialog({model: msg, chapter: this.model}); + var that = this; + var view = new CMS.Views.UploadDialog({ + model: msg, + onSuccess: function(response) { + var options = {}; + if(!that.model.get('name')) { + options.name = response.displayname; + } + options.asset_path = response.url; + 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 - // 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; - 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") - }); - } -}); diff --git a/cms/static/js/views/uploads.js b/cms/static/js/views/uploads.js new file mode 100644 index 0000000000..9ce111e805 --- /dev/null +++ b/cms/static/js/views/uploads.js @@ -0,0 +1,105 @@ +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 + // 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") + }); + } +}); diff --git a/cms/static/sass/base-style.scss b/cms/static/sass/base-style.scss index 7011089527..757ffe02bd 100644 --- a/cms/static/sass/base-style.scss +++ b/cms/static/sass/base-style.scss @@ -59,6 +59,7 @@ @import 'views/users'; @import 'views/checklists'; @import 'views/textbooks'; +@import 'views/uploads'; // temp - inherited @import 'assets/content-types'; diff --git a/cms/static/sass/views/_textbooks.scss b/cms/static/sass/views/_textbooks.scss index b83d22414b..ac644b6ff7 100644 --- a/cms/static/sass/views/_textbooks.scss +++ b/cms/static/sass/views/_textbooks.scss @@ -370,213 +370,4 @@ body.course.textbooks { .content-supplementary { 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; - } - } - } - -} - - - - - } diff --git a/cms/static/sass/views/_uploads.scss b/cms/static/sass/views/_uploads.scss new file mode 100644 index 0000000000..f564ac1c13 --- /dev/null +++ b/cms/static/sass/views/_uploads.scss @@ -0,0 +1,209 @@ +// studio - views - uploads +// ======================== + +body.course.file-upload-dialog { + + // 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; + } + } + } + + } +} diff --git a/cms/templates/textbooks.html b/cms/templates/textbooks.html index 28349b5436..5b79b7f265 100644 --- a/cms/templates/textbooks.html +++ b/cms/templates/textbooks.html @@ -4,7 +4,7 @@ <%! from django.utils.translation import ugettext as _ %> <%block name="title">${_("Textbooks")} -<%block name="bodyclass">is-signedin course textbooks +<%block name="bodyclass">is-signedin course textbooks file-upload-dialog <%block name="header_extras"> % for template_name in ["edit-textbook", "show-textbook", "edit-chapter", "no-textbooks", "upload-dialog"]: From e4302e62d2791d5d5318c6ec761b4fa71b2163b2 Mon Sep 17 00:00:00 2001 From: Peter Fogg Date: Wed, 14 Aug 2013 16:51:14 -0400 Subject: [PATCH 2/8] 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. --- .../features/course-settings.feature | 8 +++ .../contentstore/features/course-settings.py | 34 +++++++++++++ .../contentstore/tests/test_contentstore.py | 23 +++++++++ .../tests/test_course_settings.py | 9 ++++ .../contentstore/tests/test_utils.py | 11 +++++ cms/djangoapps/contentstore/utils.py | 8 +++ cms/djangoapps/contentstore/views/course.py | 7 ++- .../models/settings/course_details.py | 10 +++- .../js/models/settings/course_details.js | 4 +- .../js/views/settings/main_settings_view.js | 46 ++++++++++++++++-- cms/static/sass/views/_settings.scss | 14 ++++++ cms/templates/settings.html | 23 ++++++++- common/lib/xmodule/xmodule/course_module.py | 6 +++ common/test/data/uploads/image.jpg | Bin 0 -> 13811 bytes lms/djangoapps/courseware/courses.py | 2 +- 15 files changed, 197 insertions(+), 8 deletions(-) create mode 100644 common/test/data/uploads/image.jpg diff --git a/cms/djangoapps/contentstore/features/course-settings.feature b/cms/djangoapps/contentstore/features/course-settings.feature index 5c79dc7ee3..69183bc3da 100644 --- a/cms/djangoapps/contentstore/features/course-settings.feature +++ b/cms/djangoapps/contentstore/features/course-settings.feature @@ -57,6 +57,7 @@ Feature: Course Settings | Course Start Time | 11:00 | | Course Introduction Video | 4r7wHMg5Yjg | | Course Effort | 200:00 | + | Course Image URL | image.jpg | # Special case because we have to type in code mirror Scenario: Changes in Course Overview show a confirmation @@ -71,3 +72,10 @@ Feature: Course Settings When I select Schedule and Details And I change the "Course Start Date" field to "" 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 diff --git a/cms/djangoapps/contentstore/features/course-settings.py b/cms/djangoapps/contentstore/features/course-settings.py index da72d893cf..0847c62a18 100644 --- a/cms/djangoapps/contentstore/features/course-settings.py +++ b/cms/djangoapps/contentstore/features/course-settings.py @@ -5,9 +5,13 @@ from lettuce import world, step from terrain.steps import reload_the_page from selenium.webdriver.common.keys import Keys from common import type_in_codemirror +from django.conf import settings +import os 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_END_DATE_CSS = "#course-end-date" ENROLLMENT_START_DATE_CSS = "#course-enrollment-start-date" @@ -146,6 +150,36 @@ def test_change_course_overview(_step): type_in_codemirror(0, "

Overview

") +@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 #################### def set_date_or_time(css, date_or_time): diff --git a/cms/djangoapps/contentstore/tests/test_contentstore.py b/cms/djangoapps/contentstore/tests/test_contentstore.py index 96b0b84e36..216edc6b88 100644 --- a/cms/djangoapps/contentstore/tests/test_contentstore.py +++ b/cms/djangoapps/contentstore/tests/test_contentstore.py @@ -1608,6 +1608,29 @@ class ContentStoreTest(ModuleStoreTestCase): # is this test too strict? i.e., it requires the dicts to be == 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): """Test that metadata is correctly cached and decached.""" diff --git a/cms/djangoapps/contentstore/tests/test_course_settings.py b/cms/djangoapps/contentstore/tests/test_course_settings.py index 2007ba2f69..dbdf8b3f6e 100644 --- a/cms/djangoapps/contentstore/tests/test_course_settings.py +++ b/cms/djangoapps/contentstore/tests/test_course_settings.py @@ -30,6 +30,7 @@ class CourseDetailsTestCase(CourseTestCase): def test_virgin_fetch(self): details = CourseDetails.fetch(self.course.location) 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.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)) @@ -43,6 +44,7 @@ class CourseDetailsTestCase(CourseTestCase): jsondetails = json.dumps(details, cls=CourseSettingsEncoder) jsondetails = json.loads(jsondetails) 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['enrollment_start'], "enrollment_start date somehow initialized ") self.assertIsNone(jsondetails['enrollment_end'], "enrollment_end date somehow initialized ") @@ -97,6 +99,11 @@ class CourseDetailsTestCase(CourseTestCase): CourseDetails.update_from_json(jsondetails.__dict__).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'}) def test_marketing_site_fetch(self): @@ -188,6 +195,7 @@ class CourseDetailsViewTest(CourseTestCase): self.alter_field(url, details, 'overview', "Overview") self.alter_field(url, details, 'intro_video', "intro_video") 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): self.compare_date_fields(details, encoded, context, 'start_date') @@ -197,6 +205,7 @@ class CourseDetailsViewTest(CourseTestCase): 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['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): if details[field] is not None: diff --git a/cms/djangoapps/contentstore/tests/test_utils.py b/cms/djangoapps/contentstore/tests/test_utils.py index c3335aaaa0..3d6d1d0c56 100644 --- a/cms/djangoapps/contentstore/tests/test_utils.py +++ b/cms/djangoapps/contentstore/tests/test_utils.py @@ -5,6 +5,7 @@ import collections import copy from django.test import TestCase from django.test.utils import override_settings +from xmodule.modulestore.tests.factories import CourseFactory class LMSLinksTestCase(TestCase): @@ -150,3 +151,13 @@ class ExtraPanelTabTestCase(TestCase): changed, actual_tabs = utils.remove_extra_panel_tab(tab_type, course) self.assertFalse(changed) 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)) diff --git a/cms/djangoapps/contentstore/utils.py b/cms/djangoapps/contentstore/utils.py index d956a903b6..e5ae6bb66b 100644 --- a/cms/djangoapps/contentstore/utils.py +++ b/cms/djangoapps/contentstore/utils.py @@ -4,6 +4,7 @@ from django.conf import settings from xmodule.modulestore import Location from xmodule.modulestore.django import modulestore from xmodule.modulestore.exceptions import ItemNotFoundError +from xmodule.contentstore.content import StaticContent from django.core.urlresolvers import reverse import copy import logging @@ -153,6 +154,13 @@ def get_lms_link_for_about_page(location): 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): draft = 'draft' private = 'private' diff --git a/cms/djangoapps/contentstore/views/course.py b/cms/djangoapps/contentstore/views/course.py index 753df66fe0..aad56e4a2e 100644 --- a/cms/djangoapps/contentstore/views/course.py +++ b/cms/djangoapps/contentstore/views/course.py @@ -276,7 +276,12 @@ def get_course_settings(request, org, course, name): "section": "details"}), 'about_page_editable': not settings.MITX_FEATURES.get( 'ENABLE_MKTG_SITE', False - ) + ), + 'upload_asset_url': reverse('upload_asset', kwargs={ + 'org': org, + 'course': course, + 'coursename': name, + }) }) diff --git a/cms/djangoapps/models/settings/course_details.py b/cms/djangoapps/models/settings/course_details.py index 78c5dcff33..99ce00b891 100644 --- a/cms/djangoapps/models/settings/course_details.py +++ b/cms/djangoapps/models/settings/course_details.py @@ -3,7 +3,7 @@ from xmodule.modulestore.exceptions import ItemNotFoundError from xmodule.modulestore.inheritance import own_metadata import json 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 contentstore.utils import update_item from xmodule.fields import Date @@ -23,6 +23,8 @@ class CourseDetails(object): self.overview = "" # html to render as the overview self.intro_video = None # a video pointer self.effort = None # int hours/week + self.course_image_name = "" + self.course_image_asset_path = "" # URL of the course image @classmethod def fetch(cls, course_location): @@ -40,6 +42,8 @@ class CourseDetails(object): course.end_date = descriptor.end course.enrollment_start = descriptor.enrollment_start 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') try: @@ -121,6 +125,10 @@ class CourseDetails(object): dirty = True 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: # Save the data that we've just changed to the underlying # MongoKeyValueStore before we update the mongo datastore. diff --git a/cms/static/js/models/settings/course_details.js b/cms/static/js/models/settings/course_details.js index 4d048bab81..b66f2bbba9 100644 --- a/cms/static/js/models/settings/course_details.js +++ b/cms/static/js/models/settings/course_details.js @@ -10,7 +10,9 @@ CMS.Models.Settings.CourseDetails = Backbone.Model.extend({ syllabus: null, overview: "", 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) diff --git a/cms/static/js/views/settings/main_settings_view.js b/cms/static/js/views/settings/main_settings_view.js index 0cbf573ba9..36bee79d80 100644 --- a/cms/static/js/views/settings/main_settings_view.js +++ b/cms/static/js/views/settings/main_settings_view.js @@ -13,8 +13,8 @@ CMS.Views.Settings.Details = CMS.Views.ValidatingView.extend({ 'mouseover #timezone' : "updateTime", // would love to move to a general superclass, but event hashes don't inherit in backbone :-( 'focus :input' : "inputFocus", - 'blur :input' : "inputUnfocus" - + 'blur :input' : "inputUnfocus", + 'click .action-upload-image': "uploadImage" }, initialize : function() { @@ -51,6 +51,10 @@ CMS.Views.Settings.Details = CMS.Views.ValidatingView.extend({ 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; }, fieldToSelectorMap : { @@ -60,7 +64,8 @@ CMS.Views.Settings.Details = CMS.Views.ValidatingView.extend({ 'enrollment_end' : 'enrollment-end', 'overview' : 'course-overview', 'intro_video' : 'course-introduction-video', - 'effort' : "course-effort" + 'effort' : "course-effort", + 'course_image_asset_path': 'course-image-url' }, updateTime : function(e) { @@ -121,6 +126,17 @@ CMS.Views.Settings.Details = CMS.Views.ValidatingView.extend({ updateModel: function(event) { 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': this.setField(event); break; @@ -216,6 +232,30 @@ CMS.Views.Settings.Details = CMS.Views.ValidatingView.extend({ this.save_message, _.bind(this.saveView, 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); } }); diff --git a/cms/static/sass/views/_settings.scss b/cms/static/sass/views/_settings.scss index 1430c41368..ca48244f64 100644 --- a/cms/static/sass/views/_settings.scss +++ b/cms/static/sass/views/_settings.scss @@ -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 &.requirements { diff --git a/cms/templates/settings.html b/cms/templates/settings.html index 1f5d89b2b9..96a8e59d9d 100644 --- a/cms/templates/settings.html +++ b/cms/templates/settings.html @@ -2,7 +2,7 @@ <%inherit file="base.html" /> <%block name="title">${_("Schedule & Details Settings")} -<%block name="bodyclass">is-signedin course schedule settings +<%block name="bodyclass">is-signedin course schedule settings file-upload-dialog <%namespace name='static' file='static_content.html'/> <%! @@ -22,6 +22,10 @@ from contentstore import utils + + @@ -208,6 +214,21 @@ from contentstore import utils ${overview_text()} +
  • + +
    + % if context_course.course_image: + ${_('Course Image')} + % endif + +
    + +
    + + ${_("Enter your course image's filename.")} +
    +
  • +
  • diff --git a/common/lib/xmodule/xmodule/course_module.py b/common/lib/xmodule/xmodule/course_module.py index 57b13c10b3..4555395fef 100644 --- a/common/lib/xmodule/xmodule/course_module.py +++ b/common/lib/xmodule/xmodule/course_module.py @@ -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) enrollment_domain = String(help="External login method associated with user accounts allowed to register in course", 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 # there are courses that change the number for different runs. This allows diff --git a/common/test/data/uploads/image.jpg b/common/test/data/uploads/image.jpg new file mode 100644 index 0000000000000000000000000000000000000000..21ece4ef43ef2528c474f52167068ecf37978554 GIT binary patch literal 13811 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 Date: Mon, 19 Aug 2013 11:27:17 -0400 Subject: [PATCH 3/8] Address PR comments: - Internationalize upload errors. - Move upload tests into their own files. - Refactor upload dialog acceptance tests. --- .../contentstore/features/common.py | 13 ++ .../features/course-settings.feature | 1 + .../contentstore/features/course-settings.py | 18 ++- .../contentstore/features/textbooks.py | 12 +- .../coffee/spec/models/textbook_spec.coffee | 29 ----- .../coffee/spec/models/upload_spec.coffee | 33 +++++ .../coffee/spec/views/textbook_spec.coffee | 118 ------------------ .../coffee/spec/views/upload_spec.coffee | 118 ++++++++++++++++++ cms/static/js/models/uploads.js | 7 +- cms/templates/js/upload-dialog.underscore | 2 +- cms/templates/settings.html | 2 +- 11 files changed, 184 insertions(+), 169 deletions(-) create mode 100644 cms/static/coffee/spec/models/upload_spec.coffee create mode 100644 cms/static/coffee/spec/views/upload_spec.coffee diff --git a/cms/djangoapps/contentstore/features/common.py b/cms/djangoapps/contentstore/features/common.py index 516659fadb..1cc71097a0 100644 --- a/cms/djangoapps/contentstore/features/common.py +++ b/cms/djangoapps/contentstore/features/common.py @@ -5,9 +5,11 @@ from lettuce import world, step from nose.tools import assert_true 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 import time +import os from django.contrib.auth.models import Group from logging import getLogger @@ -15,6 +17,8 @@ logger = getLogger(__name__) from terrain.browser import reset_data +TEST_ROOT = settings.COMMON_TEST_DATA_ROOT + ########### STEP HELPERS ############## @@ -257,3 +261,12 @@ def type_in_codemirror(index, text): g._element.send_keys(text) if world.is_firefox(): 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) diff --git a/cms/djangoapps/contentstore/features/course-settings.feature b/cms/djangoapps/contentstore/features/course-settings.feature index 69183bc3da..8f00452efe 100644 --- a/cms/djangoapps/contentstore/features/course-settings.feature +++ b/cms/djangoapps/contentstore/features/course-settings.feature @@ -76,6 +76,7 @@ Feature: Course Settings 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 diff --git a/cms/djangoapps/contentstore/features/course-settings.py b/cms/djangoapps/contentstore/features/course-settings.py index 0847c62a18..570c49a8c4 100644 --- a/cms/djangoapps/contentstore/features/course-settings.py +++ b/cms/djangoapps/contentstore/features/course-settings.py @@ -4,9 +4,8 @@ from lettuce import world, step from terrain.steps import reload_the_page 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 -import os from nose.tools import assert_true, assert_false, assert_equal @@ -150,16 +149,15 @@ def test_change_course_overview(_step): type_in_codemirror(0, "

    Overview

    ") +@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_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) + upload_file('image.jpg') @step('I should see the new course image$') diff --git a/cms/djangoapps/contentstore/features/textbooks.py b/cms/djangoapps/contentstore/features/textbooks.py index d9c08ec6eb..b432b84d4f 100644 --- a/cms/djangoapps/contentstore/features/textbooks.py +++ b/cms/djangoapps/contentstore/features/textbooks.py @@ -3,7 +3,7 @@ from lettuce import world, step from django.conf import settings -import os +from common import upload_file TEST_ROOT = settings.COMMON_TEST_DATA_ROOT @@ -24,14 +24,8 @@ def assert_create_new_textbook_msg(_step): @step(u'I upload the textbook "([^"]*)"$') -def upload_file(_step, file_name): - file_css = '.upload-dialog input[type=file]' - 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) +def upload_textbook(_step, file_name): + upload_file(file_name) @step(u'I click (on )?the New Textbook button') diff --git a/cms/static/coffee/spec/models/textbook_spec.coffee b/cms/static/coffee/spec/models/textbook_spec.coffee index 6e601ecf68..d88e09f57a 100644 --- a/cms/static/coffee/spec/models/textbook_spec.coffee +++ b/cms/static/coffee/spec/models/textbook_spec.coffee @@ -196,32 +196,3 @@ describe "CMS.Collections.ChapterSet", -> # try going back one @collection.remove(@collection.last()) 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() diff --git a/cms/static/coffee/spec/models/upload_spec.coffee b/cms/static/coffee/spec/models/upload_spec.coffee new file mode 100644 index 0000000000..e4be3b9a80 --- /dev/null +++ b/cms/static/coffee/spec/models/upload_spec.coffee @@ -0,0 +1,33 @@ +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 by default", -> + file = {"type": "application/pdf"} + @model.set("selectedFile", file); + 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 non-PDF files when explicitly set", -> + file = {"type": "image/png"} + @model.set("mimeType": "image/png") + @model.set("selectedFile", file) + expect(@model.isValid()).toBeTruthy() diff --git a/cms/static/coffee/spec/views/textbook_spec.coffee b/cms/static/coffee/spec/views/textbook_spec.coffee index 5185c9fb47..ade8c4cb6e 100644 --- a/cms/static/coffee/spec/views/textbook_spec.coffee +++ b/cms/static/coffee/spec/views/textbook_spec.coffee @@ -311,121 +311,3 @@ describe "CMS.Views.EditChapter", -> @view.$(".action-upload").click() expect(@model.get("name")).toEqual("rainbows") expect(@model.get("asset_path")).toEqual("unicorns") - - -describe "CMS.Views.UploadDialog", -> - tpl = readFixtures("upload-dialog.underscore") - - beforeEach -> - setFixtures($(" From c626c773d885bcc15f863e9c7277801c7eef96b0 Mon Sep 17 00:00:00 2001 From: Brian Talbot Date: Mon, 19 Aug 2013 14:17:44 -0400 Subject: [PATCH 4/8] Studio: revises styling/copy around course image management in settings --- cms/static/sass/views/_settings.scss | 61 +++++++++++++++++++++++----- cms/templates/settings.html | 23 ++++++++--- 2 files changed, 69 insertions(+), 15 deletions(-) diff --git a/cms/static/sass/views/_settings.scss b/cms/static/sass/views/_settings.scss index ca48244f64..bddb630bbb 100644 --- a/cms/static/sass/views/_settings.scss +++ b/cms/static/sass/views/_settings.scss @@ -131,7 +131,7 @@ body.course.settings { list-style: none; .field { - margin: 0 0 $baseline 0; + margin: 0 0 ($baseline*2) 0; &:last-child { margin-bottom: 0; @@ -434,16 +434,57 @@ 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; - } + .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 @@ -459,7 +500,7 @@ body.course.settings { margin-bottom: ($baseline*3); .grade-controls { - @include clearfix; + @include clearfix(); width: flex-grid(9,9); } diff --git a/cms/templates/settings.html b/cms/templates/settings.html index ae1051d357..30ae544d18 100644 --- a/cms/templates/settings.html +++ b/cms/templates/settings.html @@ -218,14 +218,27 @@ from contentstore import utils
    % if context_course.course_image: - ${_('Course Image')} + + ${_('Course Image')} + + + <% ctx_loc = context_course.location %> + ${_("You can manage this image along with all of your other")} ${_("files & uploads")} + + % else: + + ${_('Course Image')} + + ${_("Your course currently does not have an image. Please upload one (.jpg format and mimimum suggested dimensions are 375px wide by 200px tall)")} % endif -
    -
    - - ${_("Enter your course image's filename.")} +
    +
    + + ${_("please provide a valid path and name to your course image (Note: only .jpg format supported)")} +
    +
  • From f9aecb2778957573ed074824c3ff51deafb39108 Mon Sep 17 00:00:00 2001 From: Peter Fogg Date: Tue, 20 Aug 2013 12:23:45 -0400 Subject: [PATCH 5/8] Add support for PNGs as course images. Also change the file uploader to accept multiple file types. --- .../coffee/spec/models/upload_spec.coffee | 2 +- cms/static/js/models/uploads.js | 51 +++++++++++++++---- .../js/views/settings/main_settings_view.js | 3 +- 3 files changed, 43 insertions(+), 13 deletions(-) diff --git a/cms/static/coffee/spec/models/upload_spec.coffee b/cms/static/coffee/spec/models/upload_spec.coffee index e4be3b9a80..2b77c2147d 100644 --- a/cms/static/coffee/spec/models/upload_spec.coffee +++ b/cms/static/coffee/spec/models/upload_spec.coffee @@ -28,6 +28,6 @@ describe "CMS.Models.FileUpload", -> it "can accept non-PDF files when explicitly set", -> file = {"type": "image/png"} - @model.set("mimeType": "image/png") + @model.set("mimeTypes": ["image/png"]) @model.set("selectedFile", file) expect(@model.isValid()).toBeTruthy() diff --git a/cms/static/js/models/uploads.js b/cms/static/js/models/uploads.js index 131c554afd..aca115cccf 100644 --- a/cms/static/js/models/uploads.js +++ b/cms/static/js/models/uploads.js @@ -7,22 +7,53 @@ CMS.Models.FileUpload = Backbone.Model.extend({ "uploadedBytes": 0, "totalBytes": 0, "finished": false, - "mimeType": "application/pdf", - "fileType": "PDF" + "mimeTypes": ["application/pdf"] }, - // 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 !== this.attributes.mimeType) { + if(attrs.selectedFile && !_.contains(this.attributes.mimeTypes, attrs.selectedFile.type)) { return { message: _.template( - gettext("Only {fileType} files can be uploaded. Please select a file ending in .{fileExtension} to upload."), - { - fileType: this.attributes.fileType, - fileExtension: this.attributes.fileType.toLowerCase() - }), + 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(); + }) + ) + }; } }); diff --git a/cms/static/js/views/settings/main_settings_view.js b/cms/static/js/views/settings/main_settings_view.js index 36bee79d80..7304f8e7c0 100644 --- a/cms/static/js/views/settings/main_settings_view.js +++ b/cms/static/js/views/settings/main_settings_view.js @@ -239,8 +239,7 @@ CMS.Views.Settings.Details = CMS.Views.ValidatingView.extend({ 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" + mimeTypes: ['image/jpeg', 'image/png'] }); var self = this; var modal = new CMS.Views.UploadDialog({ From a601ede7811226681faf61f8b2e1b199bbd54287 Mon Sep 17 00:00:00 2001 From: Peter Fogg Date: Tue, 20 Aug 2013 13:01:23 -0400 Subject: [PATCH 6/8] Update CHANGELOG. --- CHANGELOG.rst | 3 +++ 1 file changed, 3 insertions(+) diff --git a/CHANGELOG.rst b/CHANGELOG.rst index cd43777e96..749b9ef56e 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -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 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 Common: Allow instructors to input complicated expressions as answers to From b539a4cb94ee40c8e281d5caf792827f97e0f40b Mon Sep 17 00:00:00 2001 From: Peter Fogg Date: Tue, 20 Aug 2013 15:38:01 -0400 Subject: [PATCH 7/8] Update copy to include PNGs, add tests, remove default MIME type. --- .../coffee/spec/models/upload_spec.coffee | 35 +++++++++++++++---- .../coffee/spec/views/upload_spec.coffee | 6 ++-- cms/static/js/models/uploads.js | 4 +-- .../js/views/settings/main_settings_view.js | 2 +- cms/static/js/views/textbook.js | 3 +- cms/templates/settings.html | 4 +-- 6 files changed, 40 insertions(+), 14 deletions(-) diff --git a/cms/static/coffee/spec/models/upload_spec.coffee b/cms/static/coffee/spec/models/upload_spec.coffee index 2b77c2147d..610898745b 100644 --- a/cms/static/coffee/spec/models/upload_spec.coffee +++ b/cms/static/coffee/spec/models/upload_spec.coffee @@ -11,11 +11,6 @@ describe "CMS.Models.FileUpload", -> it "is valid by default", -> expect(@model.isValid()).toBeTruthy() - it "is valid for PDF files by default", -> - file = {"type": "application/pdf"} - @model.set("selectedFile", file); - expect(@model.isValid()).toBeTruthy() - it "is invalid for text files by default", -> file = {"type": "text/plain"} @model.set("selectedFile", file); @@ -26,8 +21,36 @@ describe "CMS.Models.FileUpload", -> @model.set("selectedFile", file); expect(@model.isValid()).toBeFalsy() - it "can accept non-PDF files when explicitly set", -> + 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' + ) diff --git a/cms/static/coffee/spec/views/upload_spec.coffee b/cms/static/coffee/spec/views/upload_spec.coffee index 39f1519e05..ebf850be75 100644 --- a/cms/static/coffee/spec/views/upload_spec.coffee +++ b/cms/static/coffee/spec/views/upload_spec.coffee @@ -8,10 +8,12 @@ describe "CMS.Views.UploadDialog", -> appendSetFixtures($("