diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ca3fff68f..0efc7b6b8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -17,12 +17,15 @@ jobs: toxenv: [quality, django32, django42] steps: - - uses: actions/checkout@v2 + - uses: actions/checkout@v3 - name: setup python - uses: actions/setup-python@v2 + uses: actions/setup-python@v4 with: python-version: ${{ matrix.python-version }} + - name: Install pip + run: pip install -r requirements/pip.txt + - name: Install Dependencies run: pip install -r requirements/ci.txt diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 9eb07fdbd..52bcdde15 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -8,6 +8,14 @@ Unreleased ---------- +1.8.0 - 2023-09-25 +------------------ +* Added `xblock-utils `_ repository code into this repository along with docs. + + * Docs moved into the docs/ directory. + + * See https://github.com/openedx/xblock-utils/issues/197 for more details. + 1.7.0 - 2023-08-03 ------------------ 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/docs/index.rst b/docs/index.rst index f1aed6c02..02944e382 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -22,5 +22,6 @@ in depth and guides developers through the process of creating an XBlock. fragment plugins exceptions + xblock-utils/index .. _EdX XBlock Tutorial: http://edx.readthedocs.org/projects/xblock-tutorial/en/latest/index.html diff --git a/docs/xblock-utils/Images/Screenshot_1.png b/docs/xblock-utils/Images/Screenshot_1.png new file mode 100644 index 000000000..663cb0015 Binary files /dev/null and b/docs/xblock-utils/Images/Screenshot_1.png differ diff --git a/docs/xblock-utils/Images/Screenshot_2.png b/docs/xblock-utils/Images/Screenshot_2.png new file mode 100644 index 000000000..ba2cbf46b Binary files /dev/null and b/docs/xblock-utils/Images/Screenshot_2.png differ diff --git a/docs/xblock-utils/index.rst b/docs/xblock-utils/index.rst new file mode 100644 index 000000000..0f9ecb5bb --- /dev/null +++ b/docs/xblock-utils/index.rst @@ -0,0 +1,179 @@ +.. _XBlock Utils: + + +Xblock.utils +############ + +Package having various utilities for XBlocks +******************************************** + + +Purpose +======= + +``xblock/utils`` package contains a collection of utility functions and base test classes that are useful for any XBlock. + + +Documentation +============= + + +StudioEditableXBlockMixin +------------------------- + +.. code:: python + + from xblock.utils.studio_editable import StudioEditableXBlockMixin + +This mixin will automatically generate a working ``studio_view`` form +that allows content authors to edit the fields of your XBlock. To use, +simply add the class to your base class list, and add a new class field +called ``editable_fields``, set to a tuple of the names of the fields +you want your user to be able to edit. + +.. code:: python + + @XBlock.needs("i18n") + class ExampleBlock(StudioEditableXBlockMixin, XBlock): + ... + mode = String( + display_name="Mode", + help="Determines the behaviour of this component. Standard is recommended.", + default='standard', + scope=Scope.content, + values=('standard', 'crazy') + ) + editable_fields = ('mode', 'display_name') + +That's all you need to do. The mixin will read the optional +``display_name``, ``help``, ``default``, and ``values`` settings from +the fields you mention and build the editor form as well as an AJAX save +handler. + +If you want to validate the data, you can override +``validate_field_data(self, validation, data)`` and/or +``clean_studio_edits(self, data)`` - see the source code for details. + +Supported field types: + +* Boolean: + ``field_name = Boolean(display_name="Field Name")`` +* Float: + ``field_name = Float(display_name="Field Name")`` +* Integer: + ``field_name = Integer(display_name="Field Name")`` +* String: + ``field_name = String(display_name="Field Name")`` +* String (multiline): + ``field_name = String(multiline_editor=True, resettable_editor=False)`` +* String (html): + ``field_name = String(multiline_editor='html', resettable_editor=False)`` + +Any of the above will use a dropdown menu if they have a pre-defined +list of possible values. + +* List of unordered unique values (i.e. sets) drawn from a small set of + possible values: + ``field_name = List(list_style='set', list_values_provider=some_method)`` + + - The ``List`` declaration must include the property ``list_style='set'`` to + indicate that the ``List`` field is being used with set semantics. + - The ``List`` declaration must also define a ``list_values_provider`` method + which will be called with the block as its only parameter and which must + return a list of possible values. +* Rudimentary support for Dict, ordered List, and any other JSONField-derived field types + + - ``list_field = List(display_name="Ordered List", default=[])`` + - ``dict_field = Dict(display_name="Normal Dict", default={})`` + +Supported field options (all field types): + +* ``values`` can define a list of possible options, changing the UI element + to a select box. Values can be set to any of the formats `defined in the + XBlock source code `__: + + - A finite set of elements: ``[1, 2, 3]`` + - A finite set of elements where the display names differ from the values:: + + [ + {"display_name": "Always", "value": "always"}, + {"display_name": "Past Due", "value": "past_due"}, + ] + + - A range for floating point numbers with specific increments: + ``{"min": 0 , "max": 10, "step": .1}`` + - A callable that returns one of the above. (Note: the callable does + *not* get passed the XBlock instance or runtime, so it cannot be a + normal member function) +* ``values_provider`` can define a callable that accepts the XBlock + instance as an argument, and returns a list of possible values in one + of the formats listed above. +* ``resettable_editor`` - defaults to ``True``. Set ``False`` to hide the + "Reset" button used to return a field to its default value by removing + the field's value from the XBlock instance. + +Basic screenshot: |Screenshot 1| + +StudioContainerXBlockMixin +-------------------------- + +.. code:: python + + from xblock.utils.studio_editable import StudioContainerXBlockMixin + +This mixin helps to create XBlocks that allow content authors to add, +remove, or reorder child blocks. By removing any existing +``author_view`` and adding this mixin, you'll get editable, +re-orderable, and deletable child support in Studio. To enable authors to +add arbitrary blocks as children, simply override ``author_edit_view`` +and set ``can_add=True`` when calling ``render_children`` - see the +source code. To restrict authors so they can add only specific types of +child blocks or a limited number of children requires custom HTML. + +An example is the mentoring XBlock: |Screenshot 2| + + +child\_isinstance +------------------------- + +.. code:: python + + from xblock.utils.helpers import child_isinstance + +If your XBlock needs to find children/descendants of a particular +class/mixin, you should use + +.. code:: python + + child_isinstance(self, child_usage_id, SomeXBlockClassOrMixin) + +rather than calling + +.. code:: python + + isinstance(self.runtime.get_block(child_usage_id), SomeXBlockClassOrMixin) + +On runtimes such as those in edx-platform, ``child_isinstance`` is +orders of magnitude faster. + +.. |Screenshot 1| image:: Images/Screenshot_1.png +.. |Screenshot 2| image:: Images/Screenshot_2.png + +XBlockWithSettingsMixin +------------------------- + +This mixin provides access to instance-wide XBlock-specific configuration settings. +See :ref:`accessing-xblock-specific-settings` for details. + +ThemableXBlockMixin +------------------------- + +This mixin provides XBlock theming capabilities built on top of XBlock-specific settings. +See :ref:`theming-support` for details. + +To learn more, refer to the page. + +.. toctree:: + :caption: Contents: + + settings-and-theme-support diff --git a/docs/xblock-utils/settings-and-theme-support.rst b/docs/xblock-utils/settings-and-theme-support.rst new file mode 100644 index 000000000..1b4b9fa83 --- /dev/null +++ b/docs/xblock-utils/settings-and-theme-support.rst @@ -0,0 +1,157 @@ +.. _settings-and-theme-support: + + +Settings and theme support +########################## + +.. _accessing-xblock-specific-settings: + +Accessing XBlock specific settings +********************************** + +XBlock utils provide a mixin to simplify accessing instance-wide +XBlock-specific configuration settings: ``XBlockWithSettingsMixin``. +This mixin aims to provide a common interface for pulling XBlock +settings from the LMS +`SettingsService `__. + +``SettingsService`` allows individual XBlocks to access environment and +django settings in an isolated manner: + +- XBlock settings are represented as dictionary stored in `django + settings `__ + and populated from environment \*.json files (cms.env.json and + lms.env.json) +- Each XBlock is associated with a particular key in that dictionary: + by default an XBlock's class name is used, but XBlocks can override + it using the ``block_settings_key`` attribute/property. + +Please note that at the time of writing the implementation of +``SettingsService`` assumed "good citizenship" behavior on the part of +XBlocks, i.e. it does not check for key collisions and allows modifying +mutable settings. Both ``SettingsService`` and +``XBlockWithSettingsMixin`` are not concerned with contents of settings +bucket and return them as is. Refer to the ``SettingsService`` docstring +and implementation for more details. + +Using XBlockWithSettingsMixin +============================= + +In order to use ``SettingsService`` and ``XBlockWithSettingsMixin``, a +client XBlock *must* require it via standard +``XBlock.wants('settings')`` or ``XBlock.needs('settings')`` decorators. +The mixins themselves are not decorated as this would not result in all +descendant XBlocks to also be decorated. + +With ``XBlockWithSettingsMixin`` and ``wants`` decorator applied, +obtaining XBlock settings is as simple as + +.. code:: python + + self.get_xblock_settings() # returns settings bucket or None + self.get_xblock_settings(default=something) # returns settings bucket or "something" + +In case of missing or inaccessible XBlock settings (i.e. no settings +service in runtime, no ``XBLOCK_SETTINGS`` in settings, or XBlock +settings key is not found) ``default`` value is used. + +.. _theming-support: + +Theming support +*************** + +XBlock theming support is built on top of XBlock-specific settings. +XBlock utils provide ``ThemableXBlockMixin`` to streamline using XBlock +themes. + +XBlock theme support is designed with two major design goals: + +- Allow for a different look and feel of an XBlock in different + environments. +- Use a pluggable approach to hosting themes, so that adding a new + theme will not require forking an XBlock. + +The first goal made using ``SettingsService`` and +``XBlockWithSettingsMixin`` an obvious choice to store and obtain theme +configuration. The second goal dictated the configuration format - it is +a dictionary (or dictionary-like object) with the following keys: + +- ``package`` - "top-level" selector specifying package which hosts + theme files +- ``locations`` - a list of locations within that package + +Examples: + +.. code:: python + + # will search for files red.css and small.css in my_xblock package + { + 'package': 'my_xblock', + 'locations': ['red.css', 'small.css'] + } + + # will search for files public/themes/red.css in my_other_xblock.assets package + default_theme_config = { + 'package': 'my_other_xblock.assets', + 'locations': ['public/themes/red.css'] + } + +Theme files must be included into package (see `python +docs `__ +for details). At the time of writing it is not possible to fetch theme +files from multiple packages. + +**Note:** XBlock themes are *not* LMS themes - they are just additional +CSS files included into an XBlock fragment when the corresponding XBlock +is rendered. However, it is possible to misuse this feature to change +look and feel of the entire LMS, as contents of CSS files are not +checked and might contain selectors that apply to elements outside of +the XBlock in question. Hence, it is advised to scope all CSS rules +belonging to a theme with a global CSS selector +``.themed-xblock.``, e.g. +``.themed-xblock.poll-block``. Note that the ``themed-xblock`` class is +not automatically added by ``ThemableXBlockMixin``, so one needs to add +it manually. + +Using ThemableXBlockMixin +========================= + +In order to use ``ThemableXBlockMixin``, a descendant XBlock must also +be a descendant of ``XBlockWithSettingsMixin`` (``XBlock.wants`` +decorator requirement applies) or provide a similar interface for +obtaining the XBlock settings bucket. + +There are three configuration parameters that govern +``ThemableXBlockMixin`` behavior: + +- ``default_theme_config`` - default theme configuration in case no + theme configuration can be obtained +- ``theme_key`` - a key in XBlock settings bucket that stores theme + configuration +- ``block_settings_key`` - inherited from ``XBlockWithSettingsMixin`` + if used in conjunction with it + +It is safe to omit ``default_theme_config`` or set it to ``None`` in +case no default theme is available. In this case, +``ThemableXBlockMixin`` will skip including theme files if no theme is +specified via settings. + +``ThemableXBlockMixin`` exposes two methods: + +- ``get_theme()`` - this is used to get theme configuration. Default + implementation uses ``get_xblock_settings`` and ``theme_key``, + descendants are free to override it. Normally, it should not be + called directly. +- ``include_theme_files(fragment)`` - this method is an entry point to + ``ThemableXBlockMixin`` functionality. It calls ``get_theme`` to + obtain theme configuration, fetches theme files and includes them + into fragment. ``fragment`` must be an + `XBlock.Fragment `__ + instance. + +So, having met usage requirements and set up theme configuration +parameters, including theme into XBlock fragment is a one liner: + +.. code:: python + + self.include_theme_files(fragment) diff --git a/requirements/base.in b/requirements/base.in index b70901c8f..5bb2941e0 100644 --- a/requirements/base.in +++ b/requirements/base.in @@ -3,6 +3,7 @@ fs lxml +mako markupsafe python-dateutil pytz diff --git a/requirements/base.txt b/requirements/base.txt index 221371a2a..6b14476c8 100644 --- a/requirements/base.txt +++ b/requirements/base.txt @@ -10,8 +10,12 @@ fs==2.4.16 # via -r requirements/base.in lxml==4.9.3 # via -r requirements/base.in -markupsafe==2.1.3 +mako==1.2.4 # via -r requirements/base.in +markupsafe==2.1.3 + # via + # -r requirements/base.in + # mako python-dateutil==2.8.2 # via -r requirements/base.in pytz==2023.3.post1 diff --git a/requirements/dev.txt b/requirements/dev.txt index b9bec2950..2badfa7bb 100644 --- a/requirements/dev.txt +++ b/requirements/dev.txt @@ -12,6 +12,10 @@ appdirs==1.4.4 # via # -r requirements/test.txt # fs +arrow==1.2.3 + # via + # -r requirements/test.txt + # cookiecutter astroid==2.15.7 # via # -r requirements/test.txt @@ -21,6 +25,10 @@ attrs==23.1.0 # via # -r requirements/test.txt # hypothesis +binaryornot==0.4.4 + # via + # -r requirements/test.txt + # cookiecutter boto3==1.28.53 # via # -r requirements/test.txt @@ -34,12 +42,25 @@ build==1.0.3 # via # -r requirements/pip-tools.txt # pip-tools +certifi==2023.7.22 + # via + # -r requirements/test.txt + # requests +chardet==5.2.0 + # via + # -r requirements/test.txt + # binaryornot +charset-normalizer==3.2.0 + # via + # -r requirements/test.txt + # requests click==8.1.7 # via # -r requirements/pip-tools.txt # -r requirements/test.txt # click-log # code-annotations + # cookiecutter # edx-lint # pip-tools click-log==0.4.0 @@ -50,6 +71,10 @@ code-annotations==1.5.0 # via # -r requirements/test.txt # edx-lint +cookiecutter==2.3.1 + # via + # -r requirements/test.txt + # xblock-sdk coverage[toml]==7.3.1 # via # -r requirements/ci.txt @@ -75,6 +100,7 @@ django==2.2.28 # -c https://raw.githubusercontent.com/edx/edx-lint/master/edx_lint/files/common_constraints.txt # -r requirements/test.txt # openedx-django-pyfs + # xblock-sdk edx-lint==5.3.4 # via -r requirements/test.txt exceptiongroup==1.1.3 @@ -93,12 +119,18 @@ fs==2.4.16 # -r requirements/test.txt # fs-s3fs # openedx-django-pyfs + # xblock fs-s3fs==1.1.1 # via # -r requirements/test.txt # openedx-django-pyfs -hypothesis==6.86.2 + # xblock-sdk +hypothesis==6.87.0 # via -r requirements/test.txt +idna==3.4 + # via + # -r requirements/test.txt + # requests importlib-metadata==6.8.0 # via # -r requirements/pip-tools.txt @@ -119,6 +151,7 @@ jinja2==3.1.2 # via # -r requirements/test.txt # code-annotations + # cookiecutter # diff-cover # jinja2-pluralize jinja2-pluralize==0.3.0 @@ -137,15 +170,30 @@ lazy-object-proxy==1.9.0 # -r requirements/test.txt # astroid lxml==4.9.3 + # via + # -r requirements/test.txt + # xblock + # xblock-sdk +mako==1.2.4 # via -r requirements/test.txt +markdown-it-py==3.0.0 + # via + # -r requirements/test.txt + # rich markupsafe==2.1.3 # via # -r requirements/test.txt # jinja2 + # mako + # xblock mccabe==0.7.0 # via # -r requirements/test.txt # pylint +mdurl==0.1.2 + # via + # -r requirements/test.txt + # markdown-it-py mock==5.1.0 # via -r requirements/test.txt openedx-django-pyfs==3.4.0 @@ -186,11 +234,11 @@ py==1.11.0 # tox pycodestyle==2.11.0 # via -r requirements/test.txt -pydantic==2.3.0 +pydantic==2.4.0 # via # -r requirements/test.txt # inflect -pydantic-core==2.6.3 +pydantic-core==2.10.0 # via # -r requirements/test.txt # pydantic @@ -198,7 +246,8 @@ pygments==2.16.1 # via # -r requirements/test.txt # diff-cover -pylint==2.17.5 + # rich +pylint==2.17.6 # via # -r requirements/test.txt # edx-lint @@ -218,6 +267,10 @@ pylint-plugin-utils==0.8.2 # -r requirements/test.txt # pylint-celery # pylint-django +pypng==0.20220715.0 + # via + # -r requirements/test.txt + # xblock-sdk pyproject-hooks==1.0.0 # via # -r requirements/pip-tools.txt @@ -234,25 +287,42 @@ pytest-django==4.5.2 python-dateutil==2.8.2 # via # -r requirements/test.txt + # arrow # botocore + # xblock python-slugify==8.0.1 # via # -r requirements/test.txt # code-annotations + # cookiecutter pytz==2023.3.post1 # via # -r requirements/test.txt # django + # xblock pyyaml==6.0.1 # via # -r requirements/test.txt # code-annotations + # cookiecutter + # xblock +requests==2.31.0 + # via + # -r requirements/test.txt + # cookiecutter + # xblock-sdk +rich==13.5.3 + # via + # -r requirements/test.txt + # cookiecutter s3transfer==0.6.2 # via # -r requirements/test.txt # boto3 simplejson==3.19.1 - # via -r requirements/test.txt + # via + # -r requirements/test.txt + # xblock-sdk six==1.16.0 # via # -r requirements/ci.txt @@ -311,19 +381,27 @@ typing-extensions==4.8.0 # pydantic # pydantic-core # pylint + # rich urllib3==1.26.16 # via # -r requirements/test.txt # botocore + # requests virtualenv==20.24.5 # via # -r requirements/ci.txt # -r requirements/test.txt # tox web-fragments==2.1.0 - # via -r requirements/test.txt + # via + # -r requirements/test.txt + # xblock + # xblock-sdk webob==1.8.7 - # via -r requirements/test.txt + # via + # -r requirements/test.txt + # xblock + # xblock-sdk wheel==0.41.2 # via # -r requirements/pip-tools.txt @@ -332,6 +410,12 @@ wrapt==1.15.0 # via # -r requirements/test.txt # astroid +xblock==1.7.0 + # via + # -r requirements/test.txt + # xblock-sdk +xblock-sdk==0.7.0 + # via -r requirements/test.txt zipp==3.17.0 # via # -r requirements/pip-tools.txt diff --git a/requirements/django.txt b/requirements/django.txt index ac5424f7f..4d0569b60 100644 --- a/requirements/django.txt +++ b/requirements/django.txt @@ -34,8 +34,12 @@ lazy==1.6 # via -r requirements/django.in lxml==4.9.3 # via -r requirements/base.txt -markupsafe==2.1.3 +mako==1.2.4 # via -r requirements/base.txt +markupsafe==2.1.3 + # via + # -r requirements/base.txt + # mako openedx-django-pyfs==3.4.0 # via -r requirements/django.in python-dateutil==2.8.2 diff --git a/requirements/doc.txt b/requirements/doc.txt index 4a2574f76..6b61dda84 100644 --- a/requirements/doc.txt +++ b/requirements/doc.txt @@ -66,10 +66,13 @@ lazy==1.6 # via -r requirements/django.txt lxml==4.9.3 # via -r requirements/django.txt +mako==1.2.4 + # via -r requirements/django.txt markupsafe==2.1.3 # via # -r requirements/django.txt # jinja2 + # mako mock==5.1.0 # via -r requirements/doc.in openedx-django-pyfs==3.4.0 diff --git a/requirements/test.in b/requirements/test.in index 4a5d2a57b..7ddb1238f 100644 --- a/requirements/test.in +++ b/requirements/test.in @@ -18,3 +18,4 @@ pytest pytest-cov pytest-django tox +xblock-sdk diff --git a/requirements/test.txt b/requirements/test.txt index 17b2a9664..5c48c51ad 100644 --- a/requirements/test.txt +++ b/requirements/test.txt @@ -10,6 +10,8 @@ appdirs==1.4.4 # via # -r requirements/django.txt # fs +arrow==1.2.3 + # via cookiecutter astroid==2.15.7 # via # -r requirements/test.in @@ -17,6 +19,8 @@ astroid==2.15.7 # pylint-celery attrs==23.1.0 # via hypothesis +binaryornot==0.4.4 + # via cookiecutter boto3==1.28.53 # via # -r requirements/django.txt @@ -26,15 +30,24 @@ botocore==1.31.53 # -r requirements/django.txt # boto3 # s3transfer +certifi==2023.7.22 + # via requests +chardet==5.2.0 + # via binaryornot +charset-normalizer==3.2.0 + # via requests click==8.1.7 # via # click-log # code-annotations + # cookiecutter # edx-lint click-log==0.4.0 # via edx-lint code-annotations==1.5.0 # via edx-lint +cookiecutter==2.3.1 + # via xblock-sdk coverage[toml]==7.3.1 # via # -r requirements/test.in @@ -53,6 +66,7 @@ distlib==0.3.7 # -c https://raw.githubusercontent.com/edx/edx-lint/master/edx_lint/files/common_constraints.txt # -r requirements/django.txt # openedx-django-pyfs + # xblock-sdk edx-lint==5.3.4 # via -r requirements/test.in exceptiongroup==1.1.3 @@ -68,12 +82,16 @@ fs==2.4.16 # -r requirements/django.txt # fs-s3fs # openedx-django-pyfs + # xblock fs-s3fs==1.1.1 # via # -r requirements/django.txt # openedx-django-pyfs -hypothesis==6.86.2 + # xblock-sdk +hypothesis==6.87.0 # via -r requirements/test.in +idna==3.4 + # via requests inflect==7.0.0 # via jinja2-pluralize iniconfig==2.0.0 @@ -83,6 +101,7 @@ isort==5.12.0 jinja2==3.1.2 # via # code-annotations + # cookiecutter # diff-cover # jinja2-pluralize jinja2-pluralize==0.3.0 @@ -97,13 +116,24 @@ lazy==1.6 lazy-object-proxy==1.9.0 # via astroid lxml==4.9.3 + # via + # -r requirements/django.txt + # xblock + # xblock-sdk +mako==1.2.4 # via -r requirements/django.txt +markdown-it-py==3.0.0 + # via rich markupsafe==2.1.3 # via # -r requirements/django.txt # jinja2 + # mako + # xblock mccabe==0.7.0 # via pylint +mdurl==0.1.2 + # via markdown-it-py mock==5.1.0 # via -r requirements/test.in openedx-django-pyfs==3.4.0 @@ -129,13 +159,15 @@ py==1.11.0 # via tox pycodestyle==2.11.0 # via -r requirements/test.in -pydantic==2.3.0 +pydantic==2.4.0 # via inflect -pydantic-core==2.6.3 +pydantic-core==2.10.0 # via pydantic pygments==2.16.1 - # via diff-cover -pylint==2.17.5 + # via + # diff-cover + # rich +pylint==2.17.6 # via # -r requirements/test.in # edx-lint @@ -150,6 +182,8 @@ pylint-plugin-utils==0.8.2 # via # pylint-celery # pylint-django +pypng==0.20220715.0 + # via xblock-sdk pytest==7.4.2 # via # -r requirements/test.in @@ -162,23 +196,38 @@ pytest-django==4.5.2 python-dateutil==2.8.2 # via # -r requirements/django.txt + # arrow # botocore + # xblock python-slugify==8.0.1 - # via code-annotations + # via + # code-annotations + # cookiecutter pytz==2023.3.post1 # via # -r requirements/django.txt # django + # xblock pyyaml==6.0.1 # via # -r requirements/django.txt # code-annotations + # cookiecutter + # xblock +requests==2.31.0 + # via + # cookiecutter + # xblock-sdk +rich==13.5.3 + # via cookiecutter s3transfer==0.6.2 # via # -r requirements/django.txt # boto3 simplejson==3.19.1 - # via -r requirements/django.txt + # via + # -r requirements/django.txt + # xblock-sdk six==1.16.0 # via # -r requirements/django.txt @@ -217,18 +266,30 @@ typing-extensions==4.8.0 # pydantic # pydantic-core # pylint + # rich urllib3==1.26.16 # via # -r requirements/django.txt # botocore + # requests virtualenv==20.24.5 # via tox web-fragments==2.1.0 - # via -r requirements/django.txt + # via + # -r requirements/django.txt + # xblock + # xblock-sdk webob==1.8.7 - # via -r requirements/django.txt + # via + # -r requirements/django.txt + # xblock + # xblock-sdk wrapt==1.15.0 # via astroid +xblock==1.7.0 + # via xblock-sdk +xblock-sdk==0.7.0 + # via -r requirements/test.in # The following packages are considered to be unsafe in a requirements file: # setuptools diff --git a/setup.py b/setup.py index b22473b45..e921e4220 100755 --- a/setup.py +++ b/setup.py @@ -36,10 +36,16 @@ def get_version(*file_paths): 'xblock', 'xblock.django', 'xblock.reference', + 'xblock.utils', 'xblock.test', 'xblock.test.django', + 'xblock.test.utils', ], include_package_data=True, + package_data={ + 'xblock.utils': ['public/*', 'templates/*', 'templatetags/*'], + 'xblock.test.utils': ['data/*'], + }, install_requires=[ 'fs', 'lxml', diff --git a/xblock/__init__.py b/xblock/__init__.py index b68166211..014831f2c 100644 --- a/xblock/__init__.py +++ b/xblock/__init__.py @@ -27,4 +27,4 @@ def __init__(self, *args, **kwargs): # without causing a circular import xblock.fields.XBlockMixin = XBlockMixin -__version__ = '1.7.0' +__version__ = '1.8.0' diff --git a/xblock/test/settings.py b/xblock/test/settings.py index ee62f3874..5fb5608d9 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..c2832cdb1 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, "ambiguous_block_1") def test_plugin_caching(): plugin.PLUGIN_CACHE = {} assert _num_plugins_cached() == 0 - XBlock.load_class("thumbs") + XBlock.load_class("ambiguous_block_1") assert _num_plugins_cached() == 1 - XBlock.load_class("thumbs") + XBlock.load_class("ambiguous_block_1") assert _num_plugins_cached() == 1 diff --git a/xblock/test/utils/__init__.py b/xblock/test/utils/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/xblock/test/utils/data/__init__.py b/xblock/test/utils/data/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/xblock/test/utils/data/another_template.xml b/xblock/test/utils/data/another_template.xml new file mode 100644 index 000000000..df6391b1f --- /dev/null +++ b/xblock/test/utils/data/another_template.xml @@ -0,0 +1 @@ +This is an even simpler xml template. diff --git a/xblock/test/utils/data/l10n_django_template.txt b/xblock/test/utils/data/l10n_django_template.txt new file mode 100644 index 000000000..9d796d378 --- /dev/null +++ b/xblock/test/utils/data/l10n_django_template.txt @@ -0,0 +1,3 @@ +{% load l10n %} +{{ 1000|localize }} +{{ 1000|unlocalize }} diff --git a/xblock/test/utils/data/simple_django_template.txt b/xblock/test/utils/data/simple_django_template.txt new file mode 100644 index 000000000..00566d218 --- /dev/null +++ b/xblock/test/utils/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/xblock/test/utils/data/simple_mako_template.txt b/xblock/test/utils/data/simple_mako_template.txt new file mode 100644 index 000000000..4f0a77a47 --- /dev/null +++ b/xblock/test/utils/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/xblock/test/utils/data/simple_template.xml b/xblock/test/utils/data/simple_template.xml new file mode 100644 index 000000000..e60fdc535 --- /dev/null +++ b/xblock/test/utils/data/simple_template.xml @@ -0,0 +1,6 @@ + + This is a simple xml template. + + {{url_name}} + + diff --git a/xblock/test/utils/data/trans_django_template.txt b/xblock/test/utils/data/trans_django_template.txt new file mode 100644 index 000000000..b144d96f6 --- /dev/null +++ b/xblock/test/utils/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/xblock/test/utils/data/translations/eo/LC_MESSAGES/text.mo b/xblock/test/utils/data/translations/eo/LC_MESSAGES/text.mo new file mode 100644 index 000000000..d931e6863 Binary files /dev/null and b/xblock/test/utils/data/translations/eo/LC_MESSAGES/text.mo differ diff --git a/xblock/test/utils/data/translations/eo/LC_MESSAGES/text.po b/xblock/test/utils/data/translations/eo/LC_MESSAGES/text.po new file mode 100644 index 000000000..81052c559 --- /dev/null +++ b/xblock/test/utils/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/xblock/test/utils/test_helpers.py b/xblock/test/utils/test_helpers.py new file mode 100644 index 000000000..2c342a2c5 --- /dev/null +++ b/xblock/test/utils/test_helpers.py @@ -0,0 +1,81 @@ +""" +Tests for helpers.py +""" + +import unittest + +from workbench.runtime import WorkbenchRuntime + +from xblock.core import XBlock +from xblock.utils.helpers import child_isinstance + + +# pylint: disable=unnecessary-pass +class DogXBlock(XBlock): + """ Test XBlock representing any dog. Raises error if instantiated. """ + pass + + +# pylint: disable=unnecessary-pass +class GoldenRetrieverXBlock(DogXBlock): + """ Test XBlock representing a golden retriever """ + pass + + +# pylint: disable=unnecessary-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 + """ + runtime = WorkbenchRuntime() + root_id = runtime.parse_xml_string(' ') + root = runtime.get_block(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 + """ + runtime = WorkbenchRuntime() + root_id = runtime.parse_xml_string(' ') + root = runtime.get_block(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/xblock/test/utils/test_publish_event.py b/xblock/test/utils/test_publish_event.py new file mode 100644 index 000000000..6c463495c --- /dev/null +++ b/xblock/test/utils/test_publish_event.py @@ -0,0 +1,100 @@ +""" +Test cases for xblock/utils/publish_event.py +""" + + +import unittest + +import simplejson as json + +from xblock.utils.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): + """ + Test cases for PublishEventMixin + """ + 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/xblock/test/utils/test_resources.py b/xblock/test/utils/test_resources.py new file mode 100644 index 000000000..d95b0114d --- /dev/null +++ b/xblock/test/utils/test_resources.py @@ -0,0 +1,234 @@ +""" +Tests for resources.py +""" + + +import gettext +import unittest +from unittest.mock import patch, DEFAULT + +from pkg_resources import resource_filename + +from xblock.utils.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): + """ + Unit Tests for ResourceLoader + """ + + 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("xblock.test.utils.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/xblock/test/utils/test_settings.py b/xblock/test/utils/test_settings.py new file mode 100644 index 000000000..f5593a553 --- /dev/null +++ b/xblock/test/utils/test_settings.py @@ -0,0 +1,163 @@ +""" +Test cases for xblock/utils/settings.py +""" +import itertools +import unittest +from unittest.mock import Mock, MagicMock, patch + +import ddt + +from xblock.core import XBlock +from xblock.utils.settings import XBlockWithSettingsMixin, ThemableXBlockMixin + + +@XBlock.wants('settings') +class DummyXBlockWithSettings(XBlock, XBlockWithSettingsMixin, ThemableXBlockMixin): + """ + A dummy XBlock test class provides configurable theme support via Settings Service + """ + block_settings_key = 'dummy_settings_bucket' + default_theme_config = { + 'package': 'xblock_utils', + 'locations': ['qwe.css'] + } + + +@XBlock.wants('settings') +class OtherXBlockWithSettings(XBlock, XBlockWithSettingsMixin, ThemableXBlockMixin): + """ + Another XBlock test class provides configurable theme support via Settings Service + """ + 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): + """ + Test cases for XBlockWithSettingsMixin + """ + 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 TestThemableXBlockMixin(unittest.TestCase): + """ + Test cases for ThemableXBlockMixin + """ + 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("xblock.utils.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("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) + + 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("xblock.utils.settings.ResourceLoader.load_unicode") as patched_load_unicode: + xblock.include_theme_files(fragment) + patched_load_unicode.assert_not_called() diff --git a/xblock/utils/__init__.py b/xblock/utils/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/xblock/utils/helpers.py b/xblock/utils/helpers.py new file mode 100644 index 000000000..941daa3b4 --- /dev/null +++ b/xblock/utils/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/xblock/utils/public/studio_container.js b/xblock/utils/public/studio_container.js new file mode 100644 index 000000000..90d7cfaca --- /dev/null +++ b/xblock/utils/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/xblock/utils/public/studio_edit.js b/xblock/utils/public/studio_edit.js new file mode 100644 index 000000000..499319c45 --- /dev/null +++ b/xblock/utils/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/xblock/utils/publish_event.py b/xblock/utils/publish_event.py new file mode 100644 index 000000000..570b182ec --- /dev/null +++ b/xblock/utils/publish_event.py @@ -0,0 +1,38 @@ +""" +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/xblock/utils/resources.py b/xblock/utils/resources.py new file mode 100644 index 000000000..1066ffd59 --- /dev/null +++ b/xblock/utils/resources.py @@ -0,0 +1,107 @@ +""" +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.lookup import TemplateLookup as MakoTemplateLookup +from mako.template import Template as MakoTemplate + + +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': 'xblock.utils.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 + Note: This function has been deprecated. Consider using Django templates or React UI instead of mako. + """ + warnings.warn( + 'ResourceLoader.render_mako_template has been deprecated. ' + 'Use Django templates or React UI instead of mako.', + DeprecationWarning, stacklevel=3, + ) + 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/xblock/utils/settings.py b/xblock/utils/settings.py new file mode 100644 index 000000000..31c3b9a8f --- /dev/null +++ b/xblock/utils/settings.py @@ -0,0 +1,88 @@ +""" +This module contains a mixins that allows third party XBlocks to access Settings Service in edX LMS. +""" + +from xblock.utils.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 settings 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/xblock/utils/studio_editable.py b/xblock/utils/studio_editable.py new file mode 100644 index 000000000..b705854cb --- /dev/null +++ b/xblock/utils/studio_editable.py @@ -0,0 +1,511 @@ +""" +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 web_fragments.fragment import Fragment + +from xblock.core import XBlock, XBlockMixin +from xblock.exceptions import JsonHandlerError, NoSuchViewError +from xblock.fields import Scope, JSONField, List, Integer, Float, Boolean, String, DateTime +from xblock.utils.resources import ResourceLoader +from xblock.validation import Validation + +# 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/xblock/utils/templates/add_buttons.html b/xblock/utils/templates/add_buttons.html new file mode 100644 index 000000000..f9b133210 --- /dev/null +++ b/xblock/utils/templates/add_buttons.html @@ -0,0 +1,22 @@ +{% load i18n %} + +
+
+
{% trans "Add New Component" %}
+ +
+
diff --git a/xblock/utils/templates/default_preview_view.html b/xblock/utils/templates/default_preview_view.html new file mode 100644 index 000000000..d18c90359 --- /dev/null +++ b/xblock/utils/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/xblock/utils/templates/studio_edit.html b/xblock/utils/templates/studio_edit.html new file mode 100644 index 000000000..d75818218 --- /dev/null +++ b/xblock/utils/templates/studio_edit.html @@ -0,0 +1,113 @@ +{% load i18n %} +
+
+
    + {% for field in fields %} + + {% endfor %} +
+
+ +
diff --git a/xblock/utils/templatetags/__init__.py b/xblock/utils/templatetags/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/xblock/utils/templatetags/i18n.py b/xblock/utils/templatetags/i18n.py new file mode 100644 index 000000000..0b8634d08 --- /dev/null +++ b/xblock/utils/templatetags/i18n.py @@ -0,0 +1,73 @@ +""" +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))