diff --git a/netbox/core/choices.py b/netbox/core/choices.py index 522eddc31..3bdde624b 100644 --- a/netbox/core/choices.py +++ b/netbox/core/choices.py @@ -115,3 +115,20 @@ class ObjectChangeActionChoices(ChoiceSet): (ACTION_UPDATE, _('Updated'), 'blue'), (ACTION_DELETE, _('Deleted'), 'red'), ) + + +# +# Plugins +# + +class CorePluginStatusChoices(ChoiceSet): + + STATUS_INSTALLED = 'installed' + STATUS_AVAILABLE = 'available' + STATUS_LOCKED = 'locked' + + CHOICES = ( + (STATUS_INSTALLED, _('Installed'), 'green'), + (STATUS_AVAILABLE, _('Available'), 'blue'), + (STATUS_LOCKED, _('Locked'), 'gray'), + ) diff --git a/netbox/core/core_plugins.py b/netbox/core/core_plugins.py new file mode 100644 index 000000000..3c16dd6a6 --- /dev/null +++ b/netbox/core/core_plugins.py @@ -0,0 +1,129 @@ +from dataclasses import dataclass + +from django.conf import settings +from django.utils.translation import gettext_lazy as _ + +from core.choices import CorePluginStatusChoices + +__all__ = ( + 'CORE_PLUGINS', + 'CorePlugin', + 'get_core_plugin_names', + 'get_core_plugins', +) + + +@dataclass +class CorePlugin: + """ + A NetBox Labs-maintained plugin. These are not published to the public plugins + catalog, so we describe them statically and render them in a dedicated section + of the plugins list page. Plugins with `commercial=True` are paid features and + appear as "Locked" on Community edition; plugins with `commercial=False` are + free and always appear as "Available" when not installed. + """ + config_name: str + title: str + description: str + mdi_icon: str + product_url: str + commercial: bool = False + + +CORE_PLUGINS = ( + CorePlugin( + config_name='netbox_asset_lifecycle', + title=_('Asset Lifecycle'), + description=_('Track network hardware through procurement, deployment, and retirement.'), + mdi_icon='mdi-clipboard-list-outline', + product_url='#', + commercial=True, + ), + CorePlugin( + config_name='netbox_branching', + title=_('Branching'), + description=_('Stage and review changes to NetBox data in isolated branches before merging.'), + mdi_icon='mdi-source-branch', + product_url='https://netboxlabs.com/docs/extensions/branching/', + ), + CorePlugin( + config_name='netbox_changes', + title=_('Changes'), + description=_('Manage proposed and scheduled changes to network infrastructure.'), + mdi_icon='mdi-history', + product_url='https://netboxlabs.com/docs/developer/plugins-extensions/changes/', + commercial=True, + ), + CorePlugin( + config_name='netbox_custom_objects', + title=_('Custom Objects'), + description=_('Define and manage your own object types alongside the built-in NetBox models.'), + mdi_icon='mdi-cube-outline', + product_url='https://netboxlabs.com/docs/extensions/custom-objects/', + ), + CorePlugin( + config_name='netbox_diode_plugin', + title=_('Diode'), + description=_('Streamline data ingestion into NetBox from external sources.'), + mdi_icon='mdi-database-import-outline', + product_url='https://netboxlabs.com/docs/diode/', + ), + CorePlugin( + config_name='netbox_physical_geometry', + title=_('Visual Explorer'), + description=_('Render interactive 3D visualizations of physical infrastructure.'), + mdi_icon='mdi-cube-scan', + product_url='#', + commercial=True, + ), +) + + +def get_core_plugins(local_plugins=None): + """ + Return a list of display entries for NetBox Labs core plugins. Each entry + includes the static metadata plus a resolved status and the installed version + (when applicable). Status is derived from whether the plugin is locally installed + and whether commercial features are enabled on this NetBox edition. + """ + local_plugins = local_plugins or {} + commercial_enabled = settings.RELEASE.features.commercial + status_labels = {value: label for value, label, *_ in CorePluginStatusChoices.CHOICES} + entries = [] + + for plugin in CORE_PLUGINS: + local = local_plugins.get(plugin.config_name) + latest_version = local.release_latest.version if local and local.release_latest else '' + + if local is not None and local.is_local: + status = CorePluginStatusChoices.STATUS_INSTALLED + installed_version = local.installed_version + elif not plugin.commercial or commercial_enabled: + status = CorePluginStatusChoices.STATUS_AVAILABLE + installed_version = '' + else: + status = CorePluginStatusChoices.STATUS_LOCKED + installed_version = '' + + entries.append({ + 'config_name': plugin.config_name, + 'title': plugin.title, + 'description': plugin.description, + 'mdi_icon': plugin.mdi_icon, + 'product_url': plugin.product_url, + 'status': status, + 'status_label': status_labels.get(status, status), + 'status_color': CorePluginStatusChoices.colors.get(status, 'gray'), + 'installed_version': installed_version, + 'latest_version': latest_version, + }) + + return entries + + +def get_core_plugin_names(): + """ + Return the set of config names of core plugins, for use in filtering them out + of the community catalog list. + """ + return {plugin.config_name for plugin in CORE_PLUGINS} diff --git a/netbox/core/tests/test_views.py b/netbox/core/tests/test_views.py index 40dc3b722..68368c219 100644 --- a/netbox/core/tests/test_views.py +++ b/netbox/core/tests/test_views.py @@ -2,8 +2,10 @@ import json import urllib.parse import uuid from datetime import datetime +from unittest.mock import patch from django.contrib.contenttypes.models import ContentType +from django.core.cache import cache from django.urls import reverse from django.utils import timezone from django_rq import get_queue @@ -13,8 +15,10 @@ from rq.job import Job as RQ_Job from rq.job import JobStatus from rq.registry import DeferredJobRegistry, FailedJobRegistry, FinishedJobRegistry, StartedJobRegistry -from core.choices import ObjectChangeActionChoices +from core.choices import CorePluginStatusChoices, ObjectChangeActionChoices +from core.core_plugins import CORE_PLUGINS, get_core_plugins from core.models import * +from core.plugins import Plugin from dcim.models import Site from users.models import User from utilities.testing import TestCase, ViewTestCases, create_tags, disable_logging @@ -440,3 +444,111 @@ class SystemTestCase(TestCase): # Test export response = self.client.get(f"{reverse('core:system')}?export=true") self.assertEqual(response.status_code, 200) + + +class PluginListViewTestCase(TestCase): + """ + Tests for the Core Plugins section rendered on the plugins list page. + """ + + def setUp(self): + super().setUp() + + self.user.is_superuser = True + self.user.save() + # The plugin catalog feed is cached process-wide; clear it so the patched + # catalog fetch is honored on every request. + cache.delete('plugins-catalog-feed') + cache.delete('plugins-catalog-error') + + def _set_commercial_features(self, enabled): + # settings.RELEASE is a dataclass loaded at startup; mutate the field in + # place and restore on teardown. + from django.conf import settings + original = settings.RELEASE.features.commercial + settings.RELEASE.features.commercial = enabled + self.addCleanup(setattr, settings.RELEASE.features, 'commercial', original) + + @patch('core.views.get_catalog_plugins', return_value={}) + def test_plugin_list_shows_core_section_locked_for_oss(self, _mock_catalog): + self._set_commercial_features(False) + + response = self.client.get(reverse('core:plugin_list')) + + self.assertEqual(response.status_code, 200) + core_plugins = response.context['core_plugins'] + self.assertEqual(len(core_plugins), len(CORE_PLUGINS)) + + # Commercial plugins should be Locked in OSS; non-commercial plugins should + # appear as Available. + status_by_name = {entry['config_name']: entry['status'] for entry in core_plugins} + for plugin in CORE_PLUGINS: + expected = ( + CorePluginStatusChoices.STATUS_LOCKED if plugin.commercial + else CorePluginStatusChoices.STATUS_AVAILABLE + ) + self.assertEqual(status_by_name[plugin.config_name], expected) + + # The Core Plugins heading and at least one product link should render. + self.assertContains(response, 'Core Plugins') + first_commercial = next(p for p in CORE_PLUGINS if p.commercial) + self.assertContains(response, first_commercial.product_url) + + @patch('core.views.get_catalog_plugins', return_value={}) + def test_plugin_list_shows_core_section_available_for_commercial(self, _mock_catalog): + self._set_commercial_features(True) + + response = self.client.get(reverse('core:plugin_list')) + + self.assertEqual(response.status_code, 200) + for entry in response.context['core_plugins']: + self.assertEqual(entry['status'], CorePluginStatusChoices.STATUS_AVAILABLE) + + def test_get_core_plugins_marks_installed(self): + # Unit-level test: simulate a locally-installed core plugin and confirm + # the helper reports it as installed with the recorded version. + target = CORE_PLUGINS[0] + local_plugins = { + target.config_name: Plugin( + config_name=target.config_name, + title_short=str(target.title), + title_long=str(target.title), + is_local=True, + is_loaded=True, + installed_version='1.2.3', + ), + } + + self._set_commercial_features(False) + entries = get_core_plugins(local_plugins) + installed = next(e for e in entries if e['config_name'] == target.config_name) + + self.assertEqual(installed['status'], CorePluginStatusChoices.STATUS_INSTALLED) + self.assertEqual(installed['installed_version'], '1.2.3') + + @patch('core.views.get_catalog_plugins') + def test_plugin_list_excludes_core_from_community_list(self, mock_catalog): + # Seed the "catalog" with one of the core plugins plus a community plugin, + # and verify only the community plugin reaches the catalog table. + target = CORE_PLUGINS[0] + mock_catalog.return_value = { + target.config_name: Plugin( + config_name=target.config_name, + title_short=str(target.title), + title_long=str(target.title), + ), + 'some_community_plugin': Plugin( + config_name='some_community_plugin', + title_short='Community Plugin', + title_long='Community Plugin', + ), + } + self._set_commercial_features(False) + + response = self.client.get(reverse('core:plugin_list')) + + self.assertEqual(response.status_code, 200) + table_rows = list(response.context['table'].rows) + row_names = {row.record.config_name for row in table_rows} + self.assertIn('some_community_plugin', row_names) + self.assertNotIn(target.config_name, row_names) diff --git a/netbox/core/views.py b/netbox/core/views.py index 760346b3d..36e22b962 100644 --- a/netbox/core/views.py +++ b/netbox/core/views.py @@ -23,6 +23,7 @@ from rq.job import JobStatus as RQJobStatus from rq.worker import Worker from rq.worker_registration import clean_worker_registry +from core.core_plugins import get_core_plugin_names, get_core_plugins from core.utils import ( delete_rq_job, enqueue_rq_job, @@ -848,11 +849,18 @@ class PluginListView(BasePluginView): def get(self, request): q = request.GET.get('q', None) - plugins = self.get_cached_plugins(request).values() + all_plugins = self.get_cached_plugins(request) + plugins = all_plugins.values() if q: plugins = [obj for obj in plugins if q.casefold() in obj.title_short.casefold()] - plugins = [plugin for plugin in plugins if not plugin.hidden] + # Exclude hidden plugins and any NetBox Labs core plugins (which are + # presented separately in the Core Plugins section). + core_names = get_core_plugin_names() + plugins = [ + plugin for plugin in plugins + if not plugin.hidden and plugin.config_name not in core_names + ] table = CatalogPluginTable(plugins) table.configure(request) @@ -865,6 +873,7 @@ class PluginListView(BasePluginView): return render(request, 'core/plugin_list.html', { 'table': table, + 'core_plugins': get_core_plugins(all_plugins), }) diff --git a/netbox/templates/core/plugin_list.html b/netbox/templates/core/plugin_list.html index 47a6c3686..3f68a9649 100644 --- a/netbox/templates/core/plugin_list.html +++ b/netbox/templates/core/plugin_list.html @@ -7,10 +7,73 @@ {% block title %}{% trans "Plugins" %}{% endblock %} {% block tabs %} -