From 8643cbce1e56b8902dafa424ee78f388a1e82894 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Wed, 30 Jul 2025 10:07:35 -0400 Subject: [PATCH] Closes #19977: Denormalize site, location, and rack for device components --- netbox/dcim/filtersets.py | 12 +- ...9_device_component_denorm_site_location.py | 247 ++++++++++++++++++ netbox/dcim/models/device_components.py | 28 ++ netbox/dcim/signals.py | 33 ++- 4 files changed, 312 insertions(+), 8 deletions(-) create mode 100644 netbox/dcim/migrations/0209_device_component_denorm_site_location.py diff --git a/netbox/dcim/filtersets.py b/netbox/dcim/filtersets.py index 7f1493557..814be356c 100644 --- a/netbox/dcim/filtersets.py +++ b/netbox/dcim/filtersets.py @@ -1515,34 +1515,34 @@ class DeviceComponentFilterSet(django_filters.FilterSet): label=_('Site group (slug)'), ) site_id = django_filters.ModelMultipleChoiceFilter( - field_name='device__site', + field_name='_site', queryset=Site.objects.all(), label=_('Site (ID)'), ) site = django_filters.ModelMultipleChoiceFilter( - field_name='device__site__slug', + field_name='_site__slug', queryset=Site.objects.all(), to_field_name='slug', label=_('Site name (slug)'), ) location_id = django_filters.ModelMultipleChoiceFilter( - field_name='device__location', + field_name='_location', queryset=Location.objects.all(), label=_('Location (ID)'), ) location = django_filters.ModelMultipleChoiceFilter( - field_name='device__location__slug', + field_name='_location__slug', queryset=Location.objects.all(), to_field_name='slug', label=_('Location (slug)'), ) rack_id = django_filters.ModelMultipleChoiceFilter( - field_name='device__rack', + field_name='_rack', queryset=Rack.objects.all(), label=_('Rack (ID)'), ) rack = django_filters.ModelMultipleChoiceFilter( - field_name='device__rack__name', + field_name='_rack__name', queryset=Rack.objects.all(), to_field_name='name', label=_('Rack (name)'), diff --git a/netbox/dcim/migrations/0209_device_component_denorm_site_location.py b/netbox/dcim/migrations/0209_device_component_denorm_site_location.py new file mode 100644 index 000000000..962dc1464 --- /dev/null +++ b/netbox/dcim/migrations/0209_device_component_denorm_site_location.py @@ -0,0 +1,247 @@ +import django.db.models.deletion +from django.db import migrations, models +from django.db.models import OuterRef, Subquery + + +def populate_denormalized_data(apps, schema_editor): + Device = apps.get_model('dcim', 'Device') + component_models = ( + apps.get_model('dcim', 'ConsolePort'), + apps.get_model('dcim', 'ConsoleServerPort'), + apps.get_model('dcim', 'PowerPort'), + apps.get_model('dcim', 'PowerOutlet'), + apps.get_model('dcim', 'Interface'), + apps.get_model('dcim', 'FrontPort'), + apps.get_model('dcim', 'RearPort'), + apps.get_model('dcim', 'DeviceBay'), + apps.get_model('dcim', 'ModuleBay'), + apps.get_model('dcim', 'InventoryItem'), + ) + + for model in component_models: + subquery = Device.objects.filter(pk=OuterRef('device_id')) + model.objects.update( + _site=Subquery(subquery.values('site_id')[:1]), + _location=Subquery(subquery.values('location_id')[:1]), + _rack=Subquery(subquery.values('rack_id')[:1]), + ) + + +class Migration(migrations.Migration): + dependencies = [ + ('dcim', '0208_devicerole_uniqueness'), + ] + + operations = [ + migrations.AddField( + model_name='consoleport', + name='_location', + field=models.ForeignKey( + null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='dcim.location' + ), + ), + migrations.AddField( + model_name='consoleport', + name='_rack', + field=models.ForeignKey( + null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='dcim.rack' + ), + ), + migrations.AddField( + model_name='consoleport', + name='_site', + field=models.ForeignKey( + null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='dcim.site' + ), + ), + migrations.AddField( + model_name='consoleserverport', + name='_location', + field=models.ForeignKey( + null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='dcim.location' + ), + ), + migrations.AddField( + model_name='consoleserverport', + name='_rack', + field=models.ForeignKey( + null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='dcim.rack' + ), + ), + migrations.AddField( + model_name='consoleserverport', + name='_site', + field=models.ForeignKey( + null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='dcim.site' + ), + ), + migrations.AddField( + model_name='devicebay', + name='_location', + field=models.ForeignKey( + null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='dcim.location' + ), + ), + migrations.AddField( + model_name='devicebay', + name='_rack', + field=models.ForeignKey( + null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='dcim.rack' + ), + ), + migrations.AddField( + model_name='devicebay', + name='_site', + field=models.ForeignKey( + null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='dcim.site' + ), + ), + migrations.AddField( + model_name='frontport', + name='_location', + field=models.ForeignKey( + null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='dcim.location' + ), + ), + migrations.AddField( + model_name='frontport', + name='_rack', + field=models.ForeignKey( + null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='dcim.rack' + ), + ), + migrations.AddField( + model_name='frontport', + name='_site', + field=models.ForeignKey( + null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='dcim.site' + ), + ), + migrations.AddField( + model_name='interface', + name='_location', + field=models.ForeignKey( + null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='dcim.location' + ), + ), + migrations.AddField( + model_name='interface', + name='_rack', + field=models.ForeignKey( + null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='dcim.rack' + ), + ), + migrations.AddField( + model_name='interface', + name='_site', + field=models.ForeignKey( + null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='dcim.site' + ), + ), + migrations.AddField( + model_name='inventoryitem', + name='_location', + field=models.ForeignKey( + null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='dcim.location' + ), + ), + migrations.AddField( + model_name='inventoryitem', + name='_rack', + field=models.ForeignKey( + null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='dcim.rack' + ), + ), + migrations.AddField( + model_name='inventoryitem', + name='_site', + field=models.ForeignKey( + null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='dcim.site' + ), + ), + migrations.AddField( + model_name='modulebay', + name='_location', + field=models.ForeignKey( + null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='dcim.location' + ), + ), + migrations.AddField( + model_name='modulebay', + name='_rack', + field=models.ForeignKey( + null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='dcim.rack' + ), + ), + migrations.AddField( + model_name='modulebay', + name='_site', + field=models.ForeignKey( + null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='dcim.site' + ), + ), + migrations.AddField( + model_name='poweroutlet', + name='_location', + field=models.ForeignKey( + null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='dcim.location' + ), + ), + migrations.AddField( + model_name='poweroutlet', + name='_rack', + field=models.ForeignKey( + null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='dcim.rack' + ), + ), + migrations.AddField( + model_name='poweroutlet', + name='_site', + field=models.ForeignKey( + null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='dcim.site' + ), + ), + migrations.AddField( + model_name='powerport', + name='_location', + field=models.ForeignKey( + null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='dcim.location' + ), + ), + migrations.AddField( + model_name='powerport', + name='_rack', + field=models.ForeignKey( + null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='dcim.rack' + ), + ), + migrations.AddField( + model_name='powerport', + name='_site', + field=models.ForeignKey( + null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='dcim.site' + ), + ), + migrations.AddField( + model_name='rearport', + name='_location', + field=models.ForeignKey( + null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='dcim.location' + ), + ), + migrations.AddField( + model_name='rearport', + name='_rack', + field=models.ForeignKey( + null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='dcim.rack' + ), + ), + migrations.AddField( + model_name='rearport', + name='_site', + field=models.ForeignKey( + null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='dcim.site' + ), + ), + migrations.RunPython(populate_denormalized_data), + ] diff --git a/netbox/dcim/models/device_components.py b/netbox/dcim/models/device_components.py index 4b44c5b4e..60a5d7395 100644 --- a/netbox/dcim/models/device_components.py +++ b/netbox/dcim/models/device_components.py @@ -65,6 +65,26 @@ class ComponentModel(NetBoxModel): blank=True ) + # Denormalized references replicated from the parent Device + _site = models.ForeignKey( + to='dcim.Site', + on_delete=models.SET_NULL, + related_name='+', + null=True, + ) + _location = models.ForeignKey( + to='dcim.Location', + on_delete=models.SET_NULL, + related_name='+', + null=True, + ) + _rack = models.ForeignKey( + to='dcim.Rack', + on_delete=models.SET_NULL, + related_name='+', + null=True, + ) + class Meta: abstract = True ordering = ('device', 'name') @@ -100,6 +120,14 @@ class ComponentModel(NetBoxModel): "device": _("Components cannot be moved to a different device.") }) + def save(self, *args, **kwargs): + # Save denormalized references + self._site = self.device.site + self._location = self.device.location + self._rack = self.device.rack + + super().save(*args, **kwargs) + @property def parent_object(self): return self.device diff --git a/netbox/dcim/signals.py b/netbox/dcim/signals.py index 6c213d64c..c7d3533fb 100644 --- a/netbox/dcim/signals.py +++ b/netbox/dcim/signals.py @@ -3,13 +3,28 @@ import logging from django.db.models.signals import post_save, post_delete, pre_delete from django.dispatch import receiver -from .choices import CableEndChoices, LinkStatusChoices +from dcim.choices import CableEndChoices, LinkStatusChoices from .models import ( - Cable, CablePath, CableTermination, Device, FrontPort, PathEndpoint, PowerPanel, Rack, Location, VirtualChassis, + Cable, CablePath, CableTermination, ConsolePort, ConsoleServerPort, Device, DeviceBay, FrontPort, Interface, + InventoryItem, ModuleBay, PathEndpoint, PowerOutlet, PowerPanel, PowerPort, Rack, RearPort, Location, + VirtualChassis, ) from .models.cables import trace_paths from .utils import create_cablepath, rebuild_paths +COMPONENT_MODELS = ( + ConsolePort, + ConsoleServerPort, + DeviceBay, + FrontPort, + Interface, + InventoryItem, + ModuleBay, + PowerOutlet, + PowerPort, + RearPort, +) + # # Location/rack/device assignment @@ -39,6 +54,20 @@ def handle_rack_site_change(instance, created, **kwargs): Device.objects.filter(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, + ) + + # # Virtual chassis #