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:
committed by
Sarina Canelake
parent
8a4503b3f4
commit
2159d34128
@@ -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.
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
|
||||
@@ -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",
|
||||
|
||||
|
||||
100
cms/static/js/models/license.js
Normal file
100
cms/static/js/models/license.js
Normal 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;
|
||||
});
|
||||
@@ -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: [],
|
||||
|
||||
72
cms/static/js/spec/models/license_spec.js
Normal file
72
cms/static/js/spec/models/license_spec.js
Normal 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");
|
||||
});
|
||||
})
|
||||
})
|
||||
157
cms/static/js/spec/views/license_spec.js
Normal file
157
cms/static/js/spec/views/license_spec.js
Normal 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");
|
||||
});
|
||||
|
||||
})
|
||||
})
|
||||
@@ -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');
|
||||
|
||||
|
||||
139
cms/static/js/views/license.js
Normal file
139
cms/static/js/views/license.js
Normal 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;
|
||||
});
|
||||
@@ -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;
|
||||
});
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
// --------------------
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
107
cms/templates/js/license-selector.underscore
Normal file
107
cms/templates/js/license-selector.underscore
Normal 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 { %>
|
||||
|
||||
<% } %>
|
||||
</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>
|
||||
@@ -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">
|
||||
|
||||
@@ -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" />
|
||||
|
||||
Reference in New Issue
Block a user