Create RenderMixin, and unify template_code rendering and exporting

This commit is contained in:
Renato Almeida de Oliveira Zaroubin 2025-04-04 02:02:57 +00:00
parent 6a966ee6c1
commit fa8343b7cb
18 changed files with 223 additions and 137 deletions

View File

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

View File

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

View File

@ -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):

View File

@ -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')),
)

View File

@ -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),
),
]

View File

@ -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),
),
]

View File

@ -4,16 +4,13 @@ from django.core.validators import ValidationError
from django.db import models
from django.urls import reverse
from django.utils.translation import gettext_lazy as _
from jinja2.loaders import BaseLoader
from jinja2.sandbox import SandboxedEnvironment
from extras.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 <a href="https://jinja.palletsprojects.com/en/3.1.x/api/#jinja2.Environment">additional parameters</a>'
' to pass when constructing the Jinja2 environment.'
)
)
class Meta:
ordering = ('name',)
@ -253,13 +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

View File

@ -6,7 +6,6 @@ from django.contrib.contenttypes.fields import GenericForeignKey, GenericRelatio
from django.contrib.postgres.fields import ArrayField
from django.core.validators import ValidationError
from django.db import models
from django.http import HttpResponse
from django.urls import reverse
from django.utils import timezone
from django.utils.translation import gettext_lazy as _
@ -16,12 +15,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 "
"<code>queryset</code>."
)
)
mime_type = models.CharField(
max_length=50,
blank=True,
verbose_name=_('MIME type'),
help_text=_('Defaults to <code>text/plain; charset=utf-8</code>')
)
file_name = models.CharField(
max_length=200,
blank=True,
help_text=_('Filename to give to the rendered export file')
)
file_extension = models.CharField(
verbose_name=_('file extension'),
max_length=15,
blank=True,
help_text=_('Extension to append to the rendered filename')
)
as_attachment = models.BooleanField(
verbose_name=_('as attachment'),
default=True,
help_text=_("Download file as attachment")
)
clone_fields = (
'object_types', 'template_code', 'mime_type', 'file_name', 'file_extension', 'as_attachment',
@ -460,37 +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):

View File

@ -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',

View File

@ -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):

View File

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

View File

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

View File

@ -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 <a href="https://jinja.palletsprojects.com/en/3.1.x/api/#jinja2.Environment">additional parameters</a>'
' to pass when constructing the Jinja2 environment.'
)
)
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")
)
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.

View File

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

View File

@ -17,6 +17,22 @@
<th scope="row">{% trans "Description" %}</th>
<td>{{ object.description|placeholder }}</td>
</tr>
<tr>
<th scope="row">{% trans "MIME Type" %}</th>
<td>{{ object.mime_type|placeholder }}</td>
</tr>
<tr>
<th scope="row">{% trans "File Name" %}</th>
<td>{{ object.file_name|placeholder }}</td>
</tr>
<tr>
<th scope="row">{% trans "File Extension" %}</th>
<td>{{ object.file_extension|placeholder }}</td>
</tr>
<tr>
<th scope="row">{% trans "Attachment" %}</th>
<td>{% checkmark object.as_attachment %}</td>
</tr>
<tr>
<th scope="row">{% trans "Data Source" %}</th>
<td>

View File

@ -86,6 +86,12 @@
<pre>{{ object.template_code }}</pre>
</div>
</div>
<div class="card">
<h2 class="card-header">{% trans "Environment Parameters" %}</h2>
<div class="card-body">
<pre>{{ object.environment_params }}</pre>
</div>
</div>
{% plugin_right_page object %}
</div>
</div>

View File

@ -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)

View File

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