From 28b5e88c50d6aba9ba08bb1be961e7f315965dfd Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Fri, 20 Mar 2020 14:35:54 -0400 Subject: [PATCH 1/5] Rename entry point group; simplify import --- netbox/netbox/settings.py | 11 ++--------- 1 file changed, 2 insertions(+), 9 deletions(-) diff --git a/netbox/netbox/settings.py b/netbox/netbox/settings.py index e3fa4e924..8527b737c 100644 --- a/netbox/netbox/settings.py +++ b/netbox/netbox/settings.py @@ -640,19 +640,12 @@ if PAGINATE_COUNT not in PER_PAGE_DEFAULTS: PLUGINS = [] if PLUGINS_ENABLED: - for entry_point in iter_entry_points(group='netbox.plugin', name=None): + for entry_point in iter_entry_points(group='netbox_plugins', name=None): plugin = entry_point.module_name PLUGINS.append(plugin) INSTALLED_APPS.append(plugin) - # Import the app config and locate the inner meta class - try: - module = importlib.import_module(plugin) - default_app_config = getattr(module, 'default_app_config') - module, app_config = default_app_config.rsplit('.', 1) - app_config = getattr(importlib.import_module(module), app_config) - except ImportError: - raise ImproperlyConfigured('Plugin config for {} could not be imported!'.format(plugin)) + app_config = entry_point.load() app_config_meta = getattr(app_config, 'NetBoxPluginMeta', None) if not app_config_meta: From bc50c2aa551091fa48428fbc00c112d42de4dd46 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Fri, 20 Mar 2020 15:13:25 -0400 Subject: [PATCH 2/5] Introduce PluginConfig --- netbox/extras/plugins/__init__.py | 35 +++++++++++++++++++++- netbox/netbox/settings.py | 50 ++++++++++++------------------- 2 files changed, 53 insertions(+), 32 deletions(-) diff --git a/netbox/extras/plugins/__init__.py b/netbox/extras/plugins/__init__.py index c0ac4259d..12e79d2dc 100644 --- a/netbox/extras/plugins/__init__.py +++ b/netbox/extras/plugins/__init__.py @@ -2,13 +2,46 @@ import collections import importlib import inspect -from django.core.exceptions import ImproperlyConfigured +from django.apps import AppConfig from django.template.loader import get_template from extras.registry import registry from .signals import register_detail_page_content_classes, register_nav_menu_link_classes +# +# Plugin AppConfig class +# + +class PluginConfig(AppConfig): + """ + Subclass of Django's built-in AppConfig class, to be used for NetBox plugins. + """ + # Plugin metadata + author = '' + description = '' + version = '' + + # Root URL path under /plugins. If not set, the plugin's label will be used. + url_slug = None + + # Minimum/maximum compatible versions of NetBox + min_version = None + max_version = None + + # Default configuration parameters + default_settings = {} + + # Mandatory configuration parameters + required_settings = [] + + # Middleware classes provided by the plugin + middleware = [] + + # Caching configuration + caching_config = {} + + # # Template content injection # diff --git a/netbox/netbox/settings.py b/netbox/netbox/settings.py index 8527b737c..6fed9dafe 100644 --- a/netbox/netbox/settings.py +++ b/netbox/netbox/settings.py @@ -642,62 +642,50 @@ PLUGINS = [] if PLUGINS_ENABLED: for entry_point in iter_entry_points(group='netbox_plugins', name=None): plugin = entry_point.module_name + app_config = entry_point.load() + PLUGINS.append(plugin) INSTALLED_APPS.append(plugin) - app_config = entry_point.load() - - app_config_meta = getattr(app_config, 'NetBoxPluginMeta', None) - if not app_config_meta: - raise ImproperlyConfigured( - 'The app config for plugin {} does not contain an inner meta class'.format(plugin) - ) - - # Check version contraints - min_version = getattr(app_config_meta, 'min_version', None) - max_version = getattr(app_config_meta, 'max_version', None) - parsed_min_version = parse_version(min_version or VERSION) - parsed_max_version = parse_version(max_version or VERSION) - if min_version and max_version and parsed_min_version > parsed_max_version: - raise ImproperlyConfigured('Plugin {} specifies invalid version contraints!'.format(plugin)) - if min_version and parsed_min_version > parse_version(VERSION): - raise ImproperlyConfigured('Plugin {} requires NetBox minimum version {}!'.format(plugin, min_version)) - if max_version and parsed_max_version < parse_version(VERSION): - raise ImproperlyConfigured('Plugin {} requires NetBox maximum version {}!'.format(plugin, max_version)) + # Check version constraints + parsed_min_version = parse_version(app_config.min_version or VERSION) + parsed_max_version = parse_version(app_config.max_version or VERSION) + if app_config.min_version and app_config.max_version and parsed_min_version > parsed_max_version: + raise ImproperlyConfigured(f"Plugin {plugin} specifies invalid version constraints!") + if app_config.min_version and parsed_min_version > parse_version(VERSION): + raise ImproperlyConfigured(f"Plugin {plugin} requires NetBox minimum version {app_config.min_version}!") + if app_config.max_version and parsed_max_version < parse_version(VERSION): + raise ImproperlyConfigured(f"Plugin {plugin} requires NetBox maximum version {app_config.max_version}!") # Add middleware - plugin_middleware = getattr(app_config_meta, 'middleware', []) + plugin_middleware = app_config.middleware if plugin_middleware and isinstance(plugin_middleware, list): MIDDLEWARE.extend(plugin_middleware) # Verify required configuration settings if plugin not in PLUGINS_CONFIG: PLUGINS_CONFIG[plugin] = {} - for setting in getattr(app_config_meta, 'required_settings', []): + for setting in app_config.required_settings: if setting not in PLUGINS_CONFIG[plugin]: raise ImproperlyConfigured( - "Plugin {} requires '{}' to be present in the PLUGINS_CONFIG section of configuration.py.".format( - plugin, - setting - ) + f"Plugin {plugin} requires '{setting}' to be present in the PLUGINS_CONFIG section of " + f"configuration.py." ) # Set defined default setting values - for setting, value in getattr(app_config_meta, 'default_settings', {}).items(): + for setting, value in app_config.default_settings.items(): if setting not in PLUGINS_CONFIG[plugin]: PLUGINS_CONFIG[plugin][setting] = value # Apply cacheops config - plugin_cacheops = getattr(app_config_meta, 'caching_config', {}) + plugin_cacheops = app_config.caching_config if plugin_cacheops and isinstance(plugin_cacheops, dict): for key in plugin_cacheops.keys(): # Validate config is only being set for the given plugin try: app = key.split('.')[0] except IndexError: - raise ImproperlyConfigured('Plugin {} caching_config is invalid!'.format(plugin)) + raise ImproperlyConfigured(f"Plugin {plugin} caching_config is invalid!") if app != plugin: - raise ImproperlyConfigured( - 'Plugin {} may not modify caching config for another app!'.format(plugin) - ) + raise ImproperlyConfigured(f"Plugin {plugin} may not modify caching config for another app!") CACHEOPS.update(plugin_cacheops) From ad1522f42802dc3e08ade11022c4685fc32ce8d9 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Fri, 20 Mar 2020 15:51:14 -0400 Subject: [PATCH 3/5] Update plugin URL loading logic --- netbox/netbox/urls.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/netbox/netbox/urls.py b/netbox/netbox/urls.py index 651aef14a..657e3d6b4 100644 --- a/netbox/netbox/urls.py +++ b/netbox/netbox/urls.py @@ -8,6 +8,7 @@ from django.views.static import serve from drf_yasg import openapi from drf_yasg.views import get_schema_view +from extras.plugins import PluginConfig from netbox.views import APIRootView, HomeView, StaticMediaFailureView, SearchView from users.views import LoginView, LogoutView from .admin import admin_site @@ -76,11 +77,11 @@ plugin_patterns = [] plugin_api_patterns = [] for app in apps.get_app_configs(): # Loop over all apps look for installed plugins - if hasattr(app, 'NetBoxPluginMeta'): + if isinstance(app, PluginConfig): # Check if the plugin specifies any URLs if importlib.util.find_spec('{}.urls'.format(app.name)): urls = importlib.import_module('{}.urls'.format(app.name)) - url_slug = getattr(app.NetBoxPluginMeta, 'url_slug', app.label) + url_slug = getattr(app, 'url_slug') or app.label if hasattr(urls, 'urlpatterns'): # Mount URLs at `/` plugin_patterns.append( @@ -91,7 +92,7 @@ for app in apps.get_app_configs(): if importlib.util.find_spec('{}.api.urls'.format(app.name)): urls = importlib.import_module('{}.api.urls'.format(app.name)) if hasattr(urls, 'urlpatterns'): - url_slug = getattr(app.NetBoxPluginMeta, 'url_slug', app.label) + url_slug = getattr(app, 'url_slug') or app.label # Mount URLs at `/` plugin_api_patterns.append( path('{}/'.format(url_slug), include((urls.urlpatterns, app.label))) From 4e84e8048f05f614dab35644f082eb611f30ef13 Mon Sep 17 00:00:00 2001 From: John Anderson Date: Fri, 20 Mar 2020 20:10:02 -0400 Subject: [PATCH 4/5] added admin and api views for listing all plugins, and refactored urls import --- netbox/extras/plugins/__init__.py | 3 +- netbox/extras/plugins/urls.py | 53 +++++++++++ netbox/extras/plugins/views.py | 95 +++++++++++++++++++ netbox/netbox/admin.py | 2 +- netbox/netbox/settings.py | 7 +- netbox/netbox/urls.py | 40 +------- netbox/netbox/views.py | 1 + netbox/templates/admin/index.html | 6 ++ .../templates/extras/admin/plugins_index.html | 14 +++ .../templates/extras/admin/plugins_list.html | 60 ++++++++++++ 10 files changed, 242 insertions(+), 39 deletions(-) create mode 100644 netbox/extras/plugins/urls.py create mode 100644 netbox/extras/plugins/views.py create mode 100644 netbox/templates/admin/index.html create mode 100644 netbox/templates/extras/admin/plugins_index.html create mode 100644 netbox/templates/extras/admin/plugins_list.html diff --git a/netbox/extras/plugins/__init__.py b/netbox/extras/plugins/__init__.py index 12e79d2dc..bab798f2f 100644 --- a/netbox/extras/plugins/__init__.py +++ b/netbox/extras/plugins/__init__.py @@ -19,6 +19,7 @@ class PluginConfig(AppConfig): """ # Plugin metadata author = '' + author_email = '' description = '' version = '' @@ -182,7 +183,7 @@ def register_nav_menu_links(): default_app_config = getattr(module, 'default_app_config') module, app_config = default_app_config.rsplit('.', 1) app_config = getattr(importlib.import_module(module), app_config) - section_name = app_config.NetBoxPluginMeta.name + section_name = getattr(app_config, 'verbose_name', app_config.name) if not isinstance(response, list): response = [response] diff --git a/netbox/extras/plugins/urls.py b/netbox/extras/plugins/urls.py new file mode 100644 index 000000000..1dc36befb --- /dev/null +++ b/netbox/extras/plugins/urls.py @@ -0,0 +1,53 @@ +import importlib + +from django.apps import apps +from django.conf import settings +from django.conf.urls import include +from django.core.exceptions import ImproperlyConfigured +from django.urls import path +from django.utils.module_loading import import_string + +from . import views + +# Plugins +plugin_patterns = [] +plugin_api_patterns = [] + +for plugin in settings.PLUGINS: + app = apps.get_app_config(plugin) + + url_slug = getattr(app, 'url_slug') or app.label + + # Check if the plugin specifies any URLs + try: + urlpatterns = import_string(f"{plugin}.urls.urlpatterns") + except ImportError: + # No urls defined + urlpatterns = None + if urlpatterns: + plugin_patterns.append( + path(f"{url_slug}/", include((urlpatterns, app.label))) + ) + + # Check if the plugin specifies any API URLs + try: + urlpatterns = import_string(f"{plugin}.api.urls.urlpatterns") + app_name = import_string(f"{plugin}.api.urls.app_name") + except ImportError: + # No urls defined + urlpatterns = None + if urlpatterns: + plugin_api_patterns.append( + path(f"{url_slug}/", include((urlpatterns, app_name))) + ) + +# Plugin list admin view +admin_plugin_patterns = [ + path('', views.installed_plugins_admin_view, name='plugins_list') +] + +# Plugin list API view +plugin_api_patterns += [ + path('', views.PluginsAPIRootView.as_view(), name='api-root'), + path('installed-plugins/', views.InstalledPluginsAPIView.as_view(), name='plugins-list') +] diff --git a/netbox/extras/plugins/views.py b/netbox/extras/plugins/views.py new file mode 100644 index 000000000..690a708b6 --- /dev/null +++ b/netbox/extras/plugins/views.py @@ -0,0 +1,95 @@ +from collections import OrderedDict + +from django.apps import apps +from django.conf import settings +from django.contrib import admin +from django.contrib.admin.views.decorators import staff_member_required +from django.urls.exceptions import NoReverseMatch +from django.utils.module_loading import import_string +from django.shortcuts import render +from django.views.generic import View +from rest_framework import authentication, permissions, routers +from rest_framework.response import Response +from rest_framework.reverse import reverse +from rest_framework.views import APIView + + +@staff_member_required +def installed_plugins_admin_view(request): + """ + Admin view for listing all installed plugins + """ + context_data = { + 'plugins': [apps.get_app_config(plugin) for plugin in settings.PLUGINS] + } + return render(request, 'extras/admin/plugins_list.html', context_data) + + +class InstalledPluginsAPIView(APIView): + """ + API view for listing all installed plugins + """ + permission_classes = [permissions.IsAdminUser] + _ignore_model_permissions = True + exclude_from_schema = True + swagger_schema = None + + def get_view_name(self): + return "Installed Plugins" + + @staticmethod + def _get_plugin_data(plugin_app_config): + return { + 'name': plugin_app_config.verbose_name, + 'package': plugin_app_config.name, + 'author': plugin_app_config.author, + 'author_email': plugin_app_config.author_email, + 'description': plugin_app_config.description, + 'verison': plugin_app_config.version + } + + def get(self, request, format=None): + return Response([self._get_plugin_data(apps.get_app_config(plugin)) for plugin in settings.PLUGINS]) + + +class PluginsAPIRootView(APIView): + _ignore_model_permissions = True + exclude_from_schema = True + swagger_schema = None + + def get_view_name(self): + return "Plugins" + + @staticmethod + def _get_plugin_entry(plugin, app_config, request, format): + try: + api_app_name = import_string(f"{plugin}.api.urls.app_name") + except (ImportError, ModuleNotFoundError): + # Plugin does not expose an API + return None + + try: + entry = (getattr(app_config, 'url_slug', app_config.label), reverse( + f"plugins-api:{api_app_name}:api-root", + request=request, + format=format + )) + except NoReverseMatch: + # The plugin does not include an api-root + entry = None + + return entry + + def get(self, request, format=None): + + entries = [] + for plugin in settings.PLUGINS: + app_config = apps.get_app_config(plugin) + entry = self._get_plugin_entry(plugin, app_config, request, format) + if entry is not None: + entries.append(entry) + + return Response(OrderedDict(( + ('installed-plugins', reverse('plugins-api:plugins-list', request=request, format=format)), + *entries + ))) diff --git a/netbox/netbox/admin.py b/netbox/netbox/admin.py index 222c6ffc5..63fcac550 100644 --- a/netbox/netbox/admin.py +++ b/netbox/netbox/admin.py @@ -11,7 +11,7 @@ class NetBoxAdminSite(AdminSite): site_header = 'NetBox Administration' site_title = 'NetBox' site_url = '/{}'.format(settings.BASE_PATH) - index_template = 'django_rq/index.html' + index_template = 'admin/index.html' admin_site = NetBoxAdminSite(name='admin') diff --git a/netbox/netbox/settings.py b/netbox/netbox/settings.py index 6fed9dafe..c86a088cc 100644 --- a/netbox/netbox/settings.py +++ b/netbox/netbox/settings.py @@ -638,13 +638,13 @@ if PAGINATE_COUNT not in PER_PAGE_DEFAULTS: # Plugins # -PLUGINS = [] +PLUGINS = set() if PLUGINS_ENABLED: for entry_point in iter_entry_points(group='netbox_plugins', name=None): plugin = entry_point.module_name app_config = entry_point.load() - PLUGINS.append(plugin) + PLUGINS.add(plugin) INSTALLED_APPS.append(plugin) # Check version constraints @@ -688,4 +688,7 @@ if PLUGINS_ENABLED: raise ImproperlyConfigured(f"Plugin {plugin} caching_config is invalid!") if app != plugin: raise ImproperlyConfigured(f"Plugin {plugin} may not modify caching config for another app!") + else: + # Apply the default config like all other core apps + plugin_cacheops = {f"{plugin}.*": {'ops': 'all'}} CACHEOPS.update(plugin_cacheops) diff --git a/netbox/netbox/urls.py b/netbox/netbox/urls.py index 657e3d6b4..4a535ad31 100644 --- a/netbox/netbox/urls.py +++ b/netbox/netbox/urls.py @@ -8,7 +8,7 @@ from django.views.static import serve from drf_yasg import openapi from drf_yasg.views import get_schema_view -from extras.plugins import PluginConfig +from extras.plugins.urls import admin_plugin_patterns, plugin_patterns, plugin_api_patterns from netbox.views import APIRootView, HomeView, StaticMediaFailureView, SearchView from users.views import LoginView, LogoutView from .admin import admin_site @@ -70,42 +70,12 @@ _patterns = [ # Errors path('media-failure/', StaticMediaFailureView.as_view(), name='media_failure'), + # Plugins + path('plugins/', include((plugin_patterns, 'plugins'))), + path('api/plugins/', include((plugin_api_patterns, 'plugins-api'))), + path('admin/plugins/installed-plugins/', include(admin_plugin_patterns)) ] -# Plugins -plugin_patterns = [] -plugin_api_patterns = [] -for app in apps.get_app_configs(): - # Loop over all apps look for installed plugins - if isinstance(app, PluginConfig): - # Check if the plugin specifies any URLs - if importlib.util.find_spec('{}.urls'.format(app.name)): - urls = importlib.import_module('{}.urls'.format(app.name)) - url_slug = getattr(app, 'url_slug') or app.label - if hasattr(urls, 'urlpatterns'): - # Mount URLs at `/` - plugin_patterns.append( - path('{}/'.format(url_slug), include((urls.urlpatterns, app.label))) - ) - # Check if the plugin specifies any API URLs - if importlib.util.find_spec('{}.api'.format(app.name)): - if importlib.util.find_spec('{}.api.urls'.format(app.name)): - urls = importlib.import_module('{}.api.urls'.format(app.name)) - if hasattr(urls, 'urlpatterns'): - url_slug = getattr(app, 'url_slug') or app.label - # Mount URLs at `/` - plugin_api_patterns.append( - path('{}/'.format(url_slug), include((urls.urlpatterns, app.label))) - ) - -# Mount all plugin URLs within the `plugins` namespace -_patterns.append( - path('plugins/', include((plugin_patterns, 'plugins'))) -) -# Mount all plugin API URLs within the `plugins-api` namespace -_patterns.append( - path('api/plugins/', include((plugin_api_patterns, 'plugins-api'))) -) if settings.DEBUG: import debug_toolbar diff --git a/netbox/netbox/views.py b/netbox/netbox/views.py index bc87a825b..25c32338b 100644 --- a/netbox/netbox/views.py +++ b/netbox/netbox/views.py @@ -341,6 +341,7 @@ class APIRootView(APIView): ('dcim', reverse('dcim-api:api-root', request=request, format=format)), ('extras', reverse('extras-api:api-root', request=request, format=format)), ('ipam', reverse('ipam-api:api-root', request=request, format=format)), + ('plugins', reverse('plugins-api:api-root', request=request, format=format)), ('secrets', reverse('secrets-api:api-root', request=request, format=format)), ('tenancy', reverse('tenancy-api:api-root', request=request, format=format)), ('virtualization', reverse('virtualization-api:api-root', request=request, format=format)), diff --git a/netbox/templates/admin/index.html b/netbox/templates/admin/index.html new file mode 100644 index 000000000..00f720738 --- /dev/null +++ b/netbox/templates/admin/index.html @@ -0,0 +1,6 @@ +{% extends "django_rq/index.html" %} + +{% block sidebar %} + {{ block.super }} + {% include 'extras/admin/plugins_index.html' %} +{% endblock %} diff --git a/netbox/templates/extras/admin/plugins_index.html b/netbox/templates/extras/admin/plugins_index.html new file mode 100644 index 000000000..d34d55c23 --- /dev/null +++ b/netbox/templates/extras/admin/plugins_index.html @@ -0,0 +1,14 @@ +
+
+ + + + + + + +
Plugins
+ Installed plugins +
+
+
diff --git a/netbox/templates/extras/admin/plugins_list.html b/netbox/templates/extras/admin/plugins_list.html new file mode 100644 index 000000000..ecc003ccc --- /dev/null +++ b/netbox/templates/extras/admin/plugins_list.html @@ -0,0 +1,60 @@ +{% extends "admin/base_site.html" %} + +{% block title %}Installed Plugins {{ block.super }}{% endblock %} + + +{% block breadcrumbs %} + +{% endblock %} + +{% block content_title %}

Installed Plugins{{ queue.name }}

{% endblock %} + +{% block content %} + +
+
+
+ + + + + + + + + + + + + {% for plugin in plugins %} + + + + + + + + + {% endfor %} + +
Name
Package Name
Author
Author Email
Description
Version
+ {{ plugin.verbose_name }} + + {{ plugin.name }} + + {{ plugin.author }} + + {{ plugin.author_email }} + + {{ plugin.description }} + + {{ plugin.version }} +
+
+
+
+ +{% endblock %} \ No newline at end of file From 60b6c487757963514eafb0c8f155f6c9957e4edc Mon Sep 17 00:00:00 2001 From: John Anderson Date: Fri, 20 Mar 2020 22:21:00 -0400 Subject: [PATCH 5/5] remove duplicate import --- netbox/templates/dcim/site.html | 1 - 1 file changed, 1 deletion(-) diff --git a/netbox/templates/dcim/site.html b/netbox/templates/dcim/site.html index 16cee782c..915588e4d 100644 --- a/netbox/templates/dcim/site.html +++ b/netbox/templates/dcim/site.html @@ -5,7 +5,6 @@ {% load plugins %} {% load static %} {% load tz %} -{% load plugins %} {% block header %}