Repository: netbox-community/netbox

Issue:
Allow multiple search terms when searching for IP addresses
### Environment
* Python version: Python 3.6.8
* NetBox version:  NetBox 2.6.3

### Proposed Functionality

It would be useful to perform a bulk attribute update on a range of IP's within a subnet. For example, adding (or removing) tag `tester-tom` to addresses `10.20.102.[50-100]`. Right now the IP search is string based (#1620), so searching for `10.20.102.5` will find addresses `10.20.102.5,[10.20.102.50-10.20.102.59], [10.20.102.500-599]`. In this scenario, to assign hosts `10.20.102.50/24-10.20.102.100/24` to Tester Tom, we have to perform five searches: `10.20.102.[5,6,7,8,9]`: for each search, select all addresses matching the query -> Edit All -> Add/Remove Tags -> Save.  One suggestion is to allow the range syntax that is supported on the IP bulk add UI: i.e. `10.20.102.[50-100]`.  Yet another suggestion, allow for multiple comma-separated search strings e.g.: `10.20.102.5,10.20.102.6`.

### Use Case

We oftentimes designate blocks of IP addresses within a subnet for certain purposes. For example, within a given subnet we may assign fifty contiguous addresses for use by a particular developer/tester, or for exclusive use by VMs on a given host system. We plan on "assigning" the IPs using tags, as I don't see a concept of IP "ownership" within netbox (although maybe I can use the custom data feature). These designations change fairly rapidly, so the multiple/search update workflow will become very tedious.

This request seems similar to #1410 (but is different), and is somewhat related to #834



Discussion:
The comma syntax would diverge from the API where we use commas to designate "and" logic.

I think regex would be the best solution for this, if we took it up.
It should be possible to pass the `address` filter multiple times in a URL to `OR` multiple values. However, I think this is not currently possible because we're using a method filter for this field.

Additionally, I'll note that the `parent` filter allows you to find all IPs within a given prefix.
Marking this accepted with the understanding that it entails improving the current filter behavior to allow multiple parameters. For example, `/api/ipam/ip-address/?address=192.0.2.1&address=192.0.2.2` should return IPs matching either address.
Is this issue for filtering multiple ipaddresses? Or more enhancement mentioned in original proposal? 
Take a look at `ipam/filters.py`,

We can do address filtering like this with a new custom lookups.

```
def filter_address(self, queryset, name, value):
    address_prefix, address_nonprefix = [], []
    try:
        for address in value:
            if '/' in address:
                address_prefix.append(address)
            else:
                address_nonprefix.append(address)

        return queryset.filter(
            Q(address__in=address_prefix) |
            Q(address__net_host_list=address_nonprefix)
        )

    except ValidationError:
        return queryset.none()
```

Resolving patch:
diff --git a/netbox/ipam/fields.py b/netbox/ipam/fields.py
--- a/netbox/ipam/fields.py
+++ b/netbox/ipam/fields.py
@@ -1,6 +1,6 @@
 from django.core.exceptions import ValidationError
 from django.db import models
-from netaddr import AddrFormatError, IPNetwork
+from netaddr import AddrFormatError, IPNetwork, IPAddress
 
 from . import lookups
 from .formfields import IPFormField
@@ -23,7 +23,10 @@ def to_python(self, value):
         if not value:
             return value
         try:
-            return IPNetwork(value)
+            if '/' in str(value):
+                return IPNetwork(value)
+            else:
+                return IPAddress(value)
         except AddrFormatError as e:
             raise ValidationError("Invalid IP address format: {}".format(value))
         except (TypeError, ValueError) as e:
@@ -32,6 +35,8 @@ def to_python(self, value):
     def get_prep_value(self, value):
         if not value:
             return None
+        if isinstance(value, list):
+            return [str(self.to_python(v)) for v in value]
         return str(self.to_python(value))
 
     def form_class(self):
@@ -90,5 +95,6 @@ def db_type(self, connection):
 IPAddressField.register_lookup(lookups.NetContains)
 IPAddressField.register_lookup(lookups.NetContainsOrEquals)
 IPAddressField.register_lookup(lookups.NetHost)
+IPAddressField.register_lookup(lookups.NetIn)
 IPAddressField.register_lookup(lookups.NetHostContained)
 IPAddressField.register_lookup(lookups.NetMaskLength)
diff --git a/netbox/ipam/filters.py b/netbox/ipam/filters.py
--- a/netbox/ipam/filters.py
+++ b/netbox/ipam/filters.py
@@ -7,7 +7,7 @@
 from dcim.models import Device, Interface, Region, Site
 from extras.filters import CustomFieldFilterSet, CreatedUpdatedFilterSet
 from tenancy.filtersets import TenancyFilterSet
-from utilities.filters import NameSlugSearchFilterSet, NumericInFilter, TagFilter, TreeNodeMultipleChoiceFilter
+from utilities.filters import NameSlugSearchFilterSet, NumericInFilter, TagFilter, TreeNodeMultipleChoiceFilter, MultiValueCharFilter
 from virtualization.models import VirtualMachine
 from .constants import *
 from .models import Aggregate, IPAddress, Prefix, RIR, Role, Service, VLAN, VLANGroup, VRF
@@ -284,7 +284,7 @@ class IPAddressFilter(TenancyFilterSet, CustomFieldFilterSet, CreatedUpdatedFilt
         method='search_by_parent',
         label='Parent prefix',
     )
-    address = django_filters.CharFilter(
+    address = MultiValueCharFilter(
         method='filter_address',
         label='Address',
     )
@@ -371,13 +371,8 @@ def search_by_parent(self, queryset, name, value):
             return queryset.none()
 
     def filter_address(self, queryset, name, value):
-        if not value.strip():
-            return queryset
         try:
-            # Match address and subnet mask
-            if '/' in value:
-                return queryset.filter(address=value)
-            return queryset.filter(address__net_host=value)
+            return queryset.filter(address__net_in=value)
         except ValidationError:
             return queryset.none()
 
diff --git a/netbox/ipam/lookups.py b/netbox/ipam/lookups.py
--- a/netbox/ipam/lookups.py
+++ b/netbox/ipam/lookups.py
@@ -100,6 +100,42 @@ def as_sql(self, qn, connection):
         return 'HOST(%s) = %s' % (lhs, rhs), params
 
 
+class NetIn(Lookup):
+    lookup_name = 'net_in'
+
+    def as_sql(self, qn, connection):
+        lhs, lhs_params = self.process_lhs(qn, connection)
+        rhs, rhs_params = self.process_rhs(qn, connection)
+        with_mask, without_mask = [], []
+        for address in rhs_params[0]:
+            if '/' in address:
+                with_mask.append(address)
+            else:
+                without_mask.append(address)
+
+        address_in_clause = self.create_in_clause('{} IN ('.format(lhs), len(with_mask))
+        host_in_clause = self.create_in_clause('HOST({}) IN ('.format(lhs), len(without_mask))
+
+        if with_mask and not without_mask:
+            return address_in_clause, with_mask
+        elif not with_mask and without_mask:
+            return host_in_clause, without_mask
+
+        in_clause = '({}) OR ({})'.format(address_in_clause, host_in_clause)
+        with_mask.extend(without_mask)
+        return in_clause, with_mask
+
+    @staticmethod
+    def create_in_clause(clause_part, max_size):
+        clause_elements = [clause_part]
+        for offset in range(0, max_size):
+            if offset > 0:
+                clause_elements.append(', ')
+            clause_elements.append('%s')
+        clause_elements.append(')')
+        return ''.join(clause_elements)
+
+
 class NetHostContained(Lookup):
     """
     Check for the host portion of an IP address without regard to its mask. This allows us to find e.g. 192.0.2.1/24


Test patch:
diff --git a/netbox/ipam/tests/test_filters.py b/netbox/ipam/tests/test_filters.py
--- a/netbox/ipam/tests/test_filters.py
+++ b/netbox/ipam/tests/test_filters.py
@@ -337,16 +337,18 @@ def setUpTestData(cls):
             IPAddress(family=4, address='10.0.0.2/24', vrf=vrfs[0], interface=interfaces[0], status=IPADDRESS_STATUS_ACTIVE, role=None, dns_name='ipaddress-b'),
             IPAddress(family=4, address='10.0.0.3/24', vrf=vrfs[1], interface=interfaces[1], status=IPADDRESS_STATUS_RESERVED, role=IPADDRESS_ROLE_VIP, dns_name='ipaddress-c'),
             IPAddress(family=4, address='10.0.0.4/24', vrf=vrfs[2], interface=interfaces[2], status=IPADDRESS_STATUS_DEPRECATED, role=IPADDRESS_ROLE_SECONDARY, dns_name='ipaddress-d'),
+            IPAddress(family=4, address='10.0.0.1/25', vrf=None, interface=None, status=IPADDRESS_STATUS_ACTIVE, role=None),
             IPAddress(family=6, address='2001:db8::1/64', vrf=None, interface=None, status=IPADDRESS_STATUS_ACTIVE, role=None, dns_name='ipaddress-a'),
             IPAddress(family=6, address='2001:db8::2/64', vrf=vrfs[0], interface=interfaces[3], status=IPADDRESS_STATUS_ACTIVE, role=None, dns_name='ipaddress-b'),
             IPAddress(family=6, address='2001:db8::3/64', vrf=vrfs[1], interface=interfaces[4], status=IPADDRESS_STATUS_RESERVED, role=IPADDRESS_ROLE_VIP, dns_name='ipaddress-c'),
             IPAddress(family=6, address='2001:db8::4/64', vrf=vrfs[2], interface=interfaces[5], status=IPADDRESS_STATUS_DEPRECATED, role=IPADDRESS_ROLE_SECONDARY, dns_name='ipaddress-d'),
+            IPAddress(family=6, address='2001:db8::1/65', vrf=None, interface=None, status=IPADDRESS_STATUS_ACTIVE, role=None),
         )
         IPAddress.objects.bulk_create(ipaddresses)
 
     def test_family(self):
         params = {'family': '6'}
-        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 5)
 
     def test_dns_name(self):
         params = {'dns_name': ['ipaddress-a', 'ipaddress-b']}
@@ -359,20 +361,24 @@ def test_id__in(self):
 
     def test_parent(self):
         params = {'parent': '10.0.0.0/24'}
-        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 5)
         params = {'parent': '2001:db8::/64'}
-        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 5)
 
-    def filter_address(self):
+    def test_filter_address(self):
         # Check IPv4 and IPv6, with and without a mask
-        params = {'address': '10.0.0.1/24'}
-        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
-        params = {'address': '10.0.0.1'}
+        params = {'address': ['10.0.0.1/24']}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
-        params = {'address': '2001:db8::1/64'}
-        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
-        params = {'address': '2001:db8::1'}
+        params = {'address': ['10.0.0.1']}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+        params = {'address': ['10.0.0.1/24', '10.0.0.1/25']}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+        params = {'address': ['2001:db8::1/64']}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
+        params = {'address': ['2001:db8::1']}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+        params = {'address': ['2001:db8::1/64', '2001:db8::1/65']}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
 
     def test_mask_length(self):
         params = {'mask_length': '24'}
@@ -411,7 +417,7 @@ def test_assigned_to_interface(self):
         params = {'assigned_to_interface': 'true'}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 6)
         params = {'assigned_to_interface': 'false'}
-        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
 
     def test_status(self):
         params = {'status': [PREFIX_STATUS_DEPRECATED, PREFIX_STATUS_RESERVED]}