diff --git a/conftest.py b/conftest.py
new file mode 100644
index 000000000..a90ee5eec
--- /dev/null
+++ b/conftest.py
@@ -0,0 +1,7 @@
+import pytest
+
+
+# https://pytest-django.readthedocs.io/en/latest/faq.html#how-can-i-give-database-access-to-all-my-tests-without-the-django-db-marker
+@pytest.fixture(autouse=True)
+def enable_db_access_for_all_tests(db):
+ pass
diff --git a/requirements/base.in b/requirements/base.in
index b70901c8f..131e1c525 100644
--- a/requirements/base.in
+++ b/requirements/base.in
@@ -3,6 +3,7 @@
fs
lxml
+mako # Used by xblockutils.resources
markupsafe
python-dateutil
pytz
diff --git a/requirements/test.in b/requirements/test.in
index 4a5d2a57b..38477d7c4 100644
--- a/requirements/test.in
+++ b/requirements/test.in
@@ -5,6 +5,7 @@
-r django.txt # Package dependencies, including optional Django support
astroid
+bok_choy
coverage
ddt
diff-cover >= 0.2.1
@@ -17,4 +18,6 @@ pylint
pytest
pytest-cov
pytest-django
+selenium
tox
+xblock-sdk
diff --git a/xblock/test/settings.py b/xblock/test/settings.py
index ee62f3874..c0ce618e7 100644
--- a/xblock/test/settings.py
+++ b/xblock/test/settings.py
@@ -109,6 +109,7 @@
# Uncomment the next line to enable admin documentation:
# 'django.contrib.admindocs',
+ 'workbench',
)
# A sample logging configuration. The only tangible logging
diff --git a/xblock/test/test_plugin.py b/xblock/test/test_plugin.py
index 126e2e77b..4878edc8d 100644
--- a/xblock/test/test_plugin.py
+++ b/xblock/test/test_plugin.py
@@ -75,13 +75,13 @@ def _num_plugins_cached():
return len(plugin.PLUGIN_CACHE)
-@XBlock.register_temp_plugin(AmbiguousBlock1, "thumbs")
+@XBlock.register_temp_plugin(AmbiguousBlock1, "thumbs_1")
def test_plugin_caching():
plugin.PLUGIN_CACHE = {}
assert _num_plugins_cached() == 0
- XBlock.load_class("thumbs")
+ XBlock.load_class("thumbs_1")
assert _num_plugins_cached() == 1
- XBlock.load_class("thumbs")
+ XBlock.load_class("thumbs_1")
assert _num_plugins_cached() == 1
diff --git a/xblockutils/__init__.py b/xblockutils/__init__.py
new file mode 100644
index 000000000..ac57d0fe7
--- /dev/null
+++ b/xblockutils/__init__.py
@@ -0,0 +1,5 @@
+"""
+Useful classes and functionality for building and testing XBlocks
+"""
+
+__version__ = '3.4.1'
diff --git a/xblockutils/base_test.py b/xblockutils/base_test.py
new file mode 100644
index 000000000..3ae58e531
--- /dev/null
+++ b/xblockutils/base_test.py
@@ -0,0 +1,174 @@
+#
+# Copyright (C) 2014-2015 edX
+#
+# This software's license gives you freedom; you can copy, convey,
+# propagate, redistribute and/or modify this program under the terms of
+# the GNU Affero General Public License (AGPL) as published by the Free
+# Software Foundation (FSF), either version 3 of the License, or (at your
+# option) any later version of the AGPL published by the FSF.
+#
+# This program is distributed in the hope that it will be useful, but
+# WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero
+# General Public License for more details.
+#
+# You should have received a copy of the GNU Affero General Public License
+# along with this program in a file in the toplevel directory called
+# "AGPLv3". If not, see .
+#
+"""
+Base classes for Selenium or bok-choy based integration tests of XBlocks.
+"""
+
+import time
+
+from selenium.webdriver.support.ui import WebDriverWait
+from workbench.runtime import WorkbenchRuntime
+from workbench.scenarios import SCENARIOS, add_xml_scenario, remove_scenario
+from workbench.test.selenium_test import SeleniumTest
+
+from .resources import ResourceLoader
+
+
+class SeleniumXBlockTest(SeleniumTest):
+ """
+ Base class for using the workbench to test XBlocks with Selenium or bok-choy.
+
+ If you want to test an XBlock that's not already installed into the python environment,
+ you can use @XBlock.register_temp_plugin around your test method[s].
+ """
+ timeout = 10 # seconds
+
+ def setUp(self):
+ super().setUp()
+ # Delete all scenarios from the workbench:
+ # Trigger initial scenario load.
+ import workbench.urls # pylint: disable=import-outside-toplevel
+ SCENARIOS.clear()
+ # Disable CSRF checks on XBlock handlers:
+ import workbench.views # pylint: disable=import-outside-toplevel
+ workbench.views.handler.csrf_exempt = True
+
+ def wait_until_visible(self, elem):
+ """ Wait until the given element is visible """
+ wait = WebDriverWait(elem, self.timeout)
+ wait.until(lambda e: e.is_displayed(), f"{elem.text} should be visible")
+
+ def wait_until_hidden(self, elem):
+ """ Wait until the DOM element elem is hidden """
+ wait = WebDriverWait(elem, self.timeout)
+ wait.until(lambda e: not e.is_displayed(), f"{elem.text} should be hidden")
+
+ def wait_until_disabled(self, elem):
+ """ Wait until the DOM element elem is disabled """
+ wait = WebDriverWait(elem, self.timeout)
+ wait.until(lambda e: not e.is_enabled(), f"{elem.text} should be disabled")
+
+ def wait_until_clickable(self, elem):
+ """ Wait until the DOM element elem is display and enabled """
+ wait = WebDriverWait(elem, self.timeout)
+ wait.until(lambda e: e.is_displayed() and e.is_enabled(), f"{elem.text} should be clickable")
+
+ def wait_until_text_in(self, text, elem):
+ """ Wait until the specified text appears in the DOM element elem """
+ wait = WebDriverWait(elem, self.timeout)
+ wait.until(lambda e: text in e.text, f"{text} should be in {elem.text}")
+
+ def wait_until_html_in(self, html, elem):
+ """ Wait until the specified HTML appears in the DOM element elem """
+ wait = WebDriverWait(elem, self.timeout)
+ wait.until(lambda e: html in e.get_attribute('innerHTML'),
+ "{} should be in {}".format(html, elem.get_attribute('innerHTML')))
+
+ def wait_until_exists(self, selector):
+ """ Wait until the specified selector exists on the page """
+ wait = WebDriverWait(self.browser, self.timeout)
+ wait.until(
+ lambda driver: driver.find_element_by_css_selector(selector),
+ f"Selector '{selector}' should exist."
+ )
+
+ @staticmethod
+ def set_scenario_xml(xml):
+ """ Reset the workbench to have only one scenario with the specified XML """
+ SCENARIOS.clear()
+ add_xml_scenario("test", "Test Scenario", xml)
+
+ def go_to_view(self, view_name='student_view', student_id="student_1"):
+ """
+ Navigate to the page `page_name`, as listed on the workbench home
+ Returns the DOM element on the visited page located by the `css_selector`
+ """
+ url = self.live_server_url + f'/scenario/test/{view_name}/'
+ if student_id:
+ url += f'?student={student_id}'
+ self.browser.get(url)
+ return self.browser.find_element_by_css_selector('.workbench .preview > div.xblock-v1:first-child')
+
+ def load_root_xblock(self, student_id="student_1"):
+ """
+ Load (in Python) the XBlock at the root of the current scenario.
+ """
+ dom_node = self.browser.find_element_by_css_selector('.workbench .preview > div.xblock-v1:first-child')
+ usage_id = dom_node.get_attribute('data-usage')
+ runtime = WorkbenchRuntime(student_id)
+ return runtime.get_block(usage_id)
+
+
+class SeleniumBaseTest(SeleniumXBlockTest):
+ """
+ Selenium Base Test for loading a whole folder of XML scenarios and then running tests.
+ This is kept for compatibility, but it is recommended that SeleniumXBlockTest be used
+ instead, since it is faster and more flexible (specifically, scenarios are only loaded
+ as needed, and can be defined inline with the tests).
+ """
+ module_name = None # You must set this to __name__ in any subclass so ResourceLoader can find scenario XML files
+ default_css_selector = None # Selector used by go_to_page to return the XBlock DOM element
+ relative_scenario_path = 'xml' # Path from the module (module_name) to the secnario XML files
+
+ @property
+ def _module_name(self):
+ """ Internal method to access module_name with a friendly warning if it's unset """
+ if self.module_name is None:
+ raise NotImplementedError("Overwrite cls.module_name in your derived class.")
+ return self.module_name
+
+ @property
+ def _default_css_selector(self):
+ """ Internal method to access default_css_selector with a warning if it's unset """
+ if self.default_css_selector is None:
+ raise NotImplementedError("Overwrite cls.default_css_selector in your derived class.")
+ return self.default_css_selector
+
+ def setUp(self):
+ super().setUp()
+ # Use test scenarios:
+ loader = ResourceLoader(self._module_name)
+ scenarios_list = loader.load_scenarios_from_path(self.relative_scenario_path, include_identifier=True)
+ for identifier, title, xml in scenarios_list:
+ add_xml_scenario(identifier, title, xml)
+ self.addCleanup(remove_scenario, identifier)
+
+ # Suzy opens the browser to visit the workbench
+ self.browser.get(self.live_server_url)
+
+ # She knows it's the site by the header
+ header1 = self.browser.find_element_by_css_selector('h1')
+ self.assertEqual(header1.text, 'XBlock scenarios')
+
+ def go_to_page(self, page_name, css_selector=None, view_name=None):
+ """
+ Navigate to the page `page_name`, as listed on the workbench home
+ Returns the DOM element on the visited page located by the `css_selector`
+ """
+ if css_selector is None:
+ css_selector = self._default_css_selector
+
+ self.browser.get(self.live_server_url)
+ target_url = self.browser.find_element_by_link_text(page_name).get_attribute('href')
+ if view_name:
+ target_url += f'{view_name}/'
+ self.browser.get(target_url)
+ time.sleep(1)
+ block = self.browser.find_element_by_css_selector(css_selector)
+ return block
diff --git a/xblockutils/helpers.py b/xblockutils/helpers.py
new file mode 100644
index 000000000..941daa3b4
--- /dev/null
+++ b/xblockutils/helpers.py
@@ -0,0 +1,25 @@
+"""
+Useful helper methods
+"""
+
+
+def child_isinstance(block, child_id, block_class_or_mixin):
+ """
+ Efficiently check if a child of an XBlock is an instance of the given class.
+
+ Arguments:
+ block -- the parent (or ancestor) of the child block in question
+ child_id -- the usage key of the child block we are wondering about
+ block_class_or_mixin -- We return true if block's child indentified by child_id is an
+ instance of this.
+
+ This method is equivalent to
+
+ isinstance(block.runtime.get_block(child_id), block_class_or_mixin)
+
+ but is far more efficient, as it avoids the need to instantiate the child.
+ """
+ def_id = block.runtime.id_reader.get_definition_id(child_id)
+ type_name = block.runtime.id_reader.get_block_type(def_id)
+ child_class = block.runtime.load_block_type(type_name)
+ return issubclass(child_class, block_class_or_mixin)
diff --git a/xblockutils/public/studio_container.js b/xblockutils/public/studio_container.js
new file mode 100644
index 000000000..90d7cfaca
--- /dev/null
+++ b/xblockutils/public/studio_container.js
@@ -0,0 +1,63 @@
+function StudioContainerXBlockWithNestedXBlocksMixin(runtime, element) {
+ var $buttons = $(".add-xblock-component-button", element),
+ $addComponent = $('.add-xblock-component', element),
+ $element = $(element);
+
+ function isSingleInstance($button) {
+ return $button.data('single-instance');
+ }
+
+ // We use delegated events here, i.e., not binding a click event listener
+ // directly to $buttons, because we want to make sure any other click event
+ // listeners of the button are called first before we disable the button.
+ // Ref: OSPR-1393
+ $addComponent.on('click', '.add-xblock-component-button', function(ev) {
+ var $button = $(ev.currentTarget);
+ if ($button.is('.disabled')) {
+ ev.preventDefault();
+ ev.stopPropagation();
+ } else {
+ if (isSingleInstance($button)) {
+ $button.addClass('disabled');
+ $button.attr('disabled', 'disabled');
+ }
+ }
+ });
+
+ function updateButtons() {
+ var nestedBlockLocations = $.map($element.find(".studio-xblock-wrapper"), function(block_wrapper) {
+ return $(block_wrapper).data('locator');
+ });
+
+ $buttons.each(function() {
+ var $this = $(this);
+ if (!isSingleInstance($this)) {
+ return;
+ }
+ var category = $this.data('category');
+ var childExists = false;
+
+ // FIXME: This is potentially buggy - if some XBlock's category is a substring of some other XBlock category
+ // it will exhibit wrong behavior. However, it's not possible to do anything about that unless studio runtime
+ // announces which block was deleted, not it's parent.
+ for (var i = 0; i < nestedBlockLocations.length; i++) {
+ if (nestedBlockLocations[i].indexOf(category) > -1) {
+ childExists = true;
+ break;
+ }
+ }
+
+ if (childExists) {
+ $this.attr('disabled', 'disabled');
+ $this.addClass('disabled')
+ }
+ else {
+ $this.removeAttr('disabled');
+ $this.removeClass('disabled');
+ }
+ });
+ }
+
+ updateButtons();
+ runtime.listenTo('deleted-child', updateButtons);
+}
diff --git a/xblockutils/public/studio_edit.js b/xblockutils/public/studio_edit.js
new file mode 100644
index 000000000..499319c45
--- /dev/null
+++ b/xblockutils/public/studio_edit.js
@@ -0,0 +1,175 @@
+/* Javascript for StudioEditableXBlockMixin. */
+function StudioEditableXBlockMixin(runtime, element) {
+ "use strict";
+
+ var fields = [];
+ var tinyMceAvailable = (typeof $.fn.tinymce !== 'undefined'); // Studio includes a copy of tinyMCE and its jQuery plugin
+ var datepickerAvailable = (typeof $.fn.datepicker !== 'undefined'); // Studio includes datepicker jQuery plugin
+
+ $(element).find('.field-data-control').each(function() {
+ var $field = $(this);
+ var $wrapper = $field.closest('li');
+ var $resetButton = $wrapper.find('button.setting-clear');
+ var type = $wrapper.data('cast');
+ fields.push({
+ name: $wrapper.data('field-name'),
+ isSet: function() { return $wrapper.hasClass('is-set'); },
+ hasEditor: function() { return tinyMceAvailable && $field.tinymce(); },
+ val: function() {
+ var val = $field.val();
+ // Cast values to the appropriate type so that we send nice clean JSON over the wire:
+ if (type == 'boolean')
+ return (val == 'true' || val == '1');
+ if (type == "integer")
+ return parseInt(val, 10);
+ if (type == "float")
+ return parseFloat(val);
+ if (type == "generic" || type == "list" || type == "set") {
+ val = val.trim();
+ if (val === "")
+ val = null;
+ else
+ val = JSON.parse(val); // TODO: handle parse errors
+ }
+ return val;
+ },
+ removeEditor: function() {
+ $field.tinymce().remove();
+ }
+ });
+ var fieldChanged = function() {
+ // Field value has been modified:
+ $wrapper.addClass('is-set');
+ $resetButton.removeClass('inactive').addClass('active');
+ };
+ $field.bind("change input paste", fieldChanged);
+ $resetButton.click(function() {
+ $field.val($wrapper.attr('data-default')); // Use attr instead of data to force treating the default value as a string
+ $wrapper.removeClass('is-set');
+ $resetButton.removeClass('active').addClass('inactive');
+ });
+ if (type == 'html' && tinyMceAvailable) {
+ tinyMCE.baseURL = baseUrl + "/js/vendor/tinymce/js/tinymce";
+ $field.tinymce({
+ theme: 'silver',
+ skin: 'studio-tmce5',
+ content_css: 'studio-tmce5',
+ height: '200px',
+ formats: { code: { inline: 'code' } },
+ codemirror: { path: "" + baseUrl + "/js/vendor" },
+ convert_urls: false,
+ plugins: "lists, link, codemirror",
+ menubar: false,
+ statusbar: false,
+ toolbar_items_size: 'small',
+ toolbar: "formatselect | styleselect | bold italic underline forecolor | bullist numlist outdent indent blockquote | link unlink | code",
+ resize: "both",
+ extended_valid_elements : 'i[class],span[class]',
+ setup : function(ed) {
+ ed.on('change', fieldChanged);
+ }
+ });
+ }
+
+ if (type == 'datepicker' && datepickerAvailable) {
+ $field.datepicker('destroy');
+ $field.datepicker({dateFormat: "m/d/yy"});
+ }
+ });
+
+ $(element).find('.wrapper-list-settings .list-set').each(function() {
+ var $optionList = $(this);
+ var $checkboxes = $(this).find('input');
+ var $wrapper = $optionList.closest('li');
+ var $resetButton = $wrapper.find('button.setting-clear');
+
+ fields.push({
+ name: $wrapper.data('field-name'),
+ isSet: function() { return $wrapper.hasClass('is-set'); },
+ hasEditor: function() { return false; },
+ val: function() {
+ var val = [];
+ $checkboxes.each(function() {
+ if ($(this).is(':checked')) {
+ val.push(JSON.parse($(this).val()));
+ }
+ });
+ return val;
+ }
+ });
+ var fieldChanged = function() {
+ // Field value has been modified:
+ $wrapper.addClass('is-set');
+ $resetButton.removeClass('inactive').addClass('active');
+ };
+ $checkboxes.bind("change input", fieldChanged);
+
+ $resetButton.click(function() {
+ var defaults = JSON.parse($wrapper.attr('data-default'));
+ $checkboxes.each(function() {
+ var val = JSON.parse($(this).val());
+ $(this).prop('checked', defaults.indexOf(val) > -1);
+ });
+ $wrapper.removeClass('is-set');
+ $resetButton.removeClass('active').addClass('inactive');
+ });
+ });
+
+ var studio_submit = function(data) {
+ var handlerUrl = runtime.handlerUrl(element, 'submit_studio_edits');
+ runtime.notify('save', {state: 'start', message: gettext("Saving")});
+ $.ajax({
+ type: "POST",
+ url: handlerUrl,
+ data: JSON.stringify(data),
+ dataType: "json",
+ global: false, // Disable Studio's error handling that conflicts with studio's notify('save') and notify('cancel') :-/
+ success: function(response) { runtime.notify('save', {state: 'end'}); }
+ }).fail(function(jqXHR) {
+ var message = gettext("This may be happening because of an error with our server or your internet connection. Try refreshing the page or making sure you are online.");
+ if (jqXHR.responseText) { // Is there a more specific error message we can show?
+ try {
+ message = JSON.parse(jqXHR.responseText).error;
+ if (typeof message === "object" && message.messages) {
+ // e.g. {"error": {"messages": [{"text": "Unknown user 'bob'!", "type": "error"}, ...]}} etc.
+ message = $.map(message.messages, function(msg) { return msg.text; }).join(", ");
+ }
+ } catch (error) { message = jqXHR.responseText.substr(0, 300); }
+ }
+ runtime.notify('error', {title: gettext("Unable to update settings"), message: message});
+ });
+ };
+
+ $('.save-button', element).bind('click', function(e) {
+ e.preventDefault();
+ var values = {};
+ var notSet = []; // List of field names that should be set to default values
+ for (var i in fields) {
+ var field = fields[i];
+ if (field.isSet()) {
+ values[field.name] = field.val();
+ } else {
+ notSet.push(field.name);
+ }
+ // Remove TinyMCE instances to make sure jQuery does not try to access stale instances
+ // when loading editor for another block:
+ if (field.hasEditor()) {
+ field.removeEditor();
+ }
+ }
+ studio_submit({values: values, defaults: notSet});
+ });
+
+ $(element).find('.cancel-button').bind('click', function(e) {
+ // Remove TinyMCE instances to make sure jQuery does not try to access stale instances
+ // when loading editor for another block:
+ for (var i in fields) {
+ var field = fields[i];
+ if (field.hasEditor()) {
+ field.removeEditor();
+ }
+ }
+ e.preventDefault();
+ runtime.notify('cancel', {});
+ });
+}
diff --git a/xblockutils/publish_event.py b/xblockutils/publish_event.py
new file mode 100644
index 000000000..d366f9cf5
--- /dev/null
+++ b/xblockutils/publish_event.py
@@ -0,0 +1,56 @@
+#
+# Copyright (C) 2014-2015 edX
+#
+# This software's license gives you freedom; you can copy, convey,
+# propagate, redistribute and/or modify this program under the terms of
+# the GNU Affero General Public License (AGPL) as published by the Free
+# Software Foundation (FSF), either version 3 of the License, or (at your
+# option) any later version of the AGPL published by the FSF.
+#
+# This program is distributed in the hope that it will be useful, but
+# WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero
+# General Public License for more details.
+#
+# You should have received a copy of the GNU Affero General Public License
+# along with this program in a file in the toplevel directory called
+# "AGPLv3". If not, see .
+#
+"""
+PublishEventMixin: A mixin for publishing events from an XBlock
+"""
+
+from xblock.core import XBlock
+
+
+class PublishEventMixin:
+ """
+ A mixin for publishing events from an XBlock
+
+ Requires the object to have a runtime.publish method.
+ """
+ additional_publish_event_data = {}
+
+ @XBlock.json_handler
+ def publish_event(self, data, suffix=''): # pylint: disable=unused-argument
+ """
+ AJAX handler to allow client-side code to publish a server-side event
+ """
+ try:
+ event_type = data.pop('event_type')
+ except KeyError:
+ return {'result': 'error', 'message': 'Missing event_type in JSON data'}
+
+ return self.publish_event_from_dict(event_type, data)
+
+ def publish_event_from_dict(self, event_type, data):
+ """
+ Combine 'data' with self.additional_publish_event_data and publish an event
+ """
+ for key, value in self.additional_publish_event_data.items():
+ if key in data:
+ return {'result': 'error', 'message': f'Key should not be in publish_event data: {key}'}
+ data[key] = value
+
+ self.runtime.publish(self, event_type, data)
+ return {'result': 'success'}
diff --git a/xblockutils/resources.py b/xblockutils/resources.py
new file mode 100644
index 000000000..2f6b26821
--- /dev/null
+++ b/xblockutils/resources.py
@@ -0,0 +1,121 @@
+#
+# Copyright (C) 2014-2015 Harvard, edX, OpenCraft
+#
+# This software's license gives you freedom; you can copy, convey,
+# propagate, redistribute and/or modify this program under the terms of
+# the GNU Affero General Public License (AGPL) as published by the Free
+# Software Foundation (FSF), either version 3 of the License, or (at your
+# option) any later version of the AGPL published by the FSF.
+#
+# This program is distributed in the hope that it will be useful, but
+# WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero
+# General Public License for more details.
+#
+# You should have received a copy of the GNU Affero General Public License
+# along with this program in a file in the toplevel directory called
+# "AGPLv3". If not, see .
+#
+"""
+Helper class (ResourceLoader) for loading resources used by an XBlock
+"""
+
+import os
+import sys
+import warnings
+
+import pkg_resources
+
+from django.template import Context, Template, Engine
+from django.template.backends.django import get_installed_libraries
+
+from mako.template import Template as MakoTemplate
+from mako.lookup import TemplateLookup as MakoTemplateLookup
+
+
+class ResourceLoader:
+ """Loads resources relative to the module named by the module_name parameter."""
+ def __init__(self, module_name):
+ self.module_name = module_name
+
+ def load_unicode(self, resource_path):
+ """
+ Gets the content of a resource
+ """
+ resource_content = pkg_resources.resource_string(self.module_name, resource_path)
+ return resource_content.decode('utf-8')
+
+ def render_django_template(self, template_path, context=None, i18n_service=None):
+ """
+ Evaluate a django template by resource path, applying the provided context.
+ """
+ context = context or {}
+ context['_i18n_service'] = i18n_service
+ libraries = {
+ 'i18n': 'xblockutils.templatetags.i18n',
+ }
+
+ installed_libraries = get_installed_libraries()
+ installed_libraries.update(libraries)
+ engine = Engine(libraries=installed_libraries)
+
+ template_str = self.load_unicode(template_path)
+ template = Template(template_str, engine=engine)
+ rendered = template.render(Context(context))
+
+ return rendered
+
+ def render_mako_template(self, template_path, context=None):
+ """
+ Evaluate a mako template by resource path, applying the provided context
+ """
+ context = context or {}
+ template_str = self.load_unicode(template_path)
+ lookup = MakoTemplateLookup(directories=[pkg_resources.resource_filename(self.module_name, '')])
+ template = MakoTemplate(template_str, lookup=lookup)
+ return template.render(**context)
+
+ def render_template(self, template_path, context=None):
+ """
+ This function has been deprecated. It calls render_django_template to support backwards compatibility.
+ """
+ warnings.warn(
+ "ResourceLoader.render_template has been deprecated in favor of ResourceLoader.render_django_template"
+ )
+ return self.render_django_template(template_path, context)
+
+ def render_js_template(self, template_path, element_id, context=None, i18n_service=None):
+ """
+ Render a js template.
+ """
+ context = context or {}
+ return "".format(
+ element_id,
+ self.render_django_template(template_path, context, i18n_service)
+ )
+
+ def load_scenarios_from_path(self, relative_scenario_dir, include_identifier=False):
+ """
+ Returns an array of (title, xmlcontent) from files contained in a specified directory,
+ formatted as expected for the return value of the workbench_scenarios() method.
+
+ If `include_identifier` is True, returns an array of (identifier, title, xmlcontent).
+ """
+ base_dir = os.path.dirname(os.path.realpath(sys.modules[self.module_name].__file__))
+ scenario_dir = os.path.join(base_dir, relative_scenario_dir)
+
+ scenarios = []
+ if os.path.isdir(scenario_dir):
+ for template in sorted(os.listdir(scenario_dir)):
+ if not template.endswith('.xml'):
+ continue
+ identifier = template[:-4]
+ title = identifier.replace('_', ' ').title()
+ template_path = os.path.join(relative_scenario_dir, template)
+ scenario = str(self.render_django_template(template_path, {"url_name": identifier}))
+ if not include_identifier:
+ scenarios.append((title, scenario))
+ else:
+ scenarios.append((identifier, title, scenario))
+
+ return scenarios
diff --git a/xblockutils/settings.py b/xblockutils/settings.py
new file mode 100644
index 000000000..26552fed0
--- /dev/null
+++ b/xblockutils/settings.py
@@ -0,0 +1,91 @@
+#
+# Copyright (C) 2015 OpenCraft
+# License: AGPLv3
+"""
+This module contains a mixins that allows third party XBlocks to access Settings Service in edX LMS.
+"""
+
+from xblockutils.resources import ResourceLoader
+
+
+class XBlockWithSettingsMixin:
+ """
+ This XBlock Mixin provides access to XBlock settings service
+ Descendant Xblock must add @XBlock.wants('settings') declaration
+
+ Configuration:
+ block_settings_key: string - XBlock settings is essentially a dictionary-like object (key-value storage).
+ Each XBlock must provide a key to look its settings up in this storage.
+ Settings Service uses `block_settings_key` attribute to get the XBlock settings key
+ If the `block_settings_key` is not provided the XBlock class name will be used.
+ """
+ # block_settings_key = "XBlockName" # (Optional)
+
+ def get_xblock_settings(self, default=None):
+ """
+ Gets XBlock-specific settigns for current XBlock
+
+ Returns default if settings service is not available.
+
+ Parameters:
+ default - default value to be used in two cases:
+ * No settings service is available
+ * As a `default` parameter to `SettingsService.get_settings_bucket`
+ """
+ settings_service = self.runtime.service(self, "settings")
+ if settings_service:
+ return settings_service.get_settings_bucket(self, default=default)
+ return default
+
+
+class ThemableXBlockMixin:
+ """
+ This XBlock Mixin provides configurable theme support via Settings Service.
+ This mixin implies XBlockWithSettingsMixin is already mixed in into Descendant XBlock
+
+ Parameters:
+ default_theme_config: dict - default theme configuration in case no theme configuration is obtained from
+ Settings Service
+ theme_key: string - XBlock settings key to look theme up
+ block_settings_key: string - (implicit)
+
+ Examples:
+
+ Looks up red.css and small.css in `my_xblock` package:
+ default_theme_config = {
+ 'package': 'my_xblock',
+ 'locations': ['red.css', 'small.css']
+ }
+
+ Looks up public/themes/red.css in my_other_xblock.assets
+ default_theme_config = {
+ 'package': 'my_other_xblock.assets',
+ 'locations': ['public/themes/red.css']
+ }
+ """
+ default_theme_config = None
+ theme_key = "theme"
+
+ def get_theme(self):
+ """
+ Gets theme settings from settings service. Falls back to default (LMS) theme
+ if settings service is not available, xblock theme settings are not set or does
+ contain mentoring theme settings.
+ """
+ xblock_settings = self.get_xblock_settings(default={})
+ if xblock_settings and self.theme_key in xblock_settings:
+ return xblock_settings[self.theme_key]
+ return self.default_theme_config
+
+ def include_theme_files(self, fragment):
+ """
+ Gets theme configuration and renders theme css into fragment
+ """
+ theme = self.get_theme()
+ if not theme or 'package' not in theme:
+ return
+
+ theme_package, theme_files = theme.get('package', None), theme.get('locations', [])
+ resource_loader = ResourceLoader(theme_package)
+ for theme_file in theme_files:
+ fragment.add_css(resource_loader.load_unicode(theme_file))
diff --git a/xblockutils/studio_editable.py b/xblockutils/studio_editable.py
new file mode 100644
index 000000000..710068126
--- /dev/null
+++ b/xblockutils/studio_editable.py
@@ -0,0 +1,510 @@
+#
+# Copyright (C) 2015 OpenCraft
+# License: AGPLv3
+"""
+This module contains a mixin that allows third party XBlocks to be easily edited within edX
+Studio just like the built-in modules. No configuration required, just add
+StudioEditableXBlockMixin to your XBlock.
+"""
+
+# Imports ###########################################################
+
+
+import logging
+import simplejson as json
+
+from xblock.core import XBlock, XBlockMixin
+from xblock.fields import Scope, JSONField, List, Integer, Float, Boolean, String, DateTime
+from xblock.exceptions import JsonHandlerError, NoSuchViewError
+from web_fragments.fragment import Fragment
+from xblock.validation import Validation
+
+from xblockutils.resources import ResourceLoader
+
+# Globals ###########################################################
+
+log = logging.getLogger(__name__)
+loader = ResourceLoader(__name__)
+
+# Classes ###########################################################
+
+
+class FutureFields:
+ """
+ A helper class whose attribute values come from the specified dictionary or fallback object.
+
+ This is only used by StudioEditableXBlockMixin and is not meant to be re-used anywhere else!
+
+ This class wraps an XBlock and makes it appear that some of the block's field values have
+ been changed to new values or deleted (and reset to default values). It does so without
+ actually modifying the XBlock. The only reason we need this is because the XBlock validation
+ API is built around attribute access, but often we want to validate data that's stored in a
+ dictionary before making changes to an XBlock's attributes (since any changes made to the
+ XBlock may get persisted even if validation fails).
+ """
+ def __init__(self, new_fields_dict, newly_removed_fields, fallback_obj):
+ """
+ Create an instance whose attributes come from new_fields_dict and fallback_obj.
+
+ Arguments:
+ new_fields_dict -- A dictionary of values that will appear as attributes of this object
+ newly_removed_fields -- A list of field names for which we will not use fallback_obj
+ fallback_obj -- An XBlock to use as a provider for any attributes not in new_fields_dict
+ """
+ self._new_fields_dict = new_fields_dict
+ self._blacklist = newly_removed_fields
+ self._fallback_obj = fallback_obj
+
+ def __getattr__(self, name):
+ try:
+ return self._new_fields_dict[name]
+ except KeyError:
+ if name in self._blacklist:
+ # Pretend like this field is not actually set, since we're going to be resetting it to default
+ return self._fallback_obj.fields[name].default
+ return getattr(self._fallback_obj, name)
+
+
+class StudioEditableXBlockMixin:
+ """
+ An XBlock mixin to provide a configuration UI for an XBlock in Studio.
+ """
+ editable_fields = () # Set this to a list of the names of fields to appear in the editor
+
+ def studio_view(self, context):
+ """
+ Render a form for editing this XBlock
+ """
+ fragment = Fragment()
+ context = {'fields': []}
+ # Build a list of all the fields that can be edited:
+ for field_name in self.editable_fields:
+ field = self.fields[field_name]
+ assert field.scope in (Scope.content, Scope.settings), (
+ "Only Scope.content or Scope.settings fields can be used with "
+ "StudioEditableXBlockMixin. Other scopes are for user-specific data and are "
+ "not generally created/configured by content authors in Studio."
+ )
+ field_info = self._make_field_info(field_name, field)
+ if field_info is not None:
+ context["fields"].append(field_info)
+ fragment.content = loader.render_django_template('templates/studio_edit.html', context)
+ fragment.add_javascript(loader.load_unicode('public/studio_edit.js'))
+ fragment.initialize_js('StudioEditableXBlockMixin')
+ return fragment
+
+ def _make_field_info(self, field_name, field): # pylint: disable=too-many-statements
+ """
+ Create the information that the template needs to render a form field for this field.
+ """
+ supported_field_types = (
+ (Integer, 'integer'),
+ (Float, 'float'),
+ (Boolean, 'boolean'),
+ (String, 'string'),
+ (List, 'list'),
+ (DateTime, 'datepicker'),
+ (JSONField, 'generic'), # This is last so as a last resort we display a text field w/ the JSON string
+ )
+ if self.service_declaration("i18n"):
+ ugettext = self.ugettext
+ else:
+
+ def ugettext(text):
+ """ Dummy ugettext method that doesn't do anything """
+ return text
+
+ info = {
+ 'name': field_name,
+ # pylint: disable=translation-of-non-string
+ 'display_name': ugettext(field.display_name) if field.display_name else "",
+ 'is_set': field.is_set_on(self),
+ 'default': field.default,
+ 'value': field.read_from(self),
+ 'has_values': False,
+ # pylint: disable=translation-of-non-string
+ 'help': ugettext(field.help) if field.help else "",
+ 'allow_reset': field.runtime_options.get('resettable_editor', True),
+ 'list_values': None, # Only available for List fields
+ 'has_list_values': False, # True if list_values_provider exists, even if it returned no available options
+ }
+ for type_class, type_name in supported_field_types:
+ if isinstance(field, type_class):
+ info['type'] = type_name
+ # If String fields are declared like String(..., multiline_editor=True), then call them "text" type:
+ editor_type = field.runtime_options.get('multiline_editor')
+ if type_class is String and editor_type:
+ if editor_type == "html":
+ info['type'] = 'html'
+ else:
+ info['type'] = 'text'
+ if type_class is List and field.runtime_options.get('list_style') == "set":
+ # List represents unordered, unique items, optionally drawn from list_values_provider()
+ info['type'] = 'set'
+ elif type_class is List:
+ info['type'] = "generic" # disable other types of list for now until properly implemented
+ break
+ if "type" not in info:
+ raise NotImplementedError("StudioEditableXBlockMixin currently only supports fields derived from JSONField")
+ if info["type"] in ("list", "set"):
+ info["value"] = [json.dumps(val) for val in info["value"]]
+ info["default"] = json.dumps(info["default"])
+ elif info["type"] == "generic":
+ # Convert value to JSON string if we're treating this field generically:
+ info["value"] = json.dumps(info["value"])
+ info["default"] = json.dumps(info["default"])
+ elif info["type"] == "datepicker":
+ if info["value"]:
+ info["value"] = info["value"].strftime("%m/%d/%Y")
+ if info["default"]:
+ info["default"] = info["default"].strftime("%m/%d/%Y")
+
+ if 'values_provider' in field.runtime_options:
+ values = field.runtime_options["values_provider"](self)
+ else:
+ values = field.values
+ if values and not isinstance(field, Boolean):
+ # This field has only a limited number of pre-defined options.
+ # Protip: when defining the field, values= can be a callable.
+ if isinstance(field.values, dict) and isinstance(field, (Float, Integer)):
+ # e.g. {"min": 0 , "max": 10, "step": .1}
+ for option in field.values:
+ if option in ("min", "max", "step"):
+ info[option] = field.values.get(option)
+ else:
+ raise KeyError("Invalid 'values' key. Should be like values={'min': 1, 'max': 10, 'step': 1}")
+ elif isinstance(values[0], dict) and "display_name" in values[0] and "value" in values[0]:
+ # e.g. [ {"display_name": "Always", "value": "always"}, ... ]
+ for value in values:
+ assert "display_name" in value and "value" in value
+ info['values'] = values
+ else:
+ # e.g. [1, 2, 3] - we need to convert it to the [{"display_name": x, "value": x}] format
+ info['values'] = [{"display_name": str(val), "value": val} for val in values]
+ info['has_values'] = 'values' in info
+ if info["type"] in ("list", "set") and field.runtime_options.get('list_values_provider'):
+ list_values = field.runtime_options['list_values_provider'](self)
+ # list_values must be a list of values or {"display_name": x, "value": y} objects
+ # Furthermore, we need to convert all values to JSON since they could be of any type
+ if list_values and isinstance(list_values[0], dict) and "display_name" in list_values[0]:
+ # e.g. [ {"display_name": "Always", "value": "always"}, ... ]
+ for entry in list_values:
+ assert "display_name" in entry and "value" in entry
+ entry["value"] = json.dumps(entry["value"])
+ else:
+ # e.g. [1, 2, 3] - we need to convert it to the [{"display_name": x, "value": x}] format
+ list_values = [json.dumps(val) for val in list_values]
+ list_values = [{"display_name": str(val), "value": val} for val in list_values]
+ info['list_values'] = list_values
+ info['has_list_values'] = True
+ return info
+
+ @XBlock.json_handler
+ def submit_studio_edits(self, data, suffix=''): # pylint: disable=unused-argument
+ """
+ AJAX handler for studio_view() Save button
+ """
+ values = {} # dict of new field values we are updating
+ to_reset = [] # list of field names to delete from this XBlock
+ for field_name in self.editable_fields:
+ field = self.fields[field_name]
+ if field_name in data['values']:
+ if isinstance(field, JSONField):
+ values[field_name] = field.from_json(data['values'][field_name])
+ else:
+ raise JsonHandlerError(400, f"Unsupported field type: {field_name}")
+ elif field_name in data['defaults'] and field.is_set_on(self):
+ to_reset.append(field_name)
+ self.clean_studio_edits(values)
+ validation = Validation(self.scope_ids.usage_id)
+ # We cannot set the fields on self yet, because even if validation fails, studio is going to save any changes we
+ # make. So we create a "fake" object that has all the field values we are about to set.
+ preview_data = FutureFields(
+ new_fields_dict=values,
+ newly_removed_fields=to_reset,
+ fallback_obj=self
+ )
+ self.validate_field_data(validation, preview_data)
+ if validation:
+ for field_name, value in values.items():
+ setattr(self, field_name, value)
+ for field_name in to_reset:
+ self.fields[field_name].delete_from(self)
+ return {'result': 'success'}
+ else:
+ raise JsonHandlerError(400, validation.to_json())
+
+ def clean_studio_edits(self, data):
+ """
+ Given POST data dictionary 'data', clean the data before validating it.
+ e.g. fix capitalization, remove trailing spaces, etc.
+ """
+ # Example:
+ # if "name" in data:
+ # data["name"] = data["name"].strip()
+
+ def validate_field_data(self, validation, data):
+ """
+ Validate this block's field data. Instead of checking fields like self.name, check the
+ fields set on data, e.g. data.name. This allows the same validation method to be re-used
+ for the studio editor. Any errors found should be added to "validation".
+
+ This method should not return any value or raise any exceptions.
+ All of this XBlock's fields should be found in "data", even if they aren't being changed
+ or aren't even set (i.e. are defaults).
+ """
+ # Example:
+ # if data.count <=0:
+ # validation.add(ValidationMessage(ValidationMessage.ERROR, u"Invalid count"))
+
+ def validate(self):
+ """
+ Validates the state of this XBlock.
+
+ Subclasses should override validate_field_data() to validate fields and override this
+ only for validation not related to this block's field values.
+ """
+ validation = super().validate()
+ self.validate_field_data(validation, self)
+ return validation
+
+
+@XBlock.needs('mako')
+class StudioContainerXBlockMixin(XBlockMixin):
+ """
+ An XBlock mixin to provide convenient use of an XBlock in Studio
+ that wants to allow the user to assign children to it.
+ """
+ has_author_view = True # Without this flag, studio will use student_view on newly-added blocks :/
+
+ def render_children(self, context, fragment, can_reorder=True, can_add=False):
+ """
+ Renders the children of the module with HTML appropriate for Studio. If can_reorder is
+ True, then the children will be rendered to support drag and drop.
+ """
+ contents = []
+
+ child_context = {'reorderable_items': set()}
+ if context:
+ child_context.update(context)
+
+ for child_id in self.children:
+ child = self.runtime.get_block(child_id)
+ if can_reorder:
+ child_context['reorderable_items'].add(child.scope_ids.usage_id)
+ view_to_render = 'author_view' if hasattr(child, 'author_view') else 'student_view'
+ rendered_child = child.render(view_to_render, child_context)
+ fragment.add_fragment_resources(rendered_child)
+
+ contents.append({
+ 'id': str(child.scope_ids.usage_id),
+ 'content': rendered_child.content
+ })
+
+ mako_service = self.runtime.service(self, 'mako')
+ # 'lms.' namespace_prefix is required for rendering in studio
+ mako_service.namespace_prefix = 'lms.'
+ fragment.add_content(mako_service.render_template("studio_render_children_view.html", {
+ 'items': contents,
+ 'xblock_context': context,
+ 'can_add': can_add,
+ 'can_reorder': can_reorder,
+ }))
+
+ def author_view(self, context):
+ """
+ Display a the studio editor when the user has clicked "View" to see the container view,
+ otherwise just show the normal 'author_preview_view' or 'student_view' preview.
+ """
+ root_xblock = context.get('root_xblock')
+
+ if root_xblock and root_xblock.location == self.location:
+ # User has clicked the "View" link. Show an editable preview of this block's children
+ return self.author_edit_view(context)
+ return self.author_preview_view(context)
+
+ def author_edit_view(self, context):
+ """
+ Child blocks can override this to control the view shown to authors in Studio when
+ editing this block's children.
+ """
+ fragment = Fragment()
+ self.render_children(context, fragment, can_reorder=True, can_add=False)
+ return fragment
+
+ def author_preview_view(self, context):
+ """
+ Child blocks can override this to add a custom preview shown to authors in Studio when
+ not editing this block's children.
+ """
+ return self.student_view(context)
+
+
+class NestedXBlockSpec:
+ """
+ Class that allows detailed specification of allowed nested XBlocks. For use with
+ StudioContainerWithNestedXBlocksMixin.allowed_nested_blocks
+ """
+ def __init__(
+ self, block, single_instance=False, disabled=False, disabled_reason=None, boilerplate=None,
+ category=None, label=None,
+ ):
+ self._block = block
+ self._single_instance = single_instance
+ self._disabled = disabled
+ self._disabled_reason = disabled_reason
+ self._boilerplate = boilerplate
+ # Some blocks may not be nesting-aware, but can be nested anyway with a bit of help.
+ # For example, if you wanted to include an XBlock from a different project that didn't
+ # yet use XBlock utils, you could specify the category and studio label here.
+ self._category = category
+ self._label = label
+
+ @property
+ def category(self):
+ """ Block category - used as a computer-readable name of an XBlock """
+ return self._category or self._block.CATEGORY
+
+ @property
+ def label(self):
+ """ Block label - used as human-readable name of an XBlock """
+ return self._label or self._block.STUDIO_LABEL
+
+ @property
+ def single_instance(self):
+ """ If True, only allow single nested instance of Xblock """
+ return self._single_instance
+
+ @property
+ def disabled(self):
+ """
+ If True, renders add buttons disabled - only use when XBlock can't be added at all (i.e. not available).
+ To allow single instance of XBlock use single_instance property
+ """
+ return self._disabled
+
+ @property
+ def disabled_reason(self):
+ """
+ If block is disabled this property is used as add button title, giving some hint about why it is disabled
+ """
+ return self._disabled_reason
+
+ @property
+ def boilerplate(self):
+ """ Boilerplate - if not None and not empty used as data-boilerplate attribute value """
+ return self._boilerplate
+
+
+class XBlockWithPreviewMixin:
+ """
+ An XBlock mixin providing simple preview view. It is to be used with StudioContainerWithNestedXBlocksMixin to
+ avoid adding studio wrappers (title, edit button, etc.) to a block when it is rendered as child in parent's
+ author_preview_view
+ """
+ def preview_view(self, context):
+ """
+ Preview view - used by StudioContainerWithNestedXBlocksMixin to render nested xblocks in preview context.
+ Default implementation uses author_view if available, otherwise falls back to student_view
+ Child classes can override this method to control their presentation in preview context
+ """
+ view_to_render = 'author_view' if hasattr(self, 'author_view') else 'student_view'
+ renderer = getattr(self, view_to_render)
+ return renderer(context)
+
+
+class StudioContainerWithNestedXBlocksMixin(StudioContainerXBlockMixin):
+ """
+ An XBlock mixin providing interface for specifying allowed nested blocks and adding/previewing them in Studio.
+ """
+ has_children = True
+ CHILD_PREVIEW_TEMPLATE = "templates/default_preview_view.html"
+
+ @property
+ def loader(self):
+ """
+ Loader for loading and rendering assets stored in child XBlock package
+ """
+ return loader
+
+ @property
+ def allowed_nested_blocks(self):
+ """
+ Returns a list of allowed nested XBlocks. Each item can be either
+ * An XBlock class
+ * A NestedXBlockSpec
+
+ If XBlock class is used it is assumed that this XBlock is enabled and allows multiple instances.
+ NestedXBlockSpec allows explicitly setting disabled/enabled state, disabled reason (if any) and single/multiple
+ instances
+ """
+ return []
+
+ def get_nested_blocks_spec(self):
+ """
+ Converts allowed_nested_blocks items to NestedXBlockSpec to provide common interface
+ """
+ return [
+ block_spec if isinstance(block_spec, NestedXBlockSpec) else NestedXBlockSpec(block_spec)
+ for block_spec in self.allowed_nested_blocks
+ ]
+
+ def author_edit_view(self, context):
+ """
+ View for adding/editing nested blocks
+ """
+ fragment = Fragment()
+
+ if 'wrap_children' in context:
+ fragment.add_content(context['wrap_children']['head'])
+
+ self.render_children(context, fragment, can_reorder=True, can_add=False)
+
+ if 'wrap_children' in context:
+ fragment.add_content(context['wrap_children']['tail'])
+ fragment.add_content(
+ loader.render_django_template(
+ 'templates/add_buttons.html',
+ {'child_blocks': self.get_nested_blocks_spec()}
+ )
+ )
+ fragment.add_javascript(loader.load_unicode('public/studio_container.js'))
+ fragment.initialize_js('StudioContainerXBlockWithNestedXBlocksMixin')
+ return fragment
+
+ def author_preview_view(self, context):
+ """
+ View for previewing contents in studio.
+ """
+ children_contents = []
+
+ fragment = Fragment()
+ for child_id in self.children:
+ child = self.runtime.get_block(child_id)
+ child_fragment = self._render_child_fragment(child, context, 'preview_view')
+ fragment.add_fragment_resources(child_fragment)
+ children_contents.append(child_fragment.content)
+
+ render_context = {
+ 'block': self,
+ 'children_contents': children_contents
+ }
+ render_context.update(context)
+ fragment.add_content(self.loader.render_django_template(self.CHILD_PREVIEW_TEMPLATE, render_context))
+ return fragment
+
+ def _render_child_fragment(self, child, context, view='student_view'):
+ """
+ Helper method to overcome html block rendering quirks
+ """
+ try:
+ child_fragment = child.render(view, context)
+ except NoSuchViewError:
+ if child.scope_ids.block_type == 'html' and getattr(self.runtime, 'is_author_mode', False):
+ # html block doesn't support preview_view, and if we use student_view Studio will wrap
+ # it in HTML that we don't want in the preview. So just render its HTML directly:
+ child_fragment = Fragment(child.data)
+ else:
+ child_fragment = child.render('student_view', context)
+
+ return child_fragment
diff --git a/xblockutils/studio_editable_test.py b/xblockutils/studio_editable_test.py
new file mode 100644
index 000000000..0b99cc498
--- /dev/null
+++ b/xblockutils/studio_editable_test.py
@@ -0,0 +1,79 @@
+"""
+Tests for StudioEditableXBlockMixin
+"""
+
+from selenium.webdriver.support.ui import WebDriverWait
+from xblockutils.base_test import SeleniumXBlockTest
+
+
+class CommonBaseTest(SeleniumXBlockTest):
+ """
+ Base class of StudioEditableBaseTest and StudioContainerWithNestedXBlocksBaseTest
+ """
+ def fix_js_environment(self):
+ """ Make the Workbench JS runtime more compatibile with Studio's """
+ # Mock gettext()
+ self.browser.execute_script('window.gettext = function(t) { return t; };')
+ # Mock runtime.notify() so we can watch for notify events like 'save'
+ self.browser.execute_script(
+ 'window.notifications = [];'
+ 'window.RuntimeProvider.getRuntime(1).notify = function() {'
+ ' window.notifications.push(arguments);'
+ '};'
+ )
+
+ def dequeue_runtime_notification(self, wait_first=True):
+ """
+ Return the oldest call from JavaScript to block.runtime.notify() that we haven't yet
+ seen here in Python-land. Waits for a notification unless wait_first is False.
+ """
+ if wait_first:
+ self.wait_for_runtime_notification()
+ return self.browser.execute_script('return window.notifications.shift();')
+
+ def wait_for_runtime_notification(self):
+ """ Wait until runtime.notify() has been called """
+ wait = WebDriverWait(self.browser, self.timeout)
+ wait.until(lambda driver: driver.execute_script('return window.notifications.length > 0;'))
+
+
+class StudioEditableBaseTest(CommonBaseTest):
+ """
+ Base class that can be used for integration tests of any XBlocks that use
+ StudioEditableXBlockMixin
+ """
+ def click_save(self, expect_success=True):
+ """ Click on the save button """
+ # Click 'Save':
+ self.browser.find_element_by_link_text('Save').click()
+ # Before saving the block changes, the runtime should get a 'save' notice:
+ notification = self.dequeue_runtime_notification()
+ self.assertEqual(notification[0], "save")
+ self.assertEqual(notification[1]["state"], "start")
+ if expect_success:
+ notification = self.dequeue_runtime_notification()
+ self.assertEqual(notification[0], "save")
+ self.assertEqual(notification[1]["state"], "end")
+
+ def get_element_for_field(self, field_name):
+ """ Given the name of a field, return the DOM element for its form control """
+ selector = f"li.field[data-field-name={field_name}] .field-data-control"
+ return self.browser.find_element_by_css_selector(selector)
+
+ def click_reset_for_field(self, field_name):
+ """ Click the reset button next to the specified setting field """
+ selector = f"li.field[data-field-name={field_name}] .setting-clear"
+ self.browser.find_element_by_css_selector(selector).click()
+
+
+class StudioContainerWithNestedXBlocksBaseTest(CommonBaseTest):
+ """
+ Base class that can be used for integration tests of any XBlocks that use
+ StudioContainerWithNestedXBlocksMixin
+ """
+ def get_add_buttons(self):
+ """
+ Gets add buttons in author view
+ """
+ selector = ".add-xblock-component .new-component a.add-xblock-component-button"
+ return self.browser.find_elements_by_css_selector(selector)
diff --git a/xblockutils/templates/add_buttons.html b/xblockutils/templates/add_buttons.html
new file mode 100644
index 000000000..f9b133210
--- /dev/null
+++ b/xblockutils/templates/add_buttons.html
@@ -0,0 +1,22 @@
+{% load i18n %}
+
+
+
+
{% trans "Add New Component" %}
+
+
+
diff --git a/xblockutils/templates/default_preview_view.html b/xblockutils/templates/default_preview_view.html
new file mode 100644
index 000000000..d18c90359
--- /dev/null
+++ b/xblockutils/templates/default_preview_view.html
@@ -0,0 +1,3 @@
+
+ {% for child_content in children_contents %} {{ child_content|safe }} {% endfor %}
+
\ No newline at end of file
diff --git a/xblockutils/templates/studio_edit.html b/xblockutils/templates/studio_edit.html
new file mode 100644
index 000000000..d75818218
--- /dev/null
+++ b/xblockutils/templates/studio_edit.html
@@ -0,0 +1,113 @@
+{% load i18n %}
+
diff --git a/xblockutils/templatetags/__init__.py b/xblockutils/templatetags/__init__.py
new file mode 100644
index 000000000..e69de29bb
diff --git a/xblockutils/templatetags/i18n.py b/xblockutils/templatetags/i18n.py
new file mode 100644
index 000000000..90ab8a83d
--- /dev/null
+++ b/xblockutils/templatetags/i18n.py
@@ -0,0 +1,74 @@
+"""
+Template tags for handling i18n translations for xblocks
+
+Based on: https://github.com/eduNEXT/django-xblock-i18n
+"""
+
+from contextlib import contextmanager
+
+from django.template import Library, Node
+from django.templatetags import i18n
+from django.utils.translation import get_language, trans_real
+
+
+register = Library()
+
+
+class ProxyTransNode(Node):
+ """
+ This node is a proxy of a django TranslateNode.
+ """
+ def __init__(self, do_translate_node):
+ """
+ Initialize the ProxyTransNode
+ """
+ self.do_translate = do_translate_node
+ self._translations = {}
+
+ @contextmanager
+ def merge_translation(self, context):
+ """
+ Context wrapper which modifies the given language's translation catalog using the i18n service, if found.
+ """
+ language = get_language()
+ i18n_service = context.get('_i18n_service', None)
+ if i18n_service:
+ # Cache the original translation object to reduce overhead
+ if language not in self._translations:
+ self._translations[language] = trans_real.DjangoTranslation(language)
+
+ translation = trans_real.translation(language)
+ translation.merge(i18n_service)
+
+ yield
+
+ # Revert to original translation object
+ if language in self._translations:
+ trans_real._translations[language] = self._translations[language] # pylint: disable=protected-access
+ # Re-activate the current language to reset translation caches
+ trans_real.activate(language)
+
+ def render(self, context):
+ """
+ Renders the translated text using the XBlock i18n service, if available.
+ """
+ with self.merge_translation(context):
+ django_translated = self.do_translate.render(context)
+
+ return django_translated
+
+
+@register.tag('trans')
+def xblock_translate(parser, token):
+ """
+ Proxy implementation of the i18n `trans` tag.
+ """
+ return ProxyTransNode(i18n.do_translate(parser, token))
+
+
+@register.tag('blocktrans')
+def xblock_translate_block(parser, token):
+ """
+ Proxy implementation of the i18n `blocktrans` tag.
+ """
+ return ProxyTransNode(i18n.do_block_translate(parser, token))
diff --git a/xblockutils/tests/__init__.py b/xblockutils/tests/__init__.py
new file mode 100644
index 000000000..e69de29bb
diff --git a/xblockutils/tests/integration/__init__.py b/xblockutils/tests/integration/__init__.py
new file mode 100644
index 000000000..e69de29bb
diff --git a/xblockutils/tests/integration/template_stubs/studio_render_children_view.html b/xblockutils/tests/integration/template_stubs/studio_render_children_view.html
new file mode 100644
index 000000000..f0be9e416
--- /dev/null
+++ b/xblockutils/tests/integration/template_stubs/studio_render_children_view.html
@@ -0,0 +1,15 @@
+{% if can_reorder %}
+
+{% endif %}
+ {% for item in items %}
+
+
+ {{ item.content|safe }}
+
+ {% endfor %}
+{% if can_reorder %}
+
+{% endif %}
+{% if can_add %}
+
+{% endif %}
diff --git a/xblockutils/tests/integration/test_base_test.py b/xblockutils/tests/integration/test_base_test.py
new file mode 100644
index 000000000..b37b5bbd2
--- /dev/null
+++ b/xblockutils/tests/integration/test_base_test.py
@@ -0,0 +1,38 @@
+#
+# Copyright (C) 2014-2015 edX
+#
+# This software's license gives you freedom; you can copy, convey,
+# propagate, redistribute and/or modify this program under the terms of
+# the GNU Affero General Public License (AGPL) as published by the Free
+# Software Foundation (FSF), either version 3 of the License, or (at your
+# option) any later version of the AGPL published by the FSF.
+#
+# This program is distributed in the hope that it will be useful, but
+# WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero
+# General Public License for more details.
+#
+# You should have received a copy of the GNU Affero General Public License
+# along with this program in a file in the toplevel directory called
+# "AGPLv3". If not, see .
+#
+
+
+import unittest
+
+from xblockutils.base_test import SeleniumBaseTest
+
+
+class TestSeleniumBaseTest(SeleniumBaseTest):
+ module_name = __name__
+ default_css_selector = "div.vertical"
+
+ def test_true(self):
+ self.go_to_page("Simple Scenario")
+
+
+class TestSeleniumBaseTestWithoutDefaultSelector(SeleniumBaseTest):
+ module_name = __name__
+
+ def test_true(self):
+ self.go_to_page("Simple Scenario", "div.vertical")
diff --git a/xblockutils/tests/integration/test_studio_editable.py b/xblockutils/tests/integration/test_studio_editable.py
new file mode 100644
index 000000000..87ff6397d
--- /dev/null
+++ b/xblockutils/tests/integration/test_studio_editable.py
@@ -0,0 +1,525 @@
+import datetime
+import textwrap
+from unittest import mock
+import pytz
+
+from django.conf import settings
+from django.test import modify_settings
+from selenium.common.exceptions import NoSuchElementException
+from xblock.core import XBlock
+from xblock.fields import Boolean, Dict, Float, Integer, List, String, DateTime
+from web_fragments.fragment import Fragment
+from xblock.validation import ValidationMessage
+from xblockutils.tests.integration.utils import render_template
+from xblockutils.studio_editable import StudioEditableXBlockMixin, StudioContainerWithNestedXBlocksMixin, \
+ NestedXBlockSpec
+from xblockutils.studio_editable_test import StudioEditableBaseTest, StudioContainerWithNestedXBlocksBaseTest
+
+
+class EditableXBlock(StudioEditableXBlockMixin, XBlock):
+ """
+ A basic Studio-editable XBlock (for use in tests)
+ """
+ CATEGORY = "editable"
+ STUDIO_LABEL = "Editable Block"
+
+ color = String(default="red")
+ count = Integer(default=42)
+ comment = String(default="")
+ date = DateTime(default=datetime.datetime(2014, 5, 14, tzinfo=pytz.UTC))
+ editable_fields = ('color', 'count', 'comment', 'date')
+
+ def student_view(self, context):
+ return Fragment()
+
+ def validate_field_data(self, validation, data):
+ """
+ A validation method to check that 'count' is positive and prevent
+ swearing in the 'comment' field.
+ """
+ if data.count < 0:
+ validation.add(ValidationMessage(ValidationMessage.ERROR, "Count cannot be negative"))
+ if "damn" in data.comment.lower():
+ validation.add(ValidationMessage(ValidationMessage.ERROR, "No swearing allowed"))
+
+
+class UnawareXBlock(XBlock):
+ """
+ A naive XBlock for use in tests
+ """
+
+ color = String(default="red")
+
+ def student_view(self, context):
+ return Fragment()
+
+
+class TestEditableXBlock_StudioView(StudioEditableBaseTest):
+ """
+ Test the Studio View created for EditableXBlock
+ """
+
+ def set_up_root_block(self):
+ self.set_scenario_xml('')
+ self.go_to_view("studio_view")
+ self.fix_js_environment()
+ return self.load_root_xblock()
+
+ def assert_unchanged(self, block, orig_field_values=None, explicitly_set=False):
+ """
+ Check that all field values on 'block' match with either the value in orig_field_values
+ (if provided) or the default value.
+ If 'explitly_set' is False (default) it asserts that no fields have an explicit value
+ set. If 'explititly_set' is True it expects all fields to be explicitly set.
+ """
+ for field_name in block.editable_fields:
+ expected_value = orig_field_values[field_name] if orig_field_values else block.fields[field_name].default
+ self.assertEqual(getattr(block, field_name), expected_value)
+ self.assertEqual(block.fields[field_name].is_set_on(block), explicitly_set)
+
+ @XBlock.register_temp_plugin(EditableXBlock, "editable")
+ def test_no_changes_with_defaults(self):
+ """
+ If we load the edit form and then save right away, there should be no changes.
+ """
+ block = self.set_up_root_block()
+ orig_values = {field_name: getattr(block, field_name) for field_name in EditableXBlock.editable_fields}
+ self.click_save()
+ self.assert_unchanged(block, orig_values)
+
+ @XBlock.register_temp_plugin(EditableXBlock, "editable")
+ def test_no_changes_with_values_set(self):
+ """
+ If the XBlock already has explicit values set, and we load the edit form and then save
+ right away, there should be no changes.
+ """
+ block = self.set_up_root_block()
+ block.color = "green"
+ block.count = 5
+ block.comment = "Hello"
+ block.date = datetime.datetime(2014, 6, 17, tzinfo=pytz.UTC)
+ block.save()
+ orig_values = {field_name: getattr(block, field_name) for field_name in EditableXBlock.editable_fields}
+ # Reload the page:
+ self.go_to_view("studio_view")
+ self.fix_js_environment()
+
+ self.click_save()
+ block = self.load_root_xblock() # Need to reload the block to bypass its cache
+ self.assert_unchanged(block, orig_values, explicitly_set=True)
+
+ @XBlock.register_temp_plugin(EditableXBlock, "editable")
+ def test_no_i18n(self):
+ """
+ Test that the studio_view doesn't call block.ugettext since the block hasn't indicated
+ @XBlock.wants("i18n") or @XBlock.needs("i18n")
+ """
+ with mock.patch.object(EditableXBlock, "ugettext") as mock_ugettext:
+ self.set_up_root_block()
+ mock_ugettext.assert_not_called()
+
+ @XBlock.register_temp_plugin(EditableXBlock, "editable")
+ def test_explicit_overrides(self):
+ """
+ Test that we can override the defaults with the same value as the default, and that the
+ value will be saved explicitly.
+ """
+ block = self.set_up_root_block()
+ self.assert_unchanged(block)
+
+ field_names = EditableXBlock.editable_fields
+ # It is crucial to this test that at least one of the fields is a String field with
+ # an empty string as its default value:
+ defaults = {block.fields[field_name].default for field_name in field_names}
+ self.assertIn('', defaults)
+
+ for field_name in field_names:
+ control = self.get_element_for_field(field_name)
+ control.send_keys('9999') # In case the field is blank and the new value is blank, this forces a change
+ control.clear()
+ control.send_keys(str(block.fields[field_name].default))
+
+ self.click_save()
+ self.assert_unchanged(block, explicitly_set=True)
+
+ @XBlock.register_temp_plugin(EditableXBlock, "editable")
+ def test_set_and_reset(self):
+ """
+ Test that we can set values, save, then reset to defaults.
+ """
+ block = self.set_up_root_block()
+ self.assert_unchanged(block)
+
+ for field_name in EditableXBlock.editable_fields:
+ if field_name == 'date':
+ continue
+ color_control = self.get_element_for_field(field_name)
+ color_control.clear()
+ color_control.send_keys('1000')
+
+ date_control = self.get_element_for_field('date')
+ date_control.clear()
+ date_control.send_keys("7/5/2015")
+
+ self.click_save()
+
+ block = self.load_root_xblock() # Need to reload the block to bypass its cache
+
+ self.assertEqual(block.color, '1000')
+ self.assertEqual(block.count, 1000)
+ self.assertEqual(block.comment, '1000')
+ self.assertEqual(block.date, datetime.datetime(2015, 7, 5, 0, 0, 0, tzinfo=pytz.UTC))
+
+ for field_name in EditableXBlock.editable_fields:
+ self.click_reset_for_field(field_name)
+
+ self.click_save()
+ block = self.load_root_xblock() # Need to reload the block to bypass its cache
+ self.assert_unchanged(block)
+
+ @XBlock.register_temp_plugin(EditableXBlock, "editable")
+ def test_invalid_data(self):
+ """
+ Test that we get notified when there's a problem with our data.
+ """
+ def expect_error_message(expected_message):
+ notification = self.dequeue_runtime_notification()
+ self.assertEqual(notification[0], "error")
+ self.assertEqual(notification[1]["title"], "Unable to update settings")
+ self.assertEqual(notification[1]["message"], expected_message)
+
+ block = self.set_up_root_block()
+
+ color_control = self.get_element_for_field('color')
+ color_control.clear()
+ color_control.send_keys('orange')
+
+ count_control = self.get_element_for_field('count')
+ count_control.clear()
+ count_control.send_keys('-10')
+
+ comment_control = self.get_element_for_field('comment')
+ comment_control.send_keys("That's a damn shame.")
+
+ self.click_save(expect_success=False)
+ expect_error_message("Count cannot be negative, No swearing allowed")
+ self.assert_unchanged(self.load_root_xblock())
+
+ count_control.clear()
+ count_control.send_keys('10')
+
+ self.click_save(expect_success=False)
+ expect_error_message("No swearing allowed")
+ self.assert_unchanged(self.load_root_xblock())
+
+ comment_control.clear()
+
+ self.click_save()
+
+
+def fancy_list_values_provider_a(block):
+ return [1, 2, 3, 4, 5]
+
+
+def fancy_list_values_provider_b(block):
+ return [{"display_name": "Robert", "value": "bob"}, {"display_name": "Alexandra", "value": "alex"}]
+
+
+@XBlock.wants("i18n")
+class FancyXBlock(StudioEditableXBlockMixin, XBlock):
+ """
+ A Studio-editable XBlock with lots of fields and fancy features
+ """
+ bool_normal = Boolean(display_name="Normal Boolean Field")
+ dict_normal = Dict(display_name="Normal Dictionary Field")
+ float_normal = Float(display_name="Normal Float Field")
+ float_values = Float(display_name="Float Field With Values", values=(0, 2.718281, 3.14159,), default=0)
+ int_normal = Integer(display_name="Normal Integer Field")
+ int_ranged = Integer(display_name="Ranged Integer Field", values={"min": 0, "max": 10, "step": 2})
+ int_dynamic = Integer(
+ display_name="Integer Field With Dynamic Values",
+ default=0,
+ values=lambda: list(range(0, 10, 2))
+ )
+ int_values = Integer(
+ display_name="Integer Field With Named Values",
+ default=0,
+ values=(
+ {"display_name": "Yes", "value": 1},
+ {"display_name": "No", "value": 0},
+ {"display_name": "Maybe So", "value": -1},
+ )
+ )
+ list_normal = List(display_name="Normal List")
+ list_intdefs = List(display_name="Integer List With Default", default=[1, 2, 3, 4, 5])
+ list_strdefs = List(display_name="String List With Default", default=['1', '2', '3', '4', '5'])
+
+ list_set_ints = List(
+ display_name="Int List (Set)",
+ list_style="set",
+ list_values_provider=fancy_list_values_provider_a,
+ default=[1, 3, 5],
+ )
+ list_set_strings = List(
+ display_name="String List (Set)",
+ list_style="set",
+ list_values_provider=fancy_list_values_provider_b,
+ default=["alex"],
+ )
+
+ string_normal = String(display_name="Normal String Field")
+ string_values = String(display_name="String Field With Values", default="A", values=("A", "B", "C", "D"))
+ string_values_provider = String(
+ display_name="String Field With Dynamic Values",
+ default="",
+ values_provider=lambda self: [str(self.scope_ids.usage_id), ""],
+ )
+ string_named = String(
+ display_name="String Field With Named Values",
+ default="AB",
+ values=(
+ {"display_name": "Alberta", "value": "AB"},
+ {"display_name": "British Columbia", "value": "BC"},
+ )
+ )
+ string_dynamic = String(
+ display_name="String Field With Dynamic Values",
+ default="",
+ values=lambda: [""] + [letter for letter in "AEIOU"]
+ )
+ string_multiline = String(display_name="Multiline", multiline_editor=True, allow_reset=False)
+ string_multiline_reset = String(display_name="Multiline", multiline_editor=True)
+ string_html = String(display_name="Multiline", multiline_editor='html', allow_reset=False)
+ string_with_help = String(
+ display_name="String Field with Help Text",
+ help="Learn more about colors."
+ )
+ # Note: The HTML editor won't work in Workbench because it depends on Studio's TinyMCE
+
+ editable_fields = (
+ 'bool_normal', 'dict_normal', 'float_normal', 'float_values', 'int_normal', 'int_ranged', 'int_dynamic',
+ 'int_values', 'list_normal', 'list_intdefs', 'list_strdefs', 'list_set_ints', 'list_set_strings',
+ 'string_normal', 'string_values', 'string_values_provider', 'string_named', 'string_dynamic',
+ 'string_multiline', 'string_multiline_reset', 'string_html', 'string_with_help'
+ )
+
+ def student_view(self, context):
+ return Fragment()
+
+
+class TestFancyXBlock_StudioView(StudioEditableBaseTest):
+ """
+ Test the Studio View created for FancyXBlock
+ """
+
+ def set_up_root_block(self):
+ self.set_scenario_xml('')
+ self.go_to_view("studio_view")
+ self.fix_js_environment()
+ return self.load_root_xblock()
+
+ @XBlock.register_temp_plugin(FancyXBlock, "fancy")
+ def test_i18n(self):
+ """
+ Test that field names and help text get translated using the XBlock runtime's ugettext
+ method.
+ """
+ with mock.patch.object(FancyXBlock, "ugettext", side_effect=lambda text: text[::-1]):
+ self.set_up_root_block()
+ bool_field = self.browser.find_element_by_css_selector('li[data-field-name=bool_normal]')
+ self.assertIn("Normal Boolean Field"[::-1], bool_field.text)
+ string_with_help_field = self.browser.find_element_by_css_selector('li[data-field-name=string_with_help]')
+ self.assertIn("Learn more about"[::-1], string_with_help_field.text)
+
+ @XBlock.register_temp_plugin(FancyXBlock, "fancy")
+ def test_no_changes_with_defaults(self):
+ """
+ If we load the edit form and then save right away, there should be no changes.
+ """
+ block = self.set_up_root_block()
+ orig_values = {field_name: getattr(block, field_name) for field_name in FancyXBlock.editable_fields}
+ self.click_save()
+ for field_name in FancyXBlock.editable_fields:
+ self.assertEqual(getattr(block, field_name), orig_values[field_name])
+ self.assertFalse(block.fields[field_name].is_set_on(block))
+
+ @XBlock.register_temp_plugin(FancyXBlock, "fancy")
+ def test_no_changes_with_values_set(self):
+ """
+ If the XBlock already has explicit values set, and we load the edit form and then save
+ right away, there should be no changes.
+ """
+ block = self.set_up_root_block()
+ block.bool_normal = True
+ block.dict_normal = {"more": "cowbell"}
+ block.float_normal = 17.0
+ block.float_values = 3.14159
+ block.int_normal = 10
+ block.int_ranged = 8
+ block.int_dynamic = 0
+ block.int_values = -1
+ block.list_normal = []
+ block.list_intdefs = [9, 10, 11]
+ block.list_strdefs = ['H', 'e', 'l', 'l', 'o']
+ block.list_set_ints = [2, 3, 4]
+ block.list_set_strings = ["bob"]
+ block.string_normal = "A"
+ block.string_values = "B"
+ block.string_values_provider = str(block.scope_ids.usage_id)
+ block.string_named = "BC"
+ block.string_dynamic = "U"
+ block.string_multiline = "why\nhello\there"
+ block.string_multiline_reset = "indubitably"
+ block.string_html = "Testing!"
+ block.string_with_help = "Red"
+ block.save()
+
+ orig_values = {field_name: getattr(block, field_name) for field_name in FancyXBlock.editable_fields}
+ # Reload the page:
+ self.go_to_view("studio_view")
+ self.fix_js_environment()
+
+ self.click_save()
+ block = self.load_root_xblock() # Need to reload the block to bypass its cache
+ for field_name in FancyXBlock.editable_fields:
+ self.assertEqual(
+ getattr(block, field_name),
+ orig_values[field_name],
+ f"{field_name} should be unchanged"
+ )
+ self.assertTrue(block.fields[field_name].is_set_on(block))
+
+ @XBlock.register_temp_plugin(FancyXBlock, "fancy")
+ def test_html_in_help(self):
+ """
+ If we include HTML in the help text for a field, the HTML should be displayed in the rendered page
+ """
+ block = self.set_up_root_block()
+ try:
+ self.browser.find_element_by_class_name('field_help_link')
+ except NoSuchElementException:
+ self.fail("HTML anchor tag missing from field help text")
+
+
+class FancyBlockShim:
+ CATEGORY = "fancy"
+ STUDIO_LABEL = "Fancy Block"
+
+
+class XBlockWithNested(StudioContainerWithNestedXBlocksMixin, XBlock):
+ @property
+ def allowed_nested_blocks(self):
+ return [
+ EditableXBlock,
+ NestedXBlockSpec(FancyBlockShim, single_instance=True, boilerplate="fancy-boiler")
+ ]
+
+
+class XBlockWithDisabledNested(StudioContainerWithNestedXBlocksMixin, XBlock):
+ @property
+ def allowed_nested_blocks(self):
+ return [
+ NestedXBlockSpec(EditableXBlock, disabled=True, disabled_reason="Some reason"),
+ NestedXBlockSpec(FancyBlockShim, disabled=False, disabled_reason="Irrelevant")
+ ]
+
+
+class XBlockWithOverriddenNested(StudioContainerWithNestedXBlocksMixin, XBlock):
+ @property
+ def allowed_nested_blocks(self):
+ return [
+ NestedXBlockSpec(UnawareXBlock, category='unaware', label='Unaware Block')
+ ]
+
+
+@modify_settings
+class StudioContainerWithNestedXBlocksTest(StudioContainerWithNestedXBlocksBaseTest):
+ def setUp(self):
+ super().setUp()
+ settings.WORKBENCH.services["mako"] = mock.Mock(render_template=mock.Mock(side_effect=render_template))
+
+ def _check_button(self, button, category, label, single, disabled, disabled_reason='', boilerplate=None):
+ self.assertEqual(button.get_attribute('data-category'), category)
+ self.assertEqual(button.text, label)
+ self.assertEqual(button.get_attribute('data-single-instance'), str(single).lower())
+ self._assert_disabled(button, disabled)
+ self.assertEqual(button.get_attribute('title'), disabled_reason)
+ self.assertEqual(button.get_attribute('data-boilerplate'), boilerplate)
+
+ def _assert_disabled(self, button, disabled):
+ if disabled:
+ self.assertEqual(button.get_attribute('disabled'), 'true')
+ else:
+ self.assertEqual(button.get_attribute('disabled'), None)
+
+ def set_up_root_block(self, scenario, view):
+ self.set_scenario_xml(scenario)
+ self.go_to_view(view)
+ self.fix_js_environment()
+ return self.load_root_xblock()
+
+ @XBlock.register_temp_plugin(XBlockWithNested, "nested")
+ def test_author_edit_view_nested(self):
+ self.set_up_root_block("", "author_edit_view")
+
+ add_buttons = self.get_add_buttons()
+ self.assertEqual(len(add_buttons), 2)
+ button_editable, button_fancy = add_buttons
+ self._check_button(button_editable, EditableXBlock.CATEGORY, EditableXBlock.STUDIO_LABEL, False, False)
+ self._check_button(
+ button_fancy, FancyBlockShim.CATEGORY, FancyBlockShim.STUDIO_LABEL, True, False, boilerplate="fancy-boiler"
+ )
+
+ @XBlock.register_temp_plugin(XBlockWithDisabledNested, "nested")
+ def test_author_edit_view_nested_with_disabled(self):
+ self.set_up_root_block("", "author_edit_view")
+
+ add_buttons = self.get_add_buttons()
+ self.assertEqual(len(add_buttons), 2)
+ button_editable, button_fancy = add_buttons
+ self._check_button(
+ button_editable, EditableXBlock.CATEGORY, EditableXBlock.STUDIO_LABEL, False, True, "Some reason"
+ )
+ self._check_button(button_fancy, FancyBlockShim.CATEGORY, FancyBlockShim.STUDIO_LABEL, False, False)
+
+ @XBlock.register_temp_plugin(XBlockWithNested, "nested")
+ def test_can_add_blocks(self):
+ self.set_up_root_block("", "author_edit_view")
+ button_editable, button_fancy = self.get_add_buttons()
+
+ self._assert_disabled(button_editable, False)
+ button_editable.click()
+ self._assert_disabled(button_editable, False)
+ button_editable.click()
+ self._assert_disabled(button_editable, False)
+
+ self._assert_disabled(button_fancy, False)
+ button_fancy.click()
+ self._assert_disabled(button_fancy, True)
+
+ @XBlock.register_temp_plugin(XBlockWithNested, "nested")
+ @XBlock.register_temp_plugin(FancyXBlock, "fancy")
+ @XBlock.register_temp_plugin(EditableXBlock, "editable")
+ def test_initial_state_with_blocks(self):
+ scenario = textwrap.dedent("""
+
+
+
+
+ """)
+ self.set_up_root_block(scenario, "author_edit_view")
+
+ button_editable, button_fancy = self.get_add_buttons()
+ self._assert_disabled(button_editable, False)
+ self._assert_disabled(button_fancy, True)
+
+ @XBlock.register_temp_plugin(XBlockWithOverriddenNested, "overrider")
+ @XBlock.register_temp_plugin(UnawareXBlock, "unaware")
+ def test_unaware_nested(self):
+ scenario = textwrap.dedent("""
+
+ """)
+ self.set_up_root_block(scenario, "author_edit_view")
+ button_unaware = self.get_add_buttons()[0]
+ self._assert_disabled(button_unaware, False)
+ self.assertEqual(button_unaware.text, 'Unaware Block')
diff --git a/xblockutils/tests/integration/utils.py b/xblockutils/tests/integration/utils.py
new file mode 100644
index 000000000..bb37ff3fd
--- /dev/null
+++ b/xblockutils/tests/integration/utils.py
@@ -0,0 +1,13 @@
+from django.template import Context, Template
+from xblockutils.resources import ResourceLoader
+
+loader = ResourceLoader(__name__)
+
+
+def render_template(template_path, context, **kwargs):
+ file_path = "tests/integration/template_stubs/" + template_path
+
+ with open(file_path) as tpl_file:
+ template_str = tpl_file.read().replace('\n', '')
+ template = Template(template_str)
+ return template.render(Context(context))
diff --git a/xblockutils/tests/integration/xml/simple_scenario.xml b/xblockutils/tests/integration/xml/simple_scenario.xml
new file mode 100644
index 000000000..7261fb363
--- /dev/null
+++ b/xblockutils/tests/integration/xml/simple_scenario.xml
@@ -0,0 +1,5 @@
+
+
+
+
+
diff --git a/xblockutils/tests/unit/__init__.py b/xblockutils/tests/unit/__init__.py
new file mode 100644
index 000000000..e69de29bb
diff --git a/xblockutils/tests/unit/data/__init__.py b/xblockutils/tests/unit/data/__init__.py
new file mode 100644
index 000000000..e69de29bb
diff --git a/xblockutils/tests/unit/data/another_template.xml b/xblockutils/tests/unit/data/another_template.xml
new file mode 100644
index 000000000..df6391b1f
--- /dev/null
+++ b/xblockutils/tests/unit/data/another_template.xml
@@ -0,0 +1 @@
+This is an even simpler xml template.
diff --git a/xblockutils/tests/unit/data/l10n_django_template.txt b/xblockutils/tests/unit/data/l10n_django_template.txt
new file mode 100644
index 000000000..9d796d378
--- /dev/null
+++ b/xblockutils/tests/unit/data/l10n_django_template.txt
@@ -0,0 +1,3 @@
+{% load l10n %}
+{{ 1000|localize }}
+{{ 1000|unlocalize }}
diff --git a/xblockutils/tests/unit/data/simple_django_template.txt b/xblockutils/tests/unit/data/simple_django_template.txt
new file mode 100644
index 000000000..00566d218
--- /dev/null
+++ b/xblockutils/tests/unit/data/simple_django_template.txt
@@ -0,0 +1,14 @@
+This is a simple template example.
+
+This template can make use of the following context variables:
+Name: {{name}}
+List: {{items|safe}}
+
+It can also do some fancy things with them:
+Default value if name is empty: {{name|default:"Default Name"}}
+Length of the list: {{items|length}}
+Items of the list:{% for item in items %} {{item}}{% endfor %}
+
+Although it is simple, it can also contain non-ASCII characters:
+
+Thé Fütüré øf Ønlïné Édüçätïøn Ⱡσяєм ι# Før änýøné, änýwhéré, änýtïmé Ⱡσяєм #
diff --git a/xblockutils/tests/unit/data/simple_mako_template.txt b/xblockutils/tests/unit/data/simple_mako_template.txt
new file mode 100644
index 000000000..4f0a77a47
--- /dev/null
+++ b/xblockutils/tests/unit/data/simple_mako_template.txt
@@ -0,0 +1,18 @@
+This is a simple template example.
+
+This template can make use of the following context variables:
+Name: ${name}
+List: ${items}
+
+It can also do some fancy things with them:
+Default value if name is empty: ${ name or "Default Name"}
+Length of the list: ${len(items)}
+Items of the list:\
+% for item in items:
+ ${item}\
+% endfor
+
+
+Although it is simple, it can also contain non-ASCII characters:
+
+Thé Fütüré øf Ønlïné Édüçätïøn Ⱡσяєм ι# Før änýøné, änýwhéré, änýtïmé Ⱡσяєм #
diff --git a/xblockutils/tests/unit/data/simple_template.xml b/xblockutils/tests/unit/data/simple_template.xml
new file mode 100644
index 000000000..e60fdc535
--- /dev/null
+++ b/xblockutils/tests/unit/data/simple_template.xml
@@ -0,0 +1,6 @@
+
+ This is a simple xml template.
+
+ {{url_name}}
+
+
diff --git a/xblockutils/tests/unit/data/trans_django_template.txt b/xblockutils/tests/unit/data/trans_django_template.txt
new file mode 100644
index 000000000..b144d96f6
--- /dev/null
+++ b/xblockutils/tests/unit/data/trans_django_template.txt
@@ -0,0 +1,8 @@
+{% load i18n %}
+{% trans "Translate 1" %}
+{% trans "Translate 2" as var %}
+{{ var }}
+{% blocktrans %}
+Multi-line translation
+with variable: {{name}}
+{% endblocktrans %}
diff --git a/xblockutils/tests/unit/data/translations/eo/LC_MESSAGES/text.mo b/xblockutils/tests/unit/data/translations/eo/LC_MESSAGES/text.mo
new file mode 100644
index 000000000..d931e6863
Binary files /dev/null and b/xblockutils/tests/unit/data/translations/eo/LC_MESSAGES/text.mo differ
diff --git a/xblockutils/tests/unit/data/translations/eo/LC_MESSAGES/text.po b/xblockutils/tests/unit/data/translations/eo/LC_MESSAGES/text.po
new file mode 100644
index 000000000..81052c559
--- /dev/null
+++ b/xblockutils/tests/unit/data/translations/eo/LC_MESSAGES/text.po
@@ -0,0 +1,29 @@
+msgid ""
+msgstr ""
+"Project-Id-Version: \n"
+"POT-Creation-Date: 2016-03-30 16:54+0500\n"
+"PO-Revision-Date: 2016-03-30 16:54+0500\n"
+"Last-Translator: \n"
+"Language-Team: \n"
+"MIME-Version: 1.0\n"
+"Content-Type: text/plain; charset=UTF-8\n"
+"Content-Transfer-Encoding: 8bit\n"
+"Language: eo\n"
+"Plural-Forms: nplurals=2; plural=(n != 1);\n"
+
+#: data/trans_django_template.txt
+msgid "Translate 1"
+msgstr "tRaNsLaTe !"
+
+#: data/trans_django_template.txt
+msgid "Translate 2"
+msgstr ""
+
+#: data/trans_django_template.txt
+msgid ""
+"\n"
+"Multi-line translation"
+"\n"
+"with variable: %(name)s"
+"\n"
+msgstr "\nmUlTi_LiNe TrAnSlAtIoN: %(name)s\n"
diff --git a/xblockutils/tests/unit/test_helpers.py b/xblockutils/tests/unit/test_helpers.py
new file mode 100644
index 000000000..482431185
--- /dev/null
+++ b/xblockutils/tests/unit/test_helpers.py
@@ -0,0 +1,76 @@
+"""
+Tests for helpers.py
+"""
+
+import unittest
+from workbench.runtime import WorkbenchRuntime
+from xblock.core import XBlock
+from xblockutils.helpers import child_isinstance
+
+
+class DogXBlock(XBlock):
+ """ Test XBlock representing any dog. Raises error if instantiated. """
+ pass
+
+
+class GoldenRetrieverXBlock(DogXBlock):
+ """ Test XBlock representing a golden retriever """
+ pass
+
+
+class CatXBlock(XBlock):
+ """ Test XBlock representing any cat """
+ pass
+
+
+class BasicXBlock(XBlock):
+ """ Basic XBlock """
+ has_children = True
+
+
+class TestChildIsInstance(unittest.TestCase):
+ """
+ Test child_isinstance helper method, in the workbench runtime.
+ """
+
+ @XBlock.register_temp_plugin(GoldenRetrieverXBlock, "gr")
+ @XBlock.register_temp_plugin(CatXBlock, "cat")
+ @XBlock.register_temp_plugin(BasicXBlock, "block")
+ def test_child_isinstance(self):
+ """
+ Check that child_isinstance() works on direct children
+ """
+ self.runtime = WorkbenchRuntime()
+ self.root_id = self.runtime.parse_xml_string(' ')
+ root = self.runtime.get_block(self.root_id)
+ self.assertFalse(child_isinstance(root, root.children[0], DogXBlock))
+ self.assertFalse(child_isinstance(root, root.children[0], GoldenRetrieverXBlock))
+ self.assertTrue(child_isinstance(root, root.children[0], BasicXBlock))
+
+ self.assertFalse(child_isinstance(root, root.children[1], DogXBlock))
+ self.assertFalse(child_isinstance(root, root.children[1], GoldenRetrieverXBlock))
+ self.assertTrue(child_isinstance(root, root.children[1], CatXBlock))
+
+ self.assertFalse(child_isinstance(root, root.children[2], CatXBlock))
+ self.assertTrue(child_isinstance(root, root.children[2], DogXBlock))
+ self.assertTrue(child_isinstance(root, root.children[2], GoldenRetrieverXBlock))
+
+ @XBlock.register_temp_plugin(GoldenRetrieverXBlock, "gr")
+ @XBlock.register_temp_plugin(CatXBlock, "cat")
+ @XBlock.register_temp_plugin(BasicXBlock, "block")
+ def test_child_isinstance_descendants(self):
+ """
+ Check that child_isinstance() works on deeper descendants
+ """
+ self.runtime = WorkbenchRuntime()
+ self.root_id = self.runtime.parse_xml_string(' ')
+ root = self.runtime.get_block(self.root_id)
+ block = root.runtime.get_block(root.children[0])
+ self.assertIsInstance(block, BasicXBlock)
+
+ self.assertFalse(child_isinstance(root, block.children[0], DogXBlock))
+ self.assertTrue(child_isinstance(root, block.children[0], CatXBlock))
+
+ self.assertTrue(child_isinstance(root, block.children[1], DogXBlock))
+ self.assertTrue(child_isinstance(root, block.children[1], GoldenRetrieverXBlock))
+ self.assertFalse(child_isinstance(root, block.children[1], CatXBlock))
diff --git a/xblockutils/tests/unit/test_publish_event.py b/xblockutils/tests/unit/test_publish_event.py
new file mode 100644
index 000000000..6c927890d
--- /dev/null
+++ b/xblockutils/tests/unit/test_publish_event.py
@@ -0,0 +1,111 @@
+#
+# Copyright (C) 2014-2015 edX
+#
+# This software's license gives you freedom; you can copy, convey,
+# propagate, redistribute and/or modify this program under the terms of
+# the GNU Affero General Public License (AGPL) as published by the Free
+# Software Foundation (FSF), either version 3 of the License, or (at your
+# option) any later version of the AGPL published by the FSF.
+#
+# This program is distributed in the hope that it will be useful, but
+# WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero
+# General Public License for more details.
+#
+# You should have received a copy of the GNU Affero General Public License
+# along with this program in a file in the toplevel directory called
+# "AGPLv3". If not, see .
+#
+
+
+import unittest
+import simplejson as json
+
+from xblockutils.publish_event import PublishEventMixin
+
+
+class EmptyMock():
+ pass
+
+
+class RequestMock:
+ method = "POST"
+
+ def __init__(self, data):
+ self.body = json.dumps(data).encode('utf-8')
+
+
+class RuntimeMock:
+ last_call = None
+
+ def publish(self, block, event_type, data):
+ self.last_call = (block, event_type, data)
+
+
+class XBlockMock:
+ def __init__(self):
+ self.runtime = RuntimeMock()
+
+
+class ObjectUnderTest(XBlockMock, PublishEventMixin):
+ pass
+
+
+class TestPublishEventMixin(unittest.TestCase):
+ def assert_no_calls_made(self, block):
+ self.assertFalse(block.last_call)
+
+ def assert_success(self, response):
+ self.assertEqual(json.loads(response.body)['result'], 'success')
+
+ def assert_error(self, response):
+ self.assertEqual(json.loads(response.body)['result'], 'error')
+
+ def test_error_when_no_event_type(self):
+ block = ObjectUnderTest()
+
+ response = block.publish_event(RequestMock({}))
+
+ self.assert_error(response)
+ self.assert_no_calls_made(block.runtime)
+
+ def test_uncustomized_publish_event(self):
+ block = ObjectUnderTest()
+
+ event_data = {"one": 1, "two": 2, "bool": True}
+ data = dict(event_data)
+ data["event_type"] = "test.event.uncustomized"
+
+ response = block.publish_event(RequestMock(data))
+
+ self.assert_success(response)
+ self.assertEqual(block.runtime.last_call, (block, "test.event.uncustomized", event_data))
+
+ def test_publish_event_with_additional_data(self):
+ block = ObjectUnderTest()
+ block.additional_publish_event_data = {"always_present": True, "block_id": "the-block-id"}
+
+ event_data = {"foo": True, "bar": False, "baz": None}
+ data = dict(event_data)
+ data["event_type"] = "test.event.customized"
+
+ response = block.publish_event(RequestMock(data))
+
+ expected_data = dict(event_data)
+ expected_data.update(block.additional_publish_event_data)
+
+ self.assert_success(response)
+ self.assertEqual(block.runtime.last_call, (block, "test.event.customized", expected_data))
+
+ def test_publish_event_fails_with_duplicate_data(self):
+ block = ObjectUnderTest()
+ block.additional_publish_event_data = {"good_argument": True, "clashing_argument": True}
+
+ event_data = {"fine_argument": True, "clashing_argument": False}
+ data = dict(event_data)
+ data["event_type"] = "test.event.clashing"
+
+ response = block.publish_event(RequestMock(data))
+
+ self.assert_error(response)
+ self.assert_no_calls_made(block.runtime)
diff --git a/xblockutils/tests/unit/test_resources.py b/xblockutils/tests/unit/test_resources.py
new file mode 100644
index 000000000..3a3aa41eb
--- /dev/null
+++ b/xblockutils/tests/unit/test_resources.py
@@ -0,0 +1,247 @@
+#
+# Copyright (C) 2014-2015 edX
+#
+# This software's license gives you freedom; you can copy, convey,
+# propagate, redistribute and/or modify this program under the terms of
+# the GNU Affero General Public License (AGPL) as published by the Free
+# Software Foundation (FSF), either version 3 of the License, or (at your
+# option) any later version of the AGPL published by the FSF.
+#
+# This program is distributed in the hope that it will be useful, but
+# WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero
+# General Public License for more details.
+#
+# You should have received a copy of the GNU Affero General Public License
+# along with this program in a file in the toplevel directory called
+# "AGPLv3". If not, see .
+#
+
+
+import unittest
+import gettext
+from unittest.mock import patch, DEFAULT
+from pkg_resources import resource_filename
+
+from django.utils.translation import get_language, to_locale
+
+from xblockutils.resources import ResourceLoader
+
+
+expected_string = """\
+This is a simple template example.
+
+This template can make use of the following context variables:
+Name: {{name}}
+List: {{items|safe}}
+
+It can also do some fancy things with them:
+Default value if name is empty: {{name|default:"Default Name"}}
+Length of the list: {{items|length}}
+Items of the list:{% for item in items %} {{item}}{% endfor %}
+
+Although it is simple, it can also contain non-ASCII characters:
+
+Thé Fütüré øf Ønlïné Édüçätïøn Ⱡσяєм ι# Før änýøné, änýwhéré, änýtïmé Ⱡσяєм #
+"""
+
+
+example_context = {
+ "name": "This is a fine name",
+ "items": [1, 2, 3, 4, "a", "b", "c"],
+}
+
+
+expected_filled_template = """\
+This is a simple template example.
+
+This template can make use of the following context variables:
+Name: This is a fine name
+List: [1, 2, 3, 4, 'a', 'b', 'c']
+
+It can also do some fancy things with them:
+Default value if name is empty: This is a fine name
+Length of the list: 7
+Items of the list: 1 2 3 4 a b c
+
+Although it is simple, it can also contain non-ASCII characters:
+
+Thé Fütüré øf Ønlïné Édüçätïøn Ⱡσяєм ι# Før änýøné, änýwhéré, änýtïmé Ⱡσяєм #
+"""
+
+expected_not_translated_template = """\
+
+Translate 1
+
+Translate 2
+
+Multi-line translation
+with variable: This is a fine name
+
+"""
+
+expected_translated_template = """\
+
+tRaNsLaTe !
+
+Translate 2
+
+mUlTi_LiNe TrAnSlAtIoN: This is a fine name
+
+"""
+
+expected_localized_template = """\
+
+1000
+1000
+"""
+
+example_id = "example-unique-id"
+
+expected_filled_js_template = """\
+\
+""".format(expected_filled_template)
+
+expected_filled_translated_js_template = """\
+\
+""".format(expected_translated_template)
+
+expected_filled_not_translated_js_template = """\
+\
+""".format(expected_not_translated_template)
+
+expected_filled_localized_js_template = """\
+\
+""".format(expected_localized_template)
+
+another_template = """\
+This is an even simpler xml template.
+"""
+
+
+simple_template = """\
+
+ This is a simple xml template.
+
+ simple_template
+
+
+"""
+
+
+expected_scenarios_with_identifiers = [
+ ("another_template", "Another Template", another_template),
+ ("simple_template", "Simple Template", simple_template),
+]
+
+
+expected_scenarios = [(t, c) for (i, t, c) in expected_scenarios_with_identifiers]
+
+
+class MockI18nService:
+ """
+ I18n service used for testing translations.
+ """
+ def __init__(self):
+
+ locale_dir = 'data/translations'
+ locale_path = resource_filename(__name__, locale_dir)
+ domain = 'text'
+ self.mock_translator = gettext.translation(
+ domain,
+ locale_path,
+ ['eo'],
+ )
+
+ def __getattr__(self, name):
+ return getattr(self.mock_translator, name)
+
+
+class TestResourceLoader(unittest.TestCase):
+ def test_load_unicode(self):
+ s = ResourceLoader(__name__).load_unicode("data/simple_django_template.txt")
+ self.assertEqual(s, expected_string)
+
+ def test_load_unicode_from_another_module(self):
+ s = ResourceLoader("xblockutils.tests.unit.data").load_unicode("simple_django_template.txt")
+ self.assertEqual(s, expected_string)
+
+ def test_render_django_template(self):
+ loader = ResourceLoader(__name__)
+ s = loader.render_django_template("data/simple_django_template.txt", example_context)
+ self.assertEqual(s, expected_filled_template)
+
+ def test_render_django_template_translated(self):
+ loader = ResourceLoader(__name__)
+ s = loader.render_django_template("data/trans_django_template.txt",
+ context=example_context,
+ i18n_service=MockI18nService())
+ self.assertEqual(s, expected_translated_template)
+
+ # Test that the language changes were reverted
+ s = loader.render_django_template("data/trans_django_template.txt", example_context)
+ self.assertEqual(s, expected_not_translated_template)
+
+ def test_render_django_template_localized(self):
+ # Test that default template tags like l10n are loaded
+ loader = ResourceLoader(__name__)
+ s = loader.render_django_template("data/l10n_django_template.txt",
+ context=example_context,
+ i18n_service=MockI18nService())
+ self.assertEqual(s, expected_localized_template)
+
+ def test_render_mako_template(self):
+ loader = ResourceLoader(__name__)
+ s = loader.render_mako_template("data/simple_mako_template.txt", example_context)
+ self.assertEqual(s, expected_filled_template)
+
+ @patch('warnings.warn', DEFAULT)
+ def test_render_template_deprecated(self, mock_warn):
+ loader = ResourceLoader(__name__)
+ s = loader.render_template("data/simple_django_template.txt", example_context)
+ self.assertTrue(mock_warn.called)
+ self.assertEqual(s, expected_filled_template)
+
+ def test_render_js_template(self):
+ loader = ResourceLoader(__name__)
+ s = loader.render_js_template("data/simple_django_template.txt", example_id, example_context)
+ self.assertEqual(s, expected_filled_js_template)
+
+ def test_render_js_template_translated(self):
+ loader = ResourceLoader(__name__)
+ s = loader.render_js_template("data/trans_django_template.txt",
+ example_id,
+ context=example_context,
+ i18n_service=MockI18nService())
+ self.assertEqual(s, expected_filled_translated_js_template)
+
+ # Test that the language changes were reverted
+ s = loader.render_js_template("data/trans_django_template.txt", example_id, example_context)
+ self.assertEqual(s, expected_filled_not_translated_js_template)
+
+ def test_render_js_template_localized(self):
+ # Test that default template tags like l10n are loaded
+ loader = ResourceLoader(__name__)
+ s = loader.render_js_template("data/l10n_django_template.txt",
+ example_id,
+ context=example_context,
+ i18n_service=MockI18nService())
+ self.assertEqual(s, expected_filled_localized_js_template)
+
+ def test_load_scenarios(self):
+ loader = ResourceLoader(__name__)
+ scenarios = loader.load_scenarios_from_path("data")
+ self.assertEqual(scenarios, expected_scenarios)
+
+ def test_load_scenarios_with_identifiers(self):
+ loader = ResourceLoader(__name__)
+ scenarios = loader.load_scenarios_from_path("data", include_identifier=True)
+ self.assertEqual(scenarios, expected_scenarios_with_identifiers)
diff --git a/xblockutils/tests/unit/test_settings.py b/xblockutils/tests/unit/test_settings.py
new file mode 100644
index 000000000..4c2caa9a7
--- /dev/null
+++ b/xblockutils/tests/unit/test_settings.py
@@ -0,0 +1,151 @@
+import unittest
+import ddt
+import itertools
+from unittest.mock import Mock, MagicMock, patch
+
+from xblock.core import XBlock
+from xblockutils.settings import XBlockWithSettingsMixin, ThemableXBlockMixin
+
+
+@XBlock.wants('settings')
+class DummyXBlockWithSettings(XBlock, XBlockWithSettingsMixin, ThemableXBlockMixin):
+ block_settings_key = 'dummy_settings_bucket'
+ default_theme_config = {
+ 'package': 'xblock_utils',
+ 'locations': ['qwe.css']
+ }
+
+
+@XBlock.wants('settings')
+class OtherXBlockWithSettings(XBlock, XBlockWithSettingsMixin, ThemableXBlockMixin):
+ block_settings_key = 'other_settings_bucket'
+ theme_key = 'other_xblock_theme'
+ default_theme_config = {
+ 'package': 'xblock_utils',
+ 'locations': ['qwe.css']
+ }
+
+
+@ddt.ddt
+class TestXBlockWithSettingsMixin(unittest.TestCase):
+ def setUp(self):
+ self.settings_service = Mock()
+ self.runtime = Mock()
+ self.runtime.service = Mock(return_value=self.settings_service)
+
+ @ddt.data(None, 1, "2", [3, 4], {5: '6'})
+ def test_no_settings_service_return_default(self, default_value):
+ xblock = DummyXBlockWithSettings(self.runtime, scope_ids=Mock())
+ self.runtime.service.return_value = None
+ self.assertEqual(xblock.get_xblock_settings(default=default_value), default_value)
+
+ @ddt.data(*itertools.product(
+ (DummyXBlockWithSettings, OtherXBlockWithSettings),
+ (None, 1, "2", [3, 4], {5: '6'}),
+ (None, 'default1')
+ ))
+ @ddt.unpack
+ def test_invokes_get_settings_bucket_and_returns_result(self, block, settings_service_return_value, default):
+ xblock = block(self.runtime, scope_ids=Mock())
+
+ self.settings_service.get_settings_bucket = Mock(return_value=settings_service_return_value)
+ self.assertEqual(xblock.get_xblock_settings(default=default), settings_service_return_value)
+ self.settings_service.get_settings_bucket.assert_called_with(xblock, default=default)
+
+
+@ddt.ddt
+class TextThemableXBlockMixin(unittest.TestCase):
+ def setUp(self):
+ self.service_mock = Mock()
+ self.runtime_mock = Mock()
+ self.runtime_mock.service = Mock(return_value=self.service_mock)
+
+ @ddt.data(DummyXBlockWithSettings, OtherXBlockWithSettings)
+ def test_theme_uses_default_theme_if_settings_service_is_not_available(self, xblock_class):
+ xblock = xblock_class(self.runtime_mock, scope_ids=Mock())
+ self.runtime_mock.service = Mock(return_value=None)
+ self.assertEqual(xblock.get_theme(), xblock_class.default_theme_config)
+
+ @ddt.data(DummyXBlockWithSettings, OtherXBlockWithSettings)
+ def test_theme_uses_default_theme_if_no_theme_is_set(self, xblock_class):
+ xblock = xblock_class(self.runtime_mock, scope_ids=Mock())
+ self.service_mock.get_settings_bucket = Mock(return_value=None)
+ self.assertEqual(xblock.get_theme(), xblock_class.default_theme_config)
+ self.service_mock.get_settings_bucket.assert_called_once_with(xblock, default={})
+
+ @ddt.data(*itertools.product(
+ (DummyXBlockWithSettings, OtherXBlockWithSettings),
+ (123, object())
+ ))
+ @ddt.unpack
+ def test_theme_raises_if_theme_object_is_not_iterable(self, xblock_class, theme_config):
+ xblock = xblock_class(self.runtime_mock, scope_ids=Mock())
+ self.service_mock.get_settings_bucket = Mock(return_value=theme_config)
+ with self.assertRaises(TypeError):
+ xblock.get_theme()
+ self.service_mock.get_settings_bucket.assert_called_once_with(xblock, default={})
+
+ @ddt.data(*itertools.product(
+ (DummyXBlockWithSettings, OtherXBlockWithSettings),
+ ({}, {'mass': 123}, {'spin': {}}, {'parity': "1"})
+ ))
+ @ddt.unpack
+ def test_theme_uses_default_theme_if_no_mentoring_theme_is_set_up(self, xblock_class, theme_config):
+ xblock = xblock_class(self.runtime_mock, scope_ids=Mock())
+ self.service_mock.get_settings_bucket = Mock(return_value=theme_config)
+ self.assertEqual(xblock.get_theme(), xblock_class.default_theme_config)
+ self.service_mock.get_settings_bucket.assert_called_once_with(xblock, default={})
+
+ @ddt.data(*itertools.product(
+ (DummyXBlockWithSettings, OtherXBlockWithSettings),
+ (
+ 123,
+ [1, 2, 3],
+ {'package': 'qwerty', 'locations': ['something_else.css']}
+ ),
+ ))
+ @ddt.unpack
+ def test_theme_correctly_returns_configured_theme(self, xblock_class, theme_config):
+ xblock = xblock_class(self.runtime_mock, scope_ids=Mock())
+ self.service_mock.get_settings_bucket = Mock(return_value={xblock_class.theme_key: theme_config})
+ self.assertEqual(xblock.get_theme(), theme_config)
+
+ @ddt.data(DummyXBlockWithSettings, OtherXBlockWithSettings)
+ def test_theme_files_are_loaded_from_correct_package(self, xblock_class):
+ xblock = xblock_class(self.runtime_mock, scope_ids=Mock())
+ fragment = MagicMock()
+ package_name = 'some_package'
+ theme_config = {xblock_class.theme_key: {'package': package_name, 'locations': ['lms.css']}}
+ self.service_mock.get_settings_bucket = Mock(return_value=theme_config)
+ with patch("xblockutils.settings.ResourceLoader") as patched_resource_loader:
+ xblock.include_theme_files(fragment)
+ patched_resource_loader.assert_called_with(package_name)
+
+ @ddt.data(
+ ('dummy_block', ['']),
+ ('dummy_block', ['public/themes/lms.css']),
+ ('other_block', ['public/themes/lms.css', 'public/themes/lms.part2.css']),
+ ('dummy_app.dummy_block', ['typography.css', 'icons.css']),
+ )
+ @ddt.unpack
+ def test_theme_files_are_added_to_fragment(self, package_name, locations):
+ xblock = DummyXBlockWithSettings(self.runtime_mock, scope_ids=Mock())
+ fragment = MagicMock()
+ theme_config = {DummyXBlockWithSettings.theme_key: {'package': package_name, 'locations': locations}}
+ self.service_mock.get_settings_bucket = Mock(return_value=theme_config)
+ with patch("xblockutils.settings.ResourceLoader.load_unicode") as patched_load_unicode:
+ xblock.include_theme_files(fragment)
+ for location in locations:
+ patched_load_unicode.assert_any_call(location)
+
+ self.assertEqual(patched_load_unicode.call_count, len(locations))
+
+ @ddt.data(None, {}, {'locations': ['red.css']})
+ def test_invalid_default_theme_config(self, theme_config):
+ xblock = DummyXBlockWithSettings(self.runtime_mock, scope_ids=Mock())
+ xblock.default_theme_config = theme_config
+ self.service_mock.get_settings_bucket = Mock(return_value={})
+ fragment = MagicMock()
+ with patch("xblockutils.settings.ResourceLoader.load_unicode") as patched_load_unicode:
+ xblock.include_theme_files(fragment)
+ patched_load_unicode.assert_not_called()