Просмотр исходного кода

perf(ipam): optimize IP availability and utilization

Replace netaddr.IPSet-based availability and utilization calculations
with SQL-side distinct host counts and Python-side interval merging.
This avoids materializing large address sets in memory when rendering
prefix and range summaries.

The new implementation:
- counts distinct IPAddress host values in the database
- merges populated/utilized IPRanges before counting address space
- avoids double-counting IPs covered by populated/utilized ranges
- finds the first available IP by streaming sorted occupied intervals
- updates the prefix detail panel to use available_ip_count
- adds host-expression indexes for IPRange start/end addresses

Also update IPRange child-IP matching and populated-range validation to
compare host portions instead of full address/mask values, preserve
rebuild_prefixes() compatibility while allowing queryset input, and fix
BaseIPField.get_prep_value() to handle zero addresses such as 0.0.0.0
and ::.
Martin Hauser 5 дней назад
Родитель
Сommit
b16dd9ebd5

+ 4 - 1
netbox/ipam/fields.py

@@ -42,7 +42,10 @@ class BaseIPField(models.Field):
             raise ValidationError(e)
 
     def get_prep_value(self, value):
-        if not value:
+        # Use an explicit None / empty-string check; `not value` incorrectly treats
+        # the valid zero addresses 0.0.0.0 and :: as empty. Raw int 0 is preserved
+        # as "empty" for backward compatibility (Django's ORM does not pass it here).
+        if value is None or value == '' or (type(value) is int and value == 0):
             return None
         if isinstance(value, list):
             return [str(self.to_python(v)) for v in value]

+ 34 - 0
netbox/ipam/migrations/0090_iprange_host_indexes.py

@@ -0,0 +1,34 @@
+import django.db.models.functions.comparison
+from django.db import migrations, models
+
+import ipam.fields
+import ipam.lookups
+
+
+class Migration(migrations.Migration):
+    dependencies = [
+        ('ipam', '0089_default_ordering_indexes'),
+    ]
+
+    operations = [
+        migrations.AddIndex(
+            model_name='iprange',
+            index=models.Index(
+                django.db.models.functions.comparison.Cast(
+                    ipam.lookups.Host('start_address'),
+                    output_field=ipam.fields.IPAddressField(),
+                ),
+                name='ipam_iprange_start_host',
+            ),
+        ),
+        migrations.AddIndex(
+            model_name='iprange',
+            index=models.Index(
+                django.db.models.functions.comparison.Cast(
+                    ipam.lookups.Host('end_address'),
+                    output_field=ipam.fields.IPAddressField(),
+                ),
+                name='ipam_iprange_end_host',
+            ),
+        ),
+    ]

+ 103 - 28
netbox/ipam/models/ip.py

@@ -16,6 +16,16 @@ from ipam.fields import IPAddressField, IPNetworkField
 from ipam.lookups import Host
 from ipam.managers import IPAddressManager
 from ipam.querysets import PrefixQuerySet
+from ipam.utils import (
+    count_distinct_ip_hosts,
+    count_distinct_ip_hosts_outside_intervals,
+    count_ip_intervals,
+    filter_ip_hosts_between,
+    find_first_available_ip,
+    get_iprange_intervals,
+    get_usable_ip_bounds,
+    merge_ip_intervals,
+)
 from ipam.validators import DNSValidator
 from netbox.config import get_config
 from netbox.models import OrganizationalModel, PrimaryModel
@@ -479,14 +489,49 @@ class Prefix(ContactsMixin, GetAvailablePrefixesMixin, CachedScopeMixin, Primary
 
         return available_ips
 
+    @property
+    def available_ip_count(self):
+        """
+        Return the number of available IPs without constructing the full IPSet.
+
+        Intended for summary display (e.g. the Prefix detail Addressing panel).
+        get_available_ips() remains available for callers that need the actual set.
+        """
+        first_ip, last_ip = get_usable_ip_bounds(self)
+        usable_size = int(last_ip) - int(first_ip) + 1
+
+        populated_intervals = merge_ip_intervals(
+            get_iprange_intervals(self.get_child_ranges(mark_populated=True), first_ip, last_ip)
+        )
+        populated_count = count_ip_intervals(populated_intervals)
+
+        # Populated ranges already cover the usable span; skip the child-IP count entirely.
+        if populated_count >= usable_size:
+            return 0
+
+        child_ips = filter_ip_hosts_between(self.get_child_ips(), first_ip, last_ip)
+        child_ip_count = count_distinct_ip_hosts_outside_intervals(
+            child_ips, populated_intervals, self.family,
+        )
+
+        return max(usable_size - populated_count - child_ip_count, 0)
+
     def get_first_available_ip(self):
         """
         Return the first available IP within the prefix (or None).
         """
-        available_ips = self.get_available_ips()
-        if not available_ips:
+        first_ip, last_ip = get_usable_ip_bounds(self)
+
+        first_available_ip = find_first_available_ip(
+            first_ip=first_ip,
+            last_ip=last_ip,
+            ip_queryset=self.get_child_ips(),
+            range_queryset=self.get_child_ranges(mark_populated=True),
+        )
+
+        if first_available_ip is None:
             return None
-        return '{}/{}'.format(next(available_ips.__iter__()), self.prefix.prefixlen)
+        return f'{first_available_ip}/{self.prefix.prefixlen}'
 
     def get_utilization(self):
         """
@@ -504,17 +549,23 @@ class Prefix(ContactsMixin, GetAvailablePrefixesMixin, CachedScopeMixin, Primary
             child_prefixes = netaddr.IPSet([p.prefix for p in queryset])
             utilization = float(child_prefixes.size) / self.prefix.size * 100
         else:
-            # Compile an IPSet to avoid counting duplicate IPs
-            child_ips = netaddr.IPSet()
-            for iprange in self.get_child_ranges().filter(mark_utilized=True):
-                child_ips.add(iprange.range)
-            for ip in self.get_child_ips():
-                child_ips.add(ip.address.ip)
-
             prefix_size = self.prefix.size
             if self.prefix.version == 4 and self.prefix.prefixlen < 31 and not self.is_pool:
                 prefix_size -= 2
-            utilization = float(child_ips.size) / prefix_size * 100
+            utilized_intervals = merge_ip_intervals(
+                get_iprange_intervals(self.get_child_ranges(mark_utilized=True))
+            )
+            utilized_range_count = count_ip_intervals(utilized_intervals)
+
+            # Utilized ranges already saturate the prefix; skip the child-IP count.
+            if utilized_range_count >= prefix_size:
+                return 100
+
+            child_ip_count = count_distinct_ip_hosts_outside_intervals(
+                self.get_child_ips(), utilized_intervals, self.family,
+            )
+
+            utilization = float(utilized_range_count + child_ip_count) / prefix_size * 100
 
         return min(utilization, 100)
 
@@ -582,6 +633,16 @@ class IPRange(ContactsMixin, PrimaryModel):
 
     class Meta:
         ordering = (F('vrf').asc(nulls_first=True), 'start_address', 'pk')  # (vrf, start_address) may be non-unique
+        indexes = (
+            models.Index(
+                Cast(Host('start_address'), output_field=IPAddressField()),
+                name='ipam_iprange_start_host',
+            ),
+            models.Index(
+                Cast(Host('end_address'), output_field=IPAddressField()),
+                name='ipam_iprange_end_host',
+            ),
+        )
         verbose_name = _('IP range')
         verbose_name_plural = _('IP ranges')
 
@@ -713,10 +774,10 @@ class IPRange(ContactsMixin, PrimaryModel):
         """
         Return all IPAddresses within this IPRange and VRF.
         """
-        return IPAddress.objects.filter(
-            address__gte=self.start_address,
-            address__lte=self.end_address,
-            vrf=self.vrf
+        return filter_ip_hosts_between(
+            IPAddress.objects.filter(vrf=self.vrf),
+            self.start_address.ip,
+            self.end_address.ip,
         )
 
     def get_available_ips(self):
@@ -731,18 +792,36 @@ class IPRange(ContactsMixin, PrimaryModel):
 
         return netaddr.IPSet(range) - child_ips
 
-    @cached_property
+    @property
+    def available_ip_count(self):
+        """
+        Return the number of available IPs without constructing the full IPSet.
+        """
+        if self.mark_populated:
+            return 0
+
+        return max(self.size - count_distinct_ip_hosts(self.get_child_ips()), 0)
+
+    @property
     def first_available_ip(self):
         """
         Return the first available IP within the range (or None).
         """
-        available_ips = self.get_available_ips()
-        if not available_ips:
+        if self.mark_populated:
             return None
 
-        return '{}/{}'.format(next(available_ips.__iter__()), self.start_address.prefixlen)
+        first_available_ip = find_first_available_ip(
+            first_ip=self.start_address.ip,
+            last_ip=self.end_address.ip,
+            ip_queryset=self.get_child_ips(),
+        )
 
-    @cached_property
+        if first_available_ip is None:
+            return None
+
+        return f'{first_available_ip}/{self.start_address.prefixlen}'
+
+    @property
     def utilization(self):
         """
         Determine the utilization of the range and return it as a percentage.
@@ -750,11 +829,7 @@ class IPRange(ContactsMixin, PrimaryModel):
         if self.mark_utilized:
             return 100
 
-        # Compile an IPSet to avoid counting duplicate IPs
-        child_count = netaddr.IPSet([
-            ip.address.ip for ip in self.get_child_ips()
-        ]).size
-
+        child_count = count_distinct_ip_hosts(self.get_child_ips())
         return min(float(child_count) / self.size * 100, 100)
 
 
@@ -948,10 +1023,10 @@ class IPAddress(ContactsMixin, PrimaryModel):
 
             # Disallow the creation of IPAddresses within an IPRange with mark_populated=True
             parent_range_qs = IPRange.objects.filter(
-                start_address__lte=self.address,
-                end_address__gte=self.address,
+                start_address__host__inet__lte=self.address.ip,
+                end_address__host__inet__gte=self.address.ip,
                 vrf=self.vrf,
-                mark_populated=True
+                mark_populated=True,
             )
             if not self.pk and (parent_range := parent_range_qs.first()):
                 raise ValidationError({

+ 29 - 0
netbox/ipam/tests/test_fields.py

@@ -0,0 +1,29 @@
+from django.test import TestCase
+from netaddr import IPAddress
+
+from ipam.fields import IPAddressField, IPNetworkField
+
+
+class BaseIPFieldTestCase(TestCase):
+    """
+    Regression coverage for BaseIPField.get_prep_value() — zero addresses such as
+    0.0.0.0 and :: are valid hosts and must not be treated as empty values.
+    """
+
+    def test_get_prep_value_accepts_ipv4_zero_address(self):
+        # Regression: 0.0.0.0 is a valid host, not an empty value.
+        self.assertEqual(IPAddressField().get_prep_value(IPAddress('0.0.0.0')), '0.0.0.0')
+
+    def test_get_prep_value_accepts_ipv6_zero_address(self):
+        # Regression: :: is a valid host, not an empty value.
+        self.assertEqual(IPAddressField().get_prep_value(IPAddress('::')), '::')
+
+    def test_get_prep_value_passes_through_empty(self):
+        self.assertIsNone(IPNetworkField().get_prep_value(None))
+        self.assertIsNone(IPAddressField().get_prep_value(''))
+
+    def test_get_prep_value_preserves_raw_zero_as_empty(self):
+        # Raw int 0 is preserved as the legacy "empty" sentinel; Django's ORM never
+        # passes it directly, but the previous `not value` check returned None for it.
+        self.assertIsNone(IPAddressField().get_prep_value(0))
+        self.assertIsNone(IPNetworkField().get_prep_value(0))

+ 470 - 0
netbox/ipam/tests/test_models.py

@@ -6,6 +6,7 @@ from netaddr import IPNetwork, IPSet
 from dcim.models import Site, SiteGroup
 from ipam.choices import *
 from ipam.models import *
+from ipam.utils import rebuild_prefixes
 from utilities.data import string_to_ranges
 
 
@@ -164,9 +165,102 @@ class TestIPRange(TestCase):
         with self.assertRaisesMessage(ValidationError, 'Defined addresses overlap'):
             iprange.clean()
 
+    def test_get_child_ips_host_portion(self):
+        iprange = IPRange.objects.create(
+            start_address=IPNetwork('10.0.0.2/24'),
+            end_address=IPNetwork('10.0.0.254/24'),
+        )
+
+        ip1 = IPAddress.objects.create(address=IPNetwork('10.0.0.2/32'))
+        ip2 = IPAddress.objects.create(address=IPNetwork('10.0.0.3/24'))
+
+        self.assertEqual(set(iprange.get_child_ips()), {ip1, ip2})
+
+    def test_available_ip_count(self):
+        iprange = IPRange.objects.create(
+            start_address=IPNetwork('192.0.2.10/24'),
+            end_address=IPNetwork('192.0.2.19/24'),
+        )
+
+        IPAddress.objects.create(address=IPNetwork('192.0.2.12/24'))
+
+        self.assertEqual(iprange.available_ip_count, 9)
+
+    def test_available_ip_count_distinct_hosts(self):
+        iprange = IPRange.objects.create(
+            start_address=IPNetwork('192.0.2.10/24'),
+            end_address=IPNetwork('192.0.2.19/24'),
+        )
+
+        # Two rows for .10 (different masks) must dedupe to a single occupied host.
+        IPAddress.objects.bulk_create((
+            IPAddress(address=IPNetwork('192.0.2.10/24')),
+            IPAddress(address=IPNetwork('192.0.2.10/32')),
+            IPAddress(address=IPNetwork('192.0.2.11/24')),
+        ))
+
+        self.assertEqual(iprange.available_ip_count, 8)
+
+    def test_available_ip_count_vrf(self):
+        vrf1 = VRF.objects.create(name='VRF 1')
+        vrf2 = VRF.objects.create(name='VRF 2')
+
+        iprange = IPRange.objects.create(
+            start_address=IPNetwork('192.0.2.10/24'),
+            end_address=IPNetwork('192.0.2.19/24'),
+            vrf=vrf1,
+        )
+
+        IPAddress.objects.create(address=IPNetwork('192.0.2.12/24'), vrf=vrf1)
+        IPAddress.objects.create(address=IPNetwork('192.0.2.13/24'), vrf=vrf2)
+
+        # Only the VRF 1 IP should count.
+        self.assertEqual(iprange.available_ip_count, 9)
+
+    def test_first_available_ip_full(self):
+        iprange = IPRange.objects.create(
+            start_address=IPNetwork('192.0.2.10/24'),
+            end_address=IPNetwork('192.0.2.11/24'),
+        )
+
+        IPAddress.objects.create(address=IPNetwork('192.0.2.10/24'))
+        IPAddress.objects.create(address=IPNetwork('192.0.2.11/24'))
+
+        self.assertIsNone(iprange.first_available_ip)
+
+    def test_first_available_ip_ipv6(self):
+        iprange = IPRange.objects.create(
+            start_address=IPNetwork('::/126'),
+            end_address=IPNetwork('::3/126'),
+        )
+
+        self.assertEqual(iprange.first_available_ip, '::/126')
+
+    def test_utilization_distinct_hosts(self):
+        iprange = IPRange.objects.create(
+            start_address=IPNetwork('192.0.2.10/24'),
+            end_address=IPNetwork('192.0.2.19/24'),
+        )
+
+        IPAddress.objects.bulk_create((
+            IPAddress(address=IPNetwork('192.0.2.10/24')),
+            IPAddress(address=IPNetwork('192.0.2.10/32')),
+            IPAddress(address=IPNetwork('192.0.2.11/24')),
+        ))
+
+        # Two distinct hosts in a 10-address range.
+        self.assertEqual(iprange.utilization, 2 / 10 * 100)
+
 
 class TestPrefix(TestCase):
 
+    def assertAvailableIPCountMatchesIPSet(self, prefix):
+        """
+        Confirm that the optimized available_ip_count property matches the legacy
+        IPSet-based get_available_ips().size for the supplied prefix.
+        """
+        self.assertEqual(prefix.available_ip_count, prefix.get_available_ips().size)
+
     def test_family_string(self):
         # Test property when prefix is a string
         prefix = Prefix(prefix='10.0.0.0/8')
@@ -329,6 +423,202 @@ class TestPrefix(TestCase):
 
         self.assertEqual(available_ips, missing_ips)
 
+    def test_available_ip_count_distinct_hosts(self):
+        prefix = Prefix.objects.create(
+            prefix=IPNetwork('192.0.2.0/29'),
+            status=PrefixStatusChoices.STATUS_ACTIVE,
+        )
+
+        IPAddress.objects.bulk_create((
+            IPAddress(address=IPNetwork('192.0.2.1/29')),
+            IPAddress(address=IPNetwork('192.0.2.1/32')),
+            IPAddress(address=IPNetwork('192.0.2.3/29')),
+        ))
+
+        # Usable hosts in /29: 6. Two unique hosts occupy .1 and .3.
+        self.assertEqual(prefix.available_ip_count, 4)
+        self.assertAvailableIPCountMatchesIPSet(prefix)
+
+    def test_available_ip_count_populated_ranges(self):
+        prefix = Prefix.objects.create(
+            prefix=IPNetwork('192.0.2.0/29'),
+            status=PrefixStatusChoices.STATUS_ACTIVE,
+        )
+
+        IPAddress.objects.bulk_create((
+            IPAddress(address=IPNetwork('192.0.2.1/29')),
+            IPAddress(address=IPNetwork('192.0.2.3/29')),  # Inside the populated range; not double-counted.
+        ))
+
+        IPRange.objects.create(
+            start_address=IPNetwork('192.0.2.3/29'),
+            end_address=IPNetwork('192.0.2.4/29'),
+            mark_populated=True,
+        )
+
+        # Usable 6, one IP outside the range at .1, populated range covers .3-.4.
+        # Available: .2, .5, .6.
+        self.assertEqual(prefix.available_ip_count, 3)
+        self.assertAvailableIPCountMatchesIPSet(prefix)
+
+    def test_available_ip_count_ipv4_pool(self):
+        prefix = Prefix.objects.create(
+            prefix=IPNetwork('192.0.2.0/30'),
+            status=PrefixStatusChoices.STATUS_ACTIVE,
+            is_pool=True,
+        )
+
+        self.assertEqual(prefix.available_ip_count, 4)
+        self.assertAvailableIPCountMatchesIPSet(prefix)
+
+    def test_available_ip_count_ipv4_non_pool(self):
+        prefix = Prefix.objects.create(
+            prefix=IPNetwork('192.0.2.0/30'),
+            status=PrefixStatusChoices.STATUS_ACTIVE,
+            is_pool=False,
+        )
+
+        self.assertEqual(prefix.available_ip_count, 2)
+        self.assertAvailableIPCountMatchesIPSet(prefix)
+
+    def test_available_ip_count_ipv4_non_pool_ignores_unusable_ips(self):
+        prefix = Prefix.objects.create(
+            prefix=IPNetwork('192.0.2.0/30'),
+            status=PrefixStatusChoices.STATUS_ACTIVE,
+        )
+
+        # Network and broadcast addresses are unusable for non-pool IPv4 prefixes;
+        # an IP assigned to either must not reduce the available count.
+        IPAddress.objects.create(address=IPNetwork('192.0.2.0/30'))
+        IPAddress.objects.create(address=IPNetwork('192.0.2.3/30'))
+
+        self.assertEqual(prefix.available_ip_count, 2)
+        self.assertEqual(prefix.get_first_available_ip(), '192.0.2.1/30')
+        self.assertAvailableIPCountMatchesIPSet(prefix)
+
+    def test_available_ip_count_ipv6(self):
+        prefix = Prefix.objects.create(
+            prefix=IPNetwork('2001:db8::/126'),
+            status=PrefixStatusChoices.STATUS_ACTIVE,
+        )
+
+        # /126 has 4 addresses; normal IPv6 prefix excludes the first.
+        self.assertEqual(prefix.available_ip_count, 3)
+        self.assertAvailableIPCountMatchesIPSet(prefix)
+
+    def test_available_ip_count_ipv6_ignores_subnet_router_anycast(self):
+        prefix = Prefix.objects.create(
+            prefix=IPNetwork('2001:db8::/126'),
+            status=PrefixStatusChoices.STATUS_ACTIVE,
+        )
+
+        # The subnet-router anycast (::) address is unusable for normal IPv6 prefixes;
+        # an IP assigned there must not reduce the available count.
+        IPAddress.objects.create(address=IPNetwork('2001:db8::/126'))
+
+        self.assertEqual(prefix.available_ip_count, 3)
+        self.assertEqual(prefix.get_first_available_ip(), '2001:db8::1/126')
+        self.assertAvailableIPCountMatchesIPSet(prefix)
+
+    def test_available_ip_count_ipv6_127(self):
+        prefix = Prefix.objects.create(
+            prefix=IPNetwork('2001:db8::/127'),
+            status=PrefixStatusChoices.STATUS_ACTIVE,
+        )
+
+        self.assertEqual(prefix.available_ip_count, 2)
+        self.assertAvailableIPCountMatchesIPSet(prefix)
+
+    def test_available_ip_count_ipv6_populated_range(self):
+        prefix = Prefix.objects.create(
+            prefix=IPNetwork('2001:db8::/126'),
+            status=PrefixStatusChoices.STATUS_ACTIVE,
+        )
+
+        IPRange.objects.create(
+            start_address=IPNetwork('2001:db8::1/126'),
+            end_address=IPNetwork('2001:db8::2/126'),
+            mark_populated=True,
+        )
+
+        # Usable IPv6 hosts in /126: ::1, ::2, ::3. Populated: ::1-::2.
+        self.assertEqual(prefix.available_ip_count, 1)
+        self.assertEqual(prefix.get_first_available_ip(), '2001:db8::3/126')
+        self.assertAvailableIPCountMatchesIPSet(prefix)
+
+    def test_available_ip_count_overlapping_ranges(self):
+        prefix = Prefix.objects.create(
+            prefix=IPNetwork('192.0.2.0/29'),
+            status=PrefixStatusChoices.STATUS_ACTIVE,
+        )
+
+        IPRange.objects.create(
+            start_address=IPNetwork('192.0.2.1/29'),
+            end_address=IPNetwork('192.0.2.3/29'),
+            mark_populated=True,
+        )
+        IPRange.objects.create(
+            start_address=IPNetwork('192.0.2.2/29'),
+            end_address=IPNetwork('192.0.2.4/29'),
+            mark_populated=True,
+        )
+
+        # Usable hosts: .1-.6 => 6. Populated union: .1-.4 => 4. Available: .5-.6 => 2.
+        self.assertEqual(prefix.available_ip_count, 2)
+        self.assertEqual(prefix.get_first_available_ip(), '192.0.2.5/29')
+        self.assertAvailableIPCountMatchesIPSet(prefix)
+
+    def test_available_ip_count_vrf(self):
+        vrf1 = VRF.objects.create(name='VRF 1')
+        vrf2 = VRF.objects.create(name='VRF 2')
+
+        prefix = Prefix.objects.create(
+            prefix=IPNetwork('192.0.2.0/29'),
+            vrf=vrf1,
+            status=PrefixStatusChoices.STATUS_ACTIVE,
+        )
+
+        IPAddress.objects.create(address=IPNetwork('192.0.2.1/29'), vrf=vrf1)
+        IPAddress.objects.create(address=IPNetwork('192.0.2.2/29'), vrf=vrf2)
+
+        # Usable .1-.6 => 6. Only the VRF 1 IP should count.
+        self.assertEqual(prefix.available_ip_count, 5)
+        self.assertAvailableIPCountMatchesIPSet(prefix)
+
+    def test_available_ip_count_fully_populated(self):
+        prefix = Prefix.objects.create(
+            prefix=IPNetwork('192.0.2.0/30'),
+            status=PrefixStatusChoices.STATUS_ACTIVE,
+        )
+
+        # Populated range covers every usable address (.1-.2 in a non-pool /30).
+        IPRange.objects.create(
+            start_address=IPNetwork('192.0.2.1/30'),
+            end_address=IPNetwork('192.0.2.2/30'),
+            mark_populated=True,
+        )
+
+        # Exercises the early-return paths that skip the child-IP count and
+        # the host-stream iterator entirely.
+        self.assertEqual(prefix.available_ip_count, 0)
+        self.assertIsNone(prefix.get_first_available_ip())
+        self.assertAvailableIPCountMatchesIPSet(prefix)
+
+    def test_available_ip_count_container(self):
+        prefix = Prefix.objects.create(
+            prefix=IPNetwork('192.0.2.0/24'),
+            status=PrefixStatusChoices.STATUS_CONTAINER,
+        )
+
+        # A child prefix exists but does not reduce available_ip_count.
+        Prefix.objects.create(
+            prefix=IPNetwork('192.0.2.0/26'),
+            status=PrefixStatusChoices.STATUS_ACTIVE,
+        )
+
+        self.assertEqual(prefix.available_ip_count, 254)
+        self.assertAvailableIPCountMatchesIPSet(prefix)
+
     def test_get_first_available_prefix(self):
 
         prefixes = Prefix.objects.bulk_create((
@@ -367,6 +657,42 @@ class TestPrefix(TestCase):
         parent_prefix = Prefix.objects.create(prefix=IPNetwork('2001:db8:500:5::/127'))
         self.assertEqual(parent_prefix.get_first_available_ip(), '2001:db8:500:5::/127')
 
+    def test_get_first_available_ip_ipv6_zero_address(self):
+        prefix = Prefix.objects.create(
+            prefix=IPNetwork('::/126'),
+            status=PrefixStatusChoices.STATUS_ACTIVE,
+        )
+
+        # Normal IPv6 prefixes exclude the subnet-router anycast address ::.
+        self.assertEqual(prefix.get_first_available_ip(), '::1/126')
+
+    def test_get_first_available_ip_populated_ranges(self):
+        prefix = Prefix.objects.create(
+            prefix=IPNetwork('192.0.2.0/29'),
+            status=PrefixStatusChoices.STATUS_ACTIVE,
+        )
+
+        IPAddress.objects.create(address=IPNetwork('192.0.2.1/29'))
+
+        IPRange.objects.create(
+            start_address=IPNetwork('192.0.2.2/29'),
+            end_address=IPNetwork('192.0.2.3/29'),
+            mark_populated=True,
+        )
+
+        self.assertEqual(prefix.get_first_available_ip(), '192.0.2.4/29')
+
+    def test_get_first_available_ip_full(self):
+        prefix = Prefix.objects.create(
+            prefix=IPNetwork('192.0.2.0/30'),
+            status=PrefixStatusChoices.STATUS_ACTIVE,
+        )
+
+        IPAddress.objects.create(address=IPNetwork('192.0.2.1/30'))
+        IPAddress.objects.create(address=IPNetwork('192.0.2.2/30'))
+
+        self.assertIsNone(prefix.get_first_available_ip())
+
     def test_get_utilization_container(self):
         prefixes = (
             Prefix(prefix=IPNetwork('10.0.0.0/24'), status=PrefixStatusChoices.STATUS_CONTAINER),
@@ -396,6 +722,94 @@ class TestPrefix(TestCase):
         )
         self.assertEqual(prefix.get_utilization(), 64 / 254 * 100)  # ~25% utilization
 
+    def test_get_utilization_distinct_hosts(self):
+        prefix = Prefix.objects.create(
+            prefix=IPNetwork('192.0.2.0/24'),
+            status=PrefixStatusChoices.STATUS_ACTIVE,
+        )
+
+        IPAddress.objects.bulk_create((
+            IPAddress(address=IPNetwork('192.0.2.10/24')),
+            IPAddress(address=IPNetwork('192.0.2.10/32')),
+            IPAddress(address=IPNetwork('192.0.2.11/24')),
+        ))
+
+        # Two unique occupied hosts over 254 usable IPv4 addresses.
+        self.assertEqual(prefix.get_utilization(), 2 / 254 * 100)
+
+    def test_get_utilization_utilized_ranges(self):
+        prefix = Prefix.objects.create(
+            prefix=IPNetwork('192.0.2.0/24'),
+            status=PrefixStatusChoices.STATUS_ACTIVE,
+        )
+
+        IPRange.objects.create(
+            start_address=IPNetwork('192.0.2.10/24'),
+            end_address=IPNetwork('192.0.2.19/24'),
+            mark_utilized=True,
+        )
+
+        IPAddress.objects.bulk_create((
+            IPAddress(address=IPNetwork('192.0.2.1/24')),
+            IPAddress(address=IPNetwork('192.0.2.10/24')),
+            IPAddress(address=IPNetwork('192.0.2.11/24')),
+            IPAddress(address=IPNetwork('192.0.2.20/24')),
+        ))
+
+        # Utilized range contributes 10 hosts; IPs inside the range are not double-counted.
+        # Outside IPs: .1 and .20 => 2 more.
+        self.assertEqual(prefix.get_utilization(), 12 / 254 * 100)
+
+    def test_get_utilization_overlapping_utilized_ranges(self):
+        prefix = Prefix.objects.create(
+            prefix=IPNetwork('192.0.2.0/24'),
+            status=PrefixStatusChoices.STATUS_ACTIVE,
+        )
+
+        IPRange.objects.create(
+            start_address=IPNetwork('192.0.2.10/24'),
+            end_address=IPNetwork('192.0.2.19/24'),
+            mark_utilized=True,
+        )
+        IPRange.objects.create(
+            start_address=IPNetwork('192.0.2.15/24'),
+            end_address=IPNetwork('192.0.2.24/24'),
+            mark_utilized=True,
+        )
+
+        # Union is .10-.24 => 15 hosts, not 20.
+        self.assertEqual(prefix.get_utilization(), 15 / 254 * 100)
+
+    def test_get_utilization_fully_utilized_range(self):
+        prefix = Prefix.objects.create(
+            prefix=IPNetwork('192.0.2.0/24'),
+            status=PrefixStatusChoices.STATUS_ACTIVE,
+        )
+
+        # Utilized range covers every usable host (.1-.254 in a non-pool /24).
+        IPRange.objects.create(
+            start_address=IPNetwork('192.0.2.1/24'),
+            end_address=IPNetwork('192.0.2.254/24'),
+            mark_utilized=True,
+        )
+
+        # Exercises the early-return path that skips the child-IP count entirely.
+        self.assertEqual(prefix.get_utilization(), 100)
+
+    def test_get_utilization_ipv6_utilized_range(self):
+        prefix = Prefix.objects.create(
+            prefix=IPNetwork('2001:db8::/126'),
+            status=PrefixStatusChoices.STATUS_ACTIVE,
+        )
+
+        IPRange.objects.create(
+            start_address=IPNetwork('2001:db8::1/126'),
+            end_address=IPNetwork('2001:db8::2/126'),
+            mark_utilized=True,
+        )
+
+        self.assertEqual(prefix.get_utilization(), 2 / 4 * 100)
+
     #
     # Uniqueness enforcement tests
     #
@@ -621,6 +1035,48 @@ class TestPrefixHierarchy(TestCase):
         self.assertEqual(prefixes[3]._depth, 2)
         self.assertEqual(prefixes[3]._children, 0)
 
+    def test_rebuild_prefixes_accepts_queryset(self):
+        # Wipe the depth/children precomputed by setUpTestData so we can observe the rebuild.
+        Prefix.objects.update(_depth=0, _children=0)
+
+        rebuild_prefixes(Prefix.objects.filter(vrf__isnull=True))
+
+        top = Prefix.objects.get(prefix='10.0.0.0/8')
+        mid = Prefix.objects.get(prefix='10.0.0.0/16')
+        leaf = Prefix.objects.get(prefix='10.0.0.0/24')
+        self.assertEqual((top._depth, top._children), (0, 2))
+        self.assertEqual((mid._depth, mid._children), (1, 1))
+        self.assertEqual((leaf._depth, leaf._children), (2, 0))
+
+    def test_rebuild_prefixes_accepts_vrf_identifier(self):
+        # Backward-compatible signature: None means "global table".
+        Prefix.objects.update(_depth=0, _children=0)
+
+        rebuild_prefixes(None)
+
+        top = Prefix.objects.get(prefix='10.0.0.0/8')
+        mid = Prefix.objects.get(prefix='10.0.0.0/16')
+        leaf = Prefix.objects.get(prefix='10.0.0.0/24')
+        self.assertEqual((top._depth, top._children), (0, 2))
+        self.assertEqual((mid._depth, mid._children), (1, 1))
+        self.assertEqual((leaf._depth, leaf._children), (2, 0))
+
+    def test_rebuild_prefixes_accepts_vrf_pk(self):
+        # Backward-compatible signature: a VRF pk filters to that VRF's prefixes.
+        vrf = VRF.objects.create(name='VRF 1')
+        Prefix.objects.create(prefix=IPNetwork('192.0.2.0/24'), vrf=vrf)
+        Prefix.objects.create(prefix=IPNetwork('192.0.2.0/25'), vrf=vrf)
+
+        # Reset depth/children so the rebuild has something to restore.
+        Prefix.objects.filter(vrf=vrf).update(_depth=0, _children=0)
+
+        rebuild_prefixes(vrf.pk)
+
+        parent = Prefix.objects.get(prefix='192.0.2.0/24', vrf=vrf)
+        child = Prefix.objects.get(prefix='192.0.2.0/25', vrf=vrf)
+        self.assertEqual((parent._depth, parent._children), (0, 1))
+        self.assertEqual((child._depth, child._children), (1, 0))
+
 
 class TestIPAddress(TestCase):
 
@@ -716,6 +1172,20 @@ class TestIPAddress(TestCase):
         with self.assertRaisesMessage(ValidationError, 'Cannot create IP address'):
             ipaddress.clean()
 
+    def test_populated_range_blocks_ip_with_different_mask(self):
+        # The populated-range check compares by host portion, so a different mask
+        # must not let an IPAddress slip past validation.
+        IPRange.objects.create(
+            start_address=IPNetwork('10.0.0.2/24'),
+            end_address=IPNetwork('10.0.0.254/24'),
+            mark_populated=True,
+        )
+
+        ip = IPAddress(address=IPNetwork('10.0.0.2/32'))
+
+        with self.assertRaises(ValidationError):
+            ip.full_clean()
+
 
 class TestVLANGroup(TestCase):
 

+ 29 - 0
netbox/ipam/tests/test_utils.py

@@ -0,0 +1,29 @@
+from django.test import SimpleTestCase
+from netaddr import IPAddress, IPNetwork
+
+from ipam.utils import _to_ipaddress
+
+
+class ToIPAddressTestCase(SimpleTestCase):
+    """
+    Coverage for the internal _to_ipaddress() value normalizer.
+    """
+
+    def test_passes_through_ipaddress(self):
+        ip = IPAddress('192.0.2.1')
+        self.assertIs(_to_ipaddress(ip), ip)
+
+    def test_extracts_ip_from_ipnetwork(self):
+        self.assertEqual(_to_ipaddress(IPNetwork('192.0.2.1/24')), IPAddress('192.0.2.1'))
+
+    def test_parses_plain_ipv4_host_string(self):
+        # Fast path: IPAddress() succeeds, IPNetwork() construction is skipped.
+        self.assertEqual(_to_ipaddress('192.0.2.1'), IPAddress('192.0.2.1'))
+
+    def test_parses_plain_ipv6_host_string(self):
+        self.assertEqual(_to_ipaddress('2001:db8::1'), IPAddress('2001:db8::1'))
+
+    def test_falls_back_to_ipnetwork_for_cidr_string(self):
+        # IPAddress('192.0.2.0/24') raises AddrFormatError; the fallback parses
+        # the value as an IPNetwork and returns its host portion.
+        self.assertEqual(_to_ipaddress('192.0.2.0/24'), IPAddress('192.0.2.0'))

+ 257 - 29
netbox/ipam/utils.py

@@ -1,17 +1,31 @@
+import heapq
 from dataclasses import dataclass
 
 import netaddr
+from django.apps import apps
+from django.db.models import Q, QuerySet
+from django.db.models.functions import Cast
 from django.utils.translation import gettext_lazy as _
 
 from .constants import *
-from .models import VLAN, Prefix
+from .fields import IPAddressField
+from .lookups import Host
 
 __all__ = (
     'AvailableIPSpace',
     'add_available_vlans',
     'add_requested_prefixes',
+    'annotate_host_address',
     'annotate_ip_space',
+    'count_distinct_ip_hosts',
+    'count_distinct_ip_hosts_outside_intervals',
+    'count_ip_intervals',
+    'filter_ip_hosts_between',
+    'find_first_available_ip',
+    'get_iprange_intervals',
     'get_next_available_prefix',
+    'get_usable_ip_bounds',
+    'merge_ip_intervals',
     'rebuild_prefixes',
 )
 
@@ -33,13 +47,47 @@ class AvailableIPSpace:
         return _('Many IPs available')
 
 
+def get_usable_ip_bounds(prefix):
+    """
+    Return the first and last IPs considered usable for available-IP calculations.
+
+    Pools and IPv4 /31-/32 / IPv6 /127-/128 are fully usable; otherwise IPv4 excludes
+    network and broadcast, IPv6 excludes the subnet-router anycast address.
+    """
+    family = prefix.family
+    first = prefix.prefix.first
+    last = prefix.prefix.last
+    mask_length = prefix.mask_length
+
+    if (
+        prefix.is_pool
+        or (family == 4 and mask_length >= 31)
+        or (family == 6 and mask_length >= 127)
+    ):
+        return (
+            netaddr.IPAddress(first, version=family),
+            netaddr.IPAddress(last, version=family),
+        )
+
+    if family == 4:
+        return (
+            netaddr.IPAddress(first + 1, version=family),
+            netaddr.IPAddress(last - 1, version=family),
+        )
+
+    return (
+        netaddr.IPAddress(first + 1, version=family),
+        netaddr.IPAddress(last, version=family),
+    )
+
+
 def add_requested_prefixes(parent, prefix_list, show_available=True, show_assigned=True):
     """
     Return a list of requested prefixes using show_available, show_assigned filters. If available prefixes are
     requested, create fake Prefix objects for all unallocated space within a prefix.
 
     :param parent: Parent Prefix instance
-    :param prefix_list: Child prefixes list
+    :param prefix_list: Child prefixes list (or queryset)
     :param show_available: Include available prefixes.
     :param show_assigned: Show assigned prefixes.
     """
@@ -47,13 +95,16 @@ def add_requested_prefixes(parent, prefix_list, show_available=True, show_assign
 
     # Add available prefixes to the table if requested
     if prefix_list and show_available:
+        # Infer the Prefix model from the queryset/list so this helper does not need
+        # a model import at module scope.
+        prefix_model = getattr(prefix_list, 'model', None) or prefix_list[0].__class__
 
         # Find all unallocated space, add fake Prefix objects to child_prefixes.
         # IMPORTANT: These are unsaved Prefix instances (pk=None). If this is ever changed to use
         # saved Prefix instances with real pks, bulk delete will fail for mixed-type selections
         # due to single-model form validation. See: https://github.com/netbox-community/netbox/issues/21176
         available_prefixes = netaddr.IPSet(parent) ^ netaddr.IPSet([p.prefix for p in prefix_list])
-        available_prefixes = [Prefix(prefix=p, status=None) for p in available_prefixes.iter_cidrs()]
+        available_prefixes = [prefix_model(prefix=p, status=None) for p in available_prefixes.iter_cidrs()]
         child_prefixes = child_prefixes + available_prefixes
 
     # Add assigned prefixes to the table if requested
@@ -78,22 +129,7 @@ def annotate_ip_space(prefix):
     records = sorted(records, key=lambda x: x[0])
 
     # Determine the first & last valid IP addresses in the prefix
-    if (
-        prefix.is_pool
-        or (prefix.family == 4 and prefix.mask_length >= 31)
-        or (prefix.family == 6 and prefix.mask_length >= 127)
-    ):
-        # Pool, IPv4 /31-/32 or IPv6 /127-/128 sets are fully usable
-        first_ip_in_prefix = netaddr.IPAddress(prefix.prefix.first)
-        last_ip_in_prefix = netaddr.IPAddress(prefix.prefix.last)
-    elif prefix.family == 4:
-        # Ignore the network and broadcast addresses for non-pool IPv4 prefixes larger than /31
-        first_ip_in_prefix = netaddr.IPAddress(prefix.prefix.first + 1)
-        last_ip_in_prefix = netaddr.IPAddress(prefix.prefix.last - 1)
-    else:
-        # For IPv6 prefixes, omit the Subnet-Router anycast address (RFC 4291)
-        first_ip_in_prefix = netaddr.IPAddress(prefix.prefix.first + 1)
-        last_ip_in_prefix = netaddr.IPAddress(prefix.prefix.last)
+    first_ip_in_prefix, last_ip_in_prefix = get_usable_ip_bounds(prefix)
 
     if not records:
         return [
@@ -195,15 +231,25 @@ def add_available_vlans(vlans, vlan_group):
         new_vlans.extend(available_vlans_from_range(vlans, vlan_group, vid_range))
 
     vlans = list(vlans) + new_vlans
-    vlans.sort(key=lambda v: v.vid if type(v) is VLAN else v['vid'])
+    vlans.sort(key=lambda v: v['vid'] if isinstance(v, dict) else v.vid)
 
     return vlans
 
 
-def rebuild_prefixes(vrf):
+def rebuild_prefixes(prefixes):
     """
-    Rebuild the prefix hierarchy for all prefixes in the specified VRF (or global table).
+    Rebuild the prefix hierarchy for the supplied Prefix queryset.
+
+    For backward compatibility with callers that pass a VRF identifier (None for the
+    global table, or a VRF pk), a non-QuerySet argument is treated as a VRF filter.
     """
+    if isinstance(prefixes, QuerySet):
+        prefix_queryset = prefixes
+        prefix_model = prefixes.model
+    else:
+        prefix_model = apps.get_model('ipam', 'Prefix')
+        prefix_queryset = prefix_model.objects.filter(vrf=prefixes)
+
     def contains(parent, child):
         return child in parent and child != parent
 
@@ -219,10 +265,10 @@ def rebuild_prefixes(vrf):
 
     stack = []
     update_queue = []
-    prefixes = Prefix.objects.filter(vrf=vrf).values('pk', 'prefix')
+    prefixes = prefix_queryset.order_by('prefix', 'pk').values('pk', 'prefix')
 
-    # Iterate through all Prefixes in the VRF, growing and shrinking the stack as we go
-    for i, p in enumerate(prefixes):
+    # Iterate through all Prefixes in the table, growing and shrinking the stack as we go
+    for p in prefixes:
 
         # Grow the stack if this is a child of the most recent prefix
         if not stack or contains(stack[-1]['prefix'], p['prefix']):
@@ -239,13 +285,13 @@ def rebuild_prefixes(vrf):
                 node = stack.pop()
                 for pk in node['pk']:
                     update_queue.append(
-                        Prefix(pk=pk, _depth=len(stack), _children=node['children'])
+                        prefix_model(pk=pk, _depth=len(stack), _children=node['children'])
                     )
             push_to_stack(p)
 
         # Flush the update queue once it reaches 100 Prefixes
         if len(update_queue) >= 100:
-            Prefix.objects.bulk_update(update_queue, ['_depth', '_children'])
+            prefix_model.objects.bulk_update(update_queue, ['_depth', '_children'])
             update_queue = []
 
     # Clear out any prefixes remaining in the stack
@@ -253,11 +299,11 @@ def rebuild_prefixes(vrf):
         node = stack.pop()
         for pk in node['pk']:
             update_queue.append(
-                Prefix(pk=pk, _depth=len(stack), _children=node['children'])
+                prefix_model(pk=pk, _depth=len(stack), _children=node['children'])
             )
 
     # Final flush of any remaining Prefixes
-    Prefix.objects.bulk_update(update_queue, ['_depth', '_children'])
+    prefix_model.objects.bulk_update(update_queue, ['_depth', '_children'])
 
 
 def get_next_available_prefix(ipset, prefix_size):
@@ -270,3 +316,185 @@ def get_next_available_prefix(ipset, prefix_size):
             ipset.remove(allocated_prefix)
             return allocated_prefix
     return None
+
+
+#
+# Address-space helpers (used by Prefix and IPRange address-counting paths)
+#
+
+
+def _to_ipaddress(value):
+    """Normalize an IPAddressField/IPNetworkField value to a netaddr.IPAddress."""
+    if isinstance(value, netaddr.IPAddress):
+        return value
+    if isinstance(value, netaddr.IPNetwork):
+        return value.ip
+
+    # Plain host strings (e.g. '192.0.2.1') avoid allocating an IPNetwork.
+    # netaddr raises AddrFormatError for malformed input and ValueError for
+    # otherwise-valid CIDR strings; both fall through to the IPNetwork path.
+    text = str(value)
+    try:
+        return netaddr.IPAddress(text)
+    except (netaddr.AddrFormatError, ValueError):
+        return netaddr.IPNetwork(text).ip
+
+
+def annotate_host_address(queryset):
+    """Annotate an IPAddress queryset with its host address (mask ignored)."""
+    return queryset.order_by().annotate(
+        host_address=Cast(
+            Host('address'),
+            output_field=IPAddressField(),
+        )
+    )
+
+
+def filter_ip_hosts_between(queryset, first_ip, last_ip):
+    """Restrict an IPAddress queryset to hosts between first_ip and last_ip (mask ignored)."""
+    return queryset.filter(
+        address__host__inet__gte=first_ip,
+        address__host__inet__lte=last_ip,
+    )
+
+
+def count_distinct_ip_hosts(queryset):
+    """Count distinct host addresses in an IPAddress queryset (dedupes by host, not mask)."""
+    return annotate_host_address(queryset).values('host_address').distinct().count()
+
+
+def get_iprange_intervals(queryset, first_ip=None, last_ip=None):
+    """Return IPRange rows as integer intervals, optionally clipped to bounds."""
+    first_int = int(first_ip) if first_ip is not None else None
+    last_int = int(last_ip) if last_ip is not None else None
+
+    intervals = []
+
+    # order_by() clears the model's default ordering; merge_ip_intervals sorts later anyway.
+    for start_address, end_address in queryset.order_by().values_list(
+        'start_address', 'end_address'
+    ):
+        start_int = int(_to_ipaddress(start_address))
+        end_int = int(_to_ipaddress(end_address))
+
+        if first_int is not None:
+            if end_int < first_int:
+                continue
+            start_int = max(start_int, first_int)
+
+        if last_int is not None:
+            if start_int > last_int:
+                continue
+            end_int = min(end_int, last_int)
+
+        intervals.append((start_int, end_int))
+
+    return intervals
+
+
+def merge_ip_intervals(intervals):
+    """Return the union of integer IP intervals as a list of merged (start, end) tuples."""
+    if not intervals:
+        return []
+
+    intervals = sorted(intervals)
+    merged = []
+    current_start, current_end = intervals[0]
+
+    for start, end in intervals[1:]:
+        if start <= current_end + 1:
+            current_end = max(current_end, end)
+            continue
+
+        merged.append((current_start, current_end))
+        current_start, current_end = start, end
+
+    merged.append((current_start, current_end))
+    return merged
+
+
+def count_ip_intervals(intervals):
+    """Count addresses covered by the supplied intervals. Callers should pass merged intervals."""
+    return sum(end - start + 1 for start, end in intervals)
+
+
+def count_distinct_ip_hosts_outside_intervals(queryset, intervals, version):
+    """
+    Count distinct host addresses not covered by any of the supplied integer intervals.
+
+    Callers should pass merged intervals; overlapping inputs work correctly but bloat
+    the SQL exclusion predicate.
+    """
+    queryset = annotate_host_address(queryset)
+
+    if not intervals:
+        return queryset.values('host_address').distinct().count()
+
+    exclusion = Q()
+    for start, end in intervals:
+        exclusion |= Q(
+            host_address__gte=netaddr.IPAddress(start, version=version),
+            host_address__lte=netaddr.IPAddress(end, version=version),
+        )
+
+    return (
+        queryset
+        .exclude(exclusion)
+        .values('host_address')
+        .distinct()
+        .count()
+    )
+
+
+def _iter_distinct_ip_host_intervals(queryset):
+    """Yield distinct hosts from an IPAddress queryset as single-IP integer intervals, sorted."""
+    hosts = (
+        annotate_host_address(queryset)
+        .values_list('host_address', flat=True)
+        .distinct()
+        .order_by('host_address')
+        .iterator(chunk_size=5000)
+    )
+
+    for host in hosts:
+        host_int = int(_to_ipaddress(host))
+        yield host_int, host_int
+
+
+def find_first_available_ip(first_ip, last_ip, ip_queryset, range_queryset=None):
+    """Find the first available IP in [first_ip, last_ip] by streaming sorted hosts and merged ranges."""
+    first_int = int(first_ip)
+    last_int = int(last_ip)
+    version = first_ip.version
+
+    intervals = merge_ip_intervals(
+        get_iprange_intervals(range_queryset, first_ip, last_ip)
+    ) if range_queryset is not None else []
+
+    # Fast path: one merged occupied interval covers the entire usable span.
+    if intervals and intervals[0][0] <= first_int and intervals[0][1] >= last_int:
+        return None
+
+    candidate = first_int
+    ip_queryset = filter_ip_hosts_between(ip_queryset, first_ip, last_ip)
+
+    # Default tuple ordering compares (start, end) — ties on `start` are harmless
+    # because the sweep handles overlapping intervals.
+    for start, end in heapq.merge(
+        intervals,
+        _iter_distinct_ip_host_intervals(ip_queryset),
+    ):
+        if end < candidate:
+            continue
+
+        if start > candidate:
+            return netaddr.IPAddress(candidate, version=version)
+
+        candidate = max(candidate, end + 1)
+        if candidate > last_int:
+            return None
+
+    if candidate <= last_int:
+        return netaddr.IPAddress(candidate, version=version)
+
+    return None

+ 7 - 5
netbox/templates/ipam/iprange/ip_addresses.html

@@ -2,9 +2,11 @@
 {% load i18n %}
 
 {% block extra_controls %}
-  {% if perms.ipam.add_ipaddress and object.first_available_ip %}
-    <a href="{% url 'ipam:ipaddress_add' %}?address={{ object.first_available_ip }}&vrf={{ object.vrf.pk }}&tenant_group={{ object.tenant.group.pk }}&tenant={{ object.tenant.pk }}" class="btn btn-primary">
-        <i class="mdi mdi-plus-thick" aria-hidden="true"></i> {% trans "Add IP Address" %}
-    </a>
-  {% endif %}
+  {% with first_available_ip=object.first_available_ip %}
+    {% if perms.ipam.add_ipaddress and first_available_ip %}
+      <a href="{% url 'ipam:ipaddress_add' %}?address={{ first_available_ip }}&vrf={{ object.vrf.pk }}&tenant_group={{ object.tenant.group.pk }}&tenant={{ object.tenant.pk }}" class="btn btn-primary">
+          <i class="mdi mdi-plus-thick" aria-hidden="true"></i> {% trans "Add IP Address" %}
+      </a>
+    {% endif %}
+  {% endwith %}
 {% endblock extra_controls %}

+ 19 - 15
netbox/templates/ipam/panels/prefix_addressing.html

@@ -30,7 +30,7 @@
         </td>
       </tr>
     {% endwith %}
-    {% with available_count=object.get_available_ips.size %}
+    {% with available_count=object.available_ip_count %}
       <tr>
         <th scope="row">{% trans "Available IPs" %}</th>
         <td>
@@ -41,22 +41,26 @@
           {% endif %}
         </td>
       </tr>
-    {% endwith %}
-    <tr>
-      <th scope="row">{% trans "First available IP" %}</th>
-      <td>
-        {% with first_available_ip=object.get_first_available_ip %}
-          {% if first_available_ip %}
-            {% if perms.ipam.add_ipaddress %}
-              <a href="{% url 'ipam:ipaddress_add' %}?address={{ first_available_ip }}{% if object.vrf %}&vrf={{ object.vrf_id }}{% endif %}{% if object.tenant %}&tenant={{ object.tenant.pk }}{% endif %}">{{ first_available_ip }}</a>
-            {% else %}
-              {{ first_available_ip }}
-            {% endif %}
+      <tr>
+        <th scope="row">{% trans "First available IP" %}</th>
+        <td>
+          {% if available_count %}
+            {% with first_available_ip=object.get_first_available_ip %}
+              {% if first_available_ip %}
+                {% if perms.ipam.add_ipaddress %}
+                  <a href="{% url 'ipam:ipaddress_add' %}?address={{ first_available_ip }}{% if object.vrf %}&vrf={{ object.vrf_id }}{% endif %}{% if object.tenant %}&tenant={{ object.tenant.pk }}{% endif %}">{{ first_available_ip }}</a>
+                {% else %}
+                  {{ first_available_ip }}
+                {% endif %}
+              {% else %}
+                {{ ''|placeholder }}
+              {% endif %}
+            {% endwith %}
           {% else %}
             {{ ''|placeholder }}
           {% endif %}
-        {% endwith %}
-      </td>
-    </tr>
+        </td>
+      </tr>
+    {% endwith %}
   </table>
 </div>