Prechádzať zdrojové kódy

Revamp plugin page to highlight core features

Jeremy Stretch 1 mesiac pred
rodič
commit
5655a63e81

+ 17 - 0
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'),
+    )

+ 129 - 0
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}

+ 113 - 1
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)

+ 11 - 2
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),
         })
 
 

+ 65 - 2
netbox/templates/core/plugin_list.html

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

+ 4 - 0
netbox/utilities/release.py

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