mirror of
https://github.com/netbox-community/netbox.git
synced 2026-04-10 19:27:05 +02:00
The ability to render nested templates was accidentally removed with the implementation of #17653, which normalized the behavior of various Jinja2 template rendering actions. This fix restores that behavior while retaining the normalized behavior. This fix also includes regression tests to ensure this behavior is not removed accidentally again in the future.
161 lines
5.1 KiB
Python
161 lines
5.1 KiB
Python
import importlib.abc
|
|
import importlib.util
|
|
import os
|
|
import sys
|
|
from django.core.files.storage import storages
|
|
from django.db import models
|
|
from django.utils.translation import gettext_lazy as _
|
|
from django.http import HttpResponse
|
|
|
|
from extras.constants import DEFAULT_MIME_TYPE
|
|
from extras.utils import filename_from_model, filename_from_object
|
|
from utilities.jinja2 import render_jinja2
|
|
|
|
|
|
__all__ = (
|
|
'PythonModuleMixin',
|
|
'RenderTemplateMixin',
|
|
)
|
|
|
|
|
|
class CustomStoragesLoader(importlib.abc.Loader):
|
|
"""
|
|
Custom loader for exec_module to use django-storages instead of the file system.
|
|
"""
|
|
def __init__(self, filename):
|
|
self.filename = filename
|
|
|
|
def create_module(self, spec):
|
|
return None # Use default module creation
|
|
|
|
def exec_module(self, module):
|
|
storage = storages.create_storage(storages.backends["scripts"])
|
|
with storage.open(self.filename, 'rb') as f:
|
|
code = f.read()
|
|
exec(code, module.__dict__)
|
|
|
|
|
|
class PythonModuleMixin:
|
|
|
|
def get_jobs(self, name):
|
|
"""
|
|
Returns a list of Jobs associated with this specific script or report module
|
|
:param name: The class name of the script or report
|
|
:return: List of Jobs associated with this
|
|
"""
|
|
return self.jobs.filter(
|
|
name=name
|
|
)
|
|
|
|
@property
|
|
def path(self):
|
|
return os.path.splitext(self.file_path)[0]
|
|
|
|
@property
|
|
def python_name(self):
|
|
path, filename = os.path.split(self.full_path)
|
|
name = os.path.splitext(filename)[0]
|
|
if name == '__init__':
|
|
# File is a package
|
|
return os.path.basename(path)
|
|
else:
|
|
return name
|
|
|
|
def get_module(self):
|
|
"""
|
|
Load the module using importlib, but use a custom loader to use django-storages
|
|
instead of the file system.
|
|
"""
|
|
spec = importlib.util.spec_from_file_location(self.python_name, self.name)
|
|
if spec is None:
|
|
raise ModuleNotFoundError(f"Could not find module: {self.python_name}")
|
|
loader = CustomStoragesLoader(self.name)
|
|
module = importlib.util.module_from_spec(spec)
|
|
sys.modules[self.python_name] = module
|
|
loader.exec_module(module)
|
|
|
|
return module
|
|
|
|
|
|
class RenderTemplateMixin(models.Model):
|
|
"""
|
|
Enables support for rendering templates.
|
|
"""
|
|
template_code = models.TextField(
|
|
verbose_name=_('template code'),
|
|
help_text=_('Jinja template code.')
|
|
)
|
|
environment_params = models.JSONField(
|
|
verbose_name=_('environment parameters'),
|
|
blank=True,
|
|
null=True,
|
|
default=dict,
|
|
help_text=_(
|
|
'Any <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, getattr(self, 'data_file', None))
|
|
|
|
# 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
|