Merge pull request #23330 from edx/tuchfarber/add_dashboard_context
Allow plugins to update contexts in specific views
This commit is contained in:
@@ -14,3 +14,8 @@ Glossary
|
||||
|
||||
More Documentation
|
||||
==================
|
||||
|
||||
Plugins
|
||||
-------
|
||||
Plugin Context view names (see ADR 0003-plugin-contexts.rst):
|
||||
* "course_dashboard" -> student.views.dashboard.student_dashboard
|
||||
|
||||
@@ -42,6 +42,8 @@ MANUAL_ENROLLMENT_ROLE_CHOICES = configuration_helpers.get_value(
|
||||
settings.MANUAL_ENROLLMENT_ROLE_CHOICES
|
||||
)
|
||||
|
||||
COURSE_DASHBOARD_PLUGIN_VIEW_NAME = "course_dashboard"
|
||||
|
||||
|
||||
def create_manual_enrollment_audit(
|
||||
enrolled_by,
|
||||
|
||||
@@ -25,6 +25,7 @@ from opaque_keys.edx.keys import CourseKey
|
||||
from pytz import UTC
|
||||
from shoppingcart.models import CourseRegistrationCode, DonationConfiguration
|
||||
from six import iteritems, text_type
|
||||
from student.api import COURSE_DASHBOARD_PLUGIN_VIEW_NAME
|
||||
from student.helpers import cert_info, check_verify_status_by_course, get_resume_urls_for_enrollments
|
||||
from student.models import (
|
||||
AccountRecovery,
|
||||
@@ -47,6 +48,8 @@ from openedx.core.djangoapps.catalog.utils import (
|
||||
get_visible_sessions_for_entitlement
|
||||
)
|
||||
from openedx.core.djangoapps.credit.email_utils import get_credit_provider_attribute_values, make_providers_strings
|
||||
from openedx.core.djangoapps.plugins.plugin_contexts import get_plugins_view_context
|
||||
from openedx.core.djangoapps.plugins import constants as plugin_constants
|
||||
from openedx.core.djangoapps.programs.models import ProgramsApiConfig
|
||||
from openedx.core.djangoapps.programs.utils import ProgramDataExtender, ProgramProgressMeter
|
||||
from openedx.core.djangoapps.site_configuration import helpers as configuration_helpers
|
||||
@@ -880,6 +883,13 @@ def student_dashboard(request):
|
||||
# TODO START: clean up as part of REVEM-199 (END)
|
||||
}
|
||||
|
||||
context_from_plugins = get_plugins_view_context(
|
||||
plugin_constants.ProjectType.LMS,
|
||||
COURSE_DASHBOARD_PLUGIN_VIEW_NAME,
|
||||
context
|
||||
)
|
||||
context.update(context_from_plugins)
|
||||
|
||||
if ecommerce_service.is_enabled(request.user):
|
||||
context.update({
|
||||
'use_ecommerce_payment_flow': True,
|
||||
|
||||
@@ -106,7 +106,7 @@ class::
|
||||
|
||||
from django.apps import AppConfig
|
||||
from openedx.core.djangoapps.plugins.constants import (
|
||||
ProjectType, SettingsType, PluginURLs, PluginSettings
|
||||
ProjectType, SettingsType, PluginURLs, PluginSettings, PluginContexts
|
||||
)
|
||||
class MyAppConfig(AppConfig):
|
||||
name = u'full_python_path.my_app'
|
||||
@@ -184,6 +184,19 @@ class::
|
||||
PluginSignals.SENDER_PATH: u'full_path_to_sender_app.ModelZ',
|
||||
}],
|
||||
}
|
||||
},
|
||||
|
||||
# Configuration setting for Plugin Contexts for this app.
|
||||
PluginContexts.CONFIG: {
|
||||
|
||||
# Configure the Plugin Signals 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'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -217,6 +230,11 @@ OR use string constants when they cannot import from djangoapps.plugins::
|
||||
u'sender_path': u'full_path_to_sender_app.ModelZ',
|
||||
}],
|
||||
}
|
||||
},
|
||||
u'view_context_config': {
|
||||
u'lms.djangoapp': {
|
||||
'course_dashboard': u'my_app.context_api.get_dashboard_context'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -76,3 +76,12 @@ class PluginSignals(object):
|
||||
|
||||
RELATIVE_PATH = u'relative_path'
|
||||
DEFAULT_RELATIVE_PATH = u'signals'
|
||||
|
||||
|
||||
class PluginContexts(object):
|
||||
"""
|
||||
The PluginContexts enum defines dictionary field names (and defaults)
|
||||
that can be specified by a Plugin App in order to configure the
|
||||
additional views it would like to add context into.
|
||||
"""
|
||||
CONFIG = u"view_context_config"
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
Plugin Contexts
|
||||
---------------
|
||||
|
||||
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 we had the ability to add settings, URLs, and signal handlers in our plugins, there wasn't any way for a plugin to affect the commonly used pages that the core was delivering. Thus a plugin couldn't change any details on the dashboard, courseware, or any other rendered page that the platform delivered.
|
||||
|
||||
Decisions
|
||||
=========
|
||||
We have added the ability to add page context additions to the plugin system. This means that a plugin will be able to add context to any view where it is enabled. To support this we have decided:
|
||||
# Plugins will define a callable function that the LMS and/or studio can import and call, which will return additional context to be added.
|
||||
# Every page that a plugin wants to add context to, must add a line to add the plugin contexts directly before the render.
|
||||
# Plugin context will live in a dictionary called "plugins" that will be passed into the context the templates receive. The structure will look like:
|
||||
::
|
||||
{
|
||||
..existing context values
|
||||
"plugins": {
|
||||
"my_new_plugin": {... my_new_plugins's values ...},
|
||||
"my_other_plugin": {... my_other_plugin's values ...},
|
||||
}
|
||||
}
|
||||
# Each view will have a constant name that will be defined within it's app's API.py which will be used by plugins. These must be globally unique. These will also be recorded in the rendering app's README.rst file.
|
||||
# Plugin apps have the option to either use the view name strings directly or import the constants from the rendering app's api.py if the plugin is part of the edx-platform repo.
|
||||
# For now, in order to use these new context data items, we must use theming alongside this to keep the new context out of the core. This may be iterated on in the future.
|
||||
|
||||
Implementation
|
||||
--------------
|
||||
|
||||
In the plugin app
|
||||
~~~~~~~~~~~~~~~~~
|
||||
Config
|
||||
++++++
|
||||
Inside of the AppConfig of your new plugin app, add a "view_context_config" item like below.
|
||||
* The format will be {"globally_unique_view_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.
|
||||
|
||||
::
|
||||
class MyAppConfig(AppConfig):
|
||||
name = "my_app"
|
||||
|
||||
plugin_app = {
|
||||
"view_context_config": {
|
||||
"lms.djangoapp": {
|
||||
"course_dashboard": "my_app.context_api.get_dashboard_context"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Function
|
||||
++++++++
|
||||
The function that will be called by the plugin system should accept a single parameter which will be the previously existing context. It should then return a dictionary which consists of items which will be added to the context
|
||||
|
||||
Example:
|
||||
::
|
||||
def my_context_function(existing_context, *args, **kwargs):
|
||||
additional_context = {"some_plugin_value": 10}
|
||||
if existing_context.get("some_core_value"):
|
||||
additional_context.append({"some_other_plugin_value": True})
|
||||
return additional_context
|
||||
|
||||
|
||||
In the core (LMS / Studio)
|
||||
~~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||
The view you wish to add context to should have the following pieces enabled:
|
||||
|
||||
* A constant defined inside the app's for the globally unique view name.
|
||||
* The view must call lines similar to the below right before the render so that the plugin has the full context.
|
||||
::
|
||||
context_from_plugins = get_plugins_view_context(
|
||||
plugin_constants.ProjectType.LMS,
|
||||
current_app.api.THIS_VIEW_NAME,
|
||||
context
|
||||
)
|
||||
context.update(context_from_plugins)
|
||||
|
||||
93
openedx/core/djangoapps/plugins/plugin_contexts.py
Normal file
93
openedx/core/djangoapps/plugins/plugin_contexts.py
Normal file
@@ -0,0 +1,93 @@
|
||||
from importlib import import_module
|
||||
|
||||
from logging import getLogger
|
||||
|
||||
from openedx.core.lib.cache_utils import process_cached
|
||||
|
||||
from . import constants, registry
|
||||
|
||||
|
||||
log = getLogger(__name__)
|
||||
|
||||
|
||||
def get_plugins_view_context(project_type, view_name, existing_context=None):
|
||||
"""
|
||||
Returns a dict of additional view context. Will check if any plugin apps
|
||||
have that view in their view_context_config, and if so will call their
|
||||
selected function to get their context dicts.
|
||||
|
||||
Params:
|
||||
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
|
||||
view_name: a string that determines which view needs the additional context. These are globally unique and
|
||||
noted in the api.py in the view's app.
|
||||
existing_context: a dictionary which includes all of the data that the page was going to render with prior
|
||||
to the addition of each plugin's context. This is what will be passed to plugins so they may choose
|
||||
what data to add to the view.
|
||||
"""
|
||||
aggregate_context = {"plugins": {}}
|
||||
|
||||
if existing_context is None:
|
||||
existing_context = {}
|
||||
|
||||
context_functions = _get_cached_context_functions_for_view(project_type, view_name)
|
||||
|
||||
for (context_function, plugin_name) in context_functions:
|
||||
try:
|
||||
plugin_context = context_function(existing_context)
|
||||
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 context function. Error: %s", exc)
|
||||
continue
|
||||
|
||||
aggregate_context["plugins"][plugin_name] = plugin_context
|
||||
|
||||
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, {})
|
||||
project_type_settings = context_config.get(project_type, {})
|
||||
return project_type_settings.get(view_name)
|
||||
Reference in New Issue
Block a user