diff --git a/cms/envs/common.py b/cms/envs/common.py index 258d72994c93..99c1e98a1069 100644 --- a/cms/envs/common.py +++ b/cms/envs/common.py @@ -701,6 +701,7 @@ # Detects user-requested locale from 'accept-language' header in http request 'django.middleware.locale.LocaleMiddleware', + 'openedx.core.djangoapps.plugins.middleware.ViewNameAndSlotMiddleware', 'codejail.django_integration.ConfigureCodeJailMiddleware', # catches any uncaught RateLimitExceptions and returns a 403 instead of a 500 diff --git a/cms/templates/base.html b/cms/templates/base.html index dacdb55a7105..bde2ed2f4a9e 100644 --- a/cms/templates/base.html +++ b/cms/templates/base.html @@ -14,6 +14,7 @@ from openedx.core.djangolib.js_utils import ( dump_js_escaped_json, js_escaped_string ) +from openedx.core.djangoapps.theming.templatetags.plugin_slot import plugin_slot from openedx.core.djangolib.markup import HTML from openedx.core.release import RELEASE_LINE %> @@ -75,9 +76,13 @@ <%include file="widgets/segment-io.html" /> <%block name="header_extras"> + ## xss-lint: disable=mako-invalid-html-filter + ${plugin_slot(context, 'studio.djangoapp', 'head-extra') | n} + ## xss-lint: disable=mako-invalid-html-filter + ${plugin_slot(context, 'studio.djangoapp', 'body-initial') | n} <%block name="view_notes"> ${_("Skip to main content")} @@ -169,5 +174,7 @@ <%include file="widgets/segment-io-footer.html" /> + ## xss-lint: disable=mako-invalid-html-filter + ${plugin_slot(context, 'studio.djangoapp', 'body-extra') | n} diff --git a/common/djangoapps/student/views/dashboard.py b/common/djangoapps/student/views/dashboard.py index 57d7ac46a17e..0436fc59183a 100644 --- a/common/djangoapps/student/views/dashboard.py +++ b/common/djangoapps/student/views/dashboard.py @@ -36,6 +36,7 @@ ) from openedx.core.djangoapps.credit.email_utils import get_credit_provider_attribute_values, make_providers_strings from openedx.core.djangoapps.plugins import constants as plugin_constants +from openedx.core.djangoapps.plugins.decorators import view_namespace from openedx.core.djangoapps.plugins.plugin_contexts import get_plugins_view_context from openedx.core.djangoapps.programs.models import ProgramsApiConfig from openedx.core.djangoapps.programs.utils import ProgramDataExtender, ProgramProgressMeter @@ -475,6 +476,7 @@ def get_dashboard_course_limit(): return course_limit +@view_namespace('students.views.dashboard') @login_required @ensure_csrf_cookie @add_maintenance_banner diff --git a/docs/guides/extension_points.rst b/docs/guides/extension_points.rst index 870e73efc222..4acd6dbb5707 100644 --- a/docs/guides/extension_points.rst +++ b/docs/guides/extension_points.rst @@ -105,6 +105,7 @@ Here are the different integration points that python plugins can use: - A "Django app plugin" is a self-contained Django `Application`_ that can define models (MySQL tables), new REST APIs, signal listeners, asynchronous tasks, and more. Even some parts of the core platform are implemented as Django app plugins, for better separation of concerns (``announcements``, ``credentials``, ``grades``, etc.) Read the `Django app plugin documentation`_ to learn more. Plugins can also inject custom data into django template contexts, to affect standard pages delivered by the core platform. See `Plugin Contexts`_ to learn more. + Plugins can also inject content into specific extendable areas (such as the header, footer or body) in django templates, to affect standard pages delivered by the core platform. See `Plugin Slots`_ to learn more. * - Course tab (``openedx.course_tab``) - Hold, Stable - A course tab plugin adds a new tab shown to learners within a course. ``courseware``, ``course_info``, and ``discussion`` are examples of built-in tab plugins. Read the `course tabs documentation`_ to learn more. @@ -134,6 +135,7 @@ Here are the different integration points that python plugins can use: .. _Application: https://docs.djangoproject.com/en/3.0/ref/applications/ .. _Django app plugin documentation: https://github.com/edx/edx-platform/blob/master/openedx/core/djangoapps/plugins/README.rst .. _Plugin Contexts: https://github.com/edx/edx-platform/blob/master/openedx/core/djangoapps/plugins/docs/decisions/0003-plugin-contexts.rst +.. _Plugin Slots: https://github.com/edx/edx-platform/blob/master/openedx/core/djangoapps/plugins/docs/decisions/0004-plugin-slots.rst .. _course tabs documentation: https://openedx.atlassian.net/wiki/spaces/AC/pages/30965919/Adding+a+new+course+tab .. |course_tools.py| replace:: ``course_tools.py`` .. _course_tools.py: https://github.com/edx/edx-platform/blob/master/openedx/features/course_experience/course_tools.py diff --git a/lms/djangoapps/courseware/views/index.py b/lms/djangoapps/courseware/views/index.py index 5dc879adf031..0dc76c564285 100644 --- a/lms/djangoapps/courseware/views/index.py +++ b/lms/djangoapps/courseware/views/index.py @@ -92,6 +92,8 @@ class CoursewareIndex(View): View class for the Courseware page. """ + slot_namespace = "courseware:index" + @cached_property def enable_unenrolled_access(self): return COURSE_ENABLE_UNENROLLED_ACCESS_FLAG.is_enabled(self.course_key) diff --git a/lms/djangoapps/discussion/django_comment_client/utils.py b/lms/djangoapps/discussion/django_comment_client/utils.py index a5cfa2a06279..1c70f2017f85 100644 --- a/lms/djangoapps/discussion/django_comment_client/utils.py +++ b/lms/djangoapps/discussion/django_comment_client/utils.py @@ -526,17 +526,6 @@ def __init__(self, html=''): super(HtmlResponse, self).__init__(html, content_type='text/plain') -class ViewNameMiddleware(MiddlewareMixin): - """ - Django middleware object to inject view name into request context - """ - def process_view(self, request, view_func, view_args, view_kwargs): - """ - Injects the view name value into the request context - """ - request.view_name = view_func.__name__ - - class QueryCountDebugMiddleware(MiddlewareMixin): """ This middleware will log the number of queries run diff --git a/lms/envs/common.py b/lms/envs/common.py index d29e173f412c..ee79cfe51167 100644 --- a/lms/envs/common.py +++ b/lms/envs/common.py @@ -1561,7 +1561,7 @@ def _make_locale_paths(settings): # pylint: disable=missing-function-docstring # Must be after DarkLangMiddleware. 'django.middleware.locale.LocaleMiddleware', - 'lms.djangoapps.discussion.django_comment_client.utils.ViewNameMiddleware', + 'openedx.core.djangoapps.plugins.middleware.ViewNameAndSlotMiddleware', 'codejail.django_integration.ConfigureCodeJailMiddleware', # catches any uncaught RateLimitExceptions and returns a 403 instead of a 500 diff --git a/lms/templates/main.html b/lms/templates/main.html index 48edc767a132..f4ce0009e442 100644 --- a/lms/templates/main.html +++ b/lms/templates/main.html @@ -24,6 +24,7 @@ from openedx.core.djangoapps.site_configuration import helpers as configuration_helpers from openedx.core.djangolib.js_utils import dump_js_escaped_json, js_escaped_string from openedx.core.release import RELEASE_LINE +from openedx.core.djangoapps.theming.templatetags.plugin_slot import plugin_slot from pipeline_mako import render_require_js_path_overrides %> @@ -133,6 +134,8 @@ <%include file="/courseware/experiments.html"/> <%include file="/experiments/user_metadata.html"/> <%static:optional_include_mako file="head-extra.html" is_theming_enabled="True" /> + ## xss-lint: disable=mako-invalid-html-filter + ${plugin_slot(context, 'lms.djangoapp', 'head-extra') | n} <%include file="widgets/optimizely.html" /> <%include file="widgets/segment-io.html" /> @@ -173,6 +176,8 @@ <%static:optional_include_mako file="body-initial.html" is_theming_enabled="True" /> +## xss-lint: disable=mako-invalid-html-filter +${plugin_slot(context, 'lms.djangoapp', 'body-initial')|n}
% if not disable_window_wrap:
@@ -210,6 +215,8 @@ <%static:optional_include_mako file="body-extra.html" is_theming_enabled="True" /> + ## xss-lint: disable=mako-invalid-html-filter + ${plugin_slot(context, 'lms.djangoapp', 'body-extra') | n} diff --git a/lms/templates/main_django.html b/lms/templates/main_django.html index 5a858546fdc7..4103b7766f5a 100644 --- a/lms/templates/main_django.html +++ b/lms/templates/main_django.html @@ -1,5 +1,5 @@ -{% load sekizai_tags i18n configuration theme_pipeline optional_include static %} +{% load sekizai_tags i18n configuration theme_pipeline optional_include static plugin_slot %} @@ -21,11 +21,13 @@ {% render_block "css" %} {% optional_include "head-extra.html"|microsite_template_path %} + {% plugin_slot "lms.djangoapp" "head-extra" %} + {% plugin_slot "lms.djangoapp" "body-initial" %} {% load render_bundle from webpack_loader %} {% render_bundle "commons" %}
@@ -46,6 +48,7 @@ {% javascript 'base_application' %} {% render_block "js" %} + {% plugin_slot "lms.djangoapp" "body-extra" %} diff --git a/openedx/core/djangoapps/plugins/README.rst b/openedx/core/djangoapps/plugins/README.rst index c2b76d4be9f7..a9ef7bc3e73c 100644 --- a/openedx/core/djangoapps/plugins/README.rst +++ b/openedx/core/djangoapps/plugins/README.rst @@ -189,7 +189,7 @@ class:: # Configuration setting for Plugin Contexts for this app. PluginContexts.CONFIG: { - # Configure the Plugin Signals for each Project Type, as needed. + # Configure the Plugin Contexts for each Project Type, as needed. ProjectType.LMS: { # Key is the view that the app wishes to add context to and the value @@ -197,6 +197,22 @@ class:: # when called with the original context u'course_dashboard': u'my_app.context_api.get_dashboard_context' } + }, + + # Configuration settings for Plugin Slots for this app. + PluginSlots.CONFIG: { + + # Configure Plugin Slots for each Project Type, as needed. + ProjectType.LMS: { + + # Specify the namespace in which the slot is to be applied + u"namespace" : { + # Key is the slot in the current Project Type that the app wants to inject + # content into and the value is the function within the app that will return + # rendered content. The function will be passed minimal context relevant to it. + u'head-extra': u'my_app.slots_api.get_slot_content' + } + } } } @@ -235,6 +251,14 @@ OR use string constants when they cannot import from djangoapps.plugins:: u'lms.djangoapp': { 'course_dashboard': u'my_app.context_api.get_dashboard_context' } + }, + + u'view_slots_config': { + u'lms.djangoapp': { + u'namespace': { + u'head-extra': u'my_app.slots_api.get_head_extra_content' + } + } } } diff --git a/openedx/core/djangoapps/plugins/constants.py b/openedx/core/djangoapps/plugins/constants.py index a887162c3abf..7051b8766b22 100644 --- a/openedx/core/djangoapps/plugins/constants.py +++ b/openedx/core/djangoapps/plugins/constants.py @@ -85,3 +85,20 @@ class PluginContexts(object): additional views it would like to add context into. """ CONFIG = u"view_context_config" + + +class PluginSlots(object): + """ + The PluginSlots enum defines dictionary field names (and default) + that can be specified by a Plugin App in order to configure the view + slots into which it would like to add addition content. + """ + CONFIG = u'slots_config' + + class LMSSlots: + HEAD_EXTRA = u'head-extra' + BODY_INITIAL = u'body-initial' + BODY_EXTRA = u'body-extra' + + class StudioSlots(LMSSlots): + pass diff --git a/openedx/core/djangoapps/plugins/decorators.py b/openedx/core/djangoapps/plugins/decorators.py new file mode 100644 index 000000000000..e4a56a1f3b50 --- /dev/null +++ b/openedx/core/djangoapps/plugins/decorators.py @@ -0,0 +1,19 @@ +from typing import Callable + +from functools import wraps + + +def view_namespace(slot_namespace: str) -> Callable: + """ + Adds the "slot_namespace" attribute to the decorated view. + + This is used by the template slots plugin mechanism to find which view to decorate. + :param slot_namespace: The namespace for the slots rendered by this view. + """ + def decorator(view): + @wraps(view) + def wrapper(*args, **kwargs): + return view(*args, **kwargs) + setattr(wrapper, 'slot_namespace', slot_namespace) + return wrapper + return decorator diff --git a/openedx/core/djangoapps/plugins/docs/decisions/0004-plugin-slots.rst b/openedx/core/djangoapps/plugins/docs/decisions/0004-plugin-slots.rst new file mode 100644 index 000000000000..a93ac61a54f1 --- /dev/null +++ b/openedx/core/djangoapps/plugins/docs/decisions/0004-plugin-slots.rst @@ -0,0 +1,113 @@ +Plugin Slots +------------ + +Status +====== +Draft + +Context +======= +edx-platform contains a plugin system (https://github.com/edx/edx-platform/tree/master/openedx/core/djangoapps/plugins) +which allows new Django apps to be installed inside the LMS and Studio without +requiring the LMS/Studio to know about them. This is what enables us to move to +a small and extensible core. While it's possible to extend the content of pages +rendered by the platform using templates, via certain extension points that allow +injecting content into 'head-extra', 'body-initial' etc slots in the base template, +it isn't possible for plugins to inject content at all. + +Decisions +========= +We have added the ability for plugins to render content into existing pages. To +support this, we have decided: + +* A template can how declare slots into which a plugin can inject content, by + using the `plugin_slot` template tag (for Django template) or function (for + Mako templates). +* Plugins can define a callable function that the LMS or Studio can import and + call. This function will be called with minimal context, and in turn supports + pluggable contexts. +* The callable function should return direct HTML content as text that can be + rendered on page. +* Each view can provide an list of what context data should be made available to + all plugins by adding it to the context itself in a list called + `context_allow_list`. This will need to be maintained across releases so + should be kept to a bare minimum. +* A plugin will need to specify the namespace in which that slot should be active. + Different views can be under different namespaces, such as: + - 'course_home' + - 'learner_dashboard' + - 'instructor_dashboard' +* All templates/pages will support three slots: + + ``head-extra``: This slot exists near the end of the header tag for each page + and can be used to add scripts, metadata, stylesheets or other header content. + It is equivalent to adding a 'head-extra.html' template file. + + ``body-initial``: This slot exists at the start of the page, right after the + opening of the body tag. It is equivalent to adding a 'body-initial.html' + template file. + + ``body-extra``: This slot exists at the end of the page near the closing of + the body tag. It is equivalent to adding a 'body-extra.html' template file. + +Implementation +============== + +In the plugin app +~~~~~~~~~~~~~~~~~ + +Config +++++++ + +Inside of the AppConfig of your new plugin app, add a "slots_config" item like below. + +* The format will be ``{"slot_name": "function_inside_plugin_app"}`` +* The function name & path don't need to be named anything specific, so long as they work +* These functions will be called on **every** render of that view, so keep them + efficient or memoize them if they aren't user specific. + +.. code-block:: python + + class MyAppConfig(AppConfig): + name = "my_app" + + plugin_app = { + "slots_config": { + "lms.djangoapp": { + "view_namespace": { + "body-initial": "my_app.slots_api.get_body_initial_content" + } + } + } + } + +Function +++++++++ +The function that will be called by the plugin system should accept a single +parameter which will be the context for that slot. It should then return an +HTML string that will be injected in the specified slot. + +Example: + +.. code-block:: python + + def my_slot_function(context): + return render_to_string('my_app/template.html', context=context) + + +In the core (LMS / Studio) +~~~~~~~~~~~~~~~~~~~~~~~~~~ +The view you wish to add slots to should have the following pieces enabled: + +* A constant defined inside the apps for the slot name. +* Decorate the view with `@view_namespace("namespace")` to make it work with + slots in that namespace. (This should be the very first decorator on that + view function). +* The view can add an entry called `context_allow_list` to its context. This + should either be equal to '*', or be a list of context entries that are + allowed to be passed to plugin slots. If omitted, only the current request + and url are passed through. +* The template can include a line like the following to declare a new slot. + + ``${plugin_slot(context, 'lms.djangoapp', 'slot_name') | n}`` + + Here ``lms.djangoapp`` or ``studio.djangoapp`` can be used to specify if this + is an slot in the LMS or Studio. The slot name should be unique for each + project. diff --git a/openedx/core/djangoapps/plugins/middleware.py b/openedx/core/djangoapps/plugins/middleware.py new file mode 100644 index 000000000000..976a1b1f9e23 --- /dev/null +++ b/openedx/core/djangoapps/plugins/middleware.py @@ -0,0 +1,19 @@ +from django.utils.deprecation import MiddlewareMixin + + +class ViewNameAndSlotMiddleware(MiddlewareMixin): + """ + Django middleware object to inject view name into request context + """ + def process_view(self, request, view_func, view_args, view_kwargs): + """ + Injects the view name value into the request context + """ + request.view_name = view_func.__name__ + # For class-based views the view function will have a `view_class` attribute + # and we can get the slot_namespace from that + view = getattr(view_func, 'view_class', view_func) + request.slot_namespace = None + if hasattr(view, 'slot_namespace'): + assert isinstance(view.slot_namespace, str) + request.slot_namespace = view.slot_namespace diff --git a/openedx/core/djangoapps/plugins/plugin_contexts.py b/openedx/core/djangoapps/plugins/plugin_contexts.py index 8152ac96d498..e886fa922fcf 100644 --- a/openedx/core/djangoapps/plugins/plugin_contexts.py +++ b/openedx/core/djangoapps/plugins/plugin_contexts.py @@ -5,6 +5,7 @@ from openedx.core.lib.cache_utils import process_cached from . import constants, registry +from .utils import get_cached_functions_for_plugin log = getLogger(__name__) @@ -30,7 +31,7 @@ def get_plugins_view_context(project_type, view_name, existing_context=None): if existing_context is None: existing_context = {} - context_functions = _get_cached_context_functions_for_view(project_type, view_name) + context_functions = get_cached_functions_for_plugin(_get_context_function_path, project_type, view_name) for (context_function, plugin_name) in context_functions: try: @@ -48,44 +49,6 @@ def get_plugins_view_context(project_type, view_name, existing_context=None): return aggregate_context -@process_cached -def _get_cached_context_functions_for_view(project_type, view_name): - """ - Returns a list of tuples where the first item is the context function - and the second item is the name of the plugin it's being called from. - - NOTE: These will be functions will be cached (in RAM not memcache) on this unique - combination. If we enable many new views to use this system, we may notice an - increase in memory usage as the entirety of these functions will be held in memory. - """ - context_functions = [] - for app_config in registry.get_app_configs(project_type): - context_function_path = _get_context_function_path(app_config, project_type, view_name) - if context_function_path: - module_path, _, name = context_function_path.rpartition('.') - try: - module = import_module(module_path) - except (ImportError, ModuleNotFoundError): - log.exception( - "Failed to import %s plugin when creating %s context", - module_path, - view_name - ) - continue - context_function = getattr(module, name, None) - if context_function: - plugin_name, _, _ = module_path.partition('.') - context_functions.append((context_function, plugin_name)) - else: - log.warning( - "Failed to retrieve %s function from %s plugin when creating %s context", - name, - module_path, - view_name - ) - return context_functions - - def _get_context_function_path(app_config, project_type, view_name): plugin_config = getattr(app_config, constants.PLUGIN_APP_CLASS_ATTRIBUTE_NAME, {}) context_config = plugin_config.get(constants.PluginContexts.CONFIG, {}) diff --git a/openedx/core/djangoapps/plugins/plugin_slots.py b/openedx/core/djangoapps/plugins/plugin_slots.py new file mode 100644 index 000000000000..ee0977077e12 --- /dev/null +++ b/openedx/core/djangoapps/plugins/plugin_slots.py @@ -0,0 +1,73 @@ +from logging import getLogger +from typing import Dict + +from django.apps import AppConfig + +from . import constants +from .plugin_contexts import get_plugins_view_context +from .utils import get_cached_functions_for_plugin + +log = getLogger(__name__) + +COMMON_ALLOW_LIST = ['request', 'current_url'] + + +def get_content_for_slot(project_type: str, slot_namespace: str, slot_name: str, raw_context: Dict) -> str: + """ + Returns a list of additional content for a view slot. Will check if any plugin apps + have that view in their slots_configs, and if so will call their selected function to + get injected content for the slot. + + Args: + project_type (str): a string that determines which project (lms or studio) the view is being called in. See the + ProjectType enum in plugins/constants.py for valid options + slot_namespace (str): a string that specifies the namespace for this slot. + slot_name (str): a string that determines which slot the plugin will render content for. These are unique for + each project type. + raw_context (Dict): the unfiltered context available to the internal view. + """ + aggregate_slot_content = u"" + slot_functions = get_cached_functions_for_plugin(_get_slots_function_path, project_type, slot_namespace, slot_name) + + # Each view can pass the approved part of the context, in the context itself. + context_allow_list = raw_context.get('context_allow_list', []) + if context_allow_list == '*': + allowed_context = raw_context + else: + # The request object is allowed by default, and always available. + context_allow_list.extend(COMMON_ALLOW_LIST) + + allowed_context = { + key: raw_context.get(key) + for key in context_allow_list + } + + for (slot_function, plugin_name) in slot_functions: + try: + # Allow plugins to extend the context for other plugins + context = get_plugins_view_context( + constants.ProjectType.LMS, + plugin_name, + raw_context, + ) + context.update(allowed_context) + plugin_slot_content = slot_function(context) + aggregate_slot_content += plugin_slot_content + + except Exception as exc: + # We're catching this because we don't want the core to blow up when a + # plugin is broken. This exception will probably need some sort of + # monitoring hooked up to it to make sure that these errors don't go + # unseen. + log.exception("Failed to call plugin slot function. Error: %s", exc) + continue + + return aggregate_slot_content + + +def _get_slots_function_path(app_config: AppConfig, project_type: str, slot_namespace: str, slot_name: str) -> str: + plugin_config = getattr(app_config, constants.PLUGIN_APP_CLASS_ATTRIBUTE_NAME, {}) + slots_config = plugin_config.get(constants.PluginSlots.CONFIG, {}) + project_type_settings = slots_config.get(project_type, {}) + slot_namespace_settings = project_type_settings.get(slot_namespace, {}) + return slot_namespace_settings.get(slot_name) diff --git a/openedx/core/djangoapps/plugins/utils.py b/openedx/core/djangoapps/plugins/utils.py index d5938f22e12a..7b2564b786b1 100644 --- a/openedx/core/djangoapps/plugins/utils.py +++ b/openedx/core/djangoapps/plugins/utils.py @@ -1,6 +1,14 @@ - from importlib import import_module as system_import_module +from logging import getLogger +from typing import Callable, List, Tuple + from django.utils.module_loading import import_string +from importlib_metadata import ModuleNotFoundError + +from openedx.core.lib.cache_utils import process_cached +from . import registry + +log = getLogger(__name__) def import_module(module_path): @@ -37,3 +45,52 @@ def import_attr_in_module(imported_module, attr_name): in the given module. """ return getattr(imported_module, attr_name) + + +@process_cached +def get_cached_functions_for_plugin( + plugin_path_func: Callable[..., str], + project_type: str, + *plugin_path_namespace: str +) -> List[Tuple[Callable, str]]: + """ + Returns a list of tuples where the first item is the plugin function, and + the second item is the name of the plugin it's being called from. + + NOTE: These will be functions will be cached (in RAM not memcache) on this unique + combination. If we enable many new views to use this system, we may notice an + increase in memory usage as the entirety of these functions will be held in memory. + + Args: + plugin_path_func: A function that fetches the plugin function path from the app config, project type + and potentially additional parameter. + project_type: a string that determines which project (lms or studio) the view is being called in. See the + ProjectType enum in plugins/constants.py for valid options. + *plugin_path_namespace: A variable number of additional string arguments to pass to the plugin path + function to get the path for the desired context. + + """ + plugin_functions = [] + for app_config in registry.get_app_configs(project_type): + plugin_function_path = plugin_path_func(app_config, project_type, *plugin_path_namespace) + if plugin_function_path: + module_path, _, name = plugin_function_path.rpartition('.') + try: + module = import_module(module_path) + except (ImportError, ModuleNotFoundError): + log.exception( + "Failed to import %s plugin", + module_path, + ) + continue + plugin_function = getattr(module, name, None) + if plugin_function: + plugin_name, _, _ = module_path.partition('.') + plugin_functions.append((plugin_function, plugin_name)) + else: + log.warning( + "Failed to retrieve %s function from %s plugin when rendering content", + name, + module_path, + ) + return plugin_functions diff --git a/openedx/core/djangoapps/theming/templatetags/plugin_slot.py b/openedx/core/djangoapps/theming/templatetags/plugin_slot.py new file mode 100644 index 000000000000..684516328a46 --- /dev/null +++ b/openedx/core/djangoapps/theming/templatetags/plugin_slot.py @@ -0,0 +1,23 @@ +from typing import Dict + +from django.utils.safestring import mark_safe + +from django.template import Library + +from openedx.core.djangoapps.plugins.plugin_slots import get_content_for_slot + +register = Library() + + +def plugin_slot(context: Dict, project_type: str, slot: str) -> str: + """ + Get content to inject into templates from all registered plugins. + """ + slot_namespace = getattr(context.get('request'), 'slot_namespace', None) + if not slot_namespace: + return '' + content = get_content_for_slot(project_type, slot_namespace, slot, raw_context=context) + return mark_safe(content) + + +register.simple_tag(plugin_slot, takes_context=True)