Extracting plugin app from edx-platform (#24678)

* Moving plugins infrastructure to edx-django-utils
This PR extracts the code that enables plugins in edx-platform and puts it in edx-django-utils. This is done to allow other IDAS to add plugin functionality.
This commit is contained in:
Manjinder Singh
2020-08-12 07:48:53 -04:00
committed by GitHub
parent 7666e131f2
commit c76ed6ae45
51 changed files with 149 additions and 848 deletions

View File

@@ -2076,9 +2076,11 @@ SYSTEM_WIDE_ROLE_CLASSES = []
############## Installed Django Apps #########################
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)
from edx_django_utils.plugins import get_plugin_apps, add_plugins
from openedx.core.djangoapps.plugins.constants import ProjectType, SettingsType
INSTALLED_APPS.extend(get_plugin_apps(ProjectType.CMS))
add_plugins(__name__, ProjectType.CMS, SettingsType.COMMON)
# Course exports streamed in blocks of this size. 8192 or 8kb is the default
# setting for the FileWrapper class used to iterate over the export file data.

View File

@@ -200,9 +200,11 @@ BLOCKSTORE_API_URL = "http://edx.devstack.blockstore:18250/api/v1/"
#####################################################################
# pylint: disable=wrong-import-order, wrong-import-position
from openedx.core.djangoapps.plugins import constants as plugin_constants, plugin_settings
from edx_django_utils.plugins import add_plugins
# pylint: disable=wrong-import-order, wrong-import-position
from openedx.core.djangoapps.plugins.constants import ProjectType, SettingsType
plugin_settings.add_plugins(__name__, plugin_constants.ProjectType.CMS, plugin_constants.SettingsType.DEVSTACK)
add_plugins(__name__, ProjectType.CMS, SettingsType.DEVSTACK)
OPENAPI_CACHE_TIMEOUT = 0

View File

@@ -15,12 +15,13 @@ import yaml
from corsheaders.defaults import default_headers as corsheaders_default_headers
from django.core.exceptions import ImproperlyConfigured
from django.urls import reverse_lazy
from edx_django_utils.plugins import add_plugins
from path import Path as path
from openedx.core.djangoapps.plugins.constants import ProjectType, SettingsType
from .common import *
from openedx.core.djangoapps.plugins import constants as plugin_constants
from openedx.core.djangoapps.plugins import plugin_settings
from openedx.core.lib.derived import derive_settings
from openedx.core.lib.logsettings import get_logger_config
from xmodule.modulestore.modulestore_settings import convert_module_store_setting_if_needed
@@ -539,7 +540,7 @@ MAX_BLOCKS_PER_CONTENT_LIBRARY = ENV_TOKENS.get('MAX_BLOCKS_PER_CONTENT_LIBRARY'
# This is at the bottom because it is going to load more settings after base settings are loaded
plugin_settings.add_plugins(__name__, plugin_constants.ProjectType.CMS, plugin_constants.SettingsType.PRODUCTION)
add_plugins(__name__, ProjectType.CMS, SettingsType.PRODUCTION)
########################## Derive Any Derived Settings #######################

View File

@@ -286,9 +286,11 @@ VIDEO_TRANSCRIPTS_SETTINGS = dict(
####################### Plugin Settings ##########################
# pylint: disable=wrong-import-position
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)
# pylint: disable=wrong-import-position, wrong-import-order
from edx_django_utils.plugins import add_plugins
# pylint: disable=wrong-import-position, wrong-import-order
from openedx.core.djangoapps.plugins.constants import ProjectType, SettingsType
add_plugins(__name__, ProjectType.CMS, SettingsType.TEST)
########################## Derive Any Derived Settings #######################

View File

@@ -303,8 +303,11 @@ if 'openedx.testing.coverage_context_listener' in settings.INSTALLED_APPS:
url(r'coverage_context', include('openedx.testing.coverage_context_listener.urls'))
]
from openedx.core.djangoapps.plugins import constants as plugin_constants, plugin_urls
urlpatterns.extend(plugin_urls.get_patterns(plugin_constants.ProjectType.CMS))
# pylint: disable=wrong-import-position, wrong-import-order
from edx_django_utils.plugins import get_plugin_url_patterns
# pylint: disable=wrong-import-position
from openedx.core.djangoapps.plugins.constants import ProjectType
urlpatterns.extend(get_plugin_url_patterns(ProjectType.CMS))
# Contentstore
urlpatterns += [

View File

@@ -15,6 +15,7 @@ from django.urls import reverse
from django.utils.translation import ugettext as _
from django.views.decorators.csrf import ensure_csrf_cookie
from edx_django_utils import monitoring as monitoring_utils
from edx_django_utils.plugins import get_plugins_view_context
from opaque_keys.edx.keys import CourseKey
from pytz import UTC
from six import iteritems, text_type
@@ -35,8 +36,7 @@ from openedx.core.djangoapps.catalog.utils import (
get_visible_sessions_for_entitlement
)
from openedx.core.djangoapps.credit.email_utils import get_credit_provider_attribute_values, make_providers_strings
from openedx.core.djangoapps.plugins import constants as plugin_constants
from openedx.core.djangoapps.plugins.plugin_contexts import get_plugins_view_context
from openedx.core.djangoapps.plugins.constants import ProjectType
from openedx.core.djangoapps.programs.models import ProgramsApiConfig
from openedx.core.djangoapps.programs.utils import ProgramDataExtender, ProgramProgressMeter
from openedx.core.djangoapps.site_configuration import helpers as configuration_helpers
@@ -800,7 +800,7 @@ def student_dashboard(request):
}
context_from_plugins = get_plugins_view_context(
plugin_constants.ProjectType.LMS,
ProjectType.LMS,
COURSE_DASHBOARD_PLUGIN_VIEW_NAME,
context
)

View File

@@ -11,7 +11,7 @@ from django.core.files.storage import get_storage_class
from six import text_type
from xblock.fields import List
from openedx.core.lib.plugins import PluginError
from edx_django_utils.plugins import PluginError
log = logging.getLogger("edx.courseware")

View File

@@ -83,11 +83,11 @@ If you wish to customize aspects of the learner or educator experiences, you'll
Most python plugins are enabled using one of two methods:
1. A Python Entry point: the core Open edX platform provides a standard plugin loading mechanism in |openedx.core.lib.plugins|_ which uses `stevedore`_ to find all installed python packages that declare a specific "entry point" in their setup.py file. See the ``entry_points`` defined in edx-platform's own ``setup.py`` for examples.
1. A Python Entry point: the core Open edX platform provides a standard plugin loading mechanism in |edx_django_utils.plugins|_ which uses `stevedore`_ to find all installed python packages that declare a specific "entry point" in their setup.py file. See the ``entry_points`` defined in edx-platform's own ``setup.py`` for examples.
2. A Django setting: Some plugins require modification of Django settings, which is typically done by editing ``/edx/etc/lms.yml`` (in Production) or ``edx-platform/lms/envs/private.py`` (on Devstack).
.. |openedx.core.lib.plugins| replace:: ``openedx.core.lib.plugins``
.. _openedx.core.lib.plugins: https://github.com/edx/edx-platform/blob/master/openedx/core/lib/plugins.py
.. |edx_django_utils.plugins| replace:: ``edx_django_utils.plugins``
.. _edx_django_utils.plugins: https://github.com/edx/edx-django-utils/blob/master/edx_django_utils/plugins
.. _stevedore: https://pypi.org/project/stevedore/
Here are the different integration points that python plugins can use:

View File

@@ -3,7 +3,6 @@ Celery needs to be loaded when the cms modules are so that task
registration and discovery can work correctly.
"""
# We monkey patch Kombu's entrypoints listing because scanning through this
# accounts for the majority of LMS/Studio startup time for tests, and we don't
# use custom Kombu serializers (which is what this is for). Still, this is

View File

@@ -6,9 +6,10 @@ Signal handlers are connected here.
from django.apps import AppConfig
from edx_django_utils.plugins import PluginSettings, PluginURLs
from openedx.core.constants import COURSE_ID_PATTERN
from openedx.core.djangoapps.plugins.constants import PluginSettings, PluginURLs, ProjectType, SettingsType
from openedx.core.djangoapps.plugins.constants import ProjectType, SettingsType
class DiscussionConfig(AppConfig):

View File

@@ -7,9 +7,10 @@ Signal handlers are connected here.
from django.apps import AppConfig
from django.conf import settings
from edx_django_utils.plugins import PluginSettings, PluginURLs
from edx_proctoring.runtime import set_runtime_service
from openedx.core.djangoapps.plugins.constants import PluginSettings, PluginURLs, ProjectType, SettingsType
from openedx.core.djangoapps.plugins.constants import ProjectType, SettingsType
class GradesConfig(AppConfig):

View File

@@ -5,10 +5,11 @@ Instructor Application Configuration
from django.apps import AppConfig
from django.conf import settings
from edx_django_utils.plugins import PluginSettings, PluginURLs
from edx_proctoring.runtime import set_runtime_service
from openedx.core.constants import COURSE_ID_PATTERN
from openedx.core.djangoapps.plugins.constants import PluginSettings, PluginURLs, ProjectType, SettingsType
from openedx.core.djangoapps.plugins.constants import ProjectType, SettingsType
class InstructorConfig(AppConfig):

View File

@@ -5,8 +5,9 @@ ProgramEnrollments Application Configuration
from django.apps import AppConfig
from edx_django_utils.plugins import PluginURLs
from openedx.core.djangoapps.plugins.constants import PluginURLs, ProjectType
from openedx.core.djangoapps.plugins.constants import ProjectType
class ProgramEnrollmentsConfig(AppConfig):

View File

@@ -3880,9 +3880,11 @@ SYSTEM_WIDE_ROLE_CLASSES = []
############## Plugin Django Apps #########################
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)
from edx_django_utils.plugins import get_plugin_apps, add_plugins
# pylint: disable=wrong-import-position, wrong-import-order
from openedx.core.djangoapps.plugins.constants import ProjectType, SettingsType
INSTALLED_APPS.extend(get_plugin_apps(ProjectType.LMS))
add_plugins(__name__, ProjectType.LMS, SettingsType.COMMON)
DEPRECATED_ADVANCED_COMPONENT_TYPES = []

View File

@@ -11,8 +11,9 @@ from corsheaders.defaults import default_headers as corsheaders_default_headers
# pylint: enable=unicode-format-string
#####################################################################
from openedx.core.djangoapps.plugins import constants as plugin_constants
from openedx.core.djangoapps.plugins import plugin_settings
from edx_django_utils.plugins import add_plugins
from openedx.core.djangoapps.plugins.constants import ProjectType, SettingsType
from .production import * # pylint: disable=wildcard-import, unused-wildcard-import
@@ -309,7 +310,7 @@ JWT_AUTH.update({
'y5ZLcTUomo4rZLjghVpq6KZxfS6I1Vz79ZsMVUWEdXOYePCKKsrQG20ogQEkmTf9FT_SouC6jPcHLXw"}]}'
),
})
plugin_settings.add_plugins(__name__, plugin_constants.ProjectType.LMS, plugin_constants.SettingsType.DEVSTACK)
add_plugins(__name__, ProjectType.LMS, SettingsType.DEVSTACK)
######################### Django Rest Framework ########################

View File

@@ -27,9 +27,10 @@ import dateutil
import yaml
from corsheaders.defaults import default_headers as corsheaders_default_headers
from django.core.exceptions import ImproperlyConfigured
from edx_django_utils.plugins import add_plugins
from path import Path as path
from openedx.core.djangoapps.plugins import plugin_settings, constants as plugin_constants
from openedx.core.djangoapps.plugins.constants import ProjectType, SettingsType
from openedx.core.lib.derived import derive_settings
from openedx.core.lib.logsettings import get_logger_config
from xmodule.modulestore.modulestore_settings import convert_module_store_setting_if_needed
@@ -949,7 +950,7 @@ MAX_BLOCKS_PER_CONTENT_LIBRARY = ENV_TOKENS.get('MAX_BLOCKS_PER_CONTENT_LIBRARY'
# This is at the bottom because it is going to load more settings after base settings are loaded
# Load production.py in plugins
plugin_settings.add_plugins(__name__, plugin_constants.ProjectType.LMS, plugin_constants.SettingsType.PRODUCTION)
add_plugins(__name__, ProjectType.LMS, SettingsType.PRODUCTION)
########################## Derive Any Derived Settings #######################

View File

@@ -23,10 +23,11 @@ from uuid import uuid4
import openid.oidutil
from django.utils.translation import ugettext_lazy
from edx_django_utils.plugins import add_plugins
from path import Path as path
from six.moves import range
from openedx.core.djangoapps.plugins import plugin_settings, constants as plugin_constants
from openedx.core.djangoapps.plugins.constants import ProjectType, SettingsType
from openedx.core.lib.derived import derive_settings
from openedx.core.lib.tempdir import mkdtemp_clean
@@ -557,7 +558,7 @@ JWT_AUTH.update({
# pylint: enable=unicode-format-string
####################### Plugin Settings ##########################
plugin_settings.add_plugins(__name__, plugin_constants.ProjectType.LMS, plugin_constants.SettingsType.TEST)
add_plugins(__name__, ProjectType.LMS, SettingsType.TEST)
########################## Derive Any Derived Settings #######################

View File

@@ -10,6 +10,7 @@ from django.contrib.admin import autodiscover as django_autodiscover
from django.utils.translation import ugettext_lazy as _
from django.views.generic.base import RedirectView
from edx_api_doc_tools import make_docs_urls
from edx_django_utils.plugins import get_plugin_url_patterns
from ratelimitbackend import admin
from branding import views as branding_views
@@ -41,8 +42,7 @@ from openedx.core.djangoapps.django_comment_common.models import ForumsConfig
from openedx.core.djangoapps.lang_pref import views as lang_pref_views
from openedx.core.djangoapps.password_policy import compliance as password_policy_compliance
from openedx.core.djangoapps.password_policy.forms import PasswordPolicyAwareAdminAuthForm
from openedx.core.djangoapps.plugins import constants as plugin_constants
from openedx.core.djangoapps.plugins import plugin_urls
from openedx.core.djangoapps.plugins.constants import ProjectType
from openedx.core.djangoapps.programs.models import ProgramsApiConfig
from openedx.core.djangoapps.self_paced.models import SelfPacedConfiguration
from openedx.core.djangoapps.site_configuration import helpers as configuration_helpers
@@ -961,7 +961,7 @@ urlpatterns.append(
),
)
urlpatterns.extend(plugin_urls.get_patterns(plugin_constants.ProjectType.LMS))
urlpatterns.extend(get_plugin_url_patterns(ProjectType.LMS))
# Course Home API urls
urlpatterns += [

View File

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

View File

@@ -5,8 +5,9 @@ Configuration for bookmarks Django app
from django.apps import AppConfig
from django.utils.translation import ugettext_lazy as _
from edx_django_utils.plugins import PluginSettings, PluginURLs
from openedx.core.djangoapps.plugins.constants import PluginSettings, PluginURLs, ProjectType, SettingsType
from openedx.core.djangoapps.plugins.constants import ProjectType, SettingsType
class BookmarksConfig(AppConfig):

View File

@@ -8,9 +8,9 @@ from base64 import b64encode
from hashlib import sha1
import six
from edx_django_utils.plugins import PluginManager
from openedx.core.lib.cache_utils import process_cached
from openedx.core.lib.plugins import PluginManager
class TransformerRegistry(PluginManager):

View File

@@ -5,8 +5,9 @@ Django AppConfig for Content Libraries Implementation
from django.apps import AppConfig
from edx_django_utils.plugins import PluginURLs, PluginSettings
from openedx.core.djangoapps.plugins.constants import ProjectType, PluginURLs, PluginSettings
from openedx.core.djangoapps.plugins.constants import ProjectType
class ContentLibrariesConfig(AppConfig):

View File

@@ -6,8 +6,9 @@ Signal handlers are connected here.
from django.apps import AppConfig
from edx_django_utils.plugins import PluginURLs
from openedx.core.djangoapps.plugins.constants import PluginURLs, ProjectType
from openedx.core.djangoapps.plugins.constants import ProjectType
class CoursewareAPIConfig(AppConfig):

View File

@@ -5,8 +5,9 @@ Credentials Configuration
from django.apps import AppConfig
from django.utils.translation import ugettext_lazy as _
from edx_django_utils.plugins import PluginSettings, PluginSignals
from openedx.core.djangoapps.plugins.constants import PluginSettings, PluginSignals, ProjectType, SettingsType
from openedx.core.djangoapps.plugins.constants import ProjectType, SettingsType
class CredentialsConfig(AppConfig):

View File

@@ -3,8 +3,9 @@
olx_rest_api Django application initialization.
"""
from django.apps import AppConfig
from edx_django_utils.plugins import PluginURLs
from openedx.core.djangoapps.plugins.constants import PluginURLs, ProjectType
from openedx.core.djangoapps.plugins.constants import ProjectType
class OlxRestApiAppConfig(AppConfig):

View File

@@ -8,8 +8,9 @@ from dateutil.parser import parse as parse_date
from django.apps import AppConfig
from django.conf import settings
from django.utils.translation import ugettext_lazy as _
from edx_django_utils.plugins import PluginSettings
from openedx.core.djangoapps.plugins.constants import PluginSettings, ProjectType, SettingsType
from openedx.core.djangoapps.plugins.constants import ProjectType, SettingsType
log = logging.getLogger(__name__)

View File

@@ -1,248 +1,8 @@
Django App Plugins
==================
Plugins App
===========
Provides functionality to enable improved plugin support of Django apps.
This app provides edx-platform specific constants and support for the Django Plugin infrastructure defined in `edx_django_utils/plugins`_.
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.
It enables you to add a plugin to LMS or CMS, including ensuring Django signals work correctly between edx-platform and installed plugins.
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, PluginContexts
)
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.PRODUCTION: {
# 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.production',
},
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',
}],
}
},
# Configuration setting for Plugin Contexts for this app.
PluginContexts.CONFIG: {
# Configure the Plugin Signals for each Project Type, as needed.
ProjectType.LMS: {
# Key is the view that the app wishes to add context to and the value
# is the function within the app that will return additional context
# when called with the original context
u'course_dashboard': u'my_app.context_api.get_dashboard_context'
}
}
}
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'production': { relative_path: u'settings.production' },
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',
}],
}
},
u'view_context_config': {
u'lms.djangoapp': {
'course_dashboard': u'my_app.context_api.get_dashboard_context'
}
}
}
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'
.. _edx_django_utils/plugins: https://github.com/edx/edx-django-utils/tree/master/edx_django_utils/plugins

View File

@@ -7,14 +7,17 @@ Signal handlers are connected here.
from django.apps import AppConfig
from django.conf import settings
from . import constants, plugin_signals
from edx_django_utils.plugins import connect_plugin_receivers
from openedx.core.djangoapps.plugins.constants import ProjectType
class PluginsConfig(AppConfig):
"""
Application Configuration for Plugins.
"""
name = u'openedx.core.djangoapps.plugins'
name = 'openedx.core.djangoapps.plugins'
plugin_app = {}
@@ -23,8 +26,8 @@ class PluginsConfig(AppConfig):
Connect plugin receivers to their signals.
"""
if settings.ROOT_URLCONF == 'lms.urls':
project_type = constants.ProjectType.LMS
project_type = ProjectType.LMS
else:
project_type = constants.ProjectType.CMS
project_type = ProjectType.CMS
plugin_signals.connect_receivers(project_type)
connect_plugin_receivers(project_type)

View File

@@ -1,25 +1,16 @@
# 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):
class ProjectType():
"""
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'
LMS = 'lms.djangoapp'
CMS = 'cms.djangoapp'
class SettingsType(object):
class SettingsType():
"""
The SettingsType enum defines the possible values for the settings files
that are available for extension in the edx-platform. Plugin apps use these
@@ -29,59 +20,8 @@ class SettingsType(object):
See https://github.com/edx/edx-platform/master/lms/envs/docs/README.rst for
further information on each Settings Type.
"""
PRODUCTION = u'production'
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'
class PluginContexts(object):
"""
The PluginContexts enum defines dictionary field names (and defaults)
that can be specified by a Plugin App in order to configure the
additional views it would like to add context into.
"""
CONFIG = u"view_context_config"
PRODUCTION = 'production'
COMMON = 'common'
DEVSTACK = 'devstack'
TEST = 'test'

View File

@@ -0,0 +1,24 @@
Purpose of App: plugins
=======================
Status
------
Accepted
Context
-------
.. note:: This doc was written long after this app was created, thus explaining the past tense in language below.
When this was created, the only way to add another app to lms/cms was to create the app in edx-platform repo. This was untenible due to already large size of edx-platform and adding another app increased the chance of further entanglement between the different apps.
Decision
--------
It was decided the ability to add django apps as plugins was necessary.
Consequences
------------
This would make it easier to move code for some critical/key apps outside of edx-platform and into their on repository. It would also make it easier to add further functionality into lms/cms without needing to change any code in edx-platform.

View File

@@ -0,0 +1,15 @@
Extract plugins infrastructure
==============================
Status
------
Accepted
Decision
---------
It was decided to extract this plugin infrastructure to make it reusable. More info on this extraction can be found in `extraction decision doc`_ in edx-django-utils repository.
.. _extraction decision doc: https://github.com/edx/edx-django-utils/blob/master/docs/decisions/0002-extract-plugins-infrastructure-from-edx-platform.rst

View File

@@ -1,88 +0,0 @@
Plugin Contexts
---------------
Status
======
Draft
Context
=======
edx-platform contains a plugin system (https://github.com/edx/edx-platform/tree/master/openedx/core/djangoapps/plugins) which allows new Django apps to be installed inside the LMS and Studio without requiring the LMS/Studio to know about them. This is what enables us to move to a small and extensible core. While we had the ability to add settings, URLs, and signal handlers in our plugins, there wasn't any way for a plugin to affect the commonly used pages that the core was delivering. Thus a plugin couldn't change any details on the dashboard, courseware, or any other rendered page that the platform delivered.
Decisions
=========
We have added the ability to add page context additions to the plugin system. This means that a plugin will be able to add context to any view where it is enabled. To support this we have decided:
1. Plugins will define a callable function that the LMS and/or studio can import and call, which will return additional context to be added.
2. Every page that a plugin wants to add context to, must add a line to add the plugin contexts directly before the render.
3. Plugin context will live in a dictionary called "plugins" that will be passed into the context the templates receive. The structure will look like:
.. code-block::
{
..existing context values..
"plugins": {
"my_new_plugin": {... my_new_plugins's values ...},
"my_other_plugin": {... my_other_plugin's values ...},
}
}
4. Each view will have a constant name that will be defined within it's app's API.py which will be used by plugins. These must be globally unique. These will also be recorded in the rendering app's README.rst file.
5. Plugin apps have the option to either use the view name strings directly or import the constants from the rendering app's api.py if the plugin is part of the edx-platform repo.
6. For now, in order to use these new context data items, we must use theming alongside this to keep the new context out of the core. This may be iterated on in the future.
Implementation
==============
In the plugin app
~~~~~~~~~~~~~~~~~
Config
++++++
Inside of the AppConfig of your new plugin app, add a "view_context_config" item like below.
* The format will be ``{"globally_unique_view_name": "function_inside_plugin_app"}``
* The function name & path don't need to be named anything specific, so long as they work
* These functions will be called on **every** render of that view, so keep them efficient or memoize them if they aren't user specific.
.. code-block::
class MyAppConfig(AppConfig):
name = "my_app"
plugin_app = {
"view_context_config": {
"lms.djangoapp": {
"course_dashboard": "my_app.context_api.get_dashboard_context"
}
}
}
Function
++++++++
The function that will be called by the plugin system should accept a single parameter which will be the previously existing context. It should then return a dictionary which consists of items which will be added to the context
Example:
.. code-block::
def my_context_function(existing_context, *args, **kwargs):
additional_context = {"some_plugin_value": 10}
if existing_context.get("some_core_value"):
additional_context.append({"some_other_plugin_value": True})
return additional_context
In the core (LMS / Studio)
~~~~~~~~~~~~~~~~~~~~~~~~~~
The view you wish to add context to should have the following pieces enabled:
* A constant defined inside the app's for the globally unique view name.
* The view must call lines similar to the below right before the render so that the plugin has the full context.
.. code-block::
context_from_plugins = get_plugins_view_context(
plugin_constants.ProjectType.LMS,
current_app.api.THIS_VIEW_NAME,
context
)
context.update(context_from_plugins)

View File

@@ -1,22 +0,0 @@
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.debug(u'Plugin Apps: Found %s', plugin_apps)
return plugin_apps

View File

@@ -1,93 +0,0 @@
from importlib import import_module
from logging import getLogger
from openedx.core.lib.cache_utils import process_cached
from . import constants, registry
log = getLogger(__name__)
def get_plugins_view_context(project_type, view_name, existing_context=None):
"""
Returns a dict of additional view context. Will check if any plugin apps
have that view in their view_context_config, and if so will call their
selected function to get their context dicts.
Params:
project_type: a string that determines which project (lms or studio) the view is being called in. See the
ProjectType enum in plugins/constants.py for valid options
view_name: a string that determines which view needs the additional context. These are globally unique and
noted in the api.py in the view's app.
existing_context: a dictionary which includes all of the data that the page was going to render with prior
to the addition of each plugin's context. This is what will be passed to plugins so they may choose
what data to add to the view.
"""
aggregate_context = {"plugins": {}}
if existing_context is None:
existing_context = {}
context_functions = _get_cached_context_functions_for_view(project_type, view_name)
for (context_function, plugin_name) in context_functions:
try:
plugin_context = context_function(existing_context)
except Exception as exc:
# We're catching this because we don't want the core to blow up when a
# plugin is broken. This exception will probably need some sort of
# monitoring hooked up to it to make sure that these errors don't go
# unseen.
log.exception("Failed to call plugin context function. Error: %s", exc)
continue
aggregate_context["plugins"][plugin_name] = plugin_context
return aggregate_context
@process_cached
def _get_cached_context_functions_for_view(project_type, view_name):
"""
Returns a list of tuples where the first item is the context function
and the second item is the name of the plugin it's being called from.
NOTE: These will be functions will be cached (in RAM not memcache) on this unique
combination. If we enable many new views to use this system, we may notice an
increase in memory usage as the entirety of these functions will be held in memory.
"""
context_functions = []
for app_config in registry.get_app_configs(project_type):
context_function_path = _get_context_function_path(app_config, project_type, view_name)
if context_function_path:
module_path, _, name = context_function_path.rpartition('.')
try:
module = import_module(module_path)
except (ImportError, ModuleNotFoundError):
log.exception(
"Failed to import %s plugin when creating %s context",
module_path,
view_name
)
continue
context_function = getattr(module, name, None)
if context_function:
plugin_name, _, _ = module_path.partition('.')
context_functions.append((context_function, plugin_name))
else:
log.warning(
"Failed to retrieve %s function from %s plugin when creating %s context",
name,
module_path,
view_name
)
return context_functions
def _get_context_function_path(app_config, project_type, view_name):
plugin_config = getattr(app_config, constants.PLUGIN_APP_CLASS_ATTRIBUTE_NAME, {})
context_config = plugin_config.get(constants.PluginContexts.CONFIG, {})
project_type_settings = context_config.get(project_type, {})
return project_type_settings.get(view_name)

View File

@@ -1,45 +0,0 @@
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.debug(
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.debug(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

@@ -1,65 +0,0 @@
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.debug(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.debug(
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

@@ -1,57 +0,0 @@
from logging import getLogger
from django.conf.urls import include, url
from . import constants, registry, utils
log = getLogger(__name__)
def get_url(url_module_path, url_config):
"""
function constructs the appropriate URL
"""
namespace = url_config[constants.PluginURLs.NAMESPACE]
app_name = url_config.get(constants.PluginURLs.APP_NAME)
regex = url_config.get(constants.PluginURLs.REGEX, r'')
if namespace:
return url(regex, include((url_module_path, app_name), namespace=namespace))
else:
return url(regex, include(url_module_path))
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 [get_url(url_module_path, url_config) 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.debug(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)
url_config[constants.PluginURLs.APP_NAME] = app_config.name
log.debug(
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

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

View File

@@ -1,39 +0,0 @@
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

@@ -2,8 +2,9 @@
from django.apps import AppConfig
from django.utils.translation import ugettext_lazy as _
from edx_django_utils.plugins import PluginSignals
from openedx.core.djangoapps.plugins.constants import PluginSignals, ProjectType
from openedx.core.djangoapps.plugins.constants import ProjectType
class SchedulesConfig(AppConfig):

View File

@@ -1,8 +1,9 @@
from django.apps import AppConfig
from edx_django_utils.plugins import PluginURLs
from openedx.core.djangoapps.plugins.constants import PluginURLs, ProjectType
from openedx.core.djangoapps.plugins.constants import ProjectType
plugin_urls_config = {PluginURLs.NAMESPACE: u'theming', PluginURLs.REGEX: r'^theming/'}

View File

@@ -2,10 +2,10 @@
User Authentication Configuration
"""
from django.apps import AppConfig
from edx_django_utils.plugins import PluginURLs
from openedx.core.djangoapps.plugins.constants import PluginURLs, ProjectType
from openedx.core.djangoapps.plugins.constants import ProjectType
class UserAuthnConfig(AppConfig):

View File

@@ -1,11 +1,11 @@
"""
Helper methods for working with learning contexts
"""
from edx_django_utils.plugins import PluginManager
from opaque_keys import OpaqueKey
from opaque_keys.edx.keys import LearningContextKey, UsageKeyV2
from openedx.core.djangoapps.xblock.apps import get_xblock_app_config
from openedx.core.lib.plugins import PluginManager
class LearningContextPluginManager(PluginManager):

View File

@@ -5,8 +5,9 @@ Zendesk Proxy Configuration
from django.apps import AppConfig
from edx_django_utils.plugins import PluginURLs, PluginSettings
from openedx.core.djangoapps.plugins.constants import ProjectType, SettingsType, PluginURLs, PluginSettings
from openedx.core.djangoapps.plugins.constants import ProjectType, SettingsType
class ZendeskProxyConfig(AppConfig):

View File

@@ -2,10 +2,9 @@
Tabs for courseware.
"""
from edx_django_utils.plugins import PluginManager
from functools import cmp_to_key
from openedx.core.lib.plugins import PluginManager
# Stevedore extension point namespaces
COURSE_TAB_NAMESPACE = 'openedx.course_tab'

View File

@@ -2,7 +2,7 @@
A plugin manager to retrieve the dynamic course partitions generators.
"""
from openedx.core.lib.plugins import PluginManager
from edx_django_utils.plugins import PluginManager
class DynamicPartitionGeneratorsPluginManager(PluginManager):

View File

@@ -1,47 +0,0 @@
"""
Adds support for first class plugins that can be added to the edX platform.
"""
from collections import OrderedDict
from stevedore.extension import ExtensionManager
from openedx.core.lib.cache_utils import process_cached
class PluginError(Exception):
"""
Base Exception for when an error was found regarding plugins.
"""
pass
class PluginManager(object):
"""
Base class that manages plugins for the edX platform.
"""
@classmethod
@process_cached
def get_available_plugins(cls, namespace=None):
"""
Returns a dict of all the plugins that have been made available through the platform.
"""
# Note: we're creating the extension manager lazily to ensure that the Python path
# has been correctly set up. Trying to create this statically will fail, unfortunately.
plugins = OrderedDict()
extension_manager = ExtensionManager(namespace=namespace or cls.NAMESPACE) # pylint: disable=no-member
for plugin_name in extension_manager.names():
plugins[plugin_name] = extension_manager[plugin_name].plugin
return plugins
@classmethod
def get_plugin(cls, name, namespace=None):
"""
Returns the plugin with the given name.
"""
plugins = cls.get_available_plugins(namespace)
if name not in plugins:
raise PluginError(u"No such plugin {name} for entry point {namespace}".format(
name=name,
namespace=namespace or cls.NAMESPACE, # pylint: disable=no-member
))
return plugins[name]

View File

@@ -4,8 +4,8 @@ Tests for the plugin API
from django.test import TestCase
from edx_django_utils.plugins import PluginError
from openedx.core.lib.plugins import PluginError
from openedx.core.lib.course_tabs import CourseTabPluginManager

View File

@@ -3,7 +3,7 @@ A module containing the CallToActionService which can be used for an xblock Runt
specific CTAs for specific XBlock contexts.
"""
from openedx.core.lib.plugins import PluginManager
from edx_django_utils.plugins import PluginManager
class CallToActionService(PluginManager):

View File

@@ -4,7 +4,9 @@ Announcements Application Configuration
from django.apps import AppConfig
from openedx.core.djangoapps.plugins.constants import ProjectType, SettingsType, PluginURLs, PluginSettings
from edx_django_utils.plugins import PluginURLs, PluginSettings
from openedx.core.djangoapps.plugins.constants import ProjectType, SettingsType
class AnnouncementsConfig(AppConfig):

View File

@@ -5,7 +5,7 @@ Support for course tool plugins.
from enum import Enum
from openedx.core.lib.plugins import PluginManager
from edx_django_utils.plugins import PluginManager
# Stevedore extension point namespace
COURSE_TOOLS_NAMESPACE = 'openedx.course_tool'