From 5c06fe6de4f5418e182ff915d9b98388a4ddefa9 Mon Sep 17 00:00:00 2001 From: Martin Hauser Date: Sun, 19 Oct 2025 11:58:13 +0200 Subject: [PATCH] feat(filtersets): Add `assigned` and `primary` filters for MACAddress Introduce Boolean filters `assigned` and `primary` to the MACAddress filterset, improving filtering capabilities. Update forms, tables, and GraphQL queries to incorporate the new filters. Add tests to validate the correct functionality. Fixes #20399 --- netbox/dcim/filtersets.py | 37 +++++++++++++++++++++++++--- netbox/dcim/forms/filtersets.py | 22 +++++++++++++++-- netbox/dcim/graphql/filters.py | 22 ++++++++++++++++- netbox/dcim/tables/devices.py | 7 ++++-- netbox/dcim/tests/test_filtersets.py | 25 ++++++++++++++++++- 5 files changed, 104 insertions(+), 9 deletions(-) diff --git a/netbox/dcim/filtersets.py b/netbox/dcim/filtersets.py index 04ba3b00d53..43383c6439d 100644 --- a/netbox/dcim/filtersets.py +++ b/netbox/dcim/filtersets.py @@ -14,16 +14,16 @@ AttributeFiltersMixin, BaseFilterSet, ChangeLoggedModelFilterSet, NestedGroupModelFilterSet, NetBoxModelFilterSet, OrganizationalModelFilterSet, ) -from tenancy.filtersets import TenancyFilterSet, ContactModelFilterSet +from tenancy.filtersets import ContactModelFilterSet, TenancyFilterSet from tenancy.models import * from users.models import User from utilities.filters import ( ContentTypeFilter, MultiValueCharFilter, MultiValueMACAddressFilter, MultiValueNumberFilter, MultiValueWWNFilter, NumericArrayFilter, TreeNodeMultipleChoiceFilter, ) -from virtualization.models import Cluster, ClusterGroup, VMInterface, VirtualMachine +from virtualization.models import Cluster, ClusterGroup, VirtualMachine, VMInterface from vpn.models import L2VPN -from wireless.choices import WirelessRoleChoices, WirelessChannelChoices +from wireless.choices import WirelessChannelChoices, WirelessRoleChoices from wireless.models import WirelessLAN, WirelessLink from .choices import * from .constants import * @@ -1807,6 +1807,14 @@ class MACAddressFilterSet(NetBoxModelFilterSet): queryset=VMInterface.objects.all(), label=_('VM interface (ID)'), ) + assigned = django_filters.BooleanFilter( + method='filter_assigned', + label=_('Is assigned'), + ) + primary = django_filters.BooleanFilter( + method='filter_primary', + label=_('Is primary'), + ) class Meta: model = MACAddress @@ -1843,6 +1851,29 @@ def filter_virtual_machine(self, queryset, name, value): vminterface__in=interface_ids ) + def filter_assigned(self, queryset, name, value): + params = { + 'assigned_object_type__isnull': True, + 'assigned_object_id__isnull': True, + } + if value: + return queryset.exclude(**params) + else: + return queryset.filter(**params) + + def filter_primary(self, queryset, name, value): + interface_mac_ids = Interface.objects.filter(primary_mac_address_id__isnull=False).values_list( + 'primary_mac_address_id', flat=True + ) + vminterface_mac_ids = VMInterface.objects.filter(primary_mac_address_id__isnull=False).values_list( + 'primary_mac_address_id', flat=True + ) + query = Q(pk__in=interface_mac_ids) | Q(pk__in=vminterface_mac_ids) + if value: + return queryset.filter(query) + else: + return queryset.exclude(query) + class CommonInterfaceFilterSet(django_filters.FilterSet): mode = django_filters.MultipleChoiceFilter( diff --git a/netbox/dcim/forms/filtersets.py b/netbox/dcim/forms/filtersets.py index daa3eef6555..12bd06907c5 100644 --- a/netbox/dcim/forms/filtersets.py +++ b/netbox/dcim/forms/filtersets.py @@ -1676,12 +1676,16 @@ class MACAddressFilterForm(NetBoxModelFilterSetForm): model = MACAddress fieldsets = ( FieldSet('q', 'filter_id', 'tag'), - FieldSet('mac_address', 'device_id', 'virtual_machine_id', name=_('MAC address')), + FieldSet('mac_address', name=_('Attributes')), + FieldSet( + 'device_id', 'virtual_machine_id', 'assigned', 'primary', + name=_('Assignments'), + ), ) selector_fields = ('filter_id', 'q', 'device_id', 'virtual_machine_id') mac_address = forms.CharField( required=False, - label=_('MAC address') + label=_('MAC address'), ) device_id = DynamicModelMultipleChoiceField( queryset=Device.objects.all(), @@ -1693,6 +1697,20 @@ class MACAddressFilterForm(NetBoxModelFilterSetForm): required=False, label=_('Assigned VM'), ) + assigned = forms.NullBooleanField( + required=False, + label=_('Assigned to an interface'), + widget=forms.Select( + choices=BOOLEAN_WITH_BLANK_CHOICES + ), + ) + primary = forms.NullBooleanField( + required=False, + label=_('Primary MAC of an interface'), + widget=forms.Select( + choices=BOOLEAN_WITH_BLANK_CHOICES + ), + ) tag = TagFilterField(model) diff --git a/netbox/dcim/graphql/filters.py b/netbox/dcim/graphql/filters.py index af2922e1362..ccf4a2d987f 100644 --- a/netbox/dcim/graphql/filters.py +++ b/netbox/dcim/graphql/filters.py @@ -18,7 +18,9 @@ ImageAttachmentFilterMixin, WeightFilterMixin, ) -from tenancy.graphql.filter_mixins import TenancyFilterMixin, ContactFilterMixin +from tenancy.graphql.filter_mixins import ContactFilterMixin, TenancyFilterMixin +from virtualization.models import VMInterface + from .filter_mixins import ( CabledObjectModelFilterMixin, ComponentModelFilterMixin, @@ -419,6 +421,24 @@ class MACAddressFilter(PrimaryModelFilterMixin): ) assigned_object_id: ID | None = strawberry_django.filter_field() + @strawberry_django.filter_field() + def assigned(self, value: bool, prefix) -> Q: + return Q(**{f'{prefix}assigned_object_id__isnull': (not value)}) + + @strawberry_django.filter_field() + def primary(self, value: bool, prefix) -> Q: + interface_mac_ids = models.Interface.objects.filter(primary_mac_address_id__isnull=False).values_list( + 'primary_mac_address_id', flat=True + ) + vminterface_mac_ids = VMInterface.objects.filter(primary_mac_address_id__isnull=False).values_list( + 'primary_mac_address_id', flat=True + ) + query = Q(**{f'{prefix}pk__in': interface_mac_ids}) | Q(**{f'{prefix}pk__in': vminterface_mac_ids}) + if value: + return Q(query) + else: + return ~Q(query) + @strawberry_django.filter_type(models.Interface, lookups=True) class InterfaceFilter(ModularComponentModelFilterMixin, InterfaceBaseFilterMixin, CabledObjectModelFilterMixin): diff --git a/netbox/dcim/tables/devices.py b/netbox/dcim/tables/devices.py index dbdfae11d98..439906e9672 100644 --- a/netbox/dcim/tables/devices.py +++ b/netbox/dcim/tables/devices.py @@ -1174,6 +1174,9 @@ class MACAddressTable(NetBoxTable): orderable=False, verbose_name=_('Parent') ) + is_primary = columns.BooleanColumn( + verbose_name=_('Primary') + ) tags = columns.TagColumn( url_name='dcim:macaddress_list' ) @@ -1184,7 +1187,7 @@ class MACAddressTable(NetBoxTable): class Meta(DeviceComponentTable.Meta): model = models.MACAddress fields = ( - 'pk', 'id', 'mac_address', 'assigned_object_parent', 'assigned_object', 'description', 'comments', 'tags', - 'created', 'last_updated', + 'pk', 'id', 'mac_address', 'assigned_object_parent', 'assigned_object', 'description', 'is_primary', + 'comments', 'tags', 'created', 'last_updated', ) default_columns = ('pk', 'mac_address', 'assigned_object_parent', 'assigned_object', 'description') diff --git a/netbox/dcim/tests/test_filtersets.py b/netbox/dcim/tests/test_filtersets.py index c05d07ab039..08682d63571 100644 --- a/netbox/dcim/tests/test_filtersets.py +++ b/netbox/dcim/tests/test_filtersets.py @@ -10,7 +10,7 @@ from tenancy.models import Tenant, TenantGroup from users.models import User from utilities.testing import ChangeLoggedFilterSetTests, create_test_device, create_test_virtualmachine -from virtualization.models import Cluster, ClusterType, ClusterGroup, VMInterface, VirtualMachine +from virtualization.models import Cluster, ClusterGroup, ClusterType, VirtualMachine, VMInterface from wireless.choices import WirelessChannelChoices, WirelessRoleChoices from wireless.models import WirelessLink @@ -7164,9 +7164,20 @@ def setUpTestData(cls): MACAddress(mac_address='00-00-00-05-01-01', assigned_object=vm_interfaces[1]), MACAddress(mac_address='00-00-00-06-01-01', assigned_object=vm_interfaces[2]), MACAddress(mac_address='00-00-00-06-01-02', assigned_object=vm_interfaces[2]), + # unassigned + MACAddress(mac_address='00-00-00-07-01-01'), ) MACAddress.objects.bulk_create(mac_addresses) + # Set MAC addresses as primary + for idx, interface in enumerate(interfaces): + interface.primary_mac_address = mac_addresses[idx] + interface.save() + for idx, vm_interface in enumerate(vm_interfaces): + # Offset by 4 for device MACs + vm_interface.primary_mac_address = mac_addresses[idx + 4] + vm_interface.save() + def test_mac_address(self): params = {'mac_address': ['00-00-00-01-01-01', '00-00-00-02-01-01']} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) @@ -7198,3 +7209,15 @@ def test_vminterface(self): self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) params = {'vminterface': [vm_interfaces[0].name, vm_interfaces[1].name]} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + + def test_assigned(self): + params = {'assigned': True} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 8) + params = {'assigned': False} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1) + + def test_primary(self): + params = {'primary': True} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 6) + params = {'primary': False} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 3)