| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332133313341335133613371338133913401341134213431344134513461347134813491350135113521353135413551356135713581359136013611362136313641365136613671368136913701371137213731374137513761377137813791380138113821383138413851386138713881389139013911392139313941395139613971398139914001401140214031404140514061407140814091410141114121413141414151416141714181419142014211422142314241425142614271428142914301431143214331434143514361437143814391440144114421443144414451446144714481449145014511452145314541455145614571458145914601461146214631464146514661467146814691470147114721473147414751476147714781479148014811482148314841485148614871488148914901491149214931494149514961497149814991500150115021503150415051506150715081509151015111512151315141515151615171518151915201521152215231524152515261527152815291530153115321533153415351536153715381539154015411542154315441545154615471548154915501551155215531554155515561557155815591560156115621563156415651566156715681569157015711572157315741575157615771578157915801581158215831584158515861587158815891590159115921593159415951596159715981599160016011602160316041605160616071608160916101611161216131614161516161617161816191620162116221623162416251626162716281629163016311632163316341635163616371638163916401641164216431644164516461647164816491650165116521653165416551656165716581659166016611662166316641665166616671668166916701671167216731674 |
- import io
- import tempfile
- from pathlib import Path
- from types import SimpleNamespace
- from unittest.mock import patch
- from django.contrib.contenttypes.models import ContentType
- from django.core.files.base import ContentFile
- 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 DebugUndefined, 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,
- ConfigTemplate,
- EventRule,
- ExportTemplate,
- ImageAttachment,
- TableConfig,
- Tag,
- TaggedItem,
- )
- from extras.models.mixins import RenderTemplateMixin
- from tenancy.models import Tenant, TenantGroup
- from utilities.exceptions import AbortRequest
- from utilities.jinja2 import env_filter, render_jinja2
- from utilities.tables import get_table_for_model
- from virtualization.models import Cluster, ClusterGroup, ClusterType, VirtualMachine
- class OverwriteStyleMemoryStorage(Storage):
- """
- In-memory storage that mimics overwrite-style backends by returning the
- incoming name unchanged from get_available_name().
- """
- def __init__(self):
- self.files = {}
- def _open(self, name, mode='rb'):
- return ContentFile(self.files[name], name=name)
- def _save(self, name, content):
- self.files[name] = content.read()
- return name
- def delete(self, name):
- self.files.pop(name, None)
- def exists(self, name):
- return name in self.files
- def get_available_name(self, name, max_length=None):
- return name
- def get_alternative_name(self, file_root, file_ext):
- return f'{file_root}_sdmmer4{file_ext}'
- def listdir(self, path):
- return [], list(self.files)
- def size(self, name):
- return len(self.files[name])
- def url(self, name):
- return f'https://example.invalid/{name}'
- class UnreadableSizeMemoryStorage(OverwriteStyleMemoryStorage):
- """
- Like OverwriteStyleMemoryStorage, but size() raises OSError to model a storage backend that is
- transiently unavailable (e.g. an S3 outage) when reading file size.
- """
- def size(self, name):
- raise OSError('storage unavailable')
- class ImageAttachmentTestCase(TestCase):
- @classmethod
- def setUpTestData(cls):
- cls.ct_rack = ContentType.objects.get_by_natural_key('dcim', 'rack')
- cls.ct_site = ContentType.objects.get_by_natural_key('dcim', 'site')
- cls.site = Site.objects.create(name='Site 1')
- cls.image_content = b''
- def _stub_image_attachment(self, object_id, image_filename, name=None):
- """
- Creates an instance of ImageAttachment with the provided object_id and image_name.
- This method prepares a stubbed image attachment to test functionalities that
- require an ImageAttachment object.
- The function initializes the attachment with a specified file name and
- pre-defined image content.
- """
- ia = ImageAttachment(
- object_type=self.ct_rack,
- object_id=object_id,
- name=name,
- image=SimpleUploadedFile(
- name=image_filename,
- content=self.image_content,
- content_type='image/jpeg',
- ),
- )
- return ia
- def _uploaded_png(self, filename):
- image = io.BytesIO()
- Image.new('RGB', (1, 1)).save(image, format='PNG')
- return SimpleUploadedFile(
- name=filename,
- content=image.getvalue(),
- content_type='image/png',
- )
- def test_filename_strips_expected_prefix(self):
- """
- Tests that the filename of the image attachment is stripped of the expected
- prefix.
- """
- ia = self._stub_image_attachment(12, 'image-attachments/rack_12_My_File.png')
- self.assertEqual(ia.filename, 'My_File.png')
- def test_filename_legacy_nested_path_returns_basename(self):
- """
- Tests if the filename of a legacy-nested path correctly returns only the basename.
- """
- # e.g. "image-attachments/rack_12_5/31/23.jpg" -> "23.jpg"
- ia = self._stub_image_attachment(12, 'image-attachments/rack_12_5/31/23.jpg')
- self.assertEqual(ia.filename, '23.jpg')
- def test_filename_no_prefix_returns_basename(self):
- """
- Tests that the filename property correctly returns the basename for an image
- attachment that has no leading prefix in its path.
- """
- ia = self._stub_image_attachment(42, 'image-attachments/just_name.webp')
- self.assertEqual(ia.filename, 'just_name.webp')
- def test_mismatched_prefix_is_not_stripped(self):
- """
- Tests that a mismatched prefix in the filename is not stripped.
- """
- # Prefix does not match object_id -> leave as-is (basename only)
- ia = self._stub_image_attachment(12, 'image-attachments/rack_13_other.png')
- self.assertEqual('rack_13_other.png', ia.filename)
- def test_str_uses_name_when_present(self):
- """
- Tests that the `str` representation of the object uses the
- `name` attribute when provided.
- """
- ia = self._stub_image_attachment(12, 'image-attachments/rack_12_file.png', name='Human title')
- self.assertEqual('Human title', str(ia))
- def test_str_falls_back_to_filename(self):
- """
- Tests that the `str` representation of the object falls back to
- the filename if the name attribute is not set.
- """
- ia = self._stub_image_attachment(12, 'image-attachments/rack_12_file.png', name='')
- self.assertEqual('file.png', str(ia))
- def test_duplicate_uploaded_names_get_suffixed_with_overwrite_style_storage(self):
- storage = OverwriteStyleMemoryStorage()
- field = ImageAttachment._meta.get_field('image')
- with patch.object(field, 'storage', storage):
- first = ImageAttachment(
- object_type=self.ct_site,
- object_id=self.site.pk,
- image=self._uploaded_png('action-buttons.png'),
- )
- first.save()
- second = ImageAttachment(
- object_type=self.ct_site,
- object_id=self.site.pk,
- image=self._uploaded_png('action-buttons.png'),
- )
- second.save()
- base_name = f'image-attachments/site_{self.site.pk}_action-buttons.png'
- suffixed_name = f'image-attachments/site_{self.site.pk}_action-buttons_sdmmer4.png'
- self.assertEqual(first.image.name, base_name)
- self.assertEqual(second.image.name, suffixed_name)
- self.assertNotEqual(first.image.name, second.image.name)
- self.assertEqual(first.filename, 'action-buttons.png')
- self.assertEqual(second.filename, 'action-buttons_sdmmer4.png')
- self.assertCountEqual(storage.files.keys(), {base_name, suffixed_name})
- def test_save_populates_image_size_on_create(self):
- """
- save() populates image_size from the uploaded file on creation.
- """
- storage = OverwriteStyleMemoryStorage()
- field = ImageAttachment._meta.get_field('image')
- with patch.object(field, 'storage', storage):
- ia = ImageAttachment(
- object_type=self.ct_site,
- object_id=self.site.pk,
- image=self._uploaded_png('size-on-create.png'),
- )
- ia.save()
- self.assertIsNotNone(ia.image_size)
- self.assertEqual(ia.image_size, ia.image.size)
- def test_size_property_returns_stored_value_without_storage_access(self):
- """
- The size property returns the cached image_size rather than the file's actual size. The stub's empty
- file reports size 0, so asserting the distinct stored value proves the property used the cached value.
- """
- ia = self._stub_image_attachment(self.site.pk, 'image-attachments/site_1_no-file.png')
- self.assertEqual(ia._read_image_size(), 0) # the stub's empty file genuinely reports 0
- ia.image_size = 9999
- self.assertEqual(ia.size, 9999)
- def test_size_property_falls_back_to_storage_when_unset(self):
- """
- For legacy rows where image_size is NULL, the size property falls back to reading storage
- (rather than reporting 0 bytes).
- """
- storage = OverwriteStyleMemoryStorage()
- field = ImageAttachment._meta.get_field('image')
- with patch.object(field, 'storage', storage):
- ia = ImageAttachment(
- object_type=self.ct_site,
- object_id=self.site.pk,
- image=self._uploaded_png('fallback.png'),
- )
- ia.save()
- # Simulate a legacy row that predates the image_size field.
- ia.image_size = None
- self.assertEqual(ia.size, ia.image.size)
- self.assertGreater(ia.size, 0)
- def test_save_does_not_clobber_existing_size_on_storage_error(self):
- """
- When the storage backend raises on a size read (modeled by a real Storage subclass, not a mock),
- save() must not overwrite an existing image_size with None.
- """
- field = ImageAttachment._meta.get_field('image')
- # Create a row with a real, readable size.
- with patch.object(field, 'storage', OverwriteStyleMemoryStorage()):
- ia = ImageAttachment(
- object_type=self.ct_site,
- object_id=self.site.pk,
- image=self._uploaded_png('keep-size.png'),
- )
- ia.save()
- original_size = ia.image_size
- self.assertIsNotNone(original_size)
- # Reload from the DB so the FieldFile has no cached size and must consult storage (as it would for a
- # row loaded fresh in production). With the backend unable to report size, the read fails (returns None),
- # and save() must keep the previously-stored value rather than clobbering it with None.
- with patch.object(field, 'storage', UnreadableSizeMemoryStorage()):
- reloaded = ImageAttachment.objects.get(pk=ia.pk)
- self.assertIsNone(reloaded._read_image_size()) # the read genuinely fails (returns None)
- # Make the image look replaced by perturbing the cached identity (different name component).
- reloaded._orig_image_key = ('image-attachments/site_1_old.png', reloaded.image_height, reloaded.image_width)
- reloaded.save()
- # In-memory value is preserved, and the persisted value is unchanged.
- self.assertEqual(reloaded.image_size, original_size)
- self.assertEqual(ImageAttachment.objects.get(pk=ia.pk).image_size, original_size)
- def test_save_recomputes_image_size_when_image_replaced(self):
- """
- Replacing the image on an existing row recomputes image_size (Cable-style change detection).
- """
- storage = OverwriteStyleMemoryStorage()
- field = ImageAttachment._meta.get_field('image')
- with patch.object(field, 'storage', storage):
- ia = ImageAttachment(
- object_type=self.ct_site,
- object_id=self.site.pk,
- image=self._uploaded_png('original.png'),
- )
- ia.save()
- original_size = ia.image_size
- self.assertIsNotNone(original_size)
- # Replace the image with a larger file and save again.
- larger = SimpleUploadedFile(
- name='replacement.png',
- content=self._uploaded_png('replacement.png').read() + b'\x00' * 100,
- content_type='image/png',
- )
- ia.image = larger
- ia.save()
- self.assertEqual(ia.image_size, ia.image.size)
- self.assertNotEqual(ia.image_size, original_size)
- def test_image_identity_includes_dimensions(self):
- """
- The change-detection key combines the image name with its dimensions, so a replacement that reuses the
- same name but changes dimensions produces a different key (which name alone would not).
- """
- ia = self._stub_image_attachment(self.site.pk, 'image-attachments/site_1_same.png')
- ia.image_height, ia.image_width = 10, 10
- key_small = ia._image_identity()
- # Same name, different dimensions (as Django would set when a same-named file is replaced).
- ia.image_height, ia.image_width = 40, 40
- key_large = ia._image_identity()
- self.assertEqual(key_small[0], key_large[0]) # name component unchanged
- self.assertNotEqual(key_small, key_large) # but the key differs, so save() will recompute
- def test_save_recomputes_image_size_when_dimensions_change_under_same_name(self):
- """
- When the image is replaced by a file with the same stored name but different dimensions, save()
- recomputes image_size. Name-only detection would miss this; the dimension component catches it.
- Simulates the same-name case by priming the cached identity with the old dimensions.
- """
- storage = OverwriteStyleMemoryStorage()
- field = ImageAttachment._meta.get_field('image')
- with patch.object(field, 'storage', storage):
- ia = ImageAttachment(
- object_type=self.ct_site,
- object_id=self.site.pk,
- image=self._uploaded_png('same-name.png'),
- )
- ia.save()
- name = ia.image.name
- # Force the cached identity to reflect the SAME name but different (old) dimensions, then bump the
- # current dimensions to mimic a same-name replacement with a differently-sized image.
- ia._orig_image_key = (name, ia.image_height + 5, ia.image_width + 5)
- ia.save()
- self.assertEqual(ia.image.name, name) # name unchanged
- self.assertEqual(ia.image_size, ia.image.size) # size recomputed despite same name
- def test_save_without_touching_image_does_not_recompute_or_read_storage(self):
- """
- Editing an existing row without replacing the image leaves image_size untouched and does not
- hit storage. Directly guards against the change-detection comparison misfiring.
- """
- storage = OverwriteStyleMemoryStorage()
- field = ImageAttachment._meta.get_field('image')
- with patch.object(field, 'storage', storage):
- ia = ImageAttachment(
- object_type=self.ct_site,
- object_id=self.site.pk,
- name='Original',
- image=self._uploaded_png('untouched.png'),
- )
- ia.save()
- stored_size = ia.image_size
- # Reload from the DB so the cached image identity is set from the persisted value, then edit only the name.
- reloaded = ImageAttachment.objects.get(pk=ia.pk)
- reloaded.name = 'Renamed'
- with patch.object(ImageAttachment, '_read_image_size', side_effect=AssertionError('storage accessed')):
- reloaded.save()
- self.assertEqual(reloaded.image_size, stored_size)
- def test_save_populates_image_size_via_constructor_kwarg(self):
- """
- The non-UI create path (constructor kwarg / REST / bulk) populates image_size correctly,
- confirming change detection behaves when image is passed as a FieldFile.
- """
- storage = OverwriteStyleMemoryStorage()
- field = ImageAttachment._meta.get_field('image')
- with patch.object(field, 'storage', storage):
- ia = ImageAttachment(
- object_type=self.ct_site,
- object_id=self.site.pk,
- image=self._uploaded_png('kwarg.png'),
- )
- ia.save()
- self.assertIsNotNone(ia.image_size)
- self.assertEqual(ia.image_size, ia.image.size)
- class TableConfigTestCase(TestCase):
- @classmethod
- def setUpTestData(cls):
- cls.site_ct = ContentType.objects.get_for_model(Site)
- cls.table_name = get_table_for_model(Site).__name__
- def test_clean_accepts_ordering_none(self):
- """clean() must accept ordering=None (field is null=True)."""
- tc = TableConfig(
- object_type=self.site_ct,
- table=self.table_name,
- name='No ordering',
- columns=['name'],
- # ordering left unset (defaults to None)
- )
- # Must not raise TypeError: 'NoneType' object is not iterable
- tc.full_clean()
- def test_clean_without_object_type(self):
- """full_clean() on an instance missing its object type must raise ValidationError."""
- tc = TableConfig(
- table=self.table_name,
- name='No object type',
- columns=['name'],
- )
- with self.assertRaises(ValidationError):
- tc.full_clean()
- def test_clean_accepts_columns_none(self):
- """full_clean() must report missing columns rather than raise TypeError."""
- tc = TableConfig(
- object_type=self.site_ct,
- table=self.table_name,
- name='No columns',
- )
- with self.assertRaises(ValidationError):
- tc.full_clean()
- class TagTestCase(TestCase):
- def test_default_ordering_weight_then_name_is_set(self):
- Tag.objects.create(name='Tag 1', slug='tag-1', weight=3000)
- Tag.objects.create(name='Tag 2', slug='tag-2') # Default: 1000
- Tag.objects.create(name='Tag 3', slug='tag-3', weight=2000)
- Tag.objects.create(name='Tag 4', slug='tag-4', weight=2000)
- tags = Tag.objects.all()
- self.assertEqual(tags[0].slug, 'tag-2')
- self.assertEqual(tags[1].slug, 'tag-3')
- self.assertEqual(tags[2].slug, 'tag-4')
- self.assertEqual(tags[3].slug, 'tag-1')
- def test_tag_related_manager_ordering_weight_then_name(self):
- tags = [
- Tag.objects.create(name='Tag 1', slug='tag-1', weight=3000),
- Tag.objects.create(name='Tag 2', slug='tag-2'), # Default: 1000
- Tag.objects.create(name='Tag 3', slug='tag-3', weight=2000),
- Tag.objects.create(name='Tag 4', slug='tag-4', weight=2000),
- ]
- site = Site.objects.create(name='Site 1')
- for _tag in tags:
- site.tags.add(_tag)
- site.save()
- site = Site.objects.first()
- tags = site.tags.all()
- self.assertEqual(tags[0].slug, 'tag-2')
- self.assertEqual(tags[1].slug, 'tag-3')
- self.assertEqual(tags[2].slug, 'tag-4')
- self.assertEqual(tags[3].slug, 'tag-1')
- def test_create_tag_unicode(self):
- tag = Tag(name='Testing Unicode: 台灣')
- tag.save()
- self.assertEqual(tag.slug, 'testing-unicode-台灣')
- def test_object_type_validation(self):
- region = Region.objects.create(name='Region 1', slug='region-1')
- sitegroup = SiteGroup.objects.create(name='Site Group 1', slug='site-group-1')
- # Create a Tag that can only be applied to Regions
- tag = Tag.objects.create(name='Tag 1', slug='tag-1')
- tag.object_types.add(ObjectType.objects.get_by_natural_key('dcim', 'region'))
- # Apply the Tag to a Region
- region.tags.add(tag)
- self.assertIn(tag, region.tags.all())
- # Apply the Tag to a SiteGroup
- with self.assertRaises(AbortRequest):
- sitegroup.tags.add(tag)
- class ConfigContextTestCase(TestCase):
- """
- These test cases deal with the weighting, ordering, and deep merge logic of config context data.
- It also ensures the various config context querysets are consistent.
- """
- @classmethod
- def setUpTestData(cls):
- manufacturer = Manufacturer.objects.create(name='Manufacturer 1', slug='manufacturer-1')
- devicetype = DeviceType.objects.create(manufacturer=manufacturer, model='Device Type 1', slug='device-type-1')
- role = DeviceRole.objects.create(name='Device Role 1', slug='device-role-1')
- region = Region.objects.create(name='Region')
- sitegroup = SiteGroup.objects.create(name='Site Group')
- site = Site.objects.create(name='Site 1', slug='site-1', region=region, group=sitegroup)
- location = Location.objects.create(name='Location 1', slug='location-1', site=site)
- Platform.objects.create(name='Platform')
- tenantgroup = TenantGroup.objects.create(name='Tenant Group')
- Tenant.objects.create(name='Tenant', group=tenantgroup)
- Tag.objects.create(name='Tag', slug='tag')
- Tag.objects.create(name='Tag2', slug='tag2')
- Device.objects.create(
- name='Device 1',
- device_type=devicetype,
- role=role,
- site=site,
- location=location
- )
- def test_higher_weight_wins(self):
- device = Device.objects.first()
- context1 = ConfigContext(
- name="context 1",
- weight=101,
- data={
- "a": 123,
- "b": 456,
- "c": 777
- }
- )
- context2 = ConfigContext(
- name="context 2",
- weight=100,
- data={
- "a": 123,
- "b": 456,
- "c": 789
- }
- )
- ConfigContext.objects.bulk_create([context1, context2])
- expected_data = {
- "a": 123,
- "b": 456,
- "c": 777
- }
- self.assertEqual(device.get_config_context(), expected_data)
- def test_name_ordering_after_weight(self):
- device = Device.objects.first()
- context1 = ConfigContext(
- name="context 1",
- weight=100,
- data={
- "a": 123,
- "b": 456,
- "c": 777
- }
- )
- context2 = ConfigContext(
- name="context 2",
- weight=100,
- data={
- "a": 123,
- "b": 456,
- "c": 789
- }
- )
- ConfigContext.objects.bulk_create([context1, context2])
- expected_data = {
- "a": 123,
- "b": 456,
- "c": 789
- }
- self.assertEqual(device.get_config_context(), expected_data)
- def test_schema_validation(self):
- """
- Check that the JSON schema defined by the assigned profile is enforced.
- """
- profile = ConfigContextProfile.objects.create(
- name="Config context profile 1",
- schema={
- "properties": {
- "foo": {
- "type": "string"
- }
- },
- "required": [
- "foo"
- ]
- }
- )
- with self.assertRaises(ValidationError):
- # Missing required attribute
- ConfigContext(name="CC1", profile=profile, data={}).clean()
- with self.assertRaises(ValidationError):
- # Invalid attribute type
- ConfigContext(name="CC1", profile=profile, data={"foo": 123}).clean()
- ConfigContext(name="CC1", profile=profile, data={"foo": "bar"}).clean()
- def test_annotation_same_as_get_for_object(self):
- """
- This test incorporates features from all of the above tests cases to ensure
- the annotate_config_context_data() and get_for_object() queryset methods are the same.
- """
- device = Device.objects.first()
- context1 = ConfigContext(
- name="context 1",
- weight=101,
- data={
- "a": 123,
- "b": 456,
- "c": 777
- }
- )
- context2 = ConfigContext(
- name="context 2",
- weight=100,
- data={
- "a": 123,
- "b": 456,
- "c": 789
- }
- )
- context3 = ConfigContext(
- name="context 3",
- weight=99,
- data={
- "d": 1
- }
- )
- context4 = ConfigContext(
- name="context 4",
- weight=99,
- data={
- "d": 2
- }
- )
- ConfigContext.objects.bulk_create([context1, context2, context3, context4])
- annotated_queryset = Device.objects.filter(name=device.name).annotate_config_context_data()
- self.assertEqual(device.get_config_context(), annotated_queryset[0].get_config_context())
- def test_annotation_same_as_get_for_object_device_relations(self):
- region = Region.objects.first()
- sitegroup = SiteGroup.objects.first()
- site = Site.objects.first()
- location = Location.objects.first()
- platform = Platform.objects.first()
- tenantgroup = TenantGroup.objects.first()
- tenant = Tenant.objects.first()
- tag = Tag.objects.first()
- region_context = ConfigContext.objects.create(
- name="region",
- weight=100,
- data={
- "region": 1
- }
- )
- region_context.regions.add(region)
- sitegroup_context = ConfigContext.objects.create(
- name="sitegroup",
- weight=100,
- data={
- "sitegroup": 1
- }
- )
- sitegroup_context.site_groups.add(sitegroup)
- site_context = ConfigContext.objects.create(
- name="site",
- weight=100,
- data={
- "site": 1
- }
- )
- site_context.sites.add(site)
- location_context = ConfigContext.objects.create(
- name="location",
- weight=100,
- data={
- "location": 1
- }
- )
- location_context.locations.add(location)
- platform_context = ConfigContext.objects.create(
- name="platform",
- weight=100,
- data={
- "platform": 1
- }
- )
- platform_context.platforms.add(platform)
- tenant_group_context = ConfigContext.objects.create(
- name="tenant group",
- weight=100,
- data={
- "tenant_group": 1
- }
- )
- tenant_group_context.tenant_groups.add(tenantgroup)
- tenant_context = ConfigContext.objects.create(
- name="tenant",
- weight=100,
- data={
- "tenant": 1
- }
- )
- tenant_context.tenants.add(tenant)
- tag_context = ConfigContext.objects.create(
- name="tag",
- weight=100,
- data={
- "tag": 1
- }
- )
- tag_context.tags.add(tag)
- device = Device.objects.create(
- name="Device 2",
- site=site,
- location=location,
- tenant=tenant,
- platform=platform,
- role=DeviceRole.objects.first(),
- device_type=DeviceType.objects.first()
- )
- device.tags.add(tag)
- annotated_queryset = Device.objects.filter(name=device.name).annotate_config_context_data()
- self.assertEqual(device.get_config_context(), annotated_queryset[0].get_config_context())
- def test_annotation_same_as_get_for_object_virtualmachine_relations(self):
- region = Region.objects.first()
- sitegroup = SiteGroup.objects.first()
- site = Site.objects.first()
- platform = Platform.objects.first()
- tenantgroup = TenantGroup.objects.first()
- tenant = Tenant.objects.first()
- tag = Tag.objects.first()
- cluster_type = ClusterType.objects.create(name="Cluster Type")
- cluster_group = ClusterGroup.objects.create(name="Cluster Group")
- cluster = Cluster.objects.create(
- name="Cluster",
- group=cluster_group,
- type=cluster_type,
- scope=site,
- )
- region_context = ConfigContext.objects.create(
- name="region",
- weight=100,
- data={"region": 1}
- )
- region_context.regions.add(region)
- sitegroup_context = ConfigContext.objects.create(
- name="sitegroup",
- weight=100,
- data={"sitegroup": 1}
- )
- sitegroup_context.site_groups.add(sitegroup)
- site_context = ConfigContext.objects.create(
- name="site",
- weight=100,
- data={"site": 1}
- )
- site_context.sites.add(site)
- platform_context = ConfigContext.objects.create(
- name="platform",
- weight=100,
- data={"platform": 1}
- )
- platform_context.platforms.add(platform)
- tenant_group_context = ConfigContext.objects.create(
- name="tenant group",
- weight=100,
- data={"tenant_group": 1}
- )
- tenant_group_context.tenant_groups.add(tenantgroup)
- tenant_context = ConfigContext.objects.create(
- name="tenant",
- weight=100,
- data={"tenant": 1}
- )
- tenant_context.tenants.add(tenant)
- tag_context = ConfigContext.objects.create(
- name="tag",
- weight=100,
- data={"tag": 1}
- )
- tag_context.tags.add(tag)
- cluster_type_context = ConfigContext.objects.create(
- name="cluster type",
- weight=100,
- data={"cluster_type": 1}
- )
- cluster_type_context.cluster_types.add(cluster_type)
- cluster_group_context = ConfigContext.objects.create(
- name="cluster group",
- weight=100,
- data={"cluster_group": 1}
- )
- cluster_group_context.cluster_groups.add(cluster_group)
- cluster_context = ConfigContext.objects.create(
- name="cluster",
- weight=100,
- data={"cluster": 1}
- )
- cluster_context.clusters.add(cluster)
- virtual_machine = VirtualMachine.objects.create(
- name="VM 1",
- cluster=cluster,
- tenant=tenant,
- platform=platform,
- role=DeviceRole.objects.first()
- )
- virtual_machine.tags.add(tag)
- annotated_queryset = VirtualMachine.objects.filter(name=virtual_machine.name).annotate_config_context_data()
- self.assertEqual(virtual_machine.get_config_context(), annotated_queryset[0].get_config_context())
- def test_virtualmachine_site_context(self):
- """
- Check that config context associated with a site applies to a VM whether the VM is assigned
- directly to that site or via its cluster.
- """
- site = Site.objects.first()
- cluster_type = ClusterType.objects.create(name="Cluster Type")
- cluster = Cluster.objects.create(name="Cluster", type=cluster_type, scope=site)
- vm_role = DeviceRole.objects.first()
- # Create a ConfigContext associated with the site
- context = ConfigContext.objects.create(
- name="context1",
- weight=100,
- data={"foo": True}
- )
- context.sites.add(site)
- # Create one VM assigned directly to the site, and one assigned via the cluster
- vm1 = VirtualMachine.objects.create(name="VM 1", site=site, role=vm_role)
- vm2 = VirtualMachine.objects.create(name="VM 2", cluster=cluster, role=vm_role)
- # Check that their individually rendered config contexts are identical
- self.assertEqual(
- vm1.get_config_context(),
- vm2.get_config_context()
- )
- # Check that their annotated config contexts are identical
- vms = VirtualMachine.objects.filter(pk__in=(vm1.pk, vm2.pk)).annotate_config_context_data()
- self.assertEqual(
- vms[0].get_config_context(),
- vms[1].get_config_context()
- )
- def test_valid_local_context_data(self):
- device = Device.objects.first()
- device.local_context_data = None
- device.clean()
- device.local_context_data = {"foo": "bar"}
- device.clean()
- def test_invalid_local_context_data(self):
- device = Device.objects.first()
- device.local_context_data = ""
- with self.assertRaises(ValidationError):
- device.clean()
- device.local_context_data = 0
- with self.assertRaises(ValidationError):
- device.clean()
- device.local_context_data = False
- with self.assertRaises(ValidationError):
- device.clean()
- device.local_context_data = 'foo'
- with self.assertRaises(ValidationError):
- device.clean()
- @tag('regression')
- def test_multiple_tags_return_distinct_objects(self):
- """
- Tagged items use a generic relationship, which results in duplicate rows being returned when queried.
- This is combated by appending distinct() to the config context querysets. This test creates a config
- context assigned to two tags and ensures objects related to those same two tags result in only a single
- config context record being returned.
- See https://github.com/netbox-community/netbox/issues/5314
- """
- site = Site.objects.first()
- platform = Platform.objects.first()
- tenant = Tenant.objects.first()
- tags = Tag.objects.all()
- tag_context = ConfigContext.objects.create(
- name="tag",
- weight=100,
- data={
- "tag": 1
- }
- )
- tag_context.tags.set(tags)
- device = Device.objects.create(
- name="Device 3",
- site=site,
- tenant=tenant,
- platform=platform,
- role=DeviceRole.objects.first(),
- device_type=DeviceType.objects.first()
- )
- device.tags.set(tags)
- annotated_queryset = Device.objects.filter(name=device.name).annotate_config_context_data()
- self.assertEqual(ConfigContext.objects.get_for_object(device).count(), 1)
- self.assertEqual(device.get_config_context(), annotated_queryset[0].get_config_context())
- @tag('regression')
- def test_multiple_tags_return_distinct_objects_with_separate_config_contexts(self):
- """
- Tagged items use a generic relationship, which results in duplicate rows being returned when queried.
- This is combated by appending distinct() to the config context querysets. This test creates a config
- context assigned to two tags and ensures objects related to those same two tags result in only a single
- config context record being returned.
- This test case is separate from the above in that it deals with multiple config context objects in play.
- See https://github.com/netbox-community/netbox/issues/5387
- """
- site = Site.objects.first()
- platform = Platform.objects.first()
- tenant = Tenant.objects.first()
- tag1, tag2 = list(Tag.objects.all())
- tag_context_1 = ConfigContext.objects.create(
- name="tag-1",
- weight=100,
- data={
- "tag": 1
- }
- )
- tag_context_1.tags.add(tag1)
- tag_context_2 = ConfigContext.objects.create(
- name="tag-2",
- weight=100,
- data={
- "tag": 1
- }
- )
- tag_context_2.tags.add(tag2)
- device = Device.objects.create(
- name="Device 3",
- site=site,
- tenant=tenant,
- platform=platform,
- role=DeviceRole.objects.first(),
- device_type=DeviceType.objects.first()
- )
- device.tags.set([tag1, tag2])
- annotated_queryset = Device.objects.filter(name=device.name).annotate_config_context_data()
- self.assertEqual(ConfigContext.objects.get_for_object(device).count(), 2)
- self.assertEqual(device.get_config_context(), annotated_queryset[0].get_config_context())
- @tag('performance', 'regression')
- def test_config_context_annotation_query_optimization(self):
- """
- Regression test for issue #20327: Ensure config context annotation
- doesn't use expensive DISTINCT on main query.
- Verifies that DISTINCT is only used in tag subquery where needed,
- not on the main device query which is expensive for large datasets.
- """
- device = Device.objects.first()
- queryset = Device.objects.filter(pk=device.pk).annotate_config_context_data()
- # Main device query should NOT use DISTINCT
- self.assertFalse(queryset.query.distinct)
- # Check that tag subqueries DO use DISTINCT by inspecting the annotation
- config_annotation = queryset.query.annotations.get('config_context_data')
- self.assertIsNotNone(config_annotation)
- def find_tag_subqueries(where_node):
- """Find subqueries in WHERE clause that relate to tag filtering"""
- subqueries = []
- def traverse(node):
- if hasattr(node, 'children'):
- for child in node.children:
- try:
- # In Django 6.0+, rhs is a Query directly; older Django wraps it in Subquery
- rhs_query = getattr(child.rhs, 'query', child.rhs)
- if rhs_query.model is TaggedItem:
- subqueries.append(rhs_query)
- except AttributeError:
- traverse(child)
- traverse(where_node)
- return subqueries
- # In Django 6.0+, the annotation is a Query directly; older Django wraps it in Subquery
- annotation_query = getattr(config_annotation, 'query', config_annotation)
- # Find subqueries in the WHERE clause that should have DISTINCT
- tag_subqueries = find_tag_subqueries(annotation_query.where)
- distinct_subqueries = [sq for sq in tag_subqueries if sq.distinct]
- # Verify we found at least one DISTINCT subquery for tags
- self.assertEqual(len(distinct_subqueries), 1)
- self.assertTrue(distinct_subqueries[0].distinct)
- class ConfigTemplateTestCase(TestCase):
- """
- TODO: These test cases deal with the weighting, ordering, and deep merge logic of config context data.
- """
- MAIN_TEMPLATE = """
- {%- include 'base.j2' %}
- """.strip()
- BASE_TEMPLATE = """
- Hi
- """.strip()
- @classmethod
- def _create_template_file(cls, templates_dir, file_name, content):
- template_file_name = file_name
- if not template_file_name.endswith('j2'):
- template_file_name += '.j2'
- temp_file_path = templates_dir / template_file_name
- with open(temp_file_path, 'w') as f:
- f.write(content)
- @classmethod
- def setUpTestData(cls):
- temp_dir = tempfile.TemporaryDirectory()
- templates_dir = Path(temp_dir.name) / "templates"
- templates_dir.mkdir(parents=True, exist_ok=True)
- cls._create_template_file(templates_dir, 'base.j2', cls.BASE_TEMPLATE)
- cls._create_template_file(templates_dir, 'main.j2', cls.MAIN_TEMPLATE)
- data_source = DataSource(
- name="Test DataSource",
- type="local",
- source_url=str(templates_dir),
- )
- data_source.save()
- data_source.sync()
- base_config_template = ConfigTemplate(
- name="BaseTemplate",
- data_file=data_source.datafiles.filter(path__endswith='base.j2').first()
- )
- base_config_template.clean()
- base_config_template.save()
- cls.base_config_template = base_config_template
- main_config_template = ConfigTemplate(
- name="MainTemplate",
- data_file=data_source.datafiles.filter(path__endswith='main.j2').first()
- )
- main_config_template.clean()
- main_config_template.save()
- cls.main_config_template = main_config_template
- @tag('regression')
- def test_config_template_with_data_source(self):
- self.assertEqual(self.BASE_TEMPLATE, self.base_config_template.render({}))
- @tag('regression')
- def test_config_template_with_data_source_nested_templates(self):
- self.assertEqual(self.BASE_TEMPLATE, self.main_config_template.render({}))
- @tag('regression')
- def test_autosyncrecord_cleanup_on_detach(self):
- """Test that AutoSyncRecord is deleted when detaching from DataSource."""
- with tempfile.TemporaryDirectory() as temp_dir:
- templates_dir = Path(temp_dir) / "templates"
- templates_dir.mkdir(parents=True, exist_ok=True)
- self._create_template_file(templates_dir, 'test.j2', 'Test content')
- data_source = DataSource(
- name="Test DataSource for Detach",
- type="local",
- source_url=str(templates_dir),
- )
- data_source.save()
- data_source.sync()
- data_file = data_source.datafiles.filter(path__endswith='test.j2').first()
- # Create a ConfigTemplate with data_file and auto_sync_enabled
- config_template = ConfigTemplate(
- name="TestTemplateForDetach",
- data_file=data_file,
- auto_sync_enabled=True
- )
- config_template.clean()
- config_template.save()
- # Verify AutoSyncRecord was created
- object_type = ObjectType.objects.get_for_model(ConfigTemplate)
- autosync_records = AutoSyncRecord.objects.filter(
- object_type=object_type,
- object_id=config_template.pk
- )
- self.assertEqual(autosync_records.count(), 1, "AutoSyncRecord should be created")
- # Detach from DataSource
- config_template.data_file = None
- config_template.data_source = None
- config_template.auto_sync_enabled = False
- config_template.clean()
- config_template.save()
- # Verify AutoSyncRecord was deleted
- autosync_records = AutoSyncRecord.objects.filter(
- object_type=object_type,
- object_id=config_template.pk
- )
- self.assertEqual(autosync_records.count(), 0, "AutoSyncRecord should be deleted after detaching")
- class ConfigTemplateDebugTestCase(TestCase):
- """
- Tests for the ConfigTemplate debug field and its effect on template rendering error output.
- """
- def _make_template(self, template_code, debug=False):
- t = ConfigTemplate(
- name=f"DebugTestTemplate-{debug}",
- template_code=template_code,
- debug=debug,
- )
- t.save()
- return t
- def test_debug_default_is_false(self):
- t = ConfigTemplate(name="t", template_code="hello")
- self.assertFalse(t.debug)
- def test_template_error_non_debug_no_traceback(self):
- """In non-debug mode, a TemplateError raises with no traceback exposure."""
- 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)."""
- 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."""
- # 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."""
- with self.assertRaises(TemplateSyntaxError):
- render_jinja2("{% debug %}", {}, debug=False)
- class JinjaEnvFilterTestCase(TestCase):
- """
- Tests for the env() Jinja2 filter and the JINJA_ENVIRONMENT_PARAMS configuration parameter.
- """
- def test_env_filter_returns_value_for_matching_name(self):
- with patch.dict('os.environ', {'NETBOX_TEST_TOKEN': 'secret'}, clear=False), \
- self.settings(JINJA_ENVIRONMENT_PARAMS=['NETBOX_TEST_TOKEN']):
- self.assertEqual(env_filter('NETBOX_TEST_TOKEN'), 'secret')
- def test_env_filter_returns_none_for_unmatched_name(self):
- with patch.dict('os.environ', {'NETBOX_OTHER_TOKEN': 'secret'}, clear=False), \
- self.settings(JINJA_ENVIRONMENT_PARAMS=['NETBOX_TEST_TOKEN']):
- self.assertIsNone(env_filter('NETBOX_OTHER_TOKEN'))
- def test_env_filter_wildcard_match(self):
- with patch.dict('os.environ', {'NETBOX_TEST_TOKEN_1': 'one', 'NETBOX_TEST_TOKEN_2': 'two'}, clear=False), \
- self.settings(JINJA_ENVIRONMENT_PARAMS=['NETBOX_TEST_TOKEN_*']):
- self.assertEqual(env_filter('NETBOX_TEST_TOKEN_1'), 'one')
- self.assertEqual(env_filter('NETBOX_TEST_TOKEN_2'), 'two')
- def test_env_filter_returns_none_for_missing_env_var(self):
- with self.settings(JINJA_ENVIRONMENT_PARAMS=['NETBOX_MISSING_VAR']):
- self.assertIsNone(env_filter('NETBOX_MISSING_VAR'))
- def test_env_filter_empty_whitelist_returns_none(self):
- with patch.dict('os.environ', {'NETBOX_TEST_TOKEN': 'secret'}, clear=False), \
- self.settings(JINJA_ENVIRONMENT_PARAMS=[]):
- self.assertIsNone(env_filter('NETBOX_TEST_TOKEN'))
- def test_env_filter_registered_by_default(self):
- with patch.dict('os.environ', {'NETBOX_TEST_TOKEN': 'secret'}, clear=False), \
- self.settings(JINJA_ENVIRONMENT_PARAMS=['NETBOX_TEST_TOKEN']):
- output = render_jinja2("{{ 'NETBOX_TEST_TOKEN' | env }}", {})
- self.assertEqual(output, 'secret')
- def test_user_defined_filter_overrides_default(self):
- with self.settings(JINJA_FILTERS={'env': lambda name: 'overridden'}):
- output = render_jinja2("{{ 'NETBOX_TEST_TOKEN' | env }}", {})
- self.assertEqual(output, 'overridden')
- class ExportTemplateContextTestCase(TestCase):
- """
- Tests for ExportTemplate.get_context() including public model population.
- """
- def test_get_context_includes_public_models(self):
- et = ExportTemplate(name='test', template_code='test')
- ctx = et.get_context()
- self.assertIs(ctx['dcim']['Site'], Site)
- self.assertIs(ctx['dcim']['Device'], Device)
- def test_get_context_includes_queryset(self):
- et = ExportTemplate(name='test', template_code='test')
- qs = Site.objects.all()
- ctx = et.get_context(queryset=qs)
- self.assertIs(ctx['queryset'], qs)
- def test_get_context_applies_extra_context(self):
- et = ExportTemplate(name='test', template_code='test')
- ctx = et.get_context(context={'custom_key': 'custom_value'})
- self.assertEqual(ctx['custom_key'], 'custom_value')
- self.assertIs(ctx['dcim']['Site'], Site)
- def test_config_template_get_context_includes_public_models(self):
- ct = ConfigTemplate(name='test', template_code='test')
- ctx = ct.get_context()
- self.assertIs(ctx['dcim']['Site'], Site)
- def finalize_none_to_dash(value):
- """
- Module-level helper used by RenderTemplateMixinRenderTestCase.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 RenderTemplateMixinRenderTestCase(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_legacy_resolution(self):
- """
- Existing finalize values continue to resolve via import_string() as a
- legacy carve-out (CVE-2026-29514). New use is blocked by clean().
- """
- 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 RenderTemplateMixinResponseTestCase(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 ExportTemplateRenderTestCase(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):
- """
- clean() should accept a JSON object (or null) as action_data.
- """
- for value in ({'key': 'value'}, None):
- rule = EventRule(name='test', event_types=[OBJECT_CREATED], action_data=value)
- rule.clean()
- def test_action_data_clean_rejects_non_dict(self):
- """
- clean() should reject action_data that is valid JSON but not an object (#21989).
- """
- for value in ('test', 42, [1, 2, 3], True):
- rule = EventRule(name='test', event_types=[OBJECT_CREATED], action_data=value)
- with self.assertRaises(ValidationError) as cm:
- rule.clean()
- self.assertIn('action_data', cm.exception.message_dict)
- class JinjaEnvironmentParamsCleanTestCase(TestCase):
- """Tests for RenderTemplateMixin.clean() validation of environment_params."""
- def _make_template(self, environment_params):
- return ConfigTemplate(
- name='test',
- template_code='{{ "test" }}',
- environment_params=environment_params,
- )
- def test_allowed_scalar_params_pass(self):
- template = self._make_template({'trim_blocks': True, 'lstrip_blocks': True})
- template.clean()
- def test_autoescape_boolean_passes(self):
- template = self._make_template({'autoescape': True})
- template.clean()
- def test_valid_undefined_passes(self):
- for value in (
- 'jinja2.Undefined',
- 'jinja2.ChainableUndefined',
- 'jinja2.DebugUndefined',
- 'jinja2.StrictUndefined',
- ):
- template = self._make_template({'undefined': value})
- template.clean()
- def test_invalid_undefined_rejected(self):
- template = self._make_template({'undefined': 'subprocess.getoutput'})
- with self.assertRaises(ValidationError) as cm:
- template.clean()
- self.assertIn('environment_params', cm.exception.message_dict)
- def test_unknown_key_rejected(self):
- template = self._make_template({'extensions': ['os']})
- with self.assertRaises(ValidationError) as cm:
- template.clean()
- self.assertIn('environment_params', cm.exception.message_dict)
- def test_finalize_blocked_from_new_use(self):
- template = self._make_template({'finalize': 'subprocess.getoutput'})
- with self.assertRaises(ValidationError) as cm:
- template.clean()
- self.assertIn('environment_params', cm.exception.message_dict)
- def test_empty_params_pass(self):
- template = self._make_template({})
- template.clean()
- def test_none_params_pass(self):
- template = self._make_template(None)
- template.clean()
- def test_exporttemplate_clean_rejects_unknown_key(self):
- """MRO smoke test: ExportTemplate.clean() reaches RenderTemplateMixin.clean()."""
- obj = ExportTemplate(
- name='test',
- template_code='{{ "test" }}',
- environment_params={'loader': 'some.loader'},
- )
- with self.assertRaises(ValidationError) as cm:
- obj.clean()
- self.assertIn('environment_params', cm.exception.message_dict)
- def test_configtemplate_clean_rejects_finalize(self):
- """MRO smoke test: ConfigTemplate.clean() reaches RenderTemplateMixin.clean()."""
- obj = ConfigTemplate(
- name='test',
- template_code='{{ "test" }}',
- environment_params={'finalize': 'subprocess.getoutput'},
- )
- with self.assertRaises(ValidationError) as cm:
- obj.clean()
- self.assertIn('environment_params', cm.exception.message_dict)
- class JinjaEnvironmentParamsFilterTestCase(TestCase):
- """Tests for RenderTemplateMixin._filter_environment_params()."""
- def test_allowed_keys_pass_through(self):
- params = {'trim_blocks': True, 'autoescape': False}
- result = RenderTemplateMixin._filter_environment_params(params)
- self.assertEqual(result, params)
- def test_unknown_keys_stripped(self):
- params = {'extensions': ['os'], 'loader': 'x', 'trim_blocks': True}
- result = RenderTemplateMixin._filter_environment_params(params)
- self.assertEqual(result, {'trim_blocks': True})
- def test_finalize_preserved_as_legacy(self):
- params = {'finalize': 'some.module.func', 'trim_blocks': True}
- result = RenderTemplateMixin._filter_environment_params(params)
- self.assertEqual(result, params)
- def test_empty_params(self):
- self.assertEqual(RenderTemplateMixin._filter_environment_params({}), {})
- class JinjaEnvironmentParamsResolveTestCase(TestCase):
- """Tests for RenderTemplateMixin._resolve_mapped_params()."""
- def test_undefined_resolved_to_class(self):
- params = {'undefined': 'jinja2.StrictUndefined'}
- result = RenderTemplateMixin._resolve_mapped_params(params)
- self.assertIs(result['undefined'], StrictUndefined)
- def test_unrecognized_undefined_value_passed_through(self):
- params = {'undefined': 'not.a.real.class'}
- result = RenderTemplateMixin._resolve_mapped_params(params)
- self.assertEqual(result['undefined'], 'not.a.real.class')
- def test_scalar_params_passed_through(self):
- params = {'trim_blocks': True, 'autoescape': False}
- result = RenderTemplateMixin._resolve_mapped_params(params)
- self.assertEqual(result, params)
- def test_empty_params(self):
- self.assertEqual(RenderTemplateMixin._resolve_mapped_params({}), {})
- class JinjaEnvironmentParamsFinalizeTestCase(TestCase):
- """Tests for RenderTemplateMixin._resolve_finalize() legacy carve-out."""
- def test_finalize_string_resolved_via_import_string(self):
- params = {'finalize': 'extras.tests.test_models.finalize_none_to_dash'}
- result = RenderTemplateMixin._resolve_finalize(params)
- self.assertIs(result['finalize'], finalize_none_to_dash)
- def test_finalize_non_string_passed_through(self):
- params = {'finalize': 42}
- result = RenderTemplateMixin._resolve_finalize(params)
- self.assertEqual(result['finalize'], 42)
- def test_no_finalize_key_unchanged(self):
- params = {'trim_blocks': True}
- result = RenderTemplateMixin._resolve_finalize(params)
- self.assertEqual(result, {'trim_blocks': True})
- def test_invalid_import_path_raises_import_error(self):
- params = {'finalize': 'nonexistent.module.func'}
- with self.assertRaises(ImportError):
- RenderTemplateMixin._resolve_finalize(params)
- def test_empty_params(self):
- self.assertEqual(RenderTemplateMixin._resolve_finalize({}), {})
- class JinjaEnvironmentParamsIntegrationTestCase(TestCase):
- """Integration tests for get_environment_params() end-to-end."""
- def _make_template(self, environment_params):
- return ConfigTemplate(
- name='test',
- template_code='{{ "test" }}',
- environment_params=environment_params,
- )
- def test_full_pipeline_with_undefined(self):
- template = self._make_template({'undefined': 'jinja2.StrictUndefined', 'trim_blocks': True})
- params = template.get_environment_params()
- self.assertIs(params['undefined'], StrictUndefined)
- self.assertIs(params['trim_blocks'], True)
- def test_full_pipeline_strips_unknown_and_resolves(self):
- template = self._make_template({
- 'extensions': ['os'],
- 'undefined': 'jinja2.DebugUndefined',
- 'trim_blocks': True,
- })
- params = template.get_environment_params()
- self.assertNotIn('extensions', params)
- self.assertIs(params['undefined'], DebugUndefined)
- self.assertIs(params['trim_blocks'], True)
- def test_full_pipeline_finalize_resolves(self):
- template = self._make_template({
- 'finalize': 'extras.tests.test_models.finalize_none_to_dash',
- })
- params = template.get_environment_params()
- self.assertIs(params['finalize'], finalize_none_to_dash)
- def test_does_not_mutate_stored_value(self):
- template = self._make_template({'undefined': 'jinja2.StrictUndefined'})
- template.get_environment_params()
- self.assertEqual(template.environment_params['undefined'], 'jinja2.StrictUndefined')
- def test_none_environment_params(self):
- template = self._make_template(None)
- self.assertEqual(template.get_environment_params(), {})
- def test_empty_environment_params(self):
- template = self._make_template({})
- self.assertEqual(template.get_environment_params(), {})
|