Merge pull request #17196 from edx/arch/plugin-signals-support
Django App Plugins: support for Signal Receivers
This commit is contained in:
@@ -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):
|
||||
|
||||
@@ -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
|
||||
"""
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
229
openedx/core/djangoapps/plugins/README.rst
Normal file
229
openedx/core/djangoapps/plugins/README.rst
Normal 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'
|
||||
0
openedx/core/djangoapps/plugins/__init__.py
Normal file
0
openedx/core/djangoapps/plugins/__init__.py
Normal file
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)
|
||||
78
openedx/core/djangoapps/plugins/constants.py
Normal file
78
openedx/core/djangoapps/plugins/constants.py
Normal 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'
|
||||
21
openedx/core/djangoapps/plugins/plugin_apps.py
Normal file
21
openedx/core/djangoapps/plugins/plugin_apps.py
Normal 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
|
||||
44
openedx/core/djangoapps/plugins/plugin_settings.py
Normal file
44
openedx/core/djangoapps/plugins/plugin_settings.py
Normal 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)
|
||||
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
|
||||
52
openedx/core/djangoapps/plugins/plugin_urls.py
Normal file
52
openedx/core/djangoapps/plugins/plugin_urls.py
Normal 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)
|
||||
12
openedx/core/djangoapps/plugins/registry.py
Normal file
12
openedx/core/djangoapps/plugins/registry.py
Normal 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()
|
||||
38
openedx/core/djangoapps/plugins/utils.py
Normal file
38
openedx/core/djangoapps/plugins/utils.py
Normal 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)
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"])
|
||||
|
||||
@@ -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/'}
|
||||
|
||||
@@ -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)
|
||||
Reference in New Issue
Block a user