mirror of
https://github.com/netbox-community/netbox.git
synced 2026-02-06 07:16:25 -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:
@@ -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 .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',
|
||||
}
|
||||
from netbox.plugins import PluginConfig
|
||||
|
||||
|
||||
#
|
||||
# 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
|
||||
# TODO: Remove in v4.0
|
||||
warnings.warn(f"{__name__} is deprecated. Import from netbox.plugins instead.", DeprecationWarning)
|
||||
|
||||
@@ -1,72 +1,7 @@
|
||||
from netbox.navigation import MenuGroup
|
||||
from utilities.choices import ButtonColorChoices
|
||||
from django.utils.text import slugify
|
||||
import warnings
|
||||
|
||||
__all__ = (
|
||||
'PluginMenu',
|
||||
'PluginMenuButton',
|
||||
'PluginMenuItem',
|
||||
)
|
||||
from netbox.plugins.navigation import *
|
||||
|
||||
|
||||
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
|
||||
# TODO: Remove in v4.0
|
||||
warnings.warn(f"{__name__} is deprecated. Import from netbox.plugins instead.", DeprecationWarning)
|
||||
|
||||
@@ -1,64 +1,7 @@
|
||||
import inspect
|
||||
import warnings
|
||||
|
||||
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',
|
||||
)
|
||||
from netbox.plugins.registration import *
|
||||
|
||||
|
||||
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 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
|
||||
# TODO: Remove in v4.0
|
||||
warnings.warn(f"{__name__} is deprecated. Import from netbox.plugins instead.", DeprecationWarning)
|
||||
|
||||
@@ -1,73 +1,7 @@
|
||||
from django.template.loader import get_template
|
||||
import warnings
|
||||
|
||||
__all__ = (
|
||||
'PluginTemplateExtension',
|
||||
)
|
||||
from netbox.plugins.templates import *
|
||||
|
||||
|
||||
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
|
||||
# TODO: Remove in v4.0
|
||||
warnings.warn(f"{__name__} is deprecated. Import from netbox.plugins instead.", DeprecationWarning)
|
||||
|
||||
@@ -1,41 +1,7 @@
|
||||
from importlib import import_module
|
||||
import warnings
|
||||
|
||||
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 netbox.plugins.urls import *
|
||||
|
||||
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")))
|
||||
)
|
||||
# TODO: Remove in v4.0
|
||||
warnings.warn(f"{__name__} is deprecated. Import from netbox.plugins instead.", DeprecationWarning)
|
||||
|
||||
@@ -1,37 +1,7 @@
|
||||
from django.apps import apps
|
||||
from django.conf import settings
|
||||
from django.core.exceptions import ImproperlyConfigured
|
||||
import warnings
|
||||
|
||||
__all__ = (
|
||||
'get_installed_plugins',
|
||||
'get_plugin_config',
|
||||
)
|
||||
from netbox.plugins.utils import *
|
||||
|
||||
|
||||
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.")
|
||||
# TODO: Remove in v4.0
|
||||
warnings.warn(f"{__name__} is deprecated. Import from netbox.plugins instead.", DeprecationWarning)
|
||||
|
||||
@@ -1,89 +1,7 @@
|
||||
from collections import OrderedDict
|
||||
import warnings
|
||||
|
||||
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
|
||||
from netbox.plugins.views import *
|
||||
|
||||
|
||||
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
|
||||
)))
|
||||
# TODO: Remove in v4.0
|
||||
warnings.warn(f"{__name__} is deprecated. Import from netbox.plugins instead.", DeprecationWarning)
|
||||
|
||||
@@ -1,83 +0,0 @@
|
||||
from django import template as template_
|
||||
from django.conf import settings
|
||||
from django.utils.safestring import mark_safe
|
||||
|
||||
from extras.plugins import PluginTemplateExtension
|
||||
from netbox.registry import registry
|
||||
|
||||
register = template_.Library()
|
||||
|
||||
|
||||
def _get_registered_content(obj, method, template_context):
|
||||
"""
|
||||
Given an object and a PluginTemplateExtension method name and the template context, return all the
|
||||
registered content for the object's model.
|
||||
"""
|
||||
html = ''
|
||||
context = {
|
||||
'object': obj,
|
||||
'request': template_context['request'],
|
||||
'settings': template_context['settings'],
|
||||
'csrf_token': template_context['csrf_token'],
|
||||
'perms': template_context['perms'],
|
||||
}
|
||||
|
||||
model_name = obj._meta.label_lower
|
||||
template_extensions = registry['plugins']['template_extensions'].get(model_name, [])
|
||||
for template_extension in template_extensions:
|
||||
|
||||
# If the class has not overridden the specified method, we can skip it (because we know it
|
||||
# will raise NotImplementedError).
|
||||
if getattr(template_extension, method) == getattr(PluginTemplateExtension, method):
|
||||
continue
|
||||
|
||||
# Update context with plugin-specific configuration parameters
|
||||
plugin_name = template_extension.__module__.split('.')[0]
|
||||
context['config'] = settings.PLUGINS_CONFIG.get(plugin_name, {})
|
||||
|
||||
# Call the method to render content
|
||||
instance = template_extension(context)
|
||||
content = getattr(instance, method)()
|
||||
html += content
|
||||
|
||||
return mark_safe(html)
|
||||
|
||||
|
||||
@register.simple_tag(takes_context=True)
|
||||
def plugin_buttons(context, obj):
|
||||
"""
|
||||
Render all buttons registered by plugins
|
||||
"""
|
||||
return _get_registered_content(obj, 'buttons', context)
|
||||
|
||||
|
||||
@register.simple_tag(takes_context=True)
|
||||
def plugin_left_page(context, obj):
|
||||
"""
|
||||
Render all left page content registered by plugins
|
||||
"""
|
||||
return _get_registered_content(obj, 'left_page', context)
|
||||
|
||||
|
||||
@register.simple_tag(takes_context=True)
|
||||
def plugin_right_page(context, obj):
|
||||
"""
|
||||
Render all right page content registered by plugins
|
||||
"""
|
||||
return _get_registered_content(obj, 'right_page', context)
|
||||
|
||||
|
||||
@register.simple_tag(takes_context=True)
|
||||
def plugin_full_width_page(context, obj):
|
||||
"""
|
||||
Render all full width page content registered by plugins
|
||||
"""
|
||||
return _get_registered_content(obj, 'full_width_page', context)
|
||||
|
||||
|
||||
@register.simple_tag(takes_context=True)
|
||||
def plugin_list_buttons(context, model):
|
||||
"""
|
||||
Render all list buttons registered by plugins
|
||||
"""
|
||||
return _get_registered_content(model, 'list_buttons', context)
|
||||
@@ -1,22 +0,0 @@
|
||||
from extras.plugins import PluginConfig
|
||||
|
||||
|
||||
class DummyPluginConfig(PluginConfig):
|
||||
name = 'extras.tests.dummy_plugin'
|
||||
verbose_name = 'Dummy plugin'
|
||||
version = '0.0'
|
||||
description = 'For testing purposes only'
|
||||
base_url = 'dummy-plugin'
|
||||
min_version = '1.0'
|
||||
max_version = '9.0'
|
||||
middleware = [
|
||||
'extras.tests.dummy_plugin.middleware.DummyMiddleware'
|
||||
]
|
||||
queues = [
|
||||
'testing-low',
|
||||
'testing-medium',
|
||||
'testing-high'
|
||||
]
|
||||
|
||||
|
||||
config = DummyPluginConfig
|
||||
@@ -1,9 +0,0 @@
|
||||
from django.contrib import admin
|
||||
|
||||
from netbox.admin import admin_site
|
||||
from .models import DummyModel
|
||||
|
||||
|
||||
@admin.register(DummyModel, site=admin_site)
|
||||
class DummyModelAdmin(admin.ModelAdmin):
|
||||
list_display = ('name', 'number')
|
||||
@@ -1,9 +0,0 @@
|
||||
from rest_framework.serializers import ModelSerializer
|
||||
from extras.tests.dummy_plugin.models import DummyModel
|
||||
|
||||
|
||||
class DummySerializer(ModelSerializer):
|
||||
|
||||
class Meta:
|
||||
model = DummyModel
|
||||
fields = ('id', 'name', 'number')
|
||||
@@ -1,6 +0,0 @@
|
||||
from rest_framework import routers
|
||||
from .views import DummyViewSet
|
||||
|
||||
router = routers.DefaultRouter()
|
||||
router.register('dummy-models', DummyViewSet)
|
||||
urlpatterns = router.urls
|
||||
@@ -1,8 +0,0 @@
|
||||
from rest_framework.viewsets import ModelViewSet
|
||||
from extras.tests.dummy_plugin.models import DummyModel
|
||||
from .serializers import DummySerializer
|
||||
|
||||
|
||||
class DummyViewSet(ModelViewSet):
|
||||
queryset = DummyModel.objects.all()
|
||||
serializer_class = DummySerializer
|
||||
@@ -1,21 +0,0 @@
|
||||
import graphene
|
||||
from graphene_django import DjangoObjectType
|
||||
|
||||
from netbox.graphql.fields import ObjectField, ObjectListField
|
||||
|
||||
from . import models
|
||||
|
||||
|
||||
class DummyModelType(DjangoObjectType):
|
||||
|
||||
class Meta:
|
||||
model = models.DummyModel
|
||||
fields = '__all__'
|
||||
|
||||
|
||||
class DummyQuery(graphene.ObjectType):
|
||||
dummymodel = ObjectField(DummyModelType)
|
||||
dummymodel_list = ObjectListField(DummyModelType)
|
||||
|
||||
|
||||
schema = DummyQuery
|
||||
@@ -1,7 +0,0 @@
|
||||
class DummyMiddleware:
|
||||
|
||||
def __init__(self, get_response):
|
||||
self.get_response = get_response
|
||||
|
||||
def __call__(self, request):
|
||||
return self.get_response(request)
|
||||
@@ -1,23 +0,0 @@
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
initial = True
|
||||
|
||||
dependencies = [
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='DummyModel',
|
||||
fields=[
|
||||
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False)),
|
||||
('name', models.CharField(max_length=20)),
|
||||
('number', models.IntegerField(default=100)),
|
||||
],
|
||||
options={
|
||||
'ordering': ['name'],
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -1,13 +0,0 @@
|
||||
from django.db import models
|
||||
|
||||
|
||||
class DummyModel(models.Model):
|
||||
name = models.CharField(
|
||||
max_length=20
|
||||
)
|
||||
number = models.IntegerField(
|
||||
default=100
|
||||
)
|
||||
|
||||
class Meta:
|
||||
ordering = ['name']
|
||||
@@ -1,32 +0,0 @@
|
||||
from django.utils.translation import gettext as _
|
||||
from extras.plugins import PluginMenu, PluginMenuButton, PluginMenuItem
|
||||
|
||||
|
||||
items = (
|
||||
PluginMenuItem(
|
||||
link='plugins:dummy_plugin:dummy_models',
|
||||
link_text='Item 1',
|
||||
buttons=(
|
||||
PluginMenuButton(
|
||||
link='admin:dummy_plugin_dummymodel_add',
|
||||
title='Add a new dummy model',
|
||||
icon_class='mdi mdi-plus-thick',
|
||||
),
|
||||
PluginMenuButton(
|
||||
link='admin:dummy_plugin_dummymodel_add',
|
||||
title='Add a new dummy model',
|
||||
icon_class='mdi mdi-plus-thick',
|
||||
),
|
||||
)
|
||||
),
|
||||
PluginMenuItem(
|
||||
link='plugins:dummy_plugin:dummy_models',
|
||||
link_text='Item 2',
|
||||
),
|
||||
)
|
||||
|
||||
menu = PluginMenu(
|
||||
label=_('Dummy Plugin'),
|
||||
groups=(('Group 1', items),),
|
||||
)
|
||||
menu_items = items
|
||||
@@ -1,20 +0,0 @@
|
||||
from users.preferences import UserPreference
|
||||
|
||||
|
||||
preferences = {
|
||||
'pref1': UserPreference(
|
||||
label='First preference',
|
||||
choices=(
|
||||
('foo', 'Foo'),
|
||||
('bar', 'Bar'),
|
||||
)
|
||||
),
|
||||
'pref2': UserPreference(
|
||||
label='Second preference',
|
||||
choices=(
|
||||
('a', 'A'),
|
||||
('b', 'B'),
|
||||
('c', 'C'),
|
||||
)
|
||||
),
|
||||
}
|
||||
@@ -1,14 +0,0 @@
|
||||
from netbox.search import SearchIndex
|
||||
from .models import DummyModel
|
||||
|
||||
|
||||
class DummyModelIndex(SearchIndex):
|
||||
model = DummyModel
|
||||
fields = (
|
||||
('name', 100),
|
||||
)
|
||||
|
||||
|
||||
indexes = (
|
||||
DummyModelIndex,
|
||||
)
|
||||
@@ -1,23 +0,0 @@
|
||||
from extras.plugins import PluginTemplateExtension
|
||||
|
||||
|
||||
class SiteContent(PluginTemplateExtension):
|
||||
model = 'dcim.site'
|
||||
|
||||
def left_page(self):
|
||||
return "SITE CONTENT - LEFT PAGE"
|
||||
|
||||
def right_page(self):
|
||||
return "SITE CONTENT - RIGHT PAGE"
|
||||
|
||||
def full_width_page(self):
|
||||
return "SITE CONTENT - FULL WIDTH PAGE"
|
||||
|
||||
def buttons(self):
|
||||
return "SITE CONTENT - BUTTONS"
|
||||
|
||||
def list_buttons(self):
|
||||
return "SITE CONTENT - LIST BUTTONS"
|
||||
|
||||
|
||||
template_extensions = [SiteContent]
|
||||
@@ -1,8 +0,0 @@
|
||||
from django.urls import path
|
||||
|
||||
from . import views
|
||||
|
||||
|
||||
urlpatterns = (
|
||||
path('models/', views.DummyModelsView.as_view(), name='dummy_models'),
|
||||
)
|
||||
@@ -1,20 +0,0 @@
|
||||
from django.http import HttpResponse
|
||||
from django.views.generic import View
|
||||
|
||||
from dcim.models import Site
|
||||
from utilities.views import register_model_view
|
||||
from .models import DummyModel
|
||||
|
||||
|
||||
class DummyModelsView(View):
|
||||
|
||||
def get(self, request):
|
||||
instance_count = DummyModel.objects.count()
|
||||
return HttpResponse(f"Instances: {instance_count}")
|
||||
|
||||
|
||||
@register_model_view(Site, 'extra', path='other-stuff')
|
||||
class ExtraCoreModelView(View):
|
||||
|
||||
def get(self, request, pk):
|
||||
return HttpResponse("Success!")
|
||||
@@ -1,186 +0,0 @@
|
||||
from unittest import skipIf
|
||||
|
||||
from django.conf import settings
|
||||
from django.core.exceptions import ImproperlyConfigured
|
||||
from django.test import Client, TestCase, override_settings
|
||||
from django.urls import reverse
|
||||
|
||||
from extras.plugins import PluginMenu
|
||||
from extras.tests.dummy_plugin import config as dummy_config
|
||||
from extras.plugins.utils import get_plugin_config
|
||||
from netbox.graphql.schema import Query
|
||||
from netbox.registry import registry
|
||||
|
||||
|
||||
@skipIf('extras.tests.dummy_plugin' not in settings.PLUGINS, "dummy_plugin not in settings.PLUGINS")
|
||||
class PluginTest(TestCase):
|
||||
|
||||
def test_config(self):
|
||||
|
||||
self.assertIn('extras.tests.dummy_plugin.DummyPluginConfig', settings.INSTALLED_APPS)
|
||||
|
||||
def test_models(self):
|
||||
from extras.tests.dummy_plugin.models import DummyModel
|
||||
|
||||
# Test saving an instance
|
||||
instance = DummyModel(name='Instance 1', number=100)
|
||||
instance.save()
|
||||
self.assertIsNotNone(instance.pk)
|
||||
|
||||
# Test deleting an instance
|
||||
instance.delete()
|
||||
self.assertIsNone(instance.pk)
|
||||
|
||||
def test_admin(self):
|
||||
|
||||
# Test admin view URL resolution
|
||||
url = reverse('admin:dummy_plugin_dummymodel_add')
|
||||
self.assertEqual(url, '/admin/dummy_plugin/dummymodel/add/')
|
||||
|
||||
def test_views(self):
|
||||
|
||||
# Test URL resolution
|
||||
url = reverse('plugins:dummy_plugin:dummy_models')
|
||||
self.assertEqual(url, '/plugins/dummy-plugin/models/')
|
||||
|
||||
# Test GET request
|
||||
client = Client()
|
||||
response = client.get(url)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
@override_settings(EXEMPT_VIEW_PERMISSIONS=['*'])
|
||||
def test_api_views(self):
|
||||
|
||||
# Test URL resolution
|
||||
url = reverse('plugins-api:dummy_plugin-api:dummymodel-list')
|
||||
self.assertEqual(url, '/api/plugins/dummy-plugin/dummy-models/')
|
||||
|
||||
# Test GET request
|
||||
client = Client()
|
||||
response = client.get(url)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
def test_registered_views(self):
|
||||
|
||||
# Test URL resolution
|
||||
url = reverse('dcim:site_extra', kwargs={'pk': 1})
|
||||
self.assertEqual(url, '/dcim/sites/1/other-stuff/')
|
||||
|
||||
# Test GET request
|
||||
client = Client()
|
||||
response = client.get(url)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
def test_menu(self):
|
||||
"""
|
||||
Check menu registration.
|
||||
"""
|
||||
menu = registry['plugins']['menus'][0]
|
||||
self.assertIsInstance(menu, PluginMenu)
|
||||
self.assertEqual(menu.label, 'Dummy Plugin')
|
||||
|
||||
def test_menu_items(self):
|
||||
"""
|
||||
Check menu_items registration.
|
||||
"""
|
||||
self.assertIn('Dummy plugin', registry['plugins']['menu_items'])
|
||||
menu_items = registry['plugins']['menu_items']['Dummy plugin']
|
||||
self.assertEqual(len(menu_items), 2)
|
||||
self.assertEqual(len(menu_items[0].buttons), 2)
|
||||
|
||||
def test_template_extensions(self):
|
||||
"""
|
||||
Check that plugin TemplateExtensions are registered.
|
||||
"""
|
||||
from extras.tests.dummy_plugin.template_content import SiteContent
|
||||
|
||||
self.assertIn(SiteContent, registry['plugins']['template_extensions']['dcim.site'])
|
||||
|
||||
def test_user_preferences(self):
|
||||
"""
|
||||
Check that plugin UserPreferences are registered.
|
||||
"""
|
||||
self.assertIn('dummy_plugin', registry['plugins']['preferences'])
|
||||
user_preferences = registry['plugins']['preferences']['dummy_plugin']
|
||||
self.assertEqual(type(user_preferences), dict)
|
||||
self.assertEqual(list(user_preferences.keys()), ['pref1', 'pref2'])
|
||||
|
||||
def test_middleware(self):
|
||||
"""
|
||||
Check that plugin middleware is registered.
|
||||
"""
|
||||
self.assertIn('extras.tests.dummy_plugin.middleware.DummyMiddleware', settings.MIDDLEWARE)
|
||||
|
||||
def test_queues(self):
|
||||
"""
|
||||
Check that plugin queues are registered with the accurate name.
|
||||
"""
|
||||
self.assertIn('extras.tests.dummy_plugin.testing-low', settings.RQ_QUEUES)
|
||||
self.assertIn('extras.tests.dummy_plugin.testing-medium', settings.RQ_QUEUES)
|
||||
self.assertIn('extras.tests.dummy_plugin.testing-high', settings.RQ_QUEUES)
|
||||
|
||||
def test_min_version(self):
|
||||
"""
|
||||
Check enforcement of minimum NetBox version.
|
||||
"""
|
||||
with self.assertRaises(ImproperlyConfigured):
|
||||
dummy_config.validate({}, '0.9')
|
||||
|
||||
def test_max_version(self):
|
||||
"""
|
||||
Check enforcement of maximum NetBox version.
|
||||
"""
|
||||
with self.assertRaises(ImproperlyConfigured):
|
||||
dummy_config.validate({}, '10.0')
|
||||
|
||||
def test_required_settings(self):
|
||||
"""
|
||||
Validate enforcement of required settings.
|
||||
"""
|
||||
class DummyConfigWithRequiredSettings(dummy_config):
|
||||
required_settings = ['foo']
|
||||
|
||||
# Validation should pass when all required settings are present
|
||||
DummyConfigWithRequiredSettings.validate({'foo': True}, settings.VERSION)
|
||||
|
||||
# Validation should fail when a required setting is missing
|
||||
with self.assertRaises(ImproperlyConfigured):
|
||||
DummyConfigWithRequiredSettings.validate({}, settings.VERSION)
|
||||
|
||||
def test_default_settings(self):
|
||||
"""
|
||||
Validate population of default config settings.
|
||||
"""
|
||||
class DummyConfigWithDefaultSettings(dummy_config):
|
||||
default_settings = {
|
||||
'bar': 123,
|
||||
}
|
||||
|
||||
# Populate the default value if setting has not been specified
|
||||
user_config = {}
|
||||
DummyConfigWithDefaultSettings.validate(user_config, settings.VERSION)
|
||||
self.assertEqual(user_config['bar'], 123)
|
||||
|
||||
# Don't overwrite specified values
|
||||
user_config = {'bar': 456}
|
||||
DummyConfigWithDefaultSettings.validate(user_config, settings.VERSION)
|
||||
self.assertEqual(user_config['bar'], 456)
|
||||
|
||||
def test_graphql(self):
|
||||
"""
|
||||
Validate the registration and operation of plugin-provided GraphQL schemas.
|
||||
"""
|
||||
from extras.tests.dummy_plugin.graphql import DummyQuery
|
||||
|
||||
self.assertIn(DummyQuery, registry['plugins']['graphql_schemas'])
|
||||
self.assertTrue(issubclass(Query, DummyQuery))
|
||||
|
||||
@override_settings(PLUGINS_CONFIG={'extras.tests.dummy_plugin': {'foo': 123}})
|
||||
def test_get_plugin_config(self):
|
||||
"""
|
||||
Validate that get_plugin_config() returns config parameters correctly.
|
||||
"""
|
||||
plugin = 'extras.tests.dummy_plugin'
|
||||
self.assertEqual(get_plugin_config(plugin, 'foo'), 123)
|
||||
self.assertEqual(get_plugin_config(plugin, 'bar'), None)
|
||||
self.assertEqual(get_plugin_config(plugin, 'bar', default=456), 456)
|
||||
Reference in New Issue
Block a user