mirror of
https://github.com/netbox-community/netbox.git
synced 2025-07-14 01:41:22 -06:00
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:
parent
3902cab925
commit
fbd6d8c7fc
@ -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).
|
@ -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).
|
||||||
|
@ -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(), {
|
||||||
|
@ -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')
|
||||||
|
@ -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')
|
||||||
|
@ -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'
|
||||||
|
|
||||||
|
@ -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():
|
||||||
|
@ -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):
|
||||||
|
@ -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',
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@ -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):
|
||||||
|
@ -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:
|
||||||
|
@ -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()
|
||||||
|
|
||||||
|
@ -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),
|
||||||
|
),
|
||||||
|
]
|
@ -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
|
|
||||||
|
@ -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
|
||||||
|
@ -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):
|
||||||
|
@ -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',
|
||||||
|
@ -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',
|
||||||
|
@ -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()
|
||||||
|
@ -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,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@ -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.
|
||||||
|
@ -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)
|
||||||
|
|
||||||
|
@ -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,
|
||||||
|
@ -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">
|
||||||
|
@ -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>
|
||||||
|
@ -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)
|
||||||
|
@ -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(), {
|
||||||
|
Loading…
Reference in New Issue
Block a user