Merge pull request #10502 from jsenecal/9880-allow-plugins-to-register-apps

Allow Plugins to register a list of Django apps to be appended to INSTALLED_APPS
This commit is contained in:
Jeremy Stretch 2022-09-30 17:28:56 -04:00 committed by GitHub
commit a454a3f74e
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
4 changed files with 53 additions and 9 deletions

View File

@ -14,6 +14,7 @@ Plugins can do a lot, including:
* Provide their own "pages" (views) in the web user interface * Provide their own "pages" (views) in the web user interface
* Inject template content and navigation links * Inject template content and navigation links
* Extend NetBox's REST and GraphQL APIs * Extend NetBox's REST and GraphQL APIs
* Load additional Django apps
* Add custom request/response middleware * Add custom request/response middleware
However, keep in mind that each piece of functionality is entirely optional. For example, if your plugin merely adds a piece of middleware or an API endpoint for existing data, there's no need to define any new models. However, keep in mind that each piece of functionality is entirely optional. For example, if your plugin merely adds a piece of middleware or an API endpoint for existing data, there's no need to define any new models.
@ -82,6 +83,7 @@ class FooBarConfig(PluginConfig):
default_settings = { default_settings = {
'baz': True 'baz': True
} }
django_apps = ["foo", "bar", "baz"]
config = FooBarConfig config = FooBarConfig
``` ```
@ -101,6 +103,7 @@ NetBox looks for the `config` variable within a plugin's `__init__.py` to load i
| `base_url` | Base path to use for plugin URLs (optional). If not specified, the project's `name` will be used. | | `base_url` | Base path to use for plugin URLs (optional). If not specified, the project's `name` will be used. |
| `required_settings` | A list of any configuration parameters that **must** be defined by the user | | `required_settings` | A list of any configuration parameters that **must** be defined by the user |
| `default_settings` | A dictionary of configuration parameters and their default values | | `default_settings` | A dictionary of configuration parameters and their default values |
| `django_apps` | A list of additional Django apps to load alongside the plugin |
| `min_version` | Minimum version of NetBox with which the plugin is compatible | | `min_version` | Minimum version of NetBox with which the plugin is compatible |
| `max_version` | Maximum version of NetBox with which the plugin is compatible | | `max_version` | Maximum version of NetBox with which the plugin is compatible |
| `middleware` | A list of middleware classes to append after NetBox's build-in middleware | | `middleware` | A list of middleware classes to append after NetBox's build-in middleware |
@ -112,6 +115,14 @@ NetBox looks for the `config` variable within a plugin's `__init__.py` to load i
All required settings must be configured by the user. If a configuration parameter is listed in both `required_settings` and `default_settings`, the default setting will be ignored. All required settings must be configured by the user. If a configuration parameter is listed in both `required_settings` and `default_settings`, the default setting will be ignored.
#### Important Notes About `django_apps`
Loading additional apps may cause more harm than good and could make identifying problems within NetBox itself more difficult. The `django_apps` attribute is intended only for advanced use cases that require a deeper Django integration.
Apps from this list are inserted *before* the plugin's `PluginConfig` in the order defined. Adding the plugin's `PluginConfig` module to this list changes this behavior and allows for apps to be loaded *after* the plugin.
Any additional apps must be installed within the same Python environment as NetBox or `ImproperlyConfigured` exceptions will be raised when loading the plugin.
## Create setup.py ## Create setup.py
`setup.py` is the [setup script](https://docs.python.org/3.8/distutils/setupscript.html) used to package and install our plugin once it's finished. The primary function of this script is to call the setuptools library's `setup()` function to create a Python distribution package. We can pass a number of keyword arguments to control the package creation as well as to provide metadata about the plugin. An example `setup.py` is below: `setup.py` is the [setup script](https://docs.python.org/3.8/distutils/setupscript.html) used to package and install our plugin once it's finished. The primary function of this script is to call the setuptools library's `setup()` function to create a Python distribution package. We can pass a number of keyword arguments to control the package creation as well as to provide metadata about the plugin. An example `setup.py` is below:

View File

@ -92,6 +92,9 @@ class DeviceTypeTable(NetBoxTable):
template_code=DEVICE_WEIGHT, template_code=DEVICE_WEIGHT,
order_by=('_abs_weight', 'weight_unit') order_by=('_abs_weight', 'weight_unit')
) )
u_height = columns.TemplateColumn(
template_code='{{ value|floatformat }}'
)
class Meta(NetBoxTable.Meta): class Meta(NetBoxTable.Meta):
model = DeviceType model = DeviceType

View File

@ -55,6 +55,9 @@ class PluginConfig(AppConfig):
# Django-rq queues dedicated to the plugin # Django-rq queues dedicated to the plugin
queues = [] queues = []
# Django apps to append to INSTALLED_APPS when plugin requires them.
django_apps = []
# Default integration paths. Plugin authors can override these to customize the paths to # Default integration paths. Plugin authors can override these to customize the paths to
# integrated components. # integrated components.
graphql_schema = 'graphql.schema' graphql_schema = 'graphql.schema'

View File

@ -1,18 +1,19 @@
import hashlib import hashlib
import importlib import importlib
import logging import importlib.util
import os import os
import platform import platform
import re
import socket
import sys import sys
import warnings import warnings
from urllib.parse import urlsplit from urllib.parse import urlsplit
import django
import sentry_sdk import sentry_sdk
from django.contrib.messages import constants as messages from django.contrib.messages import constants as messages
from django.core.exceptions import ImproperlyConfigured, ValidationError from django.core.exceptions import ImproperlyConfigured, ValidationError
from django.core.validators import URLValidator from django.core.validators import URLValidator
from django.utils.encoding import force_str
from extras.plugins import PluginConfig
from sentry_sdk.integrations.django import DjangoIntegration from sentry_sdk.integrations.django import DjangoIntegration
from netbox.config import PARAMS from netbox.config import PARAMS
@ -20,9 +21,7 @@ from netbox.config import PARAMS
# Monkey patch to fix Django 4.0 support for graphene-django (see # Monkey patch to fix Django 4.0 support for graphene-django (see
# https://github.com/graphql-python/graphene-django/issues/1284) # https://github.com/graphql-python/graphene-django/issues/1284)
# TODO: Remove this when graphene-django 2.16 becomes available # TODO: Remove this when graphene-django 2.16 becomes available
import django django.utils.encoding.force_text = force_str # type: ignore
from django.utils.encoding import force_str
django.utils.encoding.force_text = force_str
# #
@ -186,7 +185,7 @@ if STORAGE_BACKEND is not None:
if STORAGE_BACKEND.startswith('storages.'): if STORAGE_BACKEND.startswith('storages.'):
try: try:
import storages.utils import storages.utils # type: ignore
except ModuleNotFoundError as e: except ModuleNotFoundError as e:
if getattr(e, 'name') == 'storages': if getattr(e, 'name') == 'storages':
raise ImproperlyConfigured( raise ImproperlyConfigured(
@ -663,14 +662,42 @@ for plugin_name in PLUGINS:
# Determine plugin config and add to INSTALLED_APPS. # Determine plugin config and add to INSTALLED_APPS.
try: try:
plugin_config = plugin.config plugin_config: PluginConfig = plugin.config
INSTALLED_APPS.append("{}.{}".format(plugin_config.__module__, plugin_config.__name__))
except AttributeError: except AttributeError:
raise ImproperlyConfigured( raise ImproperlyConfigured(
"Plugin {} does not provide a 'config' variable. This should be defined in the plugin's __init__.py file " "Plugin {} does not provide a 'config' variable. This should be defined in the plugin's __init__.py file "
"and point to the PluginConfig subclass.".format(plugin_name) "and point to the PluginConfig subclass.".format(plugin_name)
) )
plugin_module = "{}.{}".format(plugin_config.__module__, plugin_config.__name__) # type: ignore
# Gather additional apps to load alongside this plugin
django_apps = plugin_config.django_apps
if plugin_name in django_apps:
django_apps.pop(plugin_name)
if plugin_module not in django_apps:
django_apps.append(plugin_module)
# Test if we can import all modules (or its parent, for PluginConfigs and AppConfigs)
for app in django_apps:
if "." in app:
parts = app.split(".")
spec = importlib.util.find_spec(".".join(parts[:-1]))
else:
spec = importlib.util.find_spec(app)
if spec is None:
raise ImproperlyConfigured(
f"Failed to load django_apps specified by plugin {plugin_name}: {django_apps} "
f"The module {app} cannot be imported. Check that the necessary package has been "
"installed within the correct Python environment."
)
INSTALLED_APPS.extend(django_apps)
# Preserve uniqueness of the INSTALLED_APPS list, we keep the last occurence
sorted_apps = reversed(list(dict.fromkeys(reversed(INSTALLED_APPS))))
INSTALLED_APPS = list(sorted_apps)
# Validate user-provided configuration settings and assign defaults # Validate user-provided configuration settings and assign defaults
if plugin_name not in PLUGINS_CONFIG: if plugin_name not in PLUGINS_CONFIG:
PLUGINS_CONFIG[plugin_name] = {} PLUGINS_CONFIG[plugin_name] = {}