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
This commit is contained in:
Jason Novinger 2025-03-20 08:17:56 -05:00 committed by GitHub
parent 6b7d23d684
commit 80440fd025
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
17 changed files with 120 additions and 28 deletions

View File

@ -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`. 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 ### 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).

View File

@ -27,7 +27,7 @@ class ExportTemplateSerializer(ValidatedModelSerializer):
model = ExportTemplate model = ExportTemplate
fields = [ fields = [
'id', 'url', 'display_url', 'display', 'object_types', 'name', 'description', 'template_code', 'mime_type', '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', 'file_name', 'file_extension', 'as_attachment', 'data_source', 'data_path', 'data_file', 'data_synced',
'last_updated', 'created', 'last_updated',
] ]
brief_fields = ('id', 'url', 'display', 'name', 'description') brief_fields = ('id', 'url', 'display', 'name', 'description')

View File

@ -258,8 +258,8 @@ class ExportTemplateFilterSet(ChangeLoggedModelFilterSet):
class Meta: class Meta:
model = ExportTemplate model = ExportTemplate
fields = ( fields = (
'id', 'name', 'description', 'mime_type', 'file_extension', 'as_attachment', 'auto_sync_enabled', 'id', 'name', 'description', 'mime_type', 'file_name', 'file_extension', 'as_attachment',
'data_synced', 'auto_sync_enabled', 'data_synced',
) )
def search(self, queryset, name, value): def search(self, queryset, name, value):
@ -267,7 +267,8 @@ class ExportTemplateFilterSet(ChangeLoggedModelFilterSet):
return queryset return queryset
return queryset.filter( return queryset.filter(
Q(name__icontains=value) | Q(name__icontains=value) |
Q(description__icontains=value) Q(description__icontains=value) |
Q(file_name__icontains=value)
) )

View File

@ -155,6 +155,10 @@ class ExportTemplateBulkEditForm(BulkEditForm):
max_length=50, max_length=50,
required=False required=False
) )
file_name = forms.CharField(
label=_('File name'),
required=False
)
file_extension = forms.CharField( file_extension = forms.CharField(
label=_('File extension'), label=_('File extension'),
max_length=15, max_length=15,
@ -166,7 +170,7 @@ class ExportTemplateBulkEditForm(BulkEditForm):
widget=BulkEditNullBooleanSelect() widget=BulkEditNullBooleanSelect()
) )
nullable_fields = ('description', 'mime_type', 'file_extension') nullable_fields = ('description', 'mime_type', 'file_name', 'file_extension')
class SavedFilterBulkEditForm(BulkEditForm): class SavedFilterBulkEditForm(BulkEditForm):

View File

@ -144,7 +144,8 @@ class ExportTemplateImportForm(CSVModelForm):
class Meta: class Meta:
model = ExportTemplate model = ExportTemplate
fields = ( 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',
) )

View File

@ -162,7 +162,7 @@ class ExportTemplateFilterForm(SavedFiltersMixin, FilterForm):
fieldsets = ( fieldsets = (
FieldSet('q', 'filter_id'), FieldSet('q', 'filter_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_extension', 'as_attachment', name=_('Attributes')), FieldSet('object_type_id', 'mime_type', 'file_name', 'file_extension', 'as_attachment', name=_('Attributes')),
) )
data_source_id = DynamicModelMultipleChoiceField( data_source_id = DynamicModelMultipleChoiceField(
queryset=DataSource.objects.all(), queryset=DataSource.objects.all(),
@ -186,6 +186,10 @@ class ExportTemplateFilterForm(SavedFiltersMixin, FilterForm):
required=False, required=False,
label=_('MIME type') label=_('MIME type')
) )
file_name = forms.CharField(
label=_('File name'),
required=False
)
file_extension = forms.CharField( file_extension = forms.CharField(
label=_('File extension'), label=_('File extension'),
required=False required=False

View File

@ -246,7 +246,7 @@ 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_extension', 'as_attachment', name=_('Rendering')), FieldSet('mime_type', 'file_name', 'file_extension', 'as_attachment', name=_('Rendering')),
) )
class Meta: class Meta:

View File

@ -1,5 +1,3 @@
# Generated by Django 5.2b1 on 2025-03-17 14:41
from django.db import migrations, models from django.db import migrations, models

View File

@ -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),
),
]

View File

@ -16,7 +16,7 @@ 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 image_upload from extras.utils import filename_from_model, image_upload
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
@ -409,6 +409,11 @@ class ExportTemplate(SyncedDataMixin, CloningMixin, ExportTemplatesMixin, Change
verbose_name=_('MIME type'), verbose_name=_('MIME type'),
help_text=_('Defaults to <code>text/plain; charset=utf-8</code>') 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( file_extension = models.CharField(
verbose_name=_('file extension'), verbose_name=_('file extension'),
max_length=15, max_length=15,
@ -422,7 +427,7 @@ class ExportTemplate(SyncedDataMixin, CloningMixin, ExportTemplatesMixin, Change
) )
clone_fields = ( 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: class Meta:
@ -480,10 +485,10 @@ class ExportTemplate(SyncedDataMixin, CloningMixin, ExportTemplatesMixin, Change
response = HttpResponse(output, content_type=mime_type) response = HttpResponse(output, content_type=mime_type)
if self.as_attachment: if self.as_attachment:
basename = queryset.model._meta.verbose_name_plural.replace(' ', '_')
extension = f'.{self.file_extension}' if self.file_extension else '' extension = f'.{self.file_extension}' if self.file_extension else ''
filename = f'netbox_{basename}{extension}' filename = self.file_name or filename_from_model(queryset.model)
response['Content-Disposition'] = f'attachment; filename="{filename}"' full_filename = f'{filename}{extension}'
response['Content-Disposition'] = f'attachment; filename="{full_filename}"'
return response return response

View File

@ -203,11 +203,12 @@ class ExportTemplateTable(NetBoxTable):
class Meta(NetBoxTable.Meta): class Meta(NetBoxTable.Meta):
model = ExportTemplate model = ExportTemplate
fields = ( fields = (
'pk', 'id', 'name', 'object_types', 'description', 'mime_type', 'file_extension', 'as_attachment', 'pk', 'id', 'name', 'object_types', 'description', 'mime_type', 'file_name', 'file_extension',
'data_source', 'data_file', 'data_synced', 'created', 'last_updated', 'as_attachment', 'data_source', 'data_file', 'data_synced', 'created', 'last_updated',
) )
default_columns = ( 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',
) )

View File

@ -479,6 +479,7 @@ class ExportTemplateTest(APIViewTestCases.APIViewTestCase):
'object_types': ['dcim.device'], 'object_types': ['dcim.device'],
'name': 'Test Export Template 6', 'name': 'Test Export Template 6',
'template_code': '{% for obj in queryset %}{{ obj.name }}\n{% endfor %}', 'template_code': '{% for obj in queryset %}{{ obj.name }}\n{% endfor %}',
'file_name': 'test_export_template_6',
}, },
] ]
bulk_update_data = { bulk_update_data = {
@ -494,7 +495,9 @@ class ExportTemplateTest(APIViewTestCases.APIViewTestCase):
), ),
ExportTemplate( ExportTemplate(
name='Export Template 2', 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( ExportTemplate(
name='Export Template 3', name='Export Template 3',
@ -502,8 +505,10 @@ class ExportTemplateTest(APIViewTestCases.APIViewTestCase):
), ),
) )
ExportTemplate.objects.bulk_create(export_templates) ExportTemplate.objects.bulk_create(export_templates)
device_object_type = ObjectType.objects.get_for_model(Device)
for et in export_templates: 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): class TagTest(APIViewTestCases.APIViewTestCase):

View File

@ -624,8 +624,11 @@ class ExportTemplateTestCase(TestCase, ChangeLoggedFilterSetTests):
export_templates = ( export_templates = (
ExportTemplate(name='Export Template 1', template_code='TESTING', description='foobar1'), ExportTemplate(name='Export Template 1', template_code='TESTING', description='foobar1'),
ExportTemplate(name='Export Template 2', template_code='TESTING', description='foobar2'), ExportTemplate(
ExportTemplate(name='Export Template 3', template_code='TESTING'), 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) ExportTemplate.objects.bulk_create(export_templates)
for i, et in enumerate(export_templates): for i, et in enumerate(export_templates):
@ -635,6 +638,9 @@ 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)
@ -649,6 +655,20 @@ 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_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): class ImageAttachmentTestCase(TestCase, ChangeLoggedFilterSetTests):
queryset = ImageAttachment.objects.all() queryset = ImageAttachment.objects.all()

View File

@ -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)

View File

@ -305,7 +305,7 @@ class ExportTemplateTestCase(ViewTestCases.PrimaryObjectViewTestCase):
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),
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) ExportTemplate.objects.bulk_create(export_templates)
for et in export_templates: for et in export_templates:
@ -315,13 +315,14 @@ 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,
'file_name': 'template_x',
} }
cls.csv_data = ( cls.csv_data = (
"name,object_types,template_code", "name,object_types,template_code,file_name",
f"Export Template 4,dcim.site,{TEMPLATE_CODE}", f"Export Template 4,dcim.site,{TEMPLATE_CODE},",
f"Export Template 5,dcim.site,{TEMPLATE_CODE}", f"Export Template 5,dcim.site,{TEMPLATE_CODE},template_5",
f"Export Template 6,dcim.site,{TEMPLATE_CODE}", f"Export Template 6,dcim.site,{TEMPLATE_CODE},",
) )
cls.csv_update_data = ( cls.csv_update_data = (

View File

@ -1,6 +1,7 @@
import importlib import importlib
from django.core.exceptions import ImproperlyConfigured from django.core.exceptions import ImproperlyConfigured
from django.db import models
from taggit.managers import _TaggableManager from taggit.managers import _TaggableManager
from netbox.context import current_request 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): 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.

View File

@ -23,6 +23,10 @@
<th scope="row">{% trans "MIME Type" %}</th> <th scope="row">{% trans "MIME Type" %}</th>
<td>{{ object.mime_type|placeholder }}</td> <td>{{ object.mime_type|placeholder }}</td>
</tr> </tr>
<tr>
<th scope="row">{% trans "File Name" %}</th>
<td>{{ object.file_name|placeholder }}</td>
</tr>
<tr> <tr>
<th scope="row">{% trans "File Extension" %}</th> <th scope="row">{% trans "File Extension" %}</th>
<td>{{ object.file_extension|placeholder }}</td> <td>{{ object.file_extension|placeholder }}</td>