mirror of
https://github.com/netbox-community/netbox.git
synced 2026-04-15 21:49:56 +02:00
Compare commits
2 Commits
21866-sql
...
21782-conf
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2fde9db66e | ||
|
|
46396d7667 |
@@ -373,7 +373,6 @@ class SystemTestCase(TestCase):
|
|||||||
self.assertIn('plugins', data)
|
self.assertIn('plugins', data)
|
||||||
self.assertIn('config', data)
|
self.assertIn('config', data)
|
||||||
self.assertIn('objects', data)
|
self.assertIn('objects', data)
|
||||||
self.assertIn('db_schema', data)
|
|
||||||
|
|
||||||
def test_system_view_with_config_revision(self):
|
def test_system_view_with_config_revision(self):
|
||||||
ConfigRevision.objects.create()
|
ConfigRevision.objects.create()
|
||||||
|
|||||||
@@ -50,7 +50,6 @@ urlpatterns = (
|
|||||||
path('config-revisions/<int:pk>/', include(get_model_urls('core', 'configrevision'))),
|
path('config-revisions/<int:pk>/', include(get_model_urls('core', 'configrevision'))),
|
||||||
|
|
||||||
path('system/', views.SystemView.as_view(), name='system'),
|
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/', views.PluginListView.as_view(), name='plugin_list'),
|
||||||
path('plugins/<str:name>/', views.PluginView.as_view(), name='plugin'),
|
path('plugins/<str:name>/', views.PluginView.as_view(), name='plugin'),
|
||||||
|
|||||||
@@ -3,12 +3,11 @@ import platform
|
|||||||
from copy import deepcopy
|
from copy import deepcopy
|
||||||
|
|
||||||
from django import __version__ as django_version
|
from django import __version__ as django_version
|
||||||
from django.apps import apps as django_apps_registry
|
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.contrib import messages
|
from django.contrib import messages
|
||||||
from django.contrib.auth.mixins import UserPassesTestMixin
|
from django.contrib.auth.mixins import UserPassesTestMixin
|
||||||
from django.core.cache import cache
|
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.http import Http404, HttpResponse, HttpResponseForbidden
|
||||||
from django.shortcuts import get_object_or_404, redirect, render
|
from django.shortcuts import get_object_or_404, redirect, render
|
||||||
from django.urls import reverse
|
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 extras.ui.panels import CustomFieldsPanel, TagsPanel
|
||||||
from netbox.config import PARAMS, get_config
|
from netbox.config import PARAMS, get_config
|
||||||
from netbox.object_actions import AddObject, BulkDelete, BulkExport, DeleteObject
|
from netbox.object_actions import AddObject, BulkDelete, BulkExport, DeleteObject
|
||||||
from netbox.plugins import PluginConfig
|
|
||||||
from netbox.plugins.utils import get_installed_plugins
|
from netbox.plugins.utils import get_installed_plugins
|
||||||
from netbox.ui import layout
|
from netbox.ui import layout
|
||||||
from netbox.ui.panels import (
|
from netbox.ui.panels import (
|
||||||
@@ -658,90 +656,14 @@ class WorkerView(BaseRQView):
|
|||||||
# System
|
# 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):
|
class SystemView(UserPassesTestMixin, View):
|
||||||
|
|
||||||
def test_func(self):
|
def test_func(self):
|
||||||
return self.request.user.is_superuser
|
return self.request.user.is_superuser
|
||||||
|
|
||||||
def _get_stats(self):
|
def get(self, request):
|
||||||
|
|
||||||
|
# System status
|
||||||
psql_version = db_name = db_size = None
|
psql_version = db_name = db_size = None
|
||||||
try:
|
try:
|
||||||
with connection.cursor() as cursor:
|
with connection.cursor() as cursor:
|
||||||
@@ -750,11 +672,11 @@ class SystemView(UserPassesTestMixin, View):
|
|||||||
psql_version = psql_version.split('(')[0].strip()
|
psql_version = psql_version.split('(')[0].strip()
|
||||||
cursor.execute("SELECT current_database()")
|
cursor.execute("SELECT current_database()")
|
||||||
db_name = cursor.fetchone()[0]
|
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]
|
db_size = cursor.fetchone()[0]
|
||||||
except (DatabaseError, IndexError):
|
except (ProgrammingError, IndexError):
|
||||||
pass
|
pass
|
||||||
return {
|
stats = {
|
||||||
'netbox_release': settings.RELEASE,
|
'netbox_release': settings.RELEASE,
|
||||||
'django_version': django_version,
|
'django_version': django_version,
|
||||||
'python_version': platform.python_version(),
|
'python_version': platform.python_version(),
|
||||||
@@ -764,23 +686,23 @@ class SystemView(UserPassesTestMixin, View):
|
|||||||
'rq_worker_count': Worker.count(get_connection('default')),
|
'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 = {}
|
objects = {}
|
||||||
for ot in ObjectType.objects.public().order_by('app_label', 'model'):
|
for ot in ObjectType.objects.public().order_by('app_label', 'model'):
|
||||||
if model := ot.model_class():
|
if model := ot.model_class():
|
||||||
objects[ot] = model.objects.count()
|
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
|
# Raw data export
|
||||||
if 'export' in request.GET:
|
if 'export' in request.GET:
|
||||||
db_schema = get_db_schema()
|
|
||||||
stats['netbox_release'] = stats['netbox_release'].asdict()
|
stats['netbox_release'] = stats['netbox_release'].asdict()
|
||||||
params = [param.name for param in PARAMS]
|
params = [param.name for param in PARAMS]
|
||||||
data = {
|
data = {
|
||||||
@@ -793,12 +715,6 @@ class SystemView(UserPassesTestMixin, View):
|
|||||||
'objects': {
|
'objects': {
|
||||||
f'{ot.app_label}.{ot.model}': count for ot, count in objects.items()
|
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 = HttpResponse(json.dumps(data, cls=ConfigJSONEncoder, indent=4), content_type='text/json')
|
||||||
response['Content-Disposition'] = 'attachment; filename="netbox.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
|
# Plugins
|
||||||
#
|
#
|
||||||
|
|||||||
@@ -1633,6 +1633,32 @@ class DeviceTest(APIViewTestCases.APIViewTestCase):
|
|||||||
response = self.client.post(url, {}, format='json', HTTP_AUTHORIZATION=token_header)
|
response = self.client.post(url, {}, format='json', HTTP_AUTHORIZATION=token_header)
|
||||||
self.assertHttpStatus(response, status.HTTP_200_OK)
|
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):
|
class ModuleTest(APIViewTestCases.APIViewTestCase):
|
||||||
model = Module
|
model = Module
|
||||||
|
|||||||
@@ -2362,6 +2362,32 @@ class DeviceTestCase(ViewTestCases.PrimaryObjectViewTestCase):
|
|||||||
self.remove_permissions('dcim.view_device')
|
self.remove_permissions('dcim.view_device')
|
||||||
self.assertHttpStatus(self.client.get(url), 403)
|
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):
|
def test_device_role_display_colored(self):
|
||||||
parent_role = DeviceRole.objects.create(name='Parent Role', slug='parent-role', color='111111')
|
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')
|
child_role = DeviceRole.objects.create(name='Child Role', slug='child-role', parent=parent_role, color='aa00bb')
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ from rest_framework.renderers import JSONRenderer
|
|||||||
from rest_framework.response import Response
|
from rest_framework.response import Response
|
||||||
from rest_framework.status import HTTP_400_BAD_REQUEST
|
from rest_framework.status import HTTP_400_BAD_REQUEST
|
||||||
|
|
||||||
|
from extras.models import ConfigTemplate
|
||||||
from netbox.api.authentication import TokenWritePermission
|
from netbox.api.authentication import TokenWritePermission
|
||||||
from netbox.api.renderers import TextRenderer
|
from netbox.api.renderers import TextRenderer
|
||||||
|
|
||||||
@@ -85,15 +86,25 @@ class RenderConfigMixin(ConfigTemplateRenderMixin):
|
|||||||
instance = self.get_object()
|
instance = self.get_object()
|
||||||
|
|
||||||
object_type = instance._meta.model_name
|
object_type = instance._meta.model_name
|
||||||
configtemplate = instance.get_config_template()
|
|
||||||
if not configtemplate:
|
# Check for an optional config_template_id override in the request data
|
||||||
return Response({
|
if config_template_id := request.data.get('config_template_id'):
|
||||||
'error': f'No config template found for this {object_type}.'
|
try:
|
||||||
}, status=HTTP_400_BAD_REQUEST)
|
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
|
# Compile context data
|
||||||
context_data = instance.get_config_context()
|
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})
|
context_data.update({object_type: instance})
|
||||||
|
|
||||||
return self.render_configtemplate(request, configtemplate, context_data)
|
return self.render_configtemplate(request, configtemplate, context_data)
|
||||||
|
|||||||
@@ -1268,10 +1268,20 @@ class ObjectRenderConfigView(generic.ObjectView):
|
|||||||
context_data = instance.get_config_context()
|
context_data = instance.get_config_context()
|
||||||
context_data.update(self.get_extra_context_data(request, instance))
|
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
|
# Render the config template
|
||||||
rendered_config = None
|
rendered_config = None
|
||||||
error_message = ''
|
if config_template and not error_message:
|
||||||
if config_template := instance.get_config_template():
|
|
||||||
try:
|
try:
|
||||||
rendered_config = config_template.render(context=context_data)
|
rendered_config = config_template.render(context=context_data)
|
||||||
except TemplateError as e:
|
except TemplateError as e:
|
||||||
|
|||||||
@@ -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 %}
|
|
||||||
@@ -3,7 +3,6 @@
|
|||||||
{% load helpers %}
|
{% load helpers %}
|
||||||
{% load i18n %}
|
{% load i18n %}
|
||||||
{% load render_table from django_tables2 %}
|
{% load render_table from django_tables2 %}
|
||||||
{% load humanize %}
|
|
||||||
|
|
||||||
{% block title %}{% trans "System" %}{% endblock %}
|
{% block title %}{% trans "System" %}{% endblock %}
|
||||||
|
|
||||||
@@ -35,11 +34,6 @@
|
|||||||
{% trans "Object Counts" %}
|
{% trans "Object Counts" %}
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</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>
|
</ul>
|
||||||
{% endblock tabs %}
|
{% endblock tabs %}
|
||||||
|
|
||||||
@@ -179,14 +173,4 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</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 %}
|
{% endblock content %}
|
||||||
|
|||||||
@@ -49,13 +49,18 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<div class="col">
|
<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 %}
|
{% if rendered_config %}
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<h2 class="card-header d-flex justify-content-between">
|
<h2 class="card-header d-flex justify-content-between">
|
||||||
{% trans "Rendered Config" %}
|
{% trans "Rendered Config" %}
|
||||||
<div class="card-actions">
|
<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" %}
|
<i class="mdi mdi-download" aria-hidden="true"></i> {% trans "Download" %}
|
||||||
</a>
|
</a>
|
||||||
{% copy_content "rendered_config" %}
|
{% copy_content "rendered_config" %}
|
||||||
@@ -63,11 +68,6 @@
|
|||||||
</h2>
|
</h2>
|
||||||
<pre class="card-body" id="rendered_config">{{ rendered_config }}</pre>
|
<pre class="card-body" id="rendered_config">{{ rendered_config }}</pre>
|
||||||
</div>
|
</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 %}
|
{% else %}
|
||||||
<div class="alert alert-warning">
|
<div class="alert alert-warning">
|
||||||
<h4 class="alert-title mb-1">{% trans "Template output is empty" %}</h4>
|
<h4 class="alert-title mb-1">{% trans "Template output is empty" %}</h4>
|
||||||
|
|||||||
@@ -343,6 +343,34 @@ class VirtualMachineTest(APIViewTestCases.APIViewTestCase):
|
|||||||
response = self.client.post(url, {}, format='json', HTTP_AUTHORIZATION=token_header)
|
response = self.client.post(url, {}, format='json', HTTP_AUTHORIZATION=token_header)
|
||||||
self.assertHttpStatus(response, status.HTTP_200_OK)
|
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):
|
class VMInterfaceTest(APIViewTestCases.APIViewTestCase):
|
||||||
model = VMInterface
|
model = VMInterface
|
||||||
|
|||||||
Reference in New Issue
Block a user