mirror of
https://github.com/netbox-community/netbox.git
synced 2025-07-14 01:41:22 -06:00
* 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:
parent
6b7d23d684
commit
80440fd025
@ -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).
|
||||||
|
@ -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')
|
||||||
|
@ -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)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@ -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):
|
||||||
|
@ -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',
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@ -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
|
||||||
|
@ -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:
|
||||||
|
@ -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
|
||||||
|
|
||||||
|
|
||||||
|
16
netbox/extras/migrations/0125_exporttemplate_file_name.py
Normal file
16
netbox/extras/migrations/0125_exporttemplate_file_name.py
Normal 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),
|
||||||
|
),
|
||||||
|
]
|
@ -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
|
||||||
|
|
||||||
|
@ -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',
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@ -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):
|
||||||
|
@ -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()
|
||||||
|
19
netbox/extras/tests/test_utils.py
Normal file
19
netbox/extras/tests/test_utils.py
Normal 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)
|
@ -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 = (
|
||||||
|
@ -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.
|
||||||
|
@ -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>
|
||||||
|
Loading…
Reference in New Issue
Block a user