mirror of
https://github.com/netbox-community/netbox.git
synced 2025-07-16 12:12:53 -06:00
* Move extras.plugins to netbox.plugins & add deprecation warnings * Move plugin template tags from extras to utilities * Move plugins tests from extras to netbox * Add TODO reminders for v4.0
This commit is contained in:
parent
7efbfabc0b
commit
3f40ee5501
@ -1,148 +1,9 @@
|
|||||||
import collections
|
|
||||||
from importlib import import_module
|
|
||||||
|
|
||||||
from django.apps import AppConfig
|
|
||||||
from django.core.exceptions import ImproperlyConfigured
|
|
||||||
from django.utils.module_loading import import_string
|
|
||||||
from packaging import version
|
|
||||||
|
|
||||||
from netbox.registry import registry
|
|
||||||
from netbox.search import register_search
|
|
||||||
from .navigation import *
|
from .navigation import *
|
||||||
from .registration import *
|
from .registration import *
|
||||||
from .templates import *
|
from .templates import *
|
||||||
from .utils import *
|
from .utils import *
|
||||||
|
from netbox.plugins import PluginConfig
|
||||||
# Initialize plugin registry
|
|
||||||
registry['plugins'].update({
|
|
||||||
'graphql_schemas': [],
|
|
||||||
'menus': [],
|
|
||||||
'menu_items': {},
|
|
||||||
'preferences': {},
|
|
||||||
'template_extensions': collections.defaultdict(list),
|
|
||||||
})
|
|
||||||
|
|
||||||
DEFAULT_RESOURCE_PATHS = {
|
|
||||||
'search_indexes': 'search.indexes',
|
|
||||||
'graphql_schema': 'graphql.schema',
|
|
||||||
'menu': 'navigation.menu',
|
|
||||||
'menu_items': 'navigation.menu_items',
|
|
||||||
'template_extensions': 'template_content.template_extensions',
|
|
||||||
'user_preferences': 'preferences.preferences',
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
#
|
# TODO: Remove in v4.0
|
||||||
# Plugin AppConfig class
|
warnings.warn(f"{__name__} is deprecated. Import from netbox.plugins instead.", DeprecationWarning)
|
||||||
#
|
|
||||||
|
|
||||||
class PluginConfig(AppConfig):
|
|
||||||
"""
|
|
||||||
Subclass of Django's built-in AppConfig class, to be used for NetBox plugins.
|
|
||||||
"""
|
|
||||||
# Plugin metadata
|
|
||||||
author = ''
|
|
||||||
author_email = ''
|
|
||||||
description = ''
|
|
||||||
version = ''
|
|
||||||
|
|
||||||
# Root URL path under /plugins. If not set, the plugin's label will be used.
|
|
||||||
base_url = 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 = []
|
|
||||||
|
|
||||||
# Django-rq queues dedicated to the plugin
|
|
||||||
queues = []
|
|
||||||
|
|
||||||
# Django apps to append to INSTALLED_APPS when plugin requires them.
|
|
||||||
django_apps = []
|
|
||||||
|
|
||||||
# Optional plugin resources
|
|
||||||
search_indexes = None
|
|
||||||
graphql_schema = None
|
|
||||||
menu = None
|
|
||||||
menu_items = None
|
|
||||||
template_extensions = None
|
|
||||||
user_preferences = None
|
|
||||||
|
|
||||||
def _load_resource(self, name):
|
|
||||||
# Import from the configured path, if defined.
|
|
||||||
if path := getattr(self, name, None):
|
|
||||||
return import_string(f"{self.__module__}.{path}")
|
|
||||||
|
|
||||||
# Fall back to the resource's default path. Return None if the module has not been provided.
|
|
||||||
default_path = f'{self.__module__}.{DEFAULT_RESOURCE_PATHS[name]}'
|
|
||||||
default_module, resource_name = default_path.rsplit('.', 1)
|
|
||||||
try:
|
|
||||||
module = import_module(default_module)
|
|
||||||
return getattr(module, resource_name, None)
|
|
||||||
except ModuleNotFoundError:
|
|
||||||
pass
|
|
||||||
|
|
||||||
def ready(self):
|
|
||||||
plugin_name = self.name.rsplit('.', 1)[-1]
|
|
||||||
|
|
||||||
# Register search extensions (if defined)
|
|
||||||
search_indexes = self._load_resource('search_indexes') or []
|
|
||||||
for idx in search_indexes:
|
|
||||||
register_search(idx)
|
|
||||||
|
|
||||||
# Register template content (if defined)
|
|
||||||
if template_extensions := self._load_resource('template_extensions'):
|
|
||||||
register_template_extensions(template_extensions)
|
|
||||||
|
|
||||||
# Register navigation menu and/or menu items (if defined)
|
|
||||||
if menu := self._load_resource('menu'):
|
|
||||||
register_menu(menu)
|
|
||||||
if menu_items := self._load_resource('menu_items'):
|
|
||||||
register_menu_items(self.verbose_name, menu_items)
|
|
||||||
|
|
||||||
# Register GraphQL schema (if defined)
|
|
||||||
if graphql_schema := self._load_resource('graphql_schema'):
|
|
||||||
register_graphql_schema(graphql_schema)
|
|
||||||
|
|
||||||
# Register user preferences (if defined)
|
|
||||||
if user_preferences := self._load_resource('user_preferences'):
|
|
||||||
register_user_preferences(plugin_name, user_preferences)
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def validate(cls, user_config, netbox_version):
|
|
||||||
|
|
||||||
# Enforce version constraints
|
|
||||||
current_version = version.parse(netbox_version)
|
|
||||||
if cls.min_version is not None:
|
|
||||||
min_version = version.parse(cls.min_version)
|
|
||||||
if current_version < min_version:
|
|
||||||
raise ImproperlyConfigured(
|
|
||||||
f"Plugin {cls.__module__} requires NetBox minimum version {cls.min_version}."
|
|
||||||
)
|
|
||||||
if cls.max_version is not None:
|
|
||||||
max_version = version.parse(cls.max_version)
|
|
||||||
if current_version > max_version:
|
|
||||||
raise ImproperlyConfigured(
|
|
||||||
f"Plugin {cls.__module__} requires NetBox maximum version {cls.max_version}."
|
|
||||||
)
|
|
||||||
|
|
||||||
# Verify required configuration settings
|
|
||||||
for setting in cls.required_settings:
|
|
||||||
if setting not in user_config:
|
|
||||||
raise ImproperlyConfigured(
|
|
||||||
f"Plugin {cls.__module__} requires '{setting}' to be present in the PLUGINS_CONFIG section of "
|
|
||||||
f"configuration.py."
|
|
||||||
)
|
|
||||||
|
|
||||||
# Apply default configuration values
|
|
||||||
for setting, value in cls.default_settings.items():
|
|
||||||
if setting not in user_config:
|
|
||||||
user_config[setting] = value
|
|
||||||
|
@ -1,72 +1,7 @@
|
|||||||
from netbox.navigation import MenuGroup
|
import warnings
|
||||||
from utilities.choices import ButtonColorChoices
|
|
||||||
from django.utils.text import slugify
|
|
||||||
|
|
||||||
__all__ = (
|
from netbox.plugins.navigation import *
|
||||||
'PluginMenu',
|
|
||||||
'PluginMenuButton',
|
|
||||||
'PluginMenuItem',
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class PluginMenu:
|
# TODO: Remove in v4.0
|
||||||
icon_class = 'mdi mdi-puzzle'
|
warnings.warn(f"{__name__} is deprecated. Import from netbox.plugins instead.", DeprecationWarning)
|
||||||
|
|
||||||
def __init__(self, label, groups, icon_class=None):
|
|
||||||
self.label = label
|
|
||||||
self.groups = [
|
|
||||||
MenuGroup(label, items) for label, items in groups
|
|
||||||
]
|
|
||||||
if icon_class is not None:
|
|
||||||
self.icon_class = icon_class
|
|
||||||
|
|
||||||
@property
|
|
||||||
def name(self):
|
|
||||||
return slugify(self.label)
|
|
||||||
|
|
||||||
|
|
||||||
class PluginMenuItem:
|
|
||||||
"""
|
|
||||||
This class represents a navigation menu item. This constitutes primary link and its text, but also allows for
|
|
||||||
specifying additional link buttons that appear to the right of the item in the van menu.
|
|
||||||
|
|
||||||
Links are specified as Django reverse URL strings.
|
|
||||||
Buttons are each specified as a list of PluginMenuButton instances.
|
|
||||||
"""
|
|
||||||
permissions = []
|
|
||||||
buttons = []
|
|
||||||
|
|
||||||
def __init__(self, link, link_text, staff_only=False, permissions=None, buttons=None):
|
|
||||||
self.link = link
|
|
||||||
self.link_text = link_text
|
|
||||||
self.staff_only = staff_only
|
|
||||||
if permissions is not None:
|
|
||||||
if type(permissions) not in (list, tuple):
|
|
||||||
raise TypeError("Permissions must be passed as a tuple or list.")
|
|
||||||
self.permissions = permissions
|
|
||||||
if buttons is not None:
|
|
||||||
if type(buttons) not in (list, tuple):
|
|
||||||
raise TypeError("Buttons must be passed as a tuple or list.")
|
|
||||||
self.buttons = buttons
|
|
||||||
|
|
||||||
|
|
||||||
class PluginMenuButton:
|
|
||||||
"""
|
|
||||||
This class represents a button within a PluginMenuItem. Note that button colors should come from
|
|
||||||
ButtonColorChoices.
|
|
||||||
"""
|
|
||||||
color = ButtonColorChoices.DEFAULT
|
|
||||||
permissions = []
|
|
||||||
|
|
||||||
def __init__(self, link, title, icon_class, color=None, permissions=None):
|
|
||||||
self.link = link
|
|
||||||
self.title = title
|
|
||||||
self.icon_class = icon_class
|
|
||||||
if permissions is not None:
|
|
||||||
if type(permissions) not in (list, tuple):
|
|
||||||
raise TypeError("Permissions must be passed as a tuple or list.")
|
|
||||||
self.permissions = permissions
|
|
||||||
if color is not None:
|
|
||||||
if color not in ButtonColorChoices.values():
|
|
||||||
raise ValueError("Button color must be a choice within ButtonColorChoices.")
|
|
||||||
self.color = color
|
|
||||||
|
@ -1,64 +1,7 @@
|
|||||||
import inspect
|
import warnings
|
||||||
|
|
||||||
from netbox.registry import registry
|
from netbox.plugins.registration import *
|
||||||
from .navigation import PluginMenu, PluginMenuButton, PluginMenuItem
|
|
||||||
from .templates import PluginTemplateExtension
|
|
||||||
|
|
||||||
__all__ = (
|
|
||||||
'register_graphql_schema',
|
|
||||||
'register_menu',
|
|
||||||
'register_menu_items',
|
|
||||||
'register_template_extensions',
|
|
||||||
'register_user_preferences',
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def register_template_extensions(class_list):
|
# TODO: Remove in v4.0
|
||||||
"""
|
warnings.warn(f"{__name__} is deprecated. Import from netbox.plugins instead.", DeprecationWarning)
|
||||||
Register a list of PluginTemplateExtension classes
|
|
||||||
"""
|
|
||||||
# Validation
|
|
||||||
for template_extension in class_list:
|
|
||||||
if not inspect.isclass(template_extension):
|
|
||||||
raise TypeError(f"PluginTemplateExtension class {template_extension} was passed as an instance!")
|
|
||||||
if not issubclass(template_extension, PluginTemplateExtension):
|
|
||||||
raise TypeError(f"{template_extension} is not a subclass of extras.plugins.PluginTemplateExtension!")
|
|
||||||
if template_extension.model is None:
|
|
||||||
raise TypeError(f"PluginTemplateExtension class {template_extension} does not define a valid model!")
|
|
||||||
|
|
||||||
registry['plugins']['template_extensions'][template_extension.model].append(template_extension)
|
|
||||||
|
|
||||||
|
|
||||||
def register_menu(menu):
|
|
||||||
if not isinstance(menu, PluginMenu):
|
|
||||||
raise TypeError(f"{menu} must be an instance of extras.plugins.PluginMenu")
|
|
||||||
registry['plugins']['menus'].append(menu)
|
|
||||||
|
|
||||||
|
|
||||||
def register_menu_items(section_name, class_list):
|
|
||||||
"""
|
|
||||||
Register a list of PluginMenuItem instances for a given menu section (e.g. plugin name)
|
|
||||||
"""
|
|
||||||
# Validation
|
|
||||||
for menu_link in class_list:
|
|
||||||
if not isinstance(menu_link, PluginMenuItem):
|
|
||||||
raise TypeError(f"{menu_link} must be an instance of extras.plugins.PluginMenuItem")
|
|
||||||
for button in menu_link.buttons:
|
|
||||||
if not isinstance(button, PluginMenuButton):
|
|
||||||
raise TypeError(f"{button} must be an instance of extras.plugins.PluginMenuButton")
|
|
||||||
|
|
||||||
registry['plugins']['menu_items'][section_name] = class_list
|
|
||||||
|
|
||||||
|
|
||||||
def register_graphql_schema(graphql_schema):
|
|
||||||
"""
|
|
||||||
Register a GraphQL schema class for inclusion in NetBox's GraphQL API.
|
|
||||||
"""
|
|
||||||
registry['plugins']['graphql_schemas'].append(graphql_schema)
|
|
||||||
|
|
||||||
|
|
||||||
def register_user_preferences(plugin_name, preferences):
|
|
||||||
"""
|
|
||||||
Register a list of user preferences defined by a plugin.
|
|
||||||
"""
|
|
||||||
registry['plugins']['preferences'][plugin_name] = preferences
|
|
||||||
|
@ -1,73 +1,7 @@
|
|||||||
from django.template.loader import get_template
|
import warnings
|
||||||
|
|
||||||
__all__ = (
|
from netbox.plugins.templates import *
|
||||||
'PluginTemplateExtension',
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class PluginTemplateExtension:
|
# TODO: Remove in v4.0
|
||||||
"""
|
warnings.warn(f"{__name__} is deprecated. Import from netbox.plugins instead.", DeprecationWarning)
|
||||||
This class is used to register plugin content to be injected into core NetBox templates. It contains methods
|
|
||||||
that are overridden by plugin authors to return template content.
|
|
||||||
|
|
||||||
The `model` attribute on the class defines the which model detail page this class renders content for. It
|
|
||||||
should be set as a string in the form '<app_label>.<model_name>'. render() provides the following context data:
|
|
||||||
|
|
||||||
* object - The object being viewed
|
|
||||||
* request - The current request
|
|
||||||
* settings - Global NetBox settings
|
|
||||||
* config - Plugin-specific configuration parameters
|
|
||||||
"""
|
|
||||||
model = None
|
|
||||||
|
|
||||||
def __init__(self, context):
|
|
||||||
self.context = context
|
|
||||||
|
|
||||||
def render(self, template_name, extra_context=None):
|
|
||||||
"""
|
|
||||||
Convenience method for rendering the specified Django template using the default context data. An additional
|
|
||||||
context dictionary may be passed as `extra_context`.
|
|
||||||
"""
|
|
||||||
if extra_context is None:
|
|
||||||
extra_context = {}
|
|
||||||
elif not isinstance(extra_context, dict):
|
|
||||||
raise TypeError("extra_context must be a dictionary")
|
|
||||||
|
|
||||||
return get_template(template_name).render({**self.context, **extra_context})
|
|
||||||
|
|
||||||
def left_page(self):
|
|
||||||
"""
|
|
||||||
Content that will be rendered on the left of the detail page view. Content should be returned as an
|
|
||||||
HTML string. Note that content does not need to be marked as safe because this is automatically handled.
|
|
||||||
"""
|
|
||||||
raise NotImplementedError
|
|
||||||
|
|
||||||
def right_page(self):
|
|
||||||
"""
|
|
||||||
Content that will be rendered on the right of the detail page view. Content should be returned as an
|
|
||||||
HTML string. Note that content does not need to be marked as safe because this is automatically handled.
|
|
||||||
"""
|
|
||||||
raise NotImplementedError
|
|
||||||
|
|
||||||
def full_width_page(self):
|
|
||||||
"""
|
|
||||||
Content that will be rendered within the full width of the detail page view. Content should be returned as an
|
|
||||||
HTML string. Note that content does not need to be marked as safe because this is automatically handled.
|
|
||||||
"""
|
|
||||||
raise NotImplementedError
|
|
||||||
|
|
||||||
def buttons(self):
|
|
||||||
"""
|
|
||||||
Buttons that will be rendered and added to the existing list of buttons on the detail page view. Content
|
|
||||||
should be returned as an HTML string. Note that content does not need to be marked as safe because this is
|
|
||||||
automatically handled.
|
|
||||||
"""
|
|
||||||
raise NotImplementedError
|
|
||||||
|
|
||||||
def list_buttons(self):
|
|
||||||
"""
|
|
||||||
Buttons that will be rendered and added to the existing list of buttons on the list view. Content
|
|
||||||
should be returned as an HTML string. Note that content does not need to be marked as safe because this is
|
|
||||||
automatically handled.
|
|
||||||
"""
|
|
||||||
raise NotImplementedError
|
|
||||||
|
@ -1,41 +1,7 @@
|
|||||||
from importlib import import_module
|
import warnings
|
||||||
|
|
||||||
from django.apps import apps
|
from netbox.plugins.urls import *
|
||||||
from django.conf import settings
|
|
||||||
from django.conf.urls import include
|
|
||||||
from django.contrib.admin.views.decorators import staff_member_required
|
|
||||||
from django.urls import path
|
|
||||||
from django.utils.module_loading import import_string, module_has_submodule
|
|
||||||
|
|
||||||
from . import views
|
|
||||||
|
|
||||||
# Initialize URL base, API, and admin URL patterns for plugins
|
# TODO: Remove in v4.0
|
||||||
plugin_patterns = []
|
warnings.warn(f"{__name__} is deprecated. Import from netbox.plugins instead.", DeprecationWarning)
|
||||||
plugin_api_patterns = [
|
|
||||||
path('', views.PluginsAPIRootView.as_view(), name='api-root'),
|
|
||||||
path('installed-plugins/', views.InstalledPluginsAPIView.as_view(), name='plugins-list')
|
|
||||||
]
|
|
||||||
plugin_admin_patterns = [
|
|
||||||
path('installed-plugins/', staff_member_required(views.InstalledPluginsAdminView.as_view()), name='plugins_list')
|
|
||||||
]
|
|
||||||
|
|
||||||
# Register base/API URL patterns for each plugin
|
|
||||||
for plugin_path in settings.PLUGINS:
|
|
||||||
plugin = import_module(plugin_path)
|
|
||||||
plugin_name = plugin_path.split('.')[-1]
|
|
||||||
app = apps.get_app_config(plugin_name)
|
|
||||||
base_url = getattr(app, 'base_url') or app.label
|
|
||||||
|
|
||||||
# Check if the plugin specifies any base URLs
|
|
||||||
if module_has_submodule(plugin, 'urls'):
|
|
||||||
urlpatterns = import_string(f"{plugin_path}.urls.urlpatterns")
|
|
||||||
plugin_patterns.append(
|
|
||||||
path(f"{base_url}/", include((urlpatterns, app.label)))
|
|
||||||
)
|
|
||||||
|
|
||||||
# Check if the plugin specifies any API URLs
|
|
||||||
if module_has_submodule(plugin, 'api.urls'):
|
|
||||||
urlpatterns = import_string(f"{plugin_path}.api.urls.urlpatterns")
|
|
||||||
plugin_api_patterns.append(
|
|
||||||
path(f"{base_url}/", include((urlpatterns, f"{app.label}-api")))
|
|
||||||
)
|
|
||||||
|
@ -1,37 +1,7 @@
|
|||||||
from django.apps import apps
|
import warnings
|
||||||
from django.conf import settings
|
|
||||||
from django.core.exceptions import ImproperlyConfigured
|
|
||||||
|
|
||||||
__all__ = (
|
from netbox.plugins.utils import *
|
||||||
'get_installed_plugins',
|
|
||||||
'get_plugin_config',
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def get_installed_plugins():
|
# TODO: Remove in v4.0
|
||||||
"""
|
warnings.warn(f"{__name__} is deprecated. Import from netbox.plugins instead.", DeprecationWarning)
|
||||||
Return a dictionary mapping the names of installed plugins to their versions.
|
|
||||||
"""
|
|
||||||
plugins = {}
|
|
||||||
for plugin_name in settings.PLUGINS:
|
|
||||||
plugin_name = plugin_name.rsplit('.', 1)[-1]
|
|
||||||
plugin_config = apps.get_app_config(plugin_name)
|
|
||||||
plugins[plugin_name] = getattr(plugin_config, 'version', None)
|
|
||||||
|
|
||||||
return dict(sorted(plugins.items()))
|
|
||||||
|
|
||||||
|
|
||||||
def get_plugin_config(plugin_name, parameter, default=None):
|
|
||||||
"""
|
|
||||||
Return the value of the specified plugin configuration parameter.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
plugin_name: The name of the plugin
|
|
||||||
parameter: The name of the configuration parameter
|
|
||||||
default: The value to return if the parameter is not defined (default: None)
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
plugin_config = settings.PLUGINS_CONFIG[plugin_name]
|
|
||||||
return plugin_config.get(parameter, default)
|
|
||||||
except KeyError:
|
|
||||||
raise ImproperlyConfigured(f"Plugin {plugin_name} is not registered.")
|
|
||||||
|
@ -1,89 +1,7 @@
|
|||||||
from collections import OrderedDict
|
import warnings
|
||||||
|
|
||||||
from django.apps import apps
|
from netbox.plugins.views import *
|
||||||
from django.conf import settings
|
|
||||||
from django.shortcuts import render
|
|
||||||
from django.urls.exceptions import NoReverseMatch
|
|
||||||
from django.views.generic import View
|
|
||||||
from drf_spectacular.utils import extend_schema
|
|
||||||
from rest_framework import permissions
|
|
||||||
from rest_framework.response import Response
|
|
||||||
from rest_framework.reverse import reverse
|
|
||||||
from rest_framework.views import APIView
|
|
||||||
|
|
||||||
|
|
||||||
class InstalledPluginsAdminView(View):
|
# TODO: Remove in v4.0
|
||||||
"""
|
warnings.warn(f"{__name__} is deprecated. Import from netbox.plugins instead.", DeprecationWarning)
|
||||||
Admin view for listing all installed plugins
|
|
||||||
"""
|
|
||||||
def get(self, request):
|
|
||||||
plugins = [apps.get_app_config(plugin) for plugin in settings.PLUGINS]
|
|
||||||
return render(request, 'extras/admin/plugins_list.html', {
|
|
||||||
'plugins': plugins,
|
|
||||||
})
|
|
||||||
|
|
||||||
|
|
||||||
@extend_schema(exclude=True)
|
|
||||||
class InstalledPluginsAPIView(APIView):
|
|
||||||
"""
|
|
||||||
API view for listing all installed plugins
|
|
||||||
"""
|
|
||||||
permission_classes = [permissions.IsAdminUser]
|
|
||||||
_ignore_model_permissions = True
|
|
||||||
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,
|
|
||||||
'version': 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])
|
|
||||||
|
|
||||||
|
|
||||||
@extend_schema(exclude=True)
|
|
||||||
class PluginsAPIRootView(APIView):
|
|
||||||
_ignore_model_permissions = True
|
|
||||||
schema = None
|
|
||||||
|
|
||||||
def get_view_name(self):
|
|
||||||
return "Plugins"
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _get_plugin_entry(plugin, app_config, request, format):
|
|
||||||
# Check if the plugin specifies any API URLs
|
|
||||||
api_app_name = f'{app_config.name}-api'
|
|
||||||
try:
|
|
||||||
entry = (getattr(app_config, 'base_url', 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 url
|
|
||||||
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
|
|
||||||
)))
|
|
||||||
|
@ -11,7 +11,7 @@ from rest_framework.reverse import reverse
|
|||||||
from rest_framework.views import APIView
|
from rest_framework.views import APIView
|
||||||
from rq.worker import Worker
|
from rq.worker import Worker
|
||||||
|
|
||||||
from extras.plugins.utils import get_installed_plugins
|
from netbox.plugins.utils import get_installed_plugins
|
||||||
from netbox.api.authentication import IsAuthenticatedOrLoginNotRequired
|
from netbox.api.authentication import IsAuthenticatedOrLoginNotRequired
|
||||||
|
|
||||||
|
|
||||||
|
@ -15,7 +15,7 @@ DATABASE = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
PLUGINS = [
|
PLUGINS = [
|
||||||
'extras.tests.dummy_plugin',
|
'netbox.tests.dummy_plugin',
|
||||||
]
|
]
|
||||||
|
|
||||||
REDIS = {
|
REDIS = {
|
||||||
|
148
netbox/netbox/plugins/__init__.py
Normal file
148
netbox/netbox/plugins/__init__.py
Normal file
@ -0,0 +1,148 @@
|
|||||||
|
import collections
|
||||||
|
from importlib import import_module
|
||||||
|
|
||||||
|
from django.apps import AppConfig
|
||||||
|
from django.core.exceptions import ImproperlyConfigured
|
||||||
|
from django.utils.module_loading import import_string
|
||||||
|
from packaging import version
|
||||||
|
|
||||||
|
from netbox.registry import registry
|
||||||
|
from netbox.search import register_search
|
||||||
|
from .navigation import *
|
||||||
|
from .registration import *
|
||||||
|
from .templates import *
|
||||||
|
from .utils import *
|
||||||
|
|
||||||
|
# Initialize plugin registry
|
||||||
|
registry['plugins'].update({
|
||||||
|
'graphql_schemas': [],
|
||||||
|
'menus': [],
|
||||||
|
'menu_items': {},
|
||||||
|
'preferences': {},
|
||||||
|
'template_extensions': collections.defaultdict(list),
|
||||||
|
})
|
||||||
|
|
||||||
|
DEFAULT_RESOURCE_PATHS = {
|
||||||
|
'search_indexes': 'search.indexes',
|
||||||
|
'graphql_schema': 'graphql.schema',
|
||||||
|
'menu': 'navigation.menu',
|
||||||
|
'menu_items': 'navigation.menu_items',
|
||||||
|
'template_extensions': 'template_content.template_extensions',
|
||||||
|
'user_preferences': 'preferences.preferences',
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
#
|
||||||
|
# Plugin AppConfig class
|
||||||
|
#
|
||||||
|
|
||||||
|
class PluginConfig(AppConfig):
|
||||||
|
"""
|
||||||
|
Subclass of Django's built-in AppConfig class, to be used for NetBox plugins.
|
||||||
|
"""
|
||||||
|
# Plugin metadata
|
||||||
|
author = ''
|
||||||
|
author_email = ''
|
||||||
|
description = ''
|
||||||
|
version = ''
|
||||||
|
|
||||||
|
# Root URL path under /plugins. If not set, the plugin's label will be used.
|
||||||
|
base_url = 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 = []
|
||||||
|
|
||||||
|
# Django-rq queues dedicated to the plugin
|
||||||
|
queues = []
|
||||||
|
|
||||||
|
# Django apps to append to INSTALLED_APPS when plugin requires them.
|
||||||
|
django_apps = []
|
||||||
|
|
||||||
|
# Optional plugin resources
|
||||||
|
search_indexes = None
|
||||||
|
graphql_schema = None
|
||||||
|
menu = None
|
||||||
|
menu_items = None
|
||||||
|
template_extensions = None
|
||||||
|
user_preferences = None
|
||||||
|
|
||||||
|
def _load_resource(self, name):
|
||||||
|
# Import from the configured path, if defined.
|
||||||
|
if path := getattr(self, name, None):
|
||||||
|
return import_string(f"{self.__module__}.{path}")
|
||||||
|
|
||||||
|
# Fall back to the resource's default path. Return None if the module has not been provided.
|
||||||
|
default_path = f'{self.__module__}.{DEFAULT_RESOURCE_PATHS[name]}'
|
||||||
|
default_module, resource_name = default_path.rsplit('.', 1)
|
||||||
|
try:
|
||||||
|
module = import_module(default_module)
|
||||||
|
return getattr(module, resource_name, None)
|
||||||
|
except ModuleNotFoundError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
def ready(self):
|
||||||
|
plugin_name = self.name.rsplit('.', 1)[-1]
|
||||||
|
|
||||||
|
# Register search extensions (if defined)
|
||||||
|
search_indexes = self._load_resource('search_indexes') or []
|
||||||
|
for idx in search_indexes:
|
||||||
|
register_search(idx)
|
||||||
|
|
||||||
|
# Register template content (if defined)
|
||||||
|
if template_extensions := self._load_resource('template_extensions'):
|
||||||
|
register_template_extensions(template_extensions)
|
||||||
|
|
||||||
|
# Register navigation menu and/or menu items (if defined)
|
||||||
|
if menu := self._load_resource('menu'):
|
||||||
|
register_menu(menu)
|
||||||
|
if menu_items := self._load_resource('menu_items'):
|
||||||
|
register_menu_items(self.verbose_name, menu_items)
|
||||||
|
|
||||||
|
# Register GraphQL schema (if defined)
|
||||||
|
if graphql_schema := self._load_resource('graphql_schema'):
|
||||||
|
register_graphql_schema(graphql_schema)
|
||||||
|
|
||||||
|
# Register user preferences (if defined)
|
||||||
|
if user_preferences := self._load_resource('user_preferences'):
|
||||||
|
register_user_preferences(plugin_name, user_preferences)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def validate(cls, user_config, netbox_version):
|
||||||
|
|
||||||
|
# Enforce version constraints
|
||||||
|
current_version = version.parse(netbox_version)
|
||||||
|
if cls.min_version is not None:
|
||||||
|
min_version = version.parse(cls.min_version)
|
||||||
|
if current_version < min_version:
|
||||||
|
raise ImproperlyConfigured(
|
||||||
|
f"Plugin {cls.__module__} requires NetBox minimum version {cls.min_version}."
|
||||||
|
)
|
||||||
|
if cls.max_version is not None:
|
||||||
|
max_version = version.parse(cls.max_version)
|
||||||
|
if current_version > max_version:
|
||||||
|
raise ImproperlyConfigured(
|
||||||
|
f"Plugin {cls.__module__} requires NetBox maximum version {cls.max_version}."
|
||||||
|
)
|
||||||
|
|
||||||
|
# Verify required configuration settings
|
||||||
|
for setting in cls.required_settings:
|
||||||
|
if setting not in user_config:
|
||||||
|
raise ImproperlyConfigured(
|
||||||
|
f"Plugin {cls.__module__} requires '{setting}' to be present in the PLUGINS_CONFIG section of "
|
||||||
|
f"configuration.py."
|
||||||
|
)
|
||||||
|
|
||||||
|
# Apply default configuration values
|
||||||
|
for setting, value in cls.default_settings.items():
|
||||||
|
if setting not in user_config:
|
||||||
|
user_config[setting] = value
|
72
netbox/netbox/plugins/navigation.py
Normal file
72
netbox/netbox/plugins/navigation.py
Normal file
@ -0,0 +1,72 @@
|
|||||||
|
from netbox.navigation import MenuGroup
|
||||||
|
from utilities.choices import ButtonColorChoices
|
||||||
|
from django.utils.text import slugify
|
||||||
|
|
||||||
|
__all__ = (
|
||||||
|
'PluginMenu',
|
||||||
|
'PluginMenuButton',
|
||||||
|
'PluginMenuItem',
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class PluginMenu:
|
||||||
|
icon_class = 'mdi mdi-puzzle'
|
||||||
|
|
||||||
|
def __init__(self, label, groups, icon_class=None):
|
||||||
|
self.label = label
|
||||||
|
self.groups = [
|
||||||
|
MenuGroup(label, items) for label, items in groups
|
||||||
|
]
|
||||||
|
if icon_class is not None:
|
||||||
|
self.icon_class = icon_class
|
||||||
|
|
||||||
|
@property
|
||||||
|
def name(self):
|
||||||
|
return slugify(self.label)
|
||||||
|
|
||||||
|
|
||||||
|
class PluginMenuItem:
|
||||||
|
"""
|
||||||
|
This class represents a navigation menu item. This constitutes primary link and its text, but also allows for
|
||||||
|
specifying additional link buttons that appear to the right of the item in the van menu.
|
||||||
|
|
||||||
|
Links are specified as Django reverse URL strings.
|
||||||
|
Buttons are each specified as a list of PluginMenuButton instances.
|
||||||
|
"""
|
||||||
|
permissions = []
|
||||||
|
buttons = []
|
||||||
|
|
||||||
|
def __init__(self, link, link_text, staff_only=False, permissions=None, buttons=None):
|
||||||
|
self.link = link
|
||||||
|
self.link_text = link_text
|
||||||
|
self.staff_only = staff_only
|
||||||
|
if permissions is not None:
|
||||||
|
if type(permissions) not in (list, tuple):
|
||||||
|
raise TypeError("Permissions must be passed as a tuple or list.")
|
||||||
|
self.permissions = permissions
|
||||||
|
if buttons is not None:
|
||||||
|
if type(buttons) not in (list, tuple):
|
||||||
|
raise TypeError("Buttons must be passed as a tuple or list.")
|
||||||
|
self.buttons = buttons
|
||||||
|
|
||||||
|
|
||||||
|
class PluginMenuButton:
|
||||||
|
"""
|
||||||
|
This class represents a button within a PluginMenuItem. Note that button colors should come from
|
||||||
|
ButtonColorChoices.
|
||||||
|
"""
|
||||||
|
color = ButtonColorChoices.DEFAULT
|
||||||
|
permissions = []
|
||||||
|
|
||||||
|
def __init__(self, link, title, icon_class, color=None, permissions=None):
|
||||||
|
self.link = link
|
||||||
|
self.title = title
|
||||||
|
self.icon_class = icon_class
|
||||||
|
if permissions is not None:
|
||||||
|
if type(permissions) not in (list, tuple):
|
||||||
|
raise TypeError("Permissions must be passed as a tuple or list.")
|
||||||
|
self.permissions = permissions
|
||||||
|
if color is not None:
|
||||||
|
if color not in ButtonColorChoices.values():
|
||||||
|
raise ValueError("Button color must be a choice within ButtonColorChoices.")
|
||||||
|
self.color = color
|
64
netbox/netbox/plugins/registration.py
Normal file
64
netbox/netbox/plugins/registration.py
Normal file
@ -0,0 +1,64 @@
|
|||||||
|
import inspect
|
||||||
|
|
||||||
|
from netbox.registry import registry
|
||||||
|
from .navigation import PluginMenu, PluginMenuButton, PluginMenuItem
|
||||||
|
from .templates import PluginTemplateExtension
|
||||||
|
|
||||||
|
__all__ = (
|
||||||
|
'register_graphql_schema',
|
||||||
|
'register_menu',
|
||||||
|
'register_menu_items',
|
||||||
|
'register_template_extensions',
|
||||||
|
'register_user_preferences',
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def register_template_extensions(class_list):
|
||||||
|
"""
|
||||||
|
Register a list of PluginTemplateExtension classes
|
||||||
|
"""
|
||||||
|
# Validation
|
||||||
|
for template_extension in class_list:
|
||||||
|
if not inspect.isclass(template_extension):
|
||||||
|
raise TypeError(f"PluginTemplateExtension class {template_extension} was passed as an instance!")
|
||||||
|
if not issubclass(template_extension, PluginTemplateExtension):
|
||||||
|
raise TypeError(f"{template_extension} is not a subclass of netbox.plugins.PluginTemplateExtension!")
|
||||||
|
if template_extension.model is None:
|
||||||
|
raise TypeError(f"PluginTemplateExtension class {template_extension} does not define a valid model!")
|
||||||
|
|
||||||
|
registry['plugins']['template_extensions'][template_extension.model].append(template_extension)
|
||||||
|
|
||||||
|
|
||||||
|
def register_menu(menu):
|
||||||
|
if not isinstance(menu, PluginMenu):
|
||||||
|
raise TypeError(f"{menu} must be an instance of netbox.plugins.PluginMenu")
|
||||||
|
registry['plugins']['menus'].append(menu)
|
||||||
|
|
||||||
|
|
||||||
|
def register_menu_items(section_name, class_list):
|
||||||
|
"""
|
||||||
|
Register a list of PluginMenuItem instances for a given menu section (e.g. plugin name)
|
||||||
|
"""
|
||||||
|
# Validation
|
||||||
|
for menu_link in class_list:
|
||||||
|
if not isinstance(menu_link, PluginMenuItem):
|
||||||
|
raise TypeError(f"{menu_link} must be an instance of netbox.plugins.PluginMenuItem")
|
||||||
|
for button in menu_link.buttons:
|
||||||
|
if not isinstance(button, PluginMenuButton):
|
||||||
|
raise TypeError(f"{button} must be an instance of netbox.plugins.PluginMenuButton")
|
||||||
|
|
||||||
|
registry['plugins']['menu_items'][section_name] = class_list
|
||||||
|
|
||||||
|
|
||||||
|
def register_graphql_schema(graphql_schema):
|
||||||
|
"""
|
||||||
|
Register a GraphQL schema class for inclusion in NetBox's GraphQL API.
|
||||||
|
"""
|
||||||
|
registry['plugins']['graphql_schemas'].append(graphql_schema)
|
||||||
|
|
||||||
|
|
||||||
|
def register_user_preferences(plugin_name, preferences):
|
||||||
|
"""
|
||||||
|
Register a list of user preferences defined by a plugin.
|
||||||
|
"""
|
||||||
|
registry['plugins']['preferences'][plugin_name] = preferences
|
73
netbox/netbox/plugins/templates.py
Normal file
73
netbox/netbox/plugins/templates.py
Normal file
@ -0,0 +1,73 @@
|
|||||||
|
from django.template.loader import get_template
|
||||||
|
|
||||||
|
__all__ = (
|
||||||
|
'PluginTemplateExtension',
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class PluginTemplateExtension:
|
||||||
|
"""
|
||||||
|
This class is used to register plugin content to be injected into core NetBox templates. It contains methods
|
||||||
|
that are overridden by plugin authors to return template content.
|
||||||
|
|
||||||
|
The `model` attribute on the class defines the which model detail page this class renders content for. It
|
||||||
|
should be set as a string in the form '<app_label>.<model_name>'. render() provides the following context data:
|
||||||
|
|
||||||
|
* object - The object being viewed
|
||||||
|
* request - The current request
|
||||||
|
* settings - Global NetBox settings
|
||||||
|
* config - Plugin-specific configuration parameters
|
||||||
|
"""
|
||||||
|
model = None
|
||||||
|
|
||||||
|
def __init__(self, context):
|
||||||
|
self.context = context
|
||||||
|
|
||||||
|
def render(self, template_name, extra_context=None):
|
||||||
|
"""
|
||||||
|
Convenience method for rendering the specified Django template using the default context data. An additional
|
||||||
|
context dictionary may be passed as `extra_context`.
|
||||||
|
"""
|
||||||
|
if extra_context is None:
|
||||||
|
extra_context = {}
|
||||||
|
elif not isinstance(extra_context, dict):
|
||||||
|
raise TypeError("extra_context must be a dictionary")
|
||||||
|
|
||||||
|
return get_template(template_name).render({**self.context, **extra_context})
|
||||||
|
|
||||||
|
def left_page(self):
|
||||||
|
"""
|
||||||
|
Content that will be rendered on the left of the detail page view. Content should be returned as an
|
||||||
|
HTML string. Note that content does not need to be marked as safe because this is automatically handled.
|
||||||
|
"""
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
|
def right_page(self):
|
||||||
|
"""
|
||||||
|
Content that will be rendered on the right of the detail page view. Content should be returned as an
|
||||||
|
HTML string. Note that content does not need to be marked as safe because this is automatically handled.
|
||||||
|
"""
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
|
def full_width_page(self):
|
||||||
|
"""
|
||||||
|
Content that will be rendered within the full width of the detail page view. Content should be returned as an
|
||||||
|
HTML string. Note that content does not need to be marked as safe because this is automatically handled.
|
||||||
|
"""
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
|
def buttons(self):
|
||||||
|
"""
|
||||||
|
Buttons that will be rendered and added to the existing list of buttons on the detail page view. Content
|
||||||
|
should be returned as an HTML string. Note that content does not need to be marked as safe because this is
|
||||||
|
automatically handled.
|
||||||
|
"""
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
|
def list_buttons(self):
|
||||||
|
"""
|
||||||
|
Buttons that will be rendered and added to the existing list of buttons on the list view. Content
|
||||||
|
should be returned as an HTML string. Note that content does not need to be marked as safe because this is
|
||||||
|
automatically handled.
|
||||||
|
"""
|
||||||
|
raise NotImplementedError
|
41
netbox/netbox/plugins/urls.py
Normal file
41
netbox/netbox/plugins/urls.py
Normal file
@ -0,0 +1,41 @@
|
|||||||
|
from importlib import import_module
|
||||||
|
|
||||||
|
from django.apps import apps
|
||||||
|
from django.conf import settings
|
||||||
|
from django.conf.urls import include
|
||||||
|
from django.contrib.admin.views.decorators import staff_member_required
|
||||||
|
from django.urls import path
|
||||||
|
from django.utils.module_loading import import_string, module_has_submodule
|
||||||
|
|
||||||
|
from . import views
|
||||||
|
|
||||||
|
# Initialize URL base, API, and admin URL patterns for plugins
|
||||||
|
plugin_patterns = []
|
||||||
|
plugin_api_patterns = [
|
||||||
|
path('', views.PluginsAPIRootView.as_view(), name='api-root'),
|
||||||
|
path('installed-plugins/', views.InstalledPluginsAPIView.as_view(), name='plugins-list')
|
||||||
|
]
|
||||||
|
plugin_admin_patterns = [
|
||||||
|
path('installed-plugins/', staff_member_required(views.InstalledPluginsAdminView.as_view()), name='plugins_list')
|
||||||
|
]
|
||||||
|
|
||||||
|
# Register base/API URL patterns for each plugin
|
||||||
|
for plugin_path in settings.PLUGINS:
|
||||||
|
plugin = import_module(plugin_path)
|
||||||
|
plugin_name = plugin_path.split('.')[-1]
|
||||||
|
app = apps.get_app_config(plugin_name)
|
||||||
|
base_url = getattr(app, 'base_url') or app.label
|
||||||
|
|
||||||
|
# Check if the plugin specifies any base URLs
|
||||||
|
if module_has_submodule(plugin, 'urls'):
|
||||||
|
urlpatterns = import_string(f"{plugin_path}.urls.urlpatterns")
|
||||||
|
plugin_patterns.append(
|
||||||
|
path(f"{base_url}/", include((urlpatterns, app.label)))
|
||||||
|
)
|
||||||
|
|
||||||
|
# Check if the plugin specifies any API URLs
|
||||||
|
if module_has_submodule(plugin, 'api.urls'):
|
||||||
|
urlpatterns = import_string(f"{plugin_path}.api.urls.urlpatterns")
|
||||||
|
plugin_api_patterns.append(
|
||||||
|
path(f"{base_url}/", include((urlpatterns, f"{app.label}-api")))
|
||||||
|
)
|
37
netbox/netbox/plugins/utils.py
Normal file
37
netbox/netbox/plugins/utils.py
Normal file
@ -0,0 +1,37 @@
|
|||||||
|
from django.apps import apps
|
||||||
|
from django.conf import settings
|
||||||
|
from django.core.exceptions import ImproperlyConfigured
|
||||||
|
|
||||||
|
__all__ = (
|
||||||
|
'get_installed_plugins',
|
||||||
|
'get_plugin_config',
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def get_installed_plugins():
|
||||||
|
"""
|
||||||
|
Return a dictionary mapping the names of installed plugins to their versions.
|
||||||
|
"""
|
||||||
|
plugins = {}
|
||||||
|
for plugin_name in settings.PLUGINS:
|
||||||
|
plugin_name = plugin_name.rsplit('.', 1)[-1]
|
||||||
|
plugin_config = apps.get_app_config(plugin_name)
|
||||||
|
plugins[plugin_name] = getattr(plugin_config, 'version', None)
|
||||||
|
|
||||||
|
return dict(sorted(plugins.items()))
|
||||||
|
|
||||||
|
|
||||||
|
def get_plugin_config(plugin_name, parameter, default=None):
|
||||||
|
"""
|
||||||
|
Return the value of the specified plugin configuration parameter.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
plugin_name: The name of the plugin
|
||||||
|
parameter: The name of the configuration parameter
|
||||||
|
default: The value to return if the parameter is not defined (default: None)
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
plugin_config = settings.PLUGINS_CONFIG[plugin_name]
|
||||||
|
return plugin_config.get(parameter, default)
|
||||||
|
except KeyError:
|
||||||
|
raise ImproperlyConfigured(f"Plugin {plugin_name} is not registered.")
|
89
netbox/netbox/plugins/views.py
Normal file
89
netbox/netbox/plugins/views.py
Normal file
@ -0,0 +1,89 @@
|
|||||||
|
from collections import OrderedDict
|
||||||
|
|
||||||
|
from django.apps import apps
|
||||||
|
from django.conf import settings
|
||||||
|
from django.shortcuts import render
|
||||||
|
from django.urls.exceptions import NoReverseMatch
|
||||||
|
from django.views.generic import View
|
||||||
|
from drf_spectacular.utils import extend_schema
|
||||||
|
from rest_framework import permissions
|
||||||
|
from rest_framework.response import Response
|
||||||
|
from rest_framework.reverse import reverse
|
||||||
|
from rest_framework.views import APIView
|
||||||
|
|
||||||
|
|
||||||
|
class InstalledPluginsAdminView(View):
|
||||||
|
"""
|
||||||
|
Admin view for listing all installed plugins
|
||||||
|
"""
|
||||||
|
def get(self, request):
|
||||||
|
plugins = [apps.get_app_config(plugin) for plugin in settings.PLUGINS]
|
||||||
|
return render(request, 'extras/admin/plugins_list.html', {
|
||||||
|
'plugins': plugins,
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@extend_schema(exclude=True)
|
||||||
|
class InstalledPluginsAPIView(APIView):
|
||||||
|
"""
|
||||||
|
API view for listing all installed plugins
|
||||||
|
"""
|
||||||
|
permission_classes = [permissions.IsAdminUser]
|
||||||
|
_ignore_model_permissions = True
|
||||||
|
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,
|
||||||
|
'version': 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])
|
||||||
|
|
||||||
|
|
||||||
|
@extend_schema(exclude=True)
|
||||||
|
class PluginsAPIRootView(APIView):
|
||||||
|
_ignore_model_permissions = True
|
||||||
|
schema = None
|
||||||
|
|
||||||
|
def get_view_name(self):
|
||||||
|
return "Plugins"
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _get_plugin_entry(plugin, app_config, request, format):
|
||||||
|
# Check if the plugin specifies any API URLs
|
||||||
|
api_app_name = f'{app_config.name}-api'
|
||||||
|
try:
|
||||||
|
entry = (getattr(app_config, 'base_url', 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 url
|
||||||
|
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
|
||||||
|
)))
|
@ -14,11 +14,11 @@ 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 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
|
||||||
from netbox.constants import RQ_QUEUE_DEFAULT, RQ_QUEUE_HIGH, RQ_QUEUE_LOW
|
from netbox.constants import RQ_QUEUE_DEFAULT, RQ_QUEUE_HIGH, RQ_QUEUE_LOW
|
||||||
|
from netbox.plugins import PluginConfig
|
||||||
|
|
||||||
|
|
||||||
#
|
#
|
||||||
|
@ -1,8 +1,8 @@
|
|||||||
from extras.plugins import PluginConfig
|
from netbox.plugins import PluginConfig
|
||||||
|
|
||||||
|
|
||||||
class DummyPluginConfig(PluginConfig):
|
class DummyPluginConfig(PluginConfig):
|
||||||
name = 'extras.tests.dummy_plugin'
|
name = 'netbox.tests.dummy_plugin'
|
||||||
verbose_name = 'Dummy plugin'
|
verbose_name = 'Dummy plugin'
|
||||||
version = '0.0'
|
version = '0.0'
|
||||||
description = 'For testing purposes only'
|
description = 'For testing purposes only'
|
||||||
@ -10,7 +10,7 @@ class DummyPluginConfig(PluginConfig):
|
|||||||
min_version = '1.0'
|
min_version = '1.0'
|
||||||
max_version = '9.0'
|
max_version = '9.0'
|
||||||
middleware = [
|
middleware = [
|
||||||
'extras.tests.dummy_plugin.middleware.DummyMiddleware'
|
'netbox.tests.dummy_plugin.middleware.DummyMiddleware'
|
||||||
]
|
]
|
||||||
queues = [
|
queues = [
|
||||||
'testing-low',
|
'testing-low',
|
@ -1,5 +1,5 @@
|
|||||||
from rest_framework.serializers import ModelSerializer
|
from rest_framework.serializers import ModelSerializer
|
||||||
from extras.tests.dummy_plugin.models import DummyModel
|
from netbox.tests.dummy_plugin.models import DummyModel
|
||||||
|
|
||||||
|
|
||||||
class DummySerializer(ModelSerializer):
|
class DummySerializer(ModelSerializer):
|
@ -1,5 +1,5 @@
|
|||||||
from rest_framework.viewsets import ModelViewSet
|
from rest_framework.viewsets import ModelViewSet
|
||||||
from extras.tests.dummy_plugin.models import DummyModel
|
from netbox.tests.dummy_plugin.models import DummyModel
|
||||||
from .serializers import DummySerializer
|
from .serializers import DummySerializer
|
||||||
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
|||||||
from django.utils.translation import gettext as _
|
from django.utils.translation import gettext as _
|
||||||
from extras.plugins import PluginMenu, PluginMenuButton, PluginMenuItem
|
from netbox.plugins.navigation import PluginMenu, PluginMenuButton, PluginMenuItem
|
||||||
|
|
||||||
|
|
||||||
items = (
|
items = (
|
@ -1,4 +1,4 @@
|
|||||||
from extras.plugins import PluginTemplateExtension
|
from netbox.plugins.templates import PluginTemplateExtension
|
||||||
|
|
||||||
|
|
||||||
class SiteContent(PluginTemplateExtension):
|
class SiteContent(PluginTemplateExtension):
|
@ -5,22 +5,22 @@ from django.core.exceptions import ImproperlyConfigured
|
|||||||
from django.test import Client, TestCase, override_settings
|
from django.test import Client, TestCase, override_settings
|
||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
|
|
||||||
from extras.plugins import PluginMenu
|
from netbox.tests.dummy_plugin import config as dummy_config
|
||||||
from extras.tests.dummy_plugin import config as dummy_config
|
from netbox.plugins.navigation import PluginMenu
|
||||||
from extras.plugins.utils import get_plugin_config
|
from netbox.plugins.utils import get_plugin_config
|
||||||
from netbox.graphql.schema import Query
|
from netbox.graphql.schema import Query
|
||||||
from netbox.registry import registry
|
from netbox.registry import registry
|
||||||
|
|
||||||
|
|
||||||
@skipIf('extras.tests.dummy_plugin' not in settings.PLUGINS, "dummy_plugin not in settings.PLUGINS")
|
@skipIf('netbox.tests.dummy_plugin' not in settings.PLUGINS, "dummy_plugin not in settings.PLUGINS")
|
||||||
class PluginTest(TestCase):
|
class PluginTest(TestCase):
|
||||||
|
|
||||||
def test_config(self):
|
def test_config(self):
|
||||||
|
|
||||||
self.assertIn('extras.tests.dummy_plugin.DummyPluginConfig', settings.INSTALLED_APPS)
|
self.assertIn('netbox.tests.dummy_plugin.DummyPluginConfig', settings.INSTALLED_APPS)
|
||||||
|
|
||||||
def test_models(self):
|
def test_models(self):
|
||||||
from extras.tests.dummy_plugin.models import DummyModel
|
from netbox.tests.dummy_plugin.models import DummyModel
|
||||||
|
|
||||||
# Test saving an instance
|
# Test saving an instance
|
||||||
instance = DummyModel(name='Instance 1', number=100)
|
instance = DummyModel(name='Instance 1', number=100)
|
||||||
@ -92,7 +92,7 @@ class PluginTest(TestCase):
|
|||||||
"""
|
"""
|
||||||
Check that plugin TemplateExtensions are registered.
|
Check that plugin TemplateExtensions are registered.
|
||||||
"""
|
"""
|
||||||
from extras.tests.dummy_plugin.template_content import SiteContent
|
from netbox.tests.dummy_plugin.template_content import SiteContent
|
||||||
|
|
||||||
self.assertIn(SiteContent, registry['plugins']['template_extensions']['dcim.site'])
|
self.assertIn(SiteContent, registry['plugins']['template_extensions']['dcim.site'])
|
||||||
|
|
||||||
@ -109,15 +109,15 @@ class PluginTest(TestCase):
|
|||||||
"""
|
"""
|
||||||
Check that plugin middleware is registered.
|
Check that plugin middleware is registered.
|
||||||
"""
|
"""
|
||||||
self.assertIn('extras.tests.dummy_plugin.middleware.DummyMiddleware', settings.MIDDLEWARE)
|
self.assertIn('netbox.tests.dummy_plugin.middleware.DummyMiddleware', settings.MIDDLEWARE)
|
||||||
|
|
||||||
def test_queues(self):
|
def test_queues(self):
|
||||||
"""
|
"""
|
||||||
Check that plugin queues are registered with the accurate name.
|
Check that plugin queues are registered with the accurate name.
|
||||||
"""
|
"""
|
||||||
self.assertIn('extras.tests.dummy_plugin.testing-low', settings.RQ_QUEUES)
|
self.assertIn('netbox.tests.dummy_plugin.testing-low', settings.RQ_QUEUES)
|
||||||
self.assertIn('extras.tests.dummy_plugin.testing-medium', settings.RQ_QUEUES)
|
self.assertIn('netbox.tests.dummy_plugin.testing-medium', settings.RQ_QUEUES)
|
||||||
self.assertIn('extras.tests.dummy_plugin.testing-high', settings.RQ_QUEUES)
|
self.assertIn('netbox.tests.dummy_plugin.testing-high', settings.RQ_QUEUES)
|
||||||
|
|
||||||
def test_min_version(self):
|
def test_min_version(self):
|
||||||
"""
|
"""
|
||||||
@ -170,17 +170,17 @@ class PluginTest(TestCase):
|
|||||||
"""
|
"""
|
||||||
Validate the registration and operation of plugin-provided GraphQL schemas.
|
Validate the registration and operation of plugin-provided GraphQL schemas.
|
||||||
"""
|
"""
|
||||||
from extras.tests.dummy_plugin.graphql import DummyQuery
|
from netbox.tests.dummy_plugin.graphql import DummyQuery
|
||||||
|
|
||||||
self.assertIn(DummyQuery, registry['plugins']['graphql_schemas'])
|
self.assertIn(DummyQuery, registry['plugins']['graphql_schemas'])
|
||||||
self.assertTrue(issubclass(Query, DummyQuery))
|
self.assertTrue(issubclass(Query, DummyQuery))
|
||||||
|
|
||||||
@override_settings(PLUGINS_CONFIG={'extras.tests.dummy_plugin': {'foo': 123}})
|
@override_settings(PLUGINS_CONFIG={'netbox.tests.dummy_plugin': {'foo': 123}})
|
||||||
def test_get_plugin_config(self):
|
def test_get_plugin_config(self):
|
||||||
"""
|
"""
|
||||||
Validate that get_plugin_config() returns config parameters correctly.
|
Validate that get_plugin_config() returns config parameters correctly.
|
||||||
"""
|
"""
|
||||||
plugin = 'extras.tests.dummy_plugin'
|
plugin = 'netbox.tests.dummy_plugin'
|
||||||
self.assertEqual(get_plugin_config(plugin, 'foo'), 123)
|
self.assertEqual(get_plugin_config(plugin, 'foo'), 123)
|
||||||
self.assertEqual(get_plugin_config(plugin, 'bar'), None)
|
self.assertEqual(get_plugin_config(plugin, 'bar'), None)
|
||||||
self.assertEqual(get_plugin_config(plugin, 'bar', default=456), 456)
|
self.assertEqual(get_plugin_config(plugin, 'bar', default=456), 456)
|
@ -6,10 +6,10 @@ from django.views.static import serve
|
|||||||
from drf_spectacular.views import SpectacularAPIView, SpectacularRedocView, SpectacularSwaggerView
|
from drf_spectacular.views import SpectacularAPIView, SpectacularRedocView, SpectacularSwaggerView
|
||||||
|
|
||||||
from account.views import LoginView, LogoutView
|
from account.views import LoginView, LogoutView
|
||||||
from extras.plugins.urls import plugin_admin_patterns, plugin_patterns, plugin_api_patterns
|
|
||||||
from netbox.api.views import APIRootView, StatusView
|
from netbox.api.views import APIRootView, StatusView
|
||||||
from netbox.graphql.schema import schema
|
from netbox.graphql.schema import schema
|
||||||
from netbox.graphql.views import GraphQLView
|
from netbox.graphql.views import GraphQLView
|
||||||
|
from netbox.plugins.urls import plugin_admin_patterns, plugin_patterns, plugin_api_patterns
|
||||||
from netbox.views import HomeView, StaticMediaFailureView, SearchView, htmx
|
from netbox.views import HomeView, StaticMediaFailureView, SearchView, htmx
|
||||||
from .admin import admin_site
|
from .admin import admin_site
|
||||||
|
|
||||||
|
@ -11,7 +11,7 @@ from django.views.defaults import ERROR_500_TEMPLATE_NAME, page_not_found
|
|||||||
from django.views.generic import View
|
from django.views.generic import View
|
||||||
from sentry_sdk import capture_message
|
from sentry_sdk import capture_message
|
||||||
|
|
||||||
from extras.plugins.utils import get_installed_plugins
|
from netbox.plugins.utils import get_installed_plugins
|
||||||
|
|
||||||
__all__ = (
|
__all__ = (
|
||||||
'handler_404',
|
'handler_404',
|
||||||
|
@ -2,7 +2,7 @@ from django import template as template_
|
|||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.utils.safestring import mark_safe
|
from django.utils.safestring import mark_safe
|
||||||
|
|
||||||
from extras.plugins import PluginTemplateExtension
|
from netbox.plugins import PluginTemplateExtension
|
||||||
from netbox.registry import registry
|
from netbox.registry import registry
|
||||||
|
|
||||||
register = template_.Library()
|
register = template_.Library()
|
@ -19,9 +19,9 @@ from jinja2.sandbox import SandboxedEnvironment
|
|||||||
from mptt.models import MPTTModel
|
from mptt.models import MPTTModel
|
||||||
|
|
||||||
from dcim.choices import CableLengthUnitChoices, WeightUnitChoices
|
from dcim.choices import CableLengthUnitChoices, WeightUnitChoices
|
||||||
from extras.plugins import PluginConfig
|
|
||||||
from extras.utils import is_taggable
|
from extras.utils import is_taggable
|
||||||
from netbox.config import get_config
|
from netbox.config import get_config
|
||||||
|
from netbox.plugins import PluginConfig
|
||||||
from urllib.parse import urlencode
|
from urllib.parse import urlencode
|
||||||
from utilities.constants import HTTP_REQUEST_META_SAFE_COPY
|
from utilities.constants import HTTP_REQUEST_META_SAFE_COPY
|
||||||
|
|
||||||
|
Loading…
Reference in New Issue
Block a user