Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions cms/envs/common.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
7 changes: 7 additions & 0 deletions cms/templates/base.html
Original file line number Diff line number Diff line change
Expand Up @@ -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
%>
Expand Down Expand Up @@ -75,9 +76,13 @@
<%include file="widgets/segment-io.html" />

<%block name="header_extras"></%block>
## xss-lint: disable=mako-invalid-html-filter
${plugin_slot(context, 'studio.djangoapp', 'head-extra') | n}
</head>

<body class="${static.dir_rtl()} <%block name='bodyclass'></%block> lang_${LANGUAGE_CODE}">
## xss-lint: disable=mako-invalid-html-filter
${plugin_slot(context, 'studio.djangoapp', 'body-initial') | n}
<%block name="view_notes"></%block>

<a class="nav-skip" href="#main">${_("Skip to main content")}</a>
Expand Down Expand Up @@ -169,5 +174,7 @@
</%block>
<%include file="widgets/segment-io-footer.html" />
<div class="modal-cover"></div>
## xss-lint: disable=mako-invalid-html-filter
${plugin_slot(context, 'studio.djangoapp', 'body-extra') | n}
</body>
</html>
2 changes: 2 additions & 0 deletions common/djangoapps/student/views/dashboard.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -475,6 +476,7 @@ def get_dashboard_course_limit():
return course_limit


@view_namespace('students.views.dashboard')

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is just a sample usage, not a suggestion or recommendation for how the namespaces should be named.

@login_required
@ensure_csrf_cookie
@add_maintenance_banner
Expand Down
2 changes: 2 additions & 0 deletions docs/guides/extension_points.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions lms/djangoapps/courseware/views/index.py
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,8 @@ class CoursewareIndex(View):
View class for the Courseware page.
"""

slot_namespace = "courseware:index"

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Another sample usage to show how it will work for classes.


@cached_property
def enable_unenrolled_access(self):
return COURSE_ENABLE_UNENROLLED_ACCESS_FLAG.is_enabled(self.course_key)
Expand Down
11 changes: 0 additions & 11 deletions lms/djangoapps/discussion/django_comment_client/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion lms/envs/common.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
7 changes: 7 additions & 0 deletions lms/templates/main.html
Original file line number Diff line number Diff line change
Expand Up @@ -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

%>
Expand Down Expand Up @@ -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" />
Expand Down Expand Up @@ -173,6 +176,8 @@
<body class="${static.dir_rtl()} <%block name='bodyclass'/> lang_${LANGUAGE_CODE}">

<%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}
<div id="page-prompt"></div>
% if not disable_window_wrap:
<div class="window-wrap" dir="${static.dir_rtl()}">
Expand Down Expand Up @@ -210,6 +215,8 @@
<script type="text/javascript" src="${static.url('js/utils/navigation.js')}" charset="utf-8"></script>
<script type="text/javascript" src="${static.url('js/header/header.js')}"></script>
<%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}
<script type="text/javascript" src="${static.url('js/src/jquery_extend_patch.js')}"></script>
</body>
</html>
Expand Down
5 changes: 4 additions & 1 deletion lms/templates/main_django.html
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
<!DOCTYPE html>
{% load sekizai_tags i18n configuration theme_pipeline optional_include static %}
{% load sekizai_tags i18n configuration theme_pipeline optional_include static plugin_slot %}
<html lang="{{LANGUAGE_CODE}}">
<head>
<meta charset="UTF-8">
Expand All @@ -21,11 +21,13 @@
{% render_block "css" %}

{% optional_include "head-extra.html"|microsite_template_path %}
{% plugin_slot "lms.djangoapp" "head-extra" %}

<meta name="path_prefix" content="{{EDX_ROOT_URL}}">
</head>

<body class="{% block bodyclass %}{% endblock %} lang_{{LANGUAGE_CODE}}">
{% plugin_slot "lms.djangoapp" "body-initial" %}
{% load render_bundle from webpack_loader %}
{% render_bundle "commons" %}
<div class="window-wrap" dir="{{LANGUAGE_BIDI|yesno:'rtl,ltr'}}">
Expand All @@ -46,6 +48,7 @@
{% javascript 'base_application' %}

{% render_block "js" %}
{% plugin_slot "lms.djangoapp" "body-extra" %}
</body>
</html>

Expand Down
26 changes: 25 additions & 1 deletion openedx/core/djangoapps/plugins/README.rst
Original file line number Diff line number Diff line change
Expand Up @@ -189,14 +189,30 @@ 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
# is the function within the app that will return additional context
# 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'
}
}
}
}

Expand Down Expand Up @@ -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'
}
}
}
}

Expand Down
17 changes: 17 additions & 0 deletions openedx/core/djangoapps/plugins/constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
19 changes: 19 additions & 0 deletions openedx/core/djangoapps/plugins/decorators.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
from typing import Callable

from functools import wraps


def view_namespace(slot_namespace: str) -> Callable:

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I guess this won't work for class-based views. Will look into that.

"""
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
113 changes: 113 additions & 0 deletions openedx/core/djangoapps/plugins/docs/decisions/0004-plugin-slots.rst
Original file line number Diff line number Diff line change
@@ -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
Comment thread
xitij2000 marked this conversation as resolved.
Outdated
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:

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just want to clarify that this is "three and only three" slots. My worry is that we'll have a rapid proliferation of slots for things like "Well I want to show something below the X, but above the Y" Which is something I'd like to support in a future world, but I don't think this system is right for it. Likely we'll have to do something in our MFE framework for that.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@tuchfarber My original goal for this work was simply to allow plugins to plug into the same three slots that you get via a theme, head-extra, body-initial, and body-extra. I'm starting there, and leaving the potential to hook into more places in the future.

This need not proliferate, any additions will have to go through edX review, so they can be stopped at that point. I also don't imagine that there should be hundreds of slots, just a few minimal slots in a few key places.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The MFE world will have a similar concept, with the idea being that we can be very intentional about where we choose to add more slots. We can choose to allow particular UIs/pages to be extensible in particular places, this way.

But yeah, we don't want them added willy nilly; maybe a sidebar here, additional headers there, etc.

+ ``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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@ormsbee I'm curious what you think of this approach, in terms of sandboxing of misbehaving plugins. Is there a safer way we could allow plugins to render HTML content to be injected into the page? Or is the warning on efficiency/memoization here the best we can do?

I'm thinking about your presentation about misbehaving puppies plugins.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

As is, this would make our response rendering performance directly beholden to the efficiency of our plugins. I don't see a great way around it while still enabling this sort of behavior, though.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This was also a worry on plugin_contexts since they get ran on every view. My thoughts on it are that with or without the this feature, if this code needs to be ran, it needs to be ran. So the poorly performing code would just be in edx-platform instead of a separate plugin. Due to the isolated nature of plugins, you should be able to disable a poorly performing one easier than ripping out the code in platform since nothing else will depend on it.

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.
Comment on lines 103 to 106

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I like the idea of a context_allow_list and felt the need for something similar when I did the plugin_contexts work. I'm struggling a bit with the idea that you can have a wild card, but we also want the context keys to be "maintained across releases". Template context just doesn't feel like something to be considered a stable API.

I don't have a good solution to this, but assuming this gets merged in roughly as-is, it might be worth pointing to what happens when you need to make changes to either context_allow_list or the context if it's context_allow_list="*".

* 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.
19 changes: 19 additions & 0 deletions openedx/core/djangoapps/plugins/middleware.py
Original file line number Diff line number Diff line change
@@ -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
Loading