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