diff --git a/docs/models/extras/exporttemplate.md b/docs/models/extras/exporttemplate.md
index d2f9292c6..73be522b8 100644
--- a/docs/models/extras/exporttemplate.md
+++ b/docs/models/extras/exporttemplate.md
@@ -24,6 +24,12 @@ Jinja2 template code for rendering the exported data.
The MIME type to indicate in the response when rendering the export template (optional). Defaults to `text/plain`.
+### File Name
+
+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/extras/api/serializers_/exporttemplates.py b/netbox/extras/api/serializers_/exporttemplates.py
index 11f502a02..ad77cd1f7 100644
--- a/netbox/extras/api/serializers_/exporttemplates.py
+++ b/netbox/extras/api/serializers_/exporttemplates.py
@@ -27,7 +27,7 @@ class ExportTemplateSerializer(ValidatedModelSerializer):
model = ExportTemplate
fields = [
'id', 'url', 'display_url', 'display', 'object_types', 'name', 'description', 'template_code', 'mime_type',
- 'file_extension', 'as_attachment', 'data_source', 'data_path', 'data_file', 'data_synced', 'created',
- 'last_updated',
+ '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/filtersets.py b/netbox/extras/filtersets.py
index 635102be2..e63b6d673 100644
--- a/netbox/extras/filtersets.py
+++ b/netbox/extras/filtersets.py
@@ -258,8 +258,8 @@ class ExportTemplateFilterSet(ChangeLoggedModelFilterSet):
class Meta:
model = ExportTemplate
fields = (
- 'id', 'name', 'description', 'mime_type', 'file_extension', 'as_attachment', 'auto_sync_enabled',
- 'data_synced',
+ 'id', 'name', 'description', 'mime_type', 'file_name', 'file_extension', 'as_attachment',
+ 'auto_sync_enabled', 'data_synced',
)
def search(self, queryset, name, value):
@@ -267,7 +267,8 @@ class ExportTemplateFilterSet(ChangeLoggedModelFilterSet):
return queryset
return queryset.filter(
Q(name__icontains=value) |
- Q(description__icontains=value)
+ Q(description__icontains=value) |
+ Q(file_name__icontains=value)
)
diff --git a/netbox/extras/forms/bulk_edit.py b/netbox/extras/forms/bulk_edit.py
index 7adc303f5..6891edc5d 100644
--- a/netbox/extras/forms/bulk_edit.py
+++ b/netbox/extras/forms/bulk_edit.py
@@ -155,6 +155,10 @@ class ExportTemplateBulkEditForm(BulkEditForm):
max_length=50,
required=False
)
+ file_name = forms.CharField(
+ label=_('File name'),
+ required=False
+ )
file_extension = forms.CharField(
label=_('File extension'),
max_length=15,
@@ -166,7 +170,7 @@ class ExportTemplateBulkEditForm(BulkEditForm):
widget=BulkEditNullBooleanSelect()
)
- nullable_fields = ('description', 'mime_type', 'file_extension')
+ nullable_fields = ('description', 'mime_type', 'file_name', 'file_extension')
class SavedFilterBulkEditForm(BulkEditForm):
diff --git a/netbox/extras/forms/bulk_import.py b/netbox/extras/forms/bulk_import.py
index b680382f6..fb522bd7b 100644
--- a/netbox/extras/forms/bulk_import.py
+++ b/netbox/extras/forms/bulk_import.py
@@ -144,7 +144,8 @@ class ExportTemplateImportForm(CSVModelForm):
class Meta:
model = ExportTemplate
fields = (
- 'name', 'object_types', 'description', 'mime_type', 'file_extension', 'as_attachment', 'template_code',
+ 'name', 'object_types', 'description', 'mime_type', 'file_name', 'file_extension', 'as_attachment',
+ 'template_code',
)
diff --git a/netbox/extras/forms/filtersets.py b/netbox/extras/forms/filtersets.py
index 05dcf96c4..1691559f9 100644
--- a/netbox/extras/forms/filtersets.py
+++ b/netbox/extras/forms/filtersets.py
@@ -162,7 +162,7 @@ class ExportTemplateFilterForm(SavedFiltersMixin, FilterForm):
fieldsets = (
FieldSet('q', 'filter_id'),
FieldSet('data_source_id', 'data_file_id', name=_('Data')),
- FieldSet('object_type_id', 'mime_type', 'file_extension', 'as_attachment', name=_('Attributes')),
+ FieldSet('object_type_id', 'mime_type', 'file_name', 'file_extension', 'as_attachment', name=_('Attributes')),
)
data_source_id = DynamicModelMultipleChoiceField(
queryset=DataSource.objects.all(),
@@ -186,6 +186,10 @@ class ExportTemplateFilterForm(SavedFiltersMixin, FilterForm):
required=False,
label=_('MIME type')
)
+ file_name = forms.CharField(
+ label=_('File name'),
+ required=False
+ )
file_extension = forms.CharField(
label=_('File extension'),
required=False
diff --git a/netbox/extras/forms/model_forms.py b/netbox/extras/forms/model_forms.py
index 5591de754..b5bc06b40 100644
--- a/netbox/extras/forms/model_forms.py
+++ b/netbox/extras/forms/model_forms.py
@@ -246,7 +246,7 @@ 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_extension', 'as_attachment', name=_('Rendering')),
+ FieldSet('mime_type', 'file_name', 'file_extension', 'as_attachment', name=_('Rendering')),
)
class Meta:
diff --git a/netbox/extras/migrations/0124_alter_tag_options_tag_weight.py b/netbox/extras/migrations/0124_alter_tag_options_tag_weight.py
index 86fc71fd5..759ad1595 100644
--- a/netbox/extras/migrations/0124_alter_tag_options_tag_weight.py
+++ b/netbox/extras/migrations/0124_alter_tag_options_tag_weight.py
@@ -1,5 +1,3 @@
-# Generated by Django 5.2b1 on 2025-03-17 14:41
-
from django.db import migrations, models
diff --git a/netbox/extras/migrations/0125_exporttemplate_file_name.py b/netbox/extras/migrations/0125_exporttemplate_file_name.py
new file mode 100644
index 000000000..2c8ac118b
--- /dev/null
+++ b/netbox/extras/migrations/0125_exporttemplate_file_name.py
@@ -0,0 +1,16 @@
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('extras', '0124_alter_tag_options_tag_weight'),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name='exporttemplate',
+ name='file_name',
+ field=models.CharField(blank=True, max_length=200),
+ ),
+ ]
diff --git a/netbox/extras/models/models.py b/netbox/extras/models/models.py
index d3e443b14..3cae54f29 100644
--- a/netbox/extras/models/models.py
+++ b/netbox/extras/models/models.py
@@ -16,7 +16,7 @@ from core.models import ObjectType
from extras.choices import *
from extras.conditions import ConditionSet
from extras.constants import *
-from extras.utils import image_upload
+from extras.utils import filename_from_model, image_upload
from netbox.config import get_config
from netbox.events import get_event_type_choices
from netbox.models import ChangeLoggedModel
@@ -409,6 +409,11 @@ class ExportTemplate(SyncedDataMixin, CloningMixin, ExportTemplatesMixin, Change
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,
@@ -422,7 +427,7 @@ class ExportTemplate(SyncedDataMixin, CloningMixin, ExportTemplatesMixin, Change
)
clone_fields = (
- 'object_types', 'template_code', 'mime_type', 'file_extension', 'as_attachment',
+ 'object_types', 'template_code', 'mime_type', 'file_name', 'file_extension', 'as_attachment',
)
class Meta:
@@ -480,10 +485,10 @@ class ExportTemplate(SyncedDataMixin, CloningMixin, ExportTemplatesMixin, Change
response = HttpResponse(output, content_type=mime_type)
if self.as_attachment:
- basename = queryset.model._meta.verbose_name_plural.replace(' ', '_')
extension = f'.{self.file_extension}' if self.file_extension else ''
- filename = f'netbox_{basename}{extension}'
- response['Content-Disposition'] = f'attachment; filename="{filename}"'
+ 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
diff --git a/netbox/extras/tables/tables.py b/netbox/extras/tables/tables.py
index a14356c1c..7a6e79cab 100644
--- a/netbox/extras/tables/tables.py
+++ b/netbox/extras/tables/tables.py
@@ -203,11 +203,12 @@ class ExportTemplateTable(NetBoxTable):
class Meta(NetBoxTable.Meta):
model = ExportTemplate
fields = (
- 'pk', 'id', 'name', 'object_types', 'description', 'mime_type', 'file_extension', 'as_attachment',
- 'data_source', 'data_file', 'data_synced', 'created', 'last_updated',
+ 'pk', 'id', 'name', 'object_types', 'description', 'mime_type', 'file_name', 'file_extension',
+ 'as_attachment', 'data_source', 'data_file', 'data_synced', 'created', 'last_updated',
)
default_columns = (
- 'pk', 'name', 'object_types', 'description', 'mime_type', 'file_extension', 'as_attachment', 'is_synced',
+ 'pk', 'name', 'object_types', 'description', 'mime_type', 'file_name', 'file_extension',
+ 'as_attachment', 'is_synced',
)
diff --git a/netbox/extras/tests/test_api.py b/netbox/extras/tests/test_api.py
index 1d6dfac6d..7a4d63549 100644
--- a/netbox/extras/tests/test_api.py
+++ b/netbox/extras/tests/test_api.py
@@ -479,6 +479,7 @@ class ExportTemplateTest(APIViewTestCases.APIViewTestCase):
'object_types': ['dcim.device'],
'name': 'Test Export Template 6',
'template_code': '{% for obj in queryset %}{{ obj.name }}\n{% endfor %}',
+ 'file_name': 'test_export_template_6',
},
]
bulk_update_data = {
@@ -494,7 +495,9 @@ class ExportTemplateTest(APIViewTestCases.APIViewTestCase):
),
ExportTemplate(
name='Export Template 2',
- template_code='{% for obj in queryset %}{{ obj.name }}\n{% endfor %}'
+ template_code='{% for obj in queryset %}{{ obj.name }}\n{% endfor %}',
+ file_name='export_template_2',
+ file_extension='test',
),
ExportTemplate(
name='Export Template 3',
@@ -502,8 +505,10 @@ class ExportTemplateTest(APIViewTestCases.APIViewTestCase):
),
)
ExportTemplate.objects.bulk_create(export_templates)
+
+ device_object_type = ObjectType.objects.get_for_model(Device)
for et in export_templates:
- et.object_types.set([ObjectType.objects.get_for_model(Device)])
+ et.object_types.set([device_object_type])
class TagTest(APIViewTestCases.APIViewTestCase):
diff --git a/netbox/extras/tests/test_filtersets.py b/netbox/extras/tests/test_filtersets.py
index c6c53bfcb..ff4543bd2 100644
--- a/netbox/extras/tests/test_filtersets.py
+++ b/netbox/extras/tests/test_filtersets.py
@@ -624,8 +624,11 @@ class ExportTemplateTestCase(TestCase, ChangeLoggedFilterSetTests):
export_templates = (
ExportTemplate(name='Export Template 1', template_code='TESTING', description='foobar1'),
- ExportTemplate(name='Export Template 2', template_code='TESTING', description='foobar2'),
- ExportTemplate(name='Export Template 3', template_code='TESTING'),
+ ExportTemplate(
+ name='Export Template 2', template_code='TESTING', description='foobar2',
+ file_name='export_template_2', file_extension='nagios',
+ ),
+ 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):
@@ -635,6 +638,9 @@ 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)
@@ -649,6 +655,20 @@ class ExportTemplateTestCase(TestCase, ChangeLoggedFilterSetTests):
params = {'description': ['foobar1', 'foobar2']}
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)
+
+ 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_name': 'export_filename', 'file_extension': ['nagios']}
+ self.assertEqual(self.filterset(params, self.queryset).qs.count(), 0)
+
class ImageAttachmentTestCase(TestCase, ChangeLoggedFilterSetTests):
queryset = ImageAttachment.objects.all()
diff --git a/netbox/extras/tests/test_utils.py b/netbox/extras/tests/test_utils.py
new file mode 100644
index 000000000..b851acab8
--- /dev/null
+++ b/netbox/extras/tests/test_utils.py
@@ -0,0 +1,19 @@
+from django.test import TestCase
+
+from extras.models import ExportTemplate
+from extras.utils import filename_from_model
+from tenancy.models import ContactGroup, TenantGroup
+from wireless.models import WirelessLANGroup
+
+
+class FilenameFromModelTests(TestCase):
+ def test_expected_output(self):
+ cases = (
+ (ExportTemplate, 'netbox_export_templates'),
+ (ContactGroup, 'netbox_contact_groups'),
+ (TenantGroup, 'netbox_tenant_groups'),
+ (WirelessLANGroup, 'netbox_wireless_lan_groups'),
+ )
+
+ for model, expected in cases:
+ self.assertEqual(filename_from_model(model), expected)
diff --git a/netbox/extras/tests/test_views.py b/netbox/extras/tests/test_views.py
index be8d80b2b..0688cd2c2 100644
--- a/netbox/extras/tests/test_views.py
+++ b/netbox/extras/tests/test_views.py
@@ -305,7 +305,7 @@ class ExportTemplateTestCase(ViewTestCases.PrimaryObjectViewTestCase):
export_templates = (
ExportTemplate(name='Export Template 1', template_code=TEMPLATE_CODE),
ExportTemplate(name='Export Template 2', template_code=TEMPLATE_CODE),
- ExportTemplate(name='Export Template 3', template_code=TEMPLATE_CODE),
+ ExportTemplate(name='Export Template 3', template_code=TEMPLATE_CODE, file_name='export_template_3')
)
ExportTemplate.objects.bulk_create(export_templates)
for et in export_templates:
@@ -315,13 +315,14 @@ class ExportTemplateTestCase(ViewTestCases.PrimaryObjectViewTestCase):
'name': 'Export Template X',
'object_types': [site_type.pk],
'template_code': TEMPLATE_CODE,
+ 'file_name': 'template_x',
}
cls.csv_data = (
- "name,object_types,template_code",
- f"Export Template 4,dcim.site,{TEMPLATE_CODE}",
- f"Export Template 5,dcim.site,{TEMPLATE_CODE}",
- f"Export Template 6,dcim.site,{TEMPLATE_CODE}",
+ "name,object_types,template_code,file_name",
+ f"Export Template 4,dcim.site,{TEMPLATE_CODE},",
+ f"Export Template 5,dcim.site,{TEMPLATE_CODE},template_5",
+ f"Export Template 6,dcim.site,{TEMPLATE_CODE},",
)
cls.csv_update_data = (
diff --git a/netbox/extras/utils.py b/netbox/extras/utils.py
index efe7ada5b..411d80f78 100644
--- a/netbox/extras/utils.py
+++ b/netbox/extras/utils.py
@@ -1,6 +1,7 @@
import importlib
from django.core.exceptions import ImproperlyConfigured
+from django.db import models
from taggit.managers import _TaggableManager
from netbox.context import current_request
@@ -15,6 +16,12 @@ __all__ = (
)
+def filename_from_model(model: models.Model) -> str:
+ """Standardises how we generate filenames from model class for exports"""
+ base = model._meta.verbose_name_plural.lower().replace(' ', '_')
+ return f'netbox_{base}'
+
+
def is_taggable(obj):
"""
Return True if the instance can have Tags assigned to it; False otherwise.
diff --git a/netbox/templates/extras/exporttemplate.html b/netbox/templates/extras/exporttemplate.html
index 5a19426f2..f0e370c03 100644
--- a/netbox/templates/extras/exporttemplate.html
+++ b/netbox/templates/extras/exporttemplate.html
@@ -23,6 +23,10 @@