From fbd6d8c7fc450899730a9950f2d90e1c5f2f3e93 Mon Sep 17 00:00:00 2001 From: Renato Almeida de Oliveira Date: Tue, 8 Apr 2025 11:37:15 -0300 Subject: [PATCH] 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 --- docs/models/extras/configtemplate.md | 28 +++++- docs/models/extras/exporttemplate.md | 8 +- netbox/dcim/views.py | 6 +- .../api/serializers_/configtemplates.py | 3 +- .../api/serializers_/exporttemplates.py | 6 +- netbox/extras/constants.py | 3 + netbox/extras/filtersets.py | 5 +- netbox/extras/forms/bulk_edit.py | 21 +++- netbox/extras/forms/bulk_import.py | 7 +- netbox/extras/forms/filtersets.py | 24 ++++- netbox/extras/forms/model_forms.py | 10 +- netbox/extras/graphql/filters.py | 8 ++ ...6_configtemplate_as_attachment_and_more.py | 38 +++++++ netbox/extras/models/configs.py | 63 ++---------- netbox/extras/models/mixins.py | 92 +++++++++++++++++ netbox/extras/models/models.py | 71 +++----------- netbox/extras/tables/tables.py | 27 ++++- netbox/extras/tests/test_api.py | 6 +- netbox/extras/tests/test_filtersets.py | 98 +++++++++++++++---- netbox/extras/tests/test_views.py | 30 +++++- netbox/extras/utils.py | 11 +++ netbox/netbox/api/viewsets/mixins.py | 2 +- netbox/netbox/views/generic/bulk_views.py | 2 +- netbox/templates/extras/configtemplate.html | 22 ++++- netbox/templates/extras/exporttemplate.html | 22 +++-- netbox/utilities/jinja2.py | 6 +- netbox/virtualization/views.py | 6 +- 27 files changed, 437 insertions(+), 188 deletions(-) create mode 100644 netbox/extras/migrations/0126_configtemplate_as_attachment_and_more.py diff --git a/docs/models/extras/configtemplate.md b/docs/models/extras/configtemplate.md index b580d6885..6b245e5e9 100644 --- a/docs/models/extras/configtemplate.md +++ b/docs/models/extras/configtemplate.md @@ -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). \ No newline at end of file diff --git a/docs/models/extras/exporttemplate.md b/docs/models/extras/exporttemplate.md index 73be522b8..86e1ae04a 100644 --- a/docs/models/extras/exporttemplate.md +++ b/docs/models/extras/exporttemplate.md @@ -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). diff --git a/netbox/dcim/views.py b/netbox/dcim/views.py index 2027a7368..1e231ae3a 100644 --- a/netbox/dcim/views.py +++ b/netbox/dcim/views.py @@ -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(), { diff --git a/netbox/extras/api/serializers_/configtemplates.py b/netbox/extras/api/serializers_/configtemplates.py index c4a683c74..69652907e 100644 --- a/netbox/extras/api/serializers_/configtemplates.py +++ b/netbox/extras/api/serializers_/configtemplates.py @@ -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') diff --git a/netbox/extras/api/serializers_/exporttemplates.py b/netbox/extras/api/serializers_/exporttemplates.py index ad77cd1f7..0d19d642c 100644 --- a/netbox/extras/api/serializers_/exporttemplates.py +++ b/netbox/extras/api/serializers_/exporttemplates.py @@ -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') diff --git a/netbox/extras/constants.py b/netbox/extras/constants.py index 123b771f6..db378c8fa 100644 --- a/netbox/extras/constants.py +++ b/netbox/extras/constants.py @@ -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' diff --git a/netbox/extras/filtersets.py b/netbox/extras/filtersets.py index 8381316cc..89dd4c9f1 100644 --- a/netbox/extras/filtersets.py +++ b/netbox/extras/filtersets.py @@ -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(): diff --git a/netbox/extras/forms/bulk_edit.py b/netbox/extras/forms/bulk_edit.py index 6891edc5d..7a78dba8b 100644 --- a/netbox/extras/forms/bulk_edit.py +++ b/netbox/extras/forms/bulk_edit.py @@ -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): diff --git a/netbox/extras/forms/bulk_import.py b/netbox/extras/forms/bulk_import.py index fb522bd7b..7c270da4a 100644 --- a/netbox/extras/forms/bulk_import.py +++ b/netbox/extras/forms/bulk_import.py @@ -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', ) diff --git a/netbox/extras/forms/filtersets.py b/netbox/extras/forms/filtersets.py index 0a50047fe..056ca62a5 100644 --- a/netbox/extras/forms/filtersets.py +++ b/netbox/extras/forms/filtersets.py @@ -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): diff --git a/netbox/extras/forms/model_forms.py b/netbox/extras/forms/model_forms.py index b5bc06b40..594b7d9d0 100644 --- a/netbox/extras/forms/model_forms.py +++ b/netbox/extras/forms/model_forms.py @@ -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: diff --git a/netbox/extras/graphql/filters.py b/netbox/extras/graphql/filters.py index e22bda0ac..b8db143e4 100644 --- a/netbox/extras/graphql/filters.py +++ b/netbox/extras/graphql/filters.py @@ -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() diff --git a/netbox/extras/migrations/0126_configtemplate_as_attachment_and_more.py b/netbox/extras/migrations/0126_configtemplate_as_attachment_and_more.py new file mode 100644 index 000000000..6d5aad62e --- /dev/null +++ b/netbox/extras/migrations/0126_configtemplate_as_attachment_and_more.py @@ -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), + ), + ] diff --git a/netbox/extras/models/configs.py b/netbox/extras/models/configs.py index 6b52d4c02..204cc04f9 100644 --- a/netbox/extras/models/configs.py +++ b/netbox/extras/models/configs.py @@ -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 additional parameters' - ' 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 diff --git a/netbox/extras/models/mixins.py b/netbox/extras/models/mixins.py index f22b32004..3a7273f93 100644 --- a/netbox/extras/models/mixins.py +++ b/netbox/extras/models/mixins.py @@ -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 additional parameters 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 {default}').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 diff --git a/netbox/extras/models/models.py b/netbox/extras/models/models.py index 3cae54f29..76fad1082 100644 --- a/netbox/extras/models/models.py +++ b/netbox/extras/models/models.py @@ -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 " - "queryset." - ) - ) - mime_type = models.CharField( - max_length=50, - blank=True, - verbose_name=_('MIME type'), - help_text=_('Defaults to text/plain; charset=utf-8') - ) - 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): diff --git a/netbox/extras/tables/tables.py b/netbox/extras/tables/tables.py index 7a6e79cab..60b207058 100644 --- a/netbox/extras/tables/tables.py +++ b/netbox/extras/tables/tables.py @@ -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', diff --git a/netbox/extras/tests/test_api.py b/netbox/extras/tests/test_api.py index 7a4d63549..6e3fb37fc 100644 --- a/netbox/extras/tests/test_api.py +++ b/netbox/extras/tests/test_api.py @@ -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', diff --git a/netbox/extras/tests/test_filtersets.py b/netbox/extras/tests/test_filtersets.py index 247653472..987dfe0ff 100644 --- a/netbox/extras/tests/test_filtersets.py +++ b/netbox/extras/tests/test_filtersets.py @@ -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() diff --git a/netbox/extras/tests/test_views.py b/netbox/extras/tests/test_views.py index 0688cd2c2..6378b29b8 100644 --- a/netbox/extras/tests/test_views.py +++ b/netbox/extras/tests/test_views.py @@ -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, } diff --git a/netbox/extras/utils.py b/netbox/extras/utils.py index 411d80f78..155597c30 100644 --- a/netbox/extras/utils.py +++ b/netbox/extras/utils.py @@ -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. diff --git a/netbox/netbox/api/viewsets/mixins.py b/netbox/netbox/api/viewsets/mixins.py index e07e2c78b..e21be2348 100644 --- a/netbox/netbox/api/viewsets/mixins.py +++ b/netbox/netbox/api/viewsets/mixins.py @@ -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) diff --git a/netbox/netbox/views/generic/bulk_views.py b/netbox/netbox/views/generic/bulk_views.py index 88857ad54..447e2a6c5 100644 --- a/netbox/netbox/views/generic/bulk_views.py +++ b/netbox/netbox/views/generic/bulk_views.py @@ -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, diff --git a/netbox/templates/extras/configtemplate.html b/netbox/templates/extras/configtemplate.html index 5218a3b8f..eeb962cfb 100644 --- a/netbox/templates/extras/configtemplate.html +++ b/netbox/templates/extras/configtemplate.html @@ -4,8 +4,8 @@ {% load i18n %} {% block content %} -
-
+
+

{% trans "Config Template" %}

@@ -17,6 +17,22 @@ + + + + + + + + + + + + + + + +
{% trans "Description" %} {{ object.description|placeholder }}
{% trans "MIME Type" %}{{ object.mime_type|placeholder }}
{% trans "File Name" %}{{ object.file_name|placeholder }}
{% trans "File Extension" %}{{ object.file_extension|placeholder }}
{% trans "Attachment" %}{% checkmark object.as_attachment %}
{% trans "Data Source" %} @@ -51,7 +67,7 @@ {% include 'inc/panels/tags.html' %} {% plugin_left_page object %} -
+

{% trans "Environment Parameters" %}

diff --git a/netbox/templates/extras/exporttemplate.html b/netbox/templates/extras/exporttemplate.html index f0e370c03..fd1e56d11 100644 --- a/netbox/templates/extras/exporttemplate.html +++ b/netbox/templates/extras/exporttemplate.html @@ -6,8 +6,8 @@ {% block title %}{{ object.name }}{% endblock %} {% block content %} -
-
+
+

{% trans "Export Template" %}

@@ -66,6 +66,9 @@
+ {% plugin_left_page object %} +
+

{% trans "Assigned Models" %}

@@ -76,14 +79,10 @@ {% endfor %}
- {% plugin_left_page object %} -
-
-

{% trans "Template" %}

+

{% trans "Environment Parameters" %}

- {% include 'inc/sync_warning.html' %} -
{{ object.template_code }}
+
{{ object.environment_params }}
{% plugin_right_page object %} @@ -91,6 +90,13 @@
+
+

{% trans "Template" %}

+
+ {% include 'inc/sync_warning.html' %} +
{{ object.template_code }}
+
+
{% plugin_full_width_page object %}
diff --git a/netbox/utilities/jinja2.py b/netbox/utilities/jinja2.py index cea8c9029..37b3b2dfb 100644 --- a/netbox/utilities/jinja2.py +++ b/netbox/utilities/jinja2.py @@ -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) diff --git a/netbox/virtualization/views.py b/netbox/virtualization/views.py index e76f2d52f..ae2aaa750 100644 --- a/netbox/virtualization/views.py +++ b/netbox/virtualization/views.py @@ -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(), {