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

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