From aa9ee0e5c62d1e9249b79aac385c8f2912a296ca Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Fri, 1 Aug 2025 16:40:15 -0400 Subject: [PATCH] Closes #19977: Denormalize device relationships on component models (#19984) * Closes #19977: Denormalize site, location, and rack for device components * Set blank=True on denormalized ForeignKeys * Populate denormalized field in test data * Ignore private fields when constructing test GraphQL requests --- netbox/dcim/filtersets.py | 12 +- ...9_device_component_denorm_site_location.py | 287 ++++++++++++++++++ netbox/dcim/models/device_components.py | 31 ++ netbox/dcim/signals.py | 33 +- netbox/dcim/tests/test_filtersets.py | 205 +++++++++++-- netbox/utilities/testing/api.py | 3 + 6 files changed, 546 insertions(+), 25 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..e3b5d0f37 --- /dev/null +++ b/netbox/dcim/migrations/0209_device_component_denorm_site_location.py @@ -0,0 +1,287 @@ +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( + blank=True, + 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( + blank=True, 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( + blank=True, 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( + blank=True, + 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( + blank=True, 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( + blank=True, 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( + blank=True, + 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( + blank=True, 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( + blank=True, 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( + blank=True, + 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( + blank=True, 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( + blank=True, 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( + blank=True, + 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( + blank=True, 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( + blank=True, 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( + blank=True, + 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( + blank=True, 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( + blank=True, 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( + blank=True, + 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( + blank=True, 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( + blank=True, 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( + blank=True, + 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( + blank=True, 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( + blank=True, 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( + blank=True, + 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( + blank=True, 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( + blank=True, 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( + blank=True, + 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( + blank=True, 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( + blank=True, 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..87680dd98 100644 --- a/netbox/dcim/models/device_components.py +++ b/netbox/dcim/models/device_components.py @@ -65,6 +65,29 @@ 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='+', + blank=True, + null=True, + ) + _location = models.ForeignKey( + to='dcim.Location', + on_delete=models.SET_NULL, + related_name='+', + blank=True, + null=True, + ) + _rack = models.ForeignKey( + to='dcim.Rack', + on_delete=models.SET_NULL, + related_name='+', + blank=True, + null=True, + ) + class Meta: abstract = True ordering = ('device', 'name') @@ -100,6 +123,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 # diff --git a/netbox/dcim/tests/test_filtersets.py b/netbox/dcim/tests/test_filtersets.py index 2ae178653..855c3abd3 100644 --- a/netbox/dcim/tests/test_filtersets.py +++ b/netbox/dcim/tests/test_filtersets.py @@ -3367,9 +3367,36 @@ class ConsolePortTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedF ConsoleServerPort.objects.bulk_create(console_server_ports) console_ports = ( - ConsolePort(device=devices[0], module=modules[0], name='Console Port 1', label='A', description='First'), - ConsolePort(device=devices[1], module=modules[1], name='Console Port 2', label='B', description='Second'), - ConsolePort(device=devices[2], module=modules[2], name='Console Port 3', label='C', description='Third'), + ConsolePort( + device=devices[0], + module=modules[0], + name='Console Port 1', + label='A', + description='First', + _site=devices[0].site, + _location=devices[0].location, + _rack=devices[0].rack, + ), + ConsolePort( + device=devices[1], + module=modules[1], + name='Console Port 2', + label='B', + description='Second', + _site=devices[1].site, + _location=devices[1].location, + _rack=devices[1].rack, + ), + ConsolePort( + device=devices[2], + module=modules[2], + name='Console Port 3', + label='C', + description='Third', + _site=devices[2].site, + _location=devices[2].location, + _rack=devices[2].rack, + ), ) ConsolePort.objects.bulk_create(console_ports) @@ -3581,13 +3608,34 @@ class ConsoleServerPortTestCase(TestCase, DeviceComponentFilterSetTests, ChangeL console_server_ports = ( ConsoleServerPort( - device=devices[0], module=modules[0], name='Console Server Port 1', label='A', description='First' + device=devices[0], + module=modules[0], + name='Console Server Port 1', + label='A', + description='First', + _site=devices[0].site, + _location=devices[0].location, + _rack=devices[0].rack, ), ConsoleServerPort( - device=devices[1], module=modules[1], name='Console Server Port 2', label='B', description='Second' + device=devices[1], + module=modules[1], + name='Console Server Port 2', + label='B', + description='Second', + _site=devices[1].site, + _location=devices[1].location, + _rack=devices[1].rack, ), ConsoleServerPort( - device=devices[2], module=modules[2], name='Console Server Port 3', label='C', description='Third' + device=devices[2], + module=modules[2], + name='Console Server Port 3', + label='C', + description='Third', + _site=devices[2].site, + _location=devices[2].location, + _rack=devices[2].rack, ), ) ConsoleServerPort.objects.bulk_create(console_server_ports) @@ -3807,6 +3855,9 @@ class PowerPortTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFil maximum_draw=100, allocated_draw=50, description='First', + _site=devices[0].site, + _location=devices[0].location, + _rack=devices[0].rack, ), PowerPort( device=devices[1], @@ -3816,6 +3867,9 @@ class PowerPortTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFil maximum_draw=200, allocated_draw=100, description='Second', + _site=devices[1].site, + _location=devices[1].location, + _rack=devices[1].rack, ), PowerPort( device=devices[2], @@ -3825,6 +3879,9 @@ class PowerPortTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFil maximum_draw=300, allocated_draw=150, description='Third', + _site=devices[2].site, + _location=devices[2].location, + _rack=devices[2].rack, ), ) PowerPort.objects.bulk_create(power_ports) @@ -4053,6 +4110,9 @@ class PowerOutletTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedF description='First', color='ff0000', status=PowerOutletStatusChoices.STATUS_ENABLED, + _site=devices[0].site, + _location=devices[0].location, + _rack=devices[0].rack, ), PowerOutlet( device=devices[1], @@ -4063,6 +4123,9 @@ class PowerOutletTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedF description='Second', color='00ff00', status=PowerOutletStatusChoices.STATUS_DISABLED, + _site=devices[1].site, + _location=devices[1].location, + _rack=devices[1].rack, ), PowerOutlet( device=devices[2], @@ -4073,6 +4136,9 @@ class PowerOutletTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedF description='Third', color='0000ff', status=PowerOutletStatusChoices.STATUS_FAULTY, + _site=devices[2].site, + _location=devices[2].location, + _rack=devices[2].rack, ), ) PowerOutlet.objects.bulk_create(power_outlets) @@ -4381,13 +4447,19 @@ class InterfaceTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFil poe_mode=InterfacePoEModeChoices.MODE_PSE, poe_type=InterfacePoETypeChoices.TYPE_1_8023AF, vlan_translation_policy=vlan_translation_policies[0], + _site=devices[0].site, + _location=devices[0].location, + _rack=devices[0].rack, ), Interface( device=devices[1], module=modules[1], name='VC Chassis Interface', type=InterfaceTypeChoices.TYPE_1GE_SFP, - enabled=True + enabled=True, + _site=devices[1].site, + _location=devices[1].location, + _rack=devices[1].rack, ), Interface( device=devices[2], @@ -4406,6 +4478,9 @@ class InterfaceTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFil poe_mode=InterfacePoEModeChoices.MODE_PD, poe_type=InterfacePoETypeChoices.TYPE_1_8023AF, vlan_translation_policy=vlan_translation_policies[0], + _site=devices[2].site, + _location=devices[2].location, + _rack=devices[2].rack, ), Interface( device=devices[3], @@ -4424,6 +4499,9 @@ class InterfaceTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFil poe_mode=InterfacePoEModeChoices.MODE_PSE, poe_type=InterfacePoETypeChoices.TYPE_2_8023AT, vlan_translation_policy=vlan_translation_policies[1], + _site=devices[3].site, + _location=devices[3].location, + _rack=devices[3].rack, ), Interface( device=devices[4], @@ -4440,6 +4518,9 @@ class InterfaceTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFil mode=InterfaceModeChoices.MODE_Q_IN_Q, qinq_svlan=vlans[0], vlan_translation_policy=vlan_translation_policies[1], + _site=devices[4].site, + _location=devices[4].location, + _rack=devices[4].rack, ), Interface( device=devices[4], @@ -4450,7 +4531,10 @@ class InterfaceTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFil mgmt_only=True, tx_power=40, mode=InterfaceModeChoices.MODE_Q_IN_Q, - qinq_svlan=vlans[1] + qinq_svlan=vlans[1], + _site=devices[4].site, + _location=devices[4].location, + _rack=devices[4].rack, ), Interface( device=devices[4], @@ -4461,7 +4545,10 @@ class InterfaceTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFil mgmt_only=False, tx_power=40, mode=InterfaceModeChoices.MODE_Q_IN_Q, - qinq_svlan=vlans[2] + qinq_svlan=vlans[2], + _site=devices[4].site, + _location=devices[4].location, + _rack=devices[4].rack, ), Interface( device=devices[4], @@ -4470,7 +4557,10 @@ class InterfaceTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFil rf_role=WirelessRoleChoices.ROLE_AP, rf_channel=WirelessChannelChoices.CHANNEL_24G_1, rf_channel_frequency=2412, - rf_channel_width=22 + rf_channel_width=22, + _site=devices[4].site, + _location=devices[4].location, + _rack=devices[4].rack, ), Interface( device=devices[4], @@ -4479,7 +4569,10 @@ class InterfaceTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFil rf_role=WirelessRoleChoices.ROLE_STATION, rf_channel=WirelessChannelChoices.CHANNEL_5G_32, rf_channel_frequency=5160, - rf_channel_width=20 + rf_channel_width=20, + _site=devices[4].site, + _location=devices[4].location, + _rack=devices[4].rack, ), ) Interface.objects.bulk_create(interfaces) @@ -4906,6 +4999,9 @@ class FrontPortTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFil rear_port=rear_ports[0], rear_port_position=1, description='First', + _site=devices[0].site, + _location=devices[0].location, + _rack=devices[0].rack, ), FrontPort( device=devices[1], @@ -4917,6 +5013,9 @@ class FrontPortTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFil rear_port=rear_ports[1], rear_port_position=2, description='Second', + _site=devices[1].site, + _location=devices[1].location, + _rack=devices[1].rack, ), FrontPort( device=devices[2], @@ -4928,6 +5027,9 @@ class FrontPortTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFil rear_port=rear_ports[2], rear_port_position=3, description='Third', + _site=devices[2].site, + _location=devices[2].location, + _rack=devices[2].rack, ), FrontPort( device=devices[3], @@ -4936,6 +5038,9 @@ class FrontPortTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFil type=PortTypeChoices.TYPE_FC, rear_port=rear_ports[3], rear_port_position=1, + _site=devices[3].site, + _location=devices[3].location, + _rack=devices[3].rack, ), FrontPort( device=devices[3], @@ -4944,6 +5049,9 @@ class FrontPortTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFil type=PortTypeChoices.TYPE_FC, rear_port=rear_ports[4], rear_port_position=1, + _site=devices[3].site, + _location=devices[3].location, + _rack=devices[3].rack, ), FrontPort( device=devices[3], @@ -4952,6 +5060,9 @@ class FrontPortTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFil type=PortTypeChoices.TYPE_FC, rear_port=rear_ports[5], rear_port_position=1, + _site=devices[3].site, + _location=devices[3].location, + _rack=devices[3].rack, ), ) FrontPort.objects.bulk_create(front_ports) @@ -5168,6 +5279,9 @@ class RearPortTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFilt color=ColorChoices.COLOR_RED, positions=1, description='First', + _site=devices[0].site, + _location=devices[0].location, + _rack=devices[0].rack, ), RearPort( device=devices[1], @@ -5178,6 +5292,9 @@ class RearPortTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFilt color=ColorChoices.COLOR_GREEN, positions=2, description='Second', + _site=devices[1].site, + _location=devices[1].location, + _rack=devices[1].rack, ), RearPort( device=devices[2], @@ -5188,10 +5305,40 @@ class RearPortTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFilt color=ColorChoices.COLOR_BLUE, positions=3, description='Third', + _site=devices[2].site, + _location=devices[2].location, + _rack=devices[2].rack, + ), + RearPort( + device=devices[3], + name='Rear Port 4', + label='D', + type=PortTypeChoices.TYPE_FC, + positions=4, + _site=devices[3].site, + _location=devices[3].location, + _rack=devices[3].rack, + ), + RearPort( + device=devices[3], + name='Rear Port 5', + label='E', + type=PortTypeChoices.TYPE_FC, + positions=5, + _site=devices[3].site, + _location=devices[3].location, + _rack=devices[3].rack, + ), + RearPort( + device=devices[3], + name='Rear Port 6', + label='F', + type=PortTypeChoices.TYPE_FC, + positions=6, + _site=devices[3].site, + _location=devices[3].location, + _rack=devices[3].rack, ), - RearPort(device=devices[3], name='Rear Port 4', label='D', type=PortTypeChoices.TYPE_FC, positions=4), - RearPort(device=devices[3], name='Rear Port 5', label='E', type=PortTypeChoices.TYPE_FC, positions=5), - RearPort(device=devices[3], name='Rear Port 6', label='F', type=PortTypeChoices.TYPE_FC, positions=6), ) RearPort.objects.bulk_create(rear_ports) @@ -5550,9 +5697,33 @@ class DeviceBayTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFil Device.objects.bulk_create(devices) device_bays = ( - DeviceBay(device=devices[0], name='Device Bay 1', label='A', description='First'), - DeviceBay(device=devices[1], name='Device Bay 2', label='B', description='Second'), - DeviceBay(device=devices[2], name='Device Bay 3', label='C', description='Third'), + DeviceBay( + device=devices[0], + name='Device Bay 1', + label='A', + description='First', + _site=devices[0].site, + _location=devices[0].location, + _rack=devices[0].rack, + ), + DeviceBay( + device=devices[1], + name='Device Bay 2', + label='B', + description='Second', + _site=devices[1].site, + _location=devices[1].location, + _rack=devices[1].rack, + ), + DeviceBay( + device=devices[2], + name='Device Bay 3', + label='C', + description='Third', + _site=devices[2].site, + _location=devices[2].location, + _rack=devices[2].rack, + ), ) DeviceBay.objects.bulk_create(device_bays) diff --git a/netbox/utilities/testing/api.py b/netbox/utilities/testing/api.py index 12e38a27f..9de3c43f8 100644 --- a/netbox/utilities/testing/api.py +++ b/netbox/utilities/testing/api.py @@ -470,6 +470,9 @@ class APIViewTestCases: elif type(field.type) is StrawberryOptional and type(field.type.of_type) is LazyType: fields_string += f'{field.name} {{ id }}\n' elif hasattr(field, 'is_relation') and field.is_relation: + # Ignore private fields + if field.name.startswith('_'): + continue # Note: StrawberryField types do not have is_relation fields_string += f'{field.name} {{ id }}\n' elif inspect.isclass(field.type) and issubclass(field.type, IPAddressFamilyType):