Closes #17653: Add function to trim whitespaces in export templates via jinja environment settings (#19078)

* Create RenderMixin, and unify template_code rendering and exporting

* Join migrations

* Add DEFAULT_MIME_TE constant

* Move RenderMixin to extras.models.mixins, Rename RenderMixin to RenderTemplateMixin

* Add render_jinja2 to __all__

* Rename ConfigTemplateFilterForm rendering FieldSet

* ConfigTemplate lint

* Simplify ExportTemplate get_context

* Fix table order, and add fields for translations

* Update Serializers

* Update forms, tables, graphQL, API

* Add extra tests for ConfigTemplate and ExportTemplate

* Documentation update

* Fix typo

* Misc cleanup

* Clean up template layouts

---------

Co-authored-by: Jeremy Stretch <jstretch@netboxlabs.com>
This commit is contained in:
Renato Almeida de Oliveira 2025-04-08 11:37:15 -03:00 committed by GitHub
parent 3902cab925
commit fbd6d8c7fc
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
27 changed files with 437 additions and 188 deletions

View File

@ -12,10 +12,6 @@ See the [configuration rendering documentation](../../features/configuration-ren
A unique human-friendly name.
### Weight
A numeric value which influences the order in which context data is merged. Contexts with a lower weight are merged before those with a higher weight.
### Data File
Template code may optionally be sourced from a remote [data file](../core/datafile.md), which is synchronized from a remote data source. When designating a data file, there is no need to specify template code: It will be populated automatically from the data file.
@ -27,3 +23,27 @@ Jinja2 template code, if being defined locally rather than replicated from a dat
### Environment Parameters
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
!!! 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`.
### File Name
!!! info "This field was introduced in NetBox v4.3."
The file name to give to the rendered export file (optional).
### File Extension
!!! info "This field was introduced in NetBox v4.3."
The file extension to append to the file name in the response (optional).
### 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).

View File

@ -20,6 +20,12 @@ Template code may optionally be sourced from a remote [data file](../core/datafi
Jinja2 template code for rendering the exported data.
### 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.
### MIME Type
The MIME type to indicate in the response when rendering the export template (optional). Defaults to `text/plain`.
@ -28,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).
!!! info "This field was introduced in NetBox v4.3."
### File Extension
The file extension to append to the file name in the response (optional).

View File

@ -4,7 +4,6 @@ from django.core.paginator import EmptyPage, PageNotAnInteger
from django.db import transaction
from django.db.models import Prefetch
from django.forms import ModelMultipleChoiceField, MultipleHiddenInput, modelformset_factory
from django.http import HttpResponse
from django.shortcuts import get_object_or_404, redirect, render
from django.urls import reverse
from django.utils.html import escape
@ -2293,10 +2292,7 @@ class DeviceRenderConfigView(generic.ObjectView):
# If a direct export has been requested, return the rendered template content as a
# downloadable file.
if request.GET.get('export'):
content = context['rendered_config'] or context['error_message']
response = HttpResponse(content, content_type='text')
filename = f"{instance.name or 'config'}.txt"
response['Content-Disposition'] = f'attachment; filename="{filename}"'
response = context['config_template'].render_to_response(context=context['context_data'])
return response
return render(request, self.get_template_name(), {

View File

@ -22,6 +22,7 @@ class ConfigTemplateSerializer(TaggableModelSerializer, ValidatedModelSerializer
model = ConfigTemplate
fields = [
'id', 'url', 'display_url', 'display', 'name', 'description', 'environment_params', 'template_code',
'data_source', 'data_path', 'data_file', 'data_synced', 'tags', 'created', 'last_updated',
'mime_type', 'file_name', 'file_extension', 'as_attachment', 'data_source', 'data_path', 'data_file',
'data_synced', 'tags', 'created', 'last_updated',
]
brief_fields = ('id', 'url', 'display', 'name', 'description')

View File

@ -26,8 +26,8 @@ class ExportTemplateSerializer(ValidatedModelSerializer):
class Meta:
model = ExportTemplate
fields = [
'id', 'url', 'display_url', 'display', 'object_types', 'name', 'description', 'template_code', 'mime_type',
'file_name', 'file_extension', 'as_attachment', 'data_source', 'data_path', 'data_file', 'data_synced',
'created', 'last_updated',
'id', 'url', 'display_url', 'display', 'object_types', 'name', 'description', 'environment_params',
'template_code', 'mime_type', 'file_name', 'file_extension', 'as_attachment', 'data_source',
'data_path', 'data_file', 'data_synced', 'created', 'last_updated',
]
brief_fields = ('id', 'url', 'display', 'name', 'description')

View File

@ -4,6 +4,9 @@ from extras.choices import LogLevelChoices
# Custom fields
CUSTOMFIELD_EMPTY_VALUES = (None, '', [])
# Template Export
DEFAULT_MIME_TYPE = 'text/plain; charset=utf-8'
# Webhooks
HTTP_CONTENT_TYPE_JSON = 'application/json'

View File

@ -707,7 +707,10 @@ class ConfigTemplateFilterSet(ChangeLoggedModelFilterSet):
class Meta:
model = ConfigTemplate
fields = ('id', 'name', 'description', 'auto_sync_enabled', 'data_synced')
fields = (
'id', 'name', 'description', 'mime_type', 'file_name', 'file_extension', 'as_attachment',
'auto_sync_enabled', 'data_synced'
)
def search(self, queryset, name, value):
if not value.strip():

View File

@ -321,8 +321,27 @@ class ConfigTemplateBulkEditForm(BulkEditForm):
max_length=200,
required=False
)
mime_type = forms.CharField(
label=_('MIME type'),
max_length=50,
required=False
)
file_name = forms.CharField(
label=_('File name'),
required=False
)
file_extension = forms.CharField(
label=_('File extension'),
max_length=15,
required=False
)
as_attachment = forms.NullBooleanField(
label=_('As attachment'),
required=False,
widget=BulkEditNullBooleanSelect()
)
nullable_fields = ('description',)
nullable_fields = ('description', 'mime_type', 'file_name', 'file_extension')
class JournalEntryBulkEditForm(BulkEditForm):

View File

@ -144,8 +144,8 @@ class ExportTemplateImportForm(CSVModelForm):
class Meta:
model = ExportTemplate
fields = (
'name', 'object_types', 'description', 'mime_type', 'file_name', 'file_extension', 'as_attachment',
'template_code',
'name', 'object_types', 'description', 'environment_params', 'mime_type', 'file_name', 'file_extension',
'as_attachment', 'template_code',
)
@ -154,7 +154,8 @@ class ConfigTemplateImportForm(CSVModelForm):
class Meta:
model = ConfigTemplate
fields = (
'name', 'description', 'environment_params', 'template_code', 'tags',
'name', 'description', 'template_code', 'environment_params', 'mime_type', 'file_name', 'file_extension',
'as_attachment', 'tags',
)

View File

@ -160,9 +160,9 @@ class CustomLinkFilterForm(SavedFiltersMixin, FilterForm):
class ExportTemplateFilterForm(SavedFiltersMixin, FilterForm):
fieldsets = (
FieldSet('q', 'filter_id'),
FieldSet('q', 'filter_id', 'object_type_id'),
FieldSet('data_source_id', 'data_file_id', name=_('Data')),
FieldSet('object_type_id', 'mime_type', 'file_name', 'file_extension', 'as_attachment', name=_('Attributes')),
FieldSet('mime_type', 'file_name', 'file_extension', 'as_attachment', name=_('Rendering')),
)
data_source_id = DynamicModelMultipleChoiceField(
queryset=DataSource.objects.all(),
@ -410,6 +410,7 @@ class ConfigTemplateFilterForm(SavedFiltersMixin, FilterForm):
fieldsets = (
FieldSet('q', 'filter_id', 'tag'),
FieldSet('data_source_id', 'data_file_id', name=_('Data')),
FieldSet('mime_type', 'file_name', 'file_extension', 'as_attachment', name=_('Rendering'))
)
data_source_id = DynamicModelMultipleChoiceField(
queryset=DataSource.objects.all(),
@ -425,6 +426,25 @@ class ConfigTemplateFilterForm(SavedFiltersMixin, FilterForm):
}
)
tag = TagFilterField(ConfigTemplate)
mime_type = forms.CharField(
required=False,
label=_('MIME type')
)
file_name = forms.CharField(
label=_('File name'),
required=False
)
file_extension = forms.CharField(
label=_('File extension'),
required=False
)
as_attachment = forms.NullBooleanField(
label=_('As attachment'),
required=False,
widget=forms.Select(
choices=BOOLEAN_WITH_BLANK_CHOICES
)
)
class LocalConfigContextFilterForm(forms.Form):

View File

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

View File

@ -104,6 +104,10 @@ class ConfigTemplateFilter(BaseObjectTypeFilterMixin, SyncedDataFilterMixin, Cha
environment_params: Annotated['JSONFilter', strawberry.lazy('netbox.graphql.filter_lookups')] | 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()
as_attachment: FilterLookup[bool] | None = strawberry_django.filter_field()
@strawberry_django.filter(models.CustomField, lookups=True)
@ -193,7 +197,11 @@ class ExportTemplateFilter(BaseObjectTypeFilterMixin, SyncedDataFilterMixin, Cha
name: FilterLookup[str] | None = strawberry_django.filter_field()
description: FilterLookup[str] | None = strawberry_django.filter_field()
template_code: FilterLookup[str] | None = strawberry_django.filter_field()
environment_params: Annotated['JSONFilter', strawberry.lazy('netbox.graphql.filter_lookups')] | 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()
as_attachment: FilterLookup[bool] | None = strawberry_django.filter_field()

View File

@ -0,0 +1,38 @@
# Generated by Django 5.2b1 on 2025-04-04 20:25
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('extras', '0125_exporttemplate_file_name'),
]
operations = [
migrations.AddField(
model_name='configtemplate',
name='as_attachment',
field=models.BooleanField(default=True),
),
migrations.AddField(
model_name='configtemplate',
name='file_extension',
field=models.CharField(blank=True, max_length=15),
),
migrations.AddField(
model_name='configtemplate',
name='file_name',
field=models.CharField(blank=True, max_length=200),
),
migrations.AddField(
model_name='configtemplate',
name='mime_type',
field=models.CharField(blank=True, max_length=50),
),
migrations.AddField(
model_name='exporttemplate',
name='environment_params',
field=models.JSONField(blank=True, default=dict, null=True),
),
]

View File

@ -4,16 +4,13 @@ from django.core.validators import ValidationError
from django.db import models
from django.urls import reverse
from django.utils.translation import gettext_lazy as _
from jinja2.loaders import BaseLoader
from jinja2.sandbox import SandboxedEnvironment
from extras.models.mixins import RenderTemplateMixin
from extras.querysets import ConfigContextQuerySet
from netbox.config import get_config
from netbox.models import ChangeLoggedModel
from netbox.models.features import CloningMixin, CustomLinksMixin, ExportTemplatesMixin, SyncedDataMixin, TagsMixin
from netbox.registry import registry
from utilities.data import deepmerge
from utilities.jinja2 import DataFileLoader
__all__ = (
'ConfigContext',
@ -210,7 +207,9 @@ class ConfigContextModel(models.Model):
# Config templates
#
class ConfigTemplate(SyncedDataMixin, CustomLinksMixin, ExportTemplatesMixin, TagsMixin, ChangeLoggedModel):
class ConfigTemplate(
RenderTemplateMixin, SyncedDataMixin, CustomLinksMixin, ExportTemplatesMixin, TagsMixin, ChangeLoggedModel
):
name = models.CharField(
verbose_name=_('name'),
max_length=100
@ -220,20 +219,6 @@ class ConfigTemplate(SyncedDataMixin, CustomLinksMixin, ExportTemplatesMixin, Ta
max_length=200,
blank=True
)
template_code = models.TextField(
verbose_name=_('template code'),
help_text=_('Jinja2 template code.')
)
environment_params = models.JSONField(
verbose_name=_('environment parameters'),
blank=True,
null=True,
default=dict,
help_text=_(
'Any <a href="https://jinja.palletsprojects.com/en/3.1.x/api/#jinja2.Environment">additional parameters</a>'
' to pass when constructing the Jinja2 environment.'
)
)
class Meta:
ordering = ('name',)
@ -253,13 +238,8 @@ class ConfigTemplate(SyncedDataMixin, CustomLinksMixin, ExportTemplatesMixin, Ta
self.template_code = self.data_file.data_as_string
sync_data.alters_data = True
def render(self, context=None):
"""
Render the contents of the template.
"""
def get_context(self, context=None, queryset=None):
_context = dict()
# Populate the default template context with NetBox model classes, namespaced by app
for app, model_names in registry['models'].items():
_context.setdefault(app, {})
for model_name in model_names:
@ -269,37 +249,8 @@ class ConfigTemplate(SyncedDataMixin, CustomLinksMixin, ExportTemplatesMixin, Ta
except LookupError:
pass
# Add the provided context data, if any
# Apply the provided context data, if any
if context is not None:
_context.update(context)
# Initialize the Jinja2 environment and instantiate the Template
environment = self._get_environment()
if self.data_file:
template = environment.get_template(self.data_file.path)
else:
template = environment.from_string(self.template_code)
output = template.render(**_context)
# Replace CRLF-style line terminators
return output.replace('\r\n', '\n')
def _get_environment(self):
"""
Instantiate and return a Jinja2 environment suitable for rendering the ConfigTemplate.
"""
# Initialize the template loader & cache the base template code (if applicable)
if self.data_file:
loader = DataFileLoader(data_source=self.data_source)
loader.cache_templates({
self.data_file.path: self.template_code
})
else:
loader = BaseLoader()
# Initialize the environment
env_params = self.environment_params or {}
environment = SandboxedEnvironment(loader=loader, **env_params)
environment.filters.update(get_config().JINJA2_FILTERS)
return environment
return _context

View File

@ -3,9 +3,18 @@ import importlib.util
import os
import sys
from django.core.files.storage import storages
from django.db import models
from django.utils.translation import gettext_lazy as _
from django.http import HttpResponse
from extras.constants import DEFAULT_MIME_TYPE
from extras.utils import filename_from_model, filename_from_object
from utilities.jinja2 import render_jinja2
__all__ = (
'PythonModuleMixin',
'RenderTemplateMixin',
)
@ -66,3 +75,86 @@ class PythonModuleMixin:
loader.exec_module(module)
return module
class RenderTemplateMixin(models.Model):
"""
Enables support for rendering templates.
"""
template_code = models.TextField(
verbose_name=_('template code'),
help_text=_('Jinja template code.')
)
environment_params = models.JSONField(
verbose_name=_('environment parameters'),
blank=True,
null=True,
default=dict,
help_text=_(
'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')
)
mime_type = models.CharField(
max_length=50,
blank=True,
verbose_name=_('MIME type'),
help_text=_('Defaults to <code>{default}</code>').format(default=DEFAULT_MIME_TYPE),
)
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,
blank=True,
help_text=_('Extension to append to the rendered filename')
)
as_attachment = models.BooleanField(
verbose_name=_('as attachment'),
default=True,
help_text=_("Download file as attachment")
)
class Meta:
abstract = True
def get_context(self, context=None, queryset=None):
raise NotImplementedError(_("{class_name} must implement a get_context() method.").format(
class_name=self.__class__
))
def render(self, context=None, queryset=None):
"""
Render the template with the provided context. The context is passed to the Jinja2 environment as a dictionary.
"""
context = self.get_context(context=context, queryset=queryset)
env_params = self.environment_params or {}
output = render_jinja2(self.template_code, context, env_params)
# Replace CRLF-style line terminators
output = output.replace('\r\n', '\n')
return output
def render_to_response(self, context=None, queryset=None):
output = self.render(context=context, queryset=queryset)
mime_type = self.mime_type or DEFAULT_MIME_TYPE
# Build the response
response = HttpResponse(output, content_type=mime_type)
if self.as_attachment:
extension = f'.{self.file_extension}' if self.file_extension else ''
if self.file_name:
filename = self.file_name
elif queryset:
filename = filename_from_model(queryset.model)
elif context:
filename = filename_from_object(context)
else:
filename = "output"
response['Content-Disposition'] = f'attachment; filename="{filename}{extension}"'
return response

View File

@ -6,7 +6,6 @@ from django.contrib.contenttypes.fields import GenericForeignKey, GenericRelatio
from django.contrib.postgres.fields import ArrayField
from django.core.validators import ValidationError
from django.db import models
from django.http import HttpResponse
from django.urls import reverse
from django.utils import timezone
from django.utils.translation import gettext_lazy as _
@ -16,12 +15,13 @@ from core.models import ObjectType
from extras.choices import *
from extras.conditions import ConditionSet
from extras.constants import *
from extras.utils import filename_from_model, image_upload
from extras.utils import image_upload
from extras.models.mixins import RenderTemplateMixin
from netbox.config import get_config
from netbox.events import get_event_type_choices
from netbox.models import ChangeLoggedModel
from netbox.models.features import (
CloningMixin, CustomFieldsMixin, CustomLinksMixin, ExportTemplatesMixin, SyncedDataMixin, TagsMixin,
CloningMixin, CustomFieldsMixin, CustomLinksMixin, ExportTemplatesMixin, SyncedDataMixin, TagsMixin
)
from utilities.html import clean_html
from utilities.jinja2 import render_jinja2
@ -382,7 +382,7 @@ class CustomLink(CloningMixin, ExportTemplatesMixin, ChangeLoggedModel):
}
class ExportTemplate(SyncedDataMixin, CloningMixin, ExportTemplatesMixin, ChangeLoggedModel):
class ExportTemplate(SyncedDataMixin, CloningMixin, ExportTemplatesMixin, ChangeLoggedModel, RenderTemplateMixin):
object_types = models.ManyToManyField(
to='core.ObjectType',
related_name='export_templates',
@ -397,34 +397,6 @@ class ExportTemplate(SyncedDataMixin, CloningMixin, ExportTemplatesMixin, Change
max_length=200,
blank=True
)
template_code = models.TextField(
help_text=_(
"Jinja2 template code. The list of objects being exported is passed as a context variable named "
"<code>queryset</code>."
)
)
mime_type = models.CharField(
max_length=50,
blank=True,
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,
blank=True,
help_text=_('Extension to append to the rendered filename')
)
as_attachment = models.BooleanField(
verbose_name=_('as attachment'),
default=True,
help_text=_("Download file as attachment")
)
clone_fields = (
'object_types', 'template_code', 'mime_type', 'file_name', 'file_extension', 'as_attachment',
@ -460,37 +432,16 @@ class ExportTemplate(SyncedDataMixin, CloningMixin, ExportTemplatesMixin, Change
self.template_code = self.data_file.data_as_string
sync_data.alters_data = True
def render(self, queryset):
"""
Render the contents of the template.
"""
context = {
'queryset': queryset
def get_context(self, context=None, queryset=None):
_context = {
'queryset': queryset,
}
output = render_jinja2(self.template_code, context)
# Replace CRLF-style line terminators
output = output.replace('\r\n', '\n')
# Apply the provided context data, if any
if context is not None:
_context.update(context)
return output
def render_to_response(self, queryset):
"""
Render the template to an HTTP response, delivered as a named file attachment
"""
output = self.render(queryset)
mime_type = 'text/plain; charset=utf-8' if not self.mime_type else self.mime_type
# Build the response
response = HttpResponse(output, content_type=mime_type)
if self.as_attachment:
extension = f'.{self.file_extension}' if self.file_extension else ''
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
return _context
class SavedFilter(CloningMixin, ExportTemplatesMixin, ChangeLoggedModel):

View File

@ -183,6 +183,15 @@ class ExportTemplateTable(NetBoxTable):
object_types = columns.ContentTypesColumn(
verbose_name=_('Object Types'),
)
mime_type = tables.Column(
verbose_name=_('MIME Type')
)
file_name = tables.Column(
verbose_name=_('File Name'),
)
file_extension = tables.Column(
verbose_name=_('File Extension'),
)
as_attachment = columns.BooleanColumn(
verbose_name=_('As Attachment'),
false_mark=None
@ -527,6 +536,19 @@ class ConfigTemplateTable(NetBoxTable):
orderable=False,
verbose_name=_('Synced')
)
mime_type = tables.Column(
verbose_name=_('MIME Type')
)
file_name = tables.Column(
verbose_name=_('File Name'),
)
file_extension = tables.Column(
verbose_name=_('File Extension'),
)
as_attachment = columns.BooleanColumn(
verbose_name=_('As Attachment'),
false_mark=None
)
tags = columns.TagColumn(
url_name='extras:configtemplate_list'
)
@ -554,8 +576,9 @@ class ConfigTemplateTable(NetBoxTable):
class Meta(NetBoxTable.Meta):
model = ConfigTemplate
fields = (
'pk', 'id', 'name', 'description', 'data_source', 'data_file', 'data_synced', 'role_count',
'platform_count', 'device_count', 'vm_count', 'created', 'last_updated', 'tags',
'pk', 'id', 'name', 'description', 'data_source', 'data_file', 'data_synced', 'as_attachment',
'mime_type', 'file_name', 'file_extension', 'role_count', 'platform_count', 'device_count',
'vm_count', 'created', 'last_updated', 'tags',
)
default_columns = (
'pk', 'name', 'description', 'is_synced', 'device_count', 'vm_count',

View File

@ -755,6 +755,10 @@ class ConfigTemplateTest(APIViewTestCases.APIViewTestCase):
{
'name': 'Config Template 4',
'template_code': 'Foo: {{ foo }}',
'mime_type': 'text/plain',
'file_name': 'output4',
'file_extension': 'txt',
'as_attachment': True,
},
{
'name': 'Config Template 5',
@ -778,7 +782,7 @@ class ConfigTemplateTest(APIViewTestCases.APIViewTestCase):
),
ConfigTemplate(
name='Config Template 2',
template_code='Bar: {{ bar }}'
template_code='Bar: {{ bar }}',
),
ConfigTemplate(
name='Config Template 3',

View File

@ -616,19 +616,39 @@ class BookmarkTestCase(TestCase, BaseFilterSetTests):
class ExportTemplateTestCase(TestCase, ChangeLoggedFilterSetTests):
queryset = ExportTemplate.objects.all()
filterset = ExportTemplateFilterSet
ignore_fields = ('template_code', 'data_path')
ignore_fields = ('template_code', 'environment_params', 'data_path')
@classmethod
def setUpTestData(cls):
object_types = ObjectType.objects.filter(model__in=['site', 'rack', 'device'])
export_templates = (
ExportTemplate(name='Export Template 1', template_code='TESTING', description='foobar1'),
ExportTemplate(
name='Export Template 2', template_code='TESTING', description='foobar2',
file_name='export_template_2', file_extension='nagios',
name='Export Template 1',
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)
for i, et in enumerate(export_templates):
@ -638,9 +658,6 @@ 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)
@ -655,19 +672,21 @@ class ExportTemplateTestCase(TestCase, ChangeLoggedFilterSetTests):
params = {'description': ['foobar1', 'foobar2']}
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': ['export_filename']}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
params = {'file_name': ['foo', 'bar']}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_file_extension(self):
params = {'file_extension': ['nagios']}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
params = {'file_extension': ['foo', 'bar']}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
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)
def test_as_attachment(self):
params = {'as_attachment': True}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
class ImageAttachmentTestCase(TestCase, ChangeLoggedFilterSetTests):
@ -1088,9 +1107,32 @@ class ConfigTemplateTestCase(TestCase, ChangeLoggedFilterSetTests):
@classmethod
def setUpTestData(cls):
config_templates = (
ConfigTemplate(name='Config Template 1', template_code='TESTING', description='foobar1'),
ConfigTemplate(name='Config Template 2', template_code='TESTING', description='foobar2'),
ConfigTemplate(name='Config Template 3', template_code='TESTING'),
ConfigTemplate(
name='Config Template 1',
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.objects.bulk_create(config_templates)
@ -1106,6 +1148,22 @@ class ConfigTemplateTestCase(TestCase, ChangeLoggedFilterSetTests):
params = {'description': ['foobar1', 'foobar2']}
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):
params = {'file_extension': ['foo', 'bar']}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_as_attachment(self):
params = {'as_attachment': True}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
class TagTestCase(TestCase, ChangeLoggedFilterSetTests):
queryset = Tag.objects.all()

View File

@ -301,10 +301,13 @@ class ExportTemplateTestCase(ViewTestCases.PrimaryObjectViewTestCase):
def setUpTestData(cls):
site_type = ObjectType.objects.get_for_model(Site)
TEMPLATE_CODE = """{% for object in queryset %}{{ object }}{% endfor %}"""
ENVIRONMENT_PARAMS = """{"trim_blocks": true}"""
export_templates = (
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, environment_params={"trim_blocks": True}
),
ExportTemplate(name='Export Template 3', template_code=TEMPLATE_CODE, file_name='export_template_3')
)
ExportTemplate.objects.bulk_create(export_templates)
@ -315,6 +318,7 @@ class ExportTemplateTestCase(ViewTestCases.PrimaryObjectViewTestCase):
'name': 'Export Template X',
'object_types': [site_type.pk],
'template_code': TEMPLATE_CODE,
'environment_params': ENVIRONMENT_PARAMS,
'file_name': 'template_x',
}
@ -537,11 +541,23 @@ class ConfigTemplateTestCase(
@classmethod
def setUpTestData(cls):
TEMPLATE_CODE = """Foo: {{ foo }}"""
ENVIRONMENT_PARAMS = """{"trim_blocks": true}"""
config_templates = (
ConfigTemplate(name='Config Template 1', template_code=TEMPLATE_CODE),
ConfigTemplate(name='Config Template 2', template_code=TEMPLATE_CODE),
ConfigTemplate(name='Config Template 3', template_code=TEMPLATE_CODE),
ConfigTemplate(
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.objects.bulk_create(config_templates)
@ -549,6 +565,8 @@ class ConfigTemplateTestCase(
'name': 'Config Template X',
'description': 'Config template',
'template_code': TEMPLATE_CODE,
'environment_params': ENVIRONMENT_PARAMS,
'file_name': 'config_x',
}
cls.csv_update_data = (
@ -560,6 +578,10 @@ class ConfigTemplateTestCase(
cls.bulk_edit_data = {
'description': 'New description',
'mime_type': 'text/html',
'file_name': 'output',
'file_extension': 'html',
'as_attachment': True,
}

View File

@ -22,6 +22,17 @@ def filename_from_model(model: models.Model) -> str:
return f'netbox_{base}'
def filename_from_object(context: dict) -> str:
"""Standardises how we generate filenames from model class for exports"""
if 'device' in context:
base = f"{context['device'].name or 'config'}"
elif 'virtualmachine' in context:
base = f"{context['virtualmachine'].name or 'config'}"
else:
base = 'config'
return base
def is_taggable(obj):
"""
Return True if the instance can have Tags assigned to it; False otherwise.

View File

@ -45,7 +45,7 @@ class ExportTemplatesMixin:
if et is None:
raise Http404
queryset = self.filter_queryset(self.get_queryset())
return et.render_to_response(queryset)
return et.render_to_response(queryset=queryset)
return super().list(request, *args, **kwargs)

View File

@ -107,7 +107,7 @@ class ObjectListView(BaseMultiObjectView, ActionsMixin, TableMixin):
request: The current request
"""
try:
return template.render_to_response(self.queryset)
return template.render_to_response(queryset=self.queryset)
except Exception as e:
messages.error(
request,

View File

@ -4,8 +4,8 @@
{% load i18n %}
{% block content %}
<div class="row mb-3">
<div class="col col-md-5">
<div class="row">
<div class="col col-md-6">
<div class="card">
<h2 class="card-header">{% trans "Config Template" %}</h2>
<table class="table table-hover attr-table">
@ -17,6 +17,22 @@
<th scope="row">{% trans "Description" %}</th>
<td>{{ object.description|placeholder }}</td>
</tr>
<tr>
<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>
</tr>
<tr>
<th scope="row">{% trans "Attachment" %}</th>
<td>{% checkmark object.as_attachment %}</td>
</tr>
<tr>
<th scope="row">{% trans "Data Source" %}</th>
<td>
@ -51,7 +67,7 @@
{% include 'inc/panels/tags.html' %}
{% plugin_left_page object %}
</div>
<div class="col col-md-7">
<div class="col col-md-6">
<div class="card">
<h2 class="card-header">{% trans "Environment Parameters" %}</h2>
<div class="card-body">

View File

@ -6,8 +6,8 @@
{% block title %}{{ object.name }}{% endblock %}
{% block content %}
<div class="row mb-3">
<div class="col col-md-5">
<div class="row">
<div class="col col-md-6">
<div class="card">
<h2 class="card-header">{% trans "Export Template" %}</h2>
<table class="table table-hover attr-table">
@ -66,6 +66,9 @@
</tr>
</table>
</div>
{% plugin_left_page object %}
</div>
<div class="col col-md-6">
<div class="card">
<h2 class="card-header">{% trans "Assigned Models" %}</h2>
<table class="table table-hover attr-table">
@ -76,14 +79,10 @@
{% endfor %}
</table>
</div>
{% plugin_left_page object %}
</div>
<div class="col col-md-7">
<div class="card">
<h2 class="card-header">{% trans "Template" %}</h2>
<h2 class="card-header">{% trans "Environment Parameters" %}</h2>
<div class="card-body">
{% include 'inc/sync_warning.html' %}
<pre>{{ object.template_code }}</pre>
<pre>{{ object.environment_params }}</pre>
</div>
</div>
{% plugin_right_page object %}
@ -91,6 +90,13 @@
</div>
<div class="row">
<div class="col col-md-12">
<div class="card">
<h2 class="card-header">{% trans "Template" %}</h2>
<div class="card-body">
{% include 'inc/sync_warning.html' %}
<pre>{{ object.template_code }}</pre>
</div>
</div>
{% plugin_full_width_page object %}
</div>
</div>

View File

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

View File

@ -1,7 +1,6 @@
from django.contrib import messages
from django.db import transaction
from django.db.models import Prefetch, Sum
from django.http import HttpResponse
from django.shortcuts import get_object_or_404, redirect, render
from django.urls import reverse
from django.utils.translation import gettext_lazy as _
@ -431,10 +430,7 @@ class VirtualMachineRenderConfigView(generic.ObjectView):
# If a direct export has been requested, return the rendered template content as a
# downloadable file.
if request.GET.get('export'):
content = context['rendered_config'] or context['error_message']
response = HttpResponse(content, content_type='text')
filename = f"{instance.name or 'config'}.txt"
response['Content-Disposition'] = f'attachment; filename="{filename}"'
response = context['config_template'].render_to_response(context=context['context_data'])
return response
return render(request, self.get_template_name(), {