Arthur пре 2 недеља
родитељ
комит
041e749996

+ 2 - 17
netbox/circuits/migrations/0058_denormalization_triggers.py

@@ -4,7 +4,7 @@ of the Python `post_save` handler formerly registered in netbox.denormalized.
 """
 """
 from django.db import migrations
 from django.db import migrations
 
 
-from utilities.migration import InstallDenormalizationTrigger
+from utilities.migration import cached_scope_triggers
 
 
 
 
 class Migration(migrations.Migration):
 class Migration(migrations.Migration):
@@ -15,19 +15,4 @@ class Migration(migrations.Migration):
         ('dcim', '0238_ltree_paths'),
         ('dcim', '0238_ltree_paths'),
     ]
     ]
 
 
-    operations = [
-        # When a Site's region/group changes, propagate to terminations assigned to it.
-        InstallDenormalizationTrigger(
-            dependent_table='circuits_circuittermination',
-            source_table='dcim_site',
-            fk_column='_site_id',
-            mappings={'_region_id': 'region_id', '_site_group_id': 'group_id'},
-        ),
-        # When a Location's site changes, propagate to terminations assigned to it.
-        InstallDenormalizationTrigger(
-            dependent_table='circuits_circuittermination',
-            source_table='dcim_location',
-            fk_column='_location_id',
-            mappings={'_site_id': 'site_id'},
-        ),
-    ]
+    operations = cached_scope_triggers('circuits_circuittermination')

+ 10 - 3
netbox/circuits/tests/test_models.py

@@ -206,8 +206,12 @@ class CircuitTerminationDenormalizationTriggerTestCase(TestCase):
         self.assertEqual(termination._site_group, group_b)
         self.assertEqual(termination._site_group, group_b)
 
 
     def test_location_site_change_propagates_to_termination(self):
     def test_location_site_change_propagates_to_termination(self):
-        site_a = Site.objects.create(name='Site A', slug='site-a')
-        site_b = Site.objects.create(name='Site B', slug='site-b')
+        region_a = Region.objects.create(name='Region A', slug='region-a')
+        region_b = Region.objects.create(name='Region B', slug='region-b')
+        group_a = SiteGroup.objects.create(name='Group A', slug='group-a')
+        group_b = SiteGroup.objects.create(name='Group B', slug='group-b')
+        site_a = Site.objects.create(name='Site A', slug='site-a', region=region_a, group=group_a)
+        site_b = Site.objects.create(name='Site B', slug='site-b', region=region_b, group=group_b)
         location = Location.objects.create(name='Loc', slug='loc', site=site_a)
         location = Location.objects.create(name='Loc', slug='loc', site=site_a)
 
 
         termination = CircuitTermination.objects.create(
         termination = CircuitTermination.objects.create(
@@ -216,12 +220,15 @@ class CircuitTerminationDenormalizationTriggerTestCase(TestCase):
         self.assertEqual(termination._site, site_a)
         self.assertEqual(termination._site, site_a)
         self.assertEqual(termination._location, location)
         self.assertEqual(termination._location, location)
 
 
-        # Move the Location to a different Site; the trigger should update _site.
+        # Move the Location to a different Site; the trigger updates _site and pulls the new
+        # site's region/group through in the same statement.
         location.site = site_b
         location.site = site_b
         location.save()
         location.save()
 
 
         termination.refresh_from_db()
         termination.refresh_from_db()
         self.assertEqual(termination._site, site_b)
         self.assertEqual(termination._site, site_b)
+        self.assertEqual(termination._region, region_b)
+        self.assertEqual(termination._site_group, group_b)
 
 
     def test_bulk_update_of_site_propagates_to_termination(self):
     def test_bulk_update_of_site_propagates_to_termination(self):
         """
         """

+ 34 - 2
netbox/dcim/migrations/0239_denormalization_triggers.py

@@ -1,11 +1,31 @@
 """
 """
-Maintain CableTermination's denormalized device/rack/location/site columns via PostgreSQL triggers instead
-of the Python `post_save` handler formerly registered in netbox.denormalized.
+Maintain denormalized device/rack/location/site columns via PostgreSQL triggers instead of Python
+`post_save` handlers:
+
+- CableTermination's _device/_rack/_location/_site (formerly netbox.denormalized).
+- Each device component's _site/_location/_rack (formerly dcim.signals.handle_device_site_change /
+  handle_rack_site_change / handle_location_site_change). These are derived solely from the parent
+  Device, so a single Device-sourced trigger per component table covers direct device edits as well as
+  the Rack/Location cascades (which write Device.site/location and thus fire this trigger).
 """
 """
 from django.db import migrations
 from django.db import migrations
 
 
 from utilities.migration import InstallDenormalizationTrigger
 from utilities.migration import InstallDenormalizationTrigger
 
 
+# Device component tables carrying _site/_location/_rack denormalized from their parent Device.
+COMPONENT_TABLES = (
+    'dcim_consoleport',
+    'dcim_consoleserverport',
+    'dcim_devicebay',
+    'dcim_frontport',
+    'dcim_interface',
+    'dcim_inventoryitem',
+    'dcim_modulebay',
+    'dcim_poweroutlet',
+    'dcim_powerport',
+    'dcim_rearport',
+)
+
 
 
 class Migration(migrations.Migration):
 class Migration(migrations.Migration):
 
 
@@ -35,4 +55,16 @@ class Migration(migrations.Migration):
             fk_column='_location_id',
             fk_column='_location_id',
             mappings={'_site_id': 'site_id'},
             mappings={'_site_id': 'site_id'},
         ),
         ),
+        # Device components: mirror the parent Device's site/location/rack onto each component.
+        # The Rack/Location → Device cascades (dcim.signals) write Device.site/location, which fires
+        # this same trigger, so no separate Rack/Location-sourced component triggers are needed.
+        *[
+            InstallDenormalizationTrigger(
+                dependent_table=table,
+                source_table='dcim_device',
+                fk_column='device_id',
+                mappings={'_site_id': 'site_id', '_location_id': 'location_id', '_rack_id': 'rack_id'},
+            )
+            for table in COMPONENT_TABLES
+        ],
     ]
     ]

+ 8 - 95
netbox/dcim/signals.py

@@ -5,59 +5,36 @@ from django.db.models.signals import post_delete, post_save
 from django.dispatch import receiver
 from django.dispatch import receiver
 
 
 from dcim.choices import CableEndChoices, LinkStatusChoices
 from dcim.choices import CableEndChoices, LinkStatusChoices
-from ipam.models import Prefix
-from virtualization.models import Cluster, VMInterface
-from wireless.models import WirelessLAN
+from virtualization.models import VMInterface
 
 
 from .models import (
 from .models import (
     Cable,
     Cable,
     CablePath,
     CablePath,
     CableTermination,
     CableTermination,
-    ConsolePort,
-    ConsoleServerPort,
     Device,
     Device,
-    DeviceBay,
-    FrontPort,
     Interface,
     Interface,
-    InventoryItem,
     Location,
     Location,
-    ModuleBay,
     PathEndpoint,
     PathEndpoint,
     PortMapping,
     PortMapping,
-    PowerOutlet,
     PowerPanel,
     PowerPanel,
-    PowerPort,
     Rack,
     Rack,
-    RearPort,
-    Site,
     VirtualChassis,
     VirtualChassis,
 )
 )
 from .models.cables import trace_paths
 from .models.cables import trace_paths
 from .utils import create_cablepaths, rebuild_paths
 from .utils import create_cablepaths, rebuild_paths
 
 
-COMPONENT_MODELS = (
-    ConsolePort,
-    ConsoleServerPort,
-    DeviceBay,
-    FrontPort,
-    Interface,
-    InventoryItem,
-    ModuleBay,
-    PowerOutlet,
-    PowerPort,
-    RearPort,
-)
-
-
 #
 #
 # Location/rack/device assignment
 # Location/rack/device assignment
 #
 #
 
 
+
 @receiver(post_save, sender=Location)
 @receiver(post_save, sender=Location)
 def handle_location_site_change(instance, created, **kwargs):
 def handle_location_site_change(instance, created, **kwargs):
     """
     """
-    Update child objects if Site assignment has changed. We intentionally recurse through each child
-    object instead of calling update() on the QuerySet to ensure the proper change records get created for each.
+    Cascade a Location's Site assignment down to the Racks, Devices, and PowerPanels it contains
+    (and to descendant Locations). The denormalized cache columns on cable terminations and device
+    components are maintained by PostgreSQL triggers, which fire on these Site/Location/Rack/Device
+    column writes.
     """
     """
     if not created:
     if not created:
         instance.get_descendants().update(site=instance.site)
         instance.get_descendants().update(site=instance.site)
@@ -65,39 +42,16 @@ def handle_location_site_change(instance, created, **kwargs):
         Rack.objects.filter(location__in=locations).update(site=instance.site)
         Rack.objects.filter(location__in=locations).update(site=instance.site)
         Device.objects.filter(location__in=locations).update(site=instance.site)
         Device.objects.filter(location__in=locations).update(site=instance.site)
         PowerPanel.objects.filter(location__in=locations).update(site=instance.site)
         PowerPanel.objects.filter(location__in=locations).update(site=instance.site)
-        CableTermination.objects.filter(_location__in=locations).update(_site=instance.site)
-        # Update component models for devices in these locations
-        for model in COMPONENT_MODELS:
-            model.objects.filter(device__location__in=locations).update(_site=instance.site)
 
 
 
 
 @receiver(post_save, sender=Rack)
 @receiver(post_save, sender=Rack)
 def handle_rack_site_change(instance, created, **kwargs):
 def handle_rack_site_change(instance, created, **kwargs):
     """
     """
-    Update child Devices if Site or Location assignment has changed.
+    Cascade a Rack's Site/Location assignment down to the Devices it contains. The denormalized cache
+    columns on those devices' components are maintained by PostgreSQL triggers.
     """
     """
     if not created:
     if not created:
         Device.objects.filter(rack=instance).update(site=instance.site, location=instance.location)
         Device.objects.filter(rack=instance).update(site=instance.site, location=instance.location)
-        # Update component models for devices in this rack
-        for model in COMPONENT_MODELS:
-            model.objects.filter(device__rack=instance).update(
-                _site=instance.site,
-                _location=instance.location,
-            )
-
-
-@receiver(post_save, sender=Device)
-def handle_device_site_change(instance, created, **kwargs):
-    """
-    Update child components to update the parent Site, Location, and Rack when a Device is saved.
-    """
-    if not created:
-        for model in COMPONENT_MODELS:
-            model.objects.filter(device=instance).update(
-                _site=instance.site,
-                _location=instance.location,
-                _rack=instance.rack,
-            )
 
 
 
 
 #
 #
@@ -210,44 +164,3 @@ def update_mac_address_interface(instance, created, raw, **kwargs):
     if created and not raw and instance.primary_mac_address:
     if created and not raw and instance.primary_mac_address:
         instance.primary_mac_address.assigned_object = instance
         instance.primary_mac_address.assigned_object = instance
         instance.primary_mac_address.save()
         instance.primary_mac_address.save()
-
-
-@receiver(post_save, sender=Location)
-@receiver(post_save, sender=Site)
-def sync_cached_scope_fields(instance, created, **kwargs):
-    """
-    Rebuild cached scope fields for all CachedScopeMixin-based models
-    affected by a change in a Region, SiteGroup, Site, or Location.
-
-    This method is safe to run for objects created in the past and does
-    not rely on incremental updates. Cached fields are recomputed from
-    authoritative relationships.
-    """
-    if created:
-        return
-
-    if isinstance(instance, Location):
-        filters = {'_location': instance}
-    elif isinstance(instance, Site):
-        filters = {'_site': instance}
-    else:
-        return
-
-    # These models are explicitly listed because they all subclass CachedScopeMixin
-    # and therefore require their cached scope fields to be recomputed.
-    for model in (Prefix, Cluster, WirelessLAN):
-        qs = model.objects.filter(**filters)
-
-        # Bulk update cached fields to avoid O(N) performance issues with large datasets.
-        # This does not trigger post_save signals, avoiding spurious change log entries.
-        objects_to_update = []
-        for obj in qs:
-            # Recompute cache using the same logic as save()
-            obj.cache_related_objects()
-            objects_to_update.append(obj)
-
-        if objects_to_update:
-            model.objects.bulk_update(
-                objects_to_update,
-                ['_location', '_site', '_site_group', '_region']
-            )

+ 79 - 11
netbox/dcim/tests/test_signals.py

@@ -2,6 +2,7 @@ from types import SimpleNamespace
 from unittest.mock import MagicMock, patch
 from unittest.mock import MagicMock, patch
 
 
 from django.contrib.contenttypes.models import ContentType
 from django.contrib.contenttypes.models import ContentType
+from django.db import connection
 from django.test import SimpleTestCase, TestCase
 from django.test import SimpleTestCase, TestCase
 
 
 from dcim import signals
 from dcim import signals
@@ -26,6 +27,8 @@ from dcim.models import (
     SiteGroup,
     SiteGroup,
     VirtualChassis,
     VirtualChassis,
 )
 )
+from dcim.models.device_components import ComponentModel
+from dcim.models.mixins import CachedScopeMixin
 from ipam.models import Prefix
 from ipam.models import Prefix
 from virtualization.models import Cluster, ClusterType
 from virtualization.models import Cluster, ClusterType
 from wireless.models import WirelessLAN
 from wireless.models import WirelessLAN
@@ -117,10 +120,11 @@ class RackSiteChangeSignalTestCase(TestCase):
         self.assertEqual(interface._location, self.location_b)
         self.assertEqual(interface._location, self.location_b)
 
 
 
 
-class DeviceSiteChangeSignalTestCase(TestCase):
+class DeviceComponentScopeTriggerTestCase(TestCase):
     """
     """
-    Verify dcim.signals.handle_device_site_change propagates a Device's site/location/rack
-    to its components on save.
+    Verify the PostgreSQL trigger (dcim migration 0239) that propagates a Device's site/location/rack
+    onto its components' denormalized _site/_location/_rack columns. This replaces the former
+    dcim.signals.handle_device_site_change handler.
     """
     """
 
 
     @classmethod
     @classmethod
@@ -147,6 +151,25 @@ class DeviceSiteChangeSignalTestCase(TestCase):
         interface.refresh_from_db()
         interface.refresh_from_db()
         self.assertEqual(interface._site, self.site_b)
         self.assertEqual(interface._site, self.site_b)
 
 
+    def test_bulk_update_of_device_updates_components_cached_scope(self):
+        """
+        A bulk QuerySet.update() bypasses post_save (the old handler never fired for it); the DB
+        trigger fires regardless. This is also the path the Rack/Location cascades take.
+        """
+        device = Device.objects.create(
+            name='Device',
+            site=self.site_a,
+            device_type=self.device_type,
+            role=self.device_role,
+        )
+        interface = Interface.objects.create(device=device, name='Interface 1')
+        self.assertEqual(interface._site, self.site_a)
+
+        Device.objects.filter(pk=device.pk).update(site=self.site_b)
+
+        interface.refresh_from_db()
+        self.assertEqual(interface._site, self.site_b)
+
 
 
 class VirtualChassisMasterSignalTestCase(TestCase):
 class VirtualChassisMasterSignalTestCase(TestCase):
     """
     """
@@ -386,10 +409,12 @@ class MACAddressInterfaceSignalTestCase(TestCase):
         self.assertIsNone(mac.assigned_object)
         self.assertIsNone(mac.assigned_object)
 
 
 
 
-class SyncCachedScopeFieldsSignalTestCase(TestCase):
+class CachedScopeFieldTriggerTestCase(TestCase):
     """
     """
-    Verify dcim.signals.sync_cached_scope_fields recomputes cached scope fields on
-    Prefix, Cluster, and WirelessLAN when a Site or Location is modified.
+    Verify the PostgreSQL triggers (ipam/virtualization/wireless denormalization migrations) that keep
+    the CachedScopeMixin scope columns (_site/_location/_region/_site_group) on Prefix, Cluster, and
+    WirelessLAN in sync when a scoped Site or Location is modified. These replace the former
+    dcim.signals.sync_cached_scope_fields handler.
     """
     """
 
 
     def test_site_group_change_updates_prefix_cached_scope(self):
     def test_site_group_change_updates_prefix_cached_scope(self):
@@ -429,11 +454,9 @@ class SyncCachedScopeFieldsSignalTestCase(TestCase):
         self.assertEqual(prefix._location, location)
         self.assertEqual(prefix._location, location)
         self.assertEqual(prefix._site, site_b)
         self.assertEqual(prefix._site, site_b)
 
 
-    def test_signal_updates_cluster_and_wirelesslan_cached_scope(self):
-        # Lock down the explicit (Prefix, Cluster, WirelessLAN) tuple in the
-        # signal by exercising Cluster and WirelessLAN alongside Prefix. If a
-        # future change drops Cluster or WirelessLAN from that tuple, this test
-        # will catch it.
+    def test_triggers_update_cluster_and_wirelesslan_cached_scope(self):
+        # Cluster and WirelessLAN each carry their own Site/Location triggers (installed by the
+        # virtualization and wireless denormalization migrations); exercise both alongside Prefix.
         group_a = SiteGroup.objects.create(name='Group A', slug='group-a')
         group_a = SiteGroup.objects.create(name='Group A', slug='group-a')
         group_b = SiteGroup.objects.create(name='Group B', slug='group-b')
         group_b = SiteGroup.objects.create(name='Group B', slug='group-b')
         site = Site.objects.create(name='Site', slug='site', group=group_a)
         site = Site.objects.create(name='Site', slug='site', group=group_a)
@@ -547,3 +570,48 @@ class CableTerminationDenormalizationTriggerTestCase(TestCase):
 
 
         termination.refresh_from_db()
         termination.refresh_from_db()
         self.assertEqual(termination._site, self.site_b)
         self.assertEqual(termination._site, self.site_b)
+
+
+def _concrete_subclasses(base):
+    """Yield every non-abstract model descending from an abstract base model."""
+    for subclass in base.__subclasses__():
+        if subclass._meta.abstract:
+            yield from _concrete_subclasses(subclass)
+        else:
+            yield subclass
+
+
+def _installed_triggers():
+    with connection.cursor() as cursor:
+        cursor.execute('SELECT tgname FROM pg_trigger WHERE NOT tgisinternal')
+        return {row[0] for row in cursor.fetchall()}
+
+
+class DenormalizationTriggerCoverageTestCase(TestCase):
+    """
+    Guard against a new model silently shipping without its denormalization triggers. The set of
+    device-component tables and CachedScopeMixin dependents is hand-listed in migrations; this test
+    derives those sets from the model layer and asserts the expected triggers are installed, so adding
+    a new component / scoped model without a matching trigger migration fails CI.
+    """
+
+    def test_device_components_have_device_trigger(self):
+        triggers = _installed_triggers()
+        for model in _concrete_subclasses(ComponentModel):
+            table = model._meta.db_table
+            self.assertIn(
+                f'{table}_denorm_from_dcim_device', triggers,
+                msg=f'{model.__name__} has no dcim_device denormalization trigger (add it to '
+                    f'dcim migration 0239 COMPONENT_TABLES)',
+            )
+
+    def test_cached_scope_models_have_site_and_location_triggers(self):
+        triggers = _installed_triggers()
+        for model in _concrete_subclasses(CachedScopeMixin):
+            table = model._meta.db_table
+            for source in ('dcim_site', 'dcim_location'):
+                self.assertIn(
+                    f'{table}_denorm_from_{source}', triggers,
+                    msg=f'{model.__name__} (CachedScopeMixin) has no {source} denormalization trigger; '
+                        f'add cached_scope_triggers({table!r}) in a migration for its app',
+                )

+ 3 - 18
netbox/ipam/migrations/0091_denormalization_triggers.py

@@ -1,10 +1,10 @@
 """
 """
 Maintain Prefix's denormalized site/region/site-group columns via PostgreSQL triggers instead of the
 Maintain Prefix's denormalized site/region/site-group columns via PostgreSQL triggers instead of the
-Python `post_save` handler formerly registered in netbox.denormalized.
+Python `post_save` handler formerly registered in netbox.denormalized (and dcim.signals.sync_cached_scope_fields).
 """
 """
 from django.db import migrations
 from django.db import migrations
 
 
-from utilities.migration import InstallDenormalizationTrigger
+from utilities.migration import cached_scope_triggers
 
 
 
 
 class Migration(migrations.Migration):
 class Migration(migrations.Migration):
@@ -15,19 +15,4 @@ class Migration(migrations.Migration):
         ('dcim', '0238_ltree_paths'),
         ('dcim', '0238_ltree_paths'),
     ]
     ]
 
 
-    operations = [
-        # When a Site's region/group changes, propagate to prefixes assigned to it.
-        InstallDenormalizationTrigger(
-            dependent_table='ipam_prefix',
-            source_table='dcim_site',
-            fk_column='_site_id',
-            mappings={'_region_id': 'region_id', '_site_group_id': 'group_id'},
-        ),
-        # When a Location's site changes, propagate to prefixes assigned to it.
-        InstallDenormalizationTrigger(
-            dependent_table='ipam_prefix',
-            source_table='dcim_location',
-            fk_column='_location_id',
-            mappings={'_site_id': 'site_id'},
-        ),
-    ]
+    operations = cached_scope_triggers('ipam_prefix')

+ 10 - 2
netbox/ipam/tests/test_signals.py

@@ -264,8 +264,12 @@ class PrefixDenormalizationTriggerTestCase(TestCase):
         self.assertEqual(prefix._site_group, group_b)
         self.assertEqual(prefix._site_group, group_b)
 
 
     def test_location_site_change_propagates_to_prefix(self):
     def test_location_site_change_propagates_to_prefix(self):
-        site_a = Site.objects.create(name='Site A', slug='site-a')
-        site_b = Site.objects.create(name='Site B', slug='site-b')
+        region_a = Region.objects.create(name='Region A', slug='region-a')
+        region_b = Region.objects.create(name='Region B', slug='region-b')
+        group_a = SiteGroup.objects.create(name='Group A', slug='group-a')
+        group_b = SiteGroup.objects.create(name='Group B', slug='group-b')
+        site_a = Site.objects.create(name='Site A', slug='site-a', region=region_a, group=group_a)
+        site_b = Site.objects.create(name='Site B', slug='site-b', region=region_b, group=group_b)
         location = Location.objects.create(name='Loc', slug='loc', site=site_a)
         location = Location.objects.create(name='Loc', slug='loc', site=site_a)
         prefix = Prefix.objects.create(
         prefix = Prefix.objects.create(
             prefix='10.0.0.0/24',
             prefix='10.0.0.0/24',
@@ -274,11 +278,15 @@ class PrefixDenormalizationTriggerTestCase(TestCase):
         )
         )
         self.assertEqual(prefix._site, site_a)
         self.assertEqual(prefix._site, site_a)
 
 
+        # Move the Location to a different Site; the trigger updates _site and pulls the new
+        # site's region/group through in the same statement.
         location.site = site_b
         location.site = site_b
         location.save()
         location.save()
 
 
         prefix.refresh_from_db()
         prefix.refresh_from_db()
         self.assertEqual(prefix._site, site_b)
         self.assertEqual(prefix._site, site_b)
+        self.assertEqual(prefix._region, region_b)
+        self.assertEqual(prefix._site_group, group_b)
 
 
     def test_bulk_update_of_site_propagates_to_prefix(self):
     def test_bulk_update_of_site_propagates_to_prefix(self):
         """
         """

+ 90 - 18
netbox/utilities/migration.py

@@ -4,6 +4,7 @@ from netbox.config import ConfigItem
 
 
 __all__ = (
 __all__ = (
     'InstallDenormalizationTrigger',
     'InstallDenormalizationTrigger',
+    'cached_scope_triggers',
     'custom_deconstruct',
     'custom_deconstruct',
 )
 )
 
 
@@ -40,29 +41,43 @@ class InstallDenormalizationTrigger(migrations.operations.base.Operation):
     Install a PostgreSQL trigger that keeps denormalized columns on a dependent table in sync with their
     Install a PostgreSQL trigger that keeps denormalized columns on a dependent table in sync with their
     source object.
     source object.
 
 
-    When a row in `source_table` is updated, the trigger copies the values of the mapped source columns into
-    the corresponding denormalized columns on every `dependent_table` row that references it via `fk_column`.
-    This replaces the Python `post_save` handler formerly defined in `netbox.denormalized`.
+    When rows in `source_table` are updated, the trigger copies the values of the mapped source columns into
+    the corresponding denormalized columns on every `dependent_table` row that references them via
+    `fk_column`. This replaces the Python `post_save` handlers formerly defined in `netbox.denormalized` and
+    `dcim.signals`.
+
+    The trigger is statement-level (`FOR EACH STATEMENT`) and uses transition tables: a single bulk source
+    update (`UPDATE ... WHERE ...`, `QuerySet.update()`, `bulk_update()`) fires the trigger once and is
+    propagated with a single set-based UPDATE joining the changed rows, rather than once per affected row.
 
 
     Args:
     Args:
         dependent_table: The table carrying the denormalized columns (e.g. 'ipam_prefix').
         dependent_table: The table carrying the denormalized columns (e.g. 'ipam_prefix').
         source_table: The table whose changes are propagated (e.g. 'dcim_site').
         source_table: The table whose changes are propagated (e.g. 'dcim_site').
         fk_column: The column on `dependent_table` referencing `source_table` (e.g. '_site_id').
         fk_column: The column on `dependent_table` referencing `source_table` (e.g. '_site_id').
         mappings: A mapping of {dependent_column: source_column}, using actual database column names
         mappings: A mapping of {dependent_column: source_column}, using actual database column names
-            (e.g. {'_region_id': 'region_id', '_site_group_id': 'group_id'}).
-
-    The trigger fires AFTER UPDATE of the source columns, and only when at least one of them actually changed.
-    Like the handler it replaces, it does not fire on INSERT (a newly created source row has no dependents
-    yet) and it does not cascade: updating the denormalized columns does not itself trigger further
-    denormalization.
+            (e.g. {'_region_id': 'region_id', '_site_group_id': 'group_id'}). Each is copied directly
+            from the changed source row.
+        related_mappings: An optional iterable of related-table lookups for columns that live one hop
+            beyond `source_table`. Each entry is a dict with keys `table` (the related table), `source_fk`
+            (a column on `source_table` referencing `related_table.id`), and `mappings`
+            ({dependent_column: related_column}). Each is resolved by joining the related table once.
+            This closes the chain gap when a denormalized column is derived through an intermediate object
+            (e.g. a Location's Site change must refresh the dependent's region/site-group, not just its site).
+
+    The trigger fires AFTER UPDATE of the watched source columns (the direct `mappings` sources plus each
+    related `source_fk`), and only propagates to rows whose watched column(s) actually changed (the body
+    joins the OLD/NEW transition tables and filters with IS DISTINCT FROM). It does not fire on INSERT (a
+    newly created source row has no dependents yet) and it does not recurse: the dependent tables carry no
+    triggers of their own.
     """
     """
     reversible = True
     reversible = True
 
 
-    def __init__(self, dependent_table, source_table, fk_column, mappings):
+    def __init__(self, dependent_table, source_table, fk_column, mappings, related_mappings=()):
         self.dependent_table = dependent_table
         self.dependent_table = dependent_table
         self.source_table = source_table
         self.source_table = source_table
         self.fk_column = fk_column
         self.fk_column = fk_column
         self.mappings = mappings
         self.mappings = mappings
+        self.related_mappings = list(related_mappings)
 
 
     @property
     @property
     def function_name(self):
     def function_name(self):
@@ -77,17 +92,36 @@ class InstallDenormalizationTrigger(migrations.operations.base.Operation):
         pass
         pass
 
 
     def database_forwards(self, app_label, schema_editor, from_state, to_state):
     def database_forwards(self, app_label, schema_editor, from_state, to_state):
-        source_columns = list(self.mappings.values())
-        set_clause = ', '.join(f'"{dest}" = NEW."{src}"' for dest, src in self.mappings.items())
-        update_of = ', '.join(f'"{col}"' for col in source_columns)
-        when_clause = ' OR '.join(f'OLD."{col}" IS DISTINCT FROM NEW."{col}"' for col in source_columns)
+        # `n`/`o` are the NEW/OLD transition tables (all rows changed by the source statement). Direct
+        # mappings copy from the new source row; related mappings join the related table once.
+        set_parts = [f'"{dest}" = n."{src}"' for dest, src in self.mappings.items()]
+        watched = list(self.mappings.values())
+        related_joins = []
+        for i, rel in enumerate(self.related_mappings):
+            alias = f'r{i}'
+            related_joins.append(f'LEFT JOIN "{rel["table"]}" AS {alias} ON {alias}.id = n."{rel["source_fk"]}"')
+            for dest, rel_col in rel['mappings'].items():
+                set_parts.append(f'"{dest}" = {alias}."{rel_col}"')
+            watched.append(rel['source_fk'])
+
+        # Deduplicate watched columns while preserving order (a direct mapping and a related lookup may
+        # both key off the same source column, e.g. site_id).
+        watched_columns = list(dict.fromkeys(watched))
+
+        set_clause = ', '.join(set_parts)
+        update_of = ', '.join(f'"{col}"' for col in watched_columns)
+        change_filter = ' OR '.join(f'o."{col}" IS DISTINCT FROM n."{col}"' for col in watched_columns)
+        joins = ('\n              ' + '\n              '.join(related_joins)) if related_joins else ''
 
 
         schema_editor.execute(f'''
         schema_editor.execute(f'''
             CREATE OR REPLACE FUNCTION "{self.function_name}"() RETURNS TRIGGER AS $$
             CREATE OR REPLACE FUNCTION "{self.function_name}"() RETURNS TRIGGER AS $$
             BEGIN
             BEGIN
-                UPDATE "{self.dependent_table}"
+                UPDATE "{self.dependent_table}" AS dep
                    SET {set_clause}
                    SET {set_clause}
-                 WHERE "{self.fk_column}" = NEW.id;
+                  FROM new_rows AS n
+                  JOIN old_rows AS o ON o.id = n.id{joins}
+                 WHERE dep."{self.fk_column}" = n.id
+                   AND ({change_filter});
                 RETURN NULL;
                 RETURN NULL;
             END
             END
             $$ LANGUAGE plpgsql;
             $$ LANGUAGE plpgsql;
@@ -95,8 +129,8 @@ class InstallDenormalizationTrigger(migrations.operations.base.Operation):
         schema_editor.execute(f'''
         schema_editor.execute(f'''
             CREATE TRIGGER "{self.trigger_name}"
             CREATE TRIGGER "{self.trigger_name}"
                 AFTER UPDATE OF {update_of} ON "{self.source_table}"
                 AFTER UPDATE OF {update_of} ON "{self.source_table}"
-                FOR EACH ROW WHEN ({when_clause})
-                EXECUTE FUNCTION "{self.function_name}"();
+                REFERENCING OLD TABLE AS old_rows NEW TABLE AS new_rows
+                FOR EACH STATEMENT EXECUTE FUNCTION "{self.function_name}"();
         ''')
         ''')
 
 
     def database_backwards(self, app_label, schema_editor, from_state, to_state):
     def database_backwards(self, app_label, schema_editor, from_state, to_state):
@@ -105,3 +139,41 @@ class InstallDenormalizationTrigger(migrations.operations.base.Operation):
 
 
     def describe(self):
     def describe(self):
         return f'Install denormalization trigger on {self.source_table} updating {self.dependent_table}'
         return f'Install denormalization trigger on {self.source_table} updating {self.dependent_table}'
+
+
+# Site/region/site-group lookup shared by every CachedScopeMixin-style dependent (see cached_scope_triggers).
+SITE_SCOPE_RELATED_MAPPINGS = (
+    {
+        'table': 'dcim_site',
+        'source_fk': 'site_id',
+        'mappings': {'_region_id': 'region_id', '_site_group_id': 'group_id'},
+    },
+)
+
+
+def cached_scope_triggers(dependent_table):
+    """
+    Return the Site + Location `InstallDenormalizationTrigger` pair for a dependent table carrying the
+    standard cached-scope columns (_site/_location/_region/_site_group) — i.e. any CachedScopeMixin model
+    (Prefix, Cluster, WirelessLAN) plus CircuitTermination, which share the same denormalization shape.
+
+    Region- and SiteGroup-scoped rows need no trigger: their cached FK is the scoped object itself and
+    never changes underneath them. So two triggers fully cover the cache:
+      - dcim_site: region/group changed  -> refresh _region/_site_group on rows scoped to that site
+      - dcim_location: site changed       -> refresh _site (and the new site's region/group)
+    """
+    return [
+        InstallDenormalizationTrigger(
+            dependent_table=dependent_table,
+            source_table='dcim_site',
+            fk_column='_site_id',
+            mappings={'_region_id': 'region_id', '_site_group_id': 'group_id'},
+        ),
+        InstallDenormalizationTrigger(
+            dependent_table=dependent_table,
+            source_table='dcim_location',
+            fk_column='_location_id',
+            mappings={'_site_id': 'site_id'},
+            related_mappings=SITE_SCOPE_RELATED_MAPPINGS,
+        ),
+    ]

+ 18 - 0
netbox/virtualization/migrations/0057_denormalization_triggers.py

@@ -0,0 +1,18 @@
+"""
+Maintain Cluster's denormalized scope columns (CachedScopeMixin: _site/_location/_region/_site_group)
+via PostgreSQL triggers instead of the Python `dcim.signals.sync_cached_scope_fields` handler.
+"""
+from django.db import migrations
+
+from utilities.migration import cached_scope_triggers
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('virtualization', '0056_virtualmachine_render_config_permission'),
+        # Source tables (dcim_site, dcim_location) must already exist.
+        ('dcim', '0238_ltree_paths'),
+    ]
+
+    operations = cached_scope_triggers('virtualization_cluster')

+ 19 - 0
netbox/wireless/migrations/0021_denormalization_triggers.py

@@ -0,0 +1,19 @@
+"""
+Maintain WirelessLAN's denormalized scope columns (CachedScopeMixin: _site/_location/_region/
+_site_group) via PostgreSQL triggers instead of the Python `dcim.signals.sync_cached_scope_fields`
+handler.
+"""
+from django.db import migrations
+
+from utilities.migration import cached_scope_triggers
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('wireless', '0020_ltree_paths'),
+        # Source tables (dcim_site, dcim_location) must already exist.
+        ('dcim', '0238_ltree_paths'),
+    ]
+
+    operations = cached_scope_triggers('wireless_wirelesslan')