Repository: nautobot/nautobot

Issue:
Can not search for parts of IPv6 addresses
<!--
    NOTE: IF YOUR ISSUE DOES NOT FOLLOW THIS TEMPLATE, IT WILL BE CLOSED.

    This form is only for reporting reproducible bugs. If you need assistance
    with Nautobot installation, or if you have a general question, please start a
    discussion instead: https://github.com/nautobot/nautobot/discussions

    Please describe the environment in which you are running Nautobot. Be sure
    that you are running an unmodified instance of the latest stable release
    before submitting a bug report, and that any plugins have been disabled.
-->
### Environment
* Python version: 3.6.11
* Nautobot version: 1.0.2

<!--
    Describe in detail the exact steps that someone else can take to reproduce
    this bug using the current stable release of Nautobot. Begin with the
    creation of any necessary database objects and call out every operation
    being performed explicitly. If reporting a bug in the REST API, be sure to
    reconstruct the raw HTTP request(s) being made: Don't rely on a client
    library such as pynautobot.
-->
### Steps to Reproduce
1. Under IPAM/Prefixes, create a new IPv6 prefix (e.g. 2001:DB8::/29)
2. After the prefix is created, in the `ipam/prefixes/` view, search for a portion of the prefix (e.g. 2001:DB8)
3. Notice that the created prefix doesn't show up when searched for
4. Search for the full prefix (e.g. 2001:DB8::/29)
5. Notice that the full prefix now shows up

<!-- What did you expect to happen? -->
### Expected Behavior
Expected searches for subsets of the prefix to return the prefix in the `ipam/prefixes` view.

<!-- What happened instead? -->
### Observed Behavior
Searches for subsets of the prefix do not return the prefix. Only exact searches return the prefix.



Discussion:
Probably closely related to #495.
This is related to the fact that we implemented string search for `IPAddress` but not `Prefix` or `Aggregate`. Relatively quick fix by adapting the `IPAddressQuerySet.string_search` method to `NetworkQuerySet` base.

Resolving patch:
diff --git a/nautobot/ipam/filters.py b/nautobot/ipam/filters.py
--- a/nautobot/ipam/filters.py
+++ b/nautobot/ipam/filters.py
@@ -171,20 +171,12 @@ class Meta:
         fields = ["id", "date_added"]
 
     def search(self, queryset, name, value):
-        if not value.strip():
+        value = value.strip()
+
+        if not value:
             return queryset
-        qs_filter = Q(description__icontains=value)
-        try:
-            # filter for Aggregates containing |value|
-            query = netaddr.IPNetwork(value.strip())
-            qs_filter |= Q(
-                prefix_length__lte=query.prefixlen,
-                network__lte=bytes(query.network),
-                broadcast__gte=bytes(query.broadcast if query.broadcast else query.network),
-            )
-        except (AddrFormatError, ValueError):
-            pass
-        return queryset.filter(qs_filter)
+
+        return queryset.string_search(value)
 
     def filter_prefix(self, queryset, name, value):
         if not value.strip():
@@ -195,15 +187,7 @@ def filter_prefix(self, queryset, name, value):
             return queryset.none()
 
     def filter_ip_family(self, queryset, name, value):
-        if value == 4:
-            length = IPV4_BYTE_LENGTH
-        elif value == 6:
-            length = IPV6_BYTE_LENGTH
-        else:
-            raise ValueError("invalid IP family {}".format(value))
-        return queryset.annotate(network_len=Length(F("network"))).filter(
-            network_len=length,
-        )
+        return queryset.ip_family(value)
 
 
 class RoleFilterSet(
@@ -324,20 +308,11 @@ class Meta:
 
     def search(self, queryset, name, value):
         value = value.strip()
+
         if not value:
             return queryset
-        qs_filter = Q(description__icontains=value)
-        try:
-            # filter for Prefixes containing |value|
-            query = netaddr.IPNetwork(value)
-            qs_filter |= Q(
-                prefix_length__lte=query.prefixlen,
-                network__lte=bytes(query.network),
-                broadcast__gte=bytes(query.broadcast if query.broadcast else query.network),
-            )
-        except (AddrFormatError, ValueError):
-            pass
-        return queryset.filter(qs_filter)
+
+        return queryset.string_search(value)
 
     def filter_prefix(self, queryset, name, value):
         value = value.strip()
@@ -403,15 +378,7 @@ def filter_present_in_vrf(self, queryset, name, vrf):
         return queryset.filter(Q(vrf=vrf) | Q(vrf__export_targets__in=vrf.import_targets.all()))
 
     def filter_ip_family(self, queryset, name, value):
-        if value == 4:
-            length = IPV4_BYTE_LENGTH
-        elif value == 6:
-            length = IPV6_BYTE_LENGTH
-        else:
-            raise ValueError("invalid IP family {}".format(value))
-        return queryset.annotate(network_len=Length(F("network"))).filter(
-            network_len=length,
-        )
+        return queryset.ip_family(value)
 
 
 class IPAddressFilterSet(
@@ -514,6 +481,11 @@ class Meta:
         fields = ["id", "dns_name"]
 
     def search(self, queryset, name, value):
+        value = value.strip()
+
+        if not value:
+            return queryset
+
         return queryset.string_search(value)
 
     def search_by_parent(self, queryset, name, value):
diff --git a/nautobot/ipam/querysets.py b/nautobot/ipam/querysets.py
--- a/nautobot/ipam/querysets.py
+++ b/nautobot/ipam/querysets.py
@@ -20,16 +20,150 @@
 from nautobot.utilities.querysets import RestrictedQuerySet
 
 
-class NetworkQuerySet(QuerySet):
+class BaseNetworkQuerySet(RestrictedQuerySet):
+    """Base class for network-related querysets."""
+
+    ip_family_map = {
+        4: IPV4_BYTE_LENGTH,
+        6: IPV6_BYTE_LENGTH,
+    }
+
+    # Match string with ending in "::"
+    RE_COLON = re.compile(".*::$")
+
+    # Match string from "0000" to "ffff" with no trailing ":"
+    RE_HEXTET = re.compile("^[a-f0-9]{4}$")
+
     @staticmethod
-    def _get_last_ip(prefix):
+    def _get_last_ip(network):
         """
-        Get the last IP address in the given prefix.
+        Get the last IP address in the given network.
 
-        This is distinct from prefix.broadcast in the case of point-to-point or host network prefixes
+        This is distinct from network.broadcast in the case of point-to-point or host networks
         (neither of which technically have a "broadcast" address).
         """
-        return prefix.broadcast if prefix.broadcast else prefix[-1]
+        return network.broadcast if network.broadcast else network[-1]
+
+    def parse_network_string(self, search):
+        """
+        Attempts to parse a (potentially incomplete) IPAddress and return an IPNetwork.
+        eg: '10.10' should be interpreted as netaddr.IPNetwork('10.10.0.0/16')
+        """
+        version = 4
+
+        try:
+            # Disregard netmask
+            search = search.split("/")[0]
+
+            # Attempt to quickly assess v6
+            if ":" in search:
+                version = 6
+
+            # (IPv6) If the value ends with ":" but it's not "::", make it so.
+            if search.endswith(":") and not self.RE_COLON.match(search):
+                search += ":"
+            # (IPv6) If the value has a colon in it, but doesn't end with one or
+            # contain "::"
+            elif all(
+                [
+                    not search.endswith(":"),
+                    version == 6,
+                    "::" not in search,
+                ]
+            ):
+                search += ":"
+            # (IPv6) If the value is numeric and > 255, append "::"
+            # (IPv6) If the value is a hextet (e.g. "fe80"), append "::"
+            elif any(
+                [
+                    search.isdigit() and int(search) > 255,
+                    self.RE_HEXTET.match(search),
+                ]
+            ):
+                search += "::"
+                version = 6
+
+            call_map = {
+                4: self.parse_ipv4,
+                6: self.parse_ipv6,
+            }
+            return call_map[version](search)
+
+        except netaddr.core.AddrFormatError:
+            ver_map = {4: "0/32", 6: "::/128"}
+            return netaddr.IPNetwork(ver_map[version])
+
+    def parse_ipv6(self, value):
+        """IPv6 addresses are 8, 16-bit fields."""
+
+        # Get non-empty octets from search string
+        hextets = value.split(":")
+
+        # Before we normalize, check that final value is a digit.
+        if hextets[-1].isalnum():
+            fill_zeroes = False  # Leave "" in there.
+            prefix_len = 128  # Force /128
+        # Otherwise fill blanks with zeroes and fuzz prefix_len
+        else:
+            fill_zeroes = True
+            hextets = list(filter(lambda h: h, hextets))
+            # Fuzz prefix_len based on parsed octets
+            prefix_len = 16 * len(hextets)
+
+        # Replace "" w/ "0"
+        if fill_zeroes:
+            hextets.extend(["0" for _ in range(len(hextets), 8)])
+
+        # Create an netaddr.IPNetwork to search within
+        network = ":".join(hextets)
+        ip = f"{network}/{prefix_len}"
+        return netaddr.IPNetwork(ip)
+
+    def parse_ipv4(self, value):
+        """IPv4 addresses are 4, 8-bit fields."""
+
+        # Get non-empty octets from search string
+        octets = value.split(".")
+        octets = list(filter(lambda o: o, octets))
+
+        # Fuzz prefix_len based on parsed octets
+        prefix_len = 8 * len(octets)
+
+        # Create an netaddr.IPNetwork to search within
+        octets.extend(["0" for _ in range(len(octets), 4)])
+        network = ".".join(octets)
+        ip = f"{network}/{prefix_len}"
+        return netaddr.IPNetwork(ip)
+
+    def string_search(self, search):
+        """
+        Interpret a search string and return useful results.
+        """
+        if not search:
+            return self.none()
+
+        network = self.parse_network_string(search)
+        last_ip = self._get_last_ip(network)
+
+        return self.filter(
+            Q(description__icontains=search)
+            | Q(network__gte=network.network, broadcast__lte=last_ip)  # same as `net_contained()`
+            | Q(
+                prefix_length__lte=network.prefixlen, network__lte=network.network, broadcast__gte=last_ip
+            )  # same as `net_contains_or_equals()`
+        )
+
+
+class NetworkQuerySet(BaseNetworkQuerySet):
+    """Base class for Prefix/Aggregate querysets."""
+
+    def ip_family(self, family):
+        try:
+            byte_len = self.ip_family_map[family]
+        except KeyError:
+            raise ValueError("invalid IP family {}".format(family))
+
+        return self.annotate(address_len=Length(F("network"))).filter(address_len=byte_len)
 
     def net_equals(self, prefix):
         prefix = netaddr.IPNetwork(prefix)
@@ -99,11 +233,13 @@ def filter(self, *args, prefix=None, **kwargs):
         return super().filter(*args, **kwargs)
 
 
-class AggregateQuerySet(NetworkQuerySet, RestrictedQuerySet):
-    pass
+class AggregateQuerySet(NetworkQuerySet):
+    """Queryset for `Aggregate` objects."""
 
 
-class PrefixQuerySet(NetworkQuerySet, RestrictedQuerySet):
+class PrefixQuerySet(NetworkQuerySet):
+    """Queryset for `Prefix` objects."""
+
     def annotate_tree(self):
         """
         Annotate the number of parent and child prefixes for each Prefix.
@@ -170,27 +306,8 @@ def annotate_tree(self):
         )
 
 
-class IPAddressQuerySet(RestrictedQuerySet):
-    ip_family_map = {
-        4: IPV4_BYTE_LENGTH,
-        6: IPV6_BYTE_LENGTH,
-    }
-
-    # Match string with ending in "::"
-    RE_COLON = re.compile(".*::$")
-
-    # Match string from "0000" to "ffff" with no trailing ":"
-    RE_HEXTET = re.compile("^[a-f0-9]{4}$")
-
-    @staticmethod
-    def _get_last_ip(network):
-        """
-        Get the last IP address in the given network.
-
-        This is distinct from network.broadcast in the case of point-to-point or host networks
-        (neither of which technically have a "broadcast" address).
-        """
-        return network.broadcast if network.broadcast else network[-1]
+class IPAddressQuerySet(BaseNetworkQuerySet):
+    """Queryset for `IPAddress` objects."""
 
     def get_queryset(self):
         """
@@ -202,6 +319,14 @@ def get_queryset(self):
         """
         return super().order_by("host")
 
+    def ip_family(self, family):
+        try:
+            byte_len = self.ip_family_map[family]
+        except KeyError:
+            raise ValueError("invalid IP family {}".format(family))
+
+        return self.annotate(address_len=Length(F("host"))).filter(address_len=byte_len)
+
     def string_search(self, search):
         """
         Interpret a search string and return useful results.
@@ -209,7 +334,7 @@ def string_search(self, search):
         if not search:
             return self.none()
 
-        network = self._parse_as_network_string(search)
+        network = self.parse_network_string(search)
         last_ip = self._get_last_ip(network)
         return self.filter(
             Q(dns_name__icontains=search)
@@ -217,90 +342,6 @@ def string_search(self, search):
             | Q(host__lte=last_ip, host__gte=network.network)  # same as `net_host_contained()`
         )
 
-    def _parse_as_network_string(self, search):
-        """
-        Attempts to parse a (potentially incomplete) IPAddress and return an IPNetwork.
-        eg: '10.10' should be interpreted as netaddr.IPNetwork('10.10.0.0/16')
-        """
-        version = 4
-
-        try:
-            # Disregard netmask
-            search = search.split("/")[0]
-
-            # Attempt to quickly assess v6
-            if ":" in search:
-                version = 6
-
-            # (IPv6) If the value ends with ":" but it's not "::", make it so.
-            if search.endswith(":") and not self.RE_COLON.match(search):
-                search += ":"
-            # (IPv6) If the value is numeric and > 255, append "::"
-            # (IPv6) If the value is a hextet (e.g. "fe80"), append "::"
-            elif any(
-                [
-                    search.isdigit() and int(search) > 255,
-                    self.RE_HEXTET.match(search),
-                ]
-            ):
-                search += "::"
-                version = 6
-
-            call_map = {
-                4: self._parse_ipv4,
-                6: self._parse_ipv6,
-            }
-            return call_map[version](search)
-
-        except netaddr.core.AddrFormatError:
-            ver_map = {4: "0/32", 6: "::/128"}
-            return netaddr.IPNetwork(ver_map[version])
-
-    def _parse_ipv6(self, value):
-        """IPv6 addresses are 8, 16-bit fields."""
-
-        # Get non-empty octets from search string
-        hextets = value.split(":")
-
-        # Before we normalize, check that final value is a digit.
-        if hextets[-1].isalnum():
-            fill_zeroes = False  # Leave "" in there.
-            prefix_len = 128  # Force /128
-        else:
-            fill_zeroes = True  # Replace "" w/ "0"
-            hextets = list(filter(lambda h: h, hextets))
-            prefix_len = 16 * len(hextets)
-
-        # Create an netaddr.IPNetwork to search within
-        if fill_zeroes:
-            hextets.extend(["0" for _ in range(len(hextets), 8)])
-
-        network = ":".join(hextets)
-        ip = f"{network}/{prefix_len}"
-        return netaddr.IPNetwork(ip)
-
-    def _parse_ipv4(self, value):
-        """IPv4 addresses are 4, 8-bit fields."""
-
-        # Get non-empty octets from search string
-        octets = value.split(".")
-        octets = list(filter(lambda o: o, octets))
-        prefix_len = 8 * len(octets)
-
-        # Create an netaddr.IPNetwork to search within
-        octets.extend(["0" for _ in range(len(octets), 4)])
-        network = ".".join(octets)
-        ip = f"{network}/{prefix_len}"
-        return netaddr.IPNetwork(ip)
-
-    def ip_family(self, family):
-        try:
-            byte_len = self.ip_family_map[family]
-        except KeyError:
-            raise ValueError("invalid IP family {}".format(family))
-
-        return self.annotate(address_len=Length(F("host"))).filter(address_len=byte_len)
-
     def net_host_contained(self, network):
         # consider only host ip address when
         # filtering for membership in |network|


Test patch:
diff --git a/nautobot/ipam/tests/test_querysets.py b/nautobot/ipam/tests/test_querysets.py
--- a/nautobot/ipam/tests/test_querysets.py
+++ b/nautobot/ipam/tests/test_querysets.py
@@ -116,7 +116,7 @@ def test_filter_by_address(self):
         address = self.queryset.net_in(["10.0.0.1/24"])[0]
         self.assertEqual(self.queryset.filter(address="10.0.0.1/24")[0], address)
 
-    def test_string_search_parse_as_network_string(self):
+    def test_string_search_parse_network_string(self):
         """
         Tests that the parsing underlying `string_search` behaves as expected.
         """
@@ -131,6 +131,7 @@ def test_string_search_parse_as_network_string(self):
             "2001": "2001::/16",
             "2001:": "2001::/16",
             "2001::": "2001::/16",
+            "2001:db8": "2001:db8::/32",
             "2001:db8:": "2001:db8::/32",
             "2001:0db8::": "2001:db8::/32",
             "2001:db8:abcd:0012::0/64": "2001:db8:abcd:12::/128",
@@ -142,7 +143,7 @@ def test_string_search_parse_as_network_string(self):
         }
 
         for test, expected in tests.items():
-            self.assertEqual(str(self.queryset._parse_as_network_string(test)), expected)
+            self.assertEqual(str(self.queryset.parse_network_string(test)), expected)
 
     def test_string_search(self):
         search_terms = {
@@ -154,6 +155,8 @@ def test_string_search(self):
             "11": 0,
             "2001": 3,
             "2001::": 3,
+            "2001:db8": 3,
+            "2001:db8:": 3,
             "2001:db8::": 3,
             "2001:db8::1": 1,
             "fe80::": 0,
@@ -246,3 +249,31 @@ def test_get_by_prefix_fails(self):
     def test_filter_by_prefix(self):
         prefix = self.queryset.net_equals(netaddr.IPNetwork("192.168.0.0/16"))[0]
         self.assertEqual(self.queryset.filter(prefix="192.168.0.0/16")[0], prefix)
+
+    def test_string_search(self):
+        # This test case also applies to Aggregate objects.
+        search_terms = {
+            "192": 7,
+            "192.": 7,
+            "192.168": 7,
+            "192.168.": 7,
+            "192.168.1": 2,
+            "192.168.1.0": 2,
+            "192.168.3": 5,
+            "192.168.3.192/26": 3,
+            "192.168.0.0/16": 1,
+            "11": 0,
+            "fd78": 3,
+            "fd78::": 3,
+            "fd78:da4f": 3,
+            "fd78:da4f:": 3,
+            "fd78:da4f:e596": 3,
+            "fd78:da4f:e596:c217": 3,
+            "fd78:da4f:e596:c217::": 3,
+            "fd78:da4f:e596:c217::/64": 3,
+            "fd78:da4f:e596:c217::/120": 3,
+            "fd78:da4f:e596:c217::/122": 3,
+            "fe80::": 0,
+        }
+        for term, cnt in search_terms.items():
+            self.assertEqual(self.queryset.string_search(term).count(), cnt)