Revamp plugin page to highlight core features

This commit is contained in:
Jeremy Stretch 2026-05-20 08:33:38 -04:00
parent bcfeb762e8
commit 5655a63e81
6 changed files with 339 additions and 5 deletions

View File

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

129
netbox/core/core_plugins.py Normal file
View File

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

View File

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

View File

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

View File

@ -7,10 +7,73 @@
{% block title %}{% trans "Plugins" %}{% endblock %}
{% block tabs %}
<ul class="nav nav-tabs px-3">
<ul class="nav nav-tabs px-3" role="tablist">
<li class="nav-item" role="presentation">
<a class="nav-link active" role="tab">{% trans "Plugins" %}</a>
<button class="nav-link {% if not request.GET.q %}active{% endif %}" id="core-plugins-tab" data-bs-toggle="tab" data-bs-target="#core-plugins" type="button" role="tab" aria-controls="core-plugins" aria-selected="{% if not request.GET.q %}true{% else %}false{% endif %}">
{% trans "Core Plugins" %}
</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link {% if request.GET.q %}active{% endif %}" id="community-plugins-tab" data-bs-toggle="tab" data-bs-target="#community-plugins" type="button" role="tab" aria-controls="community-plugins" aria-selected="{% if request.GET.q %}true{% else %}false{% endif %}">
{% trans "Community Plugins" %}
</button>
</li>
</ul>
{% endblock tabs %}
{% block content %}
{# Core Plugins tab #}
<div class="tab-pane {% if not request.GET.q %}show active{% endif %}" id="core-plugins" role="tabpanel" aria-labelledby="core-plugins-tab">
<div class="row row-cols-1 row-cols-md-2 row-cols-lg-3 g-3">
{% for plugin in core_plugins %}
<div class="col">
<div class="card h-100">
<div class="card-body d-flex flex-column">
<div class="d-flex align-items-start mb-2">
<i class="mdi {{ plugin.mdi_icon }} fs-1 me-3 text-secondary" aria-hidden="true"></i>
<div class="flex-grow-1">
<h3 class="h5 mb-1">
{% if plugin.status == 'installed' %}
<a href="{% url 'core:plugin' plugin.config_name %}">{{ plugin.title }}</a>
{% else %}
{{ plugin.title }}
{% endif %}
{% if plugin.status == 'locked' %}
<i class="mdi mdi-lock text-secondary ms-1" aria-label="{% trans 'Locked' %}" title="{% trans 'Locked' %}"></i>
{% endif %}
</h3>
{% if plugin.status != 'locked' %}
<span class="badge text-bg-{{ plugin.status_color }}">{{ plugin.status_label }}</span>
{% if plugin.status == 'installed' and plugin.installed_version %}
<span class="text-secondary small ms-1">v{{ plugin.installed_version }}</span>
{% elif plugin.status == 'available' and plugin.latest_version %}
<span class="text-secondary small ms-1">v{{ plugin.latest_version }}</span>
{% endif %}
{% endif %}
</div>
</div>
<p class="text-secondary small mb-2">{{ plugin.description }}</p>
{% if plugin.status == 'locked' %}
<div class="mt-auto text-end">
<a href="{{ plugin.product_url }}" target="_blank" rel="noopener" class="small">
{% trans "Learn more" %} <i class="mdi mdi-open-in-new"></i>
</a>
</div>
{% endif %}
</div>
</div>
</div>
{% endfor %}
</div>
</div>
{# Community Plugins tab #}
<div class="tab-pane {% if request.GET.q %}show active{% endif %}" id="community-plugins" role="tabpanel" aria-labelledby="community-plugins-tab">
{% include 'inc/table_controls_htmx.html' with table_modal="ObjectTable_config" %}
<div class="card">
<div class="htmx-container table-responsive" id="object_list">
{% include 'htmx/table.html' %}
</div>
</div>
</div>
{% endblock content %}

View File

@ -76,4 +76,8 @@ def load_release_data():
if 'published' in data:
data['published'] = datetime_from_timestamp(data['published'])
# Expand a nested features mapping into a FeatureSet instance
if isinstance(data.get('features'), dict):
data['features'] = FeatureSet(**data['features'])
return ReleaseInfo(**data)