mirror of
https://github.com/netbox-community/netbox.git
synced 2025-07-24 17:38:37 -06:00
Work on IP to Prefix ForeignKey relationship
This commit is contained in:
parent
697610db94
commit
747fef0bc2
@ -531,6 +531,16 @@ class IPAddressFilterSet(NetBoxModelFilterSet, TenancyFilterSet):
|
||||
method='search_by_parent',
|
||||
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(
|
||||
method='filter_address',
|
||||
label=_('Address'),
|
||||
|
@ -318,6 +318,11 @@ class IPRangeBulkEditForm(NetBoxModelBulkEditForm):
|
||||
|
||||
|
||||
class IPAddressBulkEditForm(NetBoxModelBulkEditForm):
|
||||
prefix = DynamicModelChoiceField(
|
||||
queryset=Prefix.objects.all(),
|
||||
required=False,
|
||||
label=_('Prefix')
|
||||
)
|
||||
vrf = DynamicModelChoiceField(
|
||||
queryset=VRF.objects.all(),
|
||||
required=False,
|
||||
@ -359,10 +364,10 @@ class IPAddressBulkEditForm(NetBoxModelBulkEditForm):
|
||||
model = IPAddress
|
||||
fieldsets = (
|
||||
FieldSet('status', 'role', 'tenant', 'description'),
|
||||
FieldSet('vrf', 'mask_length', 'dns_name', name=_('Addressing')),
|
||||
FieldSet('prefix', 'vrf', 'mask_length', 'dns_name', name=_('Addressing')),
|
||||
)
|
||||
nullable_fields = (
|
||||
'vrf', 'role', 'tenant', 'dns_name', 'description', 'comments',
|
||||
'prefix', 'vrf', 'role', 'tenant', 'dns_name', 'description', 'comments',
|
||||
)
|
||||
|
||||
|
||||
|
@ -274,6 +274,13 @@ class IPRangeImportForm(NetBoxModelImportForm):
|
||||
|
||||
|
||||
class IPAddressImportForm(NetBoxModelImportForm):
|
||||
prefix = CSVModelChoiceField(
|
||||
label=_('Prefix'),
|
||||
queryset=Prefix.objects.all(),
|
||||
required=False,
|
||||
to_field_name='prefix',
|
||||
help_text=_('Assigned prefix')
|
||||
)
|
||||
vrf = CSVModelChoiceField(
|
||||
label=_('VRF'),
|
||||
queryset=VRF.objects.all(),
|
||||
@ -334,8 +341,8 @@ class IPAddressImportForm(NetBoxModelImportForm):
|
||||
class Meta:
|
||||
model = IPAddress
|
||||
fields = [
|
||||
'address', 'vrf', 'tenant', 'status', 'role', 'device', 'virtual_machine', 'interface', 'is_primary',
|
||||
'is_oob', 'dns_name', 'description', 'comments', 'tags',
|
||||
'prefix', 'address', 'vrf', 'tenant', 'status', 'role', 'device', 'virtual_machine', 'interface',
|
||||
'is_primary', 'is_oob', 'dns_name', 'description', 'comments', 'tags',
|
||||
]
|
||||
|
||||
def __init__(self, data=None, *args, **kwargs):
|
||||
|
@ -306,14 +306,14 @@ class IPAddressFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm):
|
||||
fieldsets = (
|
||||
FieldSet('q', 'filter_id', 'tag'),
|
||||
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')
|
||||
),
|
||||
FieldSet('vrf_id', 'present_in_vrf_id', name=_('VRF')),
|
||||
FieldSet('tenant_group_id', 'tenant_id', name=_('Tenant')),
|
||||
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(
|
||||
required=False,
|
||||
widget=forms.TextInput(
|
||||
@ -333,6 +333,11 @@ class IPAddressFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm):
|
||||
choices=IPADDRESS_MASK_LENGTH_CHOICES,
|
||||
label=_('Mask length')
|
||||
)
|
||||
prefix_id = DynamicModelMultipleChoiceField(
|
||||
queryset=Prefix.objects.all(),
|
||||
required=False,
|
||||
label=_('Prefix'),
|
||||
)
|
||||
vrf_id = DynamicModelMultipleChoiceField(
|
||||
queryset=VRF.objects.all(),
|
||||
required=False,
|
||||
|
@ -272,6 +272,15 @@ class IPRangeForm(TenancyForm, NetBoxModelForm):
|
||||
|
||||
|
||||
class IPAddressForm(TenancyForm, NetBoxModelForm):
|
||||
prefix = DynamicModelChoiceField(
|
||||
queryset=Prefix.objects.all(),
|
||||
required=False,
|
||||
context={
|
||||
'vrf': 'vrf',
|
||||
},
|
||||
selector=True,
|
||||
label=_('Prefix'),
|
||||
)
|
||||
interface = DynamicModelChoiceField(
|
||||
queryset=Interface.objects.all(),
|
||||
required=False,
|
||||
@ -318,7 +327,7 @@ class IPAddressForm(TenancyForm, NetBoxModelForm):
|
||||
comments = CommentField()
|
||||
|
||||
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(
|
||||
TabbedGroups(
|
||||
@ -334,8 +343,8 @@ class IPAddressForm(TenancyForm, NetBoxModelForm):
|
||||
class Meta:
|
||||
model = IPAddress
|
||||
fields = [
|
||||
'address', 'vrf', 'status', 'role', 'dns_name', 'primary_for_parent', 'oob_for_parent', 'nat_inside',
|
||||
'tenant_group', 'tenant', 'description', 'comments', 'tags',
|
||||
'prefix', 'address', 'vrf', 'status', 'role', 'dns_name', 'primary_for_parent', 'oob_for_parent',
|
||||
'nat_inside', 'tenant_group', 'tenant', 'description', 'comments', 'tags',
|
||||
]
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
@ -460,6 +469,15 @@ class IPAddressForm(TenancyForm, NetBoxModelForm):
|
||||
|
||||
|
||||
class IPAddressBulkAddForm(TenancyForm, NetBoxModelForm):
|
||||
prefix = DynamicModelChoiceField(
|
||||
queryset=Prefix.objects.all(),
|
||||
required=False,
|
||||
context={
|
||||
'vrf': 'vrf',
|
||||
},
|
||||
selector=True,
|
||||
label=_('Prefix'),
|
||||
)
|
||||
vrf = DynamicModelChoiceField(
|
||||
queryset=VRF.objects.all(),
|
||||
required=False,
|
||||
@ -469,7 +487,7 @@ class IPAddressBulkAddForm(TenancyForm, NetBoxModelForm):
|
||||
class Meta:
|
||||
model = IPAddress
|
||||
fields = [
|
||||
'address', 'vrf', 'status', 'role', 'dns_name', 'description', 'tenant_group', 'tenant', 'tags',
|
||||
'address', 'prefix', 'vrf', 'status', 'role', 'dns_name', 'description', 'tenant_group', 'tenant', 'tags',
|
||||
]
|
||||
|
||||
|
||||
|
@ -122,6 +122,7 @@ class FHRPGroupAssignmentType(BaseObjectType):
|
||||
)
|
||||
class IPAddressType(NetBoxObjectType, BaseIPAddressFamilyType):
|
||||
address: str
|
||||
prefix: Annotated["PrefixType", strawberry.lazy('ipam.graphql.types')] | None
|
||||
vrf: Annotated["VRFType", strawberry.lazy('ipam.graphql.types')] | None
|
||||
tenant: Annotated["TenantType", strawberry.lazy('tenancy.graphql.types')] | None
|
||||
nat_inside: Annotated["IPAddressType", strawberry.lazy('ipam.graphql.types')] | None
|
||||
|
67
netbox/ipam/migrations/0077_ipaddress_prefix.py
Normal file
67
netbox/ipam/migrations/0077_ipaddress_prefix.py
Normal 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)
|
||||
]
|
@ -411,7 +411,7 @@ class Prefix(ContactsMixin, GetAvailablePrefixesMixin, CachedScopeMixin, Primary
|
||||
return netaddr.IPSet()
|
||||
|
||||
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 = []
|
||||
for iprange in self.get_child_ranges():
|
||||
child_ranges.append(iprange.range)
|
||||
@ -462,7 +462,7 @@ class Prefix(ContactsMixin, GetAvailablePrefixesMixin, CachedScopeMixin, Primary
|
||||
else:
|
||||
# Compile an IPSet to avoid counting duplicate IPs
|
||||
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
|
||||
@ -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
|
||||
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(
|
||||
verbose_name=_('address'),
|
||||
help_text=_('IPv4 or IPv6 address (with mask)')
|
||||
@ -835,6 +843,11 @@ class IPAddress(ContactsMixin, PrimaryModel):
|
||||
super().clean()
|
||||
|
||||
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
|
||||
if self.address.prefixlen == 0:
|
||||
|
@ -52,11 +52,12 @@ class IPAddressIndex(SearchIndex):
|
||||
model = models.IPAddress
|
||||
fields = (
|
||||
('address', 100),
|
||||
('prefix', 200),
|
||||
('dns_name', 300),
|
||||
('description', 500),
|
||||
('comments', 5000),
|
||||
)
|
||||
display_attrs = ('vrf', 'tenant', 'status', 'role', 'description')
|
||||
display_attrs = ('prefix', 'vrf', 'tenant', 'status', 'role', 'description')
|
||||
|
||||
|
||||
@register_search
|
||||
|
@ -1,3 +1,4 @@
|
||||
from django.db.models import Q
|
||||
from django.db.models.signals import post_delete, post_save, pre_delete
|
||||
from django.dispatch import receiver
|
||||
|
||||
@ -26,12 +27,51 @@ def update_children_depth(prefix):
|
||||
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)
|
||||
def handle_prefix_saved(instance, created, **kwargs):
|
||||
|
||||
# Prefix has changed (or new instance has been created)
|
||||
if created or instance.vrf_id != instance._vrf_id or instance.prefix != instance._prefix:
|
||||
|
||||
update_ipaddress_prefix(instance)
|
||||
update_parents_children(instance)
|
||||
update_children_depth(instance)
|
||||
|
||||
@ -42,11 +82,17 @@ def handle_prefix_saved(instance, created, **kwargs):
|
||||
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)
|
||||
def handle_prefix_deleted(instance, **kwargs):
|
||||
|
||||
update_parents_children(instance)
|
||||
update_children_depth(instance)
|
||||
update_ipaddress_prefix(instance, delete=True)
|
||||
|
||||
|
||||
@receiver(pre_delete, sender=IPAddress)
|
||||
|
@ -307,6 +307,10 @@ class IPAddressTable(TenancyColumnsMixin, NetBoxTable):
|
||||
template_code=IPADDRESS_LINK,
|
||||
verbose_name=_('IP Address')
|
||||
)
|
||||
prefix = tables.Column(
|
||||
linkify=True,
|
||||
verbose_name=_('Prefix')
|
||||
)
|
||||
vrf = tables.TemplateColumn(
|
||||
template_code=VRF_LINK,
|
||||
verbose_name=_('VRF')
|
||||
|
@ -172,14 +172,14 @@ class TestPrefix(TestCase):
|
||||
IPAddress(address=IPNetwork('10.0.2.1/24'), vrf=vrfs[1]),
|
||||
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
|
||||
self.assertSetEqual(child_ip_pks, {ips[0].pk, ips[1].pk, ips[2].pk, ips[3].pk})
|
||||
|
||||
parent_prefix.vrf = vrfs[0]
|
||||
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
|
||||
self.assertSetEqual(child_ip_pks, {ips[1].pk})
|
||||
|
@ -493,7 +493,7 @@ class PrefixTestCase(ViewTestCases.PrimaryObjectViewTestCase):
|
||||
IPAddress(address=IPNetwork('192.168.0.3/16')),
|
||||
)
|
||||
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})
|
||||
self.assertHttpStatus(self.client.get(url), 200)
|
||||
|
@ -631,13 +631,13 @@ class PrefixIPAddressesView(generic.ObjectChildrenView):
|
||||
template_name = 'ipam/prefix/ip_addresses.html'
|
||||
tab = ViewTab(
|
||||
label=_('IP Addresses'),
|
||||
badge=lambda x: x.get_child_ips().count(),
|
||||
badge=lambda x: x.ip_addresses.count(),
|
||||
permission='ipam.view_ipaddress',
|
||||
weight=700
|
||||
)
|
||||
|
||||
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):
|
||||
if not request.GET.get('q') and not get_table_ordering(request, self.table):
|
||||
|
@ -14,6 +14,10 @@
|
||||
<th scope="row">{% trans "Family" %}</th>
|
||||
<td>IPv{{ object.family }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">{% trans "Prefix" %}</th>
|
||||
<td>{{ object.prefix|linkify|placeholder }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">{% trans "VRF" %}</th>
|
||||
<td>
|
||||
|
@ -14,6 +14,7 @@
|
||||
<div class="row">
|
||||
<h2 class="col-9 offset-3">{% trans "IP Addresses" %}</h2>
|
||||
</div>
|
||||
{% render_field model_form.prefix %}
|
||||
{% render_field form.pattern %}
|
||||
{% render_field model_form.status %}
|
||||
{% render_field model_form.role %}
|
||||
|
@ -109,7 +109,7 @@
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% with child_ip_count=object.get_child_ips.count %}
|
||||
{% with child_ip_count=object.ip_addresses.count %}
|
||||
<tr>
|
||||
<th scope="row">{% trans "Child IPs" %}</th>
|
||||
<td>
|
||||
|
Loading…
Reference in New Issue
Block a user