Revamp plugin page to highlight core features
This commit is contained in:
parent
bcfeb762e8
commit
5655a63e81
|
|
@ -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'),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
})
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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 %}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Reference in New Issue