diff --git a/cms/envs/aws.py b/cms/envs/aws.py index 564ce9eb90..1a3bfa6e2e 100644 --- a/cms/envs/aws.py +++ b/cms/envs/aws.py @@ -562,8 +562,8 @@ COMPLETION_VIDEO_COMPLETE_PERCENTAGE = ENV_TOKENS.get( ####################### Plugin Settings ########################## -from openedx.core.djangolib.django_plugins import DjangoAppRegistry, ProjectType, SettingsType -DjangoAppRegistry.add_plugin_settings(__name__, ProjectType.CMS, SettingsType.AWS) +from openedx.core.djangoapps.plugins import plugin_settings, constants as plugin_constants +plugin_settings.add_plugins(__name__, plugin_constants.ProjectType.CMS, plugin_constants.SettingsType.AWS) ########################## Derive Any Derived Settings ####################### diff --git a/cms/envs/common.py b/cms/envs/common.py index efe84ee5e4..76bc3562f1 100644 --- a/cms/envs/common.py +++ b/cms/envs/common.py @@ -1501,6 +1501,6 @@ COMPLETION_VIDEO_COMPLETE_PERCENTAGE = 0.95 ############## Installed Django Apps ######################### -from openedx.core.djangolib.django_plugins import DjangoAppRegistry, ProjectType, SettingsType -INSTALLED_APPS.extend(DjangoAppRegistry.get_plugin_apps(ProjectType.CMS)) -DjangoAppRegistry.add_plugin_settings(__name__, ProjectType.CMS, SettingsType.COMMON) +from openedx.core.djangoapps.plugins import plugin_apps, plugin_settings, constants as plugin_constants +INSTALLED_APPS.extend(plugin_apps.get_apps(plugin_constants.ProjectType.CMS)) +plugin_settings.add_plugins(__name__, plugin_constants.ProjectType.CMS, plugin_constants.SettingsType.COMMON) diff --git a/cms/envs/devstack.py b/cms/envs/devstack.py index 78b83ab947..8f675cdcf6 100644 --- a/cms/envs/devstack.py +++ b/cms/envs/devstack.py @@ -144,8 +144,8 @@ JWT_AUTH.update({ }) ##################################################################### -from openedx.core.djangolib.django_plugins import DjangoAppRegistry, ProjectType, SettingsType -DjangoAppRegistry.add_plugin_settings(__name__, ProjectType.CMS, SettingsType.DEVSTACK) +from openedx.core.djangoapps.plugins import plugin_settings, constants as plugin_constants +plugin_settings.add_plugins(__name__, plugin_constants.ProjectType.CMS, plugin_constants.SettingsType.DEVSTACK) ############################################################################### # See if the developer has any local overrides. diff --git a/cms/envs/test.py b/cms/envs/test.py index 9abe6e0e07..aed052f1f9 100644 --- a/cms/envs/test.py +++ b/cms/envs/test.py @@ -355,8 +355,8 @@ VIDEO_TRANSCRIPTS_SETTINGS = dict( ####################### Plugin Settings ########################## -from openedx.core.djangolib.django_plugins import DjangoAppRegistry, ProjectType, SettingsType -DjangoAppRegistry.add_plugin_settings(__name__, ProjectType.CMS, SettingsType.TEST) +from openedx.core.djangoapps.plugins import plugin_settings, constants as plugin_constants +plugin_settings.add_plugins(__name__, plugin_constants.ProjectType.CMS, plugin_constants.SettingsType.TEST) ########################## Derive Any Derived Settings ####################### diff --git a/cms/urls.py b/cms/urls.py index df9bacee77..551577f516 100644 --- a/cms/urls.py +++ b/cms/urls.py @@ -257,5 +257,5 @@ urlpatterns += [ url(r'^500$', handler500), ] -from openedx.core.djangolib.django_plugins import DjangoAppRegistry, ProjectType -urlpatterns.extend(DjangoAppRegistry.get_plugin_url_patterns(ProjectType.CMS)) +from openedx.core.djangoapps.plugins import constants as plugin_constants, plugin_urls +urlpatterns.extend(plugin_urls.get_patterns(plugin_constants.ProjectType.CMS)) diff --git a/lms/djangoapps/grades/apps.py b/lms/djangoapps/grades/apps.py index 7336695b21..9c1fda902c 100644 --- a/lms/djangoapps/grades/apps.py +++ b/lms/djangoapps/grades/apps.py @@ -7,7 +7,7 @@ Signal handlers are connected here. from django.apps import AppConfig from django.conf import settings from edx_proctoring.runtime import set_runtime_service -from openedx.core.djangolib.django_plugins import ProjectType, SettingsType, PluginURLs, PluginSettings +from openedx.core.djangoapps.plugins.constants import ProjectType, SettingsType, PluginURLs, PluginSettings class GradesConfig(AppConfig): diff --git a/lms/envs/aws.py b/lms/envs/aws.py index 391471490e..b18abdedac 100644 --- a/lms/envs/aws.py +++ b/lms/envs/aws.py @@ -1088,8 +1088,8 @@ COMPLETION_VIDEO_COMPLETE_PERCENTAGE = ENV_TOKENS.get( ############################### Plugin Settings ############################### -from openedx.core.djangolib.django_plugins import DjangoAppRegistry, ProjectType, SettingsType -DjangoAppRegistry.add_plugin_settings(__name__, ProjectType.LMS, SettingsType.AWS) +from openedx.core.djangoapps.plugins import plugin_settings, constants as plugin_constants +plugin_settings.add_plugins(__name__, plugin_constants.ProjectType.LMS, plugin_constants.SettingsType.AWS) ########################## Derive Any Derived Settings ####################### diff --git a/lms/envs/common.py b/lms/envs/common.py index f0c29f3f84..2fd1365331 100644 --- a/lms/envs/common.py +++ b/lms/envs/common.py @@ -3444,6 +3444,6 @@ RATELIMIT_RATE = '30/m' ############## Plugin Django Apps ######################### -from openedx.core.djangolib.django_plugins import DjangoAppRegistry, ProjectType, SettingsType -INSTALLED_APPS.extend(DjangoAppRegistry.get_plugin_apps(ProjectType.LMS)) -DjangoAppRegistry.add_plugin_settings(__name__, ProjectType.LMS, SettingsType.COMMON) +from openedx.core.djangoapps.plugins import plugin_apps, plugin_settings, constants as plugin_constants +INSTALLED_APPS.extend(plugin_apps.get_apps(plugin_constants.ProjectType.LMS)) +plugin_settings.add_plugins(__name__, plugin_constants.ProjectType.LMS, plugin_constants.SettingsType.COMMON) diff --git a/lms/envs/devstack.py b/lms/envs/devstack.py index c73423b122..9d7b5cc62e 100644 --- a/lms/envs/devstack.py +++ b/lms/envs/devstack.py @@ -270,8 +270,8 @@ JWT_AUTH.update({ }) ##################################################################### -from openedx.core.djangolib.django_plugins import DjangoAppRegistry, ProjectType, SettingsType -DjangoAppRegistry.add_plugin_settings(__name__, ProjectType.LMS, SettingsType.DEVSTACK) +from openedx.core.djangoapps.plugins import plugin_settings, constants as plugin_constants +plugin_settings.add_plugins(__name__, plugin_constants.ProjectType.LMS, plugin_constants.SettingsType.DEVSTACK) ##################################################################### # See if the developer has any local overrides. diff --git a/lms/envs/test.py b/lms/envs/test.py index f32d50b6e7..bb6adbd1fd 100644 --- a/lms/envs/test.py +++ b/lms/envs/test.py @@ -596,8 +596,8 @@ TEMPLATES[0]['OPTIONS']['debug'] = True ####################### Plugin Settings ########################## -from openedx.core.djangolib.django_plugins import DjangoAppRegistry, ProjectType, SettingsType -DjangoAppRegistry.add_plugin_settings(__name__, ProjectType.LMS, SettingsType.TEST) +from openedx.core.djangoapps.plugins import plugin_settings, constants as plugin_constants +plugin_settings.add_plugins(__name__, plugin_constants.ProjectType.LMS, plugin_constants.SettingsType.TEST) ########################## Derive Any Derived Settings ####################### diff --git a/lms/urls.py b/lms/urls.py index 1098e6fa0b..06a7d96336 100644 --- a/lms/urls.py +++ b/lms/urls.py @@ -1076,5 +1076,5 @@ if settings.BRANCH_IO_KEY: ] -from openedx.core.djangolib.django_plugins import DjangoAppRegistry, ProjectType -urlpatterns.extend(DjangoAppRegistry.get_plugin_url_patterns(ProjectType.LMS)) +from openedx.core.djangoapps.plugins import constants as plugin_constants, plugin_urls +urlpatterns.extend(plugin_urls.get_patterns(plugin_constants.ProjectType.LMS)) diff --git a/openedx/core/djangoapps/ace_common/apps.py b/openedx/core/djangoapps/ace_common/apps.py index 33b1effbcb..68d49deee8 100644 --- a/openedx/core/djangoapps/ace_common/apps.py +++ b/openedx/core/djangoapps/ace_common/apps.py @@ -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): diff --git a/openedx/core/djangoapps/plugins/README.rst b/openedx/core/djangoapps/plugins/README.rst new file mode 100644 index 0000000000..8fb5c6f99d --- /dev/null +++ b/openedx/core/djangoapps/plugins/README.rst @@ -0,0 +1,170 @@ +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. + +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(...)) + + +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 `_. + +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, 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 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'}, + } + } + } + +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' diff --git a/openedx/core/djangoapps/plugins/__init__.py b/openedx/core/djangoapps/plugins/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/openedx/core/djangoapps/plugins/constants.py b/openedx/core/djangoapps/plugins/constants.py new file mode 100644 index 0000000000..15a810493a --- /dev/null +++ b/openedx/core/djangoapps/plugins/constants.py @@ -0,0 +1,60 @@ +# 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' diff --git a/openedx/core/djangoapps/plugins/plugin_apps.py b/openedx/core/djangoapps/plugins/plugin_apps.py new file mode 100644 index 0000000000..eb726d79ae --- /dev/null +++ b/openedx/core/djangoapps/plugins/plugin_apps.py @@ -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 diff --git a/openedx/core/djangoapps/plugins/plugin_settings.py b/openedx/core/djangoapps/plugins/plugin_settings.py new file mode 100644 index 0000000000..54b6273296 --- /dev/null +++ b/openedx/core/djangoapps/plugins/plugin_settings.py @@ -0,0 +1,43 @@ +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) diff --git a/openedx/core/djangoapps/plugins/plugin_urls.py b/openedx/core/djangoapps/plugins/plugin_urls.py new file mode 100644 index 0000000000..eb1e0e2a20 --- /dev/null +++ b/openedx/core/djangoapps/plugins/plugin_urls.py @@ -0,0 +1,51 @@ +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) diff --git a/openedx/core/djangoapps/plugins/registry.py b/openedx/core/djangoapps/plugins/registry.py new file mode 100644 index 0000000000..5b21d5fcae --- /dev/null +++ b/openedx/core/djangoapps/plugins/registry.py @@ -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() diff --git a/openedx/core/djangoapps/plugins/utils.py b/openedx/core/djangoapps/plugins/utils.py new file mode 100644 index 0000000000..9f043453b6 --- /dev/null +++ b/openedx/core/djangoapps/plugins/utils.py @@ -0,0 +1,12 @@ +from importlib import import_module as system_import_module + + +def import_module(module_path): + 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), + ) diff --git a/openedx/core/djangoapps/theming/apps.py b/openedx/core/djangoapps/theming/apps.py index 9ea4f5c2d7..14199272d6 100644 --- a/openedx/core/djangoapps/theming/apps.py +++ b/openedx/core/djangoapps/theming/apps.py @@ -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/'} diff --git a/openedx/core/djangolib/django_plugins.py b/openedx/core/djangolib/django_plugins.py deleted file mode 100644 index 9c18fa78e0..0000000000 --- a/openedx/core/djangolib/django_plugins.py +++ /dev/null @@ -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)