This commit is contained in:
parent
963306f338
commit
c27c470499
|
|
@ -138,7 +138,8 @@ class RenderTemplateMixin(models.Model):
|
|||
"""
|
||||
Pre-processing of any defined Jinja environment parameters (e.g. to support path resolution).
|
||||
"""
|
||||
params = self.environment_params or {}
|
||||
# Shallow-copy so resolved imports don't replace the string values on the model field itself.
|
||||
params = dict(self.environment_params or {})
|
||||
for name, value in params.items():
|
||||
if name in JINJA_ENV_PARAMS_WITH_PATH_IMPORT and type(value) is str:
|
||||
params[name] = import_string(value)
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import io
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import patch
|
||||
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
|
|
@ -9,11 +10,13 @@ from django.core.files.storage import Storage
|
|||
from django.core.files.uploadedfile import SimpleUploadedFile
|
||||
from django.forms import ValidationError
|
||||
from django.test import TestCase, tag
|
||||
from jinja2 import StrictUndefined, TemplateError, TemplateSyntaxError, UndefinedError
|
||||
from PIL import Image
|
||||
|
||||
from core.events import OBJECT_CREATED
|
||||
from core.models import AutoSyncRecord, DataSource, ObjectType
|
||||
from dcim.models import Device, DeviceRole, DeviceType, Location, Manufacturer, Platform, Region, Site, SiteGroup
|
||||
from extras.constants import DEFAULT_MIME_TYPE
|
||||
from extras.models import (
|
||||
ConfigContext,
|
||||
ConfigContextProfile,
|
||||
|
|
@ -26,6 +29,7 @@ from extras.models import (
|
|||
)
|
||||
from tenancy.models import Tenant, TenantGroup
|
||||
from utilities.exceptions import AbortRequest
|
||||
from utilities.jinja2 import render_jinja2
|
||||
from virtualization.models import Cluster, ClusterGroup, ClusterType, VirtualMachine
|
||||
|
||||
|
||||
|
|
@ -925,30 +929,24 @@ class ConfigTemplateDebugTestCase(TestCase):
|
|||
|
||||
def test_template_error_non_debug_no_traceback(self):
|
||||
"""In non-debug mode, a TemplateError raises with no traceback exposure."""
|
||||
from jinja2 import TemplateError
|
||||
t = self._make_template("{{ unclosed", debug=False)
|
||||
with self.assertRaises(TemplateError):
|
||||
t.render({})
|
||||
|
||||
def test_template_error_debug_mode_raises(self):
|
||||
"""In debug mode, a TemplateError still raises (callers handle display)."""
|
||||
from jinja2 import TemplateError
|
||||
t = self._make_template("{{ unclosed", debug=True)
|
||||
with self.assertRaises(TemplateError):
|
||||
t.render({})
|
||||
|
||||
def test_render_jinja2_debug_extension_enabled(self):
|
||||
"""When debug=True, the Jinja2 debug extension is loaded in the environment."""
|
||||
from utilities.jinja2 import render_jinja2
|
||||
# The {% debug %} tag is only available when the debug extension is loaded.
|
||||
output = render_jinja2("{% debug %}", {}, debug=True)
|
||||
self.assertIsInstance(output, str)
|
||||
|
||||
def test_render_jinja2_debug_extension_not_loaded_by_default(self):
|
||||
"""When debug=False, the {% debug %} tag is not available."""
|
||||
from jinja2 import TemplateSyntaxError
|
||||
|
||||
from utilities.jinja2 import render_jinja2
|
||||
with self.assertRaises(TemplateSyntaxError):
|
||||
render_jinja2("{% debug %}", {}, debug=False)
|
||||
|
||||
|
|
@ -986,6 +984,183 @@ class ExportTemplateContextTestCase(TestCase):
|
|||
self.assertIs(ctx['dcim']['Site'], Site)
|
||||
|
||||
|
||||
def finalize_none_to_dash(value):
|
||||
"""
|
||||
Module-level helper used by RenderTemplateMixinRenderTest.test_environment_params_finalize_path_import.
|
||||
Exported so it can be referenced by dotted path from a Jinja environment_params value.
|
||||
"""
|
||||
return '-' if value is None else value
|
||||
|
||||
|
||||
class RenderTemplateMixinRenderTest(TestCase):
|
||||
"""
|
||||
Tests for RenderTemplateMixin.render() and get_environment_params(), exercised via ConfigTemplate.
|
||||
"""
|
||||
|
||||
def test_render_basic_context(self):
|
||||
t = ConfigTemplate(name='basic', template_code='Hello {{ name }}')
|
||||
self.assertEqual(t.render({'name': 'world'}), 'Hello world')
|
||||
|
||||
def test_render_normalizes_crlf(self):
|
||||
t = ConfigTemplate(name='crlf', template_code='line1\r\nline2\r\nline3')
|
||||
self.assertEqual(t.render({}), 'line1\nline2\nline3')
|
||||
|
||||
def test_render_passes_environment_params(self):
|
||||
# With trim_blocks + lstrip_blocks, block tags don't emit their surrounding whitespace.
|
||||
template_code = '{% if x %}\n {% if y %}\n VALUE\n {% endif %}\n{% endif %}'
|
||||
plain = ConfigTemplate(name='plain', template_code=template_code)
|
||||
trimmed = ConfigTemplate(
|
||||
name='trimmed',
|
||||
template_code=template_code,
|
||||
environment_params={'trim_blocks': True, 'lstrip_blocks': True},
|
||||
)
|
||||
ctx = {'x': True, 'y': True}
|
||||
self.assertNotEqual(plain.render(ctx), trimmed.render(ctx))
|
||||
self.assertEqual(trimmed.render(ctx).strip(), 'VALUE')
|
||||
|
||||
def test_environment_params_undefined_path_import(self):
|
||||
# Default Undefined renders nothing for a missing variable.
|
||||
default = ConfigTemplate(name='default', template_code='{{ missing }}')
|
||||
self.assertEqual(default.render({}), '')
|
||||
|
||||
# StrictUndefined (resolved from its dotted path) raises on access.
|
||||
strict = ConfigTemplate(
|
||||
name='strict',
|
||||
template_code='{{ missing }}',
|
||||
environment_params={'undefined': 'jinja2.StrictUndefined'},
|
||||
)
|
||||
with self.assertRaises(UndefinedError):
|
||||
strict.render({})
|
||||
|
||||
def test_environment_params_finalize_path_import(self):
|
||||
t = ConfigTemplate(
|
||||
name='finalize',
|
||||
template_code='{{ v }}',
|
||||
environment_params={'finalize': 'extras.tests.test_models.finalize_none_to_dash'},
|
||||
)
|
||||
self.assertEqual(t.render({'v': None}), '-')
|
||||
self.assertEqual(t.render({'v': 'abc'}), 'abc')
|
||||
|
||||
def test_get_environment_params_handles_none(self):
|
||||
# The environment_params field may be cleared; ensure the mixin returns a dict (not None).
|
||||
t = ConfigTemplate(name='empty', template_code='ok', environment_params=None)
|
||||
self.assertEqual(t.get_environment_params(), {})
|
||||
|
||||
def test_get_environment_params_resolves_path_imports(self):
|
||||
t = ConfigTemplate(
|
||||
name='resolve',
|
||||
template_code='ok',
|
||||
environment_params={'undefined': 'jinja2.StrictUndefined', 'trim_blocks': True},
|
||||
)
|
||||
params = t.get_environment_params()
|
||||
self.assertIs(params['undefined'], StrictUndefined)
|
||||
self.assertIs(params['trim_blocks'], True)
|
||||
|
||||
def test_get_environment_params_does_not_mutate_field(self):
|
||||
# Resolving path imports must not replace the string values stored on the model field.
|
||||
t = ConfigTemplate(
|
||||
name='no-mutate',
|
||||
template_code='ok',
|
||||
environment_params={'undefined': 'jinja2.StrictUndefined'},
|
||||
)
|
||||
t.get_environment_params()
|
||||
t.get_environment_params()
|
||||
self.assertEqual(t.environment_params, {'undefined': 'jinja2.StrictUndefined'})
|
||||
|
||||
|
||||
class RenderTemplateMixinResponseTest(TestCase):
|
||||
"""
|
||||
Tests for RenderTemplateMixin.render_to_response() HTTP behavior.
|
||||
"""
|
||||
|
||||
def test_response_default_mime_type(self):
|
||||
t = ConfigTemplate(name='t', template_code='ok')
|
||||
response = t.render_to_response({})
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertEqual(response['Content-Type'], DEFAULT_MIME_TYPE)
|
||||
|
||||
def test_response_custom_mime_type(self):
|
||||
t = ConfigTemplate(name='t', template_code='{}', mime_type='application/json')
|
||||
response = t.render_to_response({})
|
||||
self.assertEqual(response['Content-Type'], 'application/json')
|
||||
|
||||
def test_response_attachment_with_file_name(self):
|
||||
t = ConfigTemplate(
|
||||
name='t', template_code='ok', file_name='router1', file_extension='cfg', as_attachment=True,
|
||||
)
|
||||
response = t.render_to_response({})
|
||||
self.assertEqual(response['Content-Disposition'], 'attachment; filename="router1.cfg"')
|
||||
|
||||
def test_response_attachment_filename_from_queryset(self):
|
||||
Site.objects.create(name='Site 1', slug='site-1')
|
||||
t = ExportTemplate(
|
||||
name='t',
|
||||
template_code='{% for obj in queryset %}{{ obj.name }}{% endfor %}',
|
||||
file_extension='txt',
|
||||
as_attachment=True,
|
||||
)
|
||||
response = t.render_to_response(queryset=Site.objects.all())
|
||||
self.assertEqual(response['Content-Disposition'], 'attachment; filename="netbox_sites.txt"')
|
||||
|
||||
def test_response_attachment_filename_from_device_context(self):
|
||||
t = ConfigTemplate(name='t', template_code='ok', as_attachment=True)
|
||||
device = SimpleNamespace(name='router1')
|
||||
response = t.render_to_response(context={'device': device})
|
||||
self.assertEqual(response['Content-Disposition'], 'attachment; filename="router1"')
|
||||
|
||||
def test_response_attachment_fallback_filename(self):
|
||||
# No file_name, no queryset, no device/vm key in context: filename falls back to "output".
|
||||
t = ConfigTemplate(name='t', template_code='ok', as_attachment=True)
|
||||
response = t.render_to_response({})
|
||||
self.assertEqual(response['Content-Disposition'], 'attachment; filename="output"')
|
||||
|
||||
def test_response_as_attachment_false_omits_disposition(self):
|
||||
t = ConfigTemplate(name='t', template_code='ok', file_name='router1', as_attachment=False)
|
||||
response = t.render_to_response({})
|
||||
self.assertNotIn('Content-Disposition', response)
|
||||
|
||||
def test_response_body_matches_render(self):
|
||||
t = ConfigTemplate(name='t', template_code='Hello {{ name }}')
|
||||
rendered = t.render({'name': 'world'})
|
||||
response = t.render_to_response({'name': 'world'})
|
||||
self.assertEqual(response.content.decode(), rendered)
|
||||
|
||||
|
||||
class ExportTemplateRenderTest(TestCase):
|
||||
"""
|
||||
Tests for ExportTemplate.render() with a queryset bound into the template context.
|
||||
"""
|
||||
|
||||
@classmethod
|
||||
def setUpTestData(cls):
|
||||
Site.objects.bulk_create([
|
||||
Site(name='Site A', slug='site-a'),
|
||||
Site(name='Site B', slug='site-b'),
|
||||
Site(name='Site C', slug='site-c'),
|
||||
])
|
||||
|
||||
def test_render_iterates_queryset(self):
|
||||
t = ExportTemplate(
|
||||
name='sites',
|
||||
template_code='{% for obj in queryset %}{{ obj.name }}\n{% endfor %}',
|
||||
)
|
||||
queryset = Site.objects.order_by('name')
|
||||
output = t.render(queryset=queryset)
|
||||
self.assertEqual(output, 'Site A\nSite B\nSite C\n')
|
||||
|
||||
def test_render_to_response_for_queryset(self):
|
||||
t = ExportTemplate(
|
||||
name='sites',
|
||||
template_code='{% for obj in queryset %}{{ obj.name }}\n{% endfor %}',
|
||||
file_extension='txt',
|
||||
)
|
||||
response = t.render_to_response(queryset=Site.objects.order_by('name'))
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertEqual(response['Content-Type'], DEFAULT_MIME_TYPE)
|
||||
self.assertEqual(response['Content-Disposition'], 'attachment; filename="netbox_sites.txt"')
|
||||
self.assertEqual(response.content.decode(), 'Site A\nSite B\nSite C\n')
|
||||
|
||||
|
||||
class EventRuleTestCase(TestCase):
|
||||
|
||||
def test_action_data_clean_accepts_dict(self):
|
||||
|
|
|
|||
|
|
@ -370,6 +370,59 @@ class ExportTemplateTestCase(ViewTestCases.PrimaryObjectViewTestCase):
|
|||
}
|
||||
|
||||
|
||||
class ExportTemplateExportFlowTest(TestCase):
|
||||
"""
|
||||
End-to-end test for ExportTemplate invocation via a list view's ?export=<name> query param.
|
||||
"""
|
||||
|
||||
@classmethod
|
||||
def setUpTestData(cls):
|
||||
Site.objects.bulk_create([
|
||||
Site(name='Site A', slug='site-a'),
|
||||
Site(name='Site B', slug='site-b'),
|
||||
])
|
||||
|
||||
site_type = ObjectType.objects.get_for_model(Site)
|
||||
|
||||
ok_template = ExportTemplate.objects.create(
|
||||
name='Sites Export',
|
||||
template_code='{% for obj in queryset %}{{ obj.name }}\n{% endfor %}',
|
||||
mime_type='text/plain',
|
||||
file_extension='txt',
|
||||
)
|
||||
ok_template.object_types.set([site_type])
|
||||
|
||||
broken_template = ExportTemplate.objects.create(
|
||||
name='Broken Export',
|
||||
template_code='{% for obj in queryset %}{{ obj.name ', # unterminated expression
|
||||
)
|
||||
broken_template.object_types.set([site_type])
|
||||
|
||||
def test_export_template_invocation(self):
|
||||
self.add_permissions('dcim.view_site')
|
||||
url = reverse('dcim:site_list')
|
||||
|
||||
response = self.client.get(f'{url}?export=Sites Export')
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertEqual(response['Content-Type'], 'text/plain')
|
||||
self.assertEqual(response['Content-Disposition'], 'attachment; filename="netbox_sites.txt"')
|
||||
# The rendered queryset reflects whatever ordering the list view applies. Assert on set
|
||||
# membership rather than line order so the test isn't coupled to Site's natural ordering.
|
||||
rendered_names = set(filter(None, response.content.decode().split('\n')))
|
||||
self.assertEqual(rendered_names, {'Site A', 'Site B'})
|
||||
|
||||
def test_export_template_render_error_redirects(self):
|
||||
self.add_permissions('dcim.view_site')
|
||||
url = reverse('dcim:site_list')
|
||||
|
||||
# A broken template surfaces an exception during render; the view catches it and redirects
|
||||
# back to the (filtered) list view rather than returning a 500.
|
||||
response = self.client.get(f'{url}?export=Broken Export')
|
||||
self.assertEqual(response.status_code, 302)
|
||||
self.assertTrue(response['Location'].startswith(url))
|
||||
self.assertNotIn('export=', response['Location'])
|
||||
|
||||
|
||||
class WebhookTestCase(ViewTestCases.PrimaryObjectViewTestCase):
|
||||
model = Webhook
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue