Browse Source

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
Jeremy Stretch 3 years ago
parent
commit
a454a3f74e

+ 11 - 0
docs/plugins/development/index.md

@@ -14,6 +14,7 @@ Plugins can do a lot, including:
 * Provide their own "pages" (views) in the web user interface
 * Inject template content and navigation links
 * Extend NetBox's REST and GraphQL APIs
+* Load additional Django apps
 * 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.
@@ -82,6 +83,7 @@ class FooBarConfig(PluginConfig):
     default_settings = {
         'baz': True
     }
+    django_apps = ["foo", "bar", "baz"]
 
 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.                        |
 | `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                                                        |
+| `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                                                            |
 | `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                                                |
@@ -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.
 
+#### 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
 
 `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:

+ 3 - 0
netbox/dcim/tables/devicetypes.py

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

+ 3 - 0
netbox/extras/plugins/__init__.py

@@ -55,6 +55,9 @@ class PluginConfig(AppConfig):
     # Django-rq queues dedicated to the plugin
     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
     # integrated components.
     graphql_schema = 'graphql.schema'

+ 36 - 9
netbox/netbox/settings.py

@@ -1,18 +1,19 @@
 import hashlib
 import importlib
-import logging
+import importlib.util
 import os
 import platform
-import re
-import socket
 import sys
 import warnings
 from urllib.parse import urlsplit
 
+import django
 import sentry_sdk
 from django.contrib.messages import constants as messages
 from django.core.exceptions import ImproperlyConfigured, ValidationError
 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 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
 # https://github.com/graphql-python/graphene-django/issues/1284)
 # TODO: Remove this when graphene-django 2.16 becomes available
-import django
-from django.utils.encoding import force_str
-django.utils.encoding.force_text = force_str
+django.utils.encoding.force_text = force_str  # type: ignore
 
 
 #
@@ -186,7 +185,7 @@ if STORAGE_BACKEND is not None:
     if STORAGE_BACKEND.startswith('storages.'):
 
         try:
-            import storages.utils
+            import storages.utils  # type: ignore
         except ModuleNotFoundError as e:
             if getattr(e, 'name') == 'storages':
                 raise ImproperlyConfigured(
@@ -663,14 +662,42 @@ for plugin_name in PLUGINS:
 
     # Determine plugin config and add to INSTALLED_APPS.
     try:
-        plugin_config = plugin.config
-        INSTALLED_APPS.append("{}.{}".format(plugin_config.__module__, plugin_config.__name__))
+        plugin_config: PluginConfig = plugin.config
     except AttributeError:
         raise ImproperlyConfigured(
             "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)
         )
 
+    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
     if plugin_name not in PLUGINS_CONFIG:
         PLUGINS_CONFIG[plugin_name] = {}