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

Fixes #17443: Adds ExportTemplate.file_name field (#18911)

* Fixes #17443: Adds ExportTemplate.file_name field

* Addresses PR feedback

- Adds `file_name` to `ExportTemplateBulkEditForm.nullable_fields`
- Shortens max length of `ExportTemplate.file_name` to 200 chars
- Adds tests for `ExportTemplateFilterSet.file_extension`

* Fixes migration conflict caused by fix for #17841
Jason Novinger 11 месяцев назад
Родитель
Сommit
80440fd025

+ 6 - 0
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).

+ 2 - 2
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')

+ 4 - 3
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)
         )
 
 

+ 5 - 1
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):

+ 2 - 1
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',
         )
 
 

+ 5 - 1
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

+ 1 - 1
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:

+ 0 - 2
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
 
 

+ 16 - 0
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),
+        ),
+    ]

+ 10 - 5
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 <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,
@@ -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
 

+ 4 - 3
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',
         )
 
 

+ 7 - 2
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):

+ 22 - 2
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()

+ 19 - 0
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)

+ 6 - 5
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 = (

+ 7 - 0
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.

+ 4 - 0
netbox/templates/extras/exporttemplate.html

@@ -23,6 +23,10 @@
             <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>