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

View File

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

View File

@ -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=_('Attributes'))
) )
data_source_id = DynamicModelMultipleChoiceField( data_source_id = DynamicModelMultipleChoiceField(
queryset=DataSource.objects.all(), queryset=DataSource.objects.all(),
@ -425,6 +426,25 @@ class ConfigTemplateFilterForm(SavedFiltersMixin, FilterForm):
} }
) )
tag = TagFilterField(ConfigTemplate) tag = TagFilterField(ConfigTemplate)
mime_type = forms.CharField(
required=False,
label=_('MIME type')
)
file_name = forms.CharField(
label=_('File name'),
required=False
)
file_extension = forms.CharField(
label=_('File extension'),
required=False
)
as_attachment = forms.NullBooleanField(
label=_('As attachment'),
required=False,
widget=forms.Select(
choices=BOOLEAN_WITH_BLANK_CHOICES
)
)
class LocalConfigContextFilterForm(forms.Form): class LocalConfigContextFilterForm(forms.Form):

View File

@ -246,7 +246,8 @@ 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,8 +632,10 @@ class ConfigTemplateForm(SyncedDataMixin, forms.ModelForm):
) )
fieldsets = ( fieldsets = (
FieldSet('name', 'description', 'environment_params', 'tags', name=_('Config Template')), FieldSet('name', 'description', 'tags', name=_('Config Template')),
FieldSet('template_code', name=_('Content')), 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')), 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.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.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, RenderMixin)
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,8 @@ class ConfigContextModel(models.Model):
# Config templates # Config templates
# #
class ConfigTemplate(SyncedDataMixin, CustomLinksMixin, ExportTemplatesMixin, TagsMixin, ChangeLoggedModel): class ConfigTemplate(
SyncedDataMixin, CustomLinksMixin, ExportTemplatesMixin, TagsMixin, ChangeLoggedModel, RenderMixin):
name = models.CharField( name = models.CharField(
verbose_name=_('name'), verbose_name=_('name'),
max_length=100 max_length=100
@ -220,20 +218,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 +237,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:
@ -273,33 +252,4 @@ class ConfigTemplate(SyncedDataMixin, CustomLinksMixin, ExportTemplatesMixin, Ta
if context is not None: if context is not None:
_context.update(context) _context.update(context)
# Initialize the Jinja2 environment and instantiate the Template return _context
environment = self._get_environment()
if self.data_file:
template = environment.get_template(self.data_file.path)
else:
template = environment.from_string(self.template_code)
output = template.render(**_context)
# Replace CRLF-style line terminators
return output.replace('\r\n', '\n')
def _get_environment(self):
"""
Instantiate and return a Jinja2 environment suitable for rendering the ConfigTemplate.
"""
# Initialize the template loader & cache the base template code (if applicable)
if self.data_file:
loader = DataFileLoader(data_source=self.data_source)
loader.cache_templates({
self.data_file.path: self.template_code
})
else:
loader = BaseLoader()
# Initialize the environment
env_params = self.environment_params or {}
environment = SandboxedEnvironment(loader=loader, **env_params)
environment.filters.update(get_config().JINJA2_FILTERS)
return environment

View File

@ -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,12 @@ 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 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, RenderMixin
) )
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 +381,7 @@ class CustomLink(CloningMixin, ExportTemplatesMixin, ChangeLoggedModel):
} }
class ExportTemplate(SyncedDataMixin, CloningMixin, ExportTemplatesMixin, ChangeLoggedModel): class ExportTemplate(SyncedDataMixin, CloningMixin, ExportTemplatesMixin, ChangeLoggedModel, RenderMixin):
object_types = models.ManyToManyField( object_types = models.ManyToManyField(
to='core.ObjectType', to='core.ObjectType',
related_name='export_templates', related_name='export_templates',
@ -397,34 +396,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 +431,11 @@ 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):
""" export_context = {
Render the contents of the template.
"""
context = {
'queryset': queryset 'queryset': queryset
} }
output = render_jinja2(self.template_code, context) return export_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
class SavedFilter(CloningMixin, ExportTemplatesMixin, ChangeLoggedModel): class SavedFilter(CloningMixin, ExportTemplatesMixin, ChangeLoggedModel):

View File

@ -550,12 +550,17 @@ class ConfigTemplateTable(NetBoxTable):
url_params={'config_template_id': 'pk'}, url_params={'config_template_id': 'pk'},
verbose_name=_('Virtual Machines') verbose_name=_('Virtual Machines')
) )
as_attachment = columns.BooleanColumn(
verbose_name=_('As Attachment'),
false_mark=None
)
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', '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 = ( default_columns = (
'pk', 'name', 'description', 'is_synced', 'device_count', 'vm_count', 'pk', 'name', 'description', 'is_synced', 'device_count', 'vm_count',

View File

@ -616,7 +616,7 @@ 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):

View File

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

View File

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

View File

@ -6,6 +6,7 @@ from django.contrib.contenttypes.fields import GenericRelation
from django.core.validators import ValidationError from django.core.validators import ValidationError
from django.db import models from django.db import models
from django.db.models import Q from django.db.models import Q
from django.http import HttpResponse
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 _
from taggit.managers import TaggableManager from taggit.managers import TaggableManager
@ -14,10 +15,11 @@ from core.choices import JobStatusChoices, ObjectChangeActionChoices
from core.models import ObjectType from core.models import ObjectType
from extras.choices import * from extras.choices import *
from extras.constants import CUSTOMFIELD_EMPTY_VALUES 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.config import get_config
from netbox.registry import registry from netbox.registry import registry
from netbox.signals import post_clean from netbox.signals import post_clean
from utilities.jinja2 import render_jinja2
from utilities.json import CustomFieldJSONEncoder from utilities.json import CustomFieldJSONEncoder
from utilities.serialization import serialize_object from utilities.serialization import serialize_object
from utilities.views import register_model_view from utilities.views import register_model_view
@ -37,6 +39,7 @@ __all__ = (
'JournalingMixin', 'JournalingMixin',
'NotificationsMixin', 'NotificationsMixin',
'SyncedDataMixin', 'SyncedDataMixin',
'RenderMixin',
'TagsMixin', 'TagsMixin',
'register_models', 'register_models',
) )
@ -337,6 +340,87 @@ class ExportTemplatesMixin(models.Model):
abstract = True 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): class ImageAttachmentsMixin(models.Model):
""" """
Enables the assignments of ImageAttachments. Enables the assignments of ImageAttachments.

View File

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

View File

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

View File

@ -86,6 +86,12 @@
<pre>{{ object.template_code }}</pre> <pre>{{ object.template_code }}</pre>
</div> </div>
</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 %} {% plugin_right_page object %}
</div> </div>
</div> </div>

View File

@ -48,10 +48,10 @@ class DataFileLoader(BaseLoader):
# Utility functions # 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. 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) environment.filters.update(get_config().JINJA2_FILTERS)
return environment.from_string(source=template_code).render(**context) return environment.from_string(source=template_code).render(**context)

View File

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