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/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/filtersets.py b/netbox/extras/forms/filtersets.py index 0a50047fe..2e2289efe 100644 --- a/netbox/extras/forms/filtersets.py +++ b/netbox/extras/forms/filtersets.py @@ -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=_('Attributes')) ) 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..4d7a0c4c7 100644 --- a/netbox/extras/forms/model_forms.py +++ b/netbox/extras/forms/model_forms.py @@ -246,7 +246,8 @@ 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,8 +632,10 @@ class ConfigTemplateForm(SyncedDataMixin, forms.ModelForm): ) fieldsets = ( - FieldSet('name', 'description', 'environment_params', 'tags', name=_('Config Template')), + FieldSet('name', 'description', 'tags', name=_('Config Template')), FieldSet('template_code', name=_('Content')), + FieldSet( + 'mime_type', 'file_name', 'file_extension', 'environment_params', 'as_attachment', name=_('Rendering')), FieldSet('data_source', 'data_file', 'auto_sync_enabled', name=_('Data Source')), ) diff --git a/netbox/extras/migrations/0126_exporttemplate_environment_params.py b/netbox/extras/migrations/0126_exporttemplate_environment_params.py new file mode 100644 index 000000000..dce32a950 --- /dev/null +++ b/netbox/extras/migrations/0126_exporttemplate_environment_params.py @@ -0,0 +1,18 @@ +# Generated by Django 5.2b1 on 2025-04-03 01:08 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('extras', '0125_exporttemplate_file_name'), + ] + + operations = [ + migrations.AddField( + model_name='exporttemplate', + name='environment_params', + field=models.JSONField(blank=True, default=dict, null=True), + ), + ] diff --git a/netbox/extras/migrations/0127_configtemplate_as_attachment_and_more.py b/netbox/extras/migrations/0127_configtemplate_as_attachment_and_more.py new file mode 100644 index 000000000..967b14c3d --- /dev/null +++ b/netbox/extras/migrations/0127_configtemplate_as_attachment_and_more.py @@ -0,0 +1,33 @@ +# Generated by Django 5.2b1 on 2025-04-04 00:58 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('extras', '0126_exporttemplate_environment_params'), + ] + + 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), + ), + ] diff --git a/netbox/extras/models/configs.py b/netbox/extras/models/configs.py index 6b52d4c02..65e7ed699 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.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.models.features import ( + CloningMixin, CustomLinksMixin, ExportTemplatesMixin, SyncedDataMixin, TagsMixin, RenderMixin) from netbox.registry import registry from utilities.data import deepmerge -from utilities.jinja2 import DataFileLoader __all__ = ( 'ConfigContext', @@ -210,7 +207,8 @@ class ConfigContextModel(models.Model): # Config templates # -class ConfigTemplate(SyncedDataMixin, CustomLinksMixin, ExportTemplatesMixin, TagsMixin, ChangeLoggedModel): +class ConfigTemplate( + SyncedDataMixin, CustomLinksMixin, ExportTemplatesMixin, TagsMixin, ChangeLoggedModel, RenderMixin): name = models.CharField( verbose_name=_('name'), max_length=100 @@ -220,20 +218,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 +237,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: @@ -273,33 +252,4 @@ class ConfigTemplate(SyncedDataMixin, CustomLinksMixin, ExportTemplatesMixin, Ta 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/models.py b/netbox/extras/models/models.py index 3cae54f29..8619445cf 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,12 @@ 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 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, RenderMixin ) from utilities.html import clean_html from utilities.jinja2 import render_jinja2 @@ -382,7 +381,7 @@ class CustomLink(CloningMixin, ExportTemplatesMixin, ChangeLoggedModel): } -class ExportTemplate(SyncedDataMixin, CloningMixin, ExportTemplatesMixin, ChangeLoggedModel): +class ExportTemplate(SyncedDataMixin, CloningMixin, ExportTemplatesMixin, ChangeLoggedModel, RenderMixin): object_types = models.ManyToManyField( to='core.ObjectType', related_name='export_templates', @@ -397,34 +396,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 +431,11 @@ 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 = { + def get_context(self, context=None, queryset=None): + export_context = { 'queryset': queryset } - output = render_jinja2(self.template_code, context) - - # Replace CRLF-style line terminators - output = output.replace('\r\n', '\n') - - 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 export_context class SavedFilter(CloningMixin, ExportTemplatesMixin, ChangeLoggedModel): diff --git a/netbox/extras/tables/tables.py b/netbox/extras/tables/tables.py index 7a6e79cab..a28ea2712 100644 --- a/netbox/extras/tables/tables.py +++ b/netbox/extras/tables/tables.py @@ -550,12 +550,17 @@ class ConfigTemplateTable(NetBoxTable): url_params={'config_template_id': 'pk'}, verbose_name=_('Virtual Machines') ) + as_attachment = columns.BooleanColumn( + verbose_name=_('As Attachment'), + false_mark=None + ) 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', + 'platform_count', 'device_count', 'vm_count', 'created', 'last_updated', 'tags', 'as_attachment', + 'mime_type', 'file_name', 'file_extension', ) default_columns = ( 'pk', 'name', 'description', 'is_synced', 'device_count', 'vm_count', diff --git a/netbox/extras/tests/test_filtersets.py b/netbox/extras/tests/test_filtersets.py index 247653472..eb3dc93de 100644 --- a/netbox/extras/tests/test_filtersets.py +++ b/netbox/extras/tests/test_filtersets.py @@ -616,7 +616,7 @@ 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): 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/models/features.py b/netbox/netbox/models/features.py index d14fdb17f..9173ae99e 100644 --- a/netbox/netbox/models/features.py +++ b/netbox/netbox/models/features.py @@ -6,6 +6,7 @@ from django.contrib.contenttypes.fields import GenericRelation from django.core.validators import ValidationError from django.db import models from django.db.models import Q +from django.http import HttpResponse from django.utils import timezone from django.utils.translation import gettext_lazy as _ from taggit.managers import TaggableManager @@ -14,10 +15,11 @@ from core.choices import JobStatusChoices, ObjectChangeActionChoices from core.models import ObjectType from extras.choices import * from extras.constants import CUSTOMFIELD_EMPTY_VALUES -from extras.utils import is_taggable +from extras.utils import is_taggable, filename_from_model, filename_from_object from netbox.config import get_config from netbox.registry import registry from netbox.signals import post_clean +from utilities.jinja2 import render_jinja2 from utilities.json import CustomFieldJSONEncoder from utilities.serialization import serialize_object from utilities.views import register_model_view @@ -37,6 +39,7 @@ __all__ = ( 'JournalingMixin', 'NotificationsMixin', 'SyncedDataMixin', + 'RenderMixin', 'TagsMixin', 'register_models', ) @@ -337,6 +340,87 @@ class ExportTemplatesMixin(models.Model): abstract = True +class RenderMixin(models.Model): + """ + Enables support for rendering templates. + """ + 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.' + ) + ) + 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") + ) + + 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 'text/plain; charset=utf-8' + + # 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 queryset: + filename = self.file_name or filename_from_model(queryset.model) + elif context: + filename = self.file_name or filename_from_object(context) + full_filename = f'{filename}{extension}' + response['Content-Disposition'] = f'attachment; filename="{full_filename}"' + + return response + + class ImageAttachmentsMixin(models.Model): """ Enables the assignments of ImageAttachments. 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..55676413e 100644 --- a/netbox/templates/extras/configtemplate.html +++ b/netbox/templates/extras/configtemplate.html @@ -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" %} diff --git a/netbox/templates/extras/exporttemplate.html b/netbox/templates/extras/exporttemplate.html index f0e370c03..268817703 100644 --- a/netbox/templates/extras/exporttemplate.html +++ b/netbox/templates/extras/exporttemplate.html @@ -86,6 +86,12 @@
{{ object.template_code }}
+
+

{% trans "Environment Parameters" %}

+
+
{{ object.environment_params }}
+
+
{% plugin_right_page object %} diff --git a/netbox/utilities/jinja2.py b/netbox/utilities/jinja2.py index cea8c9029..d51351679 100644 --- a/netbox/utilities/jinja2.py +++ b/netbox/utilities/jinja2.py @@ -48,10 +48,10 @@ class DataFileLoader(BaseLoader): # Utility functions # -def render_jinja2(template_code, context): +def render_jinja2(template_code, context, environment_params={}): """ Render a Jinja2 template with the provided context. Return the rendered content. """ - environment = SandboxedEnvironment() + 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(), {