Merge pull request #17196 from edx/arch/plugin-signals-support

Django App Plugins: support for Signal Receivers
This commit is contained in:
Nimisha Asthagiri
2018-01-18 13:23:18 -05:00
committed by GitHub
30 changed files with 613 additions and 381 deletions

View File

@@ -4,7 +4,7 @@ Configuration for the ace_common Django app.
from django.apps import AppConfig
from django.utils.translation import ugettext_lazy as _
from openedx.core.djangolib.django_plugins import ProjectType, PluginSettings, SettingsType
from openedx.core.djangoapps.plugins.constants import ProjectType, PluginSettings, SettingsType
class AceCommonConfig(AppConfig):

View File

@@ -3,15 +3,19 @@ Signal handler for invalidating cached course overviews
"""
import logging
from django.dispatch import Signal
from django.dispatch.dispatcher import receiver
from .models import CourseOverview
from openedx.core.djangoapps.signals.signals import COURSE_PACING_CHANGED, COURSE_START_DATE_CHANGED
from openedx.core.djangoapps.signals.signals import COURSE_PACING_CHANGED
from xmodule.modulestore.django import SignalHandler
LOG = logging.getLogger(__name__)
COURSE_START_DATE_CHANGED = Signal(providing_args=["updated_course_overview", "previous_start_date"])
@receiver(SignalHandler.course_published)
def _listen_for_course_publish(sender, course_key, **kwargs): # pylint: disable=unused-argument
"""

View File

@@ -82,7 +82,7 @@ class CourseOverviewSignalsTestCase(ModuleStoreTestCase):
self.store.update_item(course, ModuleStoreEnum.UserID.test)
self.assertTrue(mock_signal.called)
@patch('openedx.core.djangoapps.signals.signals.COURSE_START_DATE_CHANGED.send')
@patch('openedx.core.djangoapps.content.course_overviews.signals.COURSE_START_DATE_CHANGED.send')
def test_start_changed(self, mock_signal):
self.assert_changed_signal_sent('start', self.TODAY, self.NEXT_WEEK, mock_signal)

View File

@@ -0,0 +1,229 @@
Django App Plugins
==================
Provides functionality to enable improved plugin support of Django apps.
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. 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
into a containing Django project.
This Django App Plugin functionality allows for Django-framework code to be
encapsulated within each Django app, rather than having a monolith Project that
is aware of the details of its Django apps. It is motivated by the following
design principles:
* Single Responsibility Principle, which says "a class or module should have
one, and only one, reason to change." When code related to a single Django app
changes, there's no reason for its containing project to also change. The
encapsulation and modularity resulting from code being co-located with its
owning Django app helps prevent "God objects" that have too much responsibility
and knowledge of the details.
* Open Closed Principle, which says "software entities should be open for
extension, but closed for modification." The edx-platform is extensible via
installation of Django apps. Having automatic Django App Plugin support allows
for this extensibility without modification to the edx-platform. Going forward,
we expect this capability to be widely used by external repos that depend on and
enhance the edx-platform without the need to modify the core platform.
* Dependency Inversion Principle, which says "high level modules should not
depend upon low level modules." The high-level module here is the Django
project, while the participating Django app is the low-level module. For
long-term maintenance of a system, dependencies should go from low-level
modules/details to higher level ones.
Django Projects
---------------
In order to enable this functionality in a Django project, the project needs to
update:
1. its settings to extend its INSTALLED_APPS to include the Plugin Apps
::
INSTALLED_APPS.extend(plugin_apps.get_apps(...))
2. its settings to add all Plugin Settings
::
plugin_settings.add_plugins(__name__, ...)
3. its urls to add all Plugin URLs
::
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
-----------
In order to make use of this functionality, plugin apps need to:
1. create an AppConfig class in their apps module, as described in Django's
`Application Configuration <https://docs.djangoproject.com/en/2.0/ref/applications/#django.apps.AppConfig>`_.
2. add their AppConfig class to the appropriate entry point in their setup.py
file::
from setuptools import setup
setup(
...
entry_points={
"lms.djangoapp": [
"my_app = full_python_path.my_app.apps:MyAppConfig",
],
"cms.djangoapp": [
],
}
)
3. configure the Plugin App in their AppConfig
class::
from django.apps import AppConfig
from openedx.core.djangoapps.plugins.constants import (
ProjectType, SettingsType, PluginURLs, PluginSettings
)
class MyAppConfig(AppConfig):
name = u'full_python_path.my_app'
# Class attribute that configures and enables this app as a Plugin App.
plugin_app = {
# Configuration setting for Plugin URLs for this app.
PluginURLs.CONFIG: {
# Configure the Plugin URLs for each project type, as needed.
ProjectType.LMS: {
# The namespace to provide to django's urls.include.
PluginURLs.NAMESPACE: u'my_app',
# The application namespace to provide to django's urls.include.
# Optional; Defaults to None.
PluginURLs.APP_NAME: u'my_app',
# 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.
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.
# 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 they cannot import from djangoapps.plugins::
from django.apps import AppConfig
class MyAppConfig(AppConfig):
name = u'full_python_path.my_app'
plugin_app = {
u'url_config': {
u'lms.djangoapp': {
u'namespace': u'my_app',
u'regex': u'api/my_app/',
u'relative_path': u'api.urls',
}
},
u'settings_config': {
u'lms.djangoapp': {
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::
def plugin_settings(settings):
# Update the provided settings module with any app-specific settings.
# For example:
# settings.FEATURES['ENABLE_MY_APP'] = True
# settings.MY_APP_POLICY = 'foo'

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

@@ -0,0 +1,78 @@
# Name of the class attribute to put in the AppConfig class of the Plugin App.
PLUGIN_APP_CLASS_ATTRIBUTE_NAME = u'plugin_app'
# Name of the function that belongs in the plugin Django app's settings file.
# The function should be defined as:
# def plugin_settings(settings):
# # enter code that should be injected into the given settings module.
PLUGIN_APP_SETTINGS_FUNC_NAME = u'plugin_settings'
class ProjectType(object):
"""
The ProjectType enum defines the possible values for the Django Projects
that are available in the edx-platform. Plugin apps use these values to
declare explicitly which projects they are extending.
"""
LMS = u'lms.djangoapp'
CMS = u'cms.djangoapp'
class SettingsType(object):
"""
The SettingsType enum defines the possible values for the settings files
that are available for extension in the edx-platform. Plugin apps use these
values (in addition to ProjectType) to declare explicitly which settings
(in the specified project) they are extending.
See https://github.com/edx/edx-platform/master/lms/envs/docs/README.rst for
further information on each Settings Type.
"""
AWS = u'aws'
COMMON = u'common'
DEVSTACK = u'devstack'
TEST = u'test'
class PluginSettings(object):
"""
The PluginSettings enum defines dictionary field names (and defaults)
that can be specified by a Plugin App in order to configure the settings
that are injected into the project.
"""
CONFIG = u'settings_config'
RELATIVE_PATH = u'relative_path'
DEFAULT_RELATIVE_PATH = u'settings'
class PluginURLs(object):
"""
The PluginURLs enum defines dictionary field names (and defaults) that can
be specified by a Plugin App in order to configure the URLs that are
injected into the project.
"""
CONFIG = u'url_config'
APP_NAME = u'app_name'
NAMESPACE = u'namespace'
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

@@ -0,0 +1,21 @@
from logging import getLogger
from . import constants, registry
log = getLogger(__name__)
def get_apps(project_type):
"""
Returns a list of all registered Plugin Apps, expected to be added to
the INSTALLED_APPS list for the given project_type.
"""
plugin_apps = [
u'{module_name}.{class_name}'.format(
module_name=app_config.__module__,
class_name=app_config.__name__,
)
for app_config in registry.get_app_configs(project_type)
if getattr(app_config, constants.PLUGIN_APP_CLASS_ATTRIBUTE_NAME, None) is not None
]
log.info(u'Plugin Apps: Found %s', plugin_apps)
return plugin_apps

View File

@@ -0,0 +1,44 @@
from logging import getLogger
from . import constants, registry, utils
log = getLogger(__name__)
def add_plugins(settings_path, project_type, settings_type):
"""
Updates the module at the given ``settings_path`` with all Plugin
Settings appropriate for the given project_type and settings_type.
"""
settings_module = utils.import_module(settings_path)
for plugin_settings in _iter_plugins(project_type, settings_type):
settings_func = getattr(plugin_settings, constants.PLUGIN_APP_SETTINGS_FUNC_NAME)
settings_func(settings_module)
def _iter_plugins(project_type, settings_type):
"""
Yields Plugin Settings modules that are registered for the given
project_type and settings_type.
"""
for app_config in registry.get_app_configs(project_type):
settings_config = _get_config(app_config, project_type, settings_type)
if settings_config is None:
log.info(
u'Plugin Apps [Settings]: Did NOT find %s for %s and %s',
app_config.name,
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)
def _get_config(app_config, project_type, settings_type):
plugin_config = getattr(app_config, constants.PLUGIN_APP_CLASS_ATTRIBUTE_NAME, {})
settings_config = plugin_config.get(constants.PluginSettings.CONFIG, {})
project_type_settings = settings_config.get(project_type, {})
return project_type_settings.get(settings_type)

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

@@ -0,0 +1,52 @@
from logging import getLogger
from django.conf.urls import include, url
from . import constants, registry, utils
log = getLogger(__name__)
def get_patterns(project_type):
"""
Returns a list of all registered Plugin URLs, expected to be added to
the URL patterns for the given project_type.
"""
return [
url(
url_config.get(constants.PluginURLs.REGEX, r''),
include(
url_module_path,
app_name=url_config.get(constants.PluginURLs.APP_NAME),
namespace=url_config[constants.PluginURLs.NAMESPACE],
),
)
for url_module_path, url_config in _iter_plugins(project_type)
]
def _iter_plugins(project_type):
"""
Yields the module path and configuration for Plugin URLs registered for
the given project_type.
"""
for app_config in registry.get_app_configs(project_type):
url_config = _get_config(app_config, project_type)
if url_config is None:
log.info(u'Plugin Apps [URLs]: Did NOT find %s for %s', app_config.name, project_type)
continue
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,
url_config[constants.PluginURLs.NAMESPACE],
project_type,
)
yield urls_module_path, url_config
def _get_config(app_config, project_type):
plugin_config = getattr(app_config, constants.PLUGIN_APP_CLASS_ATTRIBUTE_NAME, {})
url_config = plugin_config.get(constants.PluginURLs.CONFIG, {})
return url_config.get(project_type)

View File

@@ -0,0 +1,12 @@
from openedx.core.lib.plugins import PluginManager
class DjangoAppRegistry(PluginManager):
"""
DjangoAppRegistry is a registry of django app plugins.
"""
pass
def get_app_configs(project_type):
return DjangoAppRegistry.get_available_plugins(project_type).itervalues()

View File

@@ -0,0 +1,38 @@
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)
def get_module_path(app_config, plugin_config, plugin_cls):
return u'{package_path}.{module_path}'.format(
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

@@ -1,12 +1,22 @@
from django.apps import AppConfig
from django.utils.translation import ugettext_lazy as _
from openedx.core.djangoapps.plugins.constants import PluginSignals, ProjectType
class SchedulesConfig(AppConfig):
name = 'openedx.core.djangoapps.schedules'
verbose_name = _('Schedules')
plugin_app = {}
plugin_app = {
PluginSignals.CONFIG: {
ProjectType.LMS: {
PluginSignals.RECEIVERS: [{
PluginSignals.RECEIVER_FUNC_NAME: u'update_schedules_on_course_start_changed',
PluginSignals.SIGNAL_PATH: u'openedx.core.djangoapps.content.course_overviews.signals.COURSE_START_DATE_CHANGED',
}]
},
},
}
def ready(self):
# noinspection PyUnresolvedReferences

View File

@@ -15,7 +15,6 @@ from courseware.models import (
from edx_ace.utils import date
from openedx.core.djangoapps.schedules.models import ScheduleExperience
from openedx.core.djangoapps.schedules.content_highlights import course_has_highlights
from openedx.core.djangoapps.signals.signals import COURSE_START_DATE_CHANGED
from openedx.core.djangoapps.theming.helpers import get_current_site
from student.models import CourseEnrollment
from .config import CREATE_SCHEDULE_WAFFLE_FLAG
@@ -54,7 +53,6 @@ def create_schedule(sender, **kwargs): # pylint: disable=unused-argument
))
@receiver(COURSE_START_DATE_CHANGED, dispatch_uid="update_schedules_on_course_start_changed")
def update_schedules_on_course_start_changed(sender, updated_course_overview, previous_start_date, **kwargs):
"""
Updates all course schedules if course hasn't started yet and

View File

@@ -24,5 +24,3 @@ COURSE_GRADE_NOW_PASSED = Signal(
LEARNER_NOW_VERIFIED = Signal(providing_args=['user'])
COURSE_PACING_CHANGED = Signal(providing_args=["updated_course_overview", "previous_self_paced"])
COURSE_START_DATE_CHANGED = Signal(providing_args=["updated_course_overview", "previous_start_date"])

View File

@@ -1,6 +1,6 @@
from django.apps import AppConfig
from openedx.core.djangolib.django_plugins import ProjectType, PluginURLs
from openedx.core.djangoapps.plugins.constants import ProjectType, PluginURLs
plugin_urls_config = {PluginURLs.NAMESPACE: u'theming', PluginURLs.REGEX: u'theming/'}

View File

@@ -1,347 +0,0 @@
"""
Provides functionality to enable improved plugin support of Django apps.
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.
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
into a containing Django project.
This Django App Plugin functionality allows for Django-framework code to be
encapsulated within each Django app, rather than having a monolith Project that
is aware of the details of its Django apps. It is motivated by the following
design principles:
* Single Responsibility Principle, which says "a class or module should have
one, and only one, reason to change." When code related to a single Django app
changes, there's no reason for its containing project to also change. The
encapsulation and modularity resulting from code being co-located with its
owning Django app helps prevent "God objects" that have too much responsibility
and knowledge of the details.
* Open Closed Principle, which says "software entities should be open for
extension, but closed for modification." The edx-platform is extensible via
installation of Django apps. Having automatic Django App Plugin support allows
for this extensibility without modification to the edx-platform. Going forward,
we expect this capability to be widely used by external repos that depend on and
enhance the edx-platform without the need to modify the core platform.
* Dependency Inversion Principle, which says "high level modules should not
depend upon low level modules." The high-level module here is the Django
project, while the participating Django app is the low-level module. For
long-term maintenance of a system, dependencies should go from low-level
modules/details to higher level ones.
== Django Projects ==
In order to enable this functionality in a Django project, the project needs to
update:
1. its settings to extend its INSTALLED_APPS to include the Plugin Apps:
INSTALLED_APPS.extend(DjangoAppRegistry.get_plugin_apps(...))
2. its settings to add all Plugin Settings:
DjangoAppRegistry.add_plugin_settings(__name__, ...)
3. its urls to add all Plugin URLs:
urlpatterns.extend(DjangoAppRegistry.get_plugin_url_patterns(...))
== Plugin Apps ==
In order to make use of this functionality, plugin apps need to:
1. create an AppConfig class in their apps module, as described in
https://docs.djangoproject.com/en/2.0/ref/applications/#django.apps.AppConfig.
2. add their AppConfig class to the appropriate entry point in their setup.py
file:
from setuptools import setup
setup(
...
entry_points={
"lms.djangoapp": [
"my_app = full_python_path.my_app.apps:MyAppConfig",
],
"cms.djangoapp": [
],
}
)
3. configure the Plugin App in their AppConfig class:
from django.apps import AppConfig
from openedx.core.djangolib.django_plugins import (
ProjectType, SettingsType, PluginURLs, PluginSettings
)
class MyAppConfig(AppConfig):
name = u'full_python_path.my_app'
# Class attribute that configures and enables this app as a Plugin App.
plugin_app = {
# Configuration setting for Plugin URLs for this app.
PluginURLs.CONFIG: {
# 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
PluginURLs.NAMESPACE: u'my_app',
# The regex to provide to django's urls.url.
PluginURLs.REGEX: u'api/my_app/',
# The python path (relative to this app) to the URLs module
# to be plugged into the project.
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.
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.
PluginSettings.RELATIVE_PATH: u'settings.aws',
},
SettingsType.COMMON: {
PluginSettings.RELATIVE_PATH: u'settings.common',
},
}
}
}
OR use string constants when you cannot import from django_plugins.
from django.apps import AppConfig
class MyAppConfig(AppConfig):
name = u'full_python_path.my_app'
plugin_app = {
u'url_config': {
u'lms.djangoapp': {
u'namespace': u'my_app',
u'regex': u'api/my_app/',
u'relative_path': u'api.urls',
}
},
u'settings_config': {
u'lms.djangoapp': {
u'aws': { relative_path: u'settings.aws' },
u'common': { relative_path: u'settings.common'},
}
}
}
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.
# For example:
# settings.FEATURES['ENABLE_MY_APP'] = True
# settings.MY_APP_POLICY = 'foo'
"""
from importlib import import_module
from django.conf.urls import include, url
from logging import getLogger
from openedx.core.lib.plugins import PluginManager
log = getLogger(__name__)
# Name of the class attribute to put in the AppConfig class of the Plugin App.
PLUGIN_APP_CLASS_ATTRIBUTE_NAME = u'plugin_app'
# Name of the function that belongs in the plugin Django app's settings file.
# The function should be defined as:
# def plugin_settings(settings):
# # enter code that should be injected into the given settings module.
PLUGIN_APP_SETTINGS_FUNC_NAME = u'plugin_settings'
class ProjectType(object):
"""
The ProjectType enum defines the possible values for the Django Projects
that are available in the edx-platform. Plugin apps use these values to
declare explicitly which projects they are extending.
"""
LMS = u'lms.djangoapp'
CMS = u'cms.djangoapp'
class SettingsType(object):
"""
The SettingsType enum defines the possible values for the settings files
that are available for extension in the edx-platform. Plugin apps use these
values (in addition to ProjectType) to declare explicitly which settings
(in the specified project) they are extending.
See https://github.com/edx/edx-platform/master/lms/envs/docs/README.rst for
further information on each Settings Type.
"""
AWS = u'aws'
COMMON = u'common'
DEVSTACK = u'devstack'
TEST = u'test'
class PluginSettings(object):
"""
The PluginSettings enum defines dictionary field names (and defaults)
that can be specified by a Plugin App in order to configure the settings
that are injected into the project.
"""
CONFIG = u'settings_config'
RELATIVE_PATH = u'relative_path'
DEFAULT_RELATIVE_PATH = u'settings'
class PluginURLs(object):
"""
The PluginURLs enum defines dictionary field names (and defaults) that can
be specified by a Plugin App in order to configure the URLs that are
injected into the project.
"""
CONFIG = u'url_config'
APP_NAME = u'app_name'
NAMESPACE = u'namespace'
REGEX = u'regex'
RELATIVE_PATH = u'relative_path'
DEFAULT_RELATIVE_PATH = u'urls'
class DjangoAppRegistry(PluginManager):
"""
The DjangoAppRegistry class encapsulates the functionality to enable
improved plugin support of Django apps.
"""
@classmethod
def get_plugin_apps(cls, project_type):
"""
Returns a list of all registered Plugin Apps, expected to be added to
the INSTALLED_APPS list for the given project_type.
"""
plugin_apps = [
u'{module_name}.{class_name}'.format(
module_name=app_config.__module__,
class_name=app_config.__name__,
)
for app_config in cls._get_app_configs(project_type)
if getattr(app_config, PLUGIN_APP_CLASS_ATTRIBUTE_NAME, None) is not None
]
log.info(u'Plugin Apps: Found %s', plugin_apps)
return plugin_apps
@classmethod
def add_plugin_settings(cls, settings_path, project_type, settings_type):
"""
Updates the module at the given ``settings_path`` with all Plugin
Settings appropriate for the given project_type and settings_type.
"""
settings_module = import_module(settings_path)
for plugin_settings in cls._iter_plugin_settings(project_type, settings_type):
settings_func = getattr(plugin_settings, PLUGIN_APP_SETTINGS_FUNC_NAME)
settings_func(settings_module)
@classmethod
def get_plugin_url_patterns(cls, project_type):
"""
Returns a list of all registered Plugin URLs, expected to be added to
the URL patterns for the given project_type.
"""
return [
url(
url_config.get(PluginURLs.REGEX, r''),
include(
url_module_path,
app_name=url_config.get(PluginURLs.APP_NAME),
namespace=url_config[PluginURLs.NAMESPACE],
),
)
for url_module_path, url_config in cls._iter_installable_urls(project_type)
]
@classmethod
def _iter_plugin_settings(cls, project_type, settings_type):
"""
Yields Plugin Settings modules that are registered for the given
project_type and settings_type.
"""
for app_config in cls._get_app_configs(project_type):
settings_config = _get_settings_config(app_config, project_type, settings_type)
if settings_config is None:
log.info(
u'Plugin Apps [Settings]: Did NOT find %s for %s and %s',
app_config.name,
project_type,
settings_type,
)
continue
plugin_settings_path = _get_module_path(app_config, settings_config, PluginSettings)
log.info(u'Plugin Apps [Settings]: Found %s for %s and %s', app_config.name, project_type, settings_type)
yield import_module(plugin_settings_path)
@classmethod
def _iter_installable_urls(cls, project_type):
"""
Yields the module path and configuration for Plugin URLs registered for
the given project_type.
"""
for app_config in cls._get_app_configs(project_type):
url_config = _get_url_config(app_config, project_type)
if url_config is None:
log.info(u'Plugin Apps [URLs]: Did NOT find %s for %s', app_config.name, project_type)
continue
urls_module_path = _get_module_path(app_config, url_config, PluginURLs)
url_config[PluginURLs.NAMESPACE] = url_config.get(PluginURLs.NAMESPACE, app_config.name)
log.info(
u'Plugin Apps [URLs]: Found %s with namespace %s for %s',
app_config.name,
url_config[PluginURLs.NAMESPACE],
project_type,
)
yield urls_module_path, url_config
@classmethod
def _get_app_configs(cls, project_type):
return cls.get_available_plugins(project_type).itervalues()
def _get_module_path(app_config, plugin_config, plugin_cls):
return u'{package_path}.{module_path}'.format(
package_path=app_config.name,
module_path=plugin_config.get(plugin_cls.RELATIVE_PATH, plugin_cls.DEFAULT_RELATIVE_PATH),
)
def _get_settings_config(app_config, project_type, settings_type):
plugin_config = getattr(app_config, PLUGIN_APP_CLASS_ATTRIBUTE_NAME, {})
settings_config = plugin_config.get(PluginSettings.CONFIG, {})
project_type_settings = settings_config.get(project_type, {})
return project_type_settings.get(settings_type)
def _get_url_config(app_config, project_type):
plugin_config = getattr(app_config, PLUGIN_APP_CLASS_ATTRIBUTE_NAME, {})
url_config = plugin_config.get(PluginURLs.CONFIG, {})
return url_config.get(project_type)