1
0
mirror of https://github.com/netbox-community/netbox.git synced 2024-05-10 07:54:54 +00:00

Closes #14036: Move extras.plugins to netbox.plugins (#14086)

* 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:
Jeremy Stretch
2023-10-20 11:24:08 -04:00
committed by GitHub
parent 7efbfabc0b
commit 3f40ee5501
38 changed files with 579 additions and 528 deletions

View File

@ -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)

View File

@ -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)

View File

@ -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)

View File

@ -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)

View File

@ -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)

View File

@ -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)

View File

@ -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)

View File

@ -11,7 +11,7 @@ from rest_framework.reverse import reverse
from rest_framework.views import APIView
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

View File

@ -15,7 +15,7 @@ DATABASE = {
}
PLUGINS = [
'extras.tests.dummy_plugin',
'netbox.tests.dummy_plugin',
]
REDIS = {

View 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

View 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

View 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

View 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

View 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")))
)

View 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.")

View 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
)))

View File

@ -14,11 +14,11 @@ from django.contrib.messages import constants as messages
from django.core.exceptions import ImproperlyConfigured, ValidationError
from django.core.validators import URLValidator
from django.utils.encoding import force_str
from extras.plugins import PluginConfig
from sentry_sdk.integrations.django import DjangoIntegration
from netbox.config import PARAMS
from netbox.constants import RQ_QUEUE_DEFAULT, RQ_QUEUE_HIGH, RQ_QUEUE_LOW
from netbox.plugins import PluginConfig
#

View File

@ -1,8 +1,8 @@
from extras.plugins import PluginConfig
from netbox.plugins import PluginConfig
class DummyPluginConfig(PluginConfig):
name = 'extras.tests.dummy_plugin'
name = 'netbox.tests.dummy_plugin'
verbose_name = 'Dummy plugin'
version = '0.0'
description = 'For testing purposes only'
@ -10,7 +10,7 @@ class DummyPluginConfig(PluginConfig):
min_version = '1.0'
max_version = '9.0'
middleware = [
'extras.tests.dummy_plugin.middleware.DummyMiddleware'
'netbox.tests.dummy_plugin.middleware.DummyMiddleware'
]
queues = [
'testing-low',

View File

@ -1,5 +1,5 @@
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):

View File

@ -1,5 +1,5 @@
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

View File

@ -1,5 +1,5 @@
from django.utils.translation import gettext as _
from extras.plugins import PluginMenu, PluginMenuButton, PluginMenuItem
from netbox.plugins.navigation import PluginMenu, PluginMenuButton, PluginMenuItem
items = (

View File

@ -1,4 +1,4 @@
from extras.plugins import PluginTemplateExtension
from netbox.plugins.templates import PluginTemplateExtension
class SiteContent(PluginTemplateExtension):

View File

@ -5,22 +5,22 @@ 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.tests.dummy_plugin import config as dummy_config
from netbox.plugins.navigation import PluginMenu
from netbox.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")
@skipIf('netbox.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)
self.assertIn('netbox.tests.dummy_plugin.DummyPluginConfig', settings.INSTALLED_APPS)
def test_models(self):
from extras.tests.dummy_plugin.models import DummyModel
from netbox.tests.dummy_plugin.models import DummyModel
# Test saving an instance
instance = DummyModel(name='Instance 1', number=100)
@ -92,7 +92,7 @@ class PluginTest(TestCase):
"""
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'])
@ -109,15 +109,15 @@ class PluginTest(TestCase):
"""
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):
"""
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)
self.assertIn('netbox.tests.dummy_plugin.testing-low', settings.RQ_QUEUES)
self.assertIn('netbox.tests.dummy_plugin.testing-medium', settings.RQ_QUEUES)
self.assertIn('netbox.tests.dummy_plugin.testing-high', settings.RQ_QUEUES)
def test_min_version(self):
"""
@ -170,17 +170,17 @@ class PluginTest(TestCase):
"""
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.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):
"""
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, 'bar'), None)
self.assertEqual(get_plugin_config(plugin, 'bar', default=456), 456)

View File

@ -6,10 +6,10 @@ from django.views.static import serve
from drf_spectacular.views import SpectacularAPIView, SpectacularRedocView, SpectacularSwaggerView
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.graphql.schema import schema
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 .admin import admin_site

View File

@ -11,7 +11,7 @@ from django.views.defaults import ERROR_500_TEMPLATE_NAME, page_not_found
from django.views.generic import View
from sentry_sdk import capture_message
from extras.plugins.utils import get_installed_plugins
from netbox.plugins.utils import get_installed_plugins
__all__ = (
'handler_404',

View File

@ -2,7 +2,7 @@ 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.plugins import PluginTemplateExtension
from netbox.registry import registry
register = template_.Library()

View File

@ -19,9 +19,9 @@ from jinja2.sandbox import SandboxedEnvironment
from mptt.models import MPTTModel
from dcim.choices import CableLengthUnitChoices, WeightUnitChoices
from extras.plugins import PluginConfig
from extras.utils import is_taggable
from netbox.config import get_config
from netbox.plugins import PluginConfig
from urllib.parse import urlencode
from utilities.constants import HTTP_REQUEST_META_SAFE_COPY