Compare commits

..

2 Commits

Author SHA1 Message Date
Arthur
2fde9db66e #21782 - Enable optional config template selection on Device 2026-04-13 15:41:42 -07:00
Arthur
46396d7667 #21782 - Enable optional config template selection on Device 2026-04-13 15:41:34 -07:00
11 changed files with 133 additions and 282 deletions

View File

@@ -373,7 +373,6 @@ class SystemTestCase(TestCase):
self.assertIn('plugins', data)
self.assertIn('config', data)
self.assertIn('objects', data)
self.assertIn('db_schema', data)
def test_system_view_with_config_revision(self):
ConfigRevision.objects.create()

View File

@@ -50,7 +50,6 @@ urlpatterns = (
path('config-revisions/<int:pk>/', include(get_model_urls('core', 'configrevision'))),
path('system/', views.SystemView.as_view(), name='system'),
path('system/db-schema/', views.SystemDBSchemaView.as_view(), name='system_db_schema'),
path('plugins/', views.PluginListView.as_view(), name='plugin_list'),
path('plugins/<str:name>/', views.PluginView.as_view(), name='plugin'),

View File

@@ -3,12 +3,11 @@ import platform
from copy import deepcopy
from django import __version__ as django_version
from django.apps import apps as django_apps_registry
from django.conf import settings
from django.contrib import messages
from django.contrib.auth.mixins import UserPassesTestMixin
from django.core.cache import cache
from django.db import DatabaseError, connection
from django.db import ProgrammingError, connection
from django.http import Http404, HttpResponse, HttpResponseForbidden
from django.shortcuts import get_object_or_404, redirect, render
from django.urls import reverse
@@ -27,7 +26,6 @@ from core.utils import delete_rq_job, enqueue_rq_job, get_rq_jobs_from_status, r
from extras.ui.panels import CustomFieldsPanel, TagsPanel
from netbox.config import PARAMS, get_config
from netbox.object_actions import AddObject, BulkDelete, BulkExport, DeleteObject
from netbox.plugins import PluginConfig
from netbox.plugins.utils import get_installed_plugins
from netbox.ui import layout
from netbox.ui.panels import (
@@ -658,90 +656,14 @@ class WorkerView(BaseRQView):
# System
#
def get_db_schema():
db_schema = []
try:
with connection.cursor() as cursor:
cursor.execute("""
SELECT table_name, column_name, data_type, is_nullable, column_default
FROM information_schema.columns
WHERE table_schema = current_schema()
ORDER BY table_name, ordinal_position
""")
columns_by_table = {}
for table_name, column_name, data_type, is_nullable, column_default in cursor.fetchall():
columns_by_table.setdefault(table_name, []).append({
'name': column_name,
'type': data_type,
'nullable': is_nullable == 'YES',
'default': column_default,
})
cursor.execute("""
SELECT tablename, indexname, indexdef
FROM pg_indexes
WHERE schemaname = current_schema()
ORDER BY tablename, indexname
""")
indexes_by_table = {}
for table_name, index_name, index_def in cursor.fetchall():
indexes_by_table.setdefault(table_name, []).append({
'name': index_name,
'definition': index_def,
})
for table_name in sorted(columns_by_table.keys()):
db_schema.append({
'name': table_name,
'columns': columns_by_table[table_name],
'indexes': indexes_by_table.get(table_name, []),
})
except DatabaseError:
pass
return db_schema
def get_db_schema_groups(db_schema):
plugin_app_labels = {
app_config.label
for app_config in django_apps_registry.get_app_configs()
if isinstance(app_config, PluginConfig)
}
# Sort longest-first so "netbox_branching" matches before "netbox"
sorted_plugin_labels = sorted(plugin_app_labels, key=len, reverse=True)
groups = {}
for table in db_schema:
matched_plugin = next(
(label for label in sorted_plugin_labels if table['name'].startswith(label + '_')),
None,
)
if matched_plugin:
prefix = matched_plugin
elif '_' in table['name']:
prefix = table['name'].split('_')[0]
else:
prefix = 'other'
groups.setdefault(prefix, []).append(table)
return sorted(
[
{
'name': name,
'tables': tables,
'index_count': sum(len(t['indexes']) for t in tables),
'is_plugin': name in plugin_app_labels,
}
for name, tables in groups.items()
],
key=lambda g: (g['is_plugin'], g['name']),
)
class SystemView(UserPassesTestMixin, View):
def test_func(self):
return self.request.user.is_superuser
def _get_stats(self):
def get(self, request):
# System status
psql_version = db_name = db_size = None
try:
with connection.cursor() as cursor:
@@ -750,11 +672,11 @@ class SystemView(UserPassesTestMixin, View):
psql_version = psql_version.split('(')[0].strip()
cursor.execute("SELECT current_database()")
db_name = cursor.fetchone()[0]
cursor.execute("SELECT pg_size_pretty(pg_database_size(current_database()))")
cursor.execute(f"SELECT pg_size_pretty(pg_database_size('{db_name}'))")
db_size = cursor.fetchone()[0]
except (DatabaseError, IndexError):
except (ProgrammingError, IndexError):
pass
return {
stats = {
'netbox_release': settings.RELEASE,
'django_version': django_version,
'python_version': platform.python_version(),
@@ -764,23 +686,23 @@ class SystemView(UserPassesTestMixin, View):
'rq_worker_count': Worker.count(get_connection('default')),
}
def _get_object_counts(self):
# Django apps
django_apps = get_installed_apps()
# Configuration
config = get_config()
# Plugins
plugins = get_installed_plugins()
# Object counts
objects = {}
for ot in ObjectType.objects.public().order_by('app_label', 'model'):
if model := ot.model_class():
objects[ot] = model.objects.count()
return objects
def get(self, request):
stats = self._get_stats()
django_apps = get_installed_apps()
config = get_config()
plugins = get_installed_plugins()
objects = self._get_object_counts()
# Raw data export
if 'export' in request.GET:
db_schema = get_db_schema()
stats['netbox_release'] = stats['netbox_release'].asdict()
params = [param.name for param in PARAMS]
data = {
@@ -793,12 +715,6 @@ class SystemView(UserPassesTestMixin, View):
'objects': {
f'{ot.app_label}.{ot.model}': count for ot, count in objects.items()
},
'db_schema': {
table['name']: {
'columns': table['columns'],
'indexes': table['indexes'],
} for table in db_schema
},
}
response = HttpResponse(json.dumps(data, cls=ConfigJSONEncoder, indent=4), content_type='text/json')
response['Content-Disposition'] = 'attachment; filename="netbox.json"'
@@ -818,26 +734,6 @@ class SystemView(UserPassesTestMixin, View):
})
class SystemDBSchemaView(UserPassesTestMixin, View):
def test_func(self):
return self.request.user.is_superuser
def get(self, request):
db_schema = get_db_schema()
db_schema_groups = get_db_schema_groups(db_schema)
db_schema_stats = {
'total_tables': len(db_schema),
'total_columns': sum(len(t['columns']) for t in db_schema),
'total_indexes': sum(len(t['indexes']) for t in db_schema),
}
return render(request, 'core/htmx/system_db_schema.html', {
'db_schema': db_schema,
'db_schema_groups': db_schema_groups,
'db_schema_stats': db_schema_stats,
})
#
# Plugins
#

View File

@@ -1633,6 +1633,32 @@ class DeviceTest(APIViewTestCases.APIViewTestCase):
response = self.client.post(url, {}, format='json', HTTP_AUTHORIZATION=token_header)
self.assertHttpStatus(response, status.HTTP_200_OK)
def test_render_config_with_config_template_id(self):
default_template = ConfigTemplate.objects.create(
name='Default Template',
template_code='Default config for {{ device.name }}'
)
override_template = ConfigTemplate.objects.create(
name='Override Template',
template_code='Override config for {{ device.name }}'
)
device = Device.objects.first()
device.config_template = default_template
device.save()
self.add_permissions('dcim.render_config_device', 'dcim.view_device')
url = reverse('dcim-api:device-render-config', kwargs={'pk': device.pk})
# Render with override template
response = self.client.post(url, {'config_template_id': override_template.pk}, format='json', **self.header)
self.assertHttpStatus(response, status.HTTP_200_OK)
self.assertEqual(response.data['content'], f'Override config for {device.name}')
# Render with invalid config_template_id
response = self.client.post(url, {'config_template_id': 999999}, format='json', **self.header)
self.assertHttpStatus(response, status.HTTP_400_BAD_REQUEST)
class ModuleTest(APIViewTestCases.APIViewTestCase):
model = Module

View File

@@ -2362,6 +2362,32 @@ class DeviceTestCase(ViewTestCases.PrimaryObjectViewTestCase):
self.remove_permissions('dcim.view_device')
self.assertHttpStatus(self.client.get(url), 403)
def test_device_renderconfig_with_config_template_id(self):
default_template = ConfigTemplate.objects.create(
name='Default Template',
template_code='Default config for {{ device.name }}'
)
override_template = ConfigTemplate.objects.create(
name='Override Template',
template_code='Override config for {{ device.name }}'
)
device = Device.objects.first()
device.config_template = default_template
device.save()
self.add_permissions('dcim.view_device', 'dcim.render_config_device')
url = reverse('dcim:device_render-config', kwargs={'pk': device.pk})
# Render with override config_template_id
response = self.client.get(url, {'config_template_id': override_template.pk})
self.assertHttpStatus(response, 200)
self.assertIn(b'Override config for', response.content)
# Render with invalid config_template_id still returns 200 with error message
response = self.client.get(url, {'config_template_id': 999999})
self.assertHttpStatus(response, 200)
self.assertIn(b'Error rendering template', response.content)
def test_device_role_display_colored(self):
parent_role = DeviceRole.objects.create(name='Parent Role', slug='parent-role', color='111111')
child_role = DeviceRole.objects.create(name='Child Role', slug='child-role', parent=parent_role, color='aa00bb')

View File

@@ -4,6 +4,7 @@ from rest_framework.renderers import JSONRenderer
from rest_framework.response import Response
from rest_framework.status import HTTP_400_BAD_REQUEST
from extras.models import ConfigTemplate
from netbox.api.authentication import TokenWritePermission
from netbox.api.renderers import TextRenderer
@@ -85,15 +86,25 @@ class RenderConfigMixin(ConfigTemplateRenderMixin):
instance = self.get_object()
object_type = instance._meta.model_name
configtemplate = instance.get_config_template()
if not configtemplate:
return Response({
'error': f'No config template found for this {object_type}.'
}, status=HTTP_400_BAD_REQUEST)
# Check for an optional config_template_id override in the request data
if config_template_id := request.data.get('config_template_id'):
try:
configtemplate = ConfigTemplate.objects.get(pk=config_template_id)
except ConfigTemplate.DoesNotExist:
return Response({
'error': f'Config template with ID {config_template_id} not found.'
}, status=HTTP_400_BAD_REQUEST)
else:
configtemplate = instance.get_config_template()
if not configtemplate:
return Response({
'error': f'No config template found for this {object_type}.'
}, status=HTTP_400_BAD_REQUEST)
# Compile context data
context_data = instance.get_config_context()
context_data.update(request.data)
context_data.update({k: v for k, v in request.data.items() if k != 'config_template_id'})
context_data.update({object_type: instance})
return self.render_configtemplate(request, configtemplate, context_data)

View File

@@ -1268,10 +1268,20 @@ class ObjectRenderConfigView(generic.ObjectView):
context_data = instance.get_config_context()
context_data.update(self.get_extra_context_data(request, instance))
# Check for an optional config_template_id override in the query params
config_template = None
error_message = ''
if config_template_id := request.GET.get('config_template_id'):
try:
config_template = ConfigTemplate.objects.get(pk=config_template_id)
except (ConfigTemplate.DoesNotExist, ValueError):
error_message = _("Config template with ID {id} not found.").format(id=config_template_id)
else:
config_template = instance.get_config_template()
# Render the config template
rendered_config = None
error_message = ''
if config_template := instance.get_config_template():
if config_template and not error_message:
try:
rendered_config = config_template.render(context=context_data)
except TemplateError as e:

View File

@@ -1,128 +0,0 @@
{% load i18n %}
{% load humanize %}
{% if db_schema %}
{# Summary boxes #}
<div class="row mb-3">
<div class="col-md-4">
<div class="card text-center">
<div class="card-body">
<div class="display-6 fw-bold">{{ db_schema_stats.total_tables|intcomma }}</div>
<div class="text-muted">{% trans "Tables" %}</div>
</div>
</div>
</div>
<div class="col-md-4">
<div class="card text-center">
<div class="card-body">
<div class="display-6 fw-bold">{{ db_schema_stats.total_columns|intcomma }}</div>
<div class="text-muted">{% trans "Columns" %}</div>
</div>
</div>
</div>
<div class="col-md-4">
<div class="card text-center">
<div class="card-body">
<div class="display-6 fw-bold">{{ db_schema_stats.total_indexes|intcomma }}</div>
<div class="text-muted">{% trans "Indexes" %}</div>
</div>
</div>
</div>
</div>
{# Tables grouped by app prefix #}
{% for group in db_schema_groups %}
<div class="card mb-3">
<h2 class="card-header">
<button class="accordion-button collapsed p-0 w-100" type="button"
data-bs-toggle="collapse" data-bs-target="#db-group-body-{{ group.name }}"
aria-expanded="false" aria-controls="db-group-body-{{ group.name }}">
{{ group.name }}
{% if group.is_plugin %}<span class="badge text-bg-purple ms-1">{% trans "plugin" %}</span>{% endif %}
<span class="badge bg-secondary text-bg-gray ms-1">{{ group.tables|length }} {% trans "tables" %}</span>
<span class="badge bg-secondary text-bg-gray ms-1">{{ group.index_count }} {% trans "indexes" %}</span>
<span class="accordion-button-toggle"><i class="mdi mdi-chevron-down"></i></span>
</button>
</h2>
<div id="db-group-body-{{ group.name }}" class="collapse">
<div class="accordion accordion-flush" id="db-group-{{ group.name }}">
{% for table in group.tables %}
<div class="accordion-item">
<h5 class="accordion-header" id="table-heading-{{ group.name }}-{{ forloop.counter }}">
<button class="accordion-button border-bottom collapsed font-monospace" type="button"
data-bs-toggle="collapse" data-bs-target="#table-collapse-{{ group.name }}-{{ forloop.counter }}"
aria-expanded="false" aria-controls="table-collapse-{{ group.name }}-{{ forloop.counter }}">
{{ table.name }}
<span class="badge bg-secondary text-white ms-2">{{ table.columns|length }} {% trans "columns" %}</span>
{% if table.indexes %}
<span class="badge bg-secondary text-white ms-1">{{ table.indexes|length }} {% trans "indexes" %}</span>
{% endif %}
<span class="accordion-button-toggle"><i class="mdi mdi-chevron-down"></i></span>
</button>
</h5>
<div id="table-collapse-{{ group.name }}-{{ forloop.counter }}" class="accordion-collapse collapse"
aria-labelledby="table-heading-{{ group.name }}-{{ forloop.counter }}">
<div class="accordion-body p-0">
<div class="px-3 py-2">
<strong>{% trans "Columns" %}</strong>
<table class="table table-hover table-sm mb-0 mt-1">
<thead>
<tr>
<th>{% trans "Column" %}</th>
<th>{% trans "Type" %}</th>
<th>{% trans "Nullable" %}</th>
<th>{% trans "Default" %}</th>
</tr>
</thead>
<tbody>
{% for column in table.columns %}
<tr>
<td class="font-monospace">{{ column.name }}</td>
<td class="font-monospace text-muted">{{ column.type }}</td>
<td>
{% if column.nullable %}
<span class="text-success">{% trans "yes" %}</span>
{% else %}
<span class="text-muted">{% trans "no" %}</span>
{% endif %}
</td>
<td class="font-monospace text-muted">{{ column.default|default:"" }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% if table.indexes %}
<div class="px-3 py-2 border-top">
<strong>{% trans "Indexes" %}</strong>
<table class="table table-hover table-sm mb-0 mt-1">
<thead>
<tr>
<th>{% trans "Name" %}</th>
<th>{% trans "Definition" %}</th>
</tr>
</thead>
<tbody>
{% for index in table.indexes %}
<tr>
<td class="font-monospace">{{ index.name }}</td>
<td class="font-monospace text-muted">{{ index.definition }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% endif %}
</div>
</div>
</div>
{% endfor %}
</div>
</div>
</div>
{% endfor %}
{% else %}
<div class="card mb-3">
<div class="card-body text-muted">
{% trans "Schema information unavailable." %}
</div>
</div>
{% endif %}

View File

@@ -3,7 +3,6 @@
{% load helpers %}
{% load i18n %}
{% load render_table from django_tables2 %}
{% load humanize %}
{% block title %}{% trans "System" %}{% endblock %}
@@ -35,11 +34,6 @@
{% trans "Object Counts" %}
</a>
</li>
<li class="nav-item" role="presentation">
<a class="nav-link" id="database-tab" data-bs-toggle="tab" data-bs-target="#database-panel" type="button" role="tab">
{% trans "Database" %}
</a>
</li>
</ul>
{% endblock tabs %}
@@ -179,14 +173,4 @@
</div>
</div>
</div>
{# Database panel #}
<div class="tab-pane" id="database-panel" role="tabpanel" aria-labelledby="database-tab"
hx-get="{% url 'core:system_db_schema' %}"
hx-trigger="show.bs.tab from:#database-tab once"
hx-swap="innerHTML">
<div class="text-center text-muted py-5">
<div class="spinner-border spinner-border-sm me-2" role="status"></div>
{% trans "Loading database schema…" %}
</div>
</div>
{% endblock content %}

View File

@@ -49,13 +49,18 @@
</div>
<div class="row">
<div class="col">
{% if config_template %}
{% if error_message %}
<div class="alert alert-warning">
<h4 class="alert-title mb-1">{% trans "Error rendering template" %}</h4>
{% trans error_message %}
</div>
{% elif config_template %}
{% if rendered_config %}
<div class="card">
<h2 class="card-header d-flex justify-content-between">
{% trans "Rendered Config" %}
<div class="card-actions">
<a href="?export=True" class="btn btn-sm btn-ghost-primary" role="button">
<a href="?export=True{% if request.GET.config_template_id %}&config_template_id={{ request.GET.config_template_id }}{% endif %}" class="btn btn-sm btn-ghost-primary" role="button">
<i class="mdi mdi-download" aria-hidden="true"></i> {% trans "Download" %}
</a>
{% copy_content "rendered_config" %}
@@ -63,11 +68,6 @@
</h2>
<pre class="card-body" id="rendered_config">{{ rendered_config }}</pre>
</div>
{% elif error_message %}
<div class="alert alert-warning">
<h4 class="alert-title mb-1">{% trans "Error rendering template" %}</h4>
{% trans error_message %}
</div>
{% else %}
<div class="alert alert-warning">
<h4 class="alert-title mb-1">{% trans "Template output is empty" %}</h4>

View File

@@ -343,6 +343,34 @@ class VirtualMachineTest(APIViewTestCases.APIViewTestCase):
response = self.client.post(url, {}, format='json', HTTP_AUTHORIZATION=token_header)
self.assertHttpStatus(response, status.HTTP_200_OK)
def test_render_config_with_config_template_id(self):
default_template = ConfigTemplate.objects.create(
name='Default Template',
template_code='Default config for {{ virtualmachine.name }}'
)
override_template = ConfigTemplate.objects.create(
name='Override Template',
template_code='Override config for {{ virtualmachine.name }}'
)
vm = VirtualMachine.objects.first()
vm.config_template = default_template
vm.save()
self.add_permissions(
'virtualization.render_config_virtualmachine', 'virtualization.view_virtualmachine'
)
url = reverse('virtualization-api:virtualmachine-render-config', kwargs={'pk': vm.pk})
# Render with override template
response = self.client.post(url, {'config_template_id': override_template.pk}, format='json', **self.header)
self.assertHttpStatus(response, status.HTTP_200_OK)
self.assertEqual(response.data['content'], f'Override config for {vm.name}')
# Render with invalid config_template_id
response = self.client.post(url, {'config_template_id': 999999}, format='json', **self.header)
self.assertHttpStatus(response, status.HTTP_400_BAD_REQUEST)
class VMInterfaceTest(APIViewTestCases.APIViewTestCase):
model = VMInterface