Misc cleanup

This commit is contained in:
Jeremy Stretch 2025-04-08 10:03:26 -04:00
parent 7eba28c2f6
commit 36fb044de5
12 changed files with 140 additions and 69 deletions

View File

@ -26,18 +26,24 @@ A dictionary of any additional parameters to pass when instantiating the [Jinja2
### MIME Type ### MIME Type
!!! info "This field was introduced in NetBox v4.3."
The MIME type to indicate in the response when rendering the configuration template (optional). Defaults to `text/plain`. The MIME type to indicate in the response when rendering the configuration template (optional). Defaults to `text/plain`.
### File Name ### File Name
The file name to give to the rendered export file (optional).
!!! info "This field was introduced in NetBox v4.3." !!! info "This field was introduced in NetBox v4.3."
The file name to give to the rendered export file (optional).
### File Extension ### File Extension
!!! info "This field was introduced in NetBox v4.3."
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).
### As Attachment ### As Attachment
!!! info "This field was introduced in NetBox v4.3."
If selected, the rendered content will be returned as a file attachment, rather than displayed directly in-browser (where supported). If selected, the rendered content will be returned as a file attachment, rather than displayed directly in-browser (where supported).

View File

@ -22,6 +22,8 @@ Jinja2 template code for rendering the exported data.
### Environment Parameters ### Environment Parameters
!!! info "This field was introduced in NetBox v4.3."
A dictionary of any additional parameters to pass when instantiating the [Jinja2 environment](https://jinja.palletsprojects.com/en/3.1.x/api/#jinja2.Environment). Jinja2 supports various optional parameters which can be used to modify its default behavior. A dictionary of any additional parameters to pass when instantiating the [Jinja2 environment](https://jinja.palletsprojects.com/en/3.1.x/api/#jinja2.Environment). Jinja2 supports various optional parameters which can be used to modify its default behavior.
### MIME Type ### MIME Type
@ -32,8 +34,6 @@ The MIME type to indicate in the response when rendering the export template (op
The file name to give to the rendered export file (optional). 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

@ -247,7 +247,8 @@ class ExportTemplateForm(SyncedDataMixin, forms.ModelForm):
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( FieldSet(
'mime_type', 'file_name', 'file_extension', 'environment_params', 'as_attachment', name=_('Rendering')), 'mime_type', 'file_name', 'file_extension', 'environment_params', 'as_attachment', name=_('Rendering')
),
) )
class Meta: class Meta:
@ -632,11 +633,11 @@ class ConfigTemplateForm(SyncedDataMixin, forms.ModelForm):
) )
fieldsets = ( fieldsets = (
FieldSet('name', 'description', 'tags', name=_('Config Template')), FieldSet('name', 'description', 'tags', 'template_code', name=_('Config Template')),
FieldSet('template_code', name=_('Content')),
FieldSet(
'mime_type', 'file_name', 'file_extension', 'environment_params', 'as_attachment', name=_('Rendering')),
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_name', 'file_extension', 'environment_params', 'as_attachment', name=_('Rendering')
),
) )
class Meta: class Meta:

View File

@ -105,6 +105,7 @@ class ConfigTemplateFilter(BaseObjectTypeFilterMixin, SyncedDataFilterMixin, Cha
strawberry_django.filter_field() strawberry_django.filter_field()
) )
mime_type: FilterLookup[str] | None = strawberry_django.filter_field() mime_type: FilterLookup[str] | None = strawberry_django.filter_field()
file_name: FilterLookup[str] | None = strawberry_django.filter_field()
file_extension: FilterLookup[str] | None = strawberry_django.filter_field() file_extension: FilterLookup[str] | None = strawberry_django.filter_field()
as_attachment: FilterLookup[bool] | None = strawberry_django.filter_field() as_attachment: FilterLookup[bool] | None = strawberry_django.filter_field()
@ -200,6 +201,7 @@ class ExportTemplateFilter(BaseObjectTypeFilterMixin, SyncedDataFilterMixin, Cha
strawberry_django.filter_field() strawberry_django.filter_field()
) )
mime_type: FilterLookup[str] | None = strawberry_django.filter_field() mime_type: FilterLookup[str] | None = strawberry_django.filter_field()
file_name: FilterLookup[str] | None = strawberry_django.filter_field()
file_extension: FilterLookup[str] | None = strawberry_django.filter_field() file_extension: FilterLookup[str] | None = strawberry_django.filter_field()
as_attachment: FilterLookup[bool] | None = strawberry_django.filter_field() as_attachment: FilterLookup[bool] | None = strawberry_django.filter_field()

View File

@ -5,8 +5,8 @@ from django.db import models
from django.urls import reverse from django.urls import reverse
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from extras.querysets import ConfigContextQuerySet
from extras.models.mixins import RenderTemplateMixin from extras.models.mixins import RenderTemplateMixin
from extras.querysets import ConfigContextQuerySet
from netbox.models import ChangeLoggedModel from netbox.models import ChangeLoggedModel
from netbox.models.features import CloningMixin, CustomLinksMixin, ExportTemplatesMixin, SyncedDataMixin, TagsMixin from netbox.models.features import CloningMixin, CustomLinksMixin, ExportTemplatesMixin, SyncedDataMixin, TagsMixin
from netbox.registry import registry from netbox.registry import registry
@ -208,7 +208,7 @@ class ConfigContextModel(models.Model):
# #
class ConfigTemplate( class ConfigTemplate(
SyncedDataMixin, CustomLinksMixin, ExportTemplatesMixin, TagsMixin, ChangeLoggedModel, RenderTemplateMixin RenderTemplateMixin, SyncedDataMixin, CustomLinksMixin, ExportTemplatesMixin, TagsMixin, ChangeLoggedModel
): ):
name = models.CharField( name = models.CharField(
verbose_name=_('name'), verbose_name=_('name'),
@ -249,7 +249,7 @@ class ConfigTemplate(
except LookupError: except LookupError:
pass pass
# Add the provided context data, if any # Apply the provided context data, if any
if context is not None: if context is not None:
_context.update(context) _context.update(context)

View File

@ -91,16 +91,14 @@ class RenderTemplateMixin(models.Model):
null=True, null=True,
default=dict, default=dict,
help_text=_( help_text=_(
'Any <a href="{url}">additional parameters</a> to pass when constructing the Jinja2 environment.' 'Any <a href="{url}">additional parameters</a> to pass when constructing the Jinja environment'
).format(url='https://jinja.palletsprojects.com/en/stable/api/#jinja2.Environment') ).format(url='https://jinja.palletsprojects.com/en/stable/api/#jinja2.Environment')
) )
mime_type = models.CharField( mime_type = models.CharField(
max_length=50, max_length=50,
blank=True, blank=True,
verbose_name=_('MIME type'), verbose_name=_('MIME type'),
help_text=_('Defaults to <code>{default}</code>').format( help_text=_('Defaults to <code>{default}</code>').format(default=DEFAULT_MIME_TYPE),
default=DEFAULT_MIME_TYPE
),
) )
file_name = models.CharField( file_name = models.CharField(
max_length=200, max_length=200,
@ -149,13 +147,14 @@ class RenderTemplateMixin(models.Model):
if self.as_attachment: if self.as_attachment:
extension = f'.{self.file_extension}' if self.file_extension else '' extension = f'.{self.file_extension}' if self.file_extension else ''
if queryset: if self.file_name:
filename = self.file_name or filename_from_model(queryset.model) filename = self.file_name
elif queryset:
filename = filename_from_model(queryset.model)
elif context: elif context:
filename = self.file_name or filename_from_object(context) filename = filename_from_object(context)
else: else:
filename = self.file_name or "template" filename = "output"
full_filename = f'{filename}{extension}' response['Content-Disposition'] = f'attachment; filename="{filename}{extension}"'
response['Content-Disposition'] = f'attachment; filename="{full_filename}"'
return response return response

View File

@ -433,10 +433,16 @@ class ExportTemplate(SyncedDataMixin, CloningMixin, ExportTemplatesMixin, Change
sync_data.alters_data = True sync_data.alters_data = True
def get_context(self, context=None, queryset=None): def get_context(self, context=None, queryset=None):
return { _context = {
'queryset': queryset 'queryset': queryset,
} }
# Apply the provided context data, if any
if context is not None:
_context.update(context)
return _context
class SavedFilter(CloningMixin, ExportTemplatesMixin, ChangeLoggedModel): class SavedFilter(CloningMixin, ExportTemplatesMixin, ChangeLoggedModel):
""" """

View File

@ -184,13 +184,13 @@ class ExportTemplateTable(NetBoxTable):
verbose_name=_('Object Types'), verbose_name=_('Object Types'),
) )
mime_type = tables.Column( mime_type = tables.Column(
verbose_name=_('MIME type') verbose_name=_('MIME Type')
) )
file_name = tables.Column( file_name = tables.Column(
verbose_name=_('File name'), verbose_name=_('File Name'),
) )
file_extension = tables.Column( file_extension = tables.Column(
verbose_name=_('File extension'), verbose_name=_('File Extension'),
) )
as_attachment = columns.BooleanColumn( as_attachment = columns.BooleanColumn(
verbose_name=_('As Attachment'), verbose_name=_('As Attachment'),
@ -537,13 +537,13 @@ class ConfigTemplateTable(NetBoxTable):
verbose_name=_('Synced') verbose_name=_('Synced')
) )
mime_type = tables.Column( mime_type = tables.Column(
verbose_name=_('MIME type') verbose_name=_('MIME Type')
) )
file_name = tables.Column( file_name = tables.Column(
verbose_name=_('File name'), verbose_name=_('File Name'),
) )
file_extension = tables.Column( file_extension = tables.Column(
verbose_name=_('File extension'), verbose_name=_('File Extension'),
) )
as_attachment = columns.BooleanColumn( as_attachment = columns.BooleanColumn(
verbose_name=_('As Attachment'), verbose_name=_('As Attachment'),

View File

@ -755,6 +755,10 @@ class ConfigTemplateTest(APIViewTestCases.APIViewTestCase):
{ {
'name': 'Config Template 4', 'name': 'Config Template 4',
'template_code': 'Foo: {{ foo }}', 'template_code': 'Foo: {{ foo }}',
'mime_type': 'text/plain',
'file_name': 'output4',
'file_extension': 'txt',
'as_attachment': True,
}, },
{ {
'name': 'Config Template 5', 'name': 'Config Template 5',
@ -763,7 +767,6 @@ class ConfigTemplateTest(APIViewTestCases.APIViewTestCase):
{ {
'name': 'Config Template 6', 'name': 'Config Template 6',
'template_code': 'Baz: {{ baz }}', 'template_code': 'Baz: {{ baz }}',
'file_name': 'test_config_template_6',
}, },
] ]
bulk_update_data = { bulk_update_data = {
@ -780,8 +783,6 @@ class ConfigTemplateTest(APIViewTestCases.APIViewTestCase):
ConfigTemplate( ConfigTemplate(
name='Config Template 2', name='Config Template 2',
template_code='Bar: {{ bar }}', template_code='Bar: {{ bar }}',
file_name='config_template_2',
file_extension='test',
), ),
ConfigTemplate( ConfigTemplate(
name='Config Template 3', name='Config Template 3',

View File

@ -623,12 +623,32 @@ class ExportTemplateTestCase(TestCase, ChangeLoggedFilterSetTests):
object_types = ObjectType.objects.filter(model__in=['site', 'rack', 'device']) object_types = ObjectType.objects.filter(model__in=['site', 'rack', 'device'])
export_templates = ( export_templates = (
ExportTemplate(name='Export Template 1', template_code='TESTING', description='foobar1'),
ExportTemplate( ExportTemplate(
name='Export Template 2', template_code='TESTING', description='foobar2', name='Export Template 1',
file_name='export_template_2', file_extension='nagios', template_code='TESTING',
description='foobar1',
mime_type='text/foo',
file_name='foo',
file_extension='foo',
as_attachment=True,
),
ExportTemplate(
name='Export Template 2',
template_code='TESTING',
description='foobar2',
mime_type='text/bar',
file_name='bar',
file_extension='bar',
as_attachment=True,
),
ExportTemplate(
name='Export Template 3',
template_code='TESTING',
mime_type='text/baz',
file_name='baz',
file_extension='baz',
as_attachment=False,
), ),
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):
@ -638,9 +658,6 @@ 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)
@ -655,19 +672,21 @@ 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_mime_type(self):
params = {'mime_type': ['text/foo', 'text/bar']}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_file_name(self): def test_file_name(self):
params = {'file_name': ['export_filename']} params = {'file_name': ['foo', 'bar']}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1) self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_file_extension(self): def test_file_extension(self):
params = {'file_extension': ['nagios']} params = {'file_extension': ['foo', 'bar']}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1) self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
params = {'file_name': ['export_template_2'], 'file_extension': ['nagios']} def test_as_attachment(self):
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1) params = {'as_attachment': True}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
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):
@ -1088,12 +1107,32 @@ class ConfigTemplateTestCase(TestCase, ChangeLoggedFilterSetTests):
@classmethod @classmethod
def setUpTestData(cls): def setUpTestData(cls):
config_templates = ( config_templates = (
ConfigTemplate(name='Config Template 1', template_code='TESTING', description='foobar1'),
ConfigTemplate( ConfigTemplate(
name='Config Template 2', template_code='TESTING', description='foobar2', name='Config Template 1',
file_name='config_template_2', file_extension='nagios', template_code='TESTING',
description='foobar1',
mime_type='text/foo',
file_name='foo',
file_extension='foo',
as_attachment=True,
),
ConfigTemplate(
name='Config Template 2',
template_code='TESTING',
description='foobar2',
mime_type='text/bar',
file_name='bar',
file_extension='bar',
as_attachment=True,
),
ConfigTemplate(
name='Config Template 3',
template_code='TESTING',
mime_type='text/baz',
file_name='baz',
file_extension='baz',
as_attachment=False,
), ),
ConfigTemplate(name='Config Template 3', template_code='TESTING', file_name='export_filename'),
) )
ConfigTemplate.objects.bulk_create(config_templates) ConfigTemplate.objects.bulk_create(config_templates)
@ -1109,15 +1148,21 @@ class ConfigTemplateTestCase(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_mime_type(self):
params = {'mime_type': ['text/foo', 'text/bar']}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_file_name(self):
params = {'file_name': ['foo', 'bar']}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_file_extension(self): def test_file_extension(self):
params = {'file_extension': ['nagios']} params = {'file_extension': ['foo', 'bar']}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1) self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
params = {'file_name': ['config_template_2'], 'file_extension': ['nagios']} def test_as_attachment(self):
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1) params = {'as_attachment': True}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
params = {'file_name': 'export_filename', 'file_extension': ['nagios']}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 0)
class TagTestCase(TestCase, ChangeLoggedFilterSetTests): class TagTestCase(TestCase, ChangeLoggedFilterSetTests):

View File

@ -544,11 +544,20 @@ class ConfigTemplateTestCase(
ENVIRONMENT_PARAMS = """{"trim_blocks": true}""" ENVIRONMENT_PARAMS = """{"trim_blocks": true}"""
config_templates = ( config_templates = (
ConfigTemplate(name='Config Template 1', template_code=TEMPLATE_CODE),
ConfigTemplate( ConfigTemplate(
name='Config Template 2', template_code=TEMPLATE_CODE, environment_params={"trim_blocks": True} name='Config Template 1',
template_code=TEMPLATE_CODE)
,
ConfigTemplate(
name='Config Template 2',
template_code=TEMPLATE_CODE,
environment_params={"trim_blocks": True},
),
ConfigTemplate(
name='Config Template 3',
template_code=TEMPLATE_CODE,
file_name='config_template_3',
), ),
ConfigTemplate(name='Config Template 3', template_code=TEMPLATE_CODE, file_name='config_template_3'),
) )
ConfigTemplate.objects.bulk_create(config_templates) ConfigTemplate.objects.bulk_create(config_templates)
@ -561,15 +570,16 @@ class ConfigTemplateTestCase(
} }
cls.csv_update_data = ( cls.csv_update_data = (
"id,name,template_code,file_name", "id,name",
f"{config_templates[0].pk},Config Template 7,{TEMPLATE_CODE},", f"{config_templates[0].pk},Config Template 7",
f"{config_templates[1].pk},Config Template 8,{TEMPLATE_CODE},config_8", f"{config_templates[1].pk},Config Template 8",
f"{config_templates[2].pk},Config Template 9,{TEMPLATE_CODE},", f"{config_templates[2].pk},Config Template 9",
) )
cls.bulk_edit_data = { cls.bulk_edit_data = {
'description': 'New description', 'description': 'New description',
'mime_type': 'text/html', 'mime_type': 'text/html',
'file_name': 'output',
'file_extension': 'html', 'file_extension': 'html',
'as_attachment': True, 'as_attachment': True,
} }

View File

@ -49,10 +49,11 @@ class DataFileLoader(BaseLoader):
# Utility functions # Utility functions
# #
def render_jinja2(template_code, context, environment_params={}): def render_jinja2(template_code, context, environment_params=None):
""" """
Render a Jinja2 template with the provided context. Return the rendered content. Render a Jinja2 template with the provided context. Return the rendered content.
""" """
environment_params = environment_params or {}
environment = SandboxedEnvironment(**environment_params) environment = SandboxedEnvironment(**environment_params)
environment.filters.update(get_config().JINJA2_FILTERS) environment.filters.update(get_config().JINJA2_FILTERS)
return environment.from_string(source=template_code).render(**context) return environment.from_string(source=template_code).render(**context)