Repository: netbox-community/netbox
Pull request: Closes #17669: Enable filtering VLANs by assigned interface
### Closes: #17669

Introduce two new VLAN filters:

- `interface_id`
- `vminterface_id`


Problem statement:
Filter VLANs by assigned interface
### NetBox version

v4.1.3

### Feature type

New functionality

### Proposed functionality

Introduce filters to filter VLANs by the assigned interface, e.g. `GET /api/ipam/vlans/?interface_id=123`).

### Use case

This will enable filtering the global VLANs list by the ID of the assigned device or VM interface.

### Database changes

_No response_

### External dependencies

_No response_

Hints from issue discussion:

Commit message: Closes #17669: Enable filtering VLANs by assigned interface

Patch to review:
diff --git a/netbox/ipam/filtersets.py b/netbox/ipam/filtersets.py
index f98f510e570..d9927c44784 100644
--- a/netbox/ipam/filtersets.py
+++ b/netbox/ipam/filtersets.py
@@ -1035,6 +1035,16 @@ class VLANFilterSet(NetBoxModelFilterSet, TenancyFilterSet):
         to_field_name='identifier',
         label=_('L2VPN'),
     )
+    interface_id = django_filters.ModelChoiceFilter(
+        queryset=Interface.objects.all(),
+        method='filter_interface_id',
+        label=_('Assigned interface')
+    )
+    vminterface_id = django_filters.ModelChoiceFilter(
+        queryset=VMInterface.objects.all(),
+        method='filter_vminterface_id',
+        label=_('Assigned VM interface')
+    )
 
     class Meta:
         model = VLAN
@@ -1062,6 +1072,23 @@ def get_for_device(self, queryset, name, value):
     def get_for_virtualmachine(self, queryset, name, value):
         return queryset.get_for_virtualmachine(value)
 
+    def test_filter_interface_id(self, queryset, name, value):
+        if value is None:
+            return queryset.none()
+        return queryset.filter(
+            Q(interfaces_as_tagged=value) |
+            Q(interfaces_as_untagged=value)
+        )
+
+    def filter_vminterface_id(self, queryset, name, value):
+        value = value.strip()
+        if not value:
+            return queryset
+        return queryset.filter(
+            Q(vminterfaces_as_tagged=value) |
+            Q(vminterfaces_as_untagged=value)
+        )
+
 
 class ServiceTemplateFilterSet(NetBoxModelFilterSet):
     port = NumericArrayFilter(
diff --git a/netbox/ipam/tests/test_filtersets.py b/netbox/ipam/tests/test_filtersets.py
index e149c0a8d72..70eeb73bce9 100644
--- a/netbox/ipam/tests/test_filtersets.py
+++ b/netbox/ipam/tests/test_filtersets.py
@@ -1658,6 +1658,13 @@ def setUpTestData(cls):
         )
         Device.objects.bulk_create(devices)
 
+        interfaces = (
+            Interface(device=devices[0], name='Interface 1', type=InterfaceTypeChoices.TYPE_1GE_FIXED),
+            Interface(device=devices[1], name='Interface 2', type=InterfaceTypeChoices.TYPE_1GE_FIXED),
+            Interface(device=devices[2], name='Interface 3', type=InterfaceTypeChoices.TYPE_1GE_FIXED),
+        )
+        Interface.objects.bulk_create(interfaces)
+
         cluster_groups = (
             ClusterGroup(name='Cluster Group 1', slug='cluster-group-1'),
             ClusterGroup(name='Cluster Group 2', slug='cluster-group-2'),
@@ -1680,6 +1687,13 @@ def setUpTestData(cls):
         )
         VirtualMachine.objects.bulk_create(virtual_machines)
 
+        vm_interfaces = (
+            VMInterface(virtual_machine=virtual_machines[0], name='VM Interface 1'),
+            VMInterface(virtual_machine=virtual_machines[1], name='VM Interface 2'),
+            VMInterface(virtual_machine=virtual_machines[2], name='VM Interface 3'),
+        )
+        VMInterface.objects.bulk_create(vm_interfaces)
+
         groups = (
             # Scoped VLAN groups
             VLANGroup(name='Region 1', slug='region-1', scope=regions[0]),
@@ -1773,6 +1787,22 @@ def setUpTestData(cls):
         )
         VLAN.objects.bulk_create(vlans)
 
+        # Assign VLANs to device interfaces
+        interfaces[0].untagged_vlan = vlans[0]
+        interfaces[0].tagged_vlans.add(vlans[1])
+        interfaces[1].untagged_vlan = vlans[2]
+        interfaces[1].tagged_vlans.add(vlans[3])
+        interfaces[2].untagged_vlan = vlans[4]
+        interfaces[2].tagged_vlans.add(vlans[5])
+
+        # Assign VLANs to VM interfaces
+        vm_interfaces[0].untagged_vlan = vlans[0]
+        vm_interfaces[0].tagged_vlans.add(vlans[1])
+        vm_interfaces[1].untagged_vlan = vlans[2]
+        vm_interfaces[1].tagged_vlans.add(vlans[3])
+        vm_interfaces[2].untagged_vlan = vlans[4]
+        vm_interfaces[2].tagged_vlans.add(vlans[5])
+
     def test_q(self):
         params = {'q': 'foobar1'}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)


Code review comments:
diff_hunk: @@ -1062,6 +1072,23 @@ def get_for_device(self, queryset, name, value):
     def get_for_virtualmachine(self, queryset, name, value):
         return queryset.get_for_virtualmachine(value)
 
+    def test_filter_interface_id(self, queryset, name, value):
path: netbox/ipam/filtersets.py
text: @user1:
Is this function name right? `test_`?


@author:
Yeah sorry, I neglected to commit my last set of changes before pushing.