Procházet zdrojové kódy

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 před 10 měsíci
rodič
revize
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.
 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
 ### 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.
 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
 ### 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.
 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.
 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
 ### MIME Type
 
 
 The MIME type to indicate in the response when rendering the export template (optional). Defaults to `text/plain`.
 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).
 The file name to give to the rendered export file (optional).
 
 
-!!! info "This field was introduced in NetBox v4.3."
-
 ### File Extension
 ### File Extension
 
 
 The file extension to append to the file name in the response (optional).
 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 import transaction
 from django.db.models import Prefetch
 from django.db.models import Prefetch
 from django.forms import ModelMultipleChoiceField, MultipleHiddenInput, modelformset_factory
 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.shortcuts import get_object_or_404, redirect, render
 from django.urls import reverse
 from django.urls import reverse
 from django.utils.html import escape
 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
         # If a direct export has been requested, return the rendered template content as a
         # downloadable file.
         # downloadable file.
         if request.GET.get('export'):
         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 response
 
 
         return render(request, self.get_template_name(), {
         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
         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',
-            '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')
         brief_fields = ('id', 'url', 'display', 'name', 'description')

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

@@ -26,8 +26,8 @@ class ExportTemplateSerializer(ValidatedModelSerializer):
     class Meta:
     class Meta:
         model = ExportTemplate
         model = ExportTemplate
         fields = [
         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')
         brief_fields = ('id', 'url', 'display', 'name', 'description')

+ 3 - 0
netbox/extras/constants.py

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

+ 4 - 1
netbox/extras/filtersets.py

@@ -707,7 +707,10 @@ class ConfigTemplateFilterSet(ChangeLoggedModelFilterSet):
 
 
     class Meta:
     class Meta:
         model = ConfigTemplate
         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):
     def search(self, queryset, name, value):
         if not value.strip():
         if not value.strip():

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

@@ -321,8 +321,27 @@ class ConfigTemplateBulkEditForm(BulkEditForm):
         max_length=200,
         max_length=200,
         required=False
         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):
 class JournalEntryBulkEditForm(BulkEditForm):

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

@@ -144,8 +144,8 @@ class ExportTemplateImportForm(CSVModelForm):
     class Meta:
     class Meta:
         model = ExportTemplate
         model = ExportTemplate
         fields = (
         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:
     class Meta:
         model = ConfigTemplate
         model = ConfigTemplate
         fields = (
         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):
 class ExportTemplateFilterForm(SavedFiltersMixin, FilterForm):
     fieldsets = (
     fieldsets = (
-        FieldSet('q', 'filter_id'),
+        FieldSet('q', 'filter_id', 'object_type_id'),
         FieldSet('data_source_id', 'data_file_id', name=_('Data')),
         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(
     data_source_id = DynamicModelMultipleChoiceField(
         queryset=DataSource.objects.all(),
         queryset=DataSource.objects.all(),
@@ -410,6 +410,7 @@ class ConfigTemplateFilterForm(SavedFiltersMixin, FilterForm):
     fieldsets = (
     fieldsets = (
         FieldSet('q', 'filter_id', 'tag'),
         FieldSet('q', 'filter_id', 'tag'),
         FieldSet('data_source_id', 'data_file_id', name=_('Data')),
         FieldSet('data_source_id', 'data_file_id', name=_('Data')),
+        FieldSet('mime_type', 'file_name', 'file_extension', 'as_attachment', name=_('Rendering'))
     )
     )
     data_source_id = DynamicModelMultipleChoiceField(
     data_source_id = DynamicModelMultipleChoiceField(
         queryset=DataSource.objects.all(),
         queryset=DataSource.objects.all(),
@@ -425,6 +426,25 @@ class ConfigTemplateFilterForm(SavedFiltersMixin, FilterForm):
         }
         }
     )
     )
     tag = TagFilterField(ConfigTemplate)
     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):
 class LocalConfigContextFilterForm(forms.Form):

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

@@ -246,7 +246,9 @@ class ExportTemplateForm(SyncedDataMixin, forms.ModelForm):
     fieldsets = (
     fieldsets = (
         FieldSet('name', 'object_types', 'description', 'template_code', name=_('Export Template')),
         FieldSet('name', 'object_types', 'description', 'template_code', name=_('Export Template')),
         FieldSet('data_source', 'data_file', 'auto_sync_enabled', name=_('Data Source')),
         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:
     class Meta:
@@ -631,9 +633,11 @@ class ConfigTemplateForm(SyncedDataMixin, forms.ModelForm):
     )
     )
 
 
     fieldsets = (
     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('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:
     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 = (
     environment_params: Annotated['JSONFilter', strawberry.lazy('netbox.graphql.filter_lookups')] | None = (
         strawberry_django.filter_field()
         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)
 @strawberry_django.filter(models.CustomField, lookups=True)
@@ -193,7 +197,11 @@ class ExportTemplateFilter(BaseObjectTypeFilterMixin, SyncedDataFilterMixin, Cha
     name: FilterLookup[str] | None = strawberry_django.filter_field()
     name: FilterLookup[str] | None = strawberry_django.filter_field()
     description: FilterLookup[str] | None = strawberry_django.filter_field()
     description: FilterLookup[str] | None = strawberry_django.filter_field()
     template_code: 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()
     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()
     file_extension: FilterLookup[str] | None = strawberry_django.filter_field()
     as_attachment: FilterLookup[bool] | 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.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.loaders import BaseLoader
-from jinja2.sandbox import SandboxedEnvironment
 
 
+from extras.models.mixins import RenderTemplateMixin
 from extras.querysets import ConfigContextQuerySet
 from extras.querysets import ConfigContextQuerySet
-from netbox.config import get_config
 from netbox.models import ChangeLoggedModel
 from netbox.models import ChangeLoggedModel
 from netbox.models.features import CloningMixin, CustomLinksMixin, ExportTemplatesMixin, SyncedDataMixin, TagsMixin
 from netbox.models.features import CloningMixin, CustomLinksMixin, ExportTemplatesMixin, SyncedDataMixin, TagsMixin
 from netbox.registry import registry
 from netbox.registry import registry
 from utilities.data import deepmerge
 from utilities.data import deepmerge
-from utilities.jinja2 import DataFileLoader
 
 
 __all__ = (
 __all__ = (
     'ConfigContext',
     'ConfigContext',
@@ -210,7 +207,9 @@ class ConfigContextModel(models.Model):
 # Config templates
 # Config templates
 #
 #
 
 
-class ConfigTemplate(SyncedDataMixin, CustomLinksMixin, ExportTemplatesMixin, TagsMixin, ChangeLoggedModel):
+class ConfigTemplate(
+    RenderTemplateMixin, SyncedDataMixin, CustomLinksMixin, ExportTemplatesMixin, TagsMixin, ChangeLoggedModel
+):
     name = models.CharField(
     name = models.CharField(
         verbose_name=_('name'),
         verbose_name=_('name'),
         max_length=100
         max_length=100
@@ -220,20 +219,6 @@ class ConfigTemplate(SyncedDataMixin, CustomLinksMixin, ExportTemplatesMixin, Ta
         max_length=200,
         max_length=200,
         blank=True
         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:
     class Meta:
         ordering = ('name',)
         ordering = ('name',)
@@ -253,13 +238,8 @@ class ConfigTemplate(SyncedDataMixin, CustomLinksMixin, ExportTemplatesMixin, Ta
         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 render(self, context=None):
-        """
-        Render the contents of the template.
-        """
+    def get_context(self, context=None, queryset=None):
         _context = dict()
         _context = dict()
-
-        # Populate the default template context with NetBox model classes, namespaced by app
         for app, model_names in registry['models'].items():
         for app, model_names in registry['models'].items():
             _context.setdefault(app, {})
             _context.setdefault(app, {})
             for model_name in model_names:
             for model_name in model_names:
@@ -269,37 +249,8 @@ class ConfigTemplate(SyncedDataMixin, CustomLinksMixin, ExportTemplatesMixin, Ta
                 except LookupError:
                 except LookupError:
                     pass
                     pass
 
 
-        # Add the provided context data, if any
+        # Apply the provided context data, if any
         if context is not None:
         if context is not None:
             _context.update(context)
             _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 os
 import sys
 import sys
 from django.core.files.storage import storages
 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__ = (
 __all__ = (
     'PythonModuleMixin',
     'PythonModuleMixin',
+    'RenderTemplateMixin',
 )
 )
 
 
 
 
@@ -66,3 +75,86 @@ class PythonModuleMixin:
         loader.exec_module(module)
         loader.exec_module(module)
 
 
         return 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.contrib.postgres.fields import ArrayField
 from django.core.validators import ValidationError
 from django.core.validators import ValidationError
 from django.db import models
 from django.db import models
-from django.http import HttpResponse
 from django.urls import reverse
 from django.urls import reverse
 from django.utils import timezone
 from django.utils import timezone
 from django.utils.translation import gettext_lazy as _
 from django.utils.translation import gettext_lazy as _
@@ -16,12 +15,13 @@ from core.models import ObjectType
 from extras.choices import *
 from extras.choices import *
 from extras.conditions import ConditionSet
 from extras.conditions import ConditionSet
 from extras.constants import *
 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.config import get_config
 from netbox.events import get_event_type_choices
 from netbox.events import get_event_type_choices
 from netbox.models import ChangeLoggedModel
 from netbox.models import ChangeLoggedModel
 from netbox.models.features import (
 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.html import clean_html
 from utilities.jinja2 import render_jinja2
 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(
     object_types = models.ManyToManyField(
         to='core.ObjectType',
         to='core.ObjectType',
         related_name='export_templates',
         related_name='export_templates',
@@ -397,34 +397,6 @@ class ExportTemplate(SyncedDataMixin, CloningMixin, ExportTemplatesMixin, Change
         max_length=200,
         max_length=200,
         blank=True
         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 = (
     clone_fields = (
         'object_types', 'template_code', 'mime_type', 'file_name', 'file_extension', 'as_attachment',
         '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
         self.template_code = self.data_file.data_as_string
     sync_data.alters_data = True
     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):
 class SavedFilter(CloningMixin, ExportTemplatesMixin, ChangeLoggedModel):

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

@@ -183,6 +183,15 @@ class ExportTemplateTable(NetBoxTable):
     object_types = columns.ContentTypesColumn(
     object_types = columns.ContentTypesColumn(
         verbose_name=_('Object Types'),
         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(
     as_attachment = columns.BooleanColumn(
         verbose_name=_('As Attachment'),
         verbose_name=_('As Attachment'),
         false_mark=None
         false_mark=None
@@ -527,6 +536,19 @@ class ConfigTemplateTable(NetBoxTable):
         orderable=False,
         orderable=False,
         verbose_name=_('Synced')
         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(
     tags = columns.TagColumn(
         url_name='extras:configtemplate_list'
         url_name='extras:configtemplate_list'
     )
     )
@@ -554,8 +576,9 @@ 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', '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 = (
         default_columns = (
             'pk', 'name', 'description', 'is_synced', 'device_count', 'vm_count',
             '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',
             'name': 'Config Template 4',
             'template_code': 'Foo: {{ foo }}',
             'template_code': 'Foo: {{ foo }}',
+            'mime_type': 'text/plain',
+            'file_name': 'output4',
+            'file_extension': 'txt',
+            'as_attachment': True,
         },
         },
         {
         {
             'name': 'Config Template 5',
             'name': 'Config Template 5',
@@ -778,7 +782,7 @@ class ConfigTemplateTest(APIViewTestCases.APIViewTestCase):
             ),
             ),
             ConfigTemplate(
             ConfigTemplate(
                 name='Config Template 2',
                 name='Config Template 2',
-                template_code='Bar: {{ bar }}'
+                template_code='Bar: {{ bar }}',
             ),
             ),
             ConfigTemplate(
             ConfigTemplate(
                 name='Config Template 3',
                 name='Config Template 3',

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

@@ -616,19 +616,39 @@ class BookmarkTestCase(TestCase, BaseFilterSetTests):
 class ExportTemplateTestCase(TestCase, ChangeLoggedFilterSetTests):
 class ExportTemplateTestCase(TestCase, ChangeLoggedFilterSetTests):
     queryset = ExportTemplate.objects.all()
     queryset = ExportTemplate.objects.all()
     filterset = ExportTemplateFilterSet
     filterset = ExportTemplateFilterSet
-    ignore_fields = ('template_code', 'data_path')
+    ignore_fields = ('template_code', 'environment_params', 'data_path')
 
 
     @classmethod
     @classmethod
     def setUpTestData(cls):
     def setUpTestData(cls):
         object_types = ObjectType.objects.filter(model__in=['site', 'rack', 'device'])
         object_types = ObjectType.objects.filter(model__in=['site', 'rack', 'device'])
 
 
         export_templates = (
         export_templates = (
-            ExportTemplate(name='Export Template 1', template_code='TESTING', description='foobar1'),
             ExportTemplate(
             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)
         ExportTemplate.objects.bulk_create(export_templates)
         for i, et in enumerate(export_templates):
         for i, et in enumerate(export_templates):
@@ -638,9 +658,6 @@ class ExportTemplateTestCase(TestCase, ChangeLoggedFilterSetTests):
         params = {'q': 'foobar1'}
         params = {'q': 'foobar1'}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
         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):
     def test_name(self):
         params = {'name': ['Export Template 1', 'Export Template 2']}
         params = {'name': ['Export Template 1', 'Export Template 2']}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
@@ -655,19 +672,21 @@ class ExportTemplateTestCase(TestCase, ChangeLoggedFilterSetTests):
         params = {'description': ['foobar1', 'foobar2']}
         params = {'description': ['foobar1', 'foobar2']}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
         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):
     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):
     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):
 class ImageAttachmentTestCase(TestCase, ChangeLoggedFilterSetTests):
@@ -1088,9 +1107,32 @@ class ConfigTemplateTestCase(TestCase, ChangeLoggedFilterSetTests):
     @classmethod
     @classmethod
     def setUpTestData(cls):
     def setUpTestData(cls):
         config_templates = (
         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)
         ConfigTemplate.objects.bulk_create(config_templates)
 
 
@@ -1106,6 +1148,22 @@ class ConfigTemplateTestCase(TestCase, ChangeLoggedFilterSetTests):
         params = {'description': ['foobar1', 'foobar2']}
         params = {'description': ['foobar1', 'foobar2']}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
         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):
 class TagTestCase(TestCase, ChangeLoggedFilterSetTests):
     queryset = Tag.objects.all()
     queryset = Tag.objects.all()

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

@@ -301,10 +301,13 @@ class ExportTemplateTestCase(ViewTestCases.PrimaryObjectViewTestCase):
     def setUpTestData(cls):
     def setUpTestData(cls):
         site_type = ObjectType.objects.get_for_model(Site)
         site_type = ObjectType.objects.get_for_model(Site)
         TEMPLATE_CODE = """{% for object in queryset %}{{ object }}{% endfor %}"""
         TEMPLATE_CODE = """{% for object in queryset %}{{ object }}{% endfor %}"""
+        ENVIRONMENT_PARAMS = """{"trim_blocks": true}"""
 
 
         export_templates = (
         export_templates = (
             ExportTemplate(name='Export Template 1', template_code=TEMPLATE_CODE),
             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(name='Export Template 3', template_code=TEMPLATE_CODE, file_name='export_template_3')
         )
         )
         ExportTemplate.objects.bulk_create(export_templates)
         ExportTemplate.objects.bulk_create(export_templates)
@@ -315,6 +318,7 @@ class ExportTemplateTestCase(ViewTestCases.PrimaryObjectViewTestCase):
             'name': 'Export Template X',
             'name': 'Export Template X',
             'object_types': [site_type.pk],
             'object_types': [site_type.pk],
             'template_code': TEMPLATE_CODE,
             'template_code': TEMPLATE_CODE,
+            'environment_params': ENVIRONMENT_PARAMS,
             'file_name': 'template_x',
             'file_name': 'template_x',
         }
         }
 
 
@@ -537,11 +541,23 @@ class ConfigTemplateTestCase(
     @classmethod
     @classmethod
     def setUpTestData(cls):
     def setUpTestData(cls):
         TEMPLATE_CODE = """Foo: {{ foo }}"""
         TEMPLATE_CODE = """Foo: {{ foo }}"""
+        ENVIRONMENT_PARAMS = """{"trim_blocks": true}"""
 
 
         config_templates = (
         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)
         ConfigTemplate.objects.bulk_create(config_templates)
 
 
@@ -549,6 +565,8 @@ class ConfigTemplateTestCase(
             'name': 'Config Template X',
             'name': 'Config Template X',
             'description': 'Config template',
             'description': 'Config template',
             'template_code': TEMPLATE_CODE,
             'template_code': TEMPLATE_CODE,
+            'environment_params': ENVIRONMENT_PARAMS,
+            'file_name': 'config_x',
         }
         }
 
 
         cls.csv_update_data = (
         cls.csv_update_data = (
@@ -560,6 +578,10 @@ class ConfigTemplateTestCase(
 
 
         cls.bulk_edit_data = {
         cls.bulk_edit_data = {
             'description': 'New description',
             '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}'
     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):
 def is_taggable(obj):
     """
     """
     Return True if the instance can have Tags assigned to it; False otherwise.
     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:
             if et is None:
                 raise Http404
                 raise Http404
             queryset = self.filter_queryset(self.get_queryset())
             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)
         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
             request: The current request
         """
         """
         try:
         try:
-            return template.render_to_response(self.queryset)
+            return template.render_to_response(queryset=self.queryset)
         except Exception as e:
         except Exception as e:
             messages.error(
             messages.error(
                 request,
                 request,

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

@@ -4,8 +4,8 @@
 {% load i18n %}
 {% load i18n %}
 
 
 {% block content %}
 {% 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">
       <div class="card">
         <h2 class="card-header">{% trans "Config Template" %}</h2>
         <h2 class="card-header">{% trans "Config Template" %}</h2>
         <table class="table table-hover attr-table">
         <table class="table table-hover attr-table">
@@ -17,6 +17,22 @@
             <th scope="row">{% trans "Description" %}</th>
             <th scope="row">{% trans "Description" %}</th>
             <td>{{ object.description|placeholder }}</td>
             <td>{{ object.description|placeholder }}</td>
           </tr>
           </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>
           <tr>
             <th scope="row">{% trans "Data Source" %}</th>
             <th scope="row">{% trans "Data Source" %}</th>
             <td>
             <td>
@@ -51,7 +67,7 @@
       {% include 'inc/panels/tags.html' %}
       {% include 'inc/panels/tags.html' %}
       {% plugin_left_page object %}
       {% plugin_left_page object %}
     </div>
     </div>
-    <div class="col col-md-7">
+    <div class="col col-md-6">
       <div class="card">
       <div class="card">
         <h2 class="card-header">{% trans "Environment Parameters" %}</h2>
         <h2 class="card-header">{% trans "Environment Parameters" %}</h2>
         <div class="card-body">
         <div class="card-body">

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

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

+ 4 - 2
netbox/utilities/jinja2.py

@@ -7,6 +7,7 @@ from netbox.config import get_config
 
 
 __all__ = (
 __all__ = (
     'DataFileLoader',
     'DataFileLoader',
+    'render_jinja2',
 )
 )
 
 
 
 
@@ -48,10 +49,11 @@ class DataFileLoader(BaseLoader):
 # Utility functions
 # 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.
     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)
     environment.filters.update(get_config().JINJA2_FILTERS)
     return environment.from_string(source=template_code).render(**context)
     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.contrib import messages
 from django.db import transaction
 from django.db import transaction
 from django.db.models import Prefetch, Sum
 from django.db.models import Prefetch, Sum
-from django.http import HttpResponse
 from django.shortcuts import get_object_or_404, redirect, render
 from django.shortcuts import get_object_or_404, redirect, render
 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 _
@@ -431,10 +430,7 @@ class VirtualMachineRenderConfigView(generic.ObjectView):
         # If a direct export has been requested, return the rendered template content as a
         # If a direct export has been requested, return the rendered template content as a
         # downloadable file.
         # downloadable file.
         if request.GET.get('export'):
         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 response
 
 
         return render(request, self.get_template_name(), {
         return render(request, self.get_template_name(), {