Przeglądaj źródła

Fix #11478 - Add vc_interfaces flag to control selection of VC interfaces (#13296)

* Add `vc_interfaces` flag to control interface queryset

* Fix test failure

* Add new filters instead of using undocumented query params

* Cleanup filterset, add test

* Rename filter and re-introduce virtual_chassis filtering method (required)

* Fix test

* Adjust tests to more accurately provide coverage

* Add breaking change note

* Misc cleanup

---------

Co-authored-by: Jeremy Stretch <jstretch@netboxlabs.com>
Daniel Sheppard 2 lat temu
rodzic
commit
1854a6b76b

+ 1 - 0
docs/release-notes/version-3.6.md

@@ -23,6 +23,7 @@
 * The `device_role` field on the Device model has been renamed to `role`. The `device_role` field has been temporarily retained on the REST API serializer for devices for backward compatibility, but is read-only.
 * The `choices` array field has been removed from the CustomField model. Any defined choices are automatically migrated to CustomFieldChoiceSets, accessible via the new `choice_set` field on the CustomField model.
 * The `napalm_driver` and `napalm_args` fields (which were deprecated in v3.5) have been removed from the Platform model.
+* The `device` and `device_id` filter for interfaces will no longer include interfaces from virtual chassis peers. Two new filters, `virtual_chassis_member` and `virtual_chassis_member_id`, have been introduced to match all interfaces belonging to the specified device's virtual chassis (if any).
 * Reports and scripts are now returned within a `results` list when fetched via the REST API, consistent with other models.
 * Superusers can no longer retrieve API token keys via the web UI if [`ALLOW_TOKEN_RETRIEVAL`](https://docs.netbox.dev/en/stable/configuration/security/#allow_token_retrieval) is disabled. (The admin view has been removed per [#13044](https://github.com/netbox-community/netbox/issues/13044).)
 

+ 9 - 23
netbox/dcim/filtersets.py

@@ -1462,17 +1462,15 @@ class InterfaceFilterSet(
     PathEndpointFilterSet,
     CommonInterfaceFilterSet
 ):
-    # Override device and device_id filters from DeviceComponentFilterSet to match against any peer virtual chassis
-    # members
-    device = MultiValueCharFilter(
-        method='filter_device',
+    virtual_chassis_member = MultiValueCharFilter(
+        method='filter_virtual_chassis_member',
         field_name='name',
-        label=_('Device'),
+        label=_('Virtual Chassis Interfaces for Device')
     )
-    device_id = MultiValueNumberFilter(
-        method='filter_device_id',
+    virtual_chassis_member_id = MultiValueNumberFilter(
+        method='filter_virtual_chassis_member',
         field_name='pk',
-        label=_('Device (ID)'),
+        label=_('Virtual Chassis Interfaces for Device (ID)')
     )
     kind = django_filters.CharFilter(
         method='filter_kind',
@@ -1540,23 +1538,11 @@ class InterfaceFilterSet(
             'rf_channel', 'rf_channel_frequency', 'rf_channel_width', 'tx_power', 'description', 'cable_end',
         ]
 
-    def filter_device(self, queryset, name, value):
+    def filter_virtual_chassis_member(self, queryset, name, value):
         try:
-            devices = Device.objects.filter(**{'{}__in'.format(name): value})
             vc_interface_ids = []
-            for device in devices:
-                vc_interface_ids.extend(device.vc_interfaces().values_list('id', flat=True))
-            return queryset.filter(pk__in=vc_interface_ids)
-        except Device.DoesNotExist:
-            return queryset.none()
-
-    def filter_device_id(self, queryset, name, id_list):
-        # Include interfaces belonging to peer virtual chassis members
-        vc_interface_ids = []
-        try:
-            devices = Device.objects.filter(pk__in=id_list)
-            for device in devices:
-                vc_interface_ids += device.vc_interfaces(if_master=False).values_list('id', flat=True)
+            for device in Device.objects.filter(**{f'{name}__in': value}):
+                vc_interface_ids.extend(device.vc_interfaces(if_master=False).values_list('id', flat=True))
             return queryset.filter(pk__in=vc_interface_ids)
         except Device.DoesNotExist:
             return queryset.none()

+ 3 - 3
netbox/dcim/forms/model_forms.py

@@ -1111,7 +1111,7 @@ class InterfaceForm(InterfaceCommonForm, ModularDeviceComponentForm):
         required=False,
         label=_('Parent interface'),
         query_params={
-            'device_id': '$device',
+            'virtual_chassis_member_id': '$device',
         }
     )
     bridge = DynamicModelChoiceField(
@@ -1119,7 +1119,7 @@ class InterfaceForm(InterfaceCommonForm, ModularDeviceComponentForm):
         required=False,
         label=_('Bridged interface'),
         query_params={
-            'device_id': '$device',
+            'virtual_chassis_member_id': '$device',
         }
     )
     lag = DynamicModelChoiceField(
@@ -1127,7 +1127,7 @@ class InterfaceForm(InterfaceCommonForm, ModularDeviceComponentForm):
         required=False,
         label=_('LAG interface'),
         query_params={
-            'device_id': '$device',
+            'virtual_chassis_member_id': '$device',
             'type': 'lag',
         }
     )

+ 83 - 26
netbox/dcim/tests/test_filtersets.py

@@ -2822,11 +2822,56 @@ class InterfaceTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFil
         )
         Rack.objects.bulk_create(racks)
 
+        # VirtualChassis assignment for filtering
+        virtual_chassis = VirtualChassis(name='Virtual Chassis')
+        virtual_chassis.save()
+
         devices = (
-            Device(name='Device 1', device_type=device_types[0], role=roles[0], site=sites[0], location=locations[0], rack=racks[0]),
-            Device(name='Device 2', device_type=device_types[1], role=roles[1], site=sites[1], location=locations[1], rack=racks[1]),
-            Device(name='Device 3', device_type=device_types[2], role=roles[2], site=sites[2], location=locations[2], rack=racks[2]),
-            Device(name=None, device_type=device_types[2], role=roles[2], site=sites[3]),  # For cable connections
+            Device(
+                name='Device 1A',
+                device_type=device_types[0],
+                role=roles[0],
+                site=sites[0],
+                location=locations[0],
+                rack=racks[0],
+                virtual_chassis=virtual_chassis,
+                vc_position=1,
+                vc_priority=1
+            ),
+            Device(
+                name='Device 1B',
+                device_type=device_types[2],
+                role=roles[2],
+                site=sites[2],
+                location=locations[2],
+                rack=racks[2],
+                virtual_chassis=virtual_chassis,
+                vc_position=2,
+                vc_priority=1
+            ),
+            Device(
+                name='Device 2',
+                device_type=device_types[1],
+                role=roles[1],
+                site=sites[1],
+                location=locations[1],
+                rack=racks[1]
+            ),
+            Device(
+                name='Device 3',
+                device_type=device_types[2],
+                role=roles[2],
+                site=sites[2],
+                location=locations[2],
+                rack=racks[2]
+            ),
+            # For cable connections
+            Device(
+                name=None,
+                device_type=device_types[2],
+                role=roles[2],
+                site=sites[3]
+            ),
         )
         Device.objects.bulk_create(devices)
 
@@ -2834,6 +2879,7 @@ class InterfaceTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFil
             ModuleBay(device=devices[0], name='Module Bay 1'),
             ModuleBay(device=devices[1], name='Module Bay 2'),
             ModuleBay(device=devices[2], name='Module Bay 3'),
+            ModuleBay(device=devices[3], name='Module Bay 4'),
         )
         ModuleBay.objects.bulk_create(module_bays)
 
@@ -2841,6 +2887,7 @@ class InterfaceTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFil
             Module(device=devices[0], module_bay=module_bays[0], module_type=module_type),
             Module(device=devices[1], module_bay=module_bays[1], module_type=module_type),
             Module(device=devices[2], module_bay=module_bays[2], module_type=module_type),
+            Module(device=devices[3], module_bay=module_bays[3], module_type=module_type),
         )
         Module.objects.bulk_create(modules)
 
@@ -2853,16 +2900,11 @@ class InterfaceTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFil
 
         # Virtual Device Context Creation
         vdcs = (
-            VirtualDeviceContext(device=devices[3], name='VDC 1', identifier=1, status=VirtualDeviceContextStatusChoices.STATUS_ACTIVE),
-            VirtualDeviceContext(device=devices[3], name='VDC 2', identifier=2, status=VirtualDeviceContextStatusChoices.STATUS_PLANNED),
+            VirtualDeviceContext(device=devices[4], name='VDC 1', identifier=1, status=VirtualDeviceContextStatusChoices.STATUS_ACTIVE),
+            VirtualDeviceContext(device=devices[4], name='VDC 2', identifier=2, status=VirtualDeviceContextStatusChoices.STATUS_PLANNED),
         )
         VirtualDeviceContext.objects.bulk_create(vdcs)
 
-        # VirtualChassis assignment for filtering
-        virtual_chassis = VirtualChassis.objects.create(master=devices[0])
-        Device.objects.filter(pk=devices[0].pk).update(virtual_chassis=virtual_chassis, vc_position=1, vc_priority=1)
-        Device.objects.filter(pk=devices[1].pk).update(virtual_chassis=virtual_chassis, vc_position=2, vc_priority=2)
-
         interfaces = (
             Interface(
                 device=devices[0],
@@ -2885,6 +2927,13 @@ class InterfaceTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFil
             Interface(
                 device=devices[1],
                 module=modules[1],
+                name='VC Chassis Interface',
+                type=InterfaceTypeChoices.TYPE_1GE_SFP,
+                enabled=True
+            ),
+            Interface(
+                device=devices[2],
+                module=modules[2],
                 name='Interface 2',
                 label='B',
                 type=InterfaceTypeChoices.TYPE_1GE_GBIC,
@@ -2901,8 +2950,8 @@ class InterfaceTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFil
                 poe_type=InterfacePoETypeChoices.TYPE_1_8023AF
             ),
             Interface(
-                device=devices[2],
-                module=modules[2],
+                device=devices[3],
+                module=modules[3],
                 name='Interface 3',
                 label='C',
                 type=InterfaceTypeChoices.TYPE_1GE_FIXED,
@@ -2919,7 +2968,7 @@ class InterfaceTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFil
                 poe_type=InterfacePoETypeChoices.TYPE_2_8023AT
             ),
             Interface(
-                device=devices[3],
+                device=devices[4],
                 name='Interface 4',
                 label='D',
                 type=InterfaceTypeChoices.TYPE_OTHER,
@@ -2932,7 +2981,7 @@ class InterfaceTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFil
                 poe_type=InterfacePoETypeChoices.TYPE_2_8023AT
             ),
             Interface(
-                device=devices[3],
+                device=devices[4],
                 name='Interface 5',
                 label='E',
                 type=InterfaceTypeChoices.TYPE_OTHER,
@@ -2941,7 +2990,7 @@ class InterfaceTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFil
                 tx_power=40
             ),
             Interface(
-                device=devices[3],
+                device=devices[4],
                 name='Interface 6',
                 label='F',
                 type=InterfaceTypeChoices.TYPE_OTHER,
@@ -2950,7 +2999,7 @@ class InterfaceTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFil
                 tx_power=40
             ),
             Interface(
-                device=devices[3],
+                device=devices[4],
                 name='Interface 7',
                 type=InterfaceTypeChoices.TYPE_80211AC,
                 rf_role=WirelessRoleChoices.ROLE_AP,
@@ -2959,7 +3008,7 @@ class InterfaceTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFil
                 rf_channel_width=22
             ),
             Interface(
-                device=devices[3],
+                device=devices[4],
                 name='Interface 8',
                 type=InterfaceTypeChoices.TYPE_80211AC,
                 rf_role=WirelessRoleChoices.ROLE_STATION,
@@ -2977,8 +3026,8 @@ class InterfaceTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFil
         interfaces[7].vdcs.set([vdcs[1]])
 
         # Cables
-        Cable(a_terminations=[interfaces[0]], b_terminations=[interfaces[3]]).save()
-        Cable(a_terminations=[interfaces[1]], b_terminations=[interfaces[4]]).save()
+        Cable(a_terminations=[interfaces[0]], b_terminations=[interfaces[5]]).save()
+        Cable(a_terminations=[interfaces[1]], b_terminations=[interfaces[6]]).save()
         # Third pair is not connected
 
     def test_name(self):
@@ -2991,7 +3040,7 @@ class InterfaceTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFil
 
     def test_enabled(self):
         params = {'enabled': 'true'}
-        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 6)
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 7)
         params = {'enabled': 'false'}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
 
@@ -3011,7 +3060,7 @@ class InterfaceTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFil
         params = {'mgmt_only': 'true'}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
         params = {'mgmt_only': 'false'}
-        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 5)
 
     def test_poe_mode(self):
         params = {'poe_mode': [InterfacePoEModeChoices.MODE_PD, InterfacePoEModeChoices.MODE_PSE]}
@@ -3116,6 +3165,14 @@ class InterfaceTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFil
         params = {'device': [devices[0].name, devices[1].name]}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
 
+    def test_virtual_chassis_member(self):
+        # Device 1A & 3 have 1 management interface, Device 1B has 1 interfaces
+        devices = Device.objects.filter(name__in=['Device 1A', 'Device 3'])
+        params = {'virtual_chassis_member_id': [devices[0].pk, devices[1].pk]}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 3)
+        params = {'virtual_chassis_member': [devices[0].name, devices[1].name]}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 3)
+
     def test_module(self):
         modules = Module.objects.all()[:2]
         params = {'module_id': [modules[0].pk, modules[1].pk]}
@@ -3125,23 +3182,23 @@ class InterfaceTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFil
         params = {'cabled': True}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
         params = {'cabled': False}
-        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 5)
 
     def test_occupied(self):
         params = {'occupied': True}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
         params = {'occupied': False}
-        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 5)
 
     def test_connected(self):
         params = {'connected': True}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
         params = {'connected': False}
-        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 5)
 
     def test_kind(self):
         params = {'kind': 'physical'}
-        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 6)
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 7)
         params = {'kind': 'virtual'}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 0)