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 %} +
+
+
    + {% for field in fields %} + + {% endfor %} +
+
+ +
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()