diff --git a/source/developers/concepts/about_xblock_asides.rst b/source/developers/concepts/about_xblock_asides.rst new file mode 100644 index 000000000..ca518da12 --- /dev/null +++ b/source/developers/concepts/about_xblock_asides.rst @@ -0,0 +1,245 @@ +.. _About XBlock Asides: + +################### +About XBlock Asides +################### + +.. tags:: developer, concept + +An XBlock Aside is a class that injects content into the rendered views of +existing XBlocks without modifying those XBlocks. Asides let you add behavior, +data, and UI elements to many XBlock instances at once, across XBlock types you +do not own, while preserving the host XBlock's code, fields, and Open Learning +XML (OLX) representation. + +.. contents:: Contents + :local: + :depth: 1 + +What an Aside Is +**************** + +An Aside is a Python class that subclasses :class:`~xblock.core.XBlockAside`, +declares one or more view-injection methods using the +:func:`~xblock.core.XBlockAside.aside_for` decorator, and is registered with +the platform through a Python entry point in the ``xblock_asides.v1`` group. +When the platform renders an XBlock view, the runtime collects every +applicable Aside, invokes its matching Aside view, and appends the resulting +fragments to the host XBlock's rendered fragment. + +An Aside is **not** a child XBlock. It does not appear in the course outline, +it does not have its own URL, and it cannot be added to a course like a +regular block. It exists only in relation to a host block, and its lifecycle +is bound to that host block's lifecycle. + +For the precise API surface, see :ref:`XBlock Asides Reference`. + +The Problem Asides Solve +************************ + +When you want to enhance the behavior of an XBlock that you did not write, +you have three options: + +#. Fork the XBlock and modify it directly. +#. Replace the XBlock with a new XBlock that wraps the original. +#. Attach an Aside to the existing XBlock. + +The first two options carry significant costs. Forking creates a parallel +codebase that must be maintained against upstream changes. Replacing the +XBlock requires every existing course that uses the original to migrate, and +it does not scale when you want to enhance many different XBlock types in the +same way. + +Asides solve this by externalizing the enhancement. The host XBlock is not +modified. The same Aside can apply to a Video block, a Problem block, or any +other block type, by overriding a single classmethod. Asides can serialize +their own scoped fields during course import and export. + +Reach for an Aside when all of the following are true: + +* You want to enhance one or more existing XBlock types without forking them. +* The enhancement is conceptually layered on top of the block, not a + replacement for any of its behavior. +* The enhancement should apply to many block instances, possibly across + block types, without per-instance configuration in the course outline. +* The enhancement may need its own settings or stored data, scoped to the + block instance. + +Reach for something else when: + +* You are creating a brand new piece of course content. Write an XBlock. +* You only need to react to platform events. Consider an Open edX event + receiver. + +How an Aside Relates to Its Host Block +************************************** + +The runtime maintains a many-to-many relationship between asides and host +blocks at runtime, but each Aside instance is bound to exactly one host block +during a single render. The relationship is established in three stages. + +Per-Block Filtering +=================== + +For each candidate Aside type, the runtime instantiates the Aside and asks +it whether it should apply to this specific block by calling its +:meth:`~xblock.core.XBlockAside.should_apply_to_block` classmethod. The +default implementation returns ``True``. Real-world asides almost always +override this method to restrict themselves to specific block types, course +contexts, or feature flags. + +Rendering and Layout +==================== + +For each Aside that survives filtering, the runtime invokes the Aside method +that was decorated with ``@XBlockAside.aside_for(view_name)`` for the view +being rendered. The Aside method returns a ``Fragment``, the runtime wraps +that fragment with identifying markup, and the runtime appends the wrapped +fragment to the host block's rendered output. A runtime can override +:meth:`~xblock.runtime.Runtime.layout_asides` to control where and how the +Aside fragments are placed. + +Why Asides Are Worth the Trouble +******************************** + +The framing above describes the trade-offs from the perspective of someone +choosing among extension mechanisms. The deeper reasons asides exist, and +remain useful, come from the production deployments that depend on them. + +Multiple Block Types, One Implementation +======================================== + +A single Aside class can decorate Video blocks, Problem blocks, and any +other block type the author chooses, by checking ``block.category`` or +``block.scope_ids.block_type`` inside ``should_apply_to_block``. The MIT +Open Learning chat Aside, for example, attaches an "AskTIM" chat button to +both Video and Problem blocks from a single class, with one entry point. +Without asides, the same outcome would require either two parallel forks +or replacement blocks for both types. + +Course Author Control +===================== + +An Aside can declare its own scoped fields, just like an XBlock. By exposing +those fields in an author view, an Aside gives course authors a UI to enable +or disable the enhancement on a per-block basis. The settings are stored +under the Aside's own scope, not the host block's, so they are preserved +across exports and imports without any change to the host block's data +model. + +OLX Export and Import +===================== + +When a course is exported to OLX, the platform serializes each Aside as an +XML child element under its host block, named after the Aside's entry point +name. On import, the runtime reconstitutes the asides automatically. This +means an Aside-enhanced course is portable, with limitations described below. + +Real-World Examples +******************* + +Two implementations in the wild illustrate the range of what asides can +do. + +Rapid Response XBlock +===================== + +The `rapid-response-xblock`_ from MIT Open Learning is a single Aside that +applies to Problem blocks. It overlays an instructor-only control on the +problem in the LMS that lets a live instructor open and close response +windows during a lecture, and it renders a real-time chart of student +responses. Course authors enable it per problem in Studio. The repository +name calls it an "xblock" but the implementation is purely an Aside. + +Open Learning Chat Aside +======================== + +The `ol-openedx-chat`_ Aside, also from MIT Open Learning, attaches an +"AskTIM" chat button to Video and Problem blocks. The button opens a +context-aware chat drawer that streams messages to a backend large language +model, passing block-specific context such as a video transcript identifier +or a problem's siblings. A single Aside class, registered as one entry +point, handles both block types and uses ``should_apply_to_block`` to gate +on a course-level waffle flag and per-course settings. + +Limitations +*********** + +Asides are a real, working feature in production deployments, but the +ecosystem around them is incomplete. The list below is drawn from the +state of the codebase as of the Sumac release and from a 2025 Open edX +Conference talk by Peter Pinch of MIT Open Learning. Read it before +committing to an Aside-based design. + +No Authoring Story in the Course Authoring MFE +============================================== + +The Studio author view for an Aside is rendered by the legacy course +authoring frontend. The current Authoring micro-frontend has no +defined location to display Aside author UI. If your project depends on the +new MFE for authoring, plan to render the Aside's author UI through a +different mechanism, or accept that authors will use the legacy Studio for +this part of the workflow. + +Not All XBlocks Round-Trip Through OLX +====================================== + +OLX export and import for asides depends on the host XBlock cooperating +with the export process. Some XBlocks, including ORA2, do not preserve +Aside data through their export and import paths. If your Aside must +survive a course export and re-import on a course that uses one of these +blocks, test the round trip end to end before depending on it. + +Multiple Asides on a Single Block Are Not Reliable +================================================== + +The runtime supports multiple Aside types decorating the same block in +principle, but interactions between asides on the same block are not +well-tested. Two asides that both decorate ``student_view`` on the same +block may render correctly in isolation and break when combined. If you +need this, build a single Aside that composes both behaviors rather than +relying on two independent asides to coexist. + +JavaScript Library Loading Is Limited +===================================== + +Asides use the same fragment-based JavaScript loading mechanism as XBlocks, +which assumes a single set of static assets. If your Aside needs a JS +library that is not already loaded by the host page, you must add it +through the fragment, and you must handle ordering and conflicts yourself. +There is no shared Aside-level mechanism for declaring library dependencies. + +Where to Go Next +**************** + +If you are ready to build an Aside, start with +:ref:`XBlock Aside Quickstart`. If you already have a target XBlock in mind +and want a step-by-step recipe, read :ref:`Add an XBlock Aside`. For the +complete list of classes, decorators, methods, and entry points, consult +:ref:`XBlock Asides Reference`. + +.. _rapid-response-xblock: https://github.com/mitodl/open-edx-plugins/tree/main/src/rapid_response_xblock +.. _ol-openedx-chat: https://github.com/mitodl/open-edx-plugins/tree/main/src/ol_openedx_chat +.. _xblock-sdk: https://github.com/openedx/xblock-sdk + +.. seealso:: + + :ref:`XBlock Asides Reference` (reference) + The complete API surface for ``XBlockAside`` and its runtime hooks. + + :ref:`Add an XBlock Aside` (how-to) + A step-by-step recipe for adding an Aside to existing XBlocks. + + :ref:`XBlock Aside Quickstart` (quickstart) + A beginner-friendly walkthrough from zero to a running Aside. + + :ref:`Hooks Extension Framework` (concept) + An alternative extension mechanism for non-view-based behaviors. + +**Maintenance chart** + ++--------------+-------------------------------+----------------+--------------------------------+ +| Review Date | Working Group Reviewer | Release |Test situation | ++--------------+-------------------------------+----------------+--------------------------------+ +| | | | | ++--------------+-------------------------------+----------------+--------------------------------+ diff --git a/source/developers/how-tos/add-an-xblock-aside.rst b/source/developers/how-tos/add-an-xblock-aside.rst new file mode 100644 index 000000000..79ac0ec8e --- /dev/null +++ b/source/developers/how-tos/add-an-xblock-aside.rst @@ -0,0 +1,326 @@ +.. _Add an XBlock Aside: + +#################### +Add an XBlock Aside +#################### + +.. tags:: developer, how-to + +Add an XBlock Aside to attach behavior, UI, or stored data to one or more +existing XBlock types without modifying those XBlocks. Use this recipe +when you want a single, installable Python package that decorates the +views of XBlocks in your platform. + +For background on what asides are and when to use them, read +:ref:`About XBlock Asides`. For a complete API reference, see +:ref:`XBlock Asides Reference`. + +.. contents:: Contents + :local: + :depth: 1 + +Prerequisites +************* + +Before you start, make sure you have: + +* A working Open edX development environment in which you can install a + Python package and restart the LMS and Studio services. Tutor devstack + is the recommended environment. +* The installed Python version used by the target Open edX release. +* Familiarity with writing a basic XBlock view that returns a + :class:`~web_fragments.fragment.Fragment`. If you have never written + one, complete :ref:`XBlock Aside Quickstart` first. + +This recipe builds a feedback-badge Aside that adds a "Report an issue" +link to Problem and Video blocks, with a course-author setting to enable +or disable it per block. Substitute your own block types and behavior as +needed. + +Step 1: Scaffold a Python package +********************************* + +Create a new directory for the Aside package, with the layout below: + +.. code-block:: text + + feedback_badge_aside/ + ├── pyproject.toml + ├── feedback_badge_aside/ + │ ├── __init__.py + │ └── Aside.py + └── README.rst + +The package name (``feedback_badge_aside``) and the module name +(``Aside.py``) are conventions; pick names that describe your Aside. + +Populate ``pyproject.toml`` with the package metadata and a placeholder +for the entry point you will add in :ref:`Step 6 `. + +.. code-block:: toml + + [project] + name = "feedback-badge-Aside" + version = "0.1.0" + description = "An XBlock Aside that adds a feedback link to Problem and Video blocks." + requires-python = ">=X.Y" # set to the minimum Python version for the target Open edX release + dependencies = [ + "XBlock", + "web-fragments", + ] + + [build-system] + requires = ["setuptools>=61.0"] + build-backend = "setuptools.build_meta" + +Step 2: Define the Aside class +****************************** + +In ``feedback_badge_aside/Aside.py``, define a subclass of +:class:`~xblock.core.XBlockAside`. + +.. code-block:: python + + from xblock.core import XBlockAside + + + class FeedbackBadgeAside(XBlockAside): + """Adds a feedback link to learner-facing views of supported blocks.""" + +The class name does not need to match the entry point name, but keeping +them consistent makes debugging easier. + +Step 3: Declare fields for course-author control +************************************************ + +Add a Boolean field that course authors can toggle to enable or disable +the Aside on a per-block basis. Scope the field to ``Scope.settings``, +which means the value is stored per block and travels with the course in +OLX export and import. + +.. code-block:: python + + from xblock.core import XBlockAside + from xblock.fields import Boolean, Scope + + + class FeedbackBadgeAside(XBlockAside): + """Adds a feedback link to learner-facing views of supported blocks.""" + + enabled = Boolean( + display_name="Show feedback link", + default=True, + scope=Scope.settings, + help="Whether to show a 'Report an issue' link on this block.", + ) + +Step 4: Decorate the views you want to inject into +************************************************** + +Add one method per XBlock view you want to decorate, using +:meth:`~xblock.core.XBlockAside.aside_for`. The method takes ``self``, +the host ``block``, and an optional ``context`` dictionary, and returns +a :class:`~web_fragments.fragment.Fragment`. + +.. code-block:: python + + from web_fragments.fragment import Fragment + from xblock.core import XBlockAside + from xblock.fields import Boolean, Scope + + + class FeedbackBadgeAside(XBlockAside): + """Adds a feedback link to learner-facing views of supported blocks.""" + + enabled = Boolean( + display_name="Show feedback link", + default=True, + scope=Scope.settings, + help="Whether to show a 'Report an issue' link on this block.", + ) + + @XBlockAside.aside_for("student_view") + def student_view_aside(self, block, context=None): + """Render the feedback link for the learner view.""" + if not self.enabled: + return Fragment("") + + block_id = block.scope_ids.usage_id.block_id + html = ( + f'Report an issue' + ) + return Fragment(html) + + @XBlockAside.aside_for("studio_view") + def studio_view_aside(self, block, context=None): + """Render the author-side toggle UI in Studio.""" + checked = "checked" if self.enabled else "" + html = ( + f'' + ) + return Fragment(html) + +For production code, render templates from files with the runtime's +template service rather than building HTML strings inline. The strings +above keep the example readable. + +Step 5: Filter to specific block types +************************************** + +By default, an Aside applies to every block. Override +:meth:`~xblock.core.XBlockAside.should_apply_to_block` to restrict the +Aside to the block types you support. + +.. code-block:: python + + @classmethod + def should_apply_to_block(cls, block): + """Apply this Aside to Problem and Video blocks only.""" + block_type = getattr(block, "category", None) + return block_type in {"problem", "video"} + +Add this classmethod to ``FeedbackBadgeAside``. Without it, the Aside +would attempt to render on every block in every course, including blocks +where the markup makes no sense. + +For more sophisticated filtering, ``should_apply_to_block`` can also +inspect: + +* ``block.scope_ids.usage_id.context_key`` to gate on a course or + library. +* Platform feature flags such as Waffle flags. +* Course-level settings retrieved through a runtime service. + +If your filter consults course settings or feature flags, guard against +the import and export paths where these may not be available; see +:ref:`About XBlock Asides` for the relevant limitations. + +.. _register entry point: + +Step 6: Register the Aside as an entry point +******************************************** + +In ``pyproject.toml``, add an entry point in the ``xblock_asides.v1`` +group. The entry point name on the left side of the equals sign becomes +the Aside's type name and is used as the XML tag during OLX +serialization. + +.. code-block:: toml + + [project.entry-points."xblock_asides.v1"] + feedback_badge = "feedback_badge_aside.Aside:FeedbackBadgeAside" + +Choose a type name that is unlikely to collide with other asides on the +same deployment. Treat the name as a stable public identifier; renaming +it later breaks OLX round-trips of any course that has used the Aside. + +Step 7: Install the package and restart services +************************************************ + +Install the package into the LMS and Studio Python environments. With +Tutor: + +.. code-block:: bash + + tutor mounts add /path/to/feedback_badge_aside + tutor dev launch + +Step 8: Enable asides in the LMS +******************************** + +The edx-platform LMS gates Aside rendering on a Django configuration +model, ``XBlockAsidesConfig``, defined in +``lms/djangoapps/lms_xblock/models.py``. Until this model has an enabled +revision, no Aside renders in the LMS regardless of installation or +registration. + +Open the LMS Django admin and create a new configuration revision: + +.. code-block:: text + + http:///admin/lms_xblock/xblockasidesconfig/ + +Click :guilabel:`Add`, check :guilabel:`Enabled`, and save. The model is +a ``ConfigurationModel`` (revision-based), so each save creates a new +revision and the most recent enabled revision is treated as current. + +The same form has a ``Disabled blocks`` field, a space-separated list of +block types on which asides will **never** render in the LMS. The +default value is ``about course_info static_tab``. If your Aside should +apply to one of these block types, remove that type from the list. + +There is no per-course allowlist and no per-Aside-type allowlist. Once +``XBlockAsidesConfig`` is enabled and your Aside's host block type is +not in ``disabled_blocks``, the runtime offers your Aside to every +matching block in every course. Per-Aside filtering happens through +your Aside's own ``should_apply_to_block`` classmethod, which you wrote +in Step 5. + +The Studio runtime does not consult this configuration. Asides render +in Studio author views independently of ``XBlockAsidesConfig``, as soon +as they are installed and registered. + +Step 9: Verify the Aside is rendering +************************************* + +Open a course that contains a Problem or Video block, view it as a +learner, and confirm the feedback link appears at the bottom of the +block. To verify the author-side UI, open the same block in Studio and +confirm the toggle appears in the studio view. + +If the Aside does not appear: + +#. Check that the entry point is registered. Run: + + .. code-block:: python + + from xblock.core import XBlockAside + print(list(XBlockAside.load_classes())) + + in a Django shell. Your Aside's type name should be in the list. + +#. Check that ``should_apply_to_block`` returns ``True`` for the block + you are testing. +#. Check the LMS and Studio logs for any exceptions raised inside your + Aside view. + +Next Steps +********** + +Once the basic Aside is working, common follow-ups include: + +* **Add an AJAX handler.** Decorate a method with ``@XBlock.handler`` + and call it from the rendered fragment with + ``self.runtime.handler_url(self, "handler_name")``. +* **Render from templates.** Use the runtime's template service to + render HTML from ``.html`` files in your package's static assets. +* **Persist user-specific state.** Add fields with + ``Scope.user_state`` to store per-learner data alongside the Aside. +* **Customize layout.** If you need the Aside to render somewhere other + than after the host block, override the runtime's ``layout_asides`` + in your platform integration. + +For the complete API surface, see :ref:`XBlock Asides Reference`. For +the conceptual background, including known limitations of the Aside +mechanism, see :ref:`About XBlock Asides`. + +.. seealso:: + + :ref:`About XBlock Asides` (concept) + Why asides exist, what problem they solve, and current limitations. + + :ref:`XBlock Asides Reference` (reference) + The complete API surface for ``XBlockAside`` and its runtime hooks. + + :ref:`XBlock Aside Quickstart` (quickstart) + A beginner-friendly walkthrough from zero to a running Aside. + +**Maintenance chart** + ++--------------+-------------------------------+----------------+--------------------------------+ +| Review Date | Working Group Reviewer | Release |Test situation | ++--------------+-------------------------------+----------------+--------------------------------+ +| | | | | ++--------------+-------------------------------+----------------+--------------------------------+ diff --git a/source/developers/quickstarts/quickstart_xblock_aside.rst b/source/developers/quickstarts/quickstart_xblock_aside.rst new file mode 100644 index 000000000..3ae66b2ca --- /dev/null +++ b/source/developers/quickstarts/quickstart_xblock_aside.rst @@ -0,0 +1,264 @@ +.. _XBlock Aside Quickstart: + +########################################## +Quickstart: Build Your First XBlock Aside +########################################## + +.. tags:: developer, quickstart + +Build, install, and run a minimal XBlock Aside in a Tutor development +environment. By the end of this quickstart, you will have an Aside that +adds a small banner to every Problem block in a course, and you will +understand the four moving parts every Aside has: the class, the +decorated views, the entry point, and the install step. + +This quickstart deliberately keeps the Aside trivial. For a more +realistic recipe, work through :ref:`Add an XBlock Aside` next. For the +conceptual background, see :ref:`About XBlock Asides`. + +.. contents:: Contents + :local: + :depth: 1 + +Prerequisites +************* + +You need: + +* A running Tutor development environment with the LMS and Studio + services accessible. Tutor's `Getting started`_ guide walks through + the install if you do not have one yet. +* The installed Python version used by the target Open edX release. +* A course in your devstack with at least one Problem block. The Tutor + demo course works. +* A text editor and a terminal. + +You do **not** need: + +* Prior experience writing XBlocks. The Aside in this quickstart uses + plain Python and a hardcoded HTML string. +* Familiarity with the Open Learning XML (OLX) format. + +Step 1: Create a Python package +******************************* + +In a working directory of your choice, create the following file +structure: + +.. code-block:: text + + hello_aside/ + ├── pyproject.toml + └── hello_aside/ + ├── __init__.py + └── Aside.py + +Make ``__init__.py`` an empty file. The remaining steps describe what to +put in the other two files. + +Step 2: Write the Aside class +***************************** + +In ``hello_aside/Aside.py``, paste the following code: + +.. code-block:: python + + from web_fragments.fragment import Fragment + from xblock.core import XBlockAside + + + class HelloAside(XBlockAside): + """A trivial Aside that prints a banner above every Problem block.""" + + @XBlockAside.aside_for("student_view") + def student_view_aside(self, block, context=None): + return Fragment( + '
' + 'Hello from an XBlock Aside!' + '
' + ) + + @classmethod + def should_apply_to_block(cls, block): + return getattr(block, "category", None) == "problem" + +This is the entire implementation. Three things to notice: + +* The class subclasses :class:`~xblock.core.XBlockAside`. +* The :meth:`~xblock.core.XBlockAside.aside_for` decorator marks + ``student_view_aside`` as the method to call when an XBlock's + ``student_view`` is being rendered. +* :meth:`~xblock.core.XBlockAside.should_apply_to_block` restricts the + Aside to Problem blocks. Without it, the banner would appear on every + block in every course. + +Step 3: Configure the package +***************************** + +In ``hello_aside/pyproject.toml``, paste: + +.. code-block:: toml + + [project] + name = "hello-Aside" + version = "0.1.0" + requires-python = ">=X.Y" # set to the minimum Python version for the target Open edX release + dependencies = ["XBlock", "web-fragments"] + + [project.entry-points."xblock_asides.v1"] + hello_aside = "hello_aside.Aside:HelloAside" + + [build-system] + requires = ["setuptools>=61.0"] + build-backend = "setuptools.build_meta" + +The critical line is the one under +``[project.entry-points."xblock_asides.v1"]``. It tells the Open edX +platform that a class called ``HelloAside``, in the ``hello_aside.Aside`` +module, should be loaded as an Aside under the type name +``hello_aside``. This entry point group is how every Aside is +discovered. + +Step 4: Install the package into Tutor +************************************** + +From the directory containing ``hello_aside/``, mount the package into +Tutor and relaunch the development environment: + +.. code-block:: bash + + tutor mounts add ./hello_aside + tutor dev launch + +The ``mounts add`` command tells Tutor to install the local package into +both the LMS and Studio containers each time they start. The +``dev launch`` command rebuilds and restarts the containers so the new +Aside is picked up. + +If you are not using Tutor, install the package directly into the LMS +and Studio Python environments with ``pip install -e ./hello_aside``, +then restart both services. + +Step 5: Enable the Aside system in the LMS +****************************************** + +The LMS gates Aside rendering on a global Django configuration model, +``XBlockAsidesConfig``. Until that model is enabled, no Aside renders on +any block, regardless of whether it is registered or installed. + +Open the LMS Django admin in your browser: + +.. code-block:: text + + http:///admin/lms_xblock/xblockasidesconfig/ + +Click :guilabel:`Add` to create a new revision. Check :guilabel:`Enabled` +and click :guilabel:`Save`. The new revision becomes the current +configuration immediately. + +The same form has a ``Disabled blocks`` field that holds a +space-separated list of block types on which asides will **never** +render. The default value is ``about course_info static_tab``. The +``hello_aside`` example targets ``problem`` blocks, which are not in the +default disabled list, so no further changes are needed. + +The Studio runtime does not consult this model. Asides will render in +Studio author views as soon as they are installed, independently of +whether ``XBlockAsidesConfig`` is enabled. + +Step 6: Verify the Aside is rendering +************************************* + +Open a course in the LMS, navigate to a unit that contains a Problem +block, and confirm a light blue banner reading "Hello from an XBlock +Aside!" appears below the problem. If you see the banner, your Aside is +working. + +If the banner does not appear, work through these checks in order: + +#. **The Aside is registered.** Open a Django shell and run: + + .. code-block:: python + + from xblock.core import XBlockAside + print(list(XBlockAside.load_classes())) + + The list should include ``hello_aside``. If it does not, the entry + point is not installed; recheck Step 3 and Step 4. + +#. **The block type matches.** Your test block must have + ``category == "problem"``. A Video block, an HTML block, or a + Discussion block will not trigger the Aside. + +#. **No exceptions are being swallowed.** Check the LMS logs for any + exception raised inside ``student_view_aside`` or + ``should_apply_to_block``. The runtime catches some Aside exceptions + silently, which can make a broken Aside look like a missing one. + +What You Just Built +******************* + +You have a working Aside with all four required pieces: + +A class + ``HelloAside`` subclasses ``XBlockAside``. + +A decorated view + ``student_view_aside`` is decorated with + ``@XBlockAside.aside_for("student_view")``, so it is invoked + whenever the runtime renders any block's ``student_view``. + +A filter + ``should_apply_to_block`` restricts the Aside to Problem blocks. + +An entry point + The ``xblock_asides.v1`` entry point in ``pyproject.toml`` makes the + Aside discoverable by the platform at startup. + +Every production Aside has the same four pieces, plus additional +features such as scoped fields, AJAX handlers, author-side UI, and +template-rendered HTML. + +Where to Go Next +**************** + +To turn this trivial example into something useful: + +* **Add a course-author toggle.** Declare a ``Boolean`` field with + ``Scope.settings`` and conditionally render the banner based on its + value. The :ref:`Add an XBlock Aside` how-to walks through this. +* **Render from a template.** Replace the inline HTML string with a + template loaded from your package's static assets, rendered through + the runtime's template service. +* **Add an AJAX handler.** Decorate a method with ``@XBlock.handler`` + and call it from JavaScript in your fragment to support interactive + behavior. +* **Decorate the author view.** Add a second method decorated with + ``@XBlockAside.aside_for("author_view")`` to render an author-facing + preview in Studio. + +For each of these extensions, the :ref:`XBlock Asides Reference` is the +authoritative source. For the trade-offs and current limitations of the +Aside mechanism, read :ref:`About XBlock Asides` before scaling up your +design. + +.. _Getting started: https://docs.tutor.edly.io/quickstart.html + +.. seealso:: + + :ref:`About XBlock Asides` (concept) + Why asides exist, what problem they solve, and current limitations. + + :ref:`Add an XBlock Aside` (how-to) + A step-by-step recipe for adding an Aside to existing XBlocks. + + :ref:`XBlock Asides Reference` (reference) + The complete API surface for ``XBlockAside`` and its runtime hooks. + +**Maintenance chart** + ++--------------+-------------------------------+----------------+--------------------------------+ +| Review Date | Working Group Reviewer | Release |Test situation | ++--------------+-------------------------------+----------------+--------------------------------+ +| | | | | ++--------------+-------------------------------+----------------+--------------------------------+ diff --git a/source/developers/references/developer_guide/extending_platform/index.rst b/source/developers/references/developer_guide/extending_platform/index.rst index 584f582cb..9e136b677 100644 --- a/source/developers/references/developer_guide/extending_platform/index.rst +++ b/source/developers/references/developer_guide/extending_platform/index.rst @@ -7,3 +7,4 @@ Extending the edX Platform extending xblocks + xblock_asides diff --git a/source/developers/references/developer_guide/extending_platform/xblock_asides.rst b/source/developers/references/developer_guide/extending_platform/xblock_asides.rst new file mode 100644 index 000000000..7e3ce0b74 --- /dev/null +++ b/source/developers/references/developer_guide/extending_platform/xblock_asides.rst @@ -0,0 +1,401 @@ +.. _XBlock Asides Reference: + +####################### +XBlock Asides Reference +####################### + +.. tags:: developer, reference + +This reference describes the public API surface for XBlock Asides as +defined in the ``xblock`` package. It covers the +:class:`~xblock.core.XBlockAside` base class, the decorators and +classmethods that subclasses use, the runtime hooks that govern Aside +discovery and rendering, the entry point group that registers asides as +plugins, and the OLX serialization contract. + +For an introduction to what asides are and when to use them, see +:ref:`About XBlock Asides`. For a guided walkthrough, see +:ref:`Add an XBlock Aside` or :ref:`XBlock Aside Quickstart`. + +.. contents:: Contents + :local: + :depth: 1 + +The XBlockAside Class +********************* + +The base class for all asides is ``xblock.core.XBlockAside``. It inherits +from ``Plugin`` and ``Blocklike``, so an Aside can declare scoped fields, +mark methods as handlers, and be loaded as a plugin in the same way as an +XBlock. + +.. code-block:: python + + from xblock.core import XBlockAside + + class MyAside(XBlockAside): + """An Aside that decorates one or more XBlock views.""" + +Class Attributes +================ + +``entry_point`` + The Python entry point group used to discover Aside plugins. Set on the + base class to ``"xblock_asides.v1"``. Subclasses should not change this. + +``fields`` + The set of fields declared on the Aside, automatically collected from + class-level :class:`~xblock.fields.Field` declarations. Inherited from + ``Blocklike``. + +Decorators +********** + +``XBlockAside.aside_for(view_name)`` +==================================== + +A classmethod decorator that marks a method as the Aside view for the +named XBlock view. When the runtime renders an XBlock view called +``view_name``, every applicable Aside whose class declares a method +decorated with ``@XBlockAside.aside_for(view_name)`` is invoked, and the +returned fragments are appended to the host block's rendered fragment. + +**Signature of the decorated method** + +.. code-block:: python + + @XBlockAside.aside_for("student_view") + def student_view_aside(self, block, context=None): + ... + return Fragment(...) + +The decorated method takes: + +* ``self`` — the Aside instance. +* ``block`` — the host XBlock instance being rendered. +* ``context`` — an optional dictionary of context data passed by the + caller of ``render``. + +The method must return a :class:`~web_fragments.fragment.Fragment`. + +**Multiple views per Aside** + +A single method may decorate multiple views by stacking decorators, and a +single Aside class may define different methods for different views. + +.. code-block:: python + + class MyAside(XBlockAside): + + @XBlockAside.aside_for("student_view") + def student_view_aside(self, block, context=None): + return Fragment("
Student-side Aside
") + + @XBlockAside.aside_for("author_view") + @XBlockAside.aside_for("studio_view") + def studio_aside(self, block, context=None): + return Fragment("
Author/Studio Aside
") + +**Common view names** + +The view names commonly decorated by asides include: + ++----------------------+---------------------------------------------------+ +| View name | When the runtime renders it | ++======================+===================================================+ +| ``student_view`` | The learner-facing view of a block in the LMS. | ++----------------------+---------------------------------------------------+ +| ``author_view`` | The author-facing preview of a block in Studio. | ++----------------------+---------------------------------------------------+ +| ``studio_view`` | The author-facing edit form of a block in Studio. | ++----------------------+---------------------------------------------------+ + +XBlocks may define additional views; an Aside can decorate any of them by +name. + +Classmethods +************ + +``XBlockAside.should_apply_to_block(cls, block)`` +================================================= + +A classmethod that returns ``True`` if the Aside should apply to the given +``block``, and ``False`` otherwise. The default implementation returns +``True`` unconditionally. + +Override this method to restrict the Aside to specific block types, +courses, or feature flags. The runtime calls this method on every +Aside-block pair before rendering, and asides for which it returns +``False`` are skipped. + +.. code-block:: python + + @classmethod + def should_apply_to_block(cls, block): + block_type = getattr(block, "category", None) + return block_type in {"problem", "video"} + +The ``block`` argument is the host XBlock instance. Most filtering logic +inspects ``block.scope_ids.block_type``, ``block.category``, the course +context derived from ``block.scope_ids.usage_id``, or platform feature +flags. + +Instance Methods +**************** + +``aside_view_declaration(view_name)`` +===================================== + +Return the bound method on this Aside instance that is decorated with +``@XBlockAside.aside_for(view_name)``, or ``None`` if no such method +exists. The runtime uses this method to look up the Aside's view function +when rendering. Subclasses do not normally need to call it directly. + +``needs_serialization()`` +========================= + +Return ``True`` if the Aside has any field whose value differs from its +default. The default implementation iterates over the Aside's fields and +returns ``True`` if any field is set on this instance. The runtime calls +this method during OLX export to decide whether to serialize the Aside as +an XML element. Subclasses rarely need to override this. + +``parse_xml(node, runtime, keys)`` and ``add_xml_to_node(node)`` +================================================================ + +Inherited from ``Blocklike``. Override these to customize how the Aside is +read from and written to OLX. The default implementations serialize the +Aside's fields as XML attributes and child elements, identical to the +default XBlock behavior. + +Fields and Scopes +***************** + +An Aside declares fields the same way an XBlock does, with class-level +field declarations. Fields are scoped, and the scope determines where the +field's value is stored and which entities share it. + +.. code-block:: python + + from xblock.core import XBlockAside + from xblock.fields import Boolean, Scope, String + + class MyAside(XBlockAside): + enabled = Boolean( + display_name="Enabled", + default=False, + scope=Scope.settings, + help="Whether this Aside is active for this block.", + ) + + last_message = String( + default="", + scope=Scope.user_state, + help="The most recent message for this user-block pair.", + ) + +The supported scopes are the standard XBlock scopes from +:mod:`xblock.fields`: ``Scope.content``, ``Scope.settings``, +``Scope.user_state``, ``Scope.user_state_summary``, +``Scope.preferences``, and ``Scope.user_info``. An Aside's field values +are stored under the Aside's own usage ID, separate from the host block's +field values. + +Handlers +******** + +An Aside can define AJAX handlers using the standard +``@XBlock.handler`` decorator from ``xblock.core``. The handler URL is +generated by the runtime in the same way as for an XBlock, so an Aside's +view fragment can call its own handler with +``self.runtime.handler_url(self, "handler_name")``. + +.. code-block:: python + + from xblock.core import XBlock, XBlockAside + + class MyAside(XBlockAside): + + @XBlock.handler + def submit_feedback(self, request, suffix=""): + ... + return Response(json_body={"ok": True}) + +The handler signature, request object, and response handling are +identical to XBlock handlers. + +Entry Point Registration +************************ + +An Aside is discovered by the runtime through a Python entry point in the +``xblock_asides.v1`` group. Declare the entry point in the package's +``pyproject.toml``: + +.. code-block:: toml + + [project.entry-points."xblock_asides.v1"] + my_aside = "my_package.Aside:MyAside" + +Or in ``setup.py``: + +.. code-block:: python + + setup( + entry_points={ + "xblock_asides.v1": [ + "my_aside = my_package.Aside:MyAside", + ], + }, + ) + +The entry point name on the left of the equals sign is the Aside's +**type name**. It is used as the XML tag when the Aside is serialized to +OLX, and as the key in :class:`~xblock.scopes.ScopeIds` when an Aside +instance is constructed. Choose a name that is unique across all +installed asides on a deployment. + +Runtime API +*********** + +The methods listed below are defined on +:class:`~xblock.runtime.Runtime` and govern Aside discovery, instantiation, +and rendering. Aside subclasses do not normally call these directly. They +are documented here so runtime implementors and Aside authors can +understand the lifecycle. + +Discovery +========= + +``runtime.applicable_aside_types(block)`` + Return the list of Aside type names that may apply to ``block``. The + default implementation returns every Aside class registered through + the ``xblock_asides.v1`` entry point. A runtime may override this to + filter by user, course, or other context. + + The edx-platform LMS runtime overrides this through + ``lms_applicable_aside_types`` in + ``lms/djangoapps/lms_xblock/runtime.py``, which gates Aside rendering + on the ``XBlockAsidesConfig`` Django configuration model. When the + model's current revision has ``enabled=False``, no asides render in + the LMS. When enabled, asides do not render on block types listed in + the model's ``disabled_blocks`` field (default value: + ``"about course_info static_tab"``). The Studio runtime does not + apply this gate. See :ref:`Add an XBlock Aside` for the + administrative steps to enable the configuration. + +``runtime.load_aside_type(aside_type)`` + Return the :class:`XBlockAside` subclass corresponding to the given + ``aside_type`` string. Raises if no Aside is registered under that + name. + +Instantiation +============= + +``runtime.create_aside(block_type, keys)`` + Construct an Aside instance of the named ``block_type`` (the Aside + type name, despite the parameter name) with the given ``ScopeIds``. + Returns an :class:`XBlockAside` instance. + +``runtime.get_aside_of_type(block, aside_type)`` + Construct an Aside of the named type that is bound to the given host + ``block``. Generates the Aside's definition and usage IDs from the + host block's IDs using the runtime's ``id_generator``. Returns an + :class:`XBlockAside` instance. + +``runtime.get_aside(aside_usage_id)`` + Construct an Aside instance from a previously known Aside usage ID. + Used during OLX import and other paths where the Aside's identity is + already established. + +``runtime.get_asides(block)`` + Return the list of Aside instances that should decorate the given + ``block``. This method composes ``applicable_aside_types`` and each + Aside's ``should_apply_to_block`` filter, returning only the asides + for which both pass. + +Rendering +========= + +``runtime.render_asides(block, view_name, frag, context)`` + Called by the runtime's ``render`` method after the block's own view + has produced its fragment. Iterates over ``get_asides(block)``, looks + up each Aside's view declaration for ``view_name``, and dispatches + layout to ``layout_asides``. Returns the augmented + :class:`Fragment`. + +``runtime.layout_asides(block, context, frag, view_name, aside_frag_fns)`` + Execute the Aside view functions and combine their fragments with the + block's fragment. The default implementation appends each Aside's + wrapped fragment after the block's fragment. Override this to control + the placement, ordering, or conditional inclusion of asides. Any + override must call ``wrap_aside`` around each Aside fragment to + preserve client-side identification. + +``runtime.wrap_aside(block, Aside, view, frag, context)`` + Wrap an Aside's fragment with a ``
`` carrying the Aside's usage + ID, the host block's usage ID, and any JavaScript initialization + metadata. Override this if you need a different wrapping element or + different ``data-`` attributes. + +OLX Serialization +***************** + +When a course is exported to OLX, the runtime serializes every Aside +that returns ``True`` from ``needs_serialization()`` as an XML child +element of its host block. The XML tag of the Aside element is the +Aside's entry point name. Field values are written as attributes or +nested elements according to the Aside's ``add_xml_to_node`` +implementation. + +On import, the runtime detects Aside elements by looking up their tag +names in the registered ``xblock_asides.v1`` entry points. Tag names +that do not resolve to a registered Aside are ignored. Field values are +then read by the Aside's ``parse_xml`` implementation. + +Two practical consequences: + +* An Aside's data only round-trips through OLX if both the source and + destination platforms have the same Aside installed under the same + entry point name. +* Some XBlocks do not preserve Aside child elements through their own + export and import paths. See :ref:`About XBlock Asides` for the + current list of known issues. + +Render Pipeline Summary +*********************** + +The full sequence of calls when a runtime renders an XBlock view is: + +#. ``runtime.render(block, view_name, context)`` is called. +#. The runtime invokes the block's view function and obtains a fragment. +#. The runtime calls ``runtime.wrap_xblock`` on that fragment. +#. The runtime calls ``runtime.render_asides(block, view_name, frag, context)``. +#. ``render_asides`` calls ``runtime.get_asides(block)``, which uses + ``applicable_aside_types(block)`` and each Aside's + ``should_apply_to_block(block)`` to compute the filtered list. +#. For each surviving Aside, ``render_asides`` calls + ``Aside.aside_view_declaration(view_name)`` to find the matching + method. +#. ``render_asides`` calls ``layout_asides``, which invokes each Aside + view function, calls ``wrap_aside`` on each result, and appends the + wrapped fragments to the block's fragment. +#. The combined fragment is returned to the original caller. + +.. seealso:: + + :ref:`About XBlock Asides` (concept) + Why asides exist, what problem they solve, and current limitations. + + :ref:`Add an XBlock Aside` (how-to) + A step-by-step recipe for adding an Aside to existing XBlocks. + + :ref:`XBlock Aside Quickstart` (quickstart) + A beginner-friendly walkthrough from zero to a running Aside. + +**Maintenance chart** + ++--------------+-------------------------------+----------------+--------------------------------+ +| Review Date | Working Group Reviewer | Release |Test situation | ++--------------+-------------------------------+----------------+--------------------------------+ +| | | | | ++--------------+-------------------------------+----------------+--------------------------------+