Просмотр исходного кода

Fixes: #19953 - ConfigTemplate debug rendering mode (#21652)

Add debug field to ConfigTemplate and (if True) render template errors
with a full traceback.
bctiemann 1 день назад
Родитель
Сommit
b01d92c98b

+ 6 - 6
netbox/extras/api/mixins.py

@@ -1,8 +1,7 @@
-from jinja2.exceptions import TemplateError
 from rest_framework.decorators import action
 from rest_framework.decorators import action
 from rest_framework.renderers import JSONRenderer
 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, HTTP_500_INTERNAL_SERVER_ERROR
 
 
 from netbox.api.authentication import TokenWritePermission
 from netbox.api.authentication import TokenWritePermission
 from netbox.api.renderers import TextRenderer
 from netbox.api.renderers import TextRenderer
@@ -45,10 +44,11 @@ class ConfigTemplateRenderMixin:
     def render_configtemplate(self, request, configtemplate, context):
     def render_configtemplate(self, request, configtemplate, context):
         try:
         try:
             output = configtemplate.render(context=context)
             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 the client has requested "text/plain", return the raw content.
         if request.accepted_renderer.format == 'txt':
         if request.accepted_renderer.format == 'txt':

+ 2 - 2
netbox/extras/api/serializers_/configtemplates.py

@@ -28,7 +28,7 @@ class ConfigTemplateSerializer(
         model = ConfigTemplate
         model = ConfigTemplate
         fields = [
         fields = [
             'id', 'url', 'display_url', 'display', 'name', 'description', 'environment_params', 'template_code',
             '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')
         brief_fields = ('id', 'url', 'display', 'name', 'description')

+ 1 - 1
netbox/extras/filtersets.py

@@ -857,7 +857,7 @@ class ConfigTemplateFilterSet(OwnerFilterMixin, ChangeLoggedModelFilterSet):
     class Meta:
     class Meta:
         model = ConfigTemplate
         model = ConfigTemplate
         fields = (
         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'
             'auto_sync_enabled', 'data_synced'
         )
         )
 
 

+ 5 - 0
netbox/extras/forms/bulk_edit.py

@@ -392,6 +392,11 @@ class ConfigTemplateBulkEditForm(ChangelogMessageMixin, OwnerMixin, BulkEditForm
         required=False,
         required=False,
         widget=BulkEditNullBooleanSelect()
         widget=BulkEditNullBooleanSelect()
     )
     )
+    debug = forms.NullBooleanField(
+        label=_('Debug'),
+        required=False,
+        widget=BulkEditNullBooleanSelect()
+    )
     auto_sync_enabled = forms.NullBooleanField(
     auto_sync_enabled = forms.NullBooleanField(
         label=_('Auto sync enabled'),
         label=_('Auto sync enabled'),
         required=False,
         required=False,

+ 2 - 1
netbox/extras/forms/bulk_import.py

@@ -190,7 +190,8 @@ class ConfigTemplateImportForm(OwnerCSVMixin, CSVModelForm):
         model = ConfigTemplate
         model = ConfigTemplate
         fields = (
         fields = (
             'name', 'description', 'template_code', 'data_source', 'data_file', 'auto_sync_enabled',
             '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):
     def clean(self):

+ 8 - 1
netbox/extras/forms/filtersets.py

@@ -497,7 +497,7 @@ class ConfigTemplateFilterForm(OwnerFilterMixin, SavedFiltersMixin, FilterForm):
     fieldsets = (
     fieldsets = (
         FieldSet('q', 'filter_id', 'tag'),
         FieldSet('q', 'filter_id', 'tag'),
         FieldSet('data_source_id', 'data_file_id', 'auto_sync_enabled', name=_('Data')),
         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')),
         FieldSet('owner_group_id', 'owner_id', name=_('Ownership')),
     )
     )
     data_source_id = DynamicModelMultipleChoiceField(
     data_source_id = DynamicModelMultipleChoiceField(
@@ -540,6 +540,13 @@ class ConfigTemplateFilterForm(OwnerFilterMixin, SavedFiltersMixin, FilterForm):
             choices=BOOLEAN_WITH_BLANK_CHOICES
             choices=BOOLEAN_WITH_BLANK_CHOICES
         )
         )
     )
     )
+    debug = forms.NullBooleanField(
+        label=_('Debug'),
+        required=False,
+        widget=forms.Select(
+            choices=BOOLEAN_WITH_BLANK_CHOICES
+        )
+    )
 
 
 
 
 class LocalConfigContextFilterForm(forms.Form):
 class LocalConfigContextFilterForm(forms.Form):

+ 2 - 1
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('name', 'description', 'tags', 'template_code', name=_('Config Template')),
         FieldSet('data_source', 'data_file', 'auto_sync_enabled', name=_('Data Source')),
         FieldSet('data_source', 'data_file', 'auto_sync_enabled', name=_('Data Source')),
         FieldSet(
         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 - 0
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',
+            ),
+        ),
+    ]

+ 27 - 0
netbox/extras/models/configs.py

@@ -1,9 +1,12 @@
+import traceback
+
 import jsonschema
 import jsonschema
 from django.conf import settings
 from django.conf import settings
 from django.core.validators import ValidationError
 from django.core.validators import ValidationError
 from django.db import models
 from django.db import models
 from django.urls import reverse
 from django.urls import reverse
 from django.utils.translation import gettext_lazy as _
 from django.utils.translation import gettext_lazy as _
+from jinja2.exceptions import TemplateError
 from jsonschema.exceptions import ValidationError as JSONValidationError
 from jsonschema.exceptions import ValidationError as JSONValidationError
 
 
 from extras.models.mixins import RenderTemplateMixin
 from extras.models.mixins import RenderTemplateMixin
@@ -281,6 +284,13 @@ class ConfigTemplate(
         max_length=200,
         max_length=200,
         blank=True
         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:
     class Meta:
         ordering = ('name',)
         ordering = ('name',)
@@ -299,3 +309,20 @@ class ConfigTemplate(
         """
         """
         self.template_code = self.data_file.data_as_string
         self.template_code = self.data_file.data_as_string
     sync_data.alters_data = True
     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}"

+ 2 - 1
netbox/extras/models/mixins.py

@@ -150,7 +150,8 @@ class RenderTemplateMixin(models.Model):
         """
         """
         context = self.get_context(context=context, queryset=queryset)
         context = self.get_context(context=context, queryset=queryset)
         env_params = self.get_environment_params()
         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
         # Replace CRLF-style line terminators
         output = output.replace('\r\n', '\n')
         output = output.replace('\r\n', '\n')

+ 5 - 1
netbox/extras/tables/tables.py

@@ -697,6 +697,10 @@ class ConfigTemplateTable(NetBoxTable):
         verbose_name=_('As Attachment'),
         verbose_name=_('As Attachment'),
         false_mark=None
         false_mark=None
     )
     )
+    debug = columns.BooleanColumn(
+        verbose_name=_('Debug'),
+        false_mark=None
+    )
     owner = tables.Column(
     owner = tables.Column(
         linkify=True,
         linkify=True,
         verbose_name=_('Owner')
         verbose_name=_('Owner')
@@ -728,7 +732,7 @@ class ConfigTemplateTable(NetBoxTable):
     class Meta(NetBoxTable.Meta):
     class Meta(NetBoxTable.Meta):
         model = ConfigTemplate
         model = ConfigTemplate
         fields = (
         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',
             'mime_type', 'file_name', 'file_extension', 'role_count', 'platform_count', 'device_count',
             'vm_count', 'created', 'last_updated', 'tags',
             'vm_count', 'created', 'last_updated', 'tags',
         )
         )

+ 48 - 0
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")
             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):
 class ExportTemplateContextTest(TestCase):
     """
     """
     Tests for ExportTemplate.get_context() including public model population.
     Tests for ExportTemplate.get_context() including public model population.

+ 4 - 4
netbox/extras/views.py

@@ -12,7 +12,6 @@ from django.utils import timezone
 from django.utils.module_loading import import_string
 from django.utils.module_loading import import_string
 from django.utils.translation import gettext as _
 from django.utils.translation import gettext as _
 from django.views.generic import View
 from django.views.generic import View
-from jinja2.exceptions import TemplateError
 
 
 from core.choices import ManagedFileRootPathChoices
 from core.choices import ManagedFileRootPathChoices
 from core.models import Job
 from core.models import Job
@@ -1120,11 +1119,12 @@ class ObjectRenderConfigView(generic.ObjectView):
         # Render the config template
         # Render the config template
         rendered_config = None
         rendered_config = None
         error_message = ''
         error_message = ''
-        if config_template := instance.get_config_template():
+        config_template = instance.get_config_template()
+        if config_template:
             try:
             try:
                 rendered_config = config_template.render(context=context_data)
                 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 {
         return {
             'base_template': self.base_template,
             'base_template': self.base_template,

+ 4 - 0
netbox/templates/extras/configtemplate.html

@@ -33,6 +33,10 @@
             <th scope="row">{% trans "Attachment" %}</th>
             <th scope="row">{% trans "Attachment" %}</th>
             <td>{% checkmark object.as_attachment %}</td>
             <td>{% checkmark object.as_attachment %}</td>
           </tr>
           </tr>
+          <tr>
+            <th scope="row">{% trans "Debug" %}</th>
+            <td>{% checkmark object.debug %}</td>
+          </tr>
           <tr>
           <tr>
             <th scope="row">{% trans "Data Source" %}</th>
             <th scope="row">{% trans "Data Source" %}</th>
             <td>
             <td>

+ 7 - 1
netbox/templates/extras/object_render_config.html

@@ -66,7 +66,13 @@
         {% elif error_message %}
         {% elif error_message %}
           <div class="alert alert-warning">
           <div class="alert alert-warning">
             <h4 class="alert-title mb-1">{% trans "Error rendering template" %}</h4>
             <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>
           </div>
         {% else %}
         {% else %}
           <div class="alert alert-warning">
           <div class="alert alert-warning">

+ 10 - 2
netbox/utilities/jinja2.py

@@ -49,11 +49,19 @@ class DataFileLoader(BaseLoader):
 # Utility functions
 # 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.
     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 'loader' not in environment_params:
         if data_file:
         if data_file: