Compare commits

...

3 Commits

Author SHA1 Message Date
Brian Tiemann
07238e9f9c Consolidate redundant traceback rendering 2026-03-11 20:47:32 -04:00
Brian Tiemann
28ac5b6201 Review tweaks 2026-03-11 20:06:07 -04:00
Brian Tiemann
311433757d Add debug field to ConfigTemplate and (if True) render template errors with a full traceback 2026-03-11 19:58:15 -04:00
16 changed files with 156 additions and 21 deletions

View File

@@ -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':

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View 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',
),
),
]

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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