diff --git a/README.rst b/README.rst index 76fda07..5a47036 100644 --- a/README.rst +++ b/README.rst @@ -10,6 +10,26 @@ Purpose These are a collection of utility functions, base test classes and documentation that are useful for any XBlocks. + +⚠️ Deprecation Notice ⚠️ +************************ + +**Effective Date:** September 26, 2023 + +**Repository Migration:** +This `xblock-utils` repository has been `deprecated `_ as of September 26, 2023, and the code and documentation have been migrated to the `Xblock `_ repository. + +This decision was made to streamline and consolidate our codebase. + +The migration process was completed through this Pull Request: `PR #669 `_ + +**Archival**: We are going to archive the `xblock-utils` repository. This means that it will become read-only, and no further updates or changes will be accepted. + +We appreciate your understanding and cooperation during this transition. If you have any questions or concerns, please don't hesitate to reach out to us through the `XBlock` repository's issue tracker. + +Thank you for your continued support and contributions to the Open edX community. + + Getting Started *************** diff --git a/setup.py b/setup.py index 4f8a5fe..f81b656 100644 --- a/setup.py +++ b/setup.py @@ -137,7 +137,7 @@ def get_version(*file_paths): 'xblockutils', ], install_requires=load_requirements('requirements/base.in'), - package_data=package_data("xblockutils", ["public", "templates", "templatetags"]), + package_data=package_data("xblockutils", ["deprecation", "public", "templates", "templatetags"]), url='https://github.com/openedx/xblock-utils', classifiers=[ 'Framework :: Django :: 3.2', diff --git a/tests/unit/test_settings.py b/tests/unit/test_settings.py index 4c2caa9..80df76f 100644 --- a/tests/unit/test_settings.py +++ b/tests/unit/test_settings.py @@ -117,7 +117,7 @@ def test_theme_files_are_loaded_from_correct_package(self, xblock_class): 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: + with patch("xblock.utils.settings.ResourceLoader") as patched_resource_loader: xblock.include_theme_files(fragment) patched_resource_loader.assert_called_with(package_name) @@ -133,7 +133,7 @@ def test_theme_files_are_added_to_fragment(self, package_name, locations): 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: + with patch("xblock.utils.settings.ResourceLoader.load_unicode") as patched_load_unicode: xblock.include_theme_files(fragment) for location in locations: patched_load_unicode.assert_any_call(location) @@ -146,6 +146,6 @@ def test_invalid_default_theme_config(self, theme_config): 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: + with patch("xblock.utils.settings.ResourceLoader.load_unicode") as patched_load_unicode: xblock.include_theme_files(fragment) patched_load_unicode.assert_not_called() diff --git a/xblockutils/__init__.py b/xblockutils/__init__.py index ac57d0f..97599e3 100644 --- a/xblockutils/__init__.py +++ b/xblockutils/__init__.py @@ -2,4 +2,4 @@ Useful classes and functionality for building and testing XBlocks """ -__version__ = '3.4.1' +__version__ = '4.0.0' diff --git a/xblockutils/deprecation/__init__.py b/xblockutils/deprecation/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/xblockutils/deprecation/warn.py b/xblockutils/deprecation/warn.py new file mode 100644 index 0000000..01fd662 --- /dev/null +++ b/xblockutils/deprecation/warn.py @@ -0,0 +1,34 @@ +""" +Utilities for warning about the use of deprecated package + +See https://github.com/openedx/xblock-utils/issues/197 for details. +""" + +import warnings + + +class DeprecatedPackageWarning(DeprecationWarning): + """ + A warning that a deprecated package is being used. + """ + + def __init__(self, old_import, new_import): + super().__init__() + self.old_import = old_import + self.new_import = new_import + + def __str__(self): + return ( + "Please use import {self.new_import} instead of {self.old_import} because " + "the 'xblock-utils' package has been deprecated and migrated to within 'xblock' package. " + ).format(self=self) + + +def warn_deprecated_package(old_import, new_import): + """ + Warn that a package has been deprecated + """ + warnings.warn( + DeprecatedPackageWarning(old_import, new_import), + stacklevel=3, # Should surface the line that is doing the importing. + ) diff --git a/xblockutils/helpers.py b/xblockutils/helpers.py index 941daa3..8d7fec4 100644 --- a/xblockutils/helpers.py +++ b/xblockutils/helpers.py @@ -1,25 +1,12 @@ -""" -Useful helper methods -""" +"""Deprecated package support.""" +# pylint: disable=useless-suppression,line-too-long,redefined-builtin,wildcard-import, +# pylint: disable=wrong-import-position,wrong-import-order +from xblockutils.deprecation.warn import warn_deprecated_package -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. +warn_deprecated_package( + 'xblockutils.helpers', + 'xblock.utils.helpers' +) - 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) +from xblock.utils.helpers import * diff --git a/xblockutils/publish_event.py b/xblockutils/publish_event.py index d366f9c..cd79e0f 100644 --- a/xblockutils/publish_event.py +++ b/xblockutils/publish_event.py @@ -1,56 +1,12 @@ -# -# 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 -""" +"""Deprecated package support.""" +# pylint: disable=useless-suppression,line-too-long,redefined-builtin,wildcard-import, +# pylint: disable=wrong-import-position,wrong-import-order -from xblock.core import XBlock +from xblockutils.deprecation.warn import warn_deprecated_package +warn_deprecated_package( + 'xblockutils.publish_event', + 'xblock.utils.publish_event' +) -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'} +from xblock.utils.publish_event import * diff --git a/xblockutils/resources.py b/xblockutils/resources.py index 2f6b268..c220f55 100644 --- a/xblockutils/resources.py +++ b/xblockutils/resources.py @@ -1,121 +1,12 @@ -# -# 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 -""" +"""Deprecated package support.""" +# pylint: disable=useless-suppression,line-too-long,redefined-builtin,wildcard-import, +# pylint: disable=wrong-import-position,wrong-import-order -import os -import sys -import warnings +from xblockutils.deprecation.warn import warn_deprecated_package -import pkg_resources +warn_deprecated_package( + 'xblockutils.resources', + 'xblock.utils.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 +from xblock.utils.resources import * diff --git a/xblockutils/settings.py b/xblockutils/settings.py index 26552fe..156481d 100644 --- a/xblockutils/settings.py +++ b/xblockutils/settings.py @@ -1,91 +1,12 @@ -# -# Copyright (C) 2015 OpenCraft -# License: AGPLv3 -""" -This module contains a mixins that allows third party XBlocks to access Settings Service in edX LMS. -""" +"""Deprecated package support.""" +# pylint: disable=useless-suppression,line-too-long,redefined-builtin,wildcard-import, +# pylint: disable=wrong-import-position,wrong-import-order -from xblockutils.resources import ResourceLoader +from xblockutils.deprecation.warn import warn_deprecated_package +warn_deprecated_package( + 'xblockutils.settings', + 'xblock.utils.settings' +) -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)) +from xblock.utils.settings import * diff --git a/xblockutils/studio_editable.py b/xblockutils/studio_editable.py index 7100681..3d8945b 100644 --- a/xblockutils/studio_editable.py +++ b/xblockutils/studio_editable.py @@ -1,510 +1,12 @@ -# -# 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. -""" +"""Deprecated package support.""" +# pylint: disable=useless-suppression,line-too-long,redefined-builtin,wildcard-import, +# pylint: disable=wrong-import-position,wrong-import-order -# Imports ########################################################### +from xblockutils.deprecation.warn import warn_deprecated_package +warn_deprecated_package( + 'xblockutils.studio_editable', + 'xblock.utils.studio_editable' +) -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 +from xblock.utils.studio_editable import * diff --git a/xblockutils/templatetags/i18n.py b/xblockutils/templatetags/i18n.py index 90ab8a8..c24fbc8 100644 --- a/xblockutils/templatetags/i18n.py +++ b/xblockutils/templatetags/i18n.py @@ -1,74 +1,12 @@ -""" -Template tags for handling i18n translations for xblocks +"""Deprecated package support.""" +# pylint: disable=useless-suppression,line-too-long,redefined-builtin,wildcard-import, +# pylint: disable=wrong-import-position,wrong-import-order -Based on: https://github.com/eduNEXT/django-xblock-i18n -""" +from xblockutils.deprecation.warn import warn_deprecated_package -from contextlib import contextmanager +warn_deprecated_package( + 'xblockutils.templatetags.i18n', + 'xblock.utils.templatetags.i18n' +) -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)) +from xblock.utils.templatetags.i18n import *