perf(ipam): Optimize Prefix availability calculations

Replace IPSet-heavy Prefix availability and utilization logic with
indexed host lookups, distinct host counts, and interval-based
availability calculation.

This adds mask-insensitive host-bound filtering for IP addresses and
ranges, moves availability/counting behavior onto QuerySet and model
methods, and uses merged occupied intervals to find available addresses
without materializing large address sets in Python.

Prefix utilization remains on a cheap utilization-only path for list
views, while Prefix detail views can use a shared usage summary when
both utilization and available IP count are needed. Usable IP bounds now
live on the Prefix model, since the logic depends on Prefix-specific
state such as is_pool.

This also adds host expression indexes for IP Ranges, fixes zero-address
preparation, fixes child IP matching across differing mask lengths,
keeps Prefix hierarchy rebuilding scoped to the existing VRF/global API,
and preserves IPRange.first_available_ip as a cached compatibility
wrapper.

Fixes #21870
This commit is contained in:
Martin Hauser 2026-06-13 15:45:44 +02:00 committed by Jeremy Stretch
parent bc7ed0e9bb
commit 8f974e3cc8
14 changed files with 1996 additions and 140 deletions

View File

@ -407,7 +407,7 @@ class AvailableIPAddressesView(AvailableObjectsView):
def get_available_objects(self, parent, limit=None):
# Calculate available IPs within the parent
ip_list = []
for index, ip in enumerate(parent.get_available_ips(), start=1):
for index, ip in enumerate(parent.iter_available_ips(), start=1):
ip_list.append(ip)
if index == limit:
break

View File

@ -42,7 +42,10 @@ class BaseIPField(models.Field):
raise ValidationError(e)
def get_prep_value(self, value):
if not value:
# Membership check; `not value` incorrectly treats the valid zero addresses
# 0.0.0.0 and :: as empty. netaddr objects compare unequal to all three
# sentinels; raw int 0 stays "empty" for backward compatibility.
if value in (None, '', 0):
return None
if isinstance(value, list):
return [str(self.to_python(v)) for v in value]
@ -107,6 +110,7 @@ IPAddressField.register_lookup(lookups.NetContainsOrEquals)
IPAddressField.register_lookup(lookups.NetHost)
IPAddressField.register_lookup(lookups.NetIn)
IPAddressField.register_lookup(lookups.NetHostContained)
IPAddressField.register_lookup(lookups.NetHostBetween)
IPAddressField.register_lookup(lookups.NetFamily)
IPAddressField.register_lookup(lookups.NetMaskLength)
IPAddressField.register_lookup(lookups.Host)

View File

@ -1,3 +1,4 @@
import netaddr
from django.db.models import IntegerField, Lookup, Transform, lookups
@ -99,7 +100,8 @@ class NetHost(Lookup):
if rhs_params:
rhs_params[0] = rhs_params[0].split('/')[0]
params = list(lhs_params) + rhs_params
return f'HOST({lhs}) = {rhs}', params
# Cast to INET so the predicate matches the inet ipam_ipaddress_host index.
return f'CAST(HOST({lhs}) AS INET) = {rhs}', params
class NetIn(Lookup):
@ -120,7 +122,8 @@ class NetIn(Lookup):
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))
# Cast to INET so the predicate matches the inet ipam_ipaddress_host index.
host_in_clause = self.create_in_clause('CAST(HOST({}) AS INET) IN ('.format(lhs), len(without_mask))
if with_mask and not without_mask:
return address_in_clause, with_mask
@ -156,6 +159,34 @@ class NetHostContained(Lookup):
return f'CAST(HOST({lhs}) AS INET) <<= {rhs}', params
class NetHostBetween(Lookup):
"""
Match host addresses (mask ignored) falling inclusively between two bounds. The left-hand
side is kept as an inet-typed host expression so PostgreSQL can use the host expression
indexes on the IPAM address and range tables; the CAST(HOST(...) AS INET) spelling matches
NetHost/NetIn for consistency (PostgreSQL canonicalizes the INET(HOST(...)) function form
to the same expression).
"""
lookup_name = 'host_between'
def get_prep_lookup(self):
if not isinstance(self.rhs, (list, tuple)) or len(self.rhs) != 2:
raise ValueError('The host_between lookup requires a (lower, upper) pair of bounds')
try:
# Normalize to bare hosts; reject malformed values before they reach SQL.
lower, upper = (netaddr.IPNetwork(str(bound)).ip for bound in self.rhs)
except (netaddr.AddrFormatError, ValueError) as e:
raise ValueError(f'Invalid host_between bound: {e}') from e
if lower.version != upper.version:
raise ValueError('host_between bounds must not mix address families')
return lower, upper
def as_sql(self, qn, connection):
lhs, lhs_params = self.process_lhs(qn, connection)
params = list(lhs_params) + [str(bound) for bound in self.rhs]
return f'CAST(HOST({lhs}) AS INET) BETWEEN %s AND %s', params
class NetFamily(Transform):
lookup_name = 'family'
function = 'FAMILY'

View File

@ -1,10 +1,10 @@
from django.db.models import Manager
from ipam.lookups import Host, Inet
from utilities.querysets import RestrictedQuerySet
from ipam.querysets import IPAddressQuerySet
class IPAddressManager(Manager.from_queryset(RestrictedQuerySet)):
class IPAddressManager(Manager.from_queryset(IPAddressQuerySet)):
def get_queryset(self):
"""

View File

@ -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', '0091_alter_service_index_and_ordering'),
]
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',
),
),
]

View File

@ -15,7 +15,7 @@ from ipam.constants import *
from ipam.fields import IPAddressField, IPNetworkField
from ipam.lookups import Host
from ipam.managers import IPAddressManager
from ipam.querysets import PrefixQuerySet
from ipam.querysets import IPRangeQuerySet, PrefixQuerySet
from ipam.validators import DNSValidator
from netbox.config import get_config
from netbox.models import OrganizationalModel, PrimaryModel
@ -425,14 +425,63 @@ class Prefix(ContactsMixin, GetAvailablePrefixesMixin, CachedScopeMixin, Primary
return Prefix.objects.filter(prefix__net_contained=str(self.prefix))
return Prefix.objects.filter(prefix__net_contained=str(self.prefix), vrf=self.vrf)
@property
def usable_ip_bounds(self):
"""
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.
"""
network = netaddr.IPNetwork(self.prefix)
family = network.version
first = network.first
last = network.last
mask_length = network.prefixlen
if (
self.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),
)
@property
def usable_size(self):
"""
The number of usable host addresses within the prefix (excludes reserved addresses).
"""
first_ip, last_ip = self.usable_ip_bounds
return int(last_ip) - int(first_ip) + 1
def get_child_ranges(self, **kwargs):
"""
Return all IPRanges within this Prefix and VRF.
"""
# A host BETWEEN over the prefix span uses the ipam_iprange_*_host btree indexes.
prefix = netaddr.IPNetwork(self.prefix)
bounds = (
netaddr.IPAddress(prefix.first, version=prefix.version),
netaddr.IPAddress(prefix.last, version=prefix.version),
)
return IPRange.objects.filter(
vrf=self.vrf,
start_address__net_host_contained=str(self.prefix),
end_address__net_host_contained=str(self.prefix),
start_address__host_between=bounds,
end_address__host_between=bounds,
**kwargs
)
@ -441,52 +490,106 @@ class Prefix(ContactsMixin, GetAvailablePrefixesMixin, CachedScopeMixin, Primary
Return all IPAddresses within this Prefix and VRF. If this Prefix is a container in the global table, return
child IPAddresses belonging to any VRF.
"""
# A host BETWEEN over the prefix span is index-sargable without the <<= containment recheck.
prefix = netaddr.IPNetwork(self.prefix)
bounds = (
netaddr.IPAddress(prefix.first, version=prefix.version),
netaddr.IPAddress(prefix.last, version=prefix.version),
)
if self.vrf is None and self.status == PrefixStatusChoices.STATUS_CONTAINER:
return IPAddress.objects.filter(address__net_host_contained=str(self.prefix))
return IPAddress.objects.filter(address__net_host_contained=str(self.prefix), vrf=self.vrf)
return IPAddress.objects.filter(address__host_between=bounds)
return IPAddress.objects.filter(address__host_between=bounds, vrf=self.vrf)
def get_available_ips(self):
"""
Return all available IPs within this prefix as an IPSet.
"""
prefix = netaddr.IPSet(self.prefix)
child_ips = netaddr.IPSet([
ip.address.ip for ip in self.get_child_ips()
])
child_ranges = netaddr.IPSet([
iprange.range for iprange in self.get_child_ranges().filter(mark_populated=True)
])
available_ips = prefix - child_ips - child_ranges
return netaddr.IPSet(
cidr
for start, end in self._available_intervals()
for cidr in netaddr.iprange_to_cidrs(start, end)
)
# Pool, IPv4 /31-/32 or IPv6 /127-/128 sets are fully usable
if (
self.is_pool
or (self.family == 4 and self.prefix.prefixlen >= 31)
or (self.family == 6 and self.prefix.prefixlen >= 127)
):
return available_ips
def iter_available_ips(self):
"""
Yield the available IPs within this prefix as netaddr.IPAddress objects, in
ascending order. Unlike get_available_ips(), consumption is lazy: stopping
early stops reading from the database.
"""
for start, end in self._available_intervals():
yield from netaddr.iter_iprange(start, end)
if self.family == 4:
# For "normal" IPv4 prefixes, omit first and last addresses
available_ips -= netaddr.IPSet([
netaddr.IPAddress(self.prefix.first),
netaddr.IPAddress(self.prefix.last),
])
else:
# For IPv6 prefixes, omit the Subnet-Router anycast address
# per RFC 4291
available_ips -= netaddr.IPSet([netaddr.IPAddress(self.prefix.first)])
def get_available_ip_count(self):
"""
Return the number of available IPs within the prefix.
"""
first_ip, last_ip = self.usable_ip_bounds
usable_size = int(last_ip) - int(first_ip) + 1
return available_ips
populated_intervals = self.get_child_ranges(mark_populated=True).get_intervals(first_ip, last_ip)
populated_count = sum(int(end) - int(start) + 1 for start, end in populated_intervals)
# Populated ranges already cover the usable span; skip the child-IP count entirely.
if populated_count >= usable_size:
return 0
child_ip_count = (
self.get_child_ips()
.filter(address__host_between=(first_ip, last_ip))
.count_distinct_hosts(exclude_intervals=populated_intervals)
)
return max(usable_size - populated_count - child_ip_count, 0)
def get_ip_usage_summary(self):
"""
Return the available IP count and utilization together as a dict, sharing a
single distinct-host scan. Intended for detail views rendering both values;
list views should call get_utilization() alone, which is cheaper per row.
"""
# Marked-utilized and container utilization need no host scan; delegate.
if self.mark_utilized or self.status == PrefixStatusChoices.STATUS_CONTAINER:
return {
'available_ip_count': self.get_available_ip_count(),
'utilization': self.get_utilization(),
}
first_ip, last_ip = self.usable_ip_bounds
usable_size = int(last_ip) - int(first_ip) + 1
populated_intervals = self.get_child_ranges(mark_populated=True).get_intervals(first_ip, last_ip)
utilized_intervals = self.get_child_ranges(mark_utilized=True).get_intervals()
counts = self.get_child_ips().count_distinct_hosts_pair(
bounds=(first_ip, last_ip),
bounded_exclude=populated_intervals,
total_exclude=utilized_intervals,
)
populated_count = sum(int(end) - int(start) + 1 for start, end in populated_intervals)
utilized_range_count = sum(int(end) - int(start) + 1 for start, end in utilized_intervals)
prefix_size = self._get_utilization_denominator()
return {
'available_ip_count': max(usable_size - populated_count - counts['bounded'], 0),
'utilization': min(float(utilized_range_count + counts['total']) / prefix_size * 100, 100),
}
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 = self.usable_ip_bounds
populated_intervals = self.get_child_ranges(mark_populated=True).get_intervals(first_ip, last_ip)
first_available_ip = self.get_child_ips().first_available_host(
first_ip, last_ip, exclude_intervals=populated_intervals,
)
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,20 +607,43 @@ 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._get_utilization_denominator()
utilized_intervals = self.get_child_ranges(mark_utilized=True).get_intervals()
utilized_range_count = sum(int(end) - int(start) + 1 for start, end in utilized_intervals)
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 ranges already saturate the prefix; skip the child-IP count.
if utilized_range_count >= prefix_size:
return 100
child_ip_count = self.get_child_ips().count_distinct_hosts(
exclude_intervals=utilized_intervals,
)
utilization = float(utilized_range_count + child_ip_count) / prefix_size * 100
return min(utilization, 100)
def _available_intervals(self):
"""
Yield the available (start, end) host intervals within the prefix.
"""
first_ip, last_ip = self.usable_ip_bounds
populated_intervals = self.get_child_ranges(mark_populated=True).get_intervals(first_ip, last_ip)
return self.get_child_ips().available_intervals(
first_ip, last_ip, exclude_intervals=populated_intervals,
)
def _get_utilization_denominator(self):
"""
The address count utilization is measured against (IPv4 non-pool prefixes
exclude the network and broadcast addresses; IPv6 uses the full prefix size).
"""
prefix_size = self.prefix.size
if self.prefix.version == 4 and self.prefix.prefixlen < 31 and not self.is_pool:
return prefix_size - 2
return prefix_size
class IPRange(ContactsMixin, PrimaryModel):
"""
@ -576,12 +702,24 @@ class IPRange(ContactsMixin, PrimaryModel):
help_text=_("Report space as fully utilized")
)
objects = IPRangeQuerySet.as_manager()
clone_fields = (
'vrf', 'tenant', 'status', 'role', 'description', 'mark_populated', 'mark_utilized',
)
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')
@ -709,40 +847,14 @@ class IPRange(ContactsMixin, PrimaryModel):
def get_status_color(self):
return IPRangeStatusChoices.colors.get(self.status)
def get_child_ips(self):
"""
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
)
def get_available_ips(self):
"""
Return all available IPs within this range as an IPSet.
"""
if self.mark_populated:
return netaddr.IPSet()
range = netaddr.IPRange(self.start_address.ip, self.end_address.ip)
child_ips = netaddr.IPSet([ip.address.ip for ip in self.get_child_ips()])
return netaddr.IPSet(range) - child_ips
@cached_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:
return None
return self.get_first_available_ip()
return '{}/{}'.format(next(available_ips.__iter__()), self.start_address.prefixlen)
@cached_property
@property
def utilization(self):
"""
Determine the utilization of the range and return it as a percentage.
@ -750,12 +862,79 @@ 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
return min(float(self._occupied_host_count) / self.size * 100, 100)
return min(float(child_count) / self.size * 100, 100)
def get_child_ips(self):
"""
Return all IPAddresses within this IPRange and VRF.
"""
return IPAddress.objects.filter(
vrf=self.vrf,
address__host_between=(self.start_address.ip, self.end_address.ip),
)
def get_available_ips(self):
"""
Return all available IPs within this range as an IPSet.
"""
return netaddr.IPSet(
cidr
for start, end in self._available_intervals()
for cidr in netaddr.iprange_to_cidrs(start, end)
)
def iter_available_ips(self):
"""
Yield the available IPs within this range as netaddr.IPAddress objects, in
ascending order. Unlike get_available_ips(), consumption is lazy: stopping
early stops reading from the database.
"""
for start, end in self._available_intervals():
yield from netaddr.iter_iprange(start, end)
def get_available_ip_count(self):
"""
Return the number of available IPs within the range.
"""
if self.mark_populated:
return 0
return max(self.size - self._occupied_host_count, 0)
def get_first_available_ip(self):
"""
Return the first available IP within the range (or None).
"""
if self.mark_populated:
return None
first_available_ip = self.get_child_ips().first_available_host(
self.start_address.ip, self.end_address.ip,
)
if first_available_ip is None:
return None
return f'{first_available_ip}/{self.start_address.prefixlen}'
def _available_intervals(self):
"""
Yield the available (start, end) host intervals within the range.
"""
if self.mark_populated:
return iter(())
return self.get_child_ips().available_intervals(
self.start_address.ip, self.end_address.ip,
)
@cached_property
def _occupied_host_count(self):
"""
The number of distinct occupied hosts within the range, cached for the
lifetime of the instance.
"""
return self.get_child_ips().count_distinct_hosts()
class IPAddress(ContactsMixin, PrimaryModel):
@ -948,10 +1127,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({

View File

@ -1,18 +1,51 @@
import heapq
import netaddr
from django.contrib.contenttypes.models import ContentType
from django.db.models import Count, F, OuterRef, Q, Subquery, Value
from django.db.models.expressions import RawSQL
from django.db.models.functions import NullIf, Round
from django.db.models.functions import Cast, NullIf, Round
from utilities.query import count_related
from utilities.querysets import RestrictedQuerySet
from .fields import IPAddressField
from .lookups import Host
__all__ = (
'ASNRangeQuerySet',
'IPAddressQuerySet',
'IPRangeQuerySet',
'PrefixQuerySet',
'VLANGroupQuerySet',
'VLANQuerySet',
)
# The host portion of an IP address (mask ignored), in the same form as the
# ipam_ipaddress_host expression index.
HOST_ADDRESS = Cast(Host('address'), output_field=IPAddressField())
def _merge_intervals(intervals):
"""
Return the union of (start, end) netaddr.IPAddress intervals, merged and sorted.
"""
if not intervals:
return []
intervals = sorted(intervals)
merged = [intervals[0]]
for start, end in intervals[1:]:
current_start, current_end = merged[-1]
# Adjacency math in int space; netaddr raises at the address-space maximum.
if start.version == current_end.version and int(start) <= int(current_end) + 1:
merged[-1] = (current_start, max(current_end, end))
else:
merged.append((start, end))
return merged
class ASNRangeQuerySet(RestrictedQuerySet):
@ -32,6 +65,162 @@ class ASNRangeQuerySet(RestrictedQuerySet):
return self.annotate(asn_count=Subquery(asns))
class IPAddressQuerySet(RestrictedQuerySet):
def count_distinct_hosts(self, exclude_intervals=()):
"""
Count distinct host addresses, optionally excluding (start, end) netaddr.IPAddress intervals.
"""
queryset = self
for start, end in exclude_intervals:
queryset = queryset.exclude(address__host_between=(start, end))
return queryset.aggregate(count=Count(HOST_ADDRESS, distinct=True))['count']
def count_distinct_hosts_pair(self, bounds, bounded_exclude=(), total_exclude=()):
"""
Return two distinct host counts computed in a single scan, as a dict:
'bounded' counts hosts within the (first_ip, last_ip) bounds excluding the
bounded_exclude intervals; 'total' counts all hosts excluding the
total_exclude intervals. Interval arguments match the output of
IPRangeQuerySet.get_intervals(). Avoids a second scan of the host expression
index when both counts are needed. Use only when both counts are needed (e.g.
Prefix.get_ip_usage_summary()); single-purpose callers should prefer
count_distinct_hosts().
"""
# The deduplicated column is already a bare host; plain comparisons beat
# the host_between lookup here, which would re-wrap it in HOST()::inet.
bounded_q = Q(host_address__range=(str(bounds[0]), str(bounds[1])))
for start, end in bounded_exclude:
bounded_q &= ~Q(host_address__range=(str(start), str(end)))
total_q = Q()
for start, end in total_exclude:
total_q &= ~Q(host_address__range=(str(start), str(end)))
hosts = self.order_by().annotate(host_address=HOST_ADDRESS).values('host_address').distinct()
return hosts.aggregate(
bounded=Count('host_address', filter=bounded_q),
# An empty Q is falsy; fall back to a plain count of all hosts.
total=Count('host_address', filter=total_q or None),
)
def _iter_distinct_hosts(self, first_ip, last_ip, batch_size):
"""
Yield the distinct occupied hosts in [first_ip, last_ip] in ascending order,
fetched in LIMIT batches that resume just past the last seen host. (A
server-side cursor is unsuitable here: on autocommit connections Django
declares it WITH HOLD, which materializes the full result at DECLARE.)
"""
resume = first_ip
while True:
# order_by() first clears the default ordering, which would otherwise
# leak into SELECT and break distinct().
hosts = list(
self.filter(address__host_between=(resume, last_ip))
.order_by()
.annotate(host_address=HOST_ADDRESS)
.values_list('host_address', flat=True)
.distinct()
.order_by('host_address')[:batch_size]
)
for host in hosts:
yield host.ip
if len(hosts) < batch_size:
return
last_host = hosts[-1].ip
if int(last_host) >= int(last_ip):
return
resume = netaddr.IPAddress(int(last_host) + 1, version=last_host.version)
def available_intervals(self, first_ip, last_ip, exclude_intervals=(), batch_size=5000):
"""
Yield the unoccupied (start, end) netaddr.IPAddress intervals (inclusive)
within [first_ip, last_ip], in ascending order. exclude_intervals are
(start, end) netaddr.IPAddress pairs; they are merged and sorted internally,
intervals of a foreign address family are ignored, and addresses they cover
count as occupied. Consumption is lazy: a caller that stops early stops
fetching host batches.
"""
if batch_size < 1:
raise ValueError('batch_size must be greater than zero')
first_int, last_int = int(first_ip), int(last_ip)
version = first_ip.version
if first_int > last_int:
return
# Normalize: the sweep below requires sorted, non-overlapping, same-family intervals.
exclude_intervals = _merge_intervals([
(start, end)
for start, end in exclude_intervals
if start.version == end.version == version
])
intervals = [(int(start), int(end)) for start, end in exclude_intervals]
# Fast path: one merged excluded interval covers the entire span.
if intervals and intervals[0][0] <= first_int and intervals[0][1] >= last_int:
return
hosts = (
(int(host), int(host))
for host in self._iter_distinct_hosts(first_ip, last_ip, batch_size)
)
candidate = first_int
# Ties on `start` are harmless; the sweep handles overlapping intervals.
for start, end in heapq.merge(intervals, hosts):
if end < candidate:
continue
if start > candidate:
yield (
netaddr.IPAddress(candidate, version=version),
netaddr.IPAddress(min(start - 1, last_int), version=version),
)
candidate = max(candidate, end + 1)
if candidate > last_int:
return
if candidate <= last_int:
yield (
netaddr.IPAddress(candidate, version=version),
netaddr.IPAddress(last_int, version=version),
)
def first_available_host(self, first_ip, last_ip, exclude_intervals=()):
"""
Return the first host in [first_ip, last_ip] neither present nor in an excluded interval (or None).
"""
interval = next(self.available_intervals(first_ip, last_ip, exclude_intervals), None)
return interval[0] if interval else None
class IPRangeQuerySet(RestrictedQuerySet):
def get_intervals(self, first_ip=None, last_ip=None):
"""
Return ranges as merged (start, end) netaddr.IPAddress intervals, optionally clipped to the bounds.
"""
intervals = []
# order_by() clears the default ordering; _merge_intervals() sorts anyway.
for start_address, end_address in self.order_by().values_list('start_address', 'end_address'):
start, end = start_address.ip, end_address.ip
if first_ip is not None:
if end < first_ip:
continue
start = max(start, first_ip)
if last_ip is not None:
if start > last_ip:
continue
end = min(end, last_ip)
intervals.append((start, end))
return _merge_intervals(intervals)
class PrefixQuerySet(RestrictedQuerySet):
def annotate_hierarchy(self):

View File

@ -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))

View File

@ -1,7 +1,9 @@
import netaddr
from django.db.backends.postgresql.psycopg_any import NumericRange
from django.test import TestCase
from netaddr import IPNetwork
from ipam.models import VLANGroup
from ipam.models import IPAddress, VLANGroup
class VLANGroupRangeContainsLookupTestCase(TestCase):
@ -65,3 +67,134 @@ class VLANGroupRangeContainsLookupTestCase(TestCase):
specific condition.
"""
self.assertFalse(VLANGroup.objects.filter(pk=self.g_empty.pk, vid_ranges__range_contains=1).exists())
class IPAddressHostBetweenLookupTestCase(TestCase):
@classmethod
def setUpTestData(cls):
IPAddress.objects.bulk_create((
IPAddress(address=IPNetwork('192.0.2.0/24')),
IPAddress(address=IPNetwork('192.0.2.1/24')),
IPAddress(address=IPNetwork('192.0.2.5/32')),
IPAddress(address=IPNetwork('192.0.2.10/25')),
IPAddress(address=IPNetwork('192.0.2.11/24')),
IPAddress(address=IPNetwork('2001:db8::1/64')),
IPAddress(address=IPNetwork('2001:db8::5/128')),
IPAddress(address=IPNetwork('2001:db8::10/64')),
))
def test_ipv4_boundaries_inclusive(self):
"""
Tests that both bounds are included and hosts outside the window are excluded.
"""
queryset = IPAddress.objects.filter(
address__host_between=(netaddr.IPAddress('192.0.2.1'), netaddr.IPAddress('192.0.2.10'))
)
self.assertEqual(
sorted(str(ip.address) for ip in queryset),
['192.0.2.1/24', '192.0.2.10/25', '192.0.2.5/32'],
)
def test_mask_insensitive(self):
"""
Tests that hosts match regardless of their mask length.
"""
queryset = IPAddress.objects.filter(
address__host_between=(netaddr.IPAddress('192.0.2.5'), netaddr.IPAddress('192.0.2.5'))
)
self.assertEqual(queryset.count(), 1)
def test_ipv6(self):
"""
Tests that IPv6 hosts filter by host portion.
"""
queryset = IPAddress.objects.filter(
address__host_between=(netaddr.IPAddress('2001:db8::1'), netaddr.IPAddress('2001:db8::5'))
)
self.assertEqual(queryset.count(), 2)
def test_bounds_mask_stripped(self):
"""
Tests that bounds supplied with a mask compare by host portion only.
"""
queryset = IPAddress.objects.filter(
address__host_between=(IPNetwork('192.0.2.1/24'), IPNetwork('192.0.2.10/24'))
)
self.assertEqual(queryset.count(), 3)
def test_invalid_bounds_raise(self):
"""
Tests that a bounds value which is not a two-item pair raises ValueError.
"""
with self.assertRaises(ValueError):
IPAddress.objects.filter(address__host_between=(netaddr.IPAddress('192.0.2.1'),))
def test_invalid_bound_value_raises(self):
"""
Tests that a bound which is not a valid IP address raises ValueError.
"""
with self.assertRaises(ValueError):
IPAddress.objects.filter(address__host_between=('invalid', netaddr.IPAddress('192.0.2.10')))
def test_mixed_family_bounds_raise(self):
"""
Tests that bounds from different address families raise ValueError.
"""
with self.assertRaises(ValueError):
IPAddress.objects.filter(
address__host_between=(netaddr.IPAddress('192.0.2.1'), netaddr.IPAddress('2001:db8::1'))
)
def test_sql_uses_cast_host_expression(self):
"""
Tests that the compiled SQL matches the ipam_ipaddress_host index expression.
"""
queryset = IPAddress.objects.filter(
address__host_between=(netaddr.IPAddress('192.0.2.1'), netaddr.IPAddress('192.0.2.10'))
)
self.assertIn('CAST(HOST(', str(queryset.query))
class IPAddressNetLookupsTestCase(TestCase):
@classmethod
def setUpTestData(cls):
IPAddress.objects.bulk_create((
IPAddress(address='10.0.0.1/24'),
IPAddress(address='10.0.0.2/24'),
IPAddress(address='10.0.0.1/25'), # Same host as the first, different mask
IPAddress(address='2001:db8::1/64'),
))
def test_net_host_matches_host_ignoring_mask(self):
"""net_host matches every address whose host portion equals the value."""
qs = IPAddress.objects.filter(address__net_host='10.0.0.1')
self.assertEqual(qs.count(), 2)
def test_net_host_predicate_is_inet_typed(self):
"""net_host casts the host expression to inet so the inet host index applies."""
sql = str(IPAddress.objects.filter(address__net_host='10.0.0.1').query)
self.assertIn('CAST(HOST(', sql)
self.assertIn('AS INET) =', sql)
def test_net_in_without_mask(self):
"""net_in matches host values supplied without a mask."""
qs = IPAddress.objects.filter(address__net_in=['10.0.0.1', '10.0.0.2'])
self.assertEqual(qs.count(), 3)
def test_net_in_with_mask(self):
"""net_in matches an exact address/mask value."""
qs = IPAddress.objects.filter(address__net_in=['10.0.0.1/25'])
self.assertEqual(qs.count(), 1)
def test_net_in_normalizes_ipv6(self):
"""net_in matches an expanded IPv6 form against the canonical host value."""
qs = IPAddress.objects.filter(
address__net_in=['2001:0db8:0000:0000:0000:0000:0000:0001']
)
self.assertEqual(qs.count(), 1)
def test_net_in_predicate_is_inet_typed(self):
"""net_in casts the host expression to inet so the inet host index applies."""
sql = str(IPAddress.objects.filter(address__net_in=['10.0.0.1']).query)
self.assertIn('CAST(HOST(', sql)
self.assertIn('AS INET) IN', sql)

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,344 @@
import netaddr
from django.test import TestCase
from netaddr import IPNetwork
from ipam.models import IPAddress, IPRange
class IPAddressQuerySetTestCase(TestCase):
@classmethod
def setUpTestData(cls):
IPAddress.objects.bulk_create((
IPAddress(address=IPNetwork('192.0.2.1/24')),
IPAddress(address=IPNetwork('192.0.2.1/32')),
IPAddress(address=IPNetwork('192.0.2.2/24')),
))
def test_count_distinct_hosts(self):
"""
Tests that duplicate hosts with different masks are counted once.
"""
self.assertEqual(IPAddress.objects.count_distinct_hosts(), 2)
def test_count_distinct_hosts_empty(self):
"""
Tests that an empty queryset counts zero hosts.
"""
self.assertEqual(IPAddress.objects.none().count_distinct_hosts(), 0)
def test_count_distinct_hosts_exclude_intervals(self):
"""
Tests that hosts covered by an excluded interval are not counted.
"""
interval = (netaddr.IPAddress('192.0.2.1'), netaddr.IPAddress('192.0.2.1'))
self.assertEqual(IPAddress.objects.count_distinct_hosts(exclude_intervals=[interval]), 1)
def test_count_distinct_hosts_pair(self):
"""
Tests that the bounded and total distinct host counts are computed correctly.
"""
counts = IPAddress.objects.count_distinct_hosts_pair(
bounds=(netaddr.IPAddress('192.0.2.2'), netaddr.IPAddress('192.0.2.10')),
bounded_exclude=[(netaddr.IPAddress('192.0.2.2'), netaddr.IPAddress('192.0.2.2'))],
total_exclude=[(netaddr.IPAddress('192.0.2.1'), netaddr.IPAddress('192.0.2.1'))],
)
self.assertEqual(counts, {'bounded': 0, 'total': 1})
def test_count_distinct_hosts_pair_no_excludes(self):
"""
Tests that both counts dedupe hosts and respect the bounds without excludes.
"""
counts = IPAddress.objects.count_distinct_hosts_pair(
bounds=(netaddr.IPAddress('192.0.2.2'), netaddr.IPAddress('192.0.2.10')),
)
self.assertEqual(counts, {'bounded': 1, 'total': 2})
def test_first_available_host(self):
"""
Tests that occupied hosts and excluded intervals are skipped, including hosts behind the sweep.
"""
interval = (netaddr.IPAddress('192.0.2.1'), netaddr.IPAddress('192.0.2.5'))
self.assertEqual(
IPAddress.objects.first_available_host(
netaddr.IPAddress('192.0.2.1'), netaddr.IPAddress('192.0.2.10'), exclude_intervals=[interval]
),
netaddr.IPAddress('192.0.2.6'),
)
def test_first_available_host_inverted_bounds(self):
"""
Tests that an inverted bounds pair yields None.
"""
self.assertIsNone(
IPAddress.objects.first_available_host(netaddr.IPAddress('192.0.2.10'), netaddr.IPAddress('192.0.2.5'))
)
def test_available_intervals(self):
"""
Tests that gaps around occupied hosts and excluded intervals are yielded in order.
"""
interval = (netaddr.IPAddress('192.0.2.5'), netaddr.IPAddress('192.0.2.6'))
self.assertEqual(
list(IPAddress.objects.available_intervals(
netaddr.IPAddress('192.0.2.1'), netaddr.IPAddress('192.0.2.10'), exclude_intervals=[interval]
)),
[
(netaddr.IPAddress('192.0.2.3'), netaddr.IPAddress('192.0.2.4')),
(netaddr.IPAddress('192.0.2.7'), netaddr.IPAddress('192.0.2.10')),
],
)
def test_available_intervals_leading_gap(self):
"""
Tests that the gap before the first occupied host is yielded.
"""
self.assertEqual(
list(IPAddress.objects.available_intervals(
netaddr.IPAddress('192.0.2.0'), netaddr.IPAddress('192.0.2.2')
)),
[(netaddr.IPAddress('192.0.2.0'), netaddr.IPAddress('192.0.2.0'))],
)
def test_available_intervals_empty_queryset(self):
"""
Tests that an empty queryset yields the full span.
"""
self.assertEqual(
list(IPAddress.objects.none().available_intervals(
netaddr.IPAddress('192.0.2.1'), netaddr.IPAddress('192.0.2.3')
)),
[(netaddr.IPAddress('192.0.2.1'), netaddr.IPAddress('192.0.2.3'))],
)
def test_available_intervals_inverted_bounds(self):
"""
Tests that an inverted bounds pair yields nothing.
"""
self.assertEqual(
list(IPAddress.objects.available_intervals(
netaddr.IPAddress('192.0.2.10'), netaddr.IPAddress('192.0.2.5')
)),
[],
)
def test_available_intervals_fully_excluded(self):
"""
Tests that a span covered by an excluded interval yields nothing.
"""
interval = (netaddr.IPAddress('192.0.2.0'), netaddr.IPAddress('192.0.2.20'))
self.assertEqual(
list(IPAddress.objects.available_intervals(
netaddr.IPAddress('192.0.2.1'), netaddr.IPAddress('192.0.2.10'), exclude_intervals=[interval]
)),
[],
)
def test_available_intervals_mixed_family_exclude(self):
"""
Tests that an exclude interval spanning address families is ignored.
"""
interval = (netaddr.IPAddress('192.0.2.5'), netaddr.IPAddress('2001:db8::5'))
self.assertEqual(
list(IPAddress.objects.available_intervals(
netaddr.IPAddress('192.0.2.1'), netaddr.IPAddress('192.0.2.10'), exclude_intervals=[interval]
)),
[(netaddr.IPAddress('192.0.2.3'), netaddr.IPAddress('192.0.2.10'))],
)
def test_available_intervals_invalid_batch_size(self):
"""
Tests that a non-positive batch size raises ValueError.
"""
intervals = IPAddress.objects.available_intervals(
netaddr.IPAddress('192.0.2.1'), netaddr.IPAddress('192.0.2.10'), batch_size=0
)
with self.assertRaises(ValueError):
next(intervals)
def test_available_intervals_first_interval_single_query(self):
"""
Tests that consuming only the first interval issues a single batch query.
"""
IPAddress.objects.bulk_create((
IPAddress(address=IPNetwork('192.0.2.12/24')),
IPAddress(address=IPNetwork('192.0.2.14/24')),
IPAddress(address=IPNetwork('192.0.2.16/24')),
))
intervals = IPAddress.objects.available_intervals(
netaddr.IPAddress('192.0.2.10'), netaddr.IPAddress('192.0.2.20'), batch_size=1
)
with self.assertNumQueries(1):
self.assertEqual(
next(intervals),
(netaddr.IPAddress('192.0.2.10'), netaddr.IPAddress('192.0.2.11')),
)
def test_available_intervals_unsorted_exclude_intervals(self):
"""
Tests that unsorted, overlapping exclude intervals are normalized internally.
"""
intervals = list(IPAddress.objects.none().available_intervals(
netaddr.IPAddress('192.0.2.1'),
netaddr.IPAddress('192.0.2.40'),
exclude_intervals=[
(netaddr.IPAddress('192.0.2.20'), netaddr.IPAddress('192.0.2.30')),
(netaddr.IPAddress('192.0.2.1'), netaddr.IPAddress('192.0.2.10')),
(netaddr.IPAddress('192.0.2.25'), netaddr.IPAddress('192.0.2.30')),
],
))
self.assertEqual(intervals, [
(netaddr.IPAddress('192.0.2.11'), netaddr.IPAddress('192.0.2.19')),
(netaddr.IPAddress('192.0.2.31'), netaddr.IPAddress('192.0.2.40')),
])
def test_available_intervals_batching(self):
"""
Tests that gaps spanning multiple fetch batches are yielded completely and in order.
"""
IPAddress.objects.bulk_create(
IPAddress(address=IPNetwork(f'192.0.3.{i}/24')) for i in range(2, 82, 2)
)
expected = [
(netaddr.IPAddress(f'192.0.3.{i}'), netaddr.IPAddress(f'192.0.3.{i}'))
for i in range(1, 83, 2)
]
self.assertEqual(
list(IPAddress.objects.available_intervals(
netaddr.IPAddress('192.0.3.1'), netaddr.IPAddress('192.0.3.81'), batch_size=8
)),
expected,
)
def test_iter_distinct_hosts_stops_at_upper_bound(self):
"""
Tests that batch resumption stops once the last fetched host reaches the upper bound.
"""
IPAddress.objects.bulk_create(
IPAddress(address=IPNetwork(f'192.0.4.{i}/24')) for i in (2, 4)
)
self.assertEqual(
list(IPAddress.objects.all()._iter_distinct_hosts(
netaddr.IPAddress('192.0.4.2'), netaddr.IPAddress('192.0.4.4'), batch_size=1
)),
[netaddr.IPAddress('192.0.4.2'), netaddr.IPAddress('192.0.4.4')],
)
def test_available_intervals_batch_size_one(self):
"""
Tests that fetching one host per batch still terminates and yields every gap.
"""
IPAddress.objects.bulk_create(
IPAddress(address=IPNetwork(f'192.0.3.{i}/24')) for i in (2, 3, 5)
)
self.assertEqual(
list(IPAddress.objects.available_intervals(
netaddr.IPAddress('192.0.3.1'), netaddr.IPAddress('192.0.3.6'), batch_size=1
)),
[
(netaddr.IPAddress('192.0.3.1'), netaddr.IPAddress('192.0.3.1')),
(netaddr.IPAddress('192.0.3.4'), netaddr.IPAddress('192.0.3.4')),
(netaddr.IPAddress('192.0.3.6'), netaddr.IPAddress('192.0.3.6')),
],
)
class IPRangeQuerySetTestCase(TestCase):
@classmethod
def setUpTestData(cls):
IPRange.objects.bulk_create((
IPRange(start_address=IPNetwork('192.0.2.10/24'), end_address=IPNetwork('192.0.2.19/24'), size=10),
IPRange(start_address=IPNetwork('192.0.2.15/24'), end_address=IPNetwork('192.0.2.24/24'), size=10),
IPRange(start_address=IPNetwork('192.0.2.40/24'), end_address=IPNetwork('192.0.2.49/24'), size=10),
))
def test_get_intervals_merges_overlaps(self):
"""
Tests that overlapping ranges merge and disjoint ranges stay separate.
"""
self.assertEqual(
IPRange.objects.get_intervals(),
[
(netaddr.IPAddress('192.0.2.10'), netaddr.IPAddress('192.0.2.24')),
(netaddr.IPAddress('192.0.2.40'), netaddr.IPAddress('192.0.2.49')),
],
)
def test_get_intervals_clips_to_bounds(self):
"""
Tests that ranges are clipped to the bounds and out-of-bounds ranges are dropped.
"""
self.assertEqual(
IPRange.objects.get_intervals(netaddr.IPAddress('192.0.2.20'), netaddr.IPAddress('192.0.2.30')),
[(netaddr.IPAddress('192.0.2.20'), netaddr.IPAddress('192.0.2.24'))],
)
def test_get_intervals_drops_ranges_below_bounds(self):
"""
Tests that ranges entirely below the lower bound are dropped.
"""
self.assertEqual(
IPRange.objects.get_intervals(netaddr.IPAddress('192.0.2.30'), netaddr.IPAddress('192.0.2.60')),
[(netaddr.IPAddress('192.0.2.40'), netaddr.IPAddress('192.0.2.49'))],
)
def test_get_intervals_drops_ranges_above_bounds(self):
"""
Tests that ranges entirely above the upper bound are dropped.
"""
self.assertEqual(
IPRange.objects.get_intervals(netaddr.IPAddress('192.0.2.0'), netaddr.IPAddress('192.0.2.30')),
[(netaddr.IPAddress('192.0.2.10'), netaddr.IPAddress('192.0.2.24'))],
)
def test_get_intervals_clips_to_upper_bound(self):
"""
Tests that a range straddling the upper bound is clipped to it.
"""
self.assertEqual(
IPRange.objects.get_intervals(netaddr.IPAddress('192.0.2.0'), netaddr.IPAddress('192.0.2.15')),
[(netaddr.IPAddress('192.0.2.10'), netaddr.IPAddress('192.0.2.15'))],
)
def test_get_intervals_mixed_families(self):
"""
Tests that int-adjacent intervals of different address families are not merged.
"""
IPRange.objects.bulk_create((
IPRange(
start_address=IPNetwork('255.255.255.254/32'),
end_address=IPNetwork('255.255.255.255/32'),
size=2,
),
IPRange(start_address=IPNetwork('::1/128'), end_address=IPNetwork('::2/128'), size=2),
))
self.assertEqual(
IPRange.objects.get_intervals(),
[
(netaddr.IPAddress('192.0.2.10'), netaddr.IPAddress('192.0.2.24')),
(netaddr.IPAddress('192.0.2.40'), netaddr.IPAddress('192.0.2.49')),
(netaddr.IPAddress('255.255.255.254'), netaddr.IPAddress('255.255.255.255')),
(netaddr.IPAddress('::1'), netaddr.IPAddress('::2')),
],
)
def test_get_intervals_ipv6(self):
"""
Tests that IPv6 ranges merge and clip by host address.
"""
IPRange.objects.create(
start_address=IPNetwork('2001:db8::10/64'),
end_address=IPNetwork('2001:db8::1f/64'),
)
IPRange.objects.create(
start_address=IPNetwork('2001:db8::18/64'),
end_address=IPNetwork('2001:db8::2f/64'),
)
self.assertEqual(
IPRange.objects.get_intervals(netaddr.IPAddress('2001:db8::'), netaddr.IPAddress('2001:db8::ffff')),
[(netaddr.IPAddress('2001:db8::10'), netaddr.IPAddress('2001:db8::2f'))],
)

View File

@ -1,10 +1,10 @@
from dataclasses import dataclass
import netaddr
from django.apps import apps
from django.utils.translation import gettext_lazy as _
from .constants import *
from .models import VLAN, Prefix
__all__ = (
'AvailableIPSpace',
@ -39,7 +39,7 @@ def add_requested_prefixes(parent, prefix_list, show_available=True, show_assign
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,6 +47,7 @@ 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:
Prefix = apps.get_model('ipam', 'Prefix')
# 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
@ -78,22 +79,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 = prefix.usable_ip_bounds
if not records:
return [
@ -195,7 +181,7 @@ 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
@ -204,6 +190,9 @@ def rebuild_prefixes(vrf):
"""
Rebuild the prefix hierarchy for all prefixes in the specified VRF (or global table).
"""
Prefix = apps.get_model('ipam', 'Prefix')
prefix_queryset = Prefix.objects.filter(vrf=vrf)
def contains(parent, child):
return child in parent and child != parent
@ -219,10 +208,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']):

View File

@ -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.get_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 %}

View File

@ -11,6 +11,7 @@
{% endif %}
</h2>
<table class="table table-hover attr-table">
{% with usage=object.get_ip_usage_summary %}
<tr>
<th scope="row">{% trans "Utilization" %}</th>
<td>
@ -18,7 +19,7 @@
{% utilization_graph 100 warning_threshold=0 danger_threshold=0 %}
<small>({% trans "Marked fully utilized" %})</small>
{% else %}
{% utilization_graph object.get_utilization %}
{% utilization_graph usage.utilization %}
{% endif %}
</td>
</tr>
@ -30,33 +31,36 @@
</td>
</tr>
{% endwith %}
{% with available_count=object.get_available_ips.size %}
<tr>
<th scope="row">{% trans "Available IPs" %}</th>
<td>
{% if available_count > 1000000 %}
{{ available_count|intword }}
{% else %}
{{ available_count|intcomma }}
{% endif %}
</td>
</tr>
{% endwith %}
<tr>
<th scope="row">{% trans "Available IPs" %}</th>
<td>
{% if usage.available_ip_count > 1000000 %}
{{ usage.available_ip_count|intword }}
{% else %}
{{ usage.available_ip_count|intcomma }}
{% endif %}
</td>
</tr>
<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>
{% if usage.available_ip_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 %}
{{ first_available_ip }}
{{ ''|placeholder }}
{% endif %}
{% else %}
{{ ''|placeholder }}
{% endif %}
{% endwith %}
{% endwith %}
{% else %}
{{ ''|placeholder }}
{% endif %}
</td>
</tr>
{% endwith %}
</table>
</div>