diff --git a/netbox/core/plugins.py b/netbox/core/plugins.py new file mode 100644 index 000000000..ab8fd97a1 --- /dev/null +++ b/netbox/core/plugins.py @@ -0,0 +1,209 @@ +import datetime +import importlib +import importlib.util +from dataclasses import dataclass, field +from typing import Optional + +import requests +from django.conf import settings +from django.core.cache import cache +from django.utils.translation import gettext_lazy as _ + +from netbox.plugins import PluginConfig +from utilities.datetime import datetime_from_timestamp + +USER_AGENT_STRING = f'NetBox/{settings.RELEASE.version} {settings.RELEASE.edition}' + + +@dataclass +class PluginAuthor: + """ + Identifying information for the author of a plugin. + """ + name: str + org_id: str = '' + url: str = '' + + +@dataclass +class PluginVersion: + """ + Details for a specific versioned release of a plugin. + """ + date: datetime.datetime = None + version: str = '' + netbox_min_version: str = '' + netbox_max_version: str = '' + has_model: bool = False + is_certified: bool = False + is_feature: bool = False + is_integration: bool = False + is_netboxlabs_supported: bool = False + + +@dataclass +class Plugin: + """ + The representation of a NetBox plugin in the catalog API. + """ + id: str = '' + status: str = '' + title_short: str = '' + title_long: str = '' + tag_line: str = '' + description_short: str = '' + slug: str = '' + author: Optional[PluginAuthor] = None + created_at: datetime.datetime = None + updated_at: datetime.datetime = None + license_type: str = '' + homepage_url: str = '' + package_name_pypi: str = '' + config_name: str = '' + is_certified: bool = False + release_latest: PluginVersion = field(default_factory=PluginVersion) + release_recent_history: list[PluginVersion] = field(default_factory=list) + is_local: bool = False # extra field for locally installed plugins + is_installed: bool = False + installed_version: str = '' + + +def get_local_plugins(): + """ + Return a dictionary of all locally-installed plugins, mapped by name. + """ + plugins = {} + for plugin_name in settings.PLUGINS: + plugin = importlib.import_module(plugin_name) + plugin_config: PluginConfig = plugin.config + + plugins[plugin_config.name] = Plugin( + slug=plugin_config.name, + title_short=plugin_config.verbose_name, + tag_line=plugin_config.description, + description_short=plugin_config.description, + is_local=True, + is_installed=True, + installed_version=plugin_config.version, + ) + + return plugins + + +def get_catalog_plugins(): + """ + Return a dictionary of all entries in the plugins catalog, mapped by name. + """ + session = requests.Session() + plugins = {} + + def get_pages(): + # TODO: pagination is currently broken in API + payload = {'page': '1', 'per_page': '50'} + first_page = session.get( + settings.PLUGIN_CATALOG_URL, + headers={'User-Agent': USER_AGENT_STRING}, + proxies=settings.HTTP_PROXIES, + timeout=3, + params=payload + ).json() + yield first_page + num_pages = first_page['metadata']['pagination']['last_page'] + + for page in range(2, num_pages + 1): + payload['page'] = page + next_page = session.get( + settings.PLUGIN_CATALOG_URL, + headers={'User-Agent': USER_AGENT_STRING}, + proxies=settings.HTTP_PROXIES, + timeout=3, + params=payload + ).json() + yield next_page + + for page in get_pages(): + for data in page['data']: + + # Populate releases + releases = [] + for version in data['release_recent_history']: + releases.append( + PluginVersion( + date=datetime_from_timestamp(version['date']), + version=version['version'], + netbox_min_version=version['netbox_min_version'], + netbox_max_version=version['netbox_max_version'], + has_model=version['has_model'], + is_certified=version['is_certified'], + is_feature=version['is_feature'], + is_integration=version['is_integration'], + is_netboxlabs_supported=version['is_netboxlabs_supported'], + ) + ) + releases = sorted(releases, key=lambda x: x.date, reverse=True) + latest_release = PluginVersion( + date=datetime_from_timestamp(data['release_latest']['date']), + version=data['release_latest']['version'], + netbox_min_version=data['release_latest']['netbox_min_version'], + netbox_max_version=data['release_latest']['netbox_max_version'], + has_model=data['release_latest']['has_model'], + is_certified=data['release_latest']['is_certified'], + is_feature=data['release_latest']['is_feature'], + is_integration=data['release_latest']['is_integration'], + is_netboxlabs_supported=data['release_latest']['is_netboxlabs_supported'], + ) + + # Populate author (if any) + if data['author']: + print(data['author']) + author = PluginAuthor( + name=data['author']['name'], + org_id=data['author']['org_id'], + url=data['author']['url'], + ) + else: + author = None + + # Populate plugin data + plugins[data['slug']] = Plugin( + id=data['id'], + status=data['status'], + title_short=data['title_short'], + title_long=data['title_long'], + tag_line=data['tag_line'], + description_short=data['description_short'], + slug=data['slug'], + author=author, + created_at=datetime_from_timestamp(data['created_at']), + updated_at=datetime_from_timestamp(data['updated_at']), + license_type=data['license_type'], + homepage_url=data['homepage_url'], + package_name_pypi=data['package_name_pypi'], + config_name=data['config_name'], + is_certified=data['is_certified'], + release_latest=latest_release, + release_recent_history=releases, + ) + + return plugins + + +def get_plugins(): + """ + Return a dictionary of all plugins (both catalog and locally installed), mapped by name. + """ + local_plugins = get_local_plugins() + catalog_plugins = cache.get('plugins-catalog-feed') + if not catalog_plugins: + catalog_plugins = get_catalog_plugins() + cache.set('plugins-catalog-feed', catalog_plugins, 3600) + + plugins = catalog_plugins + for k, v in local_plugins.items(): + if k in plugins: + plugins[k].is_local = True + plugins[k].is_installed = True + else: + plugins[k] = v + + return plugins diff --git a/netbox/core/tables/plugins.py b/netbox/core/tables/plugins.py index 21e90cd6b..529fe60f4 100644 --- a/netbox/core/tables/plugins.py +++ b/netbox/core/tables/plugins.py @@ -1,39 +1,80 @@ import django_tables2 as tables from django.utils.translation import gettext_lazy as _ -from netbox.tables import BaseTable + +from netbox.tables import BaseTable, columns __all__ = ( - 'PluginTable', + 'CatalogPluginTable', + 'PluginVersionTable', ) -class PluginTable(BaseTable): - name = tables.Column( - accessor=tables.A('verbose_name'), - verbose_name=_('Name') - ) +class PluginVersionTable(BaseTable): version = tables.Column( verbose_name=_('Version') ) - package = tables.Column( - accessor=tables.A('name'), - verbose_name=_('Package') + last_updated = columns.DateTimeColumn( + accessor=tables.A('date'), + timespec='minutes', + verbose_name=_('Last Updated') ) - author = tables.Column( - verbose_name=_('Author') + min_version = tables.Column( + accessor=tables.A('netbox_min_version'), + verbose_name=_('Minimum NetBox Version') ) - author_email = tables.Column( - verbose_name=_('Author Email') - ) - description = tables.Column( - verbose_name=_('Description') + max_version = tables.Column( + accessor=tables.A('netbox_max_version'), + verbose_name=_('Maximum NetBox Version') ) class Meta(BaseTable.Meta): - empty_text = _('No plugins found') + empty_text = _('No plugin data found') fields = ( - 'name', 'version', 'package', 'author', 'author_email', 'description', + 'version', 'last_updated', 'min_version', 'max_version', ) default_columns = ( - 'name', 'version', 'package', 'description', + 'version', 'last_updated', 'min_version', 'max_version', ) + orderable = False + + +class CatalogPluginTable(BaseTable): + title_short = tables.Column( + linkify=('core:plugin', [tables.A('slug')]), + verbose_name=_('Name') + ) + author = tables.Column( + accessor=tables.A('author.name'), + verbose_name=_('Author') + ) + is_local = columns.BooleanColumn( + verbose_name=_('Local') + ) + is_installed = columns.BooleanColumn( + verbose_name=_('Installed') + ) + is_certified = columns.BooleanColumn( + verbose_name=_('Certified') + ) + created_at = columns.DateTimeColumn( + verbose_name=_('Published') + ) + updated_at = columns.DateTimeColumn( + verbose_name=_('Updated') + ) + installed_version = tables.Column( + verbose_name=_('Installed version') + ) + + class Meta(BaseTable.Meta): + empty_text = _('No plugin data found') + fields = ( + 'title_short', 'author', 'is_local', 'is_installed', 'is_certified', 'created_at', 'updated_at', + 'installed_version', + ) + default_columns = ( + 'title_short', 'author', 'is_local', 'is_installed', 'is_certified', 'created_at', 'updated_at', + ) + # List installed plugins first, then certified plugins, then + # everything else (with each tranche ordered alphabetically) + order_by = ('-is_installed', '-is_certified', 'name') diff --git a/netbox/core/urls.py b/netbox/core/urls.py index 58e96d735..fd6ec8996 100644 --- a/netbox/core/urls.py +++ b/netbox/core/urls.py @@ -49,4 +49,8 @@ urlpatterns = ( # System path('system/', views.SystemView.as_view(), name='system'), + + # Plugins + path('plugins/', views.PluginListView.as_view(), name='plugin_list'), + path('plugins//', views.PluginView.as_view(), name='plugin'), ) diff --git a/netbox/core/views.py b/netbox/core/views.py index 508401585..79ef33e1d 100644 --- a/netbox/core/views.py +++ b/netbox/core/views.py @@ -2,7 +2,6 @@ import json import platform from django import __version__ as DJANGO_VERSION -from django.apps import apps from django.conf import settings from django.contrib import messages from django.contrib.auth.mixins import UserPassesTestMixin @@ -36,6 +35,8 @@ from utilities.query import count_related from utilities.views import ContentTypePermissionRequiredMixin, GetRelatedModelsMixin, register_model_view from . import filtersets, forms, tables from .models import * +from .plugins import get_plugins +from .tables import CatalogPluginTable, PluginVersionTable # @@ -581,7 +582,7 @@ class WorkerView(BaseRQView): # -# Plugins +# System # class SystemView(UserPassesTestMixin, View): @@ -614,12 +615,6 @@ class SystemView(UserPassesTestMixin, View): 'rq_worker_count': Worker.count(get_connection('default')), } - # Plugins - plugins = [ - # Look up app config by package name - apps.get_app_config(plugin.rsplit('.', 1)[-1]) for plugin in settings.PLUGINS - ] - # Configuration try: config = ConfigRevision.objects.get(pk=cache.get('config_version')) @@ -631,9 +626,6 @@ class SystemView(UserPassesTestMixin, View): if 'export' in request.GET: data = { **stats, - 'plugins': { - plugin.name: plugin.version for plugin in plugins - }, 'config': { k: config.data[k] for k in sorted(config.data) }, @@ -642,11 +634,58 @@ class SystemView(UserPassesTestMixin, View): response['Content-Disposition'] = 'attachment; filename="netbox.json"' return response - plugins_table = tables.PluginTable(plugins, orderable=False) - plugins_table.configure(request) - return render(request, 'core/system.html', { 'stats': stats, - 'plugins_table': plugins_table, 'config': config, }) + + +# +# Plugins +# + +class PluginListView(UserPassesTestMixin, View): + + def test_func(self): + return self.request.user.is_staff + + def get(self, request): + q = request.GET.get('q', None) + + plugins = get_plugins().values() + if q: + plugins = [obj for obj in plugins if q.casefold() in obj.title_short.casefold()] + + table = CatalogPluginTable(plugins, user=request.user) + table.configure(request) + + # If this is an HTMX request, return only the rendered table HTML + if htmx_partial(request): + return render(request, 'htmx/table.html', { + 'table': table, + }) + + return render(request, 'core/plugin_list.html', { + 'table': table, + }) + + +class PluginView(UserPassesTestMixin, View): + + def test_func(self): + return self.request.user.is_staff + + def get(self, request, name): + + plugins = get_plugins() + if name not in plugins: + raise Http404(_("Plugin {name} not found").format(name=name)) + plugin = plugins[name] + + table = PluginVersionTable(plugin.release_recent_history, user=request.user) + table.configure(request) + + return render(request, 'core/plugin.html', { + 'plugin': plugin, + 'table': table, + }) diff --git a/netbox/netbox/navigation/menu.py b/netbox/netbox/navigation/menu.py index 44f212f9c..4a9d103c7 100644 --- a/netbox/netbox/navigation/menu.py +++ b/netbox/netbox/navigation/menu.py @@ -437,6 +437,11 @@ ADMIN_MENU = Menu( link_text=_('System'), auth_required=True ), + MenuItem( + link='core:plugin_list', + link_text=_('Plugins'), + auth_required=True + ), MenuItem( link='core:configrevision_list', link_text=_('Configuration History'), diff --git a/netbox/netbox/settings.py b/netbox/netbox/settings.py index 64fb24f09..b2ce3eacd 100644 --- a/netbox/netbox/settings.py +++ b/netbox/netbox/settings.py @@ -769,6 +769,8 @@ STRAWBERRY_DJANGO = { # Plugins # +PLUGIN_CATALOG_URL = 'https://api.netbox.oss.netboxlabs.com/v1/plugins' + # Register any configured plugins for plugin_name in PLUGINS: try: diff --git a/netbox/templates/core/inc/plugin_installation.html b/netbox/templates/core/inc/plugin_installation.html new file mode 100644 index 000000000..f1878fb97 --- /dev/null +++ b/netbox/templates/core/inc/plugin_installation.html @@ -0,0 +1,29 @@ +

You can install this plugin from the command line with PyPI.

+

The following commands may be helpful; always refer to the plugin's own documentation and the Installing a Plugin unit of the NetBox documentation.

+

1. Enter the NetBox virtual environment and install the plugin package:

+ +
+source /opt/netbox/venv/bin/activate
+pip install {{ plugin.slug }}
+
+ +

2. In /opt/netbox/netbox/netbox/configuration.py, add the plugin to the PLUGINS list:

+ +
+PLUGINS=[
+"{{ plugin.config_name }}",
+]
+
+ +

3. Still from the NetBox virtual environment, run database migrations and collect static files:

+ +
+python3 /opt/netbox/netbox/netbox/manage.py migrate
+python3 /opt/netbox/netbox/netbox/manage.py collectstatic
+
+ +

4. Restart the NetBox services to complete the plugin installation:

+ +
+sudo systemctl restart netbox netbox-rq
+
diff --git a/netbox/templates/core/plugin.html b/netbox/templates/core/plugin.html new file mode 100644 index 000000000..94307dc14 --- /dev/null +++ b/netbox/templates/core/plugin.html @@ -0,0 +1,113 @@ +{% extends 'generic/object.html' %} +{% load helpers %} +{% load form_helpers %} +{% load i18n %} + +{% block title %}{{ plugin.title_short }}{% endblock %} + +{% block object_identifier %} +{% endblock object_identifier %} + +{% block breadcrumbs %} + +{% endblock breadcrumbs %} + +{% block subtitle %} + + {% checkmark plugin.is_installed %} + {% if plugin.is_installed %} + v{{ plugin.installed_version }} {% trans "installed" %} + {% else %} + {% trans "Not installed" %} + {% endif %} + +{% endblock %} + +{% block controls %}{% endblock %} + +{% block tabs %} + +{% endblock tabs %} + +{% block content %} +
+
+
+
+
{% trans "Plugin Details" %}
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
{% trans "Name" %}{{ plugin.title_short }}
{% trans "Summary" %}{{ plugin.tag_line|placeholder }}
{% trans "Author" %}{{ plugin.author.name|placeholder }}
{% trans "URL" %} + {% if plugin.homepage_url %} + {{ plugin.homepage_url }} + {% else %} + {{ ''|placeholder }} + {% endif %} +
{% trans "License" %}{{ plugin.license_type|placeholder }}
{% trans "Description" %}{{ plugin.description_short|markdown }}
{% trans "Certified" %}{% checkmark plugin.is_certified %}
{% trans "Local" %}{% checkmark plugin.is_local %}
+
+
+
+
+
{% trans "Version History" %}
+
+ {% include 'htmx/table.html' %} +
+
+
+
+
+ {% if True or not plugin.is_local and 'commercial' not in settings.RELEASE.features %} +
+
+
{% trans "Local Installation Instructions" %}
+
+ {% include 'core/inc/plugin_installation.html' %} +
+
+
+ {% endif %} +{% endblock content %} diff --git a/netbox/templates/core/plugin_list.html b/netbox/templates/core/plugin_list.html new file mode 100644 index 000000000..47a6c3686 --- /dev/null +++ b/netbox/templates/core/plugin_list.html @@ -0,0 +1,16 @@ +{% extends 'generic/object_list.html' %} +{% load buttons %} +{% load helpers %} +{% load i18n %} +{% load render_table from django_tables2 %} + +{% block title %}{% trans "Plugins" %}{% endblock %} + +{% block tabs %} + +{% endblock tabs %} + diff --git a/netbox/templates/core/system.html b/netbox/templates/core/system.html index 6f88643a0..5a7088e96 100644 --- a/netbox/templates/core/system.html +++ b/netbox/templates/core/system.html @@ -78,16 +78,6 @@ - {# Plugins #} -
-
-
-
{% trans "Plugins" %}
- {% render_table plugins_table %} -
-
-
- {# Configuration #}