diff --git a/docs/models/extras/configtemplate.md b/docs/models/extras/configtemplate.md
index b580d6885..6b245e5e9 100644
--- a/docs/models/extras/configtemplate.md
+++ b/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).
\ No newline at end of file
diff --git a/docs/models/extras/exporttemplate.md b/docs/models/extras/exporttemplate.md
index 73be522b8..86e1ae04a 100644
--- a/docs/models/extras/exporttemplate.md
+++ b/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).
diff --git a/netbox/dcim/views.py b/netbox/dcim/views.py
index 2027a7368..1e231ae3a 100644
--- a/netbox/dcim/views.py
+++ b/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(), {
diff --git a/netbox/extras/api/serializers_/configtemplates.py b/netbox/extras/api/serializers_/configtemplates.py
index c4a683c74..69652907e 100644
--- a/netbox/extras/api/serializers_/configtemplates.py
+++ b/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')
diff --git a/netbox/extras/api/serializers_/exporttemplates.py b/netbox/extras/api/serializers_/exporttemplates.py
index ad77cd1f7..0d19d642c 100644
--- a/netbox/extras/api/serializers_/exporttemplates.py
+++ b/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')
diff --git a/netbox/extras/constants.py b/netbox/extras/constants.py
index 123b771f6..db378c8fa 100644
--- a/netbox/extras/constants.py
+++ b/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'
diff --git a/netbox/extras/filtersets.py b/netbox/extras/filtersets.py
index 8381316cc..89dd4c9f1 100644
--- a/netbox/extras/filtersets.py
+++ b/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():
diff --git a/netbox/extras/forms/bulk_edit.py b/netbox/extras/forms/bulk_edit.py
index 6891edc5d..7a78dba8b 100644
--- a/netbox/extras/forms/bulk_edit.py
+++ b/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):
diff --git a/netbox/extras/forms/bulk_import.py b/netbox/extras/forms/bulk_import.py
index fb522bd7b..7c270da4a 100644
--- a/netbox/extras/forms/bulk_import.py
+++ b/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',
)
diff --git a/netbox/extras/forms/filtersets.py b/netbox/extras/forms/filtersets.py
index 0a50047fe..056ca62a5 100644
--- a/netbox/extras/forms/filtersets.py
+++ b/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):
diff --git a/netbox/extras/forms/model_forms.py b/netbox/extras/forms/model_forms.py
index b5bc06b40..594b7d9d0 100644
--- a/netbox/extras/forms/model_forms.py
+++ b/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:
diff --git a/netbox/extras/graphql/filters.py b/netbox/extras/graphql/filters.py
index e22bda0ac..b8db143e4 100644
--- a/netbox/extras/graphql/filters.py
+++ b/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()
diff --git a/netbox/extras/migrations/0126_configtemplate_as_attachment_and_more.py b/netbox/extras/migrations/0126_configtemplate_as_attachment_and_more.py
new file mode 100644
index 000000000..6d5aad62e
--- /dev/null
+++ b/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),
+ ),
+ ]
diff --git a/netbox/extras/models/configs.py b/netbox/extras/models/configs.py
index 6b52d4c02..204cc04f9 100644
--- a/netbox/extras/models/configs.py
+++ b/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 additional parameters'
- ' 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
diff --git a/netbox/extras/models/mixins.py b/netbox/extras/models/mixins.py
index f22b32004..3a7273f93 100644
--- a/netbox/extras/models/mixins.py
+++ b/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 additional parameters 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 {default}
').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
diff --git a/netbox/extras/models/models.py b/netbox/extras/models/models.py
index 3cae54f29..76fad1082 100644
--- a/netbox/extras/models/models.py
+++ b/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 "
- "queryset
."
- )
- )
- mime_type = models.CharField(
- max_length=50,
- blank=True,
- verbose_name=_('MIME type'),
- help_text=_('Defaults to text/plain; charset=utf-8
')
- )
- 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')
+ # Apply the provided context data, if any
+ if context is not None:
+ _context.update(context)
- 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}"'
-
- return response
+ return _context
class SavedFilter(CloningMixin, ExportTemplatesMixin, ChangeLoggedModel):
diff --git a/netbox/extras/tables/tables.py b/netbox/extras/tables/tables.py
index 7a6e79cab..60b207058 100644
--- a/netbox/extras/tables/tables.py
+++ b/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',
diff --git a/netbox/extras/tests/test_api.py b/netbox/extras/tests/test_api.py
index 7a4d63549..6e3fb37fc 100644
--- a/netbox/extras/tests/test_api.py
+++ b/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',
diff --git a/netbox/extras/tests/test_filtersets.py b/netbox/extras/tests/test_filtersets.py
index 247653472..987dfe0ff 100644
--- a/netbox/extras/tests/test_filtersets.py
+++ b/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_extension': ['foo', 'bar']}
+ self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
- params = {'file_name': ['export_template_2'], 'file_extension': ['nagios']}
- self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
-
- 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()
diff --git a/netbox/extras/tests/test_views.py b/netbox/extras/tests/test_views.py
index 0688cd2c2..6378b29b8 100644
--- a/netbox/extras/tests/test_views.py
+++ b/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,
}
diff --git a/netbox/extras/utils.py b/netbox/extras/utils.py
index 411d80f78..155597c30 100644
--- a/netbox/extras/utils.py
+++ b/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.
diff --git a/netbox/netbox/api/viewsets/mixins.py b/netbox/netbox/api/viewsets/mixins.py
index e07e2c78b..e21be2348 100644
--- a/netbox/netbox/api/viewsets/mixins.py
+++ b/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)
diff --git a/netbox/netbox/views/generic/bulk_views.py b/netbox/netbox/views/generic/bulk_views.py
index 88857ad54..447e2a6c5 100644
--- a/netbox/netbox/views/generic/bulk_views.py
+++ b/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,
diff --git a/netbox/templates/extras/configtemplate.html b/netbox/templates/extras/configtemplate.html
index 5218a3b8f..eeb962cfb 100644
--- a/netbox/templates/extras/configtemplate.html
+++ b/netbox/templates/extras/configtemplate.html
@@ -4,8 +4,8 @@
{% load i18n %}
{% block content %}
-
{% trans "Description" %} | {{ object.description|placeholder }} | +
---|---|
{% trans "MIME Type" %} | +{{ object.mime_type|placeholder }} | +
{% trans "File Name" %} | +{{ object.file_name|placeholder }} | +
{% trans "File Extension" %} | +{{ object.file_extension|placeholder }} | +
{% trans "Attachment" %} | +{% checkmark object.as_attachment %} | +
{% trans "Data Source" %} |
@@ -51,7 +67,7 @@
{% include 'inc/panels/tags.html' %}
{% plugin_left_page object %}
-
+ {% trans "Environment Parameters" %}
diff --git a/netbox/templates/extras/exporttemplate.html b/netbox/templates/extras/exporttemplate.html
index f0e370c03..fd1e56d11 100644
--- a/netbox/templates/extras/exporttemplate.html
+++ b/netbox/templates/extras/exporttemplate.html
@@ -6,8 +6,8 @@
{% block title %}{{ object.name }}{% endblock %}
{% block content %}
-
-
+
+ {% trans "Export Template" %}{% trans "Assigned Models" %}
-
{% plugin_right_page object %}
@@ -91,6 +90,13 @@
{% trans "Template" %}+{% trans "Environment Parameters" %}
- {% include 'inc/sync_warning.html' %}
-
{{ object.template_code }}+ {{ object.environment_params }}
+
+
{% plugin_full_width_page object %}
{% trans "Template" %}+
+ {% include 'inc/sync_warning.html' %}
+
+ {{ object.template_code }}+ |