mirror of
https://github.com/netbox-community/netbox.git
synced 2026-03-12 21:35:51 +01:00
Compare commits
3 Commits
feature
...
19953-conf
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
07238e9f9c | ||
|
|
28ac5b6201 | ||
|
|
311433757d |
@@ -1,8 +1,7 @@
|
||||
from jinja2.exceptions import TemplateError
|
||||
from rest_framework.decorators import action
|
||||
from rest_framework.renderers import JSONRenderer
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.status import HTTP_400_BAD_REQUEST
|
||||
from rest_framework.status import HTTP_400_BAD_REQUEST, HTTP_500_INTERNAL_SERVER_ERROR
|
||||
|
||||
from netbox.api.authentication import TokenWritePermission
|
||||
from netbox.api.renderers import TextRenderer
|
||||
@@ -45,10 +44,11 @@ class ConfigTemplateRenderMixin:
|
||||
def render_configtemplate(self, request, configtemplate, context):
|
||||
try:
|
||||
output = configtemplate.render(context=context)
|
||||
except TemplateError as e:
|
||||
return Response({
|
||||
'detail': f"An error occurred while rendering the template (line {e.lineno}): {e}"
|
||||
}, status=500)
|
||||
except Exception as e:
|
||||
return Response(
|
||||
{'detail': configtemplate.format_render_error(e)},
|
||||
status=HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
)
|
||||
|
||||
# If the client has requested "text/plain", return the raw content.
|
||||
if request.accepted_renderer.format == 'txt':
|
||||
|
||||
@@ -28,7 +28,7 @@ class ConfigTemplateSerializer(
|
||||
model = ConfigTemplate
|
||||
fields = [
|
||||
'id', 'url', 'display_url', 'display', 'name', 'description', 'environment_params', 'template_code',
|
||||
'mime_type', 'file_name', 'file_extension', 'as_attachment', 'data_source', 'data_path', 'data_file',
|
||||
'auto_sync_enabled', 'data_synced', 'owner', 'tags', 'created', 'last_updated',
|
||||
'mime_type', 'file_name', 'file_extension', 'as_attachment', 'debug', 'data_source', 'data_path',
|
||||
'data_file', 'auto_sync_enabled', 'data_synced', 'owner', 'tags', 'created', 'last_updated',
|
||||
]
|
||||
brief_fields = ('id', 'url', 'display', 'name', 'description')
|
||||
|
||||
@@ -857,7 +857,7 @@ class ConfigTemplateFilterSet(OwnerFilterMixin, ChangeLoggedModelFilterSet):
|
||||
class Meta:
|
||||
model = ConfigTemplate
|
||||
fields = (
|
||||
'id', 'name', 'description', 'mime_type', 'file_name', 'file_extension', 'as_attachment',
|
||||
'id', 'name', 'description', 'mime_type', 'file_name', 'file_extension', 'as_attachment', 'debug',
|
||||
'auto_sync_enabled', 'data_synced'
|
||||
)
|
||||
|
||||
|
||||
@@ -392,6 +392,11 @@ class ConfigTemplateBulkEditForm(ChangelogMessageMixin, OwnerMixin, BulkEditForm
|
||||
required=False,
|
||||
widget=BulkEditNullBooleanSelect()
|
||||
)
|
||||
debug = forms.NullBooleanField(
|
||||
label=_('Debug'),
|
||||
required=False,
|
||||
widget=BulkEditNullBooleanSelect()
|
||||
)
|
||||
auto_sync_enabled = forms.NullBooleanField(
|
||||
label=_('Auto sync enabled'),
|
||||
required=False,
|
||||
|
||||
@@ -190,7 +190,8 @@ class ConfigTemplateImportForm(OwnerCSVMixin, CSVModelForm):
|
||||
model = ConfigTemplate
|
||||
fields = (
|
||||
'name', 'description', 'template_code', 'data_source', 'data_file', 'auto_sync_enabled',
|
||||
'environment_params', 'mime_type', 'file_name', 'file_extension', 'as_attachment', 'owner', 'tags',
|
||||
'environment_params', 'mime_type', 'file_name', 'file_extension', 'as_attachment', 'debug', 'owner',
|
||||
'tags',
|
||||
)
|
||||
|
||||
def clean(self):
|
||||
|
||||
@@ -497,7 +497,7 @@ class ConfigTemplateFilterForm(OwnerFilterMixin, SavedFiltersMixin, FilterForm):
|
||||
fieldsets = (
|
||||
FieldSet('q', 'filter_id', 'tag'),
|
||||
FieldSet('data_source_id', 'data_file_id', 'auto_sync_enabled', name=_('Data')),
|
||||
FieldSet('mime_type', 'file_name', 'file_extension', 'as_attachment', name=_('Rendering')),
|
||||
FieldSet('mime_type', 'file_name', 'file_extension', 'as_attachment', 'debug', name=_('Rendering')),
|
||||
FieldSet('owner_group_id', 'owner_id', name=_('Ownership')),
|
||||
)
|
||||
data_source_id = DynamicModelMultipleChoiceField(
|
||||
@@ -540,6 +540,13 @@ class ConfigTemplateFilterForm(OwnerFilterMixin, SavedFiltersMixin, FilterForm):
|
||||
choices=BOOLEAN_WITH_BLANK_CHOICES
|
||||
)
|
||||
)
|
||||
debug = forms.NullBooleanField(
|
||||
label=_('Debug'),
|
||||
required=False,
|
||||
widget=forms.Select(
|
||||
choices=BOOLEAN_WITH_BLANK_CHOICES
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
class LocalConfigContextFilterForm(forms.Form):
|
||||
|
||||
@@ -754,7 +754,8 @@ class ConfigTemplateForm(ChangelogMessageMixin, SyncedDataMixin, OwnerMixin, for
|
||||
FieldSet('name', 'description', 'tags', 'template_code', name=_('Config Template')),
|
||||
FieldSet('data_source', 'data_file', 'auto_sync_enabled', name=_('Data Source')),
|
||||
FieldSet(
|
||||
'mime_type', 'file_name', 'file_extension', 'environment_params', 'as_attachment', name=_('Rendering')
|
||||
'mime_type', 'file_name', 'file_extension', 'environment_params', 'as_attachment', 'debug',
|
||||
name=_('Rendering')
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
22
netbox/extras/migrations/0135_configtemplate_debug.py
Normal file
22
netbox/extras/migrations/0135_configtemplate_debug.py
Normal file
@@ -0,0 +1,22 @@
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
('extras', '0134_owner'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='configtemplate',
|
||||
name='debug',
|
||||
field=models.BooleanField(
|
||||
default=False,
|
||||
help_text=(
|
||||
'Enable verbose error output when rendering this template. '
|
||||
'Not recommended for production use.'
|
||||
),
|
||||
verbose_name='debug',
|
||||
),
|
||||
),
|
||||
]
|
||||
@@ -1,9 +1,12 @@
|
||||
import traceback
|
||||
|
||||
import jsonschema
|
||||
from django.conf import settings
|
||||
from django.core.validators import ValidationError
|
||||
from django.db import models
|
||||
from django.urls import reverse
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from jinja2.exceptions import TemplateError
|
||||
from jsonschema.exceptions import ValidationError as JSONValidationError
|
||||
|
||||
from extras.models.mixins import RenderTemplateMixin
|
||||
@@ -281,6 +284,13 @@ class ConfigTemplate(
|
||||
max_length=200,
|
||||
blank=True
|
||||
)
|
||||
debug = models.BooleanField(
|
||||
verbose_name=_('debug'),
|
||||
default=False,
|
||||
help_text=_(
|
||||
'Enable verbose error output when rendering this template. Not recommended for production use.'
|
||||
)
|
||||
)
|
||||
|
||||
class Meta:
|
||||
ordering = ('name',)
|
||||
@@ -299,3 +309,21 @@ class ConfigTemplate(
|
||||
"""
|
||||
self.template_code = self.data_file.data_as_string
|
||||
sync_data.alters_data = True
|
||||
|
||||
def format_render_error(self, exc):
|
||||
"""
|
||||
Return a formatted error string for a rendering exception. When debug is enabled, the full
|
||||
traceback is returned. Otherwise, a concise, user-facing message is returned.
|
||||
|
||||
Must be called from within the except block so that traceback.format_exc() is valid.
|
||||
"""
|
||||
if self.debug:
|
||||
return traceback.format_exc()
|
||||
if isinstance(exc, TemplateError):
|
||||
parts = [f"{type(exc).__name__}: {exc}"]
|
||||
if getattr(exc, 'name', None):
|
||||
parts.append(_("Template: {name}").format(name=exc.name))
|
||||
if getattr(exc, 'lineno', None):
|
||||
parts.append(_("Line: {lineno}").format(lineno=exc.lineno))
|
||||
return "\n".join(parts)
|
||||
return f"{type(exc).__name__}: {exc}"
|
||||
|
||||
@@ -150,7 +150,8 @@ class RenderTemplateMixin(models.Model):
|
||||
"""
|
||||
context = self.get_context(context=context, queryset=queryset)
|
||||
env_params = self.get_environment_params()
|
||||
output = render_jinja2(self.template_code, context, env_params, getattr(self, 'data_file', None))
|
||||
debug = getattr(self, 'debug', False)
|
||||
output = render_jinja2(self.template_code, context, env_params, getattr(self, 'data_file', None), debug=debug)
|
||||
|
||||
# Replace CRLF-style line terminators
|
||||
output = output.replace('\r\n', '\n')
|
||||
|
||||
@@ -697,6 +697,10 @@ class ConfigTemplateTable(NetBoxTable):
|
||||
verbose_name=_('As Attachment'),
|
||||
false_mark=None
|
||||
)
|
||||
debug = columns.BooleanColumn(
|
||||
verbose_name=_('Debug'),
|
||||
false_mark=None
|
||||
)
|
||||
owner = tables.Column(
|
||||
linkify=True,
|
||||
verbose_name=_('Owner')
|
||||
@@ -728,7 +732,7 @@ class ConfigTemplateTable(NetBoxTable):
|
||||
class Meta(NetBoxTable.Meta):
|
||||
model = ConfigTemplate
|
||||
fields = (
|
||||
'pk', 'id', 'name', 'description', 'data_source', 'data_file', 'data_synced', 'as_attachment',
|
||||
'pk', 'id', 'name', 'description', 'data_source', 'data_file', 'data_synced', 'as_attachment', 'debug',
|
||||
'mime_type', 'file_name', 'file_extension', 'role_count', 'platform_count', 'device_count',
|
||||
'vm_count', 'created', 'last_updated', 'tags',
|
||||
)
|
||||
|
||||
@@ -818,6 +818,54 @@ class ConfigTemplateTest(TestCase):
|
||||
self.assertEqual(autosync_records.count(), 0, "AutoSyncRecord should be deleted after detaching")
|
||||
|
||||
|
||||
class ConfigTemplateDebugTest(TestCase):
|
||||
"""
|
||||
Tests for the ConfigTemplate debug field and its effect on template rendering error output.
|
||||
"""
|
||||
|
||||
def _make_template(self, template_code, debug=False):
|
||||
t = ConfigTemplate(
|
||||
name=f"DebugTestTemplate-{debug}",
|
||||
template_code=template_code,
|
||||
debug=debug,
|
||||
)
|
||||
t.save()
|
||||
return t
|
||||
|
||||
def test_debug_default_is_false(self):
|
||||
t = ConfigTemplate(name="t", template_code="hello")
|
||||
self.assertFalse(t.debug)
|
||||
|
||||
def test_template_error_non_debug_no_traceback(self):
|
||||
"""In non-debug mode, a TemplateError raises with no traceback exposure."""
|
||||
from jinja2 import TemplateError
|
||||
t = self._make_template("{{ unclosed", debug=False)
|
||||
with self.assertRaises(TemplateError):
|
||||
t.render({})
|
||||
|
||||
def test_template_error_debug_mode_raises(self):
|
||||
"""In debug mode, a TemplateError still raises (callers handle display)."""
|
||||
from jinja2 import TemplateError
|
||||
t = self._make_template("{{ unclosed", debug=True)
|
||||
with self.assertRaises(TemplateError):
|
||||
t.render({})
|
||||
|
||||
def test_render_jinja2_debug_extension_enabled(self):
|
||||
"""When debug=True, the Jinja2 debug extension is loaded in the environment."""
|
||||
from utilities.jinja2 import render_jinja2
|
||||
# The {% debug %} tag is only available when the debug extension is loaded.
|
||||
output = render_jinja2("{% debug %}", {}, debug=True)
|
||||
self.assertIsInstance(output, str)
|
||||
|
||||
def test_render_jinja2_debug_extension_not_loaded_by_default(self):
|
||||
"""When debug=False, the {% debug %} tag is not available."""
|
||||
from jinja2 import TemplateSyntaxError
|
||||
|
||||
from utilities.jinja2 import render_jinja2
|
||||
with self.assertRaises(TemplateSyntaxError):
|
||||
render_jinja2("{% debug %}", {}, debug=False)
|
||||
|
||||
|
||||
class ExportTemplateContextTest(TestCase):
|
||||
"""
|
||||
Tests for ExportTemplate.get_context() including public model population.
|
||||
|
||||
@@ -12,7 +12,6 @@ from django.utils import timezone
|
||||
from django.utils.module_loading import import_string
|
||||
from django.utils.translation import gettext as _
|
||||
from django.views.generic import View
|
||||
from jinja2.exceptions import TemplateError
|
||||
|
||||
from core.choices import ManagedFileRootPathChoices
|
||||
from core.models import Job
|
||||
@@ -1120,11 +1119,12 @@ class ObjectRenderConfigView(generic.ObjectView):
|
||||
# Render the config template
|
||||
rendered_config = None
|
||||
error_message = ''
|
||||
if config_template := instance.get_config_template():
|
||||
config_template = instance.get_config_template()
|
||||
if config_template:
|
||||
try:
|
||||
rendered_config = config_template.render(context=context_data)
|
||||
except TemplateError as e:
|
||||
error_message = _("An error occurred while rendering the template: {error}").format(error=e)
|
||||
except Exception as e:
|
||||
error_message = config_template.format_render_error(e)
|
||||
|
||||
return {
|
||||
'base_template': self.base_template,
|
||||
|
||||
@@ -33,6 +33,10 @@
|
||||
<th scope="row">{% trans "Attachment" %}</th>
|
||||
<td>{% checkmark object.as_attachment %}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">{% trans "Debug" %}</th>
|
||||
<td>{% checkmark object.debug %}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">{% trans "Data Source" %}</th>
|
||||
<td>
|
||||
|
||||
@@ -66,7 +66,13 @@
|
||||
{% elif error_message %}
|
||||
<div class="alert alert-warning">
|
||||
<h4 class="alert-title mb-1">{% trans "Error rendering template" %}</h4>
|
||||
{% trans error_message %}
|
||||
{% if config_template.debug %}
|
||||
<div class="overflow-auto" style="max-height: 30rem;">
|
||||
<pre class="mb-0">{{ error_message }}</pre>
|
||||
</div>
|
||||
{% else %}
|
||||
<pre class="mb-0 text-warning-emphasis bg-transparent border-0 p-0">{{ error_message }}</pre>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="alert alert-warning">
|
||||
|
||||
@@ -49,11 +49,19 @@ class DataFileLoader(BaseLoader):
|
||||
# Utility functions
|
||||
#
|
||||
|
||||
def render_jinja2(template_code, context, environment_params=None, data_file=None):
|
||||
def render_jinja2(template_code, context, environment_params=None, data_file=None, debug=False):
|
||||
"""
|
||||
Render a Jinja2 template with the provided context. Return the rendered content.
|
||||
|
||||
If debug is True, the Jinja2 debug extension is enabled to assist with template development.
|
||||
"""
|
||||
environment_params = environment_params or {}
|
||||
environment_params = dict(environment_params or {})
|
||||
|
||||
if debug:
|
||||
extensions = list(environment_params.get('extensions', []))
|
||||
if 'jinja2.ext.debug' not in extensions:
|
||||
extensions.append('jinja2.ext.debug')
|
||||
environment_params['extensions'] = extensions
|
||||
|
||||
if 'loader' not in environment_params:
|
||||
if data_file:
|
||||
|
||||
Reference in New Issue
Block a user