Repository: netbox-community/netbox

Issue:
Add a filter for device.primary_ip4 and primary_ip6 to the REST API / graphQL
### NetBox version

v3.3.9

### Feature type

Change to existing functionality

### Proposed functionality

I would like to be able to fetch a device based on it's primary ipv4 (and probably also ipv6). I understand IP addresses aren't unique (you can them in multiple VRF's), but it should still be posible to get the device based on the ID of the IP Address.

Related to https://github.com/netbox-community/netbox/issues/10058

Mentioned here https://groups.google.com/g/netbox-discuss/c/Di5VMTtK5lg

### Use case

I'm pushing devices to netbox trough a script with the API, and it happens that a device already exists with the same primary IP address. I want to handle these cases (probably by removing the old device).

When adding the device, I currently only get the error `Device with this Primary IPv4 already exists`, but there is no way to find this actual device. 

I assign the IP address to an interface of the new device before I can configure the primary IP, but somehow while doing this, the IP address is still linked to an old device, causing that device to have a primary IP4 that isn't linked to any of its interfaces.

Once this happens, it's not possible anymore to find the actual device to remove it.

### Database changes

_No response_

### External dependencies

_No response_


Discussion:
I've had to do this in my bulk import python code, when a device gets renamed from the source:
```python
fix_nb_device = [nb_device for nb_device in nb_devices if str(nb_device) == nb_address.assigned_object.device.name]
fix_nb_device[0].update({ 'primary_ip4': None })
nb_address.assigned_object_type = None
nb_address.assigned_object_id = None
nb_address.save()
```

This presumes nb_devices contains all netbox device objects, and nb_address is the "Primary IPv4 already exists" netbox address object.

However, I also do a cleanup of devices in netbox that do not exist any more in the source... (if done in the start, rather than at the end, then the above logic is not required)
@jeremystretch i'm willing to take a look add adding this filter, doesn't seem that hard.
@michaelarnauts - All yours :)

Resolving patch:
diff --git a/netbox/dcim/filtersets.py b/netbox/dcim/filtersets.py
--- a/netbox/dcim/filtersets.py
+++ b/netbox/dcim/filtersets.py
@@ -3,7 +3,7 @@
 from django.utils.translation import gettext as _
 
 from extras.filtersets import LocalConfigContextFilterSet
-from ipam.models import ASN, VRF
+from ipam.models import ASN, IPAddress, VRF
 from netbox.filtersets import (
     BaseFilterSet, ChangeLoggedModelFilterSet, OrganizationalModelFilterSet, NetBoxModelFilterSet,
 )
@@ -958,6 +958,16 @@ class DeviceFilterSet(NetBoxModelFilterSet, TenancyFilterSet, ContactModelFilter
         method='_device_bays',
         label=_('Has device bays'),
     )
+    primary_ip4_id = django_filters.ModelMultipleChoiceFilter(
+        field_name='primary_ip4',
+        queryset=IPAddress.objects.all(),
+        label=_('Primary IPv4 (ID)'),
+    )
+    primary_ip6_id = django_filters.ModelMultipleChoiceFilter(
+        field_name='primary_ip6',
+        queryset=IPAddress.objects.all(),
+        label=_('Primary IPv6 (ID)'),
+    )
 
     class Meta:
         model = Device


Test patch:
diff --git a/netbox/dcim/tests/test_filtersets.py b/netbox/dcim/tests/test_filtersets.py
--- a/netbox/dcim/tests/test_filtersets.py
+++ b/netbox/dcim/tests/test_filtersets.py
@@ -1626,10 +1626,14 @@ def setUpTestData(cls):
         ipaddresses = (
             IPAddress(address='192.0.2.1/24', assigned_object=interfaces[0]),
             IPAddress(address='192.0.2.2/24', assigned_object=interfaces[1]),
+            IPAddress(address='192.0.2.3/24', assigned_object=None),
+            IPAddress(address='2001:db8::1/64', assigned_object=interfaces[0]),
+            IPAddress(address='2001:db8::2/64', assigned_object=interfaces[1]),
+            IPAddress(address='2001:db8::3/64', assigned_object=None),
         )
         IPAddress.objects.bulk_create(ipaddresses)
-        Device.objects.filter(pk=devices[0].pk).update(primary_ip4=ipaddresses[0])
-        Device.objects.filter(pk=devices[1].pk).update(primary_ip4=ipaddresses[1])
+        Device.objects.filter(pk=devices[0].pk).update(primary_ip4=ipaddresses[0], primary_ip6=ipaddresses[3])
+        Device.objects.filter(pk=devices[1].pk).update(primary_ip4=ipaddresses[1], primary_ip6=ipaddresses[4])
 
         # VirtualChassis assignment for filtering
         virtual_chassis = VirtualChassis.objects.create(master=devices[0])
@@ -1761,6 +1765,20 @@ def test_has_primary_ip(self):
         params = {'has_primary_ip': 'false'}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
 
+    def test_primary_ip4(self):
+        addresses = IPAddress.objects.filter(address__family=4)
+        params = {'primary_ip4_id': [addresses[0].pk, addresses[1].pk]}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+        params = {'primary_ip4_id': [addresses[2].pk]}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 0)
+
+    def test_primary_ip6(self):
+        addresses = IPAddress.objects.filter(address__family=6)
+        params = {'primary_ip6_id': [addresses[0].pk, addresses[1].pk]}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+        params = {'primary_ip6_id': [addresses[2].pk]}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 0)
+
     def test_virtual_chassis_id(self):
         params = {'virtual_chassis_id': [VirtualChassis.objects.first().pk]}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)