Django App Plugins: Support for Signal Receivers
This commit is contained in:
@@ -7,7 +7,8 @@ Once a Django project is enhanced with this functionality, any participating
|
||||
Django app (a.k.a. Plugin App) that is PIP-installed on the system is
|
||||
automatically included in the Django project's INSTALLED_APPS list. In addition,
|
||||
the participating Django app's URLs and Settings are automatically recognized by
|
||||
the Django project.
|
||||
the Django project. Furthermore, the Plugin Signals feature allows Plugin Apps
|
||||
to shift their dependencies on Django Signal Senders from code-time to runtime.
|
||||
|
||||
While Django+Python already support dynamic installation of components/apps,
|
||||
they do not have out-of-the-box support for plugin apps that auto-install
|
||||
@@ -60,6 +61,22 @@ update:
|
||||
|
||||
urlpatterns.extend(plugin_urls.get_patterns(...))
|
||||
|
||||
4. its setup to register PluginsConfig (for connecting Plugin Signals)
|
||||
::
|
||||
|
||||
from setuptools import setup
|
||||
setup(
|
||||
...
|
||||
entry_points={
|
||||
"lms.djangoapp": [
|
||||
"plugins = openedx.core.djangoapps.plugins.apps:PluginsConfig",
|
||||
],
|
||||
"cms.djangoapp": [
|
||||
"plugins = openedx.core.djangoapps.plugins.apps:PluginsConfig",
|
||||
],
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
Plugin Apps
|
||||
-----------
|
||||
@@ -84,8 +101,8 @@ file::
|
||||
}
|
||||
)
|
||||
|
||||
3. configure the Plugin App in their AppConfig class
|
||||
::
|
||||
3. configure the Plugin App in their AppConfig
|
||||
class::
|
||||
|
||||
from django.apps import AppConfig
|
||||
from openedx.core.djangoapps.plugins.constants import (
|
||||
@@ -103,42 +120,74 @@ file::
|
||||
# Configure the Plugin URLs for each project type, as needed.
|
||||
ProjectType.LMS: {
|
||||
|
||||
# The namespace to provide to django's urls.include, per
|
||||
# https://docs.djangoproject.com/en/2.0/topics/http/urls/#url-namespaces
|
||||
# The namespace to provide to django's urls.include.
|
||||
PluginURLs.NAMESPACE: u'my_app',
|
||||
|
||||
# The regex to provide to django's urls.url.
|
||||
PluginURLs.REGEX: u'api/my_app/',
|
||||
# The application namespace to provide to django's urls.include.
|
||||
# Optional; Defaults to None.
|
||||
PluginURLs.APP_NAME: u'my_app',
|
||||
|
||||
# The python path (relative to this app) to the URLs module
|
||||
# to be plugged into the project.
|
||||
# The regex to provide to django's urls.url.
|
||||
# Optional; Defaults to r''.
|
||||
PluginURLs.REGEX: r'api/my_app/',
|
||||
|
||||
# The python path (relative to this app) to the URLs module to be plugged into the project.
|
||||
# Optional; Defaults to u'urls'.
|
||||
PluginURLs.RELATIVE_PATH: u'api.urls',
|
||||
}
|
||||
},
|
||||
|
||||
|
||||
# Configuration setting for Plugin Settings for this app.
|
||||
PluginSettings.CONFIG: {
|
||||
|
||||
# Configure the Plugin Settings for each Project Type, as
|
||||
# needed.
|
||||
# Configure the Plugin Settings for each Project Type, as needed.
|
||||
ProjectType.LMS: {
|
||||
|
||||
# Configure each Settings Type, as needed.
|
||||
SettingsType.AWS: {
|
||||
# The python path (relative to this app) to the settings
|
||||
# module for the relevant Project Type and Settings
|
||||
# Type.
|
||||
|
||||
# The python path (relative to this app) to the settings module for the relevant Project Type and Settings Type.
|
||||
# Optional; Defaults to u'settings'.
|
||||
PluginSettings.RELATIVE_PATH: u'settings.aws',
|
||||
},
|
||||
SettingsType.COMMON: {
|
||||
PluginSettings.RELATIVE_PATH: u'settings.common',
|
||||
},
|
||||
}
|
||||
},
|
||||
|
||||
# Configuration setting for Plugin Signals for this app.
|
||||
PluginSignals.CONFIG: {
|
||||
|
||||
# Configure the Plugin Signals for each Project Type, as needed.
|
||||
ProjectType.LMS: {
|
||||
|
||||
# The python path (relative to this app) to the Signals module containing this app's Signal receivers.
|
||||
# Optional; Defaults to u'signals'.
|
||||
PluginSignals.RELATIVE_PATH: u'my_signals',
|
||||
|
||||
# List of all plugin Signal receivers for this app and project type.
|
||||
PluginSignals.RECEIVERS: [{
|
||||
|
||||
# The name of the app's signal receiver function.
|
||||
PluginSignals.RECEIVER_FUNC_NAME: u'on_signal_x',
|
||||
|
||||
# The full path to the module where the signal is defined.
|
||||
PluginSignals.SIGNAL_PATH: u'full_path_to_signal_x_module.SignalX',
|
||||
|
||||
# The value for dispatch_uid to pass to Signal.connect to prevent duplicate signals.
|
||||
# Optional; Defaults to full path to the signal's receiver function.
|
||||
PluginSignals.DISPATCH_UID: u'my_app.my_signals.on_signal_x',
|
||||
|
||||
# The full path to a sender (if connecting to a specific sender) to be passed to Signal.connect.
|
||||
# Optional; Defaults to None.
|
||||
PluginSignals.SENDER_PATH: u'full_path_to_sender_app.ModelZ,
|
||||
}],
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
OR use string constants when you cannot import from djangoapps.plugins::
|
||||
OR use string constants when they cannot import from djangoapps.plugins::
|
||||
|
||||
from django.apps import AppConfig
|
||||
class MyAppConfig(AppConfig):
|
||||
@@ -157,11 +206,21 @@ OR use string constants when you cannot import from djangoapps.plugins::
|
||||
u'aws': { relative_path: u'settings.aws' },
|
||||
u'common': { relative_path: u'settings.common'},
|
||||
}
|
||||
}
|
||||
},
|
||||
u'signals_config': {
|
||||
u'lms.djangoapp': {
|
||||
u'relative_path': u'my_signals',
|
||||
u'receivers': [{
|
||||
u'receiver_func_name': u'on_signal_x',
|
||||
u'signal_path': u'full_path_to_signal_x_module.SignalX',
|
||||
u'dispatch_uid': u'my_app.my_signals.on_signal_x',
|
||||
u'sender_path': u'full_path_to_sender_app.ModelZ,
|
||||
}],
|
||||
}
|
||||
}
|
||||
|
||||
4. For Plugin Settings, insert the following function into each of the plugin
|
||||
settings modules::
|
||||
4. For Plugin Settings, insert the following function into each of the Plugin
|
||||
Settings modules::
|
||||
|
||||
def plugin_settings(settings):
|
||||
# Update the provided settings module with any app-specific settings.
|
||||
|
||||
29
openedx/core/djangoapps/plugins/apps.py
Normal file
29
openedx/core/djangoapps/plugins/apps.py
Normal file
@@ -0,0 +1,29 @@
|
||||
"""
|
||||
Plugins Application Configuration
|
||||
|
||||
Signal handlers are connected here.
|
||||
"""
|
||||
|
||||
from django.apps import AppConfig
|
||||
from django.conf import settings
|
||||
from . import constants, plugin_signals
|
||||
|
||||
|
||||
class PluginsConfig(AppConfig):
|
||||
"""
|
||||
Application Configuration for Plugins.
|
||||
"""
|
||||
name = u'openedx.core.djangoapps.plugins'
|
||||
|
||||
plugin_app = {}
|
||||
|
||||
def ready(self):
|
||||
"""
|
||||
Connect plugin receivers to their signals.
|
||||
"""
|
||||
if settings.ROOT_URLCONF == 'lms.urls':
|
||||
project_type = constants.ProjectType.LMS
|
||||
else:
|
||||
project_type = constants.ProjectType.CMS
|
||||
|
||||
plugin_signals.connect_receivers(project_type)
|
||||
@@ -58,3 +58,21 @@ class PluginURLs(object):
|
||||
REGEX = u'regex'
|
||||
RELATIVE_PATH = u'relative_path'
|
||||
DEFAULT_RELATIVE_PATH = u'urls'
|
||||
|
||||
|
||||
class PluginSignals(object):
|
||||
"""
|
||||
The PluginSignals enum defines dictionary field names (and defaults)
|
||||
that can be specified by a Plugin App in order to configure the signals
|
||||
that it receives.
|
||||
"""
|
||||
CONFIG = u'signals_config'
|
||||
|
||||
RECEIVERS = u'receivers'
|
||||
DISPATCH_UID = u'dispatch_uid'
|
||||
RECEIVER_FUNC_NAME = u'receiver_func_name'
|
||||
SENDER_PATH = u'sender_path'
|
||||
SIGNAL_PATH = u'signal_path'
|
||||
|
||||
RELATIVE_PATH = u'relative_path'
|
||||
DEFAULT_RELATIVE_PATH = u'signals'
|
||||
|
||||
@@ -32,6 +32,7 @@ def _iter_plugins(project_type, settings_type):
|
||||
continue
|
||||
|
||||
plugin_settings_path = utils.get_module_path(app_config, settings_config, constants.PluginSettings)
|
||||
|
||||
log.info(u'Plugin Apps [Settings]: Found %s for %s and %s', app_config.name, project_type, settings_type)
|
||||
yield utils.import_module(plugin_settings_path)
|
||||
|
||||
|
||||
64
openedx/core/djangoapps/plugins/plugin_signals.py
Normal file
64
openedx/core/djangoapps/plugins/plugin_signals.py
Normal file
@@ -0,0 +1,64 @@
|
||||
from logging import getLogger
|
||||
from . import constants, registry, utils
|
||||
|
||||
|
||||
log = getLogger(__name__)
|
||||
|
||||
|
||||
def connect_receivers(project_type):
|
||||
for signals_module, signals_config in _iter_plugins(project_type):
|
||||
for signal, receiver_func, receiver_config in _iter_receivers(signals_module, signals_config):
|
||||
signal.connect(
|
||||
receiver_func,
|
||||
sender=_get_sender(receiver_config),
|
||||
dispatch_uid=_get_dispatch_uuid(receiver_config, receiver_func),
|
||||
)
|
||||
|
||||
|
||||
def _iter_receivers(signals_module, signals_config):
|
||||
for receiver_config in signals_config.get(constants.PluginSignals.RECEIVERS, []):
|
||||
receiver_func = utils.import_attr_in_module(
|
||||
signals_module,
|
||||
receiver_config[constants.PluginSignals.RECEIVER_FUNC_NAME],
|
||||
)
|
||||
signal = utils.import_attr(receiver_config[constants.PluginSignals.SIGNAL_PATH])
|
||||
yield signal, receiver_func, receiver_config
|
||||
|
||||
|
||||
def _iter_plugins(project_type):
|
||||
for app_config in registry.get_app_configs(project_type):
|
||||
signals_config = _get_config(app_config, project_type)
|
||||
if signals_config is None:
|
||||
log.info(u'Plugin Apps [Signals]: Did NOT find %s for %s', app_config.name, project_type)
|
||||
continue
|
||||
|
||||
signals_module_path = utils.get_module_path(app_config, signals_config, constants.PluginSignals)
|
||||
signals_module = utils.import_module(signals_module_path)
|
||||
|
||||
log.info(
|
||||
u'Plugin Apps [Signals]: Found %s with %d receiver(s) for %s',
|
||||
app_config.name,
|
||||
len(signals_config.get(constants.PluginSignals.RECEIVERS, [])),
|
||||
project_type,
|
||||
)
|
||||
yield signals_module, signals_config
|
||||
|
||||
|
||||
def _get_config(app_config, project_type):
|
||||
plugin_config = getattr(app_config, constants.PLUGIN_APP_CLASS_ATTRIBUTE_NAME, {})
|
||||
signals_config = plugin_config.get(constants.PluginSignals.CONFIG, {})
|
||||
return signals_config.get(project_type)
|
||||
|
||||
|
||||
def _get_sender(receiver_config):
|
||||
sender_path = receiver_config.get(constants.PluginSignals.SENDER_PATH)
|
||||
if sender_path:
|
||||
sender = utils.import_attr(sender_path)
|
||||
return sender
|
||||
|
||||
|
||||
def _get_dispatch_uuid(receiver_config, receiver_func):
|
||||
dispatch_uid = receiver_config.get(constants.PluginSignals.DISPATCH_UID)
|
||||
if dispatch_uid is None:
|
||||
dispatch_uid = u'{}.{}'.format(receiver_func.__module__, receiver_func.__name__)
|
||||
return dispatch_uid
|
||||
@@ -36,6 +36,7 @@ def _iter_plugins(project_type):
|
||||
|
||||
urls_module_path = utils.get_module_path(app_config, url_config, constants.PluginURLs)
|
||||
url_config[constants.PluginURLs.NAMESPACE] = url_config.get(constants.PluginURLs.NAMESPACE, app_config.name)
|
||||
|
||||
log.info(
|
||||
u'Plugin Apps [URLs]: Found %s with namespace %s for %s',
|
||||
app_config.name,
|
||||
|
||||
@@ -1,7 +1,14 @@
|
||||
from importlib import import_module as system_import_module
|
||||
from django.utils.module_loading import import_string
|
||||
|
||||
|
||||
def import_module(module_path):
|
||||
"""
|
||||
Import and returns the module at the specific path.
|
||||
|
||||
Args:
|
||||
module_path is the full path to the module, including the package name.
|
||||
"""
|
||||
return system_import_module(module_path)
|
||||
|
||||
|
||||
@@ -10,3 +17,22 @@ def get_module_path(app_config, plugin_config, plugin_cls):
|
||||
package_path=app_config.name,
|
||||
module_path=plugin_config.get(plugin_cls.RELATIVE_PATH, plugin_cls.DEFAULT_RELATIVE_PATH),
|
||||
)
|
||||
|
||||
|
||||
def import_attr(attr_path):
|
||||
"""
|
||||
Import and returns a module's attribute at the specific path.
|
||||
|
||||
Args:
|
||||
attr_path should be of the form:
|
||||
{full_module_path}.attr_name
|
||||
"""
|
||||
return import_string(attr_path)
|
||||
|
||||
|
||||
def import_attr_in_module(imported_module, attr_name):
|
||||
"""
|
||||
Import and returns the attribute with name attr_name
|
||||
in the given module.
|
||||
"""
|
||||
return getattr(imported_module, attr_name)
|
||||
|
||||
6
setup.py
6
setup.py
@@ -6,7 +6,7 @@ from setuptools import setup
|
||||
|
||||
setup(
|
||||
name="Open edX",
|
||||
version="0.8",
|
||||
version="0.9",
|
||||
install_requires=["setuptools"],
|
||||
requires=[],
|
||||
# NOTE: These are not the names we should be installing. This tree should
|
||||
@@ -64,13 +64,15 @@ setup(
|
||||
"bulk_email_optout = lms.djangoapps.bulk_email.policies:CourseEmailOptout"
|
||||
],
|
||||
"lms.djangoapp": [
|
||||
"grades = lms.djangoapps.grades.apps:GradesConfig",
|
||||
"ace_common = openedx.core.djangoapps.ace_common.apps:AceCommonConfig",
|
||||
"grades = lms.djangoapps.grades.apps:GradesConfig",
|
||||
"plugins = openedx.core.djangoapps.plugins.apps:PluginsConfig",
|
||||
"schedules = openedx.core.djangoapps.schedules.apps:SchedulesConfig",
|
||||
"theming = openedx.core.djangoapps.theming.apps:ThemingConfig",
|
||||
],
|
||||
"cms.djangoapp": [
|
||||
"ace_common = openedx.core.djangoapps.ace_common.apps:AceCommonConfig",
|
||||
"plugins = openedx.core.djangoapps.plugins.apps:PluginsConfig",
|
||||
"schedules = openedx.core.djangoapps.schedules.apps:SchedulesConfig",
|
||||
"theming = openedx.core.djangoapps.theming.apps:ThemingConfig",
|
||||
],
|
||||
|
||||
Reference in New Issue
Block a user