Django App Plugins: Support for Signal Receivers

This commit is contained in:
Nimisha Asthagiri
2018-01-18 11:47:30 -05:00
parent 7286c64e18
commit 1fe74c889c
8 changed files with 221 additions and 21 deletions

View File

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

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

View File

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

View File

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

View 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

View File

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

View File

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

View File

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