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

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

View File

@ -22,6 +22,7 @@ class ConfigTemplateSerializer(TaggableModelSerializer, ValidatedModelSerializer
model = ConfigTemplate model = ConfigTemplate
fields = [ fields = [
'id', 'url', 'display_url', 'display', 'name', 'description', 'environment_params', 'template_code', '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') brief_fields = ('id', 'url', 'display', 'name', 'description')

View File

@ -26,8 +26,8 @@ class ExportTemplateSerializer(ValidatedModelSerializer):
class Meta: class Meta:
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', 'environment_params',
'file_name', 'file_extension', 'as_attachment', 'data_source', 'data_path', 'data_file', 'data_synced', 'template_code', 'mime_type', 'file_name', 'file_extension', 'as_attachment', 'data_source',
'created', 'last_updated', 'data_path', 'data_file', 'data_synced', 'created', 'last_updated',
] ]
brief_fields = ('id', 'url', 'display', 'name', 'description') brief_fields = ('id', 'url', 'display', 'name', 'description')

View File

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

View File

@ -707,7 +707,10 @@ class ConfigTemplateFilterSet(ChangeLoggedModelFilterSet):
class Meta: class Meta:
model = ConfigTemplate 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): def search(self, queryset, name, value):
if not value.strip(): if not value.strip():

View File

@ -321,8 +321,27 @@ class ConfigTemplateBulkEditForm(BulkEditForm):
max_length=200, max_length=200,
required=False 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): class JournalEntryBulkEditForm(BulkEditForm):

View File

@ -144,8 +144,8 @@ class ExportTemplateImportForm(CSVModelForm):
class Meta: class Meta:
model = ExportTemplate model = ExportTemplate
fields = ( fields = (
'name', 'object_types', 'description', 'mime_type', 'file_name', 'file_extension', 'as_attachment', 'name', 'object_types', 'description', 'environment_params', 'mime_type', 'file_name', 'file_extension',
'template_code', 'as_attachment', 'template_code',
) )
@ -154,7 +154,8 @@ class ConfigTemplateImportForm(CSVModelForm):
class Meta: class Meta:
model = ConfigTemplate model = ConfigTemplate
fields = ( 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): class ExportTemplateFilterForm(SavedFiltersMixin, FilterForm):
fieldsets = ( fieldsets = (
FieldSet('q', 'filter_id'), FieldSet('q', 'filter_id', 'object_type_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_name', 'file_extension', 'as_attachment', name=_('Attributes')), FieldSet('mime_type', 'file_name', 'file_extension', 'as_attachment', name=_('Rendering')),
) )
data_source_id = DynamicModelMultipleChoiceField( data_source_id = DynamicModelMultipleChoiceField(
queryset=DataSource.objects.all(), queryset=DataSource.objects.all(),
@ -410,6 +410,7 @@ class ConfigTemplateFilterForm(SavedFiltersMixin, FilterForm):
fieldsets = ( fieldsets = (
FieldSet('q', 'filter_id', 'tag'), FieldSet('q', 'filter_id', 'tag'),
FieldSet('data_source_id', 'data_file_id', name=_('Data')), FieldSet('data_source_id', 'data_file_id', name=_('Data')),
FieldSet('mime_type', 'file_name', 'file_extension', 'as_attachment', name=_('Rendering'))
) )
data_source_id = DynamicModelMultipleChoiceField( data_source_id = DynamicModelMultipleChoiceField(
queryset=DataSource.objects.all(), queryset=DataSource.objects.all(),
@ -425,6 +426,25 @@ class ConfigTemplateFilterForm(SavedFiltersMixin, FilterForm):
} }
) )
tag = TagFilterField(ConfigTemplate) 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): class LocalConfigContextFilterForm(forms.Form):

View File

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

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

@ -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.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 jinja2.loaders import BaseLoader
from jinja2.sandbox import SandboxedEnvironment
from extras.models.mixins import RenderTemplateMixin
from extras.querysets import ConfigContextQuerySet from extras.querysets import ConfigContextQuerySet
from netbox.config import get_config
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
from utilities.data import deepmerge from utilities.data import deepmerge
from utilities.jinja2 import DataFileLoader
__all__ = ( __all__ = (
'ConfigContext', 'ConfigContext',
@ -210,7 +207,9 @@ class ConfigContextModel(models.Model):
# Config templates # Config templates
# #
class ConfigTemplate(SyncedDataMixin, CustomLinksMixin, ExportTemplatesMixin, TagsMixin, ChangeLoggedModel): class ConfigTemplate(
RenderTemplateMixin, SyncedDataMixin, CustomLinksMixin, ExportTemplatesMixin, TagsMixin, ChangeLoggedModel
):
name = models.CharField( name = models.CharField(
verbose_name=_('name'), verbose_name=_('name'),
max_length=100 max_length=100
@ -220,20 +219,6 @@ class ConfigTemplate(SyncedDataMixin, CustomLinksMixin, ExportTemplatesMixin, Ta
max_length=200, max_length=200,
blank=True 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: class Meta:
ordering = ('name',) ordering = ('name',)
@ -253,13 +238,8 @@ class ConfigTemplate(SyncedDataMixin, CustomLinksMixin, ExportTemplatesMixin, Ta
self.template_code = self.data_file.data_as_string self.template_code = self.data_file.data_as_string
sync_data.alters_data = True sync_data.alters_data = True
def render(self, context=None): def get_context(self, context=None, queryset=None):
"""
Render the contents of the template.
"""
_context = dict() _context = dict()
# Populate the default template context with NetBox model classes, namespaced by app
for app, model_names in registry['models'].items(): for app, model_names in registry['models'].items():
_context.setdefault(app, {}) _context.setdefault(app, {})
for model_name in model_names: for model_name in model_names:
@ -269,37 +249,8 @@ class ConfigTemplate(SyncedDataMixin, CustomLinksMixin, ExportTemplatesMixin, Ta
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)
# Initialize the Jinja2 environment and instantiate the Template return _context
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

View File

@ -3,9 +3,18 @@ import importlib.util
import os import os
import sys import sys
from django.core.files.storage import storages 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__ = ( __all__ = (
'PythonModuleMixin', 'PythonModuleMixin',
'RenderTemplateMixin',
) )
@ -66,3 +75,86 @@ class PythonModuleMixin:
loader.exec_module(module) loader.exec_module(module)
return 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.contrib.postgres.fields import ArrayField
from django.core.validators import ValidationError from django.core.validators import ValidationError
from django.db import models from django.db import models
from django.http import HttpResponse
from django.urls import reverse from django.urls import reverse
from django.utils import timezone from django.utils import timezone
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
@ -16,12 +15,13 @@ 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 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.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
from netbox.models.features import ( 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.html import clean_html
from utilities.jinja2 import render_jinja2 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( object_types = models.ManyToManyField(
to='core.ObjectType', to='core.ObjectType',
related_name='export_templates', related_name='export_templates',
@ -397,34 +397,6 @@ class ExportTemplate(SyncedDataMixin, CloningMixin, ExportTemplatesMixin, Change
max_length=200, max_length=200,
blank=True 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 = ( clone_fields = (
'object_types', 'template_code', 'mime_type', 'file_name', 'file_extension', 'as_attachment', '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 self.template_code = self.data_file.data_as_string
sync_data.alters_data = True sync_data.alters_data = True
def render(self, queryset): def get_context(self, context=None, queryset=None):
""" _context = {
Render the contents of the template. 'queryset': queryset,
"""
context = {
'queryset': queryset
} }
output = render_jinja2(self.template_code, context)
# Replace CRLF-style line terminators # Apply the provided context data, if any
output = output.replace('\r\n', '\n') if context is not None:
_context.update(context)
return output return _context
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
class SavedFilter(CloningMixin, ExportTemplatesMixin, ChangeLoggedModel): class SavedFilter(CloningMixin, ExportTemplatesMixin, ChangeLoggedModel):

View File

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

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',
@ -778,7 +782,7 @@ class ConfigTemplateTest(APIViewTestCases.APIViewTestCase):
), ),
ConfigTemplate( ConfigTemplate(
name='Config Template 2', name='Config Template 2',
template_code='Bar: {{ bar }}' template_code='Bar: {{ bar }}',
), ),
ConfigTemplate( ConfigTemplate(
name='Config Template 3', name='Config Template 3',

View File

@ -616,19 +616,39 @@ class BookmarkTestCase(TestCase, BaseFilterSetTests):
class ExportTemplateTestCase(TestCase, ChangeLoggedFilterSetTests): class ExportTemplateTestCase(TestCase, ChangeLoggedFilterSetTests):
queryset = ExportTemplate.objects.all() queryset = ExportTemplate.objects.all()
filterset = ExportTemplateFilterSet filterset = ExportTemplateFilterSet
ignore_fields = ('template_code', 'data_path') ignore_fields = ('template_code', 'environment_params', 'data_path')
@classmethod @classmethod
def setUpTestData(cls): def setUpTestData(cls):
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,9 +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',
ConfigTemplate(name='Config Template 3', template_code='TESTING'), 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) ConfigTemplate.objects.bulk_create(config_templates)
@ -1106,6 +1148,22 @@ 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):
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): class TagTestCase(TestCase, ChangeLoggedFilterSetTests):
queryset = Tag.objects.all() queryset = Tag.objects.all()

View File

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

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

View File

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

View File

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

View File

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

View File

@ -7,6 +7,7 @@ from netbox.config import get_config
__all__ = ( __all__ = (
'DataFileLoader', 'DataFileLoader',
'render_jinja2',
) )
@ -48,10 +49,11 @@ class DataFileLoader(BaseLoader):
# Utility functions # 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. 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) 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)

View File

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