Просмотр исходного кода

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
Jeremy Stretch 2 лет назад
Родитель
Сommit
3f40ee5501
38 измененных файлов с 579 добавлено и 528 удалено
  1. 3 142
      netbox/extras/plugins/__init__.py
  2. 4 69
      netbox/extras/plugins/navigation.py
  3. 4 61
      netbox/extras/plugins/registration.py
  4. 4 70
      netbox/extras/plugins/templates.py
  5. 4 38
      netbox/extras/plugins/urls.py
  6. 4 34
      netbox/extras/plugins/utils.py
  7. 4 86
      netbox/extras/plugins/views.py
  8. 1 1
      netbox/netbox/api/views.py
  9. 1 1
      netbox/netbox/configuration_testing.py
  10. 148 0
      netbox/netbox/plugins/__init__.py
  11. 72 0
      netbox/netbox/plugins/navigation.py
  12. 64 0
      netbox/netbox/plugins/registration.py
  13. 73 0
      netbox/netbox/plugins/templates.py
  14. 41 0
      netbox/netbox/plugins/urls.py
  15. 37 0
      netbox/netbox/plugins/utils.py
  16. 89 0
      netbox/netbox/plugins/views.py
  17. 1 1
      netbox/netbox/settings.py
  18. 3 3
      netbox/netbox/tests/dummy_plugin/__init__.py
  19. 0 0
      netbox/netbox/tests/dummy_plugin/admin.py
  20. 1 1
      netbox/netbox/tests/dummy_plugin/api/serializers.py
  21. 0 0
      netbox/netbox/tests/dummy_plugin/api/urls.py
  22. 1 1
      netbox/netbox/tests/dummy_plugin/api/views.py
  23. 0 0
      netbox/netbox/tests/dummy_plugin/graphql.py
  24. 0 0
      netbox/netbox/tests/dummy_plugin/middleware.py
  25. 0 0
      netbox/netbox/tests/dummy_plugin/migrations/0001_initial.py
  26. 0 0
      netbox/netbox/tests/dummy_plugin/migrations/__init__.py
  27. 0 0
      netbox/netbox/tests/dummy_plugin/models.py
  28. 1 1
      netbox/netbox/tests/dummy_plugin/navigation.py
  29. 0 0
      netbox/netbox/tests/dummy_plugin/preferences.py
  30. 0 0
      netbox/netbox/tests/dummy_plugin/search.py
  31. 1 1
      netbox/netbox/tests/dummy_plugin/template_content.py
  32. 0 0
      netbox/netbox/tests/dummy_plugin/urls.py
  33. 0 0
      netbox/netbox/tests/dummy_plugin/views.py
  34. 14 14
      netbox/netbox/tests/test_plugins.py
  35. 1 1
      netbox/netbox/urls.py
  36. 1 1
      netbox/netbox/views/errors.py
  37. 1 1
      netbox/utilities/templatetags/plugins.py
  38. 1 1
      netbox/utilities/utils.py

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

@@ -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 *
+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',
-}
-
-
-#
-# 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)

+ 4 - 69
netbox/extras/plugins/navigation.py

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

+ 4 - 61
netbox/extras/plugins/registration.py

@@ -1,64 +1,7 @@
-import inspect
+import warnings
 
-from netbox.registry import registry
-from .navigation import PluginMenu, PluginMenuButton, PluginMenuItem
-from .templates import PluginTemplateExtension
+from netbox.plugins.registration import *
 
-__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 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)

+ 4 - 70
netbox/extras/plugins/templates.py

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

+ 4 - 38
netbox/extras/plugins/urls.py

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

+ 4 - 34
netbox/extras/plugins/utils.py

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

+ 4 - 86
netbox/extras/plugins/views.py

@@ -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 - 1
netbox/netbox/api/views.py

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

+ 1 - 1
netbox/netbox/configuration_testing.py

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

+ 148 - 0
netbox/netbox/plugins/__init__.py

@@ -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 - 0
netbox/netbox/plugins/navigation.py

@@ -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 - 0
netbox/netbox/plugins/registration.py

@@ -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 - 0
netbox/netbox/plugins/templates.py

@@ -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 - 0
netbox/netbox/plugins/urls.py

@@ -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 - 0
netbox/netbox/plugins/utils.py

@@ -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 - 0
netbox/netbox/plugins/views.py

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

+ 1 - 1
netbox/netbox/settings.py

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

+ 3 - 3
netbox/extras/tests/dummy_plugin/__init__.py → netbox/netbox/tests/dummy_plugin/__init__.py

@@ -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',

+ 0 - 0
netbox/extras/tests/dummy_plugin/admin.py → netbox/netbox/tests/dummy_plugin/admin.py


+ 1 - 1
netbox/extras/tests/dummy_plugin/api/serializers.py → netbox/netbox/tests/dummy_plugin/api/serializers.py

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

+ 0 - 0
netbox/extras/tests/dummy_plugin/api/urls.py → netbox/netbox/tests/dummy_plugin/api/urls.py


+ 1 - 1
netbox/extras/tests/dummy_plugin/api/views.py → netbox/netbox/tests/dummy_plugin/api/views.py

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

+ 0 - 0
netbox/extras/tests/dummy_plugin/graphql.py → netbox/netbox/tests/dummy_plugin/graphql.py


+ 0 - 0
netbox/extras/tests/dummy_plugin/middleware.py → netbox/netbox/tests/dummy_plugin/middleware.py


+ 0 - 0
netbox/extras/tests/dummy_plugin/migrations/0001_initial.py → netbox/netbox/tests/dummy_plugin/migrations/0001_initial.py


+ 0 - 0
netbox/extras/tests/dummy_plugin/migrations/__init__.py → netbox/netbox/tests/dummy_plugin/migrations/__init__.py


+ 0 - 0
netbox/extras/tests/dummy_plugin/models.py → netbox/netbox/tests/dummy_plugin/models.py


+ 1 - 1
netbox/extras/tests/dummy_plugin/navigation.py → netbox/netbox/tests/dummy_plugin/navigation.py

@@ -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 = (

+ 0 - 0
netbox/extras/tests/dummy_plugin/preferences.py → netbox/netbox/tests/dummy_plugin/preferences.py


+ 0 - 0
netbox/extras/tests/dummy_plugin/search.py → netbox/netbox/tests/dummy_plugin/search.py


+ 1 - 1
netbox/extras/tests/dummy_plugin/template_content.py → netbox/netbox/tests/dummy_plugin/template_content.py

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

+ 0 - 0
netbox/extras/tests/dummy_plugin/urls.py → netbox/netbox/tests/dummy_plugin/urls.py


+ 0 - 0
netbox/extras/tests/dummy_plugin/views.py → netbox/netbox/tests/dummy_plugin/views.py


+ 14 - 14
netbox/extras/tests/test_plugins.py → netbox/netbox/tests/test_plugins.py

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

+ 1 - 1
netbox/netbox/urls.py

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

+ 1 - 1
netbox/netbox/views/errors.py

@@ -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',

+ 1 - 1
netbox/extras/templatetags/plugins.py → netbox/utilities/templatetags/plugins.py

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

+ 1 - 1
netbox/utilities/utils.py

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