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

Closes #17653: Add function to trim whitespaces in export templates via jinja environment settings (#19078)

* Create RenderMixin, and unify template_code rendering and exporting

* Join migrations

* Add DEFAULT_MIME_TE constant

* Move RenderMixin to extras.models.mixins, Rename RenderMixin to RenderTemplateMixin

* Add render_jinja2 to __all__

* Rename ConfigTemplateFilterForm rendering FieldSet

* ConfigTemplate lint

* Simplify ExportTemplate get_context

* Fix table order, and add fields for translations

* Update Serializers

* Update forms, tables, graphQL, API

* Add extra tests for ConfigTemplate and ExportTemplate

* Documentation update

* Fix typo

* Misc cleanup

* Clean up template layouts

---------

Co-authored-by: Jeremy Stretch <jstretch@netboxlabs.com>
Renato Almeida de Oliveira 10 месяцев назад
Родитель
Сommit
fbd6d8c7fc

+ 24 - 4
docs/models/extras/configtemplate.md

@@ -12,10 +12,6 @@ See the [configuration rendering documentation](../../features/configuration-ren
 
 A unique human-friendly name.
 
-### Weight
-
-A numeric value which influences the order in which context data is merged. Contexts with a lower weight are merged before those with a higher weight.
-
 ### Data File
 
 Template code may optionally be sourced from a remote [data file](../core/datafile.md), which is synchronized from a remote data source. When designating a data file, there is no need to specify template code: It will be populated automatically from the data file.
@@ -27,3 +23,27 @@ Jinja2 template code, if being defined locally rather than replicated from a dat
 ### Environment Parameters
 
 A dictionary of any additional parameters to pass when instantiating the [Jinja2 environment](https://jinja.palletsprojects.com/en/3.1.x/api/#jinja2.Environment). Jinja2 supports various optional parameters which can be used to modify its default behavior.
+
+### MIME Type
+
+!!! info "This field was introduced in NetBox v4.3."
+
+The MIME type to indicate in the response when rendering the configuration template (optional). Defaults to `text/plain`.
+
+### File Name
+
+!!! info "This field was introduced in NetBox v4.3."
+
+The file name to give to the rendered export file (optional).
+
+### File Extension
+
+!!! info "This field was introduced in NetBox v4.3."
+
+The file extension to append to the file name in the response (optional).
+
+### As Attachment
+
+!!! info "This field was introduced in NetBox v4.3."
+
+If selected, the rendered content will be returned as a file attachment, rather than displayed directly in-browser (where supported).

+ 6 - 2
docs/models/extras/exporttemplate.md

@@ -20,6 +20,12 @@ Template code may optionally be sourced from a remote [data file](../core/datafi
 
 Jinja2 template code for rendering the exported data.
 
+### Environment Parameters
+
+!!! info "This field was introduced in NetBox v4.3."
+
+A dictionary of any additional parameters to pass when instantiating the [Jinja2 environment](https://jinja.palletsprojects.com/en/3.1.x/api/#jinja2.Environment). Jinja2 supports various optional parameters which can be used to modify its default behavior.
+
 ### MIME Type
 
 The MIME type to indicate in the response when rendering the export template (optional). Defaults to `text/plain`.
@@ -28,8 +34,6 @@ The MIME type to indicate in the response when rendering the export template (op
 
 The file name to give to the rendered export file (optional).
 
-!!! info "This field was introduced in NetBox v4.3."
-
 ### File Extension
 
 The file extension to append to the file name in the response (optional).

+ 1 - 5
netbox/dcim/views.py

@@ -4,7 +4,6 @@ from django.core.paginator import EmptyPage, PageNotAnInteger
 from django.db import transaction
 from django.db.models import Prefetch
 from django.forms import ModelMultipleChoiceField, MultipleHiddenInput, modelformset_factory
-from django.http import HttpResponse
 from django.shortcuts import get_object_or_404, redirect, render
 from django.urls import reverse
 from django.utils.html import escape
@@ -2293,10 +2292,7 @@ class DeviceRenderConfigView(generic.ObjectView):
         # If a direct export has been requested, return the rendered template content as a
         # downloadable file.
         if request.GET.get('export'):
-            content = context['rendered_config'] or context['error_message']
-            response = HttpResponse(content, content_type='text')
-            filename = f"{instance.name or 'config'}.txt"
-            response['Content-Disposition'] = f'attachment; filename="{filename}"'
+            response = context['config_template'].render_to_response(context=context['context_data'])
             return response
 
         return render(request, self.get_template_name(), {

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

@@ -22,6 +22,7 @@ class ConfigTemplateSerializer(TaggableModelSerializer, ValidatedModelSerializer
         model = ConfigTemplate
         fields = [
             'id', 'url', 'display_url', 'display', 'name', 'description', 'environment_params', 'template_code',
-            'data_source', 'data_path', 'data_file', 'data_synced', 'tags', 'created', 'last_updated',
+            'mime_type', 'file_name', 'file_extension', 'as_attachment', 'data_source', 'data_path', 'data_file',
+            'data_synced', 'tags', 'created', 'last_updated',
         ]
         brief_fields = ('id', 'url', 'display', 'name', 'description')

+ 3 - 3
netbox/extras/api/serializers_/exporttemplates.py

@@ -26,8 +26,8 @@ class ExportTemplateSerializer(ValidatedModelSerializer):
     class Meta:
         model = ExportTemplate
         fields = [
-            'id', 'url', 'display_url', 'display', 'object_types', 'name', 'description', 'template_code', 'mime_type',
-            'file_name', 'file_extension', 'as_attachment', 'data_source', 'data_path', 'data_file', 'data_synced',
-            'created', 'last_updated',
+            'id', 'url', 'display_url', 'display', 'object_types', 'name', 'description', 'environment_params',
+            'template_code', 'mime_type', 'file_name', 'file_extension', 'as_attachment', 'data_source',
+            'data_path', 'data_file', 'data_synced', 'created', 'last_updated',
         ]
         brief_fields = ('id', 'url', 'display', 'name', 'description')

+ 3 - 0
netbox/extras/constants.py

@@ -4,6 +4,9 @@ from extras.choices import LogLevelChoices
 # Custom fields
 CUSTOMFIELD_EMPTY_VALUES = (None, '', [])
 
+# Template Export
+DEFAULT_MIME_TYPE = 'text/plain; charset=utf-8'
+
 # Webhooks
 HTTP_CONTENT_TYPE_JSON = 'application/json'
 

+ 4 - 1
netbox/extras/filtersets.py

@@ -707,7 +707,10 @@ class ConfigTemplateFilterSet(ChangeLoggedModelFilterSet):
 
     class Meta:
         model = ConfigTemplate
-        fields = ('id', 'name', 'description', 'auto_sync_enabled', 'data_synced')
+        fields = (
+            'id', 'name', 'description', 'mime_type', 'file_name', 'file_extension', 'as_attachment',
+            'auto_sync_enabled', 'data_synced'
+        )
 
     def search(self, queryset, name, value):
         if not value.strip():

+ 20 - 1
netbox/extras/forms/bulk_edit.py

@@ -321,8 +321,27 @@ class ConfigTemplateBulkEditForm(BulkEditForm):
         max_length=200,
         required=False
     )
+    mime_type = forms.CharField(
+        label=_('MIME type'),
+        max_length=50,
+        required=False
+    )
+    file_name = forms.CharField(
+        label=_('File name'),
+        required=False
+    )
+    file_extension = forms.CharField(
+        label=_('File extension'),
+        max_length=15,
+        required=False
+    )
+    as_attachment = forms.NullBooleanField(
+        label=_('As attachment'),
+        required=False,
+        widget=BulkEditNullBooleanSelect()
+    )
 
-    nullable_fields = ('description',)
+    nullable_fields = ('description', 'mime_type', 'file_name', 'file_extension')
 
 
 class JournalEntryBulkEditForm(BulkEditForm):

+ 4 - 3
netbox/extras/forms/bulk_import.py

@@ -144,8 +144,8 @@ class ExportTemplateImportForm(CSVModelForm):
     class Meta:
         model = ExportTemplate
         fields = (
-            'name', 'object_types', 'description', 'mime_type', 'file_name', 'file_extension', 'as_attachment',
-            'template_code',
+            'name', 'object_types', 'description', 'environment_params', 'mime_type', 'file_name', 'file_extension',
+            'as_attachment', 'template_code',
         )
 
 
@@ -154,7 +154,8 @@ class ConfigTemplateImportForm(CSVModelForm):
     class Meta:
         model = ConfigTemplate
         fields = (
-            'name', 'description', 'environment_params', 'template_code', 'tags',
+            'name', 'description', 'template_code', 'environment_params', 'mime_type', 'file_name', 'file_extension',
+            'as_attachment', 'tags',
         )
 
 

+ 22 - 2
netbox/extras/forms/filtersets.py

@@ -160,9 +160,9 @@ class CustomLinkFilterForm(SavedFiltersMixin, FilterForm):
 
 class ExportTemplateFilterForm(SavedFiltersMixin, FilterForm):
     fieldsets = (
-        FieldSet('q', 'filter_id'),
+        FieldSet('q', 'filter_id', 'object_type_id'),
         FieldSet('data_source_id', 'data_file_id', name=_('Data')),
-        FieldSet('object_type_id', 'mime_type', 'file_name', 'file_extension', 'as_attachment', name=_('Attributes')),
+        FieldSet('mime_type', 'file_name', 'file_extension', 'as_attachment', name=_('Rendering')),
     )
     data_source_id = DynamicModelMultipleChoiceField(
         queryset=DataSource.objects.all(),
@@ -410,6 +410,7 @@ class ConfigTemplateFilterForm(SavedFiltersMixin, FilterForm):
     fieldsets = (
         FieldSet('q', 'filter_id', 'tag'),
         FieldSet('data_source_id', 'data_file_id', name=_('Data')),
+        FieldSet('mime_type', 'file_name', 'file_extension', 'as_attachment', name=_('Rendering'))
     )
     data_source_id = DynamicModelMultipleChoiceField(
         queryset=DataSource.objects.all(),
@@ -425,6 +426,25 @@ class ConfigTemplateFilterForm(SavedFiltersMixin, FilterForm):
         }
     )
     tag = TagFilterField(ConfigTemplate)
+    mime_type = forms.CharField(
+        required=False,
+        label=_('MIME type')
+    )
+    file_name = forms.CharField(
+        label=_('File name'),
+        required=False
+    )
+    file_extension = forms.CharField(
+        label=_('File extension'),
+        required=False
+    )
+    as_attachment = forms.NullBooleanField(
+        label=_('As attachment'),
+        required=False,
+        widget=forms.Select(
+            choices=BOOLEAN_WITH_BLANK_CHOICES
+        )
+    )
 
 
 class LocalConfigContextFilterForm(forms.Form):

+ 7 - 3
netbox/extras/forms/model_forms.py

@@ -246,7 +246,9 @@ class ExportTemplateForm(SyncedDataMixin, forms.ModelForm):
     fieldsets = (
         FieldSet('name', 'object_types', 'description', 'template_code', name=_('Export Template')),
         FieldSet('data_source', 'data_file', 'auto_sync_enabled', name=_('Data Source')),
-        FieldSet('mime_type', 'file_name', 'file_extension', 'as_attachment', name=_('Rendering')),
+        FieldSet(
+            'mime_type', 'file_name', 'file_extension', 'environment_params', 'as_attachment', name=_('Rendering')
+        ),
     )
 
     class Meta:
@@ -631,9 +633,11 @@ class ConfigTemplateForm(SyncedDataMixin, forms.ModelForm):
     )
 
     fieldsets = (
-        FieldSet('name', 'description', 'environment_params', 'tags', name=_('Config Template')),
-        FieldSet('template_code', name=_('Content')),
+        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')
+        ),
     )
 
     class Meta:

+ 8 - 0
netbox/extras/graphql/filters.py

@@ -104,6 +104,10 @@ class ConfigTemplateFilter(BaseObjectTypeFilterMixin, SyncedDataFilterMixin, Cha
     environment_params: Annotated['JSONFilter', strawberry.lazy('netbox.graphql.filter_lookups')] | None = (
         strawberry_django.filter_field()
     )
+    mime_type: FilterLookup[str] | None = strawberry_django.filter_field()
+    file_name: FilterLookup[str] | None = strawberry_django.filter_field()
+    file_extension: FilterLookup[str] | None = strawberry_django.filter_field()
+    as_attachment: FilterLookup[bool] | None = strawberry_django.filter_field()
 
 
 @strawberry_django.filter(models.CustomField, lookups=True)
@@ -193,7 +197,11 @@ class ExportTemplateFilter(BaseObjectTypeFilterMixin, SyncedDataFilterMixin, Cha
     name: FilterLookup[str] | None = strawberry_django.filter_field()
     description: FilterLookup[str] | None = strawberry_django.filter_field()
     template_code: FilterLookup[str] | None = strawberry_django.filter_field()
+    environment_params: Annotated['JSONFilter', strawberry.lazy('netbox.graphql.filter_lookups')] | None = (
+        strawberry_django.filter_field()
+    )
     mime_type: FilterLookup[str] | None = strawberry_django.filter_field()
+    file_name: FilterLookup[str] | None = strawberry_django.filter_field()
     file_extension: FilterLookup[str] | None = strawberry_django.filter_field()
     as_attachment: FilterLookup[bool] | None = strawberry_django.filter_field()
 

+ 38 - 0
netbox/extras/migrations/0126_configtemplate_as_attachment_and_more.py

@@ -0,0 +1,38 @@
+# Generated by Django 5.2b1 on 2025-04-04 20:25
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('extras', '0125_exporttemplate_file_name'),
+    ]
+
+    operations = [
+        migrations.AddField(
+            model_name='configtemplate',
+            name='as_attachment',
+            field=models.BooleanField(default=True),
+        ),
+        migrations.AddField(
+            model_name='configtemplate',
+            name='file_extension',
+            field=models.CharField(blank=True, max_length=15),
+        ),
+        migrations.AddField(
+            model_name='configtemplate',
+            name='file_name',
+            field=models.CharField(blank=True, max_length=200),
+        ),
+        migrations.AddField(
+            model_name='configtemplate',
+            name='mime_type',
+            field=models.CharField(blank=True, max_length=50),
+        ),
+        migrations.AddField(
+            model_name='exporttemplate',
+            name='environment_params',
+            field=models.JSONField(blank=True, default=dict, null=True),
+        ),
+    ]

+ 7 - 56
netbox/extras/models/configs.py

@@ -4,16 +4,13 @@ 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.loaders import BaseLoader
-from jinja2.sandbox import SandboxedEnvironment
 
+from extras.models.mixins import RenderTemplateMixin
 from extras.querysets import ConfigContextQuerySet
-from netbox.config import get_config
 from netbox.models import ChangeLoggedModel
 from netbox.models.features import CloningMixin, CustomLinksMixin, ExportTemplatesMixin, SyncedDataMixin, TagsMixin
 from netbox.registry import registry
 from utilities.data import deepmerge
-from utilities.jinja2 import DataFileLoader
 
 __all__ = (
     'ConfigContext',
@@ -210,7 +207,9 @@ class ConfigContextModel(models.Model):
 # Config templates
 #
 
-class ConfigTemplate(SyncedDataMixin, CustomLinksMixin, ExportTemplatesMixin, TagsMixin, ChangeLoggedModel):
+class ConfigTemplate(
+    RenderTemplateMixin, SyncedDataMixin, CustomLinksMixin, ExportTemplatesMixin, TagsMixin, ChangeLoggedModel
+):
     name = models.CharField(
         verbose_name=_('name'),
         max_length=100
@@ -220,20 +219,6 @@ class ConfigTemplate(SyncedDataMixin, CustomLinksMixin, ExportTemplatesMixin, Ta
         max_length=200,
         blank=True
     )
-    template_code = models.TextField(
-        verbose_name=_('template code'),
-        help_text=_('Jinja2 template code.')
-    )
-    environment_params = models.JSONField(
-        verbose_name=_('environment parameters'),
-        blank=True,
-        null=True,
-        default=dict,
-        help_text=_(
-            'Any <a href="https://jinja.palletsprojects.com/en/3.1.x/api/#jinja2.Environment">additional parameters</a>'
-            ' to pass when constructing the Jinja2 environment.'
-        )
-    )
 
     class Meta:
         ordering = ('name',)
@@ -253,13 +238,8 @@ class ConfigTemplate(SyncedDataMixin, CustomLinksMixin, ExportTemplatesMixin, Ta
         self.template_code = self.data_file.data_as_string
     sync_data.alters_data = True
 
-    def render(self, context=None):
-        """
-        Render the contents of the template.
-        """
+    def get_context(self, context=None, queryset=None):
         _context = dict()
-
-        # Populate the default template context with NetBox model classes, namespaced by app
         for app, model_names in registry['models'].items():
             _context.setdefault(app, {})
             for model_name in model_names:
@@ -269,37 +249,8 @@ class ConfigTemplate(SyncedDataMixin, CustomLinksMixin, ExportTemplatesMixin, Ta
                 except LookupError:
                     pass
 
-        # Add the provided context data, if any
+        # Apply the provided context data, if any
         if context is not None:
             _context.update(context)
 
-        # Initialize the Jinja2 environment and instantiate the Template
-        environment = self._get_environment()
-        if self.data_file:
-            template = environment.get_template(self.data_file.path)
-        else:
-            template = environment.from_string(self.template_code)
-        output = template.render(**_context)
-
-        # Replace CRLF-style line terminators
-        return output.replace('\r\n', '\n')
-
-    def _get_environment(self):
-        """
-        Instantiate and return a Jinja2 environment suitable for rendering the ConfigTemplate.
-        """
-        # Initialize the template loader & cache the base template code (if applicable)
-        if self.data_file:
-            loader = DataFileLoader(data_source=self.data_source)
-            loader.cache_templates({
-                self.data_file.path: self.template_code
-            })
-        else:
-            loader = BaseLoader()
-
-        # Initialize the environment
-        env_params = self.environment_params or {}
-        environment = SandboxedEnvironment(loader=loader, **env_params)
-        environment.filters.update(get_config().JINJA2_FILTERS)
-
-        return environment
+        return _context

+ 92 - 0
netbox/extras/models/mixins.py

@@ -3,9 +3,18 @@ import importlib.util
 import os
 import sys
 from django.core.files.storage import storages
+from django.db import models
+from django.utils.translation import gettext_lazy as _
+from django.http import HttpResponse
+
+from extras.constants import DEFAULT_MIME_TYPE
+from extras.utils import filename_from_model, filename_from_object
+from utilities.jinja2 import render_jinja2
+
 
 __all__ = (
     'PythonModuleMixin',
+    'RenderTemplateMixin',
 )
 
 
@@ -66,3 +75,86 @@ class PythonModuleMixin:
         loader.exec_module(module)
 
         return module
+
+
+class RenderTemplateMixin(models.Model):
+    """
+    Enables support for rendering templates.
+    """
+    template_code = models.TextField(
+        verbose_name=_('template code'),
+        help_text=_('Jinja template code.')
+    )
+    environment_params = models.JSONField(
+        verbose_name=_('environment parameters'),
+        blank=True,
+        null=True,
+        default=dict,
+        help_text=_(
+            'Any <a href="{url}">additional parameters</a> to pass when constructing the Jinja environment'
+        ).format(url='https://jinja.palletsprojects.com/en/stable/api/#jinja2.Environment')
+    )
+    mime_type = models.CharField(
+        max_length=50,
+        blank=True,
+        verbose_name=_('MIME type'),
+        help_text=_('Defaults to <code>{default}</code>').format(default=DEFAULT_MIME_TYPE),
+    )
+    file_name = models.CharField(
+        max_length=200,
+        blank=True,
+        help_text=_('Filename to give to the rendered export file')
+    )
+    file_extension = models.CharField(
+        verbose_name=_('file extension'),
+        max_length=15,
+        blank=True,
+        help_text=_('Extension to append to the rendered filename')
+    )
+    as_attachment = models.BooleanField(
+        verbose_name=_('as attachment'),
+        default=True,
+        help_text=_("Download file as attachment")
+    )
+
+    class Meta:
+        abstract = True
+
+    def get_context(self, context=None, queryset=None):
+        raise NotImplementedError(_("{class_name} must implement a get_context() method.").format(
+            class_name=self.__class__
+        ))
+
+    def render(self, context=None, queryset=None):
+        """
+        Render the template with the provided context. The context is passed to the Jinja2 environment as a dictionary.
+        """
+        context = self.get_context(context=context, queryset=queryset)
+        env_params = self.environment_params or {}
+        output = render_jinja2(self.template_code, context, env_params)
+
+        # Replace CRLF-style line terminators
+        output = output.replace('\r\n', '\n')
+
+        return output
+
+    def render_to_response(self, context=None, queryset=None):
+        output = self.render(context=context, queryset=queryset)
+        mime_type = self.mime_type or DEFAULT_MIME_TYPE
+
+        # Build the response
+        response = HttpResponse(output, content_type=mime_type)
+
+        if self.as_attachment:
+            extension = f'.{self.file_extension}' if self.file_extension else ''
+            if self.file_name:
+                filename = self.file_name
+            elif queryset:
+                filename = filename_from_model(queryset.model)
+            elif context:
+                filename = filename_from_object(context)
+            else:
+                filename = "output"
+            response['Content-Disposition'] = f'attachment; filename="{filename}{extension}"'
+
+        return response

+ 11 - 60
netbox/extras/models/models.py

@@ -6,7 +6,6 @@ from django.contrib.contenttypes.fields import GenericForeignKey, GenericRelatio
 from django.contrib.postgres.fields import ArrayField
 from django.core.validators import ValidationError
 from django.db import models
-from django.http import HttpResponse
 from django.urls import reverse
 from django.utils import timezone
 from django.utils.translation import gettext_lazy as _
@@ -16,12 +15,13 @@ from core.models import ObjectType
 from extras.choices import *
 from extras.conditions import ConditionSet
 from extras.constants import *
-from extras.utils import filename_from_model, image_upload
+from extras.utils import image_upload
+from extras.models.mixins import RenderTemplateMixin
 from netbox.config import get_config
 from netbox.events import get_event_type_choices
 from netbox.models import ChangeLoggedModel
 from netbox.models.features import (
-    CloningMixin, CustomFieldsMixin, CustomLinksMixin, ExportTemplatesMixin, SyncedDataMixin, TagsMixin,
+    CloningMixin, CustomFieldsMixin, CustomLinksMixin, ExportTemplatesMixin, SyncedDataMixin, TagsMixin
 )
 from utilities.html import clean_html
 from utilities.jinja2 import render_jinja2
@@ -382,7 +382,7 @@ class CustomLink(CloningMixin, ExportTemplatesMixin, ChangeLoggedModel):
         }
 
 
-class ExportTemplate(SyncedDataMixin, CloningMixin, ExportTemplatesMixin, ChangeLoggedModel):
+class ExportTemplate(SyncedDataMixin, CloningMixin, ExportTemplatesMixin, ChangeLoggedModel, RenderTemplateMixin):
     object_types = models.ManyToManyField(
         to='core.ObjectType',
         related_name='export_templates',
@@ -397,34 +397,6 @@ class ExportTemplate(SyncedDataMixin, CloningMixin, ExportTemplatesMixin, Change
         max_length=200,
         blank=True
     )
-    template_code = models.TextField(
-        help_text=_(
-            "Jinja2 template code. The list of objects being exported is passed as a context variable named "
-            "<code>queryset</code>."
-        )
-    )
-    mime_type = models.CharField(
-        max_length=50,
-        blank=True,
-        verbose_name=_('MIME type'),
-        help_text=_('Defaults to <code>text/plain; charset=utf-8</code>')
-    )
-    file_name = models.CharField(
-        max_length=200,
-        blank=True,
-        help_text=_('Filename to give to the rendered export file')
-    )
-    file_extension = models.CharField(
-        verbose_name=_('file extension'),
-        max_length=15,
-        blank=True,
-        help_text=_('Extension to append to the rendered filename')
-    )
-    as_attachment = models.BooleanField(
-        verbose_name=_('as attachment'),
-        default=True,
-        help_text=_("Download file as attachment")
-    )
 
     clone_fields = (
         'object_types', 'template_code', 'mime_type', 'file_name', 'file_extension', 'as_attachment',
@@ -460,37 +432,16 @@ class ExportTemplate(SyncedDataMixin, CloningMixin, ExportTemplatesMixin, Change
         self.template_code = self.data_file.data_as_string
     sync_data.alters_data = True
 
-    def render(self, queryset):
-        """
-        Render the contents of the template.
-        """
-        context = {
-            'queryset': queryset
+    def get_context(self, context=None, queryset=None):
+        _context = {
+            'queryset': queryset,
         }
-        output = render_jinja2(self.template_code, context)
-
-        # Replace CRLF-style line terminators
-        output = output.replace('\r\n', '\n')
-
-        return output
-
-    def render_to_response(self, queryset):
-        """
-        Render the template to an HTTP response, delivered as a named file attachment
-        """
-        output = self.render(queryset)
-        mime_type = 'text/plain; charset=utf-8' if not self.mime_type else self.mime_type
-
-        # Build the response
-        response = HttpResponse(output, content_type=mime_type)
 
-        if self.as_attachment:
-            extension = f'.{self.file_extension}' if self.file_extension else ''
-            filename = self.file_name or filename_from_model(queryset.model)
-            full_filename = f'{filename}{extension}'
-            response['Content-Disposition'] = f'attachment; filename="{full_filename}"'
+        # Apply the provided context data, if any
+        if context is not None:
+            _context.update(context)
 
-        return response
+        return _context
 
 
 class SavedFilter(CloningMixin, ExportTemplatesMixin, ChangeLoggedModel):

+ 25 - 2
netbox/extras/tables/tables.py

@@ -183,6 +183,15 @@ class ExportTemplateTable(NetBoxTable):
     object_types = columns.ContentTypesColumn(
         verbose_name=_('Object Types'),
     )
+    mime_type = tables.Column(
+        verbose_name=_('MIME Type')
+    )
+    file_name = tables.Column(
+        verbose_name=_('File Name'),
+    )
+    file_extension = tables.Column(
+        verbose_name=_('File Extension'),
+    )
     as_attachment = columns.BooleanColumn(
         verbose_name=_('As Attachment'),
         false_mark=None
@@ -527,6 +536,19 @@ class ConfigTemplateTable(NetBoxTable):
         orderable=False,
         verbose_name=_('Synced')
     )
+    mime_type = tables.Column(
+        verbose_name=_('MIME Type')
+    )
+    file_name = tables.Column(
+        verbose_name=_('File Name'),
+    )
+    file_extension = tables.Column(
+        verbose_name=_('File Extension'),
+    )
+    as_attachment = columns.BooleanColumn(
+        verbose_name=_('As Attachment'),
+        false_mark=None
+    )
     tags = columns.TagColumn(
         url_name='extras:configtemplate_list'
     )
@@ -554,8 +576,9 @@ class ConfigTemplateTable(NetBoxTable):
     class Meta(NetBoxTable.Meta):
         model = ConfigTemplate
         fields = (
-            'pk', 'id', 'name', 'description', 'data_source', 'data_file', 'data_synced', 'role_count',
-            'platform_count', 'device_count', 'vm_count', 'created', 'last_updated', 'tags',
+            'pk', 'id', 'name', 'description', 'data_source', 'data_file', 'data_synced', 'as_attachment',
+            'mime_type', 'file_name', 'file_extension', 'role_count', 'platform_count', 'device_count',
+            'vm_count', 'created', 'last_updated', 'tags',
         )
         default_columns = (
             'pk', 'name', 'description', 'is_synced', 'device_count', 'vm_count',

+ 5 - 1
netbox/extras/tests/test_api.py

@@ -755,6 +755,10 @@ class ConfigTemplateTest(APIViewTestCases.APIViewTestCase):
         {
             'name': 'Config Template 4',
             'template_code': 'Foo: {{ foo }}',
+            'mime_type': 'text/plain',
+            'file_name': 'output4',
+            'file_extension': 'txt',
+            'as_attachment': True,
         },
         {
             'name': 'Config Template 5',
@@ -778,7 +782,7 @@ class ConfigTemplateTest(APIViewTestCases.APIViewTestCase):
             ),
             ConfigTemplate(
                 name='Config Template 2',
-                template_code='Bar: {{ bar }}'
+                template_code='Bar: {{ bar }}',
             ),
             ConfigTemplate(
                 name='Config Template 3',

+ 78 - 20
netbox/extras/tests/test_filtersets.py

@@ -616,19 +616,39 @@ class BookmarkTestCase(TestCase, BaseFilterSetTests):
 class ExportTemplateTestCase(TestCase, ChangeLoggedFilterSetTests):
     queryset = ExportTemplate.objects.all()
     filterset = ExportTemplateFilterSet
-    ignore_fields = ('template_code', 'data_path')
+    ignore_fields = ('template_code', 'environment_params', 'data_path')
 
     @classmethod
     def setUpTestData(cls):
         object_types = ObjectType.objects.filter(model__in=['site', 'rack', 'device'])
 
         export_templates = (
-            ExportTemplate(name='Export Template 1', template_code='TESTING', description='foobar1'),
             ExportTemplate(
-                name='Export Template 2', template_code='TESTING', description='foobar2',
-                file_name='export_template_2', file_extension='nagios',
+                name='Export Template 1',
+                template_code='TESTING',
+                description='foobar1',
+                mime_type='text/foo',
+                file_name='foo',
+                file_extension='foo',
+                as_attachment=True,
+            ),
+            ExportTemplate(
+                name='Export Template 2',
+                template_code='TESTING',
+                description='foobar2',
+                mime_type='text/bar',
+                file_name='bar',
+                file_extension='bar',
+                as_attachment=True,
+            ),
+            ExportTemplate(
+                name='Export Template 3',
+                template_code='TESTING',
+                mime_type='text/baz',
+                file_name='baz',
+                file_extension='baz',
+                as_attachment=False,
             ),
-            ExportTemplate(name='Export Template 3', template_code='TESTING', file_name='export_filename'),
         )
         ExportTemplate.objects.bulk_create(export_templates)
         for i, et in enumerate(export_templates):
@@ -638,9 +658,6 @@ class ExportTemplateTestCase(TestCase, ChangeLoggedFilterSetTests):
         params = {'q': 'foobar1'}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
 
-        params = {'q': 'export_filename'}
-        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
-
     def test_name(self):
         params = {'name': ['Export Template 1', 'Export Template 2']}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
@@ -655,19 +672,21 @@ class ExportTemplateTestCase(TestCase, ChangeLoggedFilterSetTests):
         params = {'description': ['foobar1', 'foobar2']}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
 
+    def test_mime_type(self):
+        params = {'mime_type': ['text/foo', 'text/bar']}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+
     def test_file_name(self):
-        params = {'file_name': ['export_filename']}
-        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
+        params = {'file_name': ['foo', 'bar']}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
 
     def test_file_extension(self):
-        params = {'file_extension': ['nagios']}
-        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
-
-        params = {'file_name': ['export_template_2'], 'file_extension': ['nagios']}
-        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
+        params = {'file_extension': ['foo', 'bar']}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
 
-        params = {'file_name': 'export_filename', 'file_extension': ['nagios']}
-        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 0)
+    def test_as_attachment(self):
+        params = {'as_attachment': True}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
 
 
 class ImageAttachmentTestCase(TestCase, ChangeLoggedFilterSetTests):
@@ -1088,9 +1107,32 @@ class ConfigTemplateTestCase(TestCase, ChangeLoggedFilterSetTests):
     @classmethod
     def setUpTestData(cls):
         config_templates = (
-            ConfigTemplate(name='Config Template 1', template_code='TESTING', description='foobar1'),
-            ConfigTemplate(name='Config Template 2', template_code='TESTING', description='foobar2'),
-            ConfigTemplate(name='Config Template 3', template_code='TESTING'),
+            ConfigTemplate(
+                name='Config Template 1',
+                template_code='TESTING',
+                description='foobar1',
+                mime_type='text/foo',
+                file_name='foo',
+                file_extension='foo',
+                as_attachment=True,
+            ),
+            ConfigTemplate(
+                name='Config Template 2',
+                template_code='TESTING',
+                description='foobar2',
+                mime_type='text/bar',
+                file_name='bar',
+                file_extension='bar',
+                as_attachment=True,
+            ),
+            ConfigTemplate(
+                name='Config Template 3',
+                template_code='TESTING',
+                mime_type='text/baz',
+                file_name='baz',
+                file_extension='baz',
+                as_attachment=False,
+            ),
         )
         ConfigTemplate.objects.bulk_create(config_templates)
 
@@ -1106,6 +1148,22 @@ class ConfigTemplateTestCase(TestCase, ChangeLoggedFilterSetTests):
         params = {'description': ['foobar1', 'foobar2']}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
 
+    def test_mime_type(self):
+        params = {'mime_type': ['text/foo', 'text/bar']}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+
+    def test_file_name(self):
+        params = {'file_name': ['foo', 'bar']}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+
+    def test_file_extension(self):
+        params = {'file_extension': ['foo', 'bar']}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+
+    def test_as_attachment(self):
+        params = {'as_attachment': True}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+
 
 class TagTestCase(TestCase, ChangeLoggedFilterSetTests):
     queryset = Tag.objects.all()

+ 26 - 4
netbox/extras/tests/test_views.py

@@ -301,10 +301,13 @@ class ExportTemplateTestCase(ViewTestCases.PrimaryObjectViewTestCase):
     def setUpTestData(cls):
         site_type = ObjectType.objects.get_for_model(Site)
         TEMPLATE_CODE = """{% for object in queryset %}{{ object }}{% endfor %}"""
+        ENVIRONMENT_PARAMS = """{"trim_blocks": true}"""
 
         export_templates = (
             ExportTemplate(name='Export Template 1', template_code=TEMPLATE_CODE),
-            ExportTemplate(name='Export Template 2', template_code=TEMPLATE_CODE),
+            ExportTemplate(
+                name='Export Template 2', template_code=TEMPLATE_CODE, environment_params={"trim_blocks": True}
+            ),
             ExportTemplate(name='Export Template 3', template_code=TEMPLATE_CODE, file_name='export_template_3')
         )
         ExportTemplate.objects.bulk_create(export_templates)
@@ -315,6 +318,7 @@ class ExportTemplateTestCase(ViewTestCases.PrimaryObjectViewTestCase):
             'name': 'Export Template X',
             'object_types': [site_type.pk],
             'template_code': TEMPLATE_CODE,
+            'environment_params': ENVIRONMENT_PARAMS,
             'file_name': 'template_x',
         }
 
@@ -537,11 +541,23 @@ class ConfigTemplateTestCase(
     @classmethod
     def setUpTestData(cls):
         TEMPLATE_CODE = """Foo: {{ foo }}"""
+        ENVIRONMENT_PARAMS = """{"trim_blocks": true}"""
 
         config_templates = (
-            ConfigTemplate(name='Config Template 1', template_code=TEMPLATE_CODE),
-            ConfigTemplate(name='Config Template 2', template_code=TEMPLATE_CODE),
-            ConfigTemplate(name='Config Template 3', template_code=TEMPLATE_CODE),
+            ConfigTemplate(
+                name='Config Template 1',
+                template_code=TEMPLATE_CODE)
+            ,
+            ConfigTemplate(
+                name='Config Template 2',
+                template_code=TEMPLATE_CODE,
+                environment_params={"trim_blocks": True},
+            ),
+            ConfigTemplate(
+                name='Config Template 3',
+                template_code=TEMPLATE_CODE,
+                file_name='config_template_3',
+            ),
         )
         ConfigTemplate.objects.bulk_create(config_templates)
 
@@ -549,6 +565,8 @@ class ConfigTemplateTestCase(
             'name': 'Config Template X',
             'description': 'Config template',
             'template_code': TEMPLATE_CODE,
+            'environment_params': ENVIRONMENT_PARAMS,
+            'file_name': 'config_x',
         }
 
         cls.csv_update_data = (
@@ -560,6 +578,10 @@ class ConfigTemplateTestCase(
 
         cls.bulk_edit_data = {
             'description': 'New description',
+            'mime_type': 'text/html',
+            'file_name': 'output',
+            'file_extension': 'html',
+            'as_attachment': True,
         }
 
 

+ 11 - 0
netbox/extras/utils.py

@@ -22,6 +22,17 @@ def filename_from_model(model: models.Model) -> str:
     return f'netbox_{base}'
 
 
+def filename_from_object(context: dict) -> str:
+    """Standardises how we generate filenames from model class for exports"""
+    if 'device' in context:
+        base = f"{context['device'].name or 'config'}"
+    elif 'virtualmachine' in context:
+        base = f"{context['virtualmachine'].name or 'config'}"
+    else:
+        base = 'config'
+    return base
+
+
 def is_taggable(obj):
     """
     Return True if the instance can have Tags assigned to it; False otherwise.

+ 1 - 1
netbox/netbox/api/viewsets/mixins.py

@@ -45,7 +45,7 @@ class ExportTemplatesMixin:
             if et is None:
                 raise Http404
             queryset = self.filter_queryset(self.get_queryset())
-            return et.render_to_response(queryset)
+            return et.render_to_response(queryset=queryset)
 
         return super().list(request, *args, **kwargs)
 

+ 1 - 1
netbox/netbox/views/generic/bulk_views.py

@@ -107,7 +107,7 @@ class ObjectListView(BaseMultiObjectView, ActionsMixin, TableMixin):
             request: The current request
         """
         try:
-            return template.render_to_response(self.queryset)
+            return template.render_to_response(queryset=self.queryset)
         except Exception as e:
             messages.error(
                 request,

+ 19 - 3
netbox/templates/extras/configtemplate.html

@@ -4,8 +4,8 @@
 {% load i18n %}
 
 {% block content %}
-  <div class="row mb-3">
-    <div class="col col-md-5">
+  <div class="row">
+    <div class="col col-md-6">
       <div class="card">
         <h2 class="card-header">{% trans "Config Template" %}</h2>
         <table class="table table-hover attr-table">
@@ -17,6 +17,22 @@
             <th scope="row">{% trans "Description" %}</th>
             <td>{{ object.description|placeholder }}</td>
           </tr>
+          <tr>
+            <th scope="row">{% trans "MIME Type" %}</th>
+            <td>{{ object.mime_type|placeholder }}</td>
+          </tr>
+          <tr>
+            <th scope="row">{% trans "File Name" %}</th>
+            <td>{{ object.file_name|placeholder }}</td>
+          </tr>
+          <tr>
+            <th scope="row">{% trans "File Extension" %}</th>
+            <td>{{ object.file_extension|placeholder }}</td>
+          </tr>
+          <tr>
+            <th scope="row">{% trans "Attachment" %}</th>
+            <td>{% checkmark object.as_attachment %}</td>
+          </tr>
           <tr>
             <th scope="row">{% trans "Data Source" %}</th>
             <td>
@@ -51,7 +67,7 @@
       {% include 'inc/panels/tags.html' %}
       {% plugin_left_page object %}
     </div>
-    <div class="col col-md-7">
+    <div class="col col-md-6">
       <div class="card">
         <h2 class="card-header">{% trans "Environment Parameters" %}</h2>
         <div class="card-body">

+ 14 - 8
netbox/templates/extras/exporttemplate.html

@@ -6,8 +6,8 @@
 {% block title %}{{ object.name }}{% endblock %}
 
 {% block content %}
-  <div class="row mb-3">
-    <div class="col col-md-5">
+  <div class="row">
+    <div class="col col-md-6">
       <div class="card">
         <h2 class="card-header">{% trans "Export Template" %}</h2>
         <table class="table table-hover attr-table">
@@ -66,6 +66,9 @@
             </tr>
         </table>
       </div>
+      {% plugin_left_page object %}
+    </div>
+    <div class="col col-md-6">
       <div class="card">
         <h2 class="card-header">{% trans "Assigned Models" %}</h2>
         <table class="table table-hover attr-table">
@@ -76,14 +79,10 @@
           {% endfor %}
         </table>
       </div>
-      {% plugin_left_page object %}
-    </div>
-    <div class="col col-md-7">
       <div class="card">
-        <h2 class="card-header">{% trans "Template" %}</h2>
+        <h2 class="card-header">{% trans "Environment Parameters" %}</h2>
         <div class="card-body">
-          {% include 'inc/sync_warning.html' %}
-          <pre>{{ object.template_code }}</pre>
+          <pre>{{ object.environment_params }}</pre>
         </div>
       </div>
       {% plugin_right_page object %}
@@ -91,6 +90,13 @@
   </div>
   <div class="row">
     <div class="col col-md-12">
+      <div class="card">
+        <h2 class="card-header">{% trans "Template" %}</h2>
+        <div class="card-body">
+          {% include 'inc/sync_warning.html' %}
+          <pre>{{ object.template_code }}</pre>
+        </div>
+      </div>
       {% plugin_full_width_page object %}
     </div>
   </div>

+ 4 - 2
netbox/utilities/jinja2.py

@@ -7,6 +7,7 @@ from netbox.config import get_config
 
 __all__ = (
     'DataFileLoader',
+    'render_jinja2',
 )
 
 
@@ -48,10 +49,11 @@ class DataFileLoader(BaseLoader):
 # Utility functions
 #
 
-def render_jinja2(template_code, context):
+def render_jinja2(template_code, context, environment_params=None):
     """
     Render a Jinja2 template with the provided context. Return the rendered content.
     """
-    environment = SandboxedEnvironment()
+    environment_params = environment_params or {}
+    environment = SandboxedEnvironment(**environment_params)
     environment.filters.update(get_config().JINJA2_FILTERS)
     return environment.from_string(source=template_code).render(**context)

+ 1 - 5
netbox/virtualization/views.py

@@ -1,7 +1,6 @@
 from django.contrib import messages
 from django.db import transaction
 from django.db.models import Prefetch, Sum
-from django.http import HttpResponse
 from django.shortcuts import get_object_or_404, redirect, render
 from django.urls import reverse
 from django.utils.translation import gettext_lazy as _
@@ -431,10 +430,7 @@ class VirtualMachineRenderConfigView(generic.ObjectView):
         # If a direct export has been requested, return the rendered template content as a
         # downloadable file.
         if request.GET.get('export'):
-            content = context['rendered_config'] or context['error_message']
-            response = HttpResponse(content, content_type='text')
-            filename = f"{instance.name or 'config'}.txt"
-            response['Content-Disposition'] = f'attachment; filename="{filename}"'
+            response = context['config_template'].render_to_response(context=context['context_data'])
             return response
 
         return render(request, self.get_template_name(), {