diff --git a/cms/djangoapps/contentstore/tests/test_contentstore.py b/cms/djangoapps/contentstore/tests/test_contentstore.py index 54bc6e785f..b13c341070 100644 --- a/cms/djangoapps/contentstore/tests/test_contentstore.py +++ b/cms/djangoapps/contentstore/tests/test_contentstore.py @@ -5,6 +5,7 @@ import mock from mock import patch import shutil import lxml.html +from lxml import etree import ddt from datetime import timedelta @@ -1836,6 +1837,44 @@ class RerunCourseTest(ContentStoreTestCase): self.assertEqual(len(rerun_state.message), CourseRerunState.MAX_MESSAGE_LENGTH) +class ContentLicenseTest(ContentStoreTestCase): + """ + Tests around content licenses + """ + def test_course_license_export(self): + content_store = contentstore() + root_dir = path(mkdtemp_clean()) + self.course.license = "creative-commons: BY SA" + self.store.update_item(self.course, None) + export_course_to_xml(self.store, content_store, self.course.id, root_dir, 'test_license') + fname = "{block}.xml".format(block=self.course.scope_ids.usage_id.block_id) + run_file_path = root_dir / "test_license" / "course" / fname + run_xml = etree.parse(run_file_path.open()) + self.assertEqual(run_xml.getroot().get("license"), "creative-commons: BY SA") + + def test_video_license_export(self): + content_store = contentstore() + root_dir = path(mkdtemp_clean()) + video_descriptor = ItemFactory.create( + parent_location=self.course.location, category='video', + license="all-rights-reserved" + ) + export_course_to_xml(self.store, content_store, self.course.id, root_dir, 'test_license') + fname = "{block}.xml".format(block=video_descriptor.scope_ids.usage_id.block_id) + video_file_path = root_dir / "test_license" / "video" / fname + video_xml = etree.parse(video_file_path.open()) + self.assertEqual(video_xml.getroot().get("license"), "all-rights-reserved") + + def test_license_import(self): + course_items = import_course_from_xml( + self.store, self.user.id, TEST_DATA_DIR, ['toy'], create_if_not_present=True + ) + course = course_items[0] + self.assertEqual(course.license, "creative-commons: BY") + videos = self.store.get_items(course.id, qualifiers={'category': 'video'}) + self.assertEqual(videos[0].license, "all-rights-reserved") + + class EntryPageTestCase(TestCase): """ Tests entry pages that aren't specific to a course. diff --git a/cms/djangoapps/contentstore/views/component.py b/cms/djangoapps/contentstore/views/component.py index 35dd95384b..a3fadaad2a 100644 --- a/cms/djangoapps/contentstore/views/component.py +++ b/cms/djangoapps/contentstore/views/component.py @@ -63,7 +63,7 @@ CONTAINER_TEMPATES = [ "editor-mode-button", "upload-dialog", "image-modal", "add-xblock-component", "add-xblock-component-button", "add-xblock-component-menu", "add-xblock-component-menu-problem", "xblock-string-field-editor", "publish-xblock", "publish-history", - "unit-outline", "container-message" + "unit-outline", "container-message", "license-selector", ] diff --git a/cms/djangoapps/models/settings/course_details.py b/cms/djangoapps/models/settings/course_details.py index ab96448dad..5e0b93fb84 100644 --- a/cms/djangoapps/models/settings/course_details.py +++ b/cms/djangoapps/models/settings/course_details.py @@ -42,6 +42,7 @@ 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.license = None self.course_image_name = "" self.course_image_asset_path = "" # URL of the course image self.pre_requisite_courses = [] # pre-requisite courses @@ -79,6 +80,7 @@ class CourseDetails(object): course_details.pre_requisite_courses = descriptor.pre_requisite_courses course_details.course_image_name = descriptor.course_image course_details.course_image_asset_path = course_image_url(descriptor) + course_details.license = getattr(descriptor, "license", None) for attribute in ABOUT_ATTRIBUTES: value = cls._fetch_about_attribute(course_key, attribute) @@ -173,6 +175,10 @@ class CourseDetails(object): descriptor.pre_requisite_courses = jsondict['pre_requisite_courses'] dirty = True + if 'license' in jsondict: + descriptor.license = jsondict['license'] + dirty = True + if dirty: module_store.update_item(descriptor, user.id) diff --git a/cms/envs/bok_choy.py b/cms/envs/bok_choy.py index a01b334dbb..df8d0b73b1 100644 --- a/cms/envs/bok_choy.py +++ b/cms/envs/bok_choy.py @@ -71,6 +71,9 @@ FEATURES['ENABLE_EDXNOTES'] = True # Enable teams feature FEATURES['ENABLE_TEAMS'] = True +# Enable custom content licensing +FEATURES['LICENSING'] = True + ########################### Entrance Exams ################################# FEATURES['ENTRANCE_EXAMS'] = True diff --git a/cms/envs/common.py b/cms/envs/common.py index 5264605db3..e826d7f460 100644 --- a/cms/envs/common.py +++ b/cms/envs/common.py @@ -50,6 +50,7 @@ from lms.djangoapps.lms_xblock.mixin import LmsBlockMixin from cms.lib.xblock.authoring_mixin import AuthoringMixin import dealer.git from xmodule.modulestore.edit_info import EditInfoMixin +from xmodule.mixin import LicenseMixin ############################ FEATURE CONFIGURATION ############################# STUDIO_NAME = "Studio" @@ -312,6 +313,7 @@ from xmodule.modulestore.inheritance import InheritanceMixin from xmodule.modulestore import prefer_xmodules from xmodule.x_module import XModuleMixin +# These are the Mixins that should be added to every XBlock. # This should be moved into an XBlock Runtime/Application object # once the responsibility of XBlock creation is moved out of modulestore - cpennington XBLOCK_MIXINS = ( @@ -465,6 +467,7 @@ PIPELINE_CSS = { 'style-main': { 'source_filenames': [ 'sass/studio-main.css', + 'css/edx-cc.css', ], 'output_filename': 'css/studio-main.css', }, diff --git a/cms/static/coffee/spec/main.coffee b/cms/static/coffee/spec/main.coffee index edc8b3ac65..e2d10555d4 100644 --- a/cms/static/coffee/spec/main.coffee +++ b/cms/static/coffee/spec/main.coffee @@ -227,6 +227,7 @@ define([ "js/spec/models/explicit_url_spec", "js/spec/models/xblock_info_spec", "js/spec/models/xblock_validation_spec", + "js/spec/models/license_spec", "js/spec/utils/drag_and_drop_spec", "js/spec/utils/handle_iframe_binding_spec", @@ -247,6 +248,7 @@ define([ "js/spec/views/xblock_editor_spec", "js/spec/views/xblock_string_field_editor_spec", "js/spec/views/xblock_validation_spec", + "js/spec/views/license_spec", "js/spec/views/utils/view_utils_spec", diff --git a/cms/static/js/models/license.js b/cms/static/js/models/license.js new file mode 100644 index 0000000000..3c237beb79 --- /dev/null +++ b/cms/static/js/models/license.js @@ -0,0 +1,100 @@ +define(["backbone", "underscore"], function(Backbone, _) { + var LicenseModel = Backbone.Model.extend({ + defaults: { + "type": null, + "options": {}, + "custom": false // either `false`, or a string + }, + + initialize: function(attributes) { + if(attributes && attributes.asString) { + this.setFromString(attributes.asString); + this.unset("asString"); + } + }, + + toString: function() { + var custom = this.get("custom"); + if (custom) { + return custom; + } + + var type = this.get("type"), + options = this.get("options"); + + if (_.isEmpty(options)) { + return type || ""; + } + + // options are where it gets tricky + var optionStrings = _.map(options, function (value, key) { + if(_.isBoolean(value)) { + return value ? key : null + } else { + return key + "=" + value + } + }); + // filter out nulls + optionStrings = _.filter(optionStrings, _.identity); + // build license string and return + return type + ": " + optionStrings.join(" "); + }, + + setFromString: function(string, options) { + if (!string) { + // reset to defaults + return this.set(this.defaults, options); + } + + var colonIndex = string.indexOf(":"), + spaceIndex = string.indexOf(" "); + + // a string without a colon could be a custom license, or a license + // type without options + if (colonIndex == -1) { + if (spaceIndex == -1) { + // if there's no space, it's a license type without options + return this.set({ + "type": string, + "options": {}, + "custom": false + }, options); + } else { + // if there is a space, it's a custom license + return this.set({ + "type": null, + "options": {}, + "custom": string + }, options); + } + } + + // there is a colon, which indicates a license type with options. + var type = string.substring(0, colonIndex), + optionsObj = {}, + optionsString = string.substring(colonIndex + 1); + + _.each(optionsString.split(" "), function(optionString) { + if (_.isEmpty(optionString)) { + return; + } + var eqIndex = optionString.indexOf("="); + if(eqIndex == -1) { + // this is a boolean flag + optionsObj[optionString] = true; + } else { + // this is a key-value pair + var optionKey = optionString.substring(0, eqIndex); + var optionVal = optionString.substring(eqIndex + 1); + optionsObj[optionKey] = optionVal; + } + }); + + return this.set({ + "type": type, "options": optionsObj, "custom": false, + }, options); + } + }); + + return LicenseModel; +}); diff --git a/cms/static/js/models/settings/course_details.js b/cms/static/js/models/settings/course_details.js index c3bff857fd..6f8487697e 100644 --- a/cms/static/js/models/settings/course_details.js +++ b/cms/static/js/models/settings/course_details.js @@ -15,6 +15,7 @@ var CourseDetails = Backbone.Model.extend({ overview: "", intro_video: null, effort: null, // an int or null, + license: null, course_image_name: '', // the filename course_image_asset_path: '', // the full URL (/c4x/org/course/num/asset/filename) pre_requisite_courses: [], diff --git a/cms/static/js/spec/models/license_spec.js b/cms/static/js/spec/models/license_spec.js new file mode 100644 index 0000000000..6e84a2f0b6 --- /dev/null +++ b/cms/static/js/spec/models/license_spec.js @@ -0,0 +1,72 @@ +define(["js/models/license"], function(LicenseModel) { + describe("License model constructor", function() { + it("accepts no arguments", function() { + var model = new LicenseModel() + expect(model.get("type")).toBeNull(); + expect(model.get("options")).toEqual({}); + expect(model.get("custom")).toBeFalsy(); + }); + + it("accepts normal arguments", function() { + var model = new LicenseModel({ + "type": "creative-commons", + "options": {"fake-boolean": true, "version": "your momma"} + }); + expect(model.get("type")).toEqual("creative-commons"); + expect(model.get("options")).toEqual({"fake-boolean": true, "version": "your momma"}); + }) + + it("accepts a license string argument", function() { + var model = new LicenseModel({"asString": "all-rights-reserved"}); + expect(model.get("type")).toEqual("all-rights-reserved"); + expect(model.get("options")).toEqual({}); + expect(model.get("custom")).toBeFalsy(); + }); + + it("accepts a custom license argument", function() { + var model = new LicenseModel({"asString": "Mozilla Public License 2.0"}) + expect(model.get("type")).toBeNull(); + expect(model.get("options")).toEqual({}); + expect(model.get("custom")).toEqual("Mozilla Public License 2.0"); + }); + }); + + describe("License model", function() { + beforeEach(function() { + this.model = new LicenseModel(); + }); + + it("can parse license strings", function() { + this.model.setFromString("creative-commons: BY") + expect(this.model.get("type")).toEqual("creative-commons") + expect(this.model.get("options")).toEqual({"BY": true}) + expect(this.model.get("custom")).toBeFalsy(); + }); + + it("can stringify a null license", function() { + expect(this.model.toString()).toEqual(""); + }); + + it("can stringify a simple license", function() { + this.model.set("type", "foobie thinger"); + expect(this.model.toString()).toEqual("foobie thinger"); + }); + + it("can stringify a license with options", function() { + this.model.set({ + "type": "abc", + "options": {"ping": "pong", "bing": true, "buzz": true, "beep": false}} + ); + expect(this.model.toString()).toEqual("abc: ping=pong bing buzz"); + }); + + it("can stringify a custom license", function() { + this.model.set({ + "type": "doesn't matter", + "options": {"ignore": "me"}, + "custom": "this is my super cool license" + }); + expect(this.model.toString()).toEqual("this is my super cool license"); + }); + }) +}) diff --git a/cms/static/js/spec/views/license_spec.js b/cms/static/js/spec/views/license_spec.js new file mode 100644 index 0000000000..6476f45ed1 --- /dev/null +++ b/cms/static/js/spec/views/license_spec.js @@ -0,0 +1,157 @@ +define(["js/views/license", "js/models/license", "js/common_helpers/template_helpers"], + function(LicenseView, LicenseModel, TemplateHelpers) { + describe("License view", function() { + + beforeEach(function() { + TemplateHelpers.installTemplate("license-selector", true); + this.model = new LicenseModel(); + this.view = new LicenseView({model: this.model}); + }); + + it("renders with no license", function() { + this.view.render(); + expect(this.view.$("li[data-license=all-rights-reserved] button")) + .toHaveText("All Rights Reserved"); + expect(this.view.$("li[data-license=all-rights-reserved] button")) + .not.toHaveClass("is-selected"); + expect(this.view.$("li[data-license=creative-commons] button")) + .toHaveText("Creative Commons"); + expect(this.view.$("li[data-license=creative-commons] button")) + .not.toHaveClass("is-selected"); + }); + + it("renders with the right license selected", function() { + this.model.set("type", "all-rights-reserved"); + expect(this.view.$("li[data-license=all-rights-reserved] button")) + .toHaveClass("is-selected"); + expect(this.view.$("li[data-license=creative-commons] button")) + .not.toHaveClass("is-selected"); + }); + + it("switches license type on click", function() { + var arrBtn = this.view.$("li[data-license=all-rights-reserved] button"); + expect(this.model.get("type")).toBeNull(); + arrBtn.click(); + expect(this.model.get("type")).toEqual("all-rights-reserved"); + // view has re-rendered, so get a new reference to the button + arrBtn = this.view.$("li[data-license=all-rights-reserved] button"); + expect(arrBtn).toHaveClass("is-selected"); + // now switch to creative commons + var ccBtn = this.view.$("li[data-license=creative-commons] button"); + ccBtn.click(); + expect(this.model.get("type")).toEqual("creative-commons"); + // update references again + arrBtn = this.view.$("li[data-license=all-rights-reserved] button"); + ccBtn = this.view.$("li[data-license=creative-commons] button"); + expect(arrBtn).not.toHaveClass("is-selected"); + expect(ccBtn).toHaveClass("is-selected"); + }); + + it("sets default license options when switching license types", function() { + expect(this.model.get("options")).toEqual({}); + var ccBtn = this.view.$("li[data-license=creative-commons] button"); + ccBtn.click() + expect(this.model.get("options")).toEqual( + {"ver": "4.0", "BY": true, "NC": true, "ND": true, "SA": false} + ); + var arrBtn = this.view.$("li[data-license=all-rights-reserved] button"); + arrBtn.click() + expect(this.model.get("options")).toEqual({}); + }); + + it("renders license options", function() { + this.model.set({"type": "creative-commons"}) + expect(this.view.$("ul.license-options li[data-option=BY]")) + .toContainText("Attribution"); + expect(this.view.$("ul.license-options li[data-option=NC]")) + .toContainText("Noncommercial"); + expect(this.view.$("ul.license-options li[data-option=ND]")) + .toContainText("No Derivatives"); + expect(this.view.$("ul.license-options li[data-option=SA]")) + .toContainText("Share Alike"); + expect(this.view.$("ul.license-options li").length).toEqual(4); + }); + + it("toggles boolean options on click", function() { + this.view.$("li[data-license=creative-commons] button").click(); + expect(this.model.get("options")).toEqual( + {"ver": "4.0", "BY": true, "NC": true, "ND": true, "SA": false} + ); + // toggle NC option + this.view.$("li[data-option=NC]").click(); + expect(this.model.get("options")).toEqual( + {"ver": "4.0", "BY": true, "NC": false, "ND": true, "SA": false} + ); + }); + + it("doesn't toggle disabled options", function() { + this.view.$("li[data-license=creative-commons] button").click(); + expect(this.model.get("options")).toEqual( + {"ver": "4.0", "BY": true, "NC": true, "ND": true, "SA": false} + ); + var BY = this.view.$("li[data-option=BY]"); + expect(BY).toHaveClass("is-disabled"); + // try to toggle BY option + BY.click() + // no change + expect(this.model.get("options")).toEqual( + {"ver": "4.0", "BY": true, "NC": true, "ND": true, "SA": false} + ); + }); + + it("doesn't allow simultaneous conflicting options", function() { + this.view.$("li[data-license=creative-commons] button").click(); + expect(this.model.get("options")).toEqual( + {"ver": "4.0", "BY": true, "NC": true, "ND": true, "SA": false} + ); + // SA and ND conflict + var SA = this.view.$("li[data-option=SA]"); + expect(SA).toHaveClass("is-disabled"); + // try to turn on SA option, fail + SA.click() + // no change + expect(this.model.get("options")).toEqual( + {"ver": "4.0", "BY": true, "NC": true, "ND": true, "SA": false} + ); + // turn off ND + var ND = this.view.$("li[data-option=ND]"); + expect(ND).not.toHaveClass("is-disabled"); + ND.click() + expect(this.model.get("options")).toEqual( + {"ver": "4.0", "BY": true, "NC": true, "ND": false, "SA": false} + ); + // turn on SA + SA = this.view.$("li[data-option=SA]"); + expect(SA).not.toHaveClass("is-disabled"); + SA.click() + expect(this.model.get("options")).toEqual( + {"ver": "4.0", "BY": true, "NC": true, "ND": false, "SA": true} + ); + // try to turn on ND option, fail + ND = this.view.$("li[data-option=ND]"); + expect(ND).toHaveClass("is-disabled"); + ND.click(); + expect(this.model.get("options")).toEqual( + {"ver": "4.0", "BY": true, "NC": true, "ND": false, "SA": true} + ); + }); + + it("has no preview by default", function () { + this.view.render(); + expect(this.view.$("#license-preview").length).toEqual(0) + this.view.$("li[data-license=creative-commons] button").click(); + expect(this.view.$("#license-preview").length).toEqual(0) + }); + + it("displays a preview if showPreview is true", function() { + this.view = new LicenseView({model: this.model, showPreview: true}); + this.view.render() + expect(this.view.$("#license-preview").length).toEqual(1) + expect(this.view.$("#license-preview")).toHaveText(""); + this.view.$("li[data-license=creative-commons] button").click(); + expect(this.view.$("#license-preview").length).toEqual(1) + expect(this.view.$("#license-preview")).toContainText("Some Rights Reserved"); + }); + + }) +}) diff --git a/cms/static/js/spec/views/settings/main_spec.js b/cms/static/js/spec/views/settings/main_spec.js index e04be25c2a..f489732eab 100644 --- a/cms/static/js/spec/views/settings/main_spec.js +++ b/cms/static/js/spec/views/settings/main_spec.js @@ -29,7 +29,8 @@ define([ course_image_asset_path : '', pre_requisite_courses : [], entrance_exam_enabled : '', - entrance_exam_minimum_score_pct: '50' + entrance_exam_minimum_score_pct: '50', + license: null }, mockSettingsPage = readFixtures('mock/mock-settings-page.underscore'); diff --git a/cms/static/js/views/license.js b/cms/static/js/views/license.js new file mode 100644 index 0000000000..8b7a0dbfd9 --- /dev/null +++ b/cms/static/js/views/license.js @@ -0,0 +1,139 @@ +define(["js/views/baseview", "underscore"], function(BaseView, _) { + var defaultLicenseInfo = { + "all-rights-reserved": { + "name": gettext("All Rights Reserved"), + "tooltip": gettext("You reserve all rights for your work") + }, + "creative-commons": { + "name": gettext("Creative Commons"), + "tooltip": gettext("You waive some rights for your work, such that others can use it too"), + "url": "//creativecommons.org/about", + "options": { + "ver": { + "name": gettext("Version"), + "type": "string", + "default": "4.0", + }, + "BY": { + "name": gettext("Attribution"), + "type": "boolean", + "default": true, + "help": gettext("Allow others to copy, distribute, display and perform " + + "your copyrighted work but only if they give credit the way you request."), + "disabled": true, + }, + "NC": { + "name": gettext("Noncommercial"), + "type": "boolean", + "default": true, + "help": gettext("Allow others to copy, distribute, display and perform " + + "your work - and derivative works based upon it - but for noncommercial purposes only."), + }, + "ND": { + "name": gettext("No Derivatives"), + "type": "boolean", + "default": true, + "help": gettext("Allow others to copy, distribute, display and perform " + + "only verbatim copies of your work, not derivative works based upon it."), + "conflictsWith": ["SA"] + }, + "SA": { + "name": gettext("Share Alike"), + "type": "boolean", + "default": false, + "help": gettext("Allow others to distribute derivative works only under " + + "a license identical to the license that governs your work."), + "conflictsWith": ["ND"] + } + }, + "option_order": ["BY", "NC", "ND", "SA"] + } + } + + var LicenseView = BaseView.extend({ + events: { + "click ul.license-types li button" : "onLicenseClick", + "click ul.license-options li": "onOptionClick" + }, + + initialize: function(options) { + this.licenseInfo = options.licenseInfo || defaultLicenseInfo; + this.showPreview = !!options.showPreview; // coerce to boolean + this.template = this.loadTemplate("license-selector"); + + // Rerender when the model changes + this.listenTo(this.model, 'change', this.render); + this.render(); + }, + + getDefaultOptionsForLicenseType: function(licenseType) { + if (!this.licenseInfo[licenseType]) { + // custom license type, no options + return {}; + } + if (!this.licenseInfo[licenseType].options) { + // defined license type without options + return {}; + } + var defaults = {}; + _.each(this.licenseInfo[licenseType].options, function(value, key) { + defaults[key] = value.default; + }) + return defaults; + }, + + render: function() { + this.$el.html(this.template({ + model: this.model.attributes, + licenseString: this.model.toString() || "", + licenseInfo: this.licenseInfo, + showPreview: this.showPreview, + previewButton: false, + })); + return this; + }, + + onLicenseClick: function(e) { + var $li = $(e.srcElement || e.target).closest('li'); + var licenseType = $li.data("license"); + this.model.set({ + "type": licenseType, + "options": this.getDefaultOptionsForLicenseType(licenseType) + }); + }, + + onOptionClick: function(e) { + var licenseType = this.model.get("type"), + licenseOptions = $.extend({}, this.model.get("options")), + $li = $(e.srcElement || e.target).closest('li'); + + var optionKey = $li.data("option") + var licenseInfo = this.licenseInfo[licenseType]; + var optionInfo = licenseInfo.options[optionKey]; + if (optionInfo.disabled) { + // we're done here + return; + } + var currentOptionValue = licenseOptions[optionKey]; + if (optionInfo.type === "boolean") { + // toggle current value + currentOptionValue = !currentOptionValue; + licenseOptions[optionKey] = currentOptionValue; + } + // check for conflicts + if (currentOptionValue && optionInfo.conflictsWith && + _.any(optionInfo.conflictsWith, function (key) { return licenseOptions[key];})) { + // conflict! don't set new options + // need some feedback here + return; + } else { + this.model.set({"options": licenseOptions}) + // Backbone has trouble identifying when objects change, so we'll + // fire the change event manually. + this.model.trigger("change change:options") + } + } + + }); + return LicenseView; +}); diff --git a/cms/static/js/views/metadata.js b/cms/static/js/views/metadata.js index 3159ada436..ec7c6b75f7 100644 --- a/cms/static/js/views/metadata.js +++ b/cms/static/js/views/metadata.js @@ -2,10 +2,11 @@ define( [ "js/views/baseview", "underscore", "js/models/metadata", "js/views/abstract_editor", "js/models/uploads", "js/views/uploads", + "js/models/license", "js/views/license", "js/views/video/transcripts/metadata_videolist", "js/views/video/translations_editor" ], -function(BaseView, _, MetadataModel, AbstractEditor, FileUpload, UploadDialog, VideoList, VideoTranslations) { +function(BaseView, _, MetadataModel, AbstractEditor, FileUpload, UploadDialog, LicenseModel, LicenseView, VideoList, VideoTranslations) { var Metadata = {}; Metadata.Editor = BaseView.extend({ @@ -550,5 +551,41 @@ function(BaseView, _, MetadataModel, AbstractEditor, FileUpload, UploadDialog, V } }); + Metadata.License = AbstractEditor.extend({ + template: _.template( + '' + ), + + initialize: function(options) { + this.licenseModel = new LicenseModel({"asString": this.model.getValue()}); + this.licenseView = new LicenseView({model: this.licenseModel}); + + // Rerender when the license model changes + this.listenTo(this.licenseModel, 'change', this.setLicense); + this.render(); + }, + + render: function() { + this.licenseView.undelegateEvents(); + this.$el.html(this.template({ + model: this.model.attributes + })); + // make the licenseView display after this template, inline + this.licenseView.render().$el.css("display", "inline") + this.$el.append(this.licenseView.el) + // restore event bindings + this.licenseView.delegateEvents(); + return this; + }, + + setLicense: function() { + this.model.setValue(this.licenseModel.toString()); + this.render() + } + + }); + return Metadata; }); diff --git a/cms/static/js/views/settings/main.js b/cms/static/js/views/settings/main.js index d6978987fe..ded47c14ca 100644 --- a/cms/static/js/views/settings/main.js +++ b/cms/static/js/views/settings/main.js @@ -1,6 +1,7 @@ define(["js/views/validation", "codemirror", "underscore", "jquery", "jquery.ui", "js/utils/date_utils", "js/models/uploads", - "js/views/uploads", "js/utils/change_on_enter", "jquery.timepicker", "date"], - function(ValidatingView, CodeMirror, _, $, ui, DateUtils, FileUploadModel, FileUploadDialog, TriggerChangeEventOnEnter) { + "js/views/uploads", "js/utils/change_on_enter", "js/views/license", "js/models/license", "jquery.timepicker", "date"], + function(ValidatingView, CodeMirror, _, $, ui, DateUtils, FileUploadModel, + FileUploadDialog, TriggerChangeEventOnEnter, LicenseView, LicenseModel) { var DetailsView = ValidatingView.extend({ // Model class is CMS.Models.Settings.CourseDetails @@ -39,6 +40,14 @@ var DetailsView = ValidatingView.extend({ this.listenTo(this.model, 'invalid', this.handleValidationError); this.listenTo(this.model, 'change', this.showNotificationBar); this.selectorToField = _.invert(this.fieldToSelectorMap); + // handle license separately, to avoid reimplementing view logic + this.licenseModel = new LicenseModel({"asString": this.model.get('license')}); + this.licenseView = new LicenseView({ + model: this.licenseModel, + el: this.$("#course-license-selector").get(), + showPreview: true + }); + this.listenTo(this.licenseModel, 'change', this.handleLicenseChange); }, render: function() { @@ -79,6 +88,8 @@ var DetailsView = ValidatingView.extend({ } this.$('#' + this.fieldToSelectorMap['entrance_exam_minimum_score_pct']).val(this.model.get('entrance_exam_minimum_score_pct')); + this.licenseView.render() + return this; }, fieldToSelectorMap : { @@ -316,6 +327,11 @@ var DetailsView = ValidatingView.extend({ } }); modal.show(); + }, + + handleLicenseChange: function() { + this.showNotificationBar() + this.model.set("license", this.licenseModel.toString()) } }); diff --git a/cms/static/sass/elements/_forms.scss b/cms/static/sass/elements/_forms.scss index ab3a02348e..5f0bea1afc 100644 --- a/cms/static/sass/elements/_forms.scss +++ b/cms/static/sass/elements/_forms.scss @@ -59,7 +59,7 @@ textarea.text { } // +Fields - Not Editable -// ==================== +// ==================== .field.is-not-editable { & label.is-focused { @@ -72,7 +72,7 @@ textarea.text { } // +Fields - With Error -// ==================== +// ==================== .field.error { input, textarea { @@ -81,7 +81,7 @@ textarea.text { } // +Forms - Additional UI -// ==================== +// ==================== form { // CASE: cosmetic checkbox input @@ -172,8 +172,109 @@ form { } } +// +Forms - License selector UI +// ==================== +.license-img { + padding: 4px; +} + +ul.license-types { + text-align: middle; + vertical-align: middle; + display: inline-block; + + li { + display: inline-block; + } + + .action.license-button { + @include grey-button; + @extend %t-action2; + + display: inline-block; + + text-align: center; + + width: 220px; + height: 40px; + + cursor: pointer; + + &.is-selected { + @include blue-button; + } + } + + .tip { + @extend %t-copy-sub2; + } +} +.wrapper-license-options { + margin-bottom: 10px; + + .tip { + @extend %t-copy-sub2; + } + #list-license-options { + padding-bottom: 10px; + + li { + line-height: 1.5; + border-bottom: 1px solid #EEE; + padding: ($baseline / 2) 0 ($baseline * 0.4); + &.is-clickable { + cursor: pointer; + } + &.last { + border-bottom: none; + } + + .fa { + vertical-align: top; + } + .fa-square-o { + display: inline-block; + margin: ($baseline * 0.15) 15px 0px; + } + .fa-square-o.is-disabled { + color: $gray; + } + .fa-check-square-o { + color: $blue; + display: none; + margin: ($baseline * 0.15) 14px 0px 16px; + } + .fa-check-square-o.is-disabled { + color: $gray; + } + &.is-selected { + .fa-check-square-o { + display: inline-block; + } + .fa-square-o { + display: none; + } + } + .option-name { + @extend %t-action3; + @extend %t-strong; + display: inline-block; + width: 15%; + vertical-align: top; + } + .explanation { + @extend %t-action4; + display: inline-block; + width: 75%; + vertical-align: top; + color: $gray; + } + } + } +} + // +Form - Create New -// ==================== +// ==================== // form styling for creating a new content item (course, user, textbook) // TODO: refactor this into a placeholder to extend. .form-create { @@ -390,8 +491,8 @@ form { } } -// +Form - Inline Name Edit -// ==================== +// +Form - Inline Name Edit +// ==================== // form - inline xblock name edit on unit, container, outline // TODO: abstract this out into a Sass placeholder .incontext-editor.is-editable { @@ -431,8 +532,8 @@ form { } } -// +Form - Create New Wrapper -// ==================== +// +Form - Create New Wrapper +// ==================== .wrapper-create-element { height: auto; opacity: 1.0; @@ -449,8 +550,8 @@ form { } } -// +Form - Grandfathered -// ==================== +// +Form - Grandfathered +// ==================== input.search { padding: 6px 15px 8px 30px; @include box-sizing(border-box); diff --git a/cms/static/sass/elements/_xblocks.scss b/cms/static/sass/elements/_xblocks.scss index 4db9071be5..5bd2955234 100644 --- a/cms/static/sass/elements/_xblocks.scss +++ b/cms/static/sass/elements/_xblocks.scss @@ -103,6 +103,28 @@ } } + .xblock-license, + .xmodule_display.xmodule_HtmlModule .xblock-license { + text-align: $bi-app-right; + border-top: 1px solid $gray-l3; + margin: 0 15px; + padding: 5px; + font-size: 80%; + color: $gray-l3; + + a { + color: $gray-l3; + + &:hover { + color: $ui-link-color; + } + } + span { + color: inherit; + } + + } + .container-paging-header { .meta-wrap { margin: $baseline ($baseline/2); diff --git a/cms/static/sass/views/_outline.scss b/cms/static/sass/views/_outline.scss index 1b45ed2493..4cfa1958b2 100644 --- a/cms/static/sass/views/_outline.scss +++ b/cms/static/sass/views/_outline.scss @@ -176,6 +176,7 @@ // course status // -------------------- .course-status { + float: $bi-app-left; margin-bottom: $baseline; .status-release { @@ -216,6 +217,32 @@ } } + // course content license + // -------------------- + .course-license { + @extend .content-primary; + @include text-align(right); + display: block; + float: $bi-app-right; + width: auto; + + .license-label, + .license-value, + .license-actions { + display: inline-block; + vertical-align: middle; + margin-bottom: 0; + } + + img { + display: inline; + } + } + + .wrapper-dnd { + clear: both; + } + // outline // -------------------- diff --git a/cms/templates/course_outline.html b/cms/templates/course_outline.html index f3246a9e9f..f0a3cc1e98 100644 --- a/cms/templates/course_outline.html +++ b/cms/templates/course_outline.html @@ -109,6 +109,13 @@ from contentstore.utils import reverse_usage_url + % if settings.FEATURES.get("LICENSING", False): +
<%include file="license.html" args="license=context_course.license" />
++ <% if(license.url) { %> + + <%= gettext("Learn more about {license_name}") + .replace("{license_name}", license.name) + %> + + <% } else { %> + + <% } %> +
++ <%= gettext("The following message will be displayed at the bottom of the courseware pages within your course.") %> +
+