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 @@ {% trans "MIME Type" %} {{ object.mime_type|placeholder }} + + {% trans "File Name" %} + {{ object.file_name|placeholder }} + {% trans "File Extension" %} {{ object.file_extension|placeholder }}