Work on IP to Prefix ForeignKey relationship

This commit is contained in:
Daniel Sheppard 2025-02-24 14:03:18 -06:00
parent 697610db94
commit 747fef0bc2
17 changed files with 201 additions and 19 deletions

View File

@ -531,6 +531,16 @@ class IPAddressFilterSet(NetBoxModelFilterSet, TenancyFilterSet):
method='search_by_parent', method='search_by_parent',
label=_('Parent prefix'), label=_('Parent prefix'),
) )
prefix_id = django_filters.ModelMultipleChoiceFilter(
queryset=Prefix.objects.all(),
label=_('Prefix (ID)'),
)
prefix = django_filters.ModelMultipleChoiceFilter(
field_name='prefix__prefix',
queryset=Prefix.objects.all(),
to_field_name='prefix',
label=_('Prefix (prefix)'),
)
address = MultiValueCharFilter( address = MultiValueCharFilter(
method='filter_address', method='filter_address',
label=_('Address'), label=_('Address'),

View File

@ -318,6 +318,11 @@ class IPRangeBulkEditForm(NetBoxModelBulkEditForm):
class IPAddressBulkEditForm(NetBoxModelBulkEditForm): class IPAddressBulkEditForm(NetBoxModelBulkEditForm):
prefix = DynamicModelChoiceField(
queryset=Prefix.objects.all(),
required=False,
label=_('Prefix')
)
vrf = DynamicModelChoiceField( vrf = DynamicModelChoiceField(
queryset=VRF.objects.all(), queryset=VRF.objects.all(),
required=False, required=False,
@ -359,10 +364,10 @@ class IPAddressBulkEditForm(NetBoxModelBulkEditForm):
model = IPAddress model = IPAddress
fieldsets = ( fieldsets = (
FieldSet('status', 'role', 'tenant', 'description'), FieldSet('status', 'role', 'tenant', 'description'),
FieldSet('vrf', 'mask_length', 'dns_name', name=_('Addressing')), FieldSet('prefix', 'vrf', 'mask_length', 'dns_name', name=_('Addressing')),
) )
nullable_fields = ( nullable_fields = (
'vrf', 'role', 'tenant', 'dns_name', 'description', 'comments', 'prefix', 'vrf', 'role', 'tenant', 'dns_name', 'description', 'comments',
) )

View File

@ -274,6 +274,13 @@ class IPRangeImportForm(NetBoxModelImportForm):
class IPAddressImportForm(NetBoxModelImportForm): class IPAddressImportForm(NetBoxModelImportForm):
prefix = CSVModelChoiceField(
label=_('Prefix'),
queryset=Prefix.objects.all(),
required=False,
to_field_name='prefix',
help_text=_('Assigned prefix')
)
vrf = CSVModelChoiceField( vrf = CSVModelChoiceField(
label=_('VRF'), label=_('VRF'),
queryset=VRF.objects.all(), queryset=VRF.objects.all(),
@ -334,8 +341,8 @@ class IPAddressImportForm(NetBoxModelImportForm):
class Meta: class Meta:
model = IPAddress model = IPAddress
fields = [ fields = [
'address', 'vrf', 'tenant', 'status', 'role', 'device', 'virtual_machine', 'interface', 'is_primary', 'prefix', 'address', 'vrf', 'tenant', 'status', 'role', 'device', 'virtual_machine', 'interface',
'is_oob', 'dns_name', 'description', 'comments', 'tags', 'is_primary', 'is_oob', 'dns_name', 'description', 'comments', 'tags',
] ]
def __init__(self, data=None, *args, **kwargs): def __init__(self, data=None, *args, **kwargs):

View File

@ -306,14 +306,14 @@ class IPAddressFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm):
fieldsets = ( fieldsets = (
FieldSet('q', 'filter_id', 'tag'), FieldSet('q', 'filter_id', 'tag'),
FieldSet( FieldSet(
'parent', 'family', 'status', 'role', 'mask_length', 'assigned_to_interface', 'dns_name', 'prefix', 'parent', 'family', 'status', 'role', 'mask_length', 'assigned_to_interface', 'dns_name',
name=_('Attributes') name=_('Attributes')
), ),
FieldSet('vrf_id', 'present_in_vrf_id', name=_('VRF')), FieldSet('vrf_id', 'present_in_vrf_id', name=_('VRF')),
FieldSet('tenant_group_id', 'tenant_id', name=_('Tenant')), FieldSet('tenant_group_id', 'tenant_id', name=_('Tenant')),
FieldSet('device_id', 'virtual_machine_id', name=_('Device/VM')), FieldSet('device_id', 'virtual_machine_id', name=_('Device/VM')),
) )
selector_fields = ('filter_id', 'q', 'region_id', 'group_id', 'parent', 'status', 'role') selector_fields = ('filter_id', 'q', 'region_id', 'group_id', 'prefix_id', 'parent', 'status', 'role')
parent = forms.CharField( parent = forms.CharField(
required=False, required=False,
widget=forms.TextInput( widget=forms.TextInput(
@ -333,6 +333,11 @@ class IPAddressFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm):
choices=IPADDRESS_MASK_LENGTH_CHOICES, choices=IPADDRESS_MASK_LENGTH_CHOICES,
label=_('Mask length') label=_('Mask length')
) )
prefix_id = DynamicModelMultipleChoiceField(
queryset=Prefix.objects.all(),
required=False,
label=_('Prefix'),
)
vrf_id = DynamicModelMultipleChoiceField( vrf_id = DynamicModelMultipleChoiceField(
queryset=VRF.objects.all(), queryset=VRF.objects.all(),
required=False, required=False,

View File

@ -272,6 +272,15 @@ class IPRangeForm(TenancyForm, NetBoxModelForm):
class IPAddressForm(TenancyForm, NetBoxModelForm): class IPAddressForm(TenancyForm, NetBoxModelForm):
prefix = DynamicModelChoiceField(
queryset=Prefix.objects.all(),
required=False,
context={
'vrf': 'vrf',
},
selector=True,
label=_('Prefix'),
)
interface = DynamicModelChoiceField( interface = DynamicModelChoiceField(
queryset=Interface.objects.all(), queryset=Interface.objects.all(),
required=False, required=False,
@ -318,7 +327,7 @@ class IPAddressForm(TenancyForm, NetBoxModelForm):
comments = CommentField() comments = CommentField()
fieldsets = ( fieldsets = (
FieldSet('address', 'status', 'role', 'vrf', 'dns_name', 'description', 'tags', name=_('IP Address')), FieldSet('prefix', 'address', 'status', 'role', 'vrf', 'dns_name', 'description', 'tags', name=_('IP Address')),
FieldSet('tenant_group', 'tenant', name=_('Tenancy')), FieldSet('tenant_group', 'tenant', name=_('Tenancy')),
FieldSet( FieldSet(
TabbedGroups( TabbedGroups(
@ -334,8 +343,8 @@ class IPAddressForm(TenancyForm, NetBoxModelForm):
class Meta: class Meta:
model = IPAddress model = IPAddress
fields = [ fields = [
'address', 'vrf', 'status', 'role', 'dns_name', 'primary_for_parent', 'oob_for_parent', 'nat_inside', 'prefix', 'address', 'vrf', 'status', 'role', 'dns_name', 'primary_for_parent', 'oob_for_parent',
'tenant_group', 'tenant', 'description', 'comments', 'tags', 'nat_inside', 'tenant_group', 'tenant', 'description', 'comments', 'tags',
] ]
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
@ -460,6 +469,15 @@ class IPAddressForm(TenancyForm, NetBoxModelForm):
class IPAddressBulkAddForm(TenancyForm, NetBoxModelForm): class IPAddressBulkAddForm(TenancyForm, NetBoxModelForm):
prefix = DynamicModelChoiceField(
queryset=Prefix.objects.all(),
required=False,
context={
'vrf': 'vrf',
},
selector=True,
label=_('Prefix'),
)
vrf = DynamicModelChoiceField( vrf = DynamicModelChoiceField(
queryset=VRF.objects.all(), queryset=VRF.objects.all(),
required=False, required=False,
@ -469,7 +487,7 @@ class IPAddressBulkAddForm(TenancyForm, NetBoxModelForm):
class Meta: class Meta:
model = IPAddress model = IPAddress
fields = [ fields = [
'address', 'vrf', 'status', 'role', 'dns_name', 'description', 'tenant_group', 'tenant', 'tags', 'address', 'prefix', 'vrf', 'status', 'role', 'dns_name', 'description', 'tenant_group', 'tenant', 'tags',
] ]

View File

@ -122,6 +122,7 @@ class FHRPGroupAssignmentType(BaseObjectType):
) )
class IPAddressType(NetBoxObjectType, BaseIPAddressFamilyType): class IPAddressType(NetBoxObjectType, BaseIPAddressFamilyType):
address: str address: str
prefix: Annotated["PrefixType", strawberry.lazy('ipam.graphql.types')] | None
vrf: Annotated["VRFType", strawberry.lazy('ipam.graphql.types')] | None vrf: Annotated["VRFType", strawberry.lazy('ipam.graphql.types')] | None
tenant: Annotated["TenantType", strawberry.lazy('tenancy.graphql.types')] | None tenant: Annotated["TenantType", strawberry.lazy('tenancy.graphql.types')] | None
nat_inside: Annotated["IPAddressType", strawberry.lazy('ipam.graphql.types')] | None nat_inside: Annotated["IPAddressType", strawberry.lazy('ipam.graphql.types')] | None

View File

@ -0,0 +1,67 @@
# Generated by Django 5.0.9 on 2025-02-20 16:49
import sys
import time
import django.db.models.deletion
from django.db import migrations, models
def draw_progress(count, total, length=20):
progress = count / total
percent = int(progress * 100)
bar = int(progress * length)
sys.stdout.write('\r')
sys.stdout.write(f"[{'=' * bar:{length}s}] {percent}%")
sys.stdout.flush()
def set_prefix(apps, schema_editor):
start = time.time()
IPAddress = apps.get_model('ipam', 'IPAddress')
Prefix = apps.get_model('ipam', 'Prefix')
addresses = IPAddress.objects.all()
i = 0
total = addresses.count()
draw_progress(i, total, 50)
for ip in addresses:
i += 1
prefixes = Prefix.objects.filter(
vrf=ip.vrf,
prefix__net_contains_or_equals=str(ip.address.ip),
prefix__net_mask_length__lte=ip.address.prefixlen,
)
ip.prefix = prefixes.last()
ip.save()
draw_progress(i, total, 50)
end = time.time()
print(f"\r\nElapsed Time: {end - start:.2f}s")
def unset_prefix(apps, schema_editor):
IPAddress = apps.get_model('ipam', 'IPAddress')
IPAddress.objects.update(prefix=None)
class Migration(migrations.Migration):
dependencies = [
('ipam', '0076_natural_ordering'),
]
operations = [
migrations.AddField(
model_name='ipaddress',
name='prefix',
field=models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name='ip_addresses',
to='ipam.prefix',
),
),
migrations.RunPython(set_prefix, unset_prefix)
]

View File

@ -411,7 +411,7 @@ class Prefix(ContactsMixin, GetAvailablePrefixesMixin, CachedScopeMixin, Primary
return netaddr.IPSet() return netaddr.IPSet()
prefix = netaddr.IPSet(self.prefix) prefix = netaddr.IPSet(self.prefix)
child_ips = netaddr.IPSet([ip.address.ip for ip in self.get_child_ips()]) child_ips = netaddr.IPSet([ip.address.ip for ip in self.ip_addresses.all()])
child_ranges = [] child_ranges = []
for iprange in self.get_child_ranges(): for iprange in self.get_child_ranges():
child_ranges.append(iprange.range) child_ranges.append(iprange.range)
@ -462,7 +462,7 @@ class Prefix(ContactsMixin, GetAvailablePrefixesMixin, CachedScopeMixin, Primary
else: else:
# Compile an IPSet to avoid counting duplicate IPs # Compile an IPSet to avoid counting duplicate IPs
child_ips = netaddr.IPSet( child_ips = netaddr.IPSet(
[_.range for _ in self.get_child_ranges()] + [_.address.ip for _ in self.get_child_ips()] [_.range for _ in self.get_child_ranges()] + [_.address.ip for _ in self.ip_addresses.all()]
) )
prefix_size = self.prefix.size prefix_size = self.prefix.size
@ -706,6 +706,14 @@ class IPAddress(ContactsMixin, PrimaryModel):
for example, when mapping public addresses to private addresses. When an Interface has been assigned an IPAddress for example, when mapping public addresses to private addresses. When an Interface has been assigned an IPAddress
which has a NAT outside IP, that Interface's Device can use either the inside or outside IP as its primary IP. which has a NAT outside IP, that Interface's Device can use either the inside or outside IP as its primary IP.
""" """
prefix = models.ForeignKey(
to='ipam.Prefix',
on_delete=models.SET_NULL,
related_name='ip_addresses',
blank=True,
null=True,
verbose_name=_('Prefix')
)
address = IPAddressField( address = IPAddressField(
verbose_name=_('address'), verbose_name=_('address'),
help_text=_('IPv4 or IPv6 address (with mask)') help_text=_('IPv4 or IPv6 address (with mask)')
@ -835,6 +843,11 @@ class IPAddress(ContactsMixin, PrimaryModel):
super().clean() super().clean()
if self.address: if self.address:
if self.prefix:
if self.address not in self.prefix.prefix:
raise ValidationError({
'prefix': _("IP address must be part of the selected prefix.")
})
# /0 masks are not acceptable # /0 masks are not acceptable
if self.address.prefixlen == 0: if self.address.prefixlen == 0:

View File

@ -52,11 +52,12 @@ class IPAddressIndex(SearchIndex):
model = models.IPAddress model = models.IPAddress
fields = ( fields = (
('address', 100), ('address', 100),
('prefix', 200),
('dns_name', 300), ('dns_name', 300),
('description', 500), ('description', 500),
('comments', 5000), ('comments', 5000),
) )
display_attrs = ('vrf', 'tenant', 'status', 'role', 'description') display_attrs = ('prefix', 'vrf', 'tenant', 'status', 'role', 'description')
@register_search @register_search

View File

@ -1,3 +1,4 @@
from django.db.models import Q
from django.db.models.signals import post_delete, post_save, pre_delete from django.db.models.signals import post_delete, post_save, pre_delete
from django.dispatch import receiver from django.dispatch import receiver
@ -26,12 +27,51 @@ def update_children_depth(prefix):
Prefix.objects.bulk_update(children, ['_depth'], batch_size=100) Prefix.objects.bulk_update(children, ['_depth'], batch_size=100)
def update_ipaddress_prefix(prefix, delete=False):
if delete:
# Get all possible addresses
addresses = IPAddress.objects.filter(prefix=prefix)
# Find a new containing prefix
prefix = Prefix.objects.filter(
prefix__net_contains_or_equals=prefix.prefix,
vrf=prefix.vrf
).exclude(pk=prefix.pk).last()
for address in addresses:
# Set contained addresses to the containing prefix if it exists
address.prefix = prefix
else:
# Get all possible modified addresses
addresses = IPAddress.objects.filter(
Q(address__net_contained_or_equal=prefix.prefix, vrf=prefix.vrf) |
Q(prefix=prefix)
)
for address in addresses:
if not address.prefix or (prefix.prefix in address.prefix.prefix and address.address in prefix.prefix):
# Set to new Prefix as the prefix is a child of the old prefix and the address is contained in the
# prefix
address.prefix = prefix
elif address.prefix and address.address not in prefix.prefix:
# Find a new prefix as the prefix no longer contains the address
address.prefix = Prefix.objects.filter(
prefix__net_contains_or_equals=address.address,
vrf=prefix.vrf
).last()
else:
# No-OP as the prefix does not require modification
pass
# Update the addresses
IPAddress.objects.bulk_update(addresses, ['prefix'], batch_size=100)
@receiver(post_save, sender=Prefix) @receiver(post_save, sender=Prefix)
def handle_prefix_saved(instance, created, **kwargs): def handle_prefix_saved(instance, created, **kwargs):
# Prefix has changed (or new instance has been created) # Prefix has changed (or new instance has been created)
if created or instance.vrf_id != instance._vrf_id or instance.prefix != instance._prefix: if created or instance.vrf_id != instance._vrf_id or instance.prefix != instance._prefix:
update_ipaddress_prefix(instance)
update_parents_children(instance) update_parents_children(instance)
update_children_depth(instance) update_children_depth(instance)
@ -42,11 +82,17 @@ def handle_prefix_saved(instance, created, **kwargs):
update_children_depth(old_prefix) update_children_depth(old_prefix)
@receiver(pre_delete, sender=Prefix)
def pre_handle_prefix_deleted(instance, **kwargs):
update_ipaddress_prefix(instance, True)
@receiver(post_delete, sender=Prefix) @receiver(post_delete, sender=Prefix)
def handle_prefix_deleted(instance, **kwargs): def handle_prefix_deleted(instance, **kwargs):
update_parents_children(instance) update_parents_children(instance)
update_children_depth(instance) update_children_depth(instance)
update_ipaddress_prefix(instance, delete=True)
@receiver(pre_delete, sender=IPAddress) @receiver(pre_delete, sender=IPAddress)

View File

@ -307,6 +307,10 @@ class IPAddressTable(TenancyColumnsMixin, NetBoxTable):
template_code=IPADDRESS_LINK, template_code=IPADDRESS_LINK,
verbose_name=_('IP Address') verbose_name=_('IP Address')
) )
prefix = tables.Column(
linkify=True,
verbose_name=_('Prefix')
)
vrf = tables.TemplateColumn( vrf = tables.TemplateColumn(
template_code=VRF_LINK, template_code=VRF_LINK,
verbose_name=_('VRF') verbose_name=_('VRF')

View File

@ -172,14 +172,14 @@ class TestPrefix(TestCase):
IPAddress(address=IPNetwork('10.0.2.1/24'), vrf=vrfs[1]), IPAddress(address=IPNetwork('10.0.2.1/24'), vrf=vrfs[1]),
IPAddress(address=IPNetwork('10.0.3.1/24'), vrf=vrfs[2]), IPAddress(address=IPNetwork('10.0.3.1/24'), vrf=vrfs[2]),
)) ))
child_ip_pks = {p.pk for p in parent_prefix.get_child_ips()} child_ip_pks = {p.pk for p in parent_prefix.ip_addresses.all()}
# Global container should return all children # Global container should return all children
self.assertSetEqual(child_ip_pks, {ips[0].pk, ips[1].pk, ips[2].pk, ips[3].pk}) self.assertSetEqual(child_ip_pks, {ips[0].pk, ips[1].pk, ips[2].pk, ips[3].pk})
parent_prefix.vrf = vrfs[0] parent_prefix.vrf = vrfs[0]
parent_prefix.save() parent_prefix.save()
child_ip_pks = {p.pk for p in parent_prefix.get_child_ips()} child_ip_pks = {p.pk for p in parent_prefix.ip_addresses.all()}
# VRF container is limited to its own VRF # VRF container is limited to its own VRF
self.assertSetEqual(child_ip_pks, {ips[1].pk}) self.assertSetEqual(child_ip_pks, {ips[1].pk})

View File

@ -493,7 +493,7 @@ class PrefixTestCase(ViewTestCases.PrimaryObjectViewTestCase):
IPAddress(address=IPNetwork('192.168.0.3/16')), IPAddress(address=IPNetwork('192.168.0.3/16')),
) )
IPAddress.objects.bulk_create(ip_addresses) IPAddress.objects.bulk_create(ip_addresses)
self.assertEqual(prefix.get_child_ips().count(), 3) self.assertEqual(prefix.ip_addresses.all().count(), 3)
url = reverse('ipam:prefix_ipaddresses', kwargs={'pk': prefix.pk}) url = reverse('ipam:prefix_ipaddresses', kwargs={'pk': prefix.pk})
self.assertHttpStatus(self.client.get(url), 200) self.assertHttpStatus(self.client.get(url), 200)

View File

@ -631,13 +631,13 @@ class PrefixIPAddressesView(generic.ObjectChildrenView):
template_name = 'ipam/prefix/ip_addresses.html' template_name = 'ipam/prefix/ip_addresses.html'
tab = ViewTab( tab = ViewTab(
label=_('IP Addresses'), label=_('IP Addresses'),
badge=lambda x: x.get_child_ips().count(), badge=lambda x: x.ip_addresses.count(),
permission='ipam.view_ipaddress', permission='ipam.view_ipaddress',
weight=700 weight=700
) )
def get_children(self, request, parent): def get_children(self, request, parent):
return parent.get_child_ips().restrict(request.user, 'view').prefetch_related('vrf', 'tenant', 'tenant__group') return parent.ip_addresses.restrict(request.user, 'view').prefetch_related('vrf', 'tenant', 'tenant__group')
def prep_table_data(self, request, queryset, parent): def prep_table_data(self, request, queryset, parent):
if not request.GET.get('q') and not get_table_ordering(request, self.table): if not request.GET.get('q') and not get_table_ordering(request, self.table):

View File

@ -14,6 +14,10 @@
<th scope="row">{% trans "Family" %}</th> <th scope="row">{% trans "Family" %}</th>
<td>IPv{{ object.family }}</td> <td>IPv{{ object.family }}</td>
</tr> </tr>
<tr>
<th scope="row">{% trans "Prefix" %}</th>
<td>{{ object.prefix|linkify|placeholder }}</td>
</tr>
<tr> <tr>
<th scope="row">{% trans "VRF" %}</th> <th scope="row">{% trans "VRF" %}</th>
<td> <td>

View File

@ -14,6 +14,7 @@
<div class="row"> <div class="row">
<h2 class="col-9 offset-3">{% trans "IP Addresses" %}</h2> <h2 class="col-9 offset-3">{% trans "IP Addresses" %}</h2>
</div> </div>
{% render_field model_form.prefix %}
{% render_field form.pattern %} {% render_field form.pattern %}
{% render_field model_form.status %} {% render_field model_form.status %}
{% render_field model_form.role %} {% render_field model_form.role %}

View File

@ -109,7 +109,7 @@
{% endif %} {% endif %}
</td> </td>
</tr> </tr>
{% with child_ip_count=object.get_child_ips.count %} {% with child_ip_count=object.ip_addresses.count %}
<tr> <tr>
<th scope="row">{% trans "Child IPs" %}</th> <th scope="row">{% trans "Child IPs" %}</th>
<td> <td>