Merge pull request #23330 from edx/tuchfarber/add_dashboard_context

Allow plugins to update contexts in specific views
This commit is contained in:
Matt Tuchfarber
2020-03-18 10:29:56 -04:00
committed by GitHub
7 changed files with 218 additions and 1 deletions

View File

@@ -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

View File

@@ -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,

View File

@@ -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,

View File

@@ -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'
}
}
}

View File

@@ -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"

View File

@@ -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)

View 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)