This commit is contained in:
Arthur 2026-06-15 10:38:11 -07:00
parent 57094ffdfd
commit 041e749996
10 changed files with 272 additions and 165 deletions

View File

@ -4,7 +4,7 @@ of the Python `post_save` handler formerly registered in netbox.denormalized.
"""
from django.db import migrations
from utilities.migration import InstallDenormalizationTrigger
from utilities.migration import cached_scope_triggers
class Migration(migrations.Migration):
@ -15,19 +15,4 @@ class Migration(migrations.Migration):
('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')

View File

@ -206,8 +206,12 @@ class CircuitTerminationDenormalizationTriggerTestCase(TestCase):
self.assertEqual(termination._site_group, group_b)
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)
termination = CircuitTermination.objects.create(
@ -216,12 +220,15 @@ class CircuitTerminationDenormalizationTriggerTestCase(TestCase):
self.assertEqual(termination._site, site_a)
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.save()
termination.refresh_from_db()
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):
"""

View File

@ -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 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):
@ -35,4 +55,16 @@ class Migration(migrations.Migration):
fk_column='_location_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
],
]

View File

@ -5,59 +5,36 @@ from django.db.models.signals import post_delete, post_save
from django.dispatch import receiver
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 (
Cable,
CablePath,
CableTermination,
ConsolePort,
ConsoleServerPort,
Device,
DeviceBay,
FrontPort,
Interface,
InventoryItem,
Location,
ModuleBay,
PathEndpoint,
PortMapping,
PowerOutlet,
PowerPanel,
PowerPort,
Rack,
RearPort,
Site,
VirtualChassis,
)
from .models.cables import trace_paths
from .utils import create_cablepaths, rebuild_paths
COMPONENT_MODELS = (
ConsolePort,
ConsoleServerPort,
DeviceBay,
FrontPort,
Interface,
InventoryItem,
ModuleBay,
PowerOutlet,
PowerPort,
RearPort,
)
#
# Location/rack/device assignment
#
@receiver(post_save, sender=Location)
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:
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)
Device.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)
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:
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:
instance.primary_mac_address.assigned_object = instance
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']
)

View File

@ -2,6 +2,7 @@ from types import SimpleNamespace
from unittest.mock import MagicMock, patch
from django.contrib.contenttypes.models import ContentType
from django.db import connection
from django.test import SimpleTestCase, TestCase
from dcim import signals
@ -26,6 +27,8 @@ from dcim.models import (
SiteGroup,
VirtualChassis,
)
from dcim.models.device_components import ComponentModel
from dcim.models.mixins import CachedScopeMixin
from ipam.models import Prefix
from virtualization.models import Cluster, ClusterType
from wireless.models import WirelessLAN
@ -117,10 +120,11 @@ class RackSiteChangeSignalTestCase(TestCase):
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
@ -147,6 +151,25 @@ class DeviceSiteChangeSignalTestCase(TestCase):
interface.refresh_from_db()
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):
"""
@ -386,10 +409,12 @@ class MACAddressInterfaceSignalTestCase(TestCase):
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):
@ -429,11 +454,9 @@ class SyncCachedScopeFieldsSignalTestCase(TestCase):
self.assertEqual(prefix._location, location)
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_b = SiteGroup.objects.create(name='Group B', slug='group-b')
site = Site.objects.create(name='Site', slug='site', group=group_a)
@ -547,3 +570,48 @@ class CableTerminationDenormalizationTriggerTestCase(TestCase):
termination.refresh_from_db()
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',
)

View File

@ -1,10 +1,10 @@
"""
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 utilities.migration import InstallDenormalizationTrigger
from utilities.migration import cached_scope_triggers
class Migration(migrations.Migration):
@ -15,19 +15,4 @@ class Migration(migrations.Migration):
('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')

View File

@ -264,8 +264,12 @@ class PrefixDenormalizationTriggerTestCase(TestCase):
self.assertEqual(prefix._site_group, group_b)
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)
prefix = Prefix.objects.create(
prefix='10.0.0.0/24',
@ -274,11 +278,15 @@ class PrefixDenormalizationTriggerTestCase(TestCase):
)
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.save()
prefix.refresh_from_db()
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):
"""

View File

@ -4,6 +4,7 @@ from netbox.config import ConfigItem
__all__ = (
'InstallDenormalizationTrigger',
'cached_scope_triggers',
'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
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:
dependent_table: The table carrying the denormalized columns (e.g. 'ipam_prefix').
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').
mappings: A mapping of {dependent_column: source_column}, using actual database column names
(e.g. {'_region_id': 'region_id', '_site_group_id': 'group_id'}).
(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 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.
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
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.source_table = source_table
self.fk_column = fk_column
self.mappings = mappings
self.related_mappings = list(related_mappings)
@property
def function_name(self):
@ -77,17 +92,36 @@ class InstallDenormalizationTrigger(migrations.operations.base.Operation):
pass
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'''
CREATE OR REPLACE FUNCTION "{self.function_name}"() RETURNS TRIGGER AS $$
BEGIN
UPDATE "{self.dependent_table}"
UPDATE "{self.dependent_table}" AS dep
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;
END
$$ LANGUAGE plpgsql;
@ -95,8 +129,8 @@ class InstallDenormalizationTrigger(migrations.operations.base.Operation):
schema_editor.execute(f'''
CREATE TRIGGER "{self.trigger_name}"
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):
@ -105,3 +139,41 @@ class InstallDenormalizationTrigger(migrations.operations.base.Operation):
def describe(self):
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,
),
]

View File

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

View File

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