mirror of
https://github.com/netbox-community/netbox.git
synced 2026-04-15 05:29:54 +02:00
Compare commits
9 Commits
21782-conf
...
21866-sql
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1e73f5d66a | ||
|
|
ac99c4f32c | ||
|
|
5a991a7d56 | ||
|
|
e002971b60 | ||
|
|
86844c1793 | ||
|
|
2f5300d6cf | ||
|
|
6187b8d344 | ||
|
|
38017d8bd4 | ||
|
|
8eff162b07 |
@@ -373,6 +373,7 @@ 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()
|
||||
|
||||
@@ -50,6 +50,7 @@ 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'),
|
||||
|
||||
@@ -3,11 +3,12 @@ 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 ProgrammingError, connection
|
||||
from django.db import DatabaseError, connection
|
||||
from django.http import Http404, HttpResponse, HttpResponseForbidden
|
||||
from django.shortcuts import get_object_or_404, redirect, render
|
||||
from django.urls import reverse
|
||||
@@ -26,6 +27,7 @@ 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 (
|
||||
@@ -656,14 +658,90 @@ 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(self, request):
|
||||
|
||||
# System status
|
||||
def _get_stats(self):
|
||||
psql_version = db_name = db_size = None
|
||||
try:
|
||||
with connection.cursor() as cursor:
|
||||
@@ -672,11 +750,11 @@ class SystemView(UserPassesTestMixin, View):
|
||||
psql_version = psql_version.split('(')[0].strip()
|
||||
cursor.execute("SELECT current_database()")
|
||||
db_name = cursor.fetchone()[0]
|
||||
cursor.execute(f"SELECT pg_size_pretty(pg_database_size('{db_name}'))")
|
||||
cursor.execute("SELECT pg_size_pretty(pg_database_size(current_database()))")
|
||||
db_size = cursor.fetchone()[0]
|
||||
except (ProgrammingError, IndexError):
|
||||
except (DatabaseError, IndexError):
|
||||
pass
|
||||
stats = {
|
||||
return {
|
||||
'netbox_release': settings.RELEASE,
|
||||
'django_version': django_version,
|
||||
'python_version': platform.python_version(),
|
||||
@@ -686,23 +764,23 @@ class SystemView(UserPassesTestMixin, View):
|
||||
'rq_worker_count': Worker.count(get_connection('default')),
|
||||
}
|
||||
|
||||
# Django apps
|
||||
django_apps = get_installed_apps()
|
||||
|
||||
# Configuration
|
||||
config = get_config()
|
||||
|
||||
# Plugins
|
||||
plugins = get_installed_plugins()
|
||||
|
||||
# Object counts
|
||||
def _get_object_counts(self):
|
||||
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 = {
|
||||
@@ -715,6 +793,12 @@ 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"'
|
||||
@@ -734,6 +818,26 @@ 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
|
||||
#
|
||||
|
||||
@@ -1633,32 +1633,6 @@ 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
|
||||
|
||||
@@ -2362,32 +2362,6 @@ 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')
|
||||
|
||||
@@ -4,7 +4,6 @@ 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
|
||||
|
||||
@@ -86,25 +85,15 @@ class RenderConfigMixin(ConfigTemplateRenderMixin):
|
||||
instance = self.get_object()
|
||||
|
||||
object_type = instance._meta.model_name
|
||||
|
||||
# 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)
|
||||
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({k: v for k, v in request.data.items() if k != 'config_template_id'})
|
||||
context_data.update(request.data)
|
||||
context_data.update({object_type: instance})
|
||||
|
||||
return self.render_configtemplate(request, configtemplate, context_data)
|
||||
|
||||
@@ -1268,20 +1268,10 @@ 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
|
||||
if config_template and not error_message:
|
||||
error_message = ''
|
||||
if config_template := instance.get_config_template():
|
||||
try:
|
||||
rendered_config = config_template.render(context=context_data)
|
||||
except TemplateError as e:
|
||||
|
||||
128
netbox/templates/core/htmx/system_db_schema.html
Normal file
128
netbox/templates/core/htmx/system_db_schema.html
Normal file
@@ -0,0 +1,128 @@
|
||||
{% 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,6 +3,7 @@
|
||||
{% load helpers %}
|
||||
{% load i18n %}
|
||||
{% load render_table from django_tables2 %}
|
||||
{% load humanize %}
|
||||
|
||||
{% block title %}{% trans "System" %}{% endblock %}
|
||||
|
||||
@@ -34,6 +35,11 @@
|
||||
{% 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 %}
|
||||
|
||||
@@ -173,4 +179,14 @@
|
||||
</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 %}
|
||||
|
||||
@@ -49,18 +49,13 @@
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col">
|
||||
{% 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 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{% if request.GET.config_template_id %}&config_template_id={{ request.GET.config_template_id }}{% endif %}" class="btn btn-sm btn-ghost-primary" role="button">
|
||||
<a href="?export=True" 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" %}
|
||||
@@ -68,6 +63,11 @@
|
||||
</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>
|
||||
|
||||
@@ -343,34 +343,6 @@ 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
|
||||
|
||||
Reference in New Issue
Block a user