Commit 1775dd53 by Sarina Canelake

Merge pull request #7315 from edx/db/creative-commons

Allow custom licensing for course content
parents 36793960 ca64c665
...@@ -5,6 +5,7 @@ import mock ...@@ -5,6 +5,7 @@ import mock
from mock import patch from mock import patch
import shutil import shutil
import lxml.html import lxml.html
from lxml import etree
import ddt import ddt
from datetime import timedelta from datetime import timedelta
...@@ -1836,6 +1837,44 @@ class RerunCourseTest(ContentStoreTestCase): ...@@ -1836,6 +1837,44 @@ class RerunCourseTest(ContentStoreTestCase):
self.assertEqual(len(rerun_state.message), CourseRerunState.MAX_MESSAGE_LENGTH) 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): class EntryPageTestCase(TestCase):
""" """
Tests entry pages that aren't specific to a course. Tests entry pages that aren't specific to a course.
......
...@@ -63,7 +63,7 @@ CONTAINER_TEMPATES = [ ...@@ -63,7 +63,7 @@ CONTAINER_TEMPATES = [
"editor-mode-button", "upload-dialog", "image-modal", "editor-mode-button", "upload-dialog", "image-modal",
"add-xblock-component", "add-xblock-component-button", "add-xblock-component-menu", "add-xblock-component", "add-xblock-component-button", "add-xblock-component-menu",
"add-xblock-component-menu-problem", "xblock-string-field-editor", "publish-xblock", "publish-history", "add-xblock-component-menu-problem", "xblock-string-field-editor", "publish-xblock", "publish-history",
"unit-outline", "container-message" "unit-outline", "container-message", "license-selector",
] ]
......
...@@ -17,6 +17,7 @@ from xmodule.exceptions import NotFoundError, ProcessingError ...@@ -17,6 +17,7 @@ from xmodule.exceptions import NotFoundError, ProcessingError
from xmodule.library_tools import LibraryToolsService from xmodule.library_tools import LibraryToolsService
from xmodule.services import SettingsService from xmodule.services import SettingsService
from xmodule.modulestore.django import modulestore, ModuleI18nService from xmodule.modulestore.django import modulestore, ModuleI18nService
from xmodule.mixin import wrap_with_license
from opaque_keys.edx.keys import UsageKey from opaque_keys.edx.keys import UsageKey
from opaque_keys.edx.locator import LibraryUsageLocator from opaque_keys.edx.locator import LibraryUsageLocator
from xmodule.x_module import ModuleSystem from xmodule.x_module import ModuleSystem
...@@ -170,6 +171,10 @@ def _preview_module_system(request, descriptor, field_data): ...@@ -170,6 +171,10 @@ def _preview_module_system(request, descriptor, field_data):
_studio_wrap_xblock, _studio_wrap_xblock,
] ]
if settings.FEATURES.get("LICENSING", False):
# stick the license wrapper in front
wrappers.insert(0, wrap_with_license)
descriptor.runtime._services['studio_user_permissions'] = StudioPermissionsService(request) # pylint: disable=protected-access descriptor.runtime._services['studio_user_permissions'] = StudioPermissionsService(request) # pylint: disable=protected-access
return PreviewModuleSystem( return PreviewModuleSystem(
......
...@@ -42,6 +42,7 @@ class CourseDetails(object): ...@@ -42,6 +42,7 @@ class CourseDetails(object):
self.overview = "" # html to render as the overview self.overview = "" # html to render as the overview
self.intro_video = None # a video pointer self.intro_video = None # a video pointer
self.effort = None # int hours/week self.effort = None # int hours/week
self.license = "all-rights-reserved" # default course license is all rights reserved
self.course_image_name = "" self.course_image_name = ""
self.course_image_asset_path = "" # URL of the course image self.course_image_asset_path = "" # URL of the course image
self.pre_requisite_courses = [] # pre-requisite courses self.pre_requisite_courses = [] # pre-requisite courses
...@@ -79,6 +80,8 @@ class CourseDetails(object): ...@@ -79,6 +80,8 @@ class CourseDetails(object):
course_details.pre_requisite_courses = descriptor.pre_requisite_courses course_details.pre_requisite_courses = descriptor.pre_requisite_courses
course_details.course_image_name = descriptor.course_image course_details.course_image_name = descriptor.course_image
course_details.course_image_asset_path = course_image_url(descriptor) course_details.course_image_asset_path = course_image_url(descriptor)
# Default course license is "All Rights Reserved"
course_details.license = getattr(descriptor, "license", "all-rights-reserved")
for attribute in ABOUT_ATTRIBUTES: for attribute in ABOUT_ATTRIBUTES:
value = cls._fetch_about_attribute(course_key, attribute) value = cls._fetch_about_attribute(course_key, attribute)
...@@ -173,6 +176,10 @@ class CourseDetails(object): ...@@ -173,6 +176,10 @@ class CourseDetails(object):
descriptor.pre_requisite_courses = jsondict['pre_requisite_courses'] descriptor.pre_requisite_courses = jsondict['pre_requisite_courses']
dirty = True dirty = True
if 'license' in jsondict:
descriptor.license = jsondict['license']
dirty = True
if dirty: if dirty:
module_store.update_item(descriptor, user.id) module_store.update_item(descriptor, user.id)
......
...@@ -339,3 +339,4 @@ if FEATURES['ENABLE_COURSEWARE_INDEX'] or FEATURES['ENABLE_LIBRARY_INDEX']: ...@@ -339,3 +339,4 @@ if FEATURES['ENABLE_COURSEWARE_INDEX'] or FEATURES['ENABLE_LIBRARY_INDEX']:
SEARCH_ENGINE = "search.elastic.ElasticSearchEngine" SEARCH_ENGINE = "search.elastic.ElasticSearchEngine"
XBLOCK_SETTINGS = ENV_TOKENS.get('XBLOCK_SETTINGS', {}) XBLOCK_SETTINGS = ENV_TOKENS.get('XBLOCK_SETTINGS', {})
XBLOCK_SETTINGS.setdefault("VideoDescriptor", {})["licensing_enabled"] = FEATURES.get("LICENSING", False)
...@@ -71,6 +71,9 @@ FEATURES['ENABLE_EDXNOTES'] = True ...@@ -71,6 +71,9 @@ FEATURES['ENABLE_EDXNOTES'] = True
# Enable teams feature # Enable teams feature
FEATURES['ENABLE_TEAMS'] = True FEATURES['ENABLE_TEAMS'] = True
# Enable custom content licensing
FEATURES['LICENSING'] = True
########################### Entrance Exams ################################# ########################### Entrance Exams #################################
FEATURES['ENTRANCE_EXAMS'] = True FEATURES['ENTRANCE_EXAMS'] = True
......
...@@ -50,6 +50,7 @@ from lms.djangoapps.lms_xblock.mixin import LmsBlockMixin ...@@ -50,6 +50,7 @@ from lms.djangoapps.lms_xblock.mixin import LmsBlockMixin
from cms.lib.xblock.authoring_mixin import AuthoringMixin from cms.lib.xblock.authoring_mixin import AuthoringMixin
import dealer.git import dealer.git
from xmodule.modulestore.edit_info import EditInfoMixin from xmodule.modulestore.edit_info import EditInfoMixin
from xmodule.mixin import LicenseMixin
############################ FEATURE CONFIGURATION ############################# ############################ FEATURE CONFIGURATION #############################
STUDIO_NAME = "Studio" STUDIO_NAME = "Studio"
...@@ -142,6 +143,9 @@ FEATURES = { ...@@ -142,6 +143,9 @@ FEATURES = {
# Toggle course entrance exams feature # Toggle course entrance exams feature
'ENTRANCE_EXAMS': False, 'ENTRANCE_EXAMS': False,
# Toggle platform-wide course licensing
'LICENSING': False,
# Enable the courseware search functionality # Enable the courseware search functionality
'ENABLE_COURSEWARE_INDEX': False, 'ENABLE_COURSEWARE_INDEX': False,
...@@ -312,6 +316,7 @@ from xmodule.modulestore.inheritance import InheritanceMixin ...@@ -312,6 +316,7 @@ from xmodule.modulestore.inheritance import InheritanceMixin
from xmodule.modulestore import prefer_xmodules from xmodule.modulestore import prefer_xmodules
from xmodule.x_module import XModuleMixin 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 # This should be moved into an XBlock Runtime/Application object
# once the responsibility of XBlock creation is moved out of modulestore - cpennington # once the responsibility of XBlock creation is moved out of modulestore - cpennington
XBLOCK_MIXINS = ( XBLOCK_MIXINS = (
...@@ -465,6 +470,7 @@ PIPELINE_CSS = { ...@@ -465,6 +470,7 @@ PIPELINE_CSS = {
'style-main': { 'style-main': {
'source_filenames': [ 'source_filenames': [
'sass/studio-main.css', 'sass/studio-main.css',
'css/edx-cc.css',
], ],
'output_filename': 'css/studio-main.css', 'output_filename': 'css/studio-main.css',
}, },
...@@ -942,3 +948,9 @@ ELASTIC_FIELD_MAPPINGS = { ...@@ -942,3 +948,9 @@ ELASTIC_FIELD_MAPPINGS = {
"type": "date" "type": "date"
} }
} }
XBLOCK_SETTINGS = {
"VideoDescriptor": {
"licensing_enabled": FEATURES.get("LICENSING", False)
}
}
...@@ -78,6 +78,9 @@ FEATURES['MILESTONES_APP'] = True ...@@ -78,6 +78,9 @@ FEATURES['MILESTONES_APP'] = True
################################ ENTRANCE EXAMS ################################ ################################ ENTRANCE EXAMS ################################
FEATURES['ENTRANCE_EXAMS'] = True FEATURES['ENTRANCE_EXAMS'] = True
################################ COURSE LICENSES ################################
FEATURES['LICENSING'] = True
################################ SEARCH INDEX ################################ ################################ SEARCH INDEX ################################
FEATURES['ENABLE_COURSEWARE_INDEX'] = True FEATURES['ENABLE_COURSEWARE_INDEX'] = True
FEATURES['ENABLE_LIBRARY_INDEX'] = True FEATURES['ENABLE_LIBRARY_INDEX'] = True
......
...@@ -227,6 +227,7 @@ define([ ...@@ -227,6 +227,7 @@ define([
"js/spec/models/explicit_url_spec", "js/spec/models/explicit_url_spec",
"js/spec/models/xblock_info_spec", "js/spec/models/xblock_info_spec",
"js/spec/models/xblock_validation_spec", "js/spec/models/xblock_validation_spec",
"js/spec/models/license_spec",
"js/spec/utils/drag_and_drop_spec", "js/spec/utils/drag_and_drop_spec",
"js/spec/utils/handle_iframe_binding_spec", "js/spec/utils/handle_iframe_binding_spec",
...@@ -247,6 +248,7 @@ define([ ...@@ -247,6 +248,7 @@ define([
"js/spec/views/xblock_editor_spec", "js/spec/views/xblock_editor_spec",
"js/spec/views/xblock_string_field_editor_spec", "js/spec/views/xblock_string_field_editor_spec",
"js/spec/views/xblock_validation_spec", "js/spec/views/xblock_validation_spec",
"js/spec/views/license_spec",
"js/spec/views/utils/view_utils_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({ ...@@ -15,6 +15,7 @@ var CourseDetails = Backbone.Model.extend({
overview: "", overview: "",
intro_video: null, intro_video: null,
effort: null, // an int or null, effort: null, // an int or null,
license: null,
course_image_name: '', // the filename course_image_name: '', // the filename
course_image_asset_path: '', // the full URL (/c4x/org/course/num/asset/filename) course_image_asset_path: '', // the full URL (/c4x/org/course/num/asset/filename)
pre_requisite_courses: [], 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]");
// try to turn on SA option
SA.click()
// ND should no longer be selected
expect(this.model.get("options")).toEqual(
{"ver": "4.0", "BY": true, "NC": true, "ND": false, "SA": true}
);
// try to turn on ND option
ND = this.view.$("li[data-option=ND]");
ND.click();
expect(this.model.get("options")).toEqual(
{"ver": "4.0", "BY": true, "NC": true, "ND": true, "SA": false}
);
});
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 default text to be "All Rights Reserved"
expect(this.view.$(".license-preview")).toContainText("All Rights Reserved");
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([ ...@@ -29,7 +29,8 @@ define([
course_image_asset_path : '', course_image_asset_path : '',
pre_requisite_courses : [], pre_requisite_courses : [],
entrance_exam_enabled : '', entrance_exam_enabled : '',
entrance_exam_minimum_score_pct: '50' entrance_exam_minimum_score_pct: '50',
license: null
}, },
mockSettingsPage = readFixtures('mock/mock-settings-page.underscore'); 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": "https://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. Currently, this option is required."),
"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. This option is incompatible with \"Share Alike\"."),
"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. This option is incompatible with \"No Derivatives\"."),
"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");
// Check that we've selected a different license type than what's currently selected
if (licenseType != this.model.attributes.type) {
this.model.set({
"type": licenseType,
"options": this.getDefaultOptionsForLicenseType(licenseType)
});
// Fire the change event manually
this.model.trigger("change change:type")
}
e.preventDefault();
},
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) {
var conflicts = optionInfo.conflictsWith;
for (var i=0; i<conflicts.length; i++) {
// Uncheck all conflicts
licenseOptions[conflicts[i]] = false;
console.log(licenseOptions);
}
}
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")
e.preventDefault();
}
});
return LicenseView;
});
...@@ -2,10 +2,12 @@ define( ...@@ -2,10 +2,12 @@ define(
[ [
"js/views/baseview", "underscore", "js/models/metadata", "js/views/abstract_editor", "js/views/baseview", "underscore", "js/models/metadata", "js/views/abstract_editor",
"js/models/uploads", "js/views/uploads", "js/models/uploads", "js/views/uploads",
"js/models/license", "js/views/license",
"js/views/video/transcripts/metadata_videolist", "js/views/video/transcripts/metadata_videolist",
"js/views/video/translations_editor" "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 = {}; var Metadata = {};
Metadata.Editor = BaseView.extend({ Metadata.Editor = BaseView.extend({
...@@ -550,5 +552,32 @@ function(BaseView, _, MetadataModel, AbstractEditor, FileUpload, UploadDialog, V ...@@ -550,5 +552,32 @@ function(BaseView, _, MetadataModel, AbstractEditor, FileUpload, UploadDialog, V
} }
}); });
Metadata.License = AbstractEditor.extend({
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.render().$el.css("display", "inline");
this.licenseView.undelegateEvents();
this.$el.empty().append(this.licenseView.el);
// restore event bindings
this.licenseView.delegateEvents();
return this;
},
setLicense: function() {
this.model.setValue(this.licenseModel.toString());
this.render()
}
});
return Metadata; return Metadata;
}); });
define(["js/views/validation", "codemirror", "underscore", "jquery", "jquery.ui", "js/utils/date_utils", "js/models/uploads", 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"], "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) { function(ValidatingView, CodeMirror, _, $, ui, DateUtils, FileUploadModel,
FileUploadDialog, TriggerChangeEventOnEnter, LicenseView, LicenseModel) {
var DetailsView = ValidatingView.extend({ var DetailsView = ValidatingView.extend({
// Model class is CMS.Models.Settings.CourseDetails // Model class is CMS.Models.Settings.CourseDetails
...@@ -39,6 +40,14 @@ var DetailsView = ValidatingView.extend({ ...@@ -39,6 +40,14 @@ var DetailsView = ValidatingView.extend({
this.listenTo(this.model, 'invalid', this.handleValidationError); this.listenTo(this.model, 'invalid', this.handleValidationError);
this.listenTo(this.model, 'change', this.showNotificationBar); this.listenTo(this.model, 'change', this.showNotificationBar);
this.selectorToField = _.invert(this.fieldToSelectorMap); 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() { render: function() {
...@@ -79,6 +88,8 @@ var DetailsView = ValidatingView.extend({ ...@@ -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.$('#' + this.fieldToSelectorMap['entrance_exam_minimum_score_pct']).val(this.model.get('entrance_exam_minimum_score_pct'));
this.licenseView.render()
return this; return this;
}, },
fieldToSelectorMap : { fieldToSelectorMap : {
...@@ -265,12 +276,13 @@ var DetailsView = ValidatingView.extend({ ...@@ -265,12 +276,13 @@ var DetailsView = ValidatingView.extend({
this.model.fetch({ this.model.fetch({
success: function() { success: function() {
self.render(); self.render();
_.each(self.codeMirrors, _.each(self.codeMirrors, function(mirror) {
function(mirror) { var ele = mirror.getTextArea();
var ele = mirror.getTextArea(); var field = self.selectorToField[ele.id];
var field = self.selectorToField[ele.id]; mirror.setValue(self.model.get(field));
mirror.setValue(self.model.get(field)); });
}); self.licenseModel.setFromString(self.model.get("license"), {silent: true});
self.licenseView.render()
}, },
reset: true, reset: true,
silent: true}); silent: true});
...@@ -316,6 +328,11 @@ var DetailsView = ValidatingView.extend({ ...@@ -316,6 +328,11 @@ var DetailsView = ValidatingView.extend({
} }
}); });
modal.show(); modal.show();
},
handleLicenseChange: function() {
this.showNotificationBar()
this.model.set("license", this.licenseModel.toString())
} }
}); });
......
...@@ -59,7 +59,7 @@ textarea.text { ...@@ -59,7 +59,7 @@ textarea.text {
} }
// +Fields - Not Editable // +Fields - Not Editable
// ==================== // ====================
.field.is-not-editable { .field.is-not-editable {
& label.is-focused { & label.is-focused {
...@@ -72,7 +72,7 @@ textarea.text { ...@@ -72,7 +72,7 @@ textarea.text {
} }
// +Fields - With Error // +Fields - With Error
// ==================== // ====================
.field.error { .field.error {
input, textarea { input, textarea {
...@@ -81,7 +81,7 @@ textarea.text { ...@@ -81,7 +81,7 @@ textarea.text {
} }
// +Forms - Additional UI // +Forms - Additional UI
// ==================== // ====================
form { form {
// CASE: cosmetic checkbox input // CASE: cosmetic checkbox input
...@@ -173,7 +173,7 @@ form { ...@@ -173,7 +173,7 @@ form {
} }
// +Form - Create New // +Form - Create New
// ==================== // ====================
// form styling for creating a new content item (course, user, textbook) // form styling for creating a new content item (course, user, textbook)
// TODO: refactor this into a placeholder to extend. // TODO: refactor this into a placeholder to extend.
.form-create { .form-create {
...@@ -390,8 +390,8 @@ form { ...@@ -390,8 +390,8 @@ form {
} }
} }
// +Form - Inline Name Edit // +Form - Inline Name Edit
// ==================== // ====================
// form - inline xblock name edit on unit, container, outline // form - inline xblock name edit on unit, container, outline
// TODO: abstract this out into a Sass placeholder // TODO: abstract this out into a Sass placeholder
.incontext-editor.is-editable { .incontext-editor.is-editable {
...@@ -431,8 +431,8 @@ form { ...@@ -431,8 +431,8 @@ form {
} }
} }
// +Form - Create New Wrapper // +Form - Create New Wrapper
// ==================== // ====================
.wrapper-create-element { .wrapper-create-element {
height: auto; height: auto;
opacity: 1.0; opacity: 1.0;
...@@ -449,8 +449,8 @@ form { ...@@ -449,8 +449,8 @@ form {
} }
} }
// +Form - Grandfathered // +Form - Grandfathered
// ==================== // ====================
input.search { input.search {
padding: 6px 15px 8px 30px; padding: 6px 15px 8px 30px;
@include box-sizing(border-box); @include box-sizing(border-box);
......
// studio - elements - xblock rendering // studio - elements - xblock rendering
// ========================== // ==========================
// Table of Contents
// * +Layout - Xblocks
// * +Licensing - Xblocks
// * +Pagination - Xblocks
// * +Messaging - Xblocks
// * +Case: Page Level
// * +Case: Nesting Level
// * +Case: Element / Component Level
// * +Case: Experiment Groups - Edited
// * +Editing - Xblocks
// * +Case - Special Xblock Type Overrides
// +Layout - Xblocks
// ====================
// styling for xblocks at various levels of nesting: page level, // styling for xblocks at various levels of nesting: page level,
// extends - UI archetypes - xblock rendering
.wrapper-xblock { .wrapper-xblock {
margin: ($baseline/2); margin: ($baseline/2);
border: 1px solid $gray-l4; border: 1px solid $gray-l4;
...@@ -45,7 +58,7 @@ ...@@ -45,7 +58,7 @@
} }
} }
// secondary header for meta-information and associated actions // UI: secondary header for meta-information and associated actions
.xblock-header-secondary { .xblock-header-secondary {
overflow: hidden; overflow: hidden;
border-top: 1px solid $gray-l3; border-top: 1px solid $gray-l3;
...@@ -103,6 +116,48 @@ ...@@ -103,6 +116,48 @@
} }
} }
// +Licensing - Xblocks
// ====================
.xblock-license,
.xmodule_display.xmodule_HtmlModule .xblock-license,
.xmodule_VideoModule .xblock-license {
@include text-align(right);
@extend %t-title7;
display: block;
width: auto;
border-top: 1px solid $gray-l3;
padding: ($baseline/4) 0;
color: $gray;
text-align: $bi-app-right;
.license-label,
.license-value,
.license-actions {
display: inline-block;
vertical-align: middle;
margin-bottom: 0;
}
a {
color: $gray;
&:hover {
color: $ui-link-color;
}
}
i {
font-style: normal;
}
}
// CASE: xblocks video
.xmodule_VideoModule .xblock-license {
border: 0;
}
// +Pagination - Xblocks
.container-paging-header { .container-paging-header {
.meta-wrap { .meta-wrap {
margin: $baseline ($baseline/2); margin: $baseline ($baseline/2);
...@@ -132,12 +187,9 @@ ...@@ -132,12 +187,9 @@
} }
} }
// ====================
//UI: default internal xblock content styles //UI: default internal xblock content styles
// ====================
// TO-DO: clean-up / remove this reset
// internal headings for problems and video components // internal headings for problems and video components
h2 { h2 {
@extend %t-title5; @extend %t-title5;
...@@ -198,6 +250,8 @@ ...@@ -198,6 +250,8 @@
} }
} }
// +Messaging - Xblocks
// ====================
// xblock message area, for general information as well as validation // xblock message area, for general information as well as validation
.wrapper-xblock-message { .wrapper-xblock-message {
...@@ -259,7 +313,8 @@ ...@@ -259,7 +313,8 @@
} }
} }
// CASE: page level - outer most level // +Case: Page Level
// ====================
&.level-page { &.level-page {
margin: 0; margin: 0;
box-shadow: none; box-shadow: none;
...@@ -303,7 +358,9 @@ ...@@ -303,7 +358,9 @@
} }
// CASE: nesting level - element wrapper level // +Case: Nesting Level
// ====================
// element wrapper level
&.level-nesting { &.level-nesting {
@include transition(all $tmg-f2 linear 0s); @include transition(all $tmg-f2 linear 0s);
border: 1px solid $gray-l3; border: 1px solid $gray-l3;
...@@ -337,7 +394,8 @@ ...@@ -337,7 +394,8 @@
} }
} }
// CASE: element/component level // +Case: Element / Component Level
// ====================
&.level-element { &.level-element {
@include transition(all $tmg-f2 linear 0s); @include transition(all $tmg-f2 linear 0s);
box-shadow: none; box-shadow: none;
...@@ -394,7 +452,9 @@ ...@@ -394,7 +452,9 @@
} }
} }
// CASE: edited experiment groups: active and inactive // +Case: Experiment Groups - Edited
// ====================
// edited experiment groups: active and inactive
.wrapper-groups { .wrapper-groups {
.title { .title {
...@@ -434,8 +494,8 @@ ...@@ -434,8 +494,8 @@
} }
} }
// +Editing - Xblocks
// ==================== // ====================
// XBlock editing
// xblock Editor tab wrapper // xblock Editor tab wrapper
.wrapper-comp-editor { .wrapper-comp-editor {
...@@ -785,7 +845,10 @@ ...@@ -785,7 +845,10 @@
} }
} }
// CASE: special xblock type overrides
// +Case - Special Xblock Type Overrides
// ====================
// TO-DO - remove this reset styling from base _xblocks.scss file
// Latex Compiler // Latex Compiler
// make room for the launch compiler button // make room for the launch compiler button
...@@ -816,3 +879,96 @@ div.wrapper-comp-editor.is-inactive ~ div.launch-latex-compiler { ...@@ -816,3 +879,96 @@ div.wrapper-comp-editor.is-inactive ~ div.launch-latex-compiler {
font-style: italic; font-style: italic;
} }
} }
// CASE: xblock license settings
.wrapper-license {
.license-types {
text-align: center;
vertical-align: middle;
display: inline-block;
.license-type {
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: ($baseline/2);
.tip {
@extend %t-copy-sub2;
}
.license-options {
padding-bottom: ($baseline/2);
.license-option {
line-height: 1.5;
border-bottom: 1px solid $gray-l4;
padding: ($baseline/2) 0 ($baseline*0.4);
&.is-clickable {
cursor: pointer;
}
&:last-child {
border-bottom: none;
}
input[type=checkbox] {
vertical-align: top;
width: auto;
min-width: auto;
height: auto;
border: 0;
margin: ($baseline*0.15) 15px 0px;
}
.option-name {
@extend %t-action3;
@extend %t-strong;
display: inline-block;
width: 15%;
vertical-align: top;
cursor: pointer;
}
.explanation {
@extend %t-action4;
display: inline-block;
width: 75%;
vertical-align: top;
color: $gray;
}
}
}
}
.license-preview a {
color: $gray;
&:hover {
color: $ui-link-color;
}
}
.list-input.settings-list ul.license-options li {
// to make sure the padding is correctly overridden
padding: ($baseline / 2) 0 ($baseline * 0.4);
}
}
...@@ -176,6 +176,7 @@ ...@@ -176,6 +176,7 @@
// course status // course status
// -------------------- // --------------------
.course-status { .course-status {
float: $bi-app-left;
margin-bottom: $baseline; margin-bottom: $baseline;
.status-release { .status-release {
...@@ -216,6 +217,12 @@ ...@@ -216,6 +217,12 @@
} }
} }
// REMOVE BEFORE MERGE - removed outline styling here from cms
.wrapper-dnd {
clear: both;
}
// outline // outline
// -------------------- // --------------------
......
// studio - views - course settings // studio - views - course settings
// ==================== // ====================
// Table of Contents
// * +Settings - Base / All
// * +Settings - Licenses
// +Settings - Base / All
// ====================
.view-settings { .view-settings {
@include text-align(left); @include text-align(left);
@include direction(); @include direction();
...@@ -104,10 +109,11 @@ ...@@ -104,10 +109,11 @@
margin-top: ($baseline/4); margin-top: ($baseline/4);
color: $gray-d1; color: $gray-d1;
} }
.tip-inline{
display: inline; .tip-inline {
margin-left: 5px; display: inline;
} @include margin-left($baseline/4);
}
.message-error { .message-error {
@extend %t-copy-sub1; @extend %t-copy-sub1;
...@@ -149,6 +155,7 @@ ...@@ -149,6 +155,7 @@
} }
} }
} }
#heading-entrance-exam{ #heading-entrance-exam{
font-weight: 600; font-weight: 600;
} }
...@@ -156,6 +163,7 @@ ...@@ -156,6 +163,7 @@
label[for="entrance-exam-enabled"] { label[for="entrance-exam-enabled"] {
font-size: 14px; font-size: 14px;
} }
.field { .field {
margin: 0 0 ($baseline*2) 0; margin: 0 0 ($baseline*2) 0;
...@@ -977,4 +985,4 @@ ...@@ -977,4 +985,4 @@
} }
} }
} }
} }
\ No newline at end of file
<div class="wrapper-license">
<h3 class="label setting-label">
<%= gettext("License Type") %>
</h3>
<ul class="license-types">
<% var link_start_tpl = '<a href="{url}" target="_blank">'; %>
<% _.each(licenseInfo, function(license, licenseType) { %>
<li class="license-type" data-license="<%- licenseType %>">
<button name="license-<%- licenseType %>"
class="action license-button <% if(model.type === licenseType) { print("is-selected"); } %>"
aria-pressed="<%- (model.type === licenseType).toString() %>"
<% 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">
<h4 class="label setting-label">
<%- gettext("Options for {license_name}").replace("{license_name}", license.name) %>
</h4>
<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">
<% _.each(license.option_order, function(optionKey) { %>
<% var optionInfo = license.options[optionKey]; %>
<% if (optionInfo.type == "boolean") { %>
<% var optionSelected = model.options[optionKey]; %>
<% var optionDisabled = optionInfo.disabled %>
<li data-option="<%- optionKey %>"
class="action-item license-option
<% if (optionSelected) { print("is-selected"); } %>
<% if (optionDisabled) { print("is-disabled"); } else { print("is-clickable"); } %>"
>
<input type="checkbox"
id="<%- model.type %>-<%- optionKey %>"
name="<%- model.type %>-<%- optionKey %>"
aria-describedby="<%- optionKey %>-explanation"
<% if(optionSelected) { print('checked="checked"'); } %>
<% if(optionDisabled) { print('disabled="disabled"'); } %>
/>
<label for="<%- model.type %>-<%- optionKey %>" class="option-name">
<%- optionInfo.name %>
</label>
<div id="<%- optionKey %>-explanation" class="explanation">
<%- optionInfo.help %>
</div>
</li>
<% } // could implement other types here %>
<% }) %>
</ul>
</div>
<% } %>
<% if (showPreview) { %>
<div class="wrapper-license-preview">
<h4 class="label setting-label">
<%= gettext("License Display") %>
</h4>
<p class="tip">
<%= gettext("The following message will be displayed at the bottom of the courseware pages within your course:") %>
</p>
<div class="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="https://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 { %>
<% //<span> must come before <i> icon or else spacing gets messed up %>
<span class="sr">gettext("Creative Commons licensed content, with terms as follow:")&nbsp;</span><i aria-hidden="true" class="icon-cc"></i>
<% _.each(enabled, function(option) { %>
<span class="sr"><%- license.options[option.toUpperCase()].name %>&nbsp;</span><i aria-hidden="true" class="icon-cc-<%- option %>"></i>
<% }); %>
<span class="license-text"><%= gettext("Some Rights Reserved") %></span>
<% } %>
<% } else { %>
<%= typeof licenseString == "string" ? licenseString : "" %>
<% // Default to ARR license %>
© <span class="license-text"><%= gettext("All Rights Reserved") %></span>
<% } %>
</a>
</div>
<% } %>
</div>
...@@ -11,7 +11,7 @@ ...@@ -11,7 +11,7 @@
%> %>
<%block name="header_extras"> <%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"> <script type="text/template" id="${template_name}-tpl">
<%static:include path="js/${template_name}.underscore" /> <%static:include path="js/${template_name}.underscore" />
</script> </script>
...@@ -328,6 +328,26 @@ CMS.URL.UPLOAD_ASSET = '${upload_asset_url}'; ...@@ -328,6 +328,26 @@ CMS.URL.UPLOAD_ASSET = '${upload_asset_url}';
</ol> </ol>
</section> </section>
% endif % 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> </form>
</article> </article>
<aside class="content-supplementary" role="complementary"> <aside class="content-supplementary" role="complementary">
......
...@@ -10,9 +10,11 @@ ...@@ -10,9 +10,11 @@
%> %>
## js templates ## js templates
<script id="metadata-editor-tpl" type="text/template"> % for template_name in ["metadata-editor", "license-selector"]:
<%static:include path="js/metadata-editor.underscore" /> <script id="${template_name}-tpl" type="text/template">
</script> <%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"]: % 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"> <script id="${template_name}" type="text/template">
<%static:include path="js/${template_name}.underscore" /> <%static:include path="js/${template_name}.underscore" />
......
...@@ -16,6 +16,7 @@ from xmodule.exceptions import UndefinedContext ...@@ -16,6 +16,7 @@ from xmodule.exceptions import UndefinedContext
from xmodule.seq_module import SequenceDescriptor, SequenceModule from xmodule.seq_module import SequenceDescriptor, SequenceModule
from xmodule.graders import grader_from_conf from xmodule.graders import grader_from_conf
from xmodule.tabs import CourseTabList from xmodule.tabs import CourseTabList
from xmodule.mixin import LicenseMixin
import json import json
from xblock.fields import Scope, List, String, Dict, Boolean, Integer, Float from xblock.fields import Scope, List, String, Dict, Boolean, Integer, Float
...@@ -864,7 +865,10 @@ class CourseModule(CourseFields, SequenceModule): # pylint: disable=abstract-me ...@@ -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 module_class = CourseModule
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
...@@ -995,10 +999,12 @@ class CourseDescriptor(CourseFields, SequenceDescriptor): ...@@ -995,10 +999,12 @@ class CourseDescriptor(CourseFields, SequenceDescriptor):
xml_object.remove(wiki_tag) xml_object.remove(wiki_tag)
definition, children = super(CourseDescriptor, cls).definition_from_xml(xml_object, system) definition, children = super(CourseDescriptor, cls).definition_from_xml(xml_object, system)
definition['textbooks'] = textbooks definition['textbooks'] = textbooks
definition['wiki_slug'] = wiki_slug definition['wiki_slug'] = wiki_slug
# load license if it exists
definition = LicenseMixin.parse_license_from_xml(definition, xml_object)
return definition, children return definition, children
def definition_to_xml(self, resource_fs): def definition_to_xml(self, resource_fs):
...@@ -1017,6 +1023,10 @@ class CourseDescriptor(CourseFields, SequenceDescriptor): ...@@ -1017,6 +1023,10 @@ class CourseDescriptor(CourseFields, SequenceDescriptor):
wiki_xml_object.set('slug', self.wiki_slug) wiki_xml_object.set('slug', self.wiki_slug)
xml_object.append(wiki_xml_object) xml_object.append(wiki_xml_object)
# handle license specifically. Default the course to have a license
# of "All Rights Reserved", if a license is not explicitly set.
self.add_license_to_xml(xml_object, default="all-rights-reserved")
return xml_object return xml_object
def has_ended(self): def has_ended(self):
......
...@@ -22,6 +22,15 @@ class EditingDescriptor(EditingFields, MakoModuleDescriptor): ...@@ -22,6 +22,15 @@ class EditingDescriptor(EditingFields, MakoModuleDescriptor):
""" """
mako_template = "widgets/raw-edit.html" mako_template = "widgets/raw-edit.html"
@property
def non_editable_metadata_fields(self):
"""
`data` should not be editable in the Studio settings editor.
"""
non_editable_fields = super(EditingDescriptor, self).non_editable_metadata_fields
non_editable_fields.append(self.fields['data'])
return non_editable_fields
# cdodge: a little refactoring here, since we're basically doing the same thing # cdodge: a little refactoring here, since we're basically doing the same thing
# here as with our parent class, let's call into it to get the basic fields # here as with our parent class, let's call into it to get the basic fields
# set and then add our additional fields. Trying to keep it DRY. # set and then add our additional fields. Trying to keep it DRY.
......
...@@ -87,7 +87,7 @@ class HtmlModule(HtmlModuleMixin): ...@@ -87,7 +87,7 @@ class HtmlModule(HtmlModuleMixin):
pass pass
class HtmlDescriptor(HtmlFields, XmlDescriptor, EditingDescriptor): class HtmlDescriptor(HtmlFields, XmlDescriptor, EditingDescriptor): # pylint: disable=abstract-method
""" """
Module for putting raw html in a course Module for putting raw html in a course
""" """
...@@ -263,6 +263,9 @@ class HtmlDescriptor(HtmlFields, XmlDescriptor, EditingDescriptor): ...@@ -263,6 +263,9 @@ class HtmlDescriptor(HtmlFields, XmlDescriptor, EditingDescriptor):
@property @property
def non_editable_metadata_fields(self): 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 = super(HtmlDescriptor, self).non_editable_metadata_fields
non_editable_fields.append(HtmlDescriptor.use_latex_compiler) non_editable_fields.append(HtmlDescriptor.use_latex_compiler)
return non_editable_fields 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, default=None):
"""
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", default):
node.set('license', self.license)
def wrap_with_license(block, view, frag, context): # pylint: disable=unused-argument
"""
In the LMS, display the custom license underneath the XBlock.
"""
license = getattr(block, "license", None) # pylint: disable=redefined-builtin
if license:
context = {"license": license}
frag.content += block.runtime.render_template('license_wrapper.html', context)
return frag
...@@ -49,6 +49,7 @@ from xmodule.modulestore.edit_info import EditInfoRuntimeMixin ...@@ -49,6 +49,7 @@ from xmodule.modulestore.edit_info import EditInfoRuntimeMixin
from xmodule.modulestore.exceptions import ItemNotFoundError, DuplicateCourseError, ReferentialIntegrityError from xmodule.modulestore.exceptions import ItemNotFoundError, DuplicateCourseError, ReferentialIntegrityError
from xmodule.modulestore.inheritance import InheritanceMixin, inherit_metadata, InheritanceKeyValueStore from xmodule.modulestore.inheritance import InheritanceMixin, inherit_metadata, InheritanceKeyValueStore
from xmodule.modulestore.xml import CourseLocationManager from xmodule.modulestore.xml import CourseLocationManager
from xmodule.services import SettingsService
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
...@@ -900,6 +901,7 @@ class MongoModuleStore(ModuleStoreDraftAndPublished, ModuleStoreWriteBase, Mongo ...@@ -900,6 +901,7 @@ class MongoModuleStore(ModuleStoreDraftAndPublished, ModuleStoreWriteBase, Mongo
if self.user_service: if self.user_service:
services["user"] = self.user_service services["user"] = self.user_service
services["settings"] = SettingsService()
system = CachingDescriptorSystem( system = CachingDescriptorSystem(
modulestore=self, modulestore=self,
......
...@@ -181,6 +181,15 @@ class SequenceDescriptor(SequenceFields, MakoModuleDescriptor, XmlDescriptor): ...@@ -181,6 +181,15 @@ class SequenceDescriptor(SequenceFields, MakoModuleDescriptor, XmlDescriptor):
self.runtime.add_block_as_child_node(child, xml_object) self.runtime.add_block_as_child_node(child, xml_object)
return xml_object return xml_object
@property
def non_editable_metadata_fields(self):
"""
`is_entrance_exam` should not be editable in the Studio settings editor.
"""
non_editable_fields = super(SequenceDescriptor, self).non_editable_metadata_fields
non_editable_fields.append(self.fields['is_entrance_exam'])
return non_editable_fields
def index_dictionary(self): def index_dictionary(self):
""" """
Return dictionary prepared with module content and type for indexing. Return dictionary prepared with module content and type for indexing.
......
...@@ -473,7 +473,8 @@ class SplitTestDescriptor(SplitTestFields, SequenceDescriptor, StudioEditableDes ...@@ -473,7 +473,8 @@ class SplitTestDescriptor(SplitTestFields, SequenceDescriptor, StudioEditableDes
non_editable_fields = super(SplitTestDescriptor, self).non_editable_metadata_fields non_editable_fields = super(SplitTestDescriptor, self).non_editable_metadata_fields
non_editable_fields.extend([ non_editable_fields.extend([
SplitTestDescriptor.due, SplitTestDescriptor.due,
SplitTestDescriptor.user_partitions SplitTestDescriptor.user_partitions,
SplitTestDescriptor.group_id_to_child,
]) ])
return non_editable_fields return non_editable_fields
......
...@@ -25,6 +25,7 @@ from pkg_resources import resource_string ...@@ -25,6 +25,7 @@ from pkg_resources import resource_string
from django.conf import settings from django.conf import settings
from xblock.core import XBlock
from xblock.fields import ScopeIds from xblock.fields import ScopeIds
from xblock.runtime import KvsFieldData from xblock.runtime import KvsFieldData
...@@ -41,6 +42,7 @@ from .video_xfields import VideoFields ...@@ -41,6 +42,7 @@ from .video_xfields import VideoFields
from .video_handlers import VideoStudentViewHandlers, VideoStudioViewHandlers from .video_handlers import VideoStudentViewHandlers, VideoStudioViewHandlers
from xmodule.video_module import manage_video_subtitles_save 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 # The following import/except block for edxval is temporary measure until
# edxval is a proper XBlock Runtime Service. # edxval is a proper XBlock Runtime Service.
...@@ -282,10 +284,13 @@ class VideoModule(VideoFields, VideoTranscriptsMixin, VideoStudentViewHandlers, ...@@ -282,10 +284,13 @@ class VideoModule(VideoFields, VideoTranscriptsMixin, VideoStudentViewHandlers,
'transcript_languages': json.dumps(sorted_languages), 'transcript_languages': json.dumps(sorted_languages),
'transcript_translation_url': self.runtime.handler_url(self, 'transcript', 'translation').rstrip('/?'), 'transcript_translation_url': self.runtime.handler_url(self, 'transcript', 'translation').rstrip('/?'),
'transcript_available_translations_url': self.runtime.handler_url(self, 'transcript', 'available_translations').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): @XBlock.wants("settings")
class VideoDescriptor(VideoFields, VideoTranscriptsMixin, VideoStudioViewHandlers,
TabsEditingDescriptor, EmptyDataRawDescriptor):
""" """
Descriptor for `VideoModule`. Descriptor for `VideoModule`.
""" """
...@@ -381,6 +386,12 @@ class VideoDescriptor(VideoFields, VideoTranscriptsMixin, VideoStudioViewHandler ...@@ -381,6 +386,12 @@ class VideoDescriptor(VideoFields, VideoTranscriptsMixin, VideoStudioViewHandler
def editable_metadata_fields(self): def editable_metadata_fields(self):
editable_fields = super(VideoDescriptor, self).editable_metadata_fields editable_fields = super(VideoDescriptor, self).editable_metadata_fields
settings_service = self.runtime.service(self, 'settings')
if settings_service:
xb_settings = settings_service.get_settings_bucket(self)
if not xb_settings.get("licensing_enabled", False) and "license" in editable_fields:
del editable_fields["license"]
if self.source_visible: if self.source_visible:
editable_fields['source']['non_editable'] = True editable_fields['source']['non_editable'] = True
else: else:
...@@ -483,6 +494,9 @@ class VideoDescriptor(VideoFields, VideoTranscriptsMixin, VideoStudioViewHandler ...@@ -483,6 +494,9 @@ class VideoDescriptor(VideoFields, VideoTranscriptsMixin, VideoStudioViewHandler
except edxval_api.ValVideoNotFoundError: except edxval_api.ValVideoNotFoundError:
pass pass
# handle license specifically
self.add_license_to_xml(xml)
return xml return xml
def get_context(self): def get_context(self):
...@@ -642,6 +656,9 @@ class VideoDescriptor(VideoFields, VideoTranscriptsMixin, VideoStudioViewHandler ...@@ -642,6 +656,9 @@ class VideoDescriptor(VideoFields, VideoTranscriptsMixin, VideoStudioViewHandler
course_id=getattr(id_generator, 'target_course_id', None) 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 return field_data
def index_dictionary(self): def index_dictionary(self):
......
...@@ -6,12 +6,13 @@ import datetime ...@@ -6,12 +6,13 @@ import datetime
from xblock.fields import Scope, String, Float, Boolean, List, Dict from xblock.fields import Scope, String, Float, Boolean, List, Dict
from xmodule.fields import RelativeTime from xmodule.fields import RelativeTime
from xmodule.mixin import LicenseMixin
# Make '_' a no-op so we can scrape strings # Make '_' a no-op so we can scrape strings
_ = lambda text: text _ = lambda text: text
class VideoFields(object): class VideoFields(LicenseMixin):
"""Fields for `VideoModule` and `VideoDescriptor`.""" """Fields for `VideoModule` and `VideoDescriptor`."""
display_name = String( display_name = String(
help=_("The name students see. This name appears in the course ribbon and as a header for the video."), help=_("The name students see. This name appears in the course ribbon and as a header for the video."),
......
...@@ -620,8 +620,9 @@ class XModuleMixin(XModuleFields, XBlockMixin): ...@@ -620,8 +620,9 @@ class XModuleMixin(XModuleFields, XBlockMixin):
fields = getattr(self, 'unmixed_class', self.__class__).fields fields = getattr(self, 'unmixed_class', self.__class__).fields
for field in fields.values(): for field in fields.values():
if field in self.non_editable_metadata_fields:
if field.scope != Scope.settings or field in self.non_editable_metadata_fields: continue
if field.scope not in (Scope.settings, Scope.content):
continue continue
metadata_fields[field.name] = self._create_metadata_editor_info(field) metadata_fields[field.name] = self._create_metadata_editor_info(field)
...@@ -681,6 +682,8 @@ class XModuleMixin(XModuleFields, XBlockMixin): ...@@ -681,6 +682,8 @@ class XModuleMixin(XModuleFields, XBlockMixin):
editor_type = "Dict" editor_type = "Dict"
elif isinstance(field, RelativeTime): elif isinstance(field, RelativeTime):
editor_type = "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['type'] = editor_type
metadata_field_editor_info['options'] = [] if values is None else values metadata_field_editor_info['options'] = [] if values is None else values
......
@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;
}
[class^="icon-cc"]:before, [class*=" icon-cc"]:before {
font-family: "edx-cc";
}
.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
// studio - utilities - mixins and extends // common - utilities - mixins and extends
// ==================== // ====================
// Table of Contents // Table of Contents
...@@ -427,3 +427,4 @@ ...@@ -427,3 +427,4 @@
white-space: nowrap; white-space: nowrap;
text-overflow: ellipsis; text-overflow: ellipsis;
} }
<%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"]
names = {
"by": _("Attribution"), "nc": _("Noncommercial"),
"nd": _("No Derivatives"), "sa": _("Share Alike")
}
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="https://creativecommons.org/licenses/${'-'.join(enabled)}/${version}/" target="_blank">
% if button:
<img src="https://licensebuttons.net/l/${'-'.join(enabled)}/${version}/${button_size}.png"
alt="${license}"
/>
</a>
% else:
## <span> must come before <i> icon or else spacing gets messed up
<span class="sr">${_("Creative Commons licensed content, with terms as follow:")}&nbsp;</span><i aria-hidden="true" class="icon-cc"></i>
% for option in enabled:
<span class="sr">${names[option]}&nbsp;</span><i aria-hidden="true" class="icon-cc-${option}"></i>
% endfor
<span class="license-text">${_("Some Rights Reserved")}</span>
% endif
</a>
% else:
${license}
% endif
<div class="xblock-license">
<%include file="license.html" args="license=license" />
</div>
...@@ -88,6 +88,16 @@ class CoursewarePage(CoursePage): ...@@ -88,6 +88,16 @@ class CoursewarePage(CoursePage):
return True return True
@property
def course_license(self):
"""
Returns the course license text, if present. Else returns None.
"""
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): def get_active_subsection_url(self):
""" """
return the url of the active subsection in the left nav return the url of the active subsection in the left nav
......
...@@ -487,6 +487,19 @@ class XBlockWrapper(PageObject): ...@@ -487,6 +487,19 @@ class XBlockWrapper(PageObject):
""" """
type_in_codemirror(self, index, text, find_prefix='$("{}").find'.format(self.editor_selector)) type_in_codemirror(self, index, text, find_prefix='$("{}").find'.format(self.editor_selector))
def set_license(self, license_type):
"""
Uses the UI to set the course's license to the given license_type (str)
"""
css_selector = (
"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_settings(self): def save_settings(self):
""" """
Click on settings Save button. Click on settings Save button.
......
...@@ -579,6 +579,13 @@ class CourseOutlinePage(CoursePage, CourseOutlineContainer): ...@@ -579,6 +579,13 @@ class CourseOutlinePage(CoursePage, CourseOutlineContainer):
""" """
return self.children(CourseOutlineChild) return self.children(CourseOutlineChild)
@property
def license(self):
"""
Returns the course license text, if present. Else returns None.
"""
return self.q(css=".license-value").first.text[0]
class CourseOutlineModal(object): class CourseOutlineModal(object):
MODAL_SELECTOR = ".wrapper-modal-window" MODAL_SELECTOR = ".wrapper-modal-window"
......
# coding: utf-8
""" """
Course Schedule and Details Settings page. Course Schedule and Details Settings page.
""" """
from __future__ import unicode_literals
from bok_choy.promise import EmptyPromise from bok_choy.promise import EmptyPromise
from .course_page import CoursePage from .course_page import CoursePage
...@@ -17,6 +19,13 @@ class SettingsPage(CoursePage): ...@@ -17,6 +19,13 @@ class SettingsPage(CoursePage):
def is_browser_on_page(self): def is_browser_on_page(self):
return self.q(css='body.view-settings').present return self.q(css='body.view-settings').present
def refresh_and_wait_for_load(self):
"""
Refresh the page and wait for all resources to load.
"""
self.browser.refresh()
self.wait_for_page()
def get_elements(self, css_selector): def get_elements(self, css_selector):
self.wait_for_element_presence( self.wait_for_element_presence(
css_selector, css_selector,
...@@ -70,6 +79,50 @@ class SettingsPage(CoursePage): ...@@ -70,6 +79,50 @@ class SettingsPage(CoursePage):
'Entrance exam minimum score percent is invisible' 'Entrance exam minimum score percent is invisible'
) )
@property
def course_license(self):
"""
Property. Returns the text of the license type for the course
("All Rights Reserved" or "Creative Commons")
"""
license_types_css = "section.license ul.license-types li.license-type"
self.wait_for_element_presence(
license_types_css,
"license type buttons are present",
)
selected = self.q(css=license_types_css + " button.is-selected")
if selected.is_present():
return selected.text[0]
# Look for the license text that will be displayed by default,
# if no button is yet explicitly selected
license_text = self.q(css='section.license span.license-text')
if license_text.is_present():
return license_text.text[0]
return None
@course_license.setter
def course_license(self, license_name):
"""
Sets the course license to the given license_name
(str, "All Rights Reserved" or "Creative Commons")
"""
license_types_css = "section.license ul.license-types li.license-type"
self.wait_for_element_presence(
license_types_css,
"license type buttons are present",
)
button_xpath = (
"//section[contains(@class, 'license')]"
"//ul[contains(@class, 'license-types')]"
"//li[contains(@class, 'license-type')]"
"//button[contains(text(),'{license_name}')]"
).format(license_name=license_name)
button = self.q(xpath=button_xpath)
if not button.present:
raise Exception("Invalid license name: {name}".format(name=license_name))
button.click()
def save_changes(self, wait_for_confirmation=True): def save_changes(self, wait_for_confirmation=True):
""" """
Clicks save button, waits for confirmation unless otherwise specified Clicks save button, waits for confirmation unless otherwise specified
......
# coding: utf-8
""" """
Acceptance tests for Studio's Setting pages 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 base_studio_test import StudioCourseTest
from bok_choy.promise import EmptyPromise from bok_choy.promise import EmptyPromise
from ...fixtures.course import XBlockFixtureDesc from ...fixtures.course import XBlockFixtureDesc
from ..helpers import create_user_partition_json from ..helpers import create_user_partition_json
from ...pages.studio.overview import CourseOutlinePage from ...pages.studio.overview import CourseOutlinePage
from ...pages.studio.settings import SettingsPage
from ...pages.studio.settings_advanced import AdvancedSettingsPage from ...pages.studio.settings_advanced import AdvancedSettingsPage
from ...pages.studio.settings_group_configurations import GroupConfigurationsPage from ...pages.studio.settings_group_configurations import GroupConfigurationsPage
from ...pages.lms.courseware import CoursewarePage
from unittest import skip from unittest import skip
from textwrap import dedent from textwrap import dedent
from xmodule.partitions.partitions import Group from xmodule.partitions.partitions import Group
...@@ -397,3 +402,77 @@ class AdvancedSettingsValidationTest(StudioCourseTest): ...@@ -397,3 +402,77 @@ class AdvancedSettingsValidationTest(StudioCourseTest):
expected_fields = self.advanced_settings.expected_settings_names expected_fields = self.advanced_settings.expected_settings_names
displayed_fields = self.advanced_settings.displayed_settings_names displayed_fields = self.advanced_settings.displayed_settings_names
self.assertEquals(set(displayed_fields), set(expected_fields)) self.assertEquals(set(displayed_fields), set(expected_fields))
@attr('shard_1')
class ContentLicenseTest(StudioCourseTest):
"""
Tests for course-level licensing (that is, setting the license,
for an entire course's content, to All Rights Reserved or Creative Commons)
"""
def setUp(self): # pylint: disable=arguments-differ
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.settings_page.visit()
def test_empty_license(self):
"""
When I visit the Studio settings page,
I see that the course license is "All Rights Reserved" by default.
Then I visit the LMS courseware page,
and I see that the default course license is displayed.
"""
self.assertEqual(self.settings_page.course_license, "All Rights Reserved")
self.lms_courseware.visit()
self.assertEqual(self.lms_courseware.course_license, "© All Rights Reserved")
def test_arr_license(self):
"""
When I visit the Studio settings page,
and I set the course license to "All Rights Reserved",
and I refresh the page,
I see that the course license is "All Rights Reserved".
Then I visit the LMS courseware page,
and I see that the course license is "All Rights Reserved".
"""
self.settings_page.course_license = "All Rights Reserved"
self.settings_page.save_changes()
self.settings_page.refresh_and_wait_for_load()
self.assertEqual(self.settings_page.course_license, "All Rights Reserved")
self.lms_courseware.visit()
self.assertEqual(self.lms_courseware.course_license, "© All Rights Reserved")
def test_cc_license(self):
"""
When I visit the Studio settings page,
and I set the course license to "Creative Commons",
and I refresh the page,
I see that the course license is "Creative Commons".
Then I visit the LMS courseware page,
and I see that the course license is "Some Rights Reserved".
"""
self.settings_page.course_license = "Creative Commons"
self.settings_page.save_changes()
self.settings_page.refresh_and_wait_for_load()
self.assertEqual(self.settings_page.course_license, "Creative Commons")
self.lms_courseware.visit()
# The course_license text will include a bunch of screen reader text to explain
# the selected options
self.assertIn("Some Rights Reserved", self.lms_courseware.course_license)
# coding: utf-8
"""
Acceptance tests for licensing of the Video module
"""
from __future__ import unicode_literals
from nose.plugins.attrib import attr
from ..studio.base_studio_test import StudioCourseTest
#from ..helpers import UniqueCourseTest
from ...pages.studio.overview import CourseOutlinePage
from ...pages.lms.courseware import CoursewarePage
from ...fixtures.course import XBlockFixtureDesc
@attr('shard_1')
class VideoLicenseTest(StudioCourseTest):
"""
Tests for video module-level licensing (that is, setting the license,
for a specific video module, to All Rights Reserved or Creative Commons)
"""
def setUp(self): # pylint: disable=arguments-differ
super(VideoLicenseTest, self).setUp()
self.lms_courseware = CoursewarePage(
self.browser,
self.course_id,
)
self.studio_course_outline = CourseOutlinePage(
self.browser,
self.course_info['org'],
self.course_info['number'],
self.course_info['run']
)
# used by StudioCourseTest.setUp()
def populate_course_fixture(self, course_fixture):
"""
Create a course with a single chapter.
That chapter has a single section.
That section has a single vertical.
That vertical has a single video element.
"""
video_block = XBlockFixtureDesc('video', "Test Video")
vertical = XBlockFixtureDesc('vertical', "Test Vertical")
vertical.add_children(video_block)
sequential = XBlockFixtureDesc('sequential', "Test Section")
sequential.add_children(vertical)
chapter = XBlockFixtureDesc('chapter', "Test Chapter")
chapter.add_children(sequential)
self.course_fixture.add_children(chapter)
def test_empty_license(self):
"""
When I visit the LMS courseware,
I can see that the video is present
but it has no license displayed by default.
"""
self.lms_courseware.visit()
video = self.lms_courseware.q(css=".vert .xblock .video")
self.assertTrue(video.is_present())
video_license = self.lms_courseware.q(css=".vert .xblock.xmodule_VideoModule .xblock-license")
self.assertFalse(video_license.is_present())
def test_arr_license(self):
"""
When I edit a video element in Studio,
I can set an "All Rights Reserved" license on that video element.
When I visit the LMS courseware,
I can see that the video is present
and that it has "All Rights Reserved" displayed for the license.
"""
self.studio_course_outline.visit()
subsection = self.studio_course_outline.section_at(0).subsection_at(0)
subsection.expand_subsection()
unit = subsection.unit_at(0)
container_page = unit.go_to()
container_page.edit()
video = [xb for xb in container_page.xblocks if xb.name == "Test Video"][0]
video.edit().open_advanced_tab()
video.set_license('all-rights-reserved')
video.save_settings()
container_page.publish_action.click()
self.lms_courseware.visit()
video = self.lms_courseware.q(css=".vert .xblock .video")
self.assertTrue(video.is_present())
video_license = self.lms_courseware.q(css=".vert .xblock.xmodule_VideoModule .xblock-license")
self.assertTrue(video_license.is_present())
self.assertEqual(video_license.text[0], "© All Rights Reserved")
def test_cc_license(self):
"""
When I edit a video element in Studio,
I can set a "Creative Commons" license on that video element.
When I visit the LMS courseware,
I can see that the video is present
and that it has "Some Rights Reserved" displayed for the license.
"""
self.studio_course_outline.visit()
subsection = self.studio_course_outline.section_at(0).subsection_at(0)
subsection.expand_subsection()
unit = subsection.unit_at(0)
container_page = unit.go_to()
container_page.edit()
video = [xb for xb in container_page.xblocks if xb.name == "Test Video"][0]
video.edit().open_advanced_tab()
video.set_license('creative-commons')
video.save_settings()
container_page.publish_action.click()
self.lms_courseware.visit()
video = self.lms_courseware.q(css=".vert .xblock .video")
self.assertTrue(video.is_present())
video_license = self.lms_courseware.q(css=".vert .xblock.xmodule_VideoModule .xblock-license")
self.assertTrue(video_license.is_present())
self.assertIn("Some Rights Reserved", video_license.text[0])
<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/"/> <textbook title="Textbook" book_url="https://s3.amazonaws.com/edx-textbooks/guttag_computation_v3/"/>
<chapter url_name="Overview"> <chapter url_name="Overview">
<videosequence url_name="Toy_Videos"> <videosequence url_name="Toy_Videos">
...@@ -10,7 +10,7 @@ ...@@ -10,7 +10,7 @@
<html url_name="badlink"/> <html url_name="badlink"/>
<html url_name="with_styling"/> <html url_name="with_styling"/>
<html url_name="just_img"/> <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> </videosequence>
<video url_name="Welcome" youtube_id_1_0="p2Q6BrNhdh8" display_name="Welcome"/> <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'/> <video url_name="video_123456789012" youtube_id_1_0="p2Q6BrNhdh8" display_name='Test Video'/>
......
...@@ -67,6 +67,7 @@ from openedx.core.lib.xblock_utils import ( ...@@ -67,6 +67,7 @@ from openedx.core.lib.xblock_utils import (
) )
from xmodule.lti_module import LTIModule from xmodule.lti_module import LTIModule
from xmodule.x_module import XModuleDescriptor from xmodule.x_module import XModuleDescriptor
from xmodule.mixin import wrap_with_license
from xblock_django.user_service import DjangoXBlockUserService from xblock_django.user_service import DjangoXBlockUserService
from util.json_request import JsonResponse from util.json_request import JsonResponse
from util.sandboxing import can_execute_unsafe_code, get_python_lib_zip from util.sandboxing import can_execute_unsafe_code, get_python_lib_zip
...@@ -533,6 +534,9 @@ def get_module_system_for_user(user, field_data_cache, ...@@ -533,6 +534,9 @@ def get_module_system_for_user(user, field_data_cache,
# to the Fragment content coming out of the xblocks that are about to be rendered. # to the Fragment content coming out of the xblocks that are about to be rendered.
block_wrappers = [] block_wrappers = []
if settings.FEATURES.get("LICENSING", False):
block_wrappers.append(wrap_with_license)
# Wrap the output display in a single div to allow for the XModule # Wrap the output display in a single div to allow for the XModule
# javascript to be bound correctly # javascript to be bound correctly
if wrap_xmodule_display is True: if wrap_xmodule_display is True:
......
...@@ -37,6 +37,7 @@ class TestVideoYouTube(TestVideo): ...@@ -37,6 +37,7 @@ class TestVideoYouTube(TestVideo):
'ajax_url': self.item_descriptor.xmodule_runtime.ajax_url + '/save_user_state', 'ajax_url': self.item_descriptor.xmodule_runtime.ajax_url + '/save_user_state',
'autoplay': settings.FEATURES.get('AUTOPLAY_VIDEOS', False), 'autoplay': settings.FEATURES.get('AUTOPLAY_VIDEOS', False),
'branding_info': None, 'branding_info': None,
'license': None,
'cdn_eval': False, 'cdn_eval': False,
'cdn_exp_group': None, 'cdn_exp_group': None,
'data_dir': getattr(self, 'data_dir', None), 'data_dir': getattr(self, 'data_dir', None),
...@@ -104,6 +105,7 @@ class TestVideoNonYouTube(TestVideo): ...@@ -104,6 +105,7 @@ class TestVideoNonYouTube(TestVideo):
expected_context = { expected_context = {
'ajax_url': self.item_descriptor.xmodule_runtime.ajax_url + '/save_user_state', 'ajax_url': self.item_descriptor.xmodule_runtime.ajax_url + '/save_user_state',
'branding_info': None, 'branding_info': None,
'license': None,
'cdn_eval': False, 'cdn_eval': False,
'cdn_exp_group': None, 'cdn_exp_group': None,
'data_dir': getattr(self, 'data_dir', None), 'data_dir': getattr(self, 'data_dir', None),
...@@ -211,6 +213,7 @@ class TestGetHtmlMethod(BaseTestXmodule): ...@@ -211,6 +213,7 @@ class TestGetHtmlMethod(BaseTestXmodule):
expected_context = { expected_context = {
'branding_info': None, 'branding_info': None,
'license': None,
'cdn_eval': False, 'cdn_eval': False,
'cdn_exp_group': None, 'cdn_exp_group': None,
'data_dir': getattr(self, 'data_dir', None), 'data_dir': getattr(self, 'data_dir', None),
...@@ -330,6 +333,7 @@ class TestGetHtmlMethod(BaseTestXmodule): ...@@ -330,6 +333,7 @@ class TestGetHtmlMethod(BaseTestXmodule):
initial_context = { initial_context = {
'branding_info': None, 'branding_info': None,
'license': None,
'cdn_eval': False, 'cdn_eval': False,
'cdn_exp_group': None, 'cdn_exp_group': None,
'data_dir': getattr(self, 'data_dir', None), 'data_dir': getattr(self, 'data_dir', None),
...@@ -472,6 +476,7 @@ class TestGetHtmlMethod(BaseTestXmodule): ...@@ -472,6 +476,7 @@ class TestGetHtmlMethod(BaseTestXmodule):
# Video found for edx_video_id # Video found for edx_video_id
initial_context = { initial_context = {
'branding_info': None, 'branding_info': None,
'license': None,
'cdn_eval': False, 'cdn_eval': False,
'cdn_exp_group': None, 'cdn_exp_group': None,
'data_dir': getattr(self, 'data_dir', None), 'data_dir': getattr(self, 'data_dir', None),
...@@ -584,6 +589,7 @@ class TestGetHtmlMethod(BaseTestXmodule): ...@@ -584,6 +589,7 @@ class TestGetHtmlMethod(BaseTestXmodule):
# Video found for edx_video_id # Video found for edx_video_id
initial_context = { initial_context = {
'branding_info': None, 'branding_info': None,
'license': None,
'cdn_eval': False, 'cdn_eval': False,
'cdn_exp_group': None, 'cdn_exp_group': None,
'data_dir': getattr(self, 'data_dir', None), 'data_dir': getattr(self, 'data_dir', None),
...@@ -705,6 +711,7 @@ class TestGetHtmlMethod(BaseTestXmodule): ...@@ -705,6 +711,7 @@ class TestGetHtmlMethod(BaseTestXmodule):
'logo_tag': 'Video hosted by XuetangX.com', 'logo_tag': 'Video hosted by XuetangX.com',
'url': 'http://www.xuetangx.com' 'url': 'http://www.xuetangx.com'
}, },
'license': None,
'cdn_eval': False, 'cdn_eval': False,
'cdn_exp_group': None, 'cdn_exp_group': None,
'data_dir': getattr(self, 'data_dir', None), 'data_dir': getattr(self, 'data_dir', None),
......
...@@ -576,6 +576,7 @@ FACEBOOK_APP_SECRET = AUTH_TOKENS.get("FACEBOOK_APP_SECRET") ...@@ -576,6 +576,7 @@ FACEBOOK_APP_SECRET = AUTH_TOKENS.get("FACEBOOK_APP_SECRET")
FACEBOOK_APP_ID = AUTH_TOKENS.get("FACEBOOK_APP_ID") FACEBOOK_APP_ID = AUTH_TOKENS.get("FACEBOOK_APP_ID")
XBLOCK_SETTINGS = ENV_TOKENS.get('XBLOCK_SETTINGS', {}) XBLOCK_SETTINGS = ENV_TOKENS.get('XBLOCK_SETTINGS', {})
XBLOCK_SETTINGS.setdefault("VideoDescriptor", {})["licensing_enabled"] = FEATURES.get("LICENSING", False)
##### CDN EXPERIMENT/MONITORING FLAGS ##### ##### CDN EXPERIMENT/MONITORING FLAGS #####
CDN_VIDEO_URLS = ENV_TOKENS.get('CDN_VIDEO_URLS', CDN_VIDEO_URLS) CDN_VIDEO_URLS = ENV_TOKENS.get('CDN_VIDEO_URLS', CDN_VIDEO_URLS)
......
...@@ -99,6 +99,9 @@ FEATURES['ENABLE_EDXNOTES'] = True ...@@ -99,6 +99,9 @@ FEATURES['ENABLE_EDXNOTES'] = True
# Enable teams feature # Enable teams feature
FEATURES['ENABLE_TEAMS'] = True FEATURES['ENABLE_TEAMS'] = True
# Enable custom content licensing
FEATURES['LICENSING'] = True
# Unfortunately, we need to use debug mode to serve staticfiles # Unfortunately, we need to use debug mode to serve staticfiles
DEBUG = True DEBUG = True
......
...@@ -40,6 +40,7 @@ from django.utils.translation import ugettext_lazy as _ ...@@ -40,6 +40,7 @@ from django.utils.translation import ugettext_lazy as _
from .discussionsettings import * from .discussionsettings import *
import dealer.git import dealer.git
from xmodule.modulestore.modulestore_settings import update_module_store_settings from xmodule.modulestore.modulestore_settings import update_module_store_settings
from xmodule.mixin import LicenseMixin
from lms.djangoapps.lms_xblock.mixin import LmsBlockMixin from lms.djangoapps.lms_xblock.mixin import LmsBlockMixin
################################### FEATURES ################################### ################################### FEATURES ###################################
...@@ -371,6 +372,9 @@ FEATURES = { ...@@ -371,6 +372,9 @@ FEATURES = {
# enable beacons for lms onload event statistics # enable beacons for lms onload event statistics
'ENABLE_ONLOAD_BEACON': False, 'ENABLE_ONLOAD_BEACON': False,
# Toggle platform-wide course licensing
'LICENSING': False,
# Certificates Web/HTML Views # Certificates Web/HTML Views
'CERTIFICATES_HTML_VIEW': False, 'CERTIFICATES_HTML_VIEW': False,
...@@ -676,6 +680,7 @@ from xmodule.modulestore.inheritance import InheritanceMixin ...@@ -676,6 +680,7 @@ from xmodule.modulestore.inheritance import InheritanceMixin
from xmodule.modulestore import prefer_xmodules from xmodule.modulestore import prefer_xmodules
from xmodule.x_module import XModuleMixin 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 # This should be moved into an XBlock Runtime/Application object
# once the responsibility of XBlock creation is moved out of modulestore - cpennington # once the responsibility of XBlock creation is moved out of modulestore - cpennington
XBLOCK_MIXINS = (LmsBlockMixin, InheritanceMixin, XModuleMixin) XBLOCK_MIXINS = (LmsBlockMixin, InheritanceMixin, XModuleMixin)
...@@ -1278,6 +1283,7 @@ PIPELINE_CSS = { ...@@ -1278,6 +1283,7 @@ PIPELINE_CSS = {
'style-main': { 'style-main': {
'source_filenames': [ 'source_filenames': [
'sass/lms-main.css', 'sass/lms-main.css',
'css/edx-cc.css',
], ],
'output_filename': 'css/lms-main.css', 'output_filename': 'css/lms-main.css',
}, },
......
...@@ -115,6 +115,9 @@ FEATURES['MILESTONES_APP'] = True ...@@ -115,6 +115,9 @@ FEATURES['MILESTONES_APP'] = True
########################### Entrance Exams ################################# ########################### Entrance Exams #################################
FEATURES['ENTRANCE_EXAMS'] = True FEATURES['ENTRANCE_EXAMS'] = True
################################ COURSE LICENSES ################################
FEATURES['LICENSING'] = True
########################## Courseware Search ####################### ########################## Courseware Search #######################
FEATURES['ENABLE_COURSEWARE_SEARCH'] = True FEATURES['ENABLE_COURSEWARE_SEARCH'] = True
......
// lms - course - base // lms - course - base
// ==================== // ====================
// Table of Contents
// * +Containers
// * +Resets - Old, Body
// * +Resets - Old, Forms
// * +Resets - Old, Images
// * +Resets - Old, Misc
// +Containers
// ====================
.content-wrapper {
background: none;
border: none;
}
.container {
padding: 0;
> div {
display: table;
table-layout: fixed;
width: 100%;
border-radius: 3px;
border: 1px solid $outer-border-color;
background: $container-bg;
box-shadow: 0 1px 2px $shadow-l2;
}
}
// +Resets - Old, Body
// ====================
body { body {
min-width: 980px; min-width: 980px;
min-height: 100%; min-height: 100%;
...@@ -26,25 +60,8 @@ a { ...@@ -26,25 +60,8 @@ a {
} }
} }
.content-wrapper { // +Resets - Old, Forms
background: none; // ====================
border: none;
}
.container {
padding: 0;
> div {
display: table;
table-layout: fixed;
width: 100%;
border-radius: 3px;
border: 1px solid $outer-border-color;
background: $container-bg;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05);
}
}
form { form {
label { label {
display: block; display: block;
...@@ -102,6 +119,8 @@ button, ...@@ -102,6 +119,8 @@ button,
} }
// +Resets - Old, Images
// ====================
img { img {
max-width: 100%; max-width: 100%;
} }
...@@ -134,6 +153,9 @@ img { ...@@ -134,6 +153,9 @@ img {
} }
} }
// +Resets - Old, Misc
// ====================
.test-class { .test-class {
border: 1px solid #f00; border: 1px solid #f00;
} }
......
...@@ -41,12 +41,63 @@ html.video-fullscreen{ ...@@ -41,12 +41,63 @@ html.video-fullscreen{
} }
} }
.content-wrapper {
.container-footer {
margin: 0 auto;
max-width: grid-width(12);
min-width: 760px;
width: flex-grid(12);
color: $gray;
text-align: $bi-app-right;
}
}
.content-wrapper {
.course-license, .xblock-license {
@include text-align(right);
@extend %t-title7;
display: block;
width: auto;
padding: ($baseline/4) 0;
span {
color: inherit;
}
a:link, a:visited {
color: $gray;
}
a:active, a:hover {
color: $link-hover;
}
.license-label,
.license-value,
.license-actions {
display: inline-block;
vertical-align: middle;
margin-bottom: 0;
}
i {
font-style: normal;
}
img {
display: inline;
}
}
}
// TO-DO should this be content wrapper?
div.course-wrapper { div.course-wrapper {
position: relative; position: relative;
section.course-content { section.course-content {
@extend .content; @extend .content;
padding: 40px; padding: ($baseline*2);
line-height: 1.6; line-height: 1.6;
h1 { h1 {
......
...@@ -174,6 +174,7 @@ ${fragment.foot_html()} ...@@ -174,6 +174,7 @@ ${fragment.foot_html()}
% endif % endif
</nav> </nav>
</div> </div>
</div> </div>
% endif % endif
<section class="course-content" id="course-content" role="main" aria-label=“Content”> <section class="course-content" id="course-content" role="main" aria-label=“Content”>
...@@ -205,6 +206,18 @@ ${fragment.foot_html()} ...@@ -205,6 +206,18 @@ ${fragment.foot_html()}
% endif % endif
</div> </div>
</div> </div>
<div class="container-footer">
% if settings.FEATURES.get("LICENSING", False):
<div class="course-license">
% if getattr(course, "license", None):
<%include file="../license.html" args="license=course.license" />
% else:
## Default course license: All Rights Reserved, if none is explicitly set.
<%include file="../license.html" args="license='all-rights-reserved'" />
% endif
</div>
% endif
</div>
<nav class="nav-utilities ${"has-utility-calculator" if course.show_calculator else ""}" aria-label="${_('Course Utilities')}"> <nav class="nav-utilities ${"has-utility-calculator" if course.show_calculator else ""}" aria-label="${_('Course Utilities')}">
## Utility: Chat ## Utility: Chat
......
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