Commit 2159d341 by David Baumgold Committed by Sarina Canelake

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.
parent 8a4503b3
......@@ -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",
......
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: [],
......
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");
});
})
})
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');
......
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;
});
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
......
<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>
......@@ -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" />
......
......@@ -202,6 +202,9 @@ class CapaFields(object):
@property
def non_editable_metadata_fields(self):
"""
`data` should not be editable in the Studio settings editor.
"""
non_editable_fields = super(CapaFields, self).non_editable_metadata_fields
non_editable_fields.append(CapaFields.data)
return non_editable_fields
......
......@@ -16,6 +16,7 @@ from xmodule.exceptions import UndefinedContext
from xmodule.seq_module import SequenceDescriptor, SequenceModule
from xmodule.graders import grader_from_conf
from xmodule.tabs import CourseTabList
from xmodule.mixin import LicenseMixin
import json
from xblock.fields import Scope, List, String, Dict, Boolean, Integer, Float
......@@ -864,7 +865,10 @@ class CourseModule(CourseFields, SequenceModule): # pylint: disable=abstract-me
"""
class CourseDescriptor(CourseFields, SequenceDescriptor):
class CourseDescriptor(CourseFields, LicenseMixin, SequenceDescriptor):
"""
The descriptor for the course XModule
"""
module_class = CourseModule
def __init__(self, *args, **kwargs):
......@@ -995,10 +999,12 @@ class CourseDescriptor(CourseFields, SequenceDescriptor):
xml_object.remove(wiki_tag)
definition, children = super(CourseDescriptor, cls).definition_from_xml(xml_object, system)
definition['textbooks'] = textbooks
definition['wiki_slug'] = wiki_slug
# load license if it exists
definition = LicenseMixin.parse_license_from_xml(definition, xml_object)
return definition, children
def definition_to_xml(self, resource_fs):
......@@ -1017,6 +1023,9 @@ class CourseDescriptor(CourseFields, SequenceDescriptor):
wiki_xml_object.set('slug', self.wiki_slug)
xml_object.append(wiki_xml_object)
# handle license specifically
self.add_license_to_xml(xml_object)
return xml_object
def has_ended(self):
......
......@@ -43,6 +43,9 @@ class DiscussionFields(object):
@property
def non_editable_metadata_fields(self):
"""
`data` should not be editable in the Studio settings editor.
"""
non_editable_fields = super(DiscussionFields, self).non_editable_metadata_fields
non_editable_fields.append(DiscussionFields.data)
return non_editable_fields
......
......@@ -20,6 +20,7 @@ from xmodule.x_module import XModule, DEPRECATION_VSCOMPAT_EVENT
from xmodule.xml_module import XmlDescriptor, name_to_pathname
from xblock.core import XBlock
from xblock.fields import Scope, String, Boolean, List
from xmodule.mixin import LicenseMixin
log = logging.getLogger("edx.courseware")
......@@ -56,6 +57,9 @@ class HtmlFields(object):
@property
def non_editable_metadata_fields(self):
"""
`data` should not be editable in the Studio settings editor.
"""
non_editable_fields = super(HtmlFields, self).non_editable_metadata_fields
non_editable_fields.append(HtmlFields.data)
return non_editable_fields
......@@ -93,7 +97,7 @@ class HtmlModule(HtmlModuleMixin):
pass
class HtmlDescriptor(HtmlFields, XmlDescriptor, EditingDescriptor):
class HtmlDescriptor(HtmlFields, LicenseMixin, XmlDescriptor, EditingDescriptor): # pylint: disable=abstract-method
"""
Module for putting raw html in a course
"""
......@@ -269,6 +273,9 @@ class HtmlDescriptor(HtmlFields, XmlDescriptor, EditingDescriptor):
@property
def non_editable_metadata_fields(self):
"""
`use_latex_compiler` should not be editable in the Studio settings editor.
"""
non_editable_fields = super(HtmlDescriptor, self).non_editable_metadata_fields
non_editable_fields.append(HtmlDescriptor.use_latex_compiler)
return non_editable_fields
......@@ -307,6 +314,9 @@ class AboutFields(object):
@property
def non_editable_metadata_fields(self):
"""
`data` should not be editable in the Studio settings editor.
"""
non_editable_fields = super(AboutFields, self).non_editable_metadata_fields
non_editable_fields.append(AboutFields.data)
return non_editable_fields
......@@ -350,6 +360,9 @@ class StaticTabFields(object):
@property
def non_editable_metadata_fields(self):
"""
`data` should not be editable in the Studio settings editor.
"""
non_editable_fields = super(StaticTabFields, self).non_editable_metadata_fields
non_editable_fields.append(StaticTabFields.data)
return non_editable_fields
......@@ -390,6 +403,9 @@ class CourseInfoFields(object):
@property
def non_editable_metadata_fields(self):
"""
`data` should not be editable in the Studio settings editor.
"""
non_editable_fields = super(CourseInfoFields, self).non_editable_metadata_fields
non_editable_fields.append(CourseInfoFields.data)
return non_editable_fields
......
"""
Reusable mixins for XBlocks and/or XModules
"""
from xblock.fields import Scope, String, XBlockMixin
# Make '_' a no-op so we can scrape strings
_ = lambda text: text
class LicenseMixin(XBlockMixin):
"""
Mixin that allows an author to indicate a license on the contents of an
XBlock. For example, a video could be marked as Creative Commons SA-BY
licensed. You can even indicate the license on an entire course.
If this mixin is not applied to an XBlock, or if the license field is
blank, then the content is subject to whatever legal licensing terms that
apply to content by default. For example, in the United States, that content
is exclusively owned by the creator of the content by default. Other
countries may have similar laws.
"""
license = String(
display_name=_("License"),
help=_("A license defines how the contents of this block can be shared and reused."),
default=None,
scope=Scope.content,
)
@classmethod
def parse_license_from_xml(cls, definition, node):
"""
When importing an XBlock from XML, this method will parse the license
information out of the XML and attach it to the block.
It is defined here so that classes that use this mixin can simply refer
to this method, rather than reimplementing it in their XML import
functions.
"""
license = node.get('license', default=None) # pylint: disable=redefined-builtin
if license:
definition['license'] = license
return definition
def add_license_to_xml(self, node):
"""
When generating XML from an XBlock, this method will add the XBlock's
license to the XML representation before it is serialized.
It is defined here so that classes that use this mixin can simply refer
to this method, rather than reimplementing it in their XML export
functions.
"""
if getattr(self, "license", None):
node.set('license', self.license)
......@@ -41,6 +41,7 @@ from .video_xfields import VideoFields
from .video_handlers import VideoStudentViewHandlers, VideoStudioViewHandlers
from xmodule.video_module import manage_video_subtitles_save
from xmodule.mixin import LicenseMixin
# The following import/except block for edxval is temporary measure until
# edxval is a proper XBlock Runtime Service.
......@@ -282,10 +283,12 @@ class VideoModule(VideoFields, VideoTranscriptsMixin, VideoStudentViewHandlers,
'transcript_languages': json.dumps(sorted_languages),
'transcript_translation_url': self.runtime.handler_url(self, 'transcript', 'translation').rstrip('/?'),
'transcript_available_translations_url': self.runtime.handler_url(self, 'transcript', 'available_translations').rstrip('/?'),
'license': getattr(self, "license", None),
})
class VideoDescriptor(VideoFields, VideoTranscriptsMixin, VideoStudioViewHandlers, TabsEditingDescriptor, EmptyDataRawDescriptor):
class VideoDescriptor(VideoFields, VideoTranscriptsMixin, VideoStudioViewHandlers,
TabsEditingDescriptor, LicenseMixin, EmptyDataRawDescriptor):
"""
Descriptor for `VideoModule`.
"""
......@@ -483,6 +486,9 @@ class VideoDescriptor(VideoFields, VideoTranscriptsMixin, VideoStudioViewHandler
except edxval_api.ValVideoNotFoundError:
pass
# handle license specifically
self.add_license_to_xml(xml)
return xml
def get_context(self):
......@@ -642,6 +648,9 @@ class VideoDescriptor(VideoFields, VideoTranscriptsMixin, VideoStudioViewHandler
course_id=getattr(id_generator, 'target_course_id', None)
)
# load license if it exists
field_data = LicenseMixin.parse_license_from_xml(field_data, xml)
return field_data
def index_dictionary(self):
......
......@@ -682,6 +682,8 @@ class XModuleMixin(XModuleFields, XBlockMixin):
editor_type = "Dict"
elif isinstance(field, RelativeTime):
editor_type = "RelativeTime"
elif isinstance(field, String) and field.name == "license":
editor_type = "License"
metadata_field_editor_info['type'] = editor_type
metadata_field_editor_info['options'] = [] if values is None else values
......
[class^="icon-cc"], [class*=" icon-cc"] {
font-family: 'edx-cc';
font-style: normal;
font-weight: normal;
/* fix buttons height */
line-height: 1em;
/* you can be more comfortable with increased icons size */
/* font-size: 120%; */
}
.icon-cc { *zoom: expression( this.runtimeStyle['zoom'] = '1', this.innerHTML = '&#xe800;&nbsp;'); }
.icon-cc-by { *zoom: expression( this.runtimeStyle['zoom'] = '1', this.innerHTML = '&#xe801;&nbsp;'); }
.icon-cc-nc { *zoom: expression( this.runtimeStyle['zoom'] = '1', this.innerHTML = '&#xe802;&nbsp;'); }
.icon-cc-nc-eu { *zoom: expression( this.runtimeStyle['zoom'] = '1', this.innerHTML = '&#xe803;&nbsp;'); }
.icon-cc-nc-jp { *zoom: expression( this.runtimeStyle['zoom'] = '1', this.innerHTML = '&#xe804;&nbsp;'); }
.icon-cc-sa { *zoom: expression( this.runtimeStyle['zoom'] = '1', this.innerHTML = '&#xe805;&nbsp;'); }
.icon-cc-nd { *zoom: expression( this.runtimeStyle['zoom'] = '1', this.innerHTML = '&#xe806;&nbsp;'); }
.icon-cc-pd { *zoom: expression( this.runtimeStyle['zoom'] = '1', this.innerHTML = '&#xe807;&nbsp;'); }
.icon-cc-zero { *zoom: expression( this.runtimeStyle['zoom'] = '1', this.innerHTML = '&#xe808;&nbsp;'); }
.icon-cc-share { *zoom: expression( this.runtimeStyle['zoom'] = '1', this.innerHTML = '&#xe809;&nbsp;'); }
.icon-cc-remix { *zoom: expression( this.runtimeStyle['zoom'] = '1', this.innerHTML = '&#xe80a;&nbsp;'); }
@font-face {
font-family: 'edx-cc';
src: url('../fonts/edx-cc/edx-cc.eot?52318265');
src: url('../fonts/edx-cc/edx-cc.eot?52318265#iefix') format('embedded-opentype'),
url('../fonts/edx-cc/edx-cc.woff?52318265') format('woff'),
url('../fonts/edx-cc/edx-cc.ttf?52318265') format('truetype'),
url('../fonts/edx-cc/edx-cc.svg?52318265#edx-cc') format('svg');
font-weight: normal;
font-style: normal;
}
/* Chrome hack: SVG is rendered more smooth in Windozze. 100% magic, uncomment if you need it. */
/* Note, that will break hinting! In other OS-es font will be not as sharp as it could be */
/*
@media screen and (-webkit-min-device-pixel-ratio:0) {
@font-face {
font-family: 'edx-cc';
src: url('../fonts/edx-cc/edx-cc.svg?52318265#edx-cc') format('svg');
}
}
*/
[class^="icon-cc"]:before, [class*=" icon-cc"]:before {
font-family: "edx-cc";
font-style: normal;
font-weight: normal;
speak: none;
display: inline-block;
text-decoration: inherit;
width: 1em;
margin-right: .2em;
text-align: center;
/* opacity: .8; */
/* For safety - reset parent styles, that can break glyph codes*/
font-variant: normal;
text-transform: none;
/* fix buttons height, for twitter bootstrap */
line-height: 1em;
/* Animation center compensation - margins should be symmetric */
/* remove if not needed */
margin-left: .2em;
/* you can be more comfortable with increased icons size */
/* font-size: 120%; */
/* Uncomment for 3D effect */
/* text-shadow: 1px 1px 1px rgba(127, 127, 127, 0.3); */
}
.icon-cc:before { content: '\e800'; } /* '' */
.icon-cc-by:before { content: '\e801'; } /* '' */
.icon-cc-nc:before { content: '\e802'; } /* '' */
.icon-cc-nc-eu:before { content: '\e803'; } /* '' */
.icon-cc-nc-jp:before { content: '\e804'; } /* '' */
.icon-cc-sa:before { content: '\e805'; } /* '' */
.icon-cc-nd:before { content: '\e806'; } /* '' */
.icon-cc-pd:before { content: '\e807'; } /* '' */
.icon-cc-zero:before { content: '\e808'; } /* '' */
.icon-cc-share:before { content: '\e809'; } /* '' */
.icon-cc-remix:before { content: '\e80a'; } /* '' */
<?xml version="1.0" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg xmlns="http://www.w3.org/2000/svg">
<metadata>Copyright (C) 2014 by original authors @ fontello.com</metadata>
<defs>
<font id="edx-cc" horiz-adv-x="1000" >
<font-face font-family="edx-cc" font-weight="400" font-stretch="normal" units-per-em="1000" ascent="850" descent="-150" />
<missing-glyph horiz-adv-x="1000" />
<glyph glyph-name="cc" unicode="&#xe800;" d="m474 830q198 2 340-136t146-336q2-200-136-342t-338-146q-198-2-341 137t-145 337q-4 200 135 342t339 144z m12-858q156 2 266 114t108 270-115 267-269 107q-158-2-267-114t-107-270 114-267 270-107z m-124 298q40 0 58 40l56-30q-20-36-50-52-32-20-70-20-62 0-100 38-38 36-38 104t38 106 96 38q86 0 124-66l-62-32q-10 20-24 28t-28 8q-60 0-60-82 0-38 14-58 18-22 46-22z m266 0q42 0 56 40l58-30q-18-32-50-52t-70-20q-64 0-100 38-38 36-38 104 0 64 38 106 38 38 98 38 84 0 120-66l-60-32q-10 20-24 28t-28 8q-62 0-62-82 0-36 16-58t46-22z" horiz-adv-x="960" />
<glyph glyph-name="cc-by" unicode="&#xe801;" d="m480 526q-66 0-66 68t66 68q68 0 68-68t-68-68z m98-26q14 0 22-8 10-10 10-22l0-196-56 0 0-234-148 0 0 234-56 0 0 196q0 12 10 22 8 8 22 8l196 0z m-98 330q200 0 340-140t140-340q0-198-140-339t-340-141q-198 0-339 141t-141 339q0 200 141 340t339 140z m0-872q162 0 277 115t115 277q0 164-115 278t-277 114-277-114-115-278q0-162 115-277t277-115z" horiz-adv-x="960" />
<glyph glyph-name="cc-nc" unicode="&#xe802;" d="m480 830q200 0 340-140t140-340q0-198-140-339t-340-141q-198 0-339 141t-141 339q0 200 141 340t339 140z m-370-350q-22-62-22-130 0-162 115-277t277-115q110 0 202 56t142 148l-178 80q-8-46-46-74-38-30-86-34l0-74-56 0 0 74q-78 0-146 58l66 66q50-44 108-44 24 0 42 12t18 36q0 18-14 30l-46 20-56 26-76 32z m506-122l242-108q14 44 14 100 0 164-115 278t-277 114q-102 0-188-48t-140-130l182-82q12 36 46 62 32 22 78 24l0 74 56 0 0-74q68-4 120-44l-62-64q-44 28-84 28-24 0-38-8-18-10-18-30 0-8 4-12l60-28 42-18z" horiz-adv-x="960" />
<glyph glyph-name="cc-nc-eu" unicode="&#xe803;" d="m480 830q200 0 340-140t140-340q0-198-140-339t-340-141q-198 0-339 141t-141 339q0 200 141 340t339 140z m-370-352q-22-62-22-128 0-162 115-277t277-115q110 0 201 55t143 149l-246 108-174 0q10-36 26-56 38-40 104-40 46 0 92 20l18-90q-56-30-124-30-128 0-196 92-34 44-46 104l-52 0 0 58 44 0 0 14q0 4 1 12t1 12l-46 0 0 56 10 0z m488-112l262-116q12 48 12 100 0 164-115 278t-277 114q-102 0-189-48t-141-130l158-70q8 14 28 38 72 82 184 82 70 0 122-24l-24-92q-40 20-88 20-64 0-100-44-10-10-16-28l56-24 136 0 0-56-8 0z" horiz-adv-x="960" />
<glyph glyph-name="cc-nc-jp" unicode="&#xe804;" d="m480 830q200 0 340-140t140-340q0-198-140-339t-340-141q-198 0-339 141t-141 339q0 200 141 340t339 140z m-374-364q-18-54-18-116 0-162 115-277t277-115q106 0 195 52t141 140l-152 68 0-68-126 0 0-108-118 0 0 108-124 0 0 74 124 0 0 36-12 24-112 0 0 74 54 0z m432-242l112 0-106 48-6-12 0-36z m126 100l192-86q16 58 16 112 0 164-115 278t-277 114q-106 0-194-51t-140-137l158-70-54 98 128 0 76-166 46-20 82 186 128 0-122-224 76 0 0-34z" horiz-adv-x="960" />
<glyph glyph-name="cc-sa" unicode="&#xe805;" d="m478 604q114 0 180-74 66-72 66-186 0-110-68-184-74-74-180-74-80 0-142 50-58 48-70 138l120 0q6-86 106-86 50 0 82 42 30 44 30 118 0 76-28 116-30 40-82 40-96 0-108-86l36 0-96-94-94 94 36 0q14 90 72 138t140 48z m2 226q200 0 340-140t140-340q0-198-140-339t-340-141q-198 0-339 141t-141 339q0 200 141 340t339 140z m0-872q162 0 277 115t115 277q0 164-115 278t-277 114-277-114-115-278q0-162 115-277t277-115z" horiz-adv-x="960" />
<glyph glyph-name="cc-nd" unicode="&#xe806;" d="m306 382l0 82 348 0 0-82-348 0z m0-154l0 82 348 0 0-82-348 0z m174 602q200 0 340-140t140-340q0-198-140-339t-340-141q-198 0-339 141t-141 339q0 200 141 340t339 140z m0-872q162 0 277 115t115 277q0 164-115 278t-277 114-277-114-115-278q0-162 115-277t277-115z" horiz-adv-x="960" />
<glyph glyph-name="cc-pd" unicode="&#xe807;" d="m480 830q200 0 340-140t140-340q0-198-140-339t-340-141q-198 0-339 141t-141 339q0 200 141 340t339 140z m-370-352q-22-62-22-128 0-162 115-277t277-115q110 0 201 55t143 149l-424 188q2-54 28-96t76-42q36 0 64 26l6 6 70-84q-4-2-10-7t-8-9q-62-42-136-42-86 0-159 58t-73 188q0 32 6 62z m310-34l440-194q12 48 12 100 0 164-115 278t-277 114q-102 0-189-48t-141-130l148-66q64 102 196 102 88 0 150-54l-78-80q-8 8-14 12-22 16-52 16-52 0-80-50z" horiz-adv-x="960" />
<glyph glyph-name="cc-zero" unicode="&#xe808;" d="m480 628q108 0 153-81t45-197q0-114-45-195t-153-81-153 81-45 195q0 116 45 197t153 81z m-86-278q0-18 4-66l106 194q14 24-6 42-12 4-18 4-86 0-86-174z m86-172q86 0 86 172 0 40-6 84l-118-204q-22-30 12-46 2-2 6-2 2 0 2-2 2 0 8-1t10-1z m0 652q200 0 340-140t140-340q0-198-140-339t-340-141q-198 0-339 141t-141 339q0 200 141 340t339 140z m0-872q162 0 277 115t115 277q0 164-115 278t-277 114-277-114-115-278q0-162 115-277t277-115z" horiz-adv-x="960" />
<glyph glyph-name="cc-share" unicode="&#xe809;" d="m676 488q12 0 20-8t8-18l0-354q0-10-8-18t-20-8l-260 0q-12 0-20 8t-8 18l0 104-104 0q-10 0-18 8t-8 20l0 352q0 12 6 18 4 6 18 10l264 0q10 0 18-8t8-20l0-104 104 0z m-264 0l108 0 0 78-210 0 0-300 78 0 0 196q0 10 8 18 4 4 16 8z m238-354l0 302-210 0 0-302 210 0z m-170 696q200 0 340-140t140-340q0-198-140-339t-340-141q-198 0-339 141t-141 339q0 200 141 340t339 140z m0-872q162 0 277 115t115 277q0 164-115 278t-277 114-277-114-115-278q0-162 115-277t277-115z" horiz-adv-x="960" />
<glyph glyph-name="cc-remix" unicode="&#xe80a;" d="m794 342l10-4 0-136-10-4-116-50-4-2-6 2-252 104-8 4-124-52-124 54 0 122 116 48-2 2 0 136 130 56 294-122 0-118z m-136-158l0 86-2 0 0 2-220 90 0-86 220-92 0 2z m14 112l78 32-72 30-76-32z m102-74l0 84-86-36 0-84z m-294 608q200 0 340-140t140-340q0-198-140-339t-340-141q-198 0-339 141t-141 339q0 200 141 340t339 140z m0-872q162 0 277 115t115 277q0 164-115 278t-277 114-277-114-115-278q0-162 115-277t277-115z" horiz-adv-x="960" />
</font>
</defs>
</svg>
\ No newline at end of file
<%page args="license, button=False, button_size='88x31'"/>
## keep this synchronized with the contents of cms/templates/js/license-selector.underscore
<%!
from django.utils.translation import ugettext as _
def parse_license(lic):
"""
Returns a two-tuple: license type, and options.
"""
if not lic:
return None, {}
if ":" not in lic:
# no options, so the entire thing is the license type
return lic, {}
ltype, option_str = lic.split(":", 1)
options = {}
for part in option_str.split():
if "=" in part:
key, value = part.split("=", 1)
options[key] = value
else:
options[part] = True
return ltype, options
%>
<% license_type, license_options = parse_license(license) %>
% if license_type == "all-rights-reserved":
© <span class="license-text">${_("All Rights Reserved")}</span>
% elif license_type == "creative-commons":
<%
possible = ["by", "nc", "nd", "sa"]
enabled = [opt for opt in possible
if license_options.get(opt) or license_options.get(opt.upper())]
version = license_options.get("ver", "4.0")
if len(enabled) == 0:
enabled = ["zero"]
version = license_options.get("ver", "1.0")
%>
<a rel="license" href="//creativecommons.org/licenses/${'-'.join(enabled)}/${version}/">
% if button:
<img src="https://licensebuttons.net/l/${'-'.join(enabled)}/${version}/${button_size}.png"
alt="${license}"
/>
</a>
% else:
<i class="icon-cc"></i>
% for option in enabled:
<i class="icon-cc-${option}"></i>
% endfor
<span class="license-text">${_("Some Rights Reserved")}</span>
% endif
</a>
% else:
${license}
% endif
......@@ -5,4 +5,9 @@
</script>
% endif
${content}
% if license:
<div class="xblock-license">
<%include file="license.html" args="license=license" />
</div>
% endif
</div>
......@@ -88,6 +88,13 @@ class CoursewarePage(CoursePage):
return True
@property
def course_license(self):
element = self.q(css="#content .container-footer .course-license")
if element.is_present():
return element.text[0]
return None
def get_active_subsection_url(self):
"""
return the url of the active subsection in the left nav
......
......@@ -12,6 +12,7 @@ from selenium.webdriver.common.action_chains import ActionChains
from .course_page import CoursePage
from .container import ContainerPage
from .settings import SettingsPage
from .utils import set_input_value_and_save, set_input_value, click_css, confirm_prompt
......@@ -579,6 +580,21 @@ class CourseOutlinePage(CoursePage, CourseOutlineContainer):
"""
return self.children(CourseOutlineChild)
@property
def license(self):
return self.q(css=".license-value").first.text[0]
def edit_course_start_date(self):
self.q(css=".status-release .action-edit a.action-button").click()
sp = SettingsPage(
self.browser,
self.course_info['course_org'],
self.course_info['course_num'],
self.course_info['course_run'],
)
sp.wait_for_page()
return sp
class CourseOutlineModal(object):
MODAL_SELECTOR = ".wrapper-modal-window"
......
# coding: utf-8
"""
Course Schedule and Details Settings page.
"""
from __future__ import unicode_literals
from bok_choy.promise import EmptyPromise
from .course_page import CoursePage
......@@ -70,6 +72,17 @@ class SettingsPage(CoursePage):
'Entrance exam minimum score percent is invisible'
)
def set_course_license(self, license_type):
css_selector = (
"section.license ul.license-types "
"li[data-license={license_type}] button"
).format(license_type=license_type)
self.wait_for_element_presence(
css_selector,
'{license_type} button is present'.format(license_type=license_type)
)
self.q(css=css_selector).click()
def save_changes(self, wait_for_confirmation=True):
"""
Clicks save button, waits for confirmation unless otherwise specified
......
# coding: utf-8
"""
Acceptance tests for Studio's Setting pages
"""
from __future__ import unicode_literals
from nose.plugins.attrib import attr
from base_studio_test import StudioCourseTest
from bok_choy.promise import EmptyPromise
from ...fixtures.course import XBlockFixtureDesc
from ..helpers import create_user_partition_json
from ...pages.studio.overview import CourseOutlinePage
from ...pages.studio.settings import SettingsPage
from ...pages.studio.settings_advanced import AdvancedSettingsPage
from ...pages.studio.settings_group_configurations import GroupConfigurationsPage
from ...pages.lms.courseware import CoursewarePage
from unittest import skip
from textwrap import dedent
from xmodule.partitions.partitions import Group
......@@ -397,3 +402,49 @@ class AdvancedSettingsValidationTest(StudioCourseTest):
expected_fields = self.advanced_settings.expected_settings_names
displayed_fields = self.advanced_settings.displayed_settings_names
self.assertEquals(set(displayed_fields), set(expected_fields))
@attr('shard_1')
class ContentLicenseTest(StudioCourseTest):
def setUp(self):
super(ContentLicenseTest, self).setUp()
self.outline_page = CourseOutlinePage(
self.browser,
self.course_info['org'],
self.course_info['number'],
self.course_info['run']
)
self.settings_page = SettingsPage(
self.browser,
self.course_info['org'],
self.course_info['number'],
self.course_info['run']
)
self.lms_courseware = CoursewarePage(
self.browser,
self.course_id,
)
self.outline_page.visit()
def test_empty_license(self):
self.assertEqual(self.outline_page.license, "None")
self.lms_courseware.visit()
self.assertIsNone(self.lms_courseware.course_license)
def test_arr_license(self):
self.outline_page.edit_course_start_date()
self.settings_page.set_course_license("all-rights-reserved")
self.settings_page.save_changes()
self.outline_page.visit()
self.assertEqual(self.outline_page.license, "© All Rights Reserved")
self.lms_courseware.visit()
self.assertEqual(self.lms_courseware.course_license, "© All Rights Reserved")
def test_cc_license(self):
self.outline_page.edit_course_start_date()
self.settings_page.set_course_license("creative-commons")
self.settings_page.save_changes()
self.outline_page.visit()
self.assertEqual(self.outline_page.license, "Some Rights Reserved")
self.lms_courseware.visit()
self.assertEqual(self.lms_courseware.course_license, "Some Rights Reserved")
<course course_image="just_a_test.jpg">
<course course_image="just_a_test.jpg" license="creative-commons: BY">
<textbook title="Textbook" book_url="https://s3.amazonaws.com/edx-textbooks/guttag_computation_v3/"/>
<chapter url_name="Overview">
<videosequence url_name="Toy_Videos">
......@@ -10,7 +10,7 @@
<html url_name="badlink"/>
<html url_name="with_styling"/>
<html url_name="just_img"/>
<video url_name="Video_Resources" youtube_id_1_0="1bK-WdDi6Qw" display_name="Video Resources"/>
<video url_name="Video_Resources" youtube_id_1_0="1bK-WdDi6Qw" display_name="Video Resources" license="all-rights-reserved"/>
</videosequence>
<video url_name="Welcome" youtube_id_1_0="p2Q6BrNhdh8" display_name="Welcome"/>
<video url_name="video_123456789012" youtube_id_1_0="p2Q6BrNhdh8" display_name='Test Video'/>
......
......@@ -37,6 +37,7 @@ class TestVideoYouTube(TestVideo):
'ajax_url': self.item_descriptor.xmodule_runtime.ajax_url + '/save_user_state',
'autoplay': settings.FEATURES.get('AUTOPLAY_VIDEOS', False),
'branding_info': None,
'license': None,
'cdn_eval': False,
'cdn_exp_group': None,
'data_dir': getattr(self, 'data_dir', None),
......@@ -104,6 +105,7 @@ class TestVideoNonYouTube(TestVideo):
expected_context = {
'ajax_url': self.item_descriptor.xmodule_runtime.ajax_url + '/save_user_state',
'branding_info': None,
'license': None,
'cdn_eval': False,
'cdn_exp_group': None,
'data_dir': getattr(self, 'data_dir', None),
......@@ -211,6 +213,7 @@ class TestGetHtmlMethod(BaseTestXmodule):
expected_context = {
'branding_info': None,
'license': None,
'cdn_eval': False,
'cdn_exp_group': None,
'data_dir': getattr(self, 'data_dir', None),
......@@ -330,6 +333,7 @@ class TestGetHtmlMethod(BaseTestXmodule):
initial_context = {
'branding_info': None,
'license': None,
'cdn_eval': False,
'cdn_exp_group': None,
'data_dir': getattr(self, 'data_dir', None),
......@@ -472,6 +476,7 @@ class TestGetHtmlMethod(BaseTestXmodule):
# Video found for edx_video_id
initial_context = {
'branding_info': None,
'license': None,
'cdn_eval': False,
'cdn_exp_group': None,
'data_dir': getattr(self, 'data_dir', None),
......@@ -584,6 +589,7 @@ class TestGetHtmlMethod(BaseTestXmodule):
# Video found for edx_video_id
initial_context = {
'branding_info': None,
'license': None,
'cdn_eval': False,
'cdn_exp_group': None,
'data_dir': getattr(self, 'data_dir', None),
......@@ -705,6 +711,7 @@ class TestGetHtmlMethod(BaseTestXmodule):
'logo_tag': 'Video hosted by XuetangX.com',
'url': 'http://www.xuetangx.com'
},
'license': None,
'cdn_eval': False,
'cdn_exp_group': None,
'data_dir': getattr(self, 'data_dir', None),
......
......@@ -99,6 +99,9 @@ FEATURES['ENABLE_EDXNOTES'] = True
# Enable teams feature
FEATURES['ENABLE_TEAMS'] = True
# Enable custom content licensing
FEATURES['LICENSING'] = True
# Unfortunately, we need to use debug mode to serve staticfiles
DEBUG = True
......
......@@ -40,6 +40,7 @@ from django.utils.translation import ugettext_lazy as _
from .discussionsettings import *
import dealer.git
from xmodule.modulestore.modulestore_settings import update_module_store_settings
from xmodule.mixin import LicenseMixin
from lms.djangoapps.lms_xblock.mixin import LmsBlockMixin
################################### FEATURES ###################################
......@@ -673,6 +674,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 = (LmsBlockMixin, InheritanceMixin, XModuleMixin)
......@@ -1274,6 +1276,8 @@ PIPELINE_CSS = {
'style-main': {
'source_filenames': [
'sass/lms-main.css',
'css/edx-cc.css',
'css/edx-cc-ie7.css',
],
'output_filename': 'css/lms-main.css',
},
......
......@@ -137,6 +137,25 @@ a:focus {
min-width: 760px;
width: flex-grid(12);
}
.container-footer {
max-width: grid-width(12);
min-width: 760px;
width: flex-grid(12);
margin: 0 auto;
text-align: $bi-app-right;
color: $gray-l2;
span {
color: inherit;
}
a:link, a:visited {
color: $gray-l2;
}
a:active, a:hover {
color: $blue;
}
}
span.edx {
text-transform: none;
......
......@@ -44,6 +44,55 @@ html.video-fullscreen{
div.course-wrapper {
position: relative;
.course-license {
position: absolute;
margin: 5px auto;
bottom: 0;
text-align: center;
width: 100%;
color: $gray-l3;
a:link, a:visited {
color: $gray-l3;
}
a:active, a:hover {
color: $link-hover;
}
}
.xblock-license {
text-align: $bi-app-right;
border-top: 1px solid $gray-l3;
margin: 0 15px;
padding: 5px;
font-size: 80%;
span {
color: inherit;
}
&, a {
color: $gray-l3;
&:hover {
color: $link-hover;
}
}
}
.xmodule_display .xblock-license,
.xmodule_display .xblock-license a {
color: $gray-l3;
&:hover {
color: $link-hover;
}
}
.xmodule_VideoModule .xblock-license {
border: 0;
}
section.course-content {
@extend .content;
padding: 40px;
......
......@@ -174,6 +174,7 @@ ${fragment.foot_html()}
% endif
</nav>
</div>
</div>
% endif
<section class="course-content" id="course-content" role="main" aria-label=“Content”>
......@@ -205,6 +206,13 @@ ${fragment.foot_html()}
% endif
</div>
</div>
<div class="container-footer">
% if settings.FEATURES.get("LICENSING", False) and getattr(course, "license", None):
<div class="course-license">
<%include file="../license.html" args="license=course.license" />
</div>
% endif
</div>
<nav class="nav-utilities ${"has-utility-calculator" if course.show_calculator else ""}" aria-label="${_('Course Utilities')}">
## Utility: Chat
......
......@@ -122,12 +122,18 @@ def wrap_xblock(
if block.name:
data['name'] = block.name
if settings.FEATURES.get("LICENSING", False):
license = getattr(block, "license", None)
else:
license = None
template_context = {
'content': block.display_name if display_name_only else frag.content,
'classes': css_classes,
'display_name': block.display_name_with_default,
'data_attributes': u' '.join(u'data-{}="{}"'.format(markupsafe.escape(key), markupsafe.escape(value))
for key, value in data.iteritems()),
'license': license,
}
if hasattr(frag, 'json_init_args') and frag.json_init_args is not None:
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment