diff --git a/netbox/extras/api/mixins.py b/netbox/extras/api/mixins.py index a98218b78..795d50af8 100644 --- a/netbox/extras/api/mixins.py +++ b/netbox/extras/api/mixins.py @@ -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: + detail = configtemplate.format_render_error(e) + if request.accepted_renderer.format == 'txt': + return Response(detail, status=HTTP_500_INTERNAL_SERVER_ERROR) + return Response({'detail': detail}, status=HTTP_500_INTERNAL_SERVER_ERROR) # If the client has requested "text/plain", return the raw content. if request.accepted_renderer.format == 'txt': diff --git a/netbox/extras/api/serializers_/configtemplates.py b/netbox/extras/api/serializers_/configtemplates.py index ac9f40738..4bf4608ac 100644 --- a/netbox/extras/api/serializers_/configtemplates.py +++ b/netbox/extras/api/serializers_/configtemplates.py @@ -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') diff --git a/netbox/extras/filtersets.py b/netbox/extras/filtersets.py index 2b995e52a..444b87da1 100644 --- a/netbox/extras/filtersets.py +++ b/netbox/extras/filtersets.py @@ -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' ) diff --git a/netbox/extras/forms/bulk_edit.py b/netbox/extras/forms/bulk_edit.py index 6805898fc..de0f2bb27 100644 --- a/netbox/extras/forms/bulk_edit.py +++ b/netbox/extras/forms/bulk_edit.py @@ -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, diff --git a/netbox/extras/forms/bulk_import.py b/netbox/extras/forms/bulk_import.py index 91d5b2751..df329fbcb 100644 --- a/netbox/extras/forms/bulk_import.py +++ b/netbox/extras/forms/bulk_import.py @@ -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): diff --git a/netbox/extras/forms/filtersets.py b/netbox/extras/forms/filtersets.py index 3fd551929..cbff6947e 100644 --- a/netbox/extras/forms/filtersets.py +++ b/netbox/extras/forms/filtersets.py @@ -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): diff --git a/netbox/extras/forms/model_forms.py b/netbox/extras/forms/model_forms.py index 6a5e8f209..d01e32691 100644 --- a/netbox/extras/forms/model_forms.py +++ b/netbox/extras/forms/model_forms.py @@ -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') ), ) diff --git a/netbox/extras/migrations/0135_configtemplate_debug.py b/netbox/extras/migrations/0135_configtemplate_debug.py new file mode 100644 index 000000000..cddfaf240 --- /dev/null +++ b/netbox/extras/migrations/0135_configtemplate_debug.py @@ -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', + ), + ), + ] diff --git a/netbox/extras/models/configs.py b/netbox/extras/models/configs.py index 1aaeec1ab..477fa2e03 100644 --- a/netbox/extras/models/configs.py +++ b/netbox/extras/models/configs.py @@ -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,20 @@ 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 for the provided exception is returned. Otherwise, a concise, user-facing message + is returned. + """ + if self.debug: + return ''.join(traceback.format_exception(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}" diff --git a/netbox/extras/models/mixins.py b/netbox/extras/models/mixins.py index 704f96133..805916d9f 100644 --- a/netbox/extras/models/mixins.py +++ b/netbox/extras/models/mixins.py @@ -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') diff --git a/netbox/extras/tables/tables.py b/netbox/extras/tables/tables.py index a4026fe39..3315e1252 100644 --- a/netbox/extras/tables/tables.py +++ b/netbox/extras/tables/tables.py @@ -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', ) diff --git a/netbox/extras/tests/test_models.py b/netbox/extras/tests/test_models.py index 46cf4fe7d..2494b266b 100644 --- a/netbox/extras/tests/test_models.py +++ b/netbox/extras/tests/test_models.py @@ -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. diff --git a/netbox/extras/views.py b/netbox/extras/views.py index 05a3dbb13..567a0a5fe 100644 --- a/netbox/extras/views.py +++ b/netbox/extras/views.py @@ -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, diff --git a/netbox/templates/extras/configtemplate.html b/netbox/templates/extras/configtemplate.html index a2e31baed..623263a15 100644 --- a/netbox/templates/extras/configtemplate.html +++ b/netbox/templates/extras/configtemplate.html @@ -33,6 +33,10 @@
{{ error_message }}
+ {{ error_message }}
+ {% endif %}