Added a simple XBlockMixin for courseware licenses

This allows course authors to choose between two difference licenses for their
course content: All Rights Reserved, or Creative Commons. In the backend, XBlocks
that wish to allow custom licenses need only inherit from LicenseMixin, which
adds a `license` field as a string.

License information is displayed in the Studio editor view, and just below the
rendered XBlock in the LMS. In addition, if the course block itself has a custom
license set, this license will display just below the main body of the page
on courseware pages.

This entire feature is gated behind the LICENSING feature flag.
This commit is contained in:
David Baumgold
2015-03-05 10:56:14 -05:00
committed by Sarina Canelake
parent 8a4503b3f4
commit 2159d34128
48 changed files with 1333 additions and 25 deletions

View File

@@ -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.

View File

@@ -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",
]

View File

@@ -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)

View File

@@ -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

View File

@@ -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',
},

View File

@@ -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",

View File

@@ -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;
});

View File

@@ -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: [],

View File

@@ -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");
});
})
})

View File

@@ -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");
});
})
})

View File

@@ -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');

View File

@@ -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;
});

View File

@@ -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(
'<label class="label setting-label" for="list-license-types">' +
'<%- model.display_name %>' +
'</label>'
),
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;
});

View File

@@ -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())
}
});

View File

@@ -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);

View File

@@ -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);

View File

@@ -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
// --------------------

View File

@@ -109,6 +109,13 @@ from contentstore.utils import reverse_usage_url
</div>
</div>
% if settings.FEATURES.get("LICENSING", False):
<div class="course-license">
<h2 class="license-label">${_("License:")}</h2>
<p class="license-value"><%include file="license.html" args="license=context_course.license" /></p>
</div>
% endif
<div class="wrapper-dnd">
<%
course_locator = context_course.location

View File

@@ -0,0 +1,107 @@
<div class="license-wrapper">
<label class="label setting-label" for="list-license-types">
<%= gettext("License Type") %>
</label>
<ul class="license-types" id="list-license-types">
<% var link_start_tpl = '<a href="{url}" target="_blank">'; %>
<% _.each(licenseInfo, function(license, licenseType) { %>
<li data-license="<%- licenseType %>">
<button name="license-<%- licenseType %>" class="action license-button
<% if(model.type === licenseType) { print("is-selected"); } %>"
name="license-<%- licenseType %>"
<% if (license.tooltip) { %>data-tooltip="<%- license.tooltip %>"<% } %>>
<%- license.name %>
</button>
<p class="tip">
<% if(license.url) { %>
<a href="<%- license.url %>" target="_blank">
<%= gettext("Learn more about {license_name}")
.replace("{license_name}", license.name)
%>
</a>
<% } else { %>
&nbsp;
<% } %>
</p>
</li>
<% }) %>
</ul>
<% var license = licenseInfo[model.type]; %>
<% if(license && !_.isEmpty(license.options)) { %>
<div class="wrapper-license-options">
<label class="label setting-label" for="list-license-options">
<%- gettext("Options for {license_name}").replace("{license_name}", license.name) %>
</label>
<p class='tip tip-inline'>
<%- gettext("The following options are available for the {license_name} license.")
.replace("{license_name}", license.name) %>
</p>
<ul class="license-options" id="list-license-options">
<% _.each(license.option_order, function(optionKey) { %>
<% var optionInfo = license.options[optionKey]; %>
<% if (optionInfo.type == "boolean") { %>
<% var optionSelected = model.options[optionKey]; %>
<% var optionDisabled = optionInfo.disabled ||
(optionInfo.conflictsWith && _.any(optionInfo.conflictsWith, function(key) {return model.options[key];}));
%>
<li data-option="<%- optionKey %>"
class="action-item license-button
<% if (optionSelected) { print("is-selected"); } %>
<% if (optionDisabled) { print("is-disabled"); } else { print("is-clickable"); } %>"
>
<i class="fa fa-fw
<% if(optionSelected) { print("fa-check-square-o"); } else { print("fa-square-o"); } %>
<% if(optionDisabled) { print("is-disabled"); } %>
"></i>
<h4 class="option-name"><%- optionInfo.name %></h4>
<div class="explanation"><%- optionInfo.help %></div>
</li>
<% } // could implement other types here %>
<% }) %>
</ul>
</div>
<% } %>
<% if (showPreview) { %>
<div class="license-preview-wrapper">
<label class="label setting-label" for="license-preview">
<%= gettext("License Display") %>
</label>
<p class="tip">
<%= gettext("The following message will be displayed at the bottom of the courseware pages within your course.") %>
</p>
<div id="license-preview">
<% // keep this synchronized with the contents of common/templates/license.html %>
<% if (model.type === "all-rights-reserved") { %>
© <span class="license-text"><%= gettext("All Rights Reserved") %></span>
<% } else if (model.type === "creative-commons") {
var possible = ["by", "nc", "nd", "sa"];
var enabled = _.filter(possible, function(option) {
return model.options[option] === true || model.options[option.toUpperCase()] === true;
});
var version = model.options.ver || "4.0";
if (_.isEmpty(enabled)) {
enabled = ["zero"];
version = model.options.ver || "1.0";
}
%>
<a rel="license" href="//creativecommons.org/licenses/<%- enabled.join("-") %>/<%- version %>/">
<% if (previewButton) { %>
<img src="https://licensebuttons.net/l/<%- enabled.join("-") %>/<%- version %>/<%- typeof buttonSize == "string" ? buttonSize : "88x31" %>.png"
alt="<%- typeof licenseString == "string" ? licenseString : "" %>"
/>
<% } else { %>
<i class="icon-cc"></i>
<% _.each(enabled, function(option) { %>
<i class="icon-cc-<%- option %>"></i>
<% }); %>
<span class="license-text"><%= gettext("Some Rights Reserved") %></span>
<% } %>
<% } else { %>
<%= typeof licenseString == "string" ? licenseString : "" %>
<% } %>
</a>
</div>
<% } %>
</div>

View File

@@ -11,7 +11,7 @@
%>
<%block name="header_extras">
% for template_name in ["basic-modal", "modal-button", "upload-dialog"]:
% for template_name in ["basic-modal", "modal-button", "upload-dialog", "license-selector"]:
<script type="text/template" id="${template_name}-tpl">
<%static:include path="js/${template_name}.underscore" />
</script>
@@ -328,6 +328,26 @@ CMS.URL.UPLOAD_ASSET = '${upload_asset_url}';
</ol>
</section>
% endif
% if settings.FEATURES.get("LICENSING", False):
<hr class="divide" />
<section class="group-settings license">
<header>
<h2 class="title-2">${_("Course Content License")}</h2>
## Translators: At the course settings, the editor is able to select the default course content license.
## The course content will have this license set, some assets can override the license with their own.
## In the form, the license selector for course content is described using the following string:
<span class="tip">${_("Select the default license for course content")}</span>
</header>
<ol class="list-input">
<li class="field text" id="field-course-license">
<div id="course-license-selector"></div>
</li>
</ol>
</section>
% endif
</form>
</article>
<aside class="content-supplementary" role="complementary">

View File

@@ -10,9 +10,11 @@
%>
## js templates
<script id="metadata-editor-tpl" type="text/template">
<%static:include path="js/metadata-editor.underscore" />
</script>
% for template_name in ["metadata-editor", "license-selector"]:
<script id="${template_name}-tpl" type="text/template">
<%static:include path="js/${template_name}.underscore" />
</script>
% endfor
% for template_name in ["metadata-number-entry", "metadata-string-entry", "metadata-option-entry", "metadata-list-entry", "metadata-dict-entry", "metadata-file-uploader-entry", "metadata-file-uploader-item"]:
<script id="${template_name}" type="text/template">
<%static:include path="js/${template_name}.underscore" />