From 1fe74c889cb15d41261b3cd40da896c95a50258c Mon Sep 17 00:00:00 2001 From: Nimisha Asthagiri Date: Thu, 18 Jan 2018 11:47:30 -0500 Subject: [PATCH] Django App Plugins: Support for Signal Receivers --- openedx/core/djangoapps/plugins/README.rst | 97 +++++++++++++++---- openedx/core/djangoapps/plugins/apps.py | 29 ++++++ openedx/core/djangoapps/plugins/constants.py | 18 ++++ .../djangoapps/plugins/plugin_settings.py | 1 + .../core/djangoapps/plugins/plugin_signals.py | 64 ++++++++++++ .../core/djangoapps/plugins/plugin_urls.py | 1 + openedx/core/djangoapps/plugins/utils.py | 26 +++++ setup.py | 6 +- 8 files changed, 221 insertions(+), 21 deletions(-) create mode 100644 openedx/core/djangoapps/plugins/apps.py create mode 100644 openedx/core/djangoapps/plugins/plugin_signals.py diff --git a/openedx/core/djangoapps/plugins/README.rst b/openedx/core/djangoapps/plugins/README.rst index 8fb5c6f99d..fdd5ea94e7 100644 --- a/openedx/core/djangoapps/plugins/README.rst +++ b/openedx/core/djangoapps/plugins/README.rst @@ -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. diff --git a/openedx/core/djangoapps/plugins/apps.py b/openedx/core/djangoapps/plugins/apps.py new file mode 100644 index 0000000000..7f67af0deb --- /dev/null +++ b/openedx/core/djangoapps/plugins/apps.py @@ -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) diff --git a/openedx/core/djangoapps/plugins/constants.py b/openedx/core/djangoapps/plugins/constants.py index 15a810493a..e9acdb370c 100644 --- a/openedx/core/djangoapps/plugins/constants.py +++ b/openedx/core/djangoapps/plugins/constants.py @@ -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' diff --git a/openedx/core/djangoapps/plugins/plugin_settings.py b/openedx/core/djangoapps/plugins/plugin_settings.py index 54b6273296..9c468ef650 100644 --- a/openedx/core/djangoapps/plugins/plugin_settings.py +++ b/openedx/core/djangoapps/plugins/plugin_settings.py @@ -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) diff --git a/openedx/core/djangoapps/plugins/plugin_signals.py b/openedx/core/djangoapps/plugins/plugin_signals.py new file mode 100644 index 0000000000..cb41a0128b --- /dev/null +++ b/openedx/core/djangoapps/plugins/plugin_signals.py @@ -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 diff --git a/openedx/core/djangoapps/plugins/plugin_urls.py b/openedx/core/djangoapps/plugins/plugin_urls.py index eb1e0e2a20..0f9128e8ad 100644 --- a/openedx/core/djangoapps/plugins/plugin_urls.py +++ b/openedx/core/djangoapps/plugins/plugin_urls.py @@ -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, diff --git a/openedx/core/djangoapps/plugins/utils.py b/openedx/core/djangoapps/plugins/utils.py index 9f043453b6..131b24d376 100644 --- a/openedx/core/djangoapps/plugins/utils.py +++ b/openedx/core/djangoapps/plugins/utils.py @@ -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) diff --git a/setup.py b/setup.py index cf594d82d5..1843d2f570 100644 --- a/setup.py +++ b/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", ],