From 747fef0bc2c2c13b4db9ea7aa9a1c261fc7ee3af Mon Sep 17 00:00:00 2001 From: Daniel Sheppard Date: Mon, 24 Feb 2025 14:03:18 -0600 Subject: [PATCH 01/10] Work on IP to Prefix ForeignKey relationship --- netbox/ipam/filtersets.py | 10 +++ netbox/ipam/forms/bulk_edit.py | 9 ++- netbox/ipam/forms/bulk_import.py | 11 ++- netbox/ipam/forms/filtersets.py | 9 ++- netbox/ipam/forms/model_forms.py | 26 +++++-- netbox/ipam/graphql/types.py | 1 + .../ipam/migrations/0077_ipaddress_prefix.py | 67 +++++++++++++++++++ netbox/ipam/models/ip.py | 17 ++++- netbox/ipam/search.py | 3 +- netbox/ipam/signals.py | 46 +++++++++++++ netbox/ipam/tables/ip.py | 4 ++ netbox/ipam/tests/test_models.py | 4 +- netbox/ipam/tests/test_views.py | 2 +- netbox/ipam/views.py | 4 +- netbox/templates/ipam/ipaddress.html | 4 ++ netbox/templates/ipam/ipaddress_bulk_add.html | 1 + netbox/templates/ipam/prefix.html | 2 +- 17 files changed, 201 insertions(+), 19 deletions(-) create mode 100644 netbox/ipam/migrations/0077_ipaddress_prefix.py diff --git a/netbox/ipam/filtersets.py b/netbox/ipam/filtersets.py index 81cbd2ef8..12a36fbe1 100644 --- a/netbox/ipam/filtersets.py +++ b/netbox/ipam/filtersets.py @@ -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'), diff --git a/netbox/ipam/forms/bulk_edit.py b/netbox/ipam/forms/bulk_edit.py index 7f3216cfd..2a071da8c 100644 --- a/netbox/ipam/forms/bulk_edit.py +++ b/netbox/ipam/forms/bulk_edit.py @@ -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', ) diff --git a/netbox/ipam/forms/bulk_import.py b/netbox/ipam/forms/bulk_import.py index c1f2dedd7..65a6e5601 100644 --- a/netbox/ipam/forms/bulk_import.py +++ b/netbox/ipam/forms/bulk_import.py @@ -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): diff --git a/netbox/ipam/forms/filtersets.py b/netbox/ipam/forms/filtersets.py index 3f951512b..e6aef3054 100644 --- a/netbox/ipam/forms/filtersets.py +++ b/netbox/ipam/forms/filtersets.py @@ -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, diff --git a/netbox/ipam/forms/model_forms.py b/netbox/ipam/forms/model_forms.py index c381f99c9..d7c6b17df 100644 --- a/netbox/ipam/forms/model_forms.py +++ b/netbox/ipam/forms/model_forms.py @@ -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', ] diff --git a/netbox/ipam/graphql/types.py b/netbox/ipam/graphql/types.py index e6ecca984..446ac2f2e 100644 --- a/netbox/ipam/graphql/types.py +++ b/netbox/ipam/graphql/types.py @@ -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 diff --git a/netbox/ipam/migrations/0077_ipaddress_prefix.py b/netbox/ipam/migrations/0077_ipaddress_prefix.py new file mode 100644 index 000000000..216fa166a --- /dev/null +++ b/netbox/ipam/migrations/0077_ipaddress_prefix.py @@ -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) + ] diff --git a/netbox/ipam/models/ip.py b/netbox/ipam/models/ip.py index e1a8d91e3..3db12f761 100644 --- a/netbox/ipam/models/ip.py +++ b/netbox/ipam/models/ip.py @@ -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: diff --git a/netbox/ipam/search.py b/netbox/ipam/search.py index 6e71d44a5..6150fbc4a 100644 --- a/netbox/ipam/search.py +++ b/netbox/ipam/search.py @@ -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 diff --git a/netbox/ipam/signals.py b/netbox/ipam/signals.py index 3b36b561f..25fde9ff2 100644 --- a/netbox/ipam/signals.py +++ b/netbox/ipam/signals.py @@ -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) diff --git a/netbox/ipam/tables/ip.py b/netbox/ipam/tables/ip.py index 1eefa6b3a..0ad984248 100644 --- a/netbox/ipam/tables/ip.py +++ b/netbox/ipam/tables/ip.py @@ -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') diff --git a/netbox/ipam/tests/test_models.py b/netbox/ipam/tests/test_models.py index 62eb74123..4644b0870 100644 --- a/netbox/ipam/tests/test_models.py +++ b/netbox/ipam/tests/test_models.py @@ -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}) diff --git a/netbox/ipam/tests/test_views.py b/netbox/ipam/tests/test_views.py index d7d367bb7..d3c7d9449 100644 --- a/netbox/ipam/tests/test_views.py +++ b/netbox/ipam/tests/test_views.py @@ -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) diff --git a/netbox/ipam/views.py b/netbox/ipam/views.py index 007a652ca..944aa211e 100644 --- a/netbox/ipam/views.py +++ b/netbox/ipam/views.py @@ -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): diff --git a/netbox/templates/ipam/ipaddress.html b/netbox/templates/ipam/ipaddress.html index d9196d9e8..ac6cc9dd0 100644 --- a/netbox/templates/ipam/ipaddress.html +++ b/netbox/templates/ipam/ipaddress.html @@ -14,6 +14,10 @@ {% trans "Family" %} IPv{{ object.family }} + + {% trans "Prefix" %} + {{ object.prefix|linkify|placeholder }} + {% trans "VRF" %} diff --git a/netbox/templates/ipam/ipaddress_bulk_add.html b/netbox/templates/ipam/ipaddress_bulk_add.html index a5b3599fc..965b91d10 100644 --- a/netbox/templates/ipam/ipaddress_bulk_add.html +++ b/netbox/templates/ipam/ipaddress_bulk_add.html @@ -14,6 +14,7 @@

{% trans "IP Addresses" %}

+ {% render_field model_form.prefix %} {% render_field form.pattern %} {% render_field model_form.status %} {% render_field model_form.role %} diff --git a/netbox/templates/ipam/prefix.html b/netbox/templates/ipam/prefix.html index 2416fc0f7..202b62282 100644 --- a/netbox/templates/ipam/prefix.html +++ b/netbox/templates/ipam/prefix.html @@ -109,7 +109,7 @@ {% endif %} - {% with child_ip_count=object.get_child_ips.count %} + {% with child_ip_count=object.ip_addresses.count %} {% trans "Child IPs" %} From 68d0b58293c7e32932bcc4c5bc058d7000c3b0bb Mon Sep 17 00:00:00 2001 From: Daniel Sheppard Date: Thu, 10 Apr 2025 08:22:23 -0500 Subject: [PATCH 02/10] Update migration --- netbox/ipam/migrations/0079_ipaddress_prefix.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/netbox/ipam/migrations/0079_ipaddress_prefix.py b/netbox/ipam/migrations/0079_ipaddress_prefix.py index 8d3c3b67c..cb0a2a4eb 100644 --- a/netbox/ipam/migrations/0079_ipaddress_prefix.py +++ b/netbox/ipam/migrations/0079_ipaddress_prefix.py @@ -24,6 +24,8 @@ def set_prefix(apps, schema_editor): addresses = IPAddress.objects.all() i = 0 total = addresses.count() + if total > 0: + print('\r\n') draw_progress(i, total, 50) for ip in addresses: i += 1 From 4c8301b3a5589398892aead03105e17f0f4a8437 Mon Sep 17 00:00:00 2001 From: Daniel Sheppard Date: Thu, 15 May 2025 08:35:45 -0500 Subject: [PATCH 03/10] Update migration --- .../{0079_ipaddress_prefix.py => 0082_ipaddress_prefix.py} | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) rename netbox/ipam/migrations/{0079_ipaddress_prefix.py => 0082_ipaddress_prefix.py} (95%) diff --git a/netbox/ipam/migrations/0079_ipaddress_prefix.py b/netbox/ipam/migrations/0082_ipaddress_prefix.py similarity index 95% rename from netbox/ipam/migrations/0079_ipaddress_prefix.py rename to netbox/ipam/migrations/0082_ipaddress_prefix.py index cb0a2a4eb..e96c48070 100644 --- a/netbox/ipam/migrations/0079_ipaddress_prefix.py +++ b/netbox/ipam/migrations/0082_ipaddress_prefix.py @@ -50,7 +50,7 @@ def unset_prefix(apps, schema_editor): class Migration(migrations.Migration): dependencies = [ - ('ipam', '0078_iprange_mark_utilized'), + ('ipam', '0081_remove_service_device_virtual_machine_add_parent_gfk_index'), ] operations = [ From c5e7b21147651483ec89e5ff6da4b401a5f9b0fc Mon Sep 17 00:00:00 2001 From: Daniel Sheppard Date: Thu, 5 Jun 2025 09:43:18 -0500 Subject: [PATCH 04/10] Add additional FKs --- .../ipam/migrations/0082_ipaddress_prefix.py | 103 ++++++++++++++++-- netbox/ipam/models/ip.py | 16 +++ 2 files changed, 110 insertions(+), 9 deletions(-) diff --git a/netbox/ipam/migrations/0082_ipaddress_prefix.py b/netbox/ipam/migrations/0082_ipaddress_prefix.py index e96c48070..7f9e78e24 100644 --- a/netbox/ipam/migrations/0082_ipaddress_prefix.py +++ b/netbox/ipam/migrations/0082_ipaddress_prefix.py @@ -16,7 +16,7 @@ def draw_progress(count, total, length=20): sys.stdout.flush() -def set_prefix(apps, schema_editor): +def set_ipaddress_prefix(apps, schema_editor): start = time.time() IPAddress = apps.get_model('ipam', 'IPAddress') Prefix = apps.get_model('ipam', 'Prefix') @@ -27,26 +27,87 @@ def set_prefix(apps, schema_editor): if total > 0: print('\r\n') draw_progress(i, total, 50) - for ip in addresses: + for address 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, + vrf=address.vrf, + prefix__net_contains_or_equals=str(address.address.ip), + prefix__net_mask_length__lte=address.address.prefixlen, ) - ip.prefix = prefixes.last() - ip.save() + address.prefix = prefixes.last() + address.save() draw_progress(i, total, 50) end = time.time() print(f"\r\nElapsed Time: {end - start:.2f}s") -def unset_prefix(apps, schema_editor): +def unset_ipaddress_prefix(apps, schema_editor): IPAddress = apps.get_model('ipam', 'IPAddress') IPAddress.objects.update(prefix=None) +def set_iprange_prefix(apps, schema_editor): + IPRange = apps.get_model('ipam', 'IPRange') + Prefix = apps.get_model('ipam', 'Prefix') + start = time.time() + + addresses = IPRange.objects.all() + i = 0 + total = addresses.count() + if total > 0: + print('\r\n') + draw_progress(i, total, 50) + for address in addresses: + i += 1 + prefixes = Prefix.objects.filter( + vrf=address.vrf, + prefix__net_contains_or_equals=str(address.start_address.ip), + prefix__net_mask_length__lte=address.start_address.prefixlen, + ) + address.prefix = prefixes.last() + address.save() + draw_progress(i, total, 50) + + end = time.time() + print(f"\r\nElapsed Time: {end - start:.2f}s") + + +def unset_iprange_prefix(apps, schema_editor): + IPRange = apps.get_model('ipam', 'IPRange') + IPRange.objects.update(prefix=None) + + +def set_prefix_aggregate(apps, schema_editor): + Prefix = apps.get_model('ipam', 'Prefix') + Aggregate = apps.get_model('ipam', 'Aggregate') + + start = time.time() + addresses = Prefix.objects.all() + i = 0 + total = addresses.count() + if total > 0: + print('\r\n') + draw_progress(i, total, 50) + for address in addresses: + i += 1 + aggregates = Aggregate.objects.filter( + prefix__net_contains_or_equals=str(address.prefix.ip), + prefix__net_mask_length__lte=address.prefix.prefixlen, + ) + address.aggregate = aggregates.last() + address.save() + draw_progress(i, total, 50) + + end = time.time() + print(f"\r\nElapsed Time: {end - start:.2f}s") + + +def unset_prefix_aggregate(apps, schema_editor): + Prefix = apps.get_model('ipam', 'Prefix') + Prefix.objects.update(aggregate=None) + + class Migration(migrations.Migration): dependencies = [ @@ -65,5 +126,29 @@ class Migration(migrations.Migration): to='ipam.prefix', ), ), - migrations.RunPython(set_prefix, unset_prefix) + migrations.AddField( + model_name='iprange', + name='prefix', + field=models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.PROTECT, + related_name='ip_ranges', + to='ipam.prefix', + ), + ), + migrations.AddField( + model_name='prefix', + name='aggregate', + field=models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.PROTECT, + related_name='prefixes', + to='ipam.aggregate', + ), + ), + migrations.RunPython(set_ipaddress_prefix, unset_ipaddress_prefix), + migrations.RunPython(set_iprange_prefix, unset_iprange_prefix), + migrations.RunPython(set_prefix_aggregate, unset_prefix_aggregate), ] diff --git a/netbox/ipam/models/ip.py b/netbox/ipam/models/ip.py index f356fb8ba..a3f5451b2 100644 --- a/netbox/ipam/models/ip.py +++ b/netbox/ipam/models/ip.py @@ -204,6 +204,14 @@ class Prefix(ContactsMixin, GetAvailablePrefixesMixin, CachedScopeMixin, Primary areas and/or assigned to VRFs. A Prefix must be assigned a status and may optionally be assigned a used-define Role. A Prefix can also be assigned to a VLAN where appropriate. """ + aggregate = models.ForeignKey( + to='ipam.Aggregate', + on_delete=models.PROTECT, + related_name='prefixes', + blank=True, + null=True, + verbose_name=_('aggregate') + ) prefix = IPNetworkField( verbose_name=_('prefix'), help_text=_('IPv4 or IPv6 network with mask') @@ -480,6 +488,14 @@ class IPRange(ContactsMixin, PrimaryModel): """ A range of IP addresses, defined by start and end addresses. """ + prefix = models.ForeignKey( + to='ipam.Prefix', + on_delete=models.PROTECT, + related_name='ip_ranges', + null=True, + blank=True, + verbose_name=_('prefix'), + ) start_address = IPAddressField( verbose_name=_('start address'), help_text=_('IPv4 or IPv6 address (with mask)') From b19f81cedebaa61197c1c5be62d6d7ae794a24e6 Mon Sep 17 00:00:00 2001 From: Daniel Sheppard Date: Wed, 9 Jul 2025 10:36:41 -0500 Subject: [PATCH 05/10] More work on IP Address/Range and Prefix relationship --- netbox/ipam/api/serializers_/ip.py | 3 + netbox/ipam/filtersets.py | 14 ++ netbox/ipam/forms/bulk_edit.py | 3 + netbox/ipam/forms/bulk_import.py | 4 + netbox/ipam/forms/filtersets.py | 10 +- netbox/ipam/forms/model_forms.py | 13 +- .../0082_ipaddress_iprange_prefix_parent.py | 58 ++++++ ...3_ipaddress_iprange_prefix_parent_data.py} | 97 +++++---- netbox/ipam/models/ip.py | 75 ++++++- netbox/ipam/search.py | 7 +- netbox/ipam/signals.py | 128 +++++++++++- netbox/ipam/tables/ip.py | 3 + netbox/ipam/tests/test_api.py | 3 + netbox/ipam/tests/test_filtersets.py | 6 + netbox/ipam/tests/test_models.py | 196 +++++++++++++++++- netbox/ipam/tests/test_views.py | 12 ++ 16 files changed, 574 insertions(+), 58 deletions(-) create mode 100644 netbox/ipam/migrations/0082_ipaddress_iprange_prefix_parent.py rename netbox/ipam/migrations/{0082_ipaddress_prefix.py => 0083_ipaddress_iprange_prefix_parent_data.py} (68%) diff --git a/netbox/ipam/api/serializers_/ip.py b/netbox/ipam/api/serializers_/ip.py index 6f815b5ce..a07eadce6 100644 --- a/netbox/ipam/api/serializers_/ip.py +++ b/netbox/ipam/api/serializers_/ip.py @@ -44,6 +44,7 @@ class AggregateSerializer(NetBoxModelSerializer): class PrefixSerializer(NetBoxModelSerializer): + # TODO: Alter for parent prefix family = ChoiceField(choices=IPAddressFamilyChoices, read_only=True) vrf = VRFSerializer(nested=True, required=False, allow_null=True) scope_type = ContentTypeField( @@ -134,6 +135,7 @@ class AvailablePrefixSerializer(serializers.Serializer): # class IPRangeSerializer(NetBoxModelSerializer): + # TODO: Alter for prefix family = ChoiceField(choices=IPAddressFamilyChoices, read_only=True) start_address = IPAddressField() end_address = IPAddressField() @@ -158,6 +160,7 @@ class IPRangeSerializer(NetBoxModelSerializer): # class IPAddressSerializer(NetBoxModelSerializer): + # TODO: Alter for prefix family = ChoiceField(choices=IPAddressFamilyChoices, read_only=True) address = IPAddressField() vrf = VRFSerializer(nested=True, required=False, allow_null=True) diff --git a/netbox/ipam/filtersets.py b/netbox/ipam/filtersets.py index 2434e6c1c..720c5d0bf 100644 --- a/netbox/ipam/filtersets.py +++ b/netbox/ipam/filtersets.py @@ -291,6 +291,8 @@ class RoleFilterSet(OrganizationalModelFilterSet): class PrefixFilterSet(NetBoxModelFilterSet, ScopedFilterSet, TenancyFilterSet, ContactModelFilterSet): + # TODO: Alter for aggregate + # TODO: Alter for parent prefix family = django_filters.NumberFilter( field_name='prefix', lookup_expr='family' @@ -457,6 +459,7 @@ class PrefixFilterSet(NetBoxModelFilterSet, ScopedFilterSet, TenancyFilterSet, C class IPRangeFilterSet(TenancyFilterSet, NetBoxModelFilterSet, ContactModelFilterSet): + # TODO: Alter for prefix family = django_filters.NumberFilter( field_name='start_address', lookup_expr='family' @@ -473,6 +476,16 @@ class IPRangeFilterSet(TenancyFilterSet, NetBoxModelFilterSet, ContactModelFilte method='search_contains', label=_('Ranges which contain this prefix or IP'), ) + 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'), + ) vrf_id = django_filters.ModelMultipleChoiceFilter( queryset=VRF.objects.all(), label=_('VRF'), @@ -549,6 +562,7 @@ class IPRangeFilterSet(TenancyFilterSet, NetBoxModelFilterSet, ContactModelFilte class IPAddressFilterSet(NetBoxModelFilterSet, TenancyFilterSet, ContactModelFilterSet): + # TODO: Alter for prefix family = django_filters.NumberFilter( field_name='address', lookup_expr='family' diff --git a/netbox/ipam/forms/bulk_edit.py b/netbox/ipam/forms/bulk_edit.py index 411208eeb..cefd59cbc 100644 --- a/netbox/ipam/forms/bulk_edit.py +++ b/netbox/ipam/forms/bulk_edit.py @@ -207,6 +207,7 @@ class RoleBulkEditForm(NetBoxModelBulkEditForm): class PrefixBulkEditForm(ScopedBulkEditForm, NetBoxModelBulkEditForm): + # TODO: Alter for parent prefix vlan_group = DynamicModelChoiceField( queryset=VLANGroup.objects.all(), required=False, @@ -276,6 +277,7 @@ class PrefixBulkEditForm(ScopedBulkEditForm, NetBoxModelBulkEditForm): class IPRangeBulkEditForm(NetBoxModelBulkEditForm): + # TODO: Alter for prefix vrf = DynamicModelChoiceField( queryset=VRF.objects.all(), required=False, @@ -323,6 +325,7 @@ class IPRangeBulkEditForm(NetBoxModelBulkEditForm): class IPAddressBulkEditForm(NetBoxModelBulkEditForm): + # TODO: Alter for prefix prefix = DynamicModelChoiceField( queryset=Prefix.objects.all(), required=False, diff --git a/netbox/ipam/forms/bulk_import.py b/netbox/ipam/forms/bulk_import.py index d4945a50d..f01f5bdf7 100644 --- a/netbox/ipam/forms/bulk_import.py +++ b/netbox/ipam/forms/bulk_import.py @@ -156,6 +156,8 @@ class RoleImportForm(NetBoxModelImportForm): class PrefixImportForm(ScopedImportForm, NetBoxModelImportForm): + # TODO: Alter for aggregate + # TODO: Alter for parent prefix vrf = CSVModelChoiceField( label=_('VRF'), queryset=VRF.objects.all(), @@ -245,6 +247,7 @@ class PrefixImportForm(ScopedImportForm, NetBoxModelImportForm): class IPRangeImportForm(NetBoxModelImportForm): + # TODO: Alter for prefix vrf = CSVModelChoiceField( label=_('VRF'), queryset=VRF.objects.all(), @@ -281,6 +284,7 @@ class IPRangeImportForm(NetBoxModelImportForm): class IPAddressImportForm(NetBoxModelImportForm): + # TODO: Alter for prefix prefix = CSVModelChoiceField( label=_('Prefix'), queryset=Prefix.objects.all(), diff --git a/netbox/ipam/forms/filtersets.py b/netbox/ipam/forms/filtersets.py index 669b94ec9..780d43bb5 100644 --- a/netbox/ipam/forms/filtersets.py +++ b/netbox/ipam/forms/filtersets.py @@ -278,10 +278,18 @@ class IPRangeFilterForm(ContactModelFilterForm, TenancyFilterForm, NetBoxModelFi model = IPRange fieldsets = ( FieldSet('q', 'filter_id', 'tag'), - FieldSet('family', 'vrf_id', 'status', 'role_id', 'mark_populated', 'mark_utilized', name=_('Attributes')), + FieldSet( + 'prefix', 'family', 'vrf_id', 'status', 'role_id', 'mark_populated', 'mark_utilized', name=_('Attributes') + ), FieldSet('tenant_group_id', 'tenant_id', name=_('Tenant')), FieldSet('contact', 'contact_role', 'contact_group', name=_('Contacts')), ) + prefix = DynamicModelMultipleChoiceField( + queryset=Prefix.objects.all(), + required=False, + label=_('Prefix'), + null_option='None' + ) family = forms.ChoiceField( required=False, choices=add_blank_choice(IPAddressFamilyChoices), diff --git a/netbox/ipam/forms/model_forms.py b/netbox/ipam/forms/model_forms.py index a8fd43421..1df915ca9 100644 --- a/netbox/ipam/forms/model_forms.py +++ b/netbox/ipam/forms/model_forms.py @@ -250,6 +250,11 @@ class PrefixForm(TenancyForm, ScopedForm, NetBoxModelForm): class IPRangeForm(TenancyForm, NetBoxModelForm): + prefix = DynamicModelChoiceField( + queryset=Prefix.objects.all(), + required=False, + label=_('Prefix') + ) vrf = DynamicModelChoiceField( queryset=VRF.objects.all(), required=False, @@ -265,8 +270,8 @@ class IPRangeForm(TenancyForm, NetBoxModelForm): fieldsets = ( FieldSet( - 'vrf', 'start_address', 'end_address', 'role', 'status', 'mark_populated', 'mark_utilized', 'description', - 'tags', name=_('IP Range') + 'prefix', 'vrf', 'start_address', 'end_address', 'role', 'status', 'mark_populated', 'mark_utilized', + 'description', 'tags', name=_('IP Range') ), FieldSet('tenant_group', 'tenant', name=_('Tenancy')), ) @@ -274,8 +279,8 @@ class IPRangeForm(TenancyForm, NetBoxModelForm): class Meta: model = IPRange fields = [ - 'vrf', 'start_address', 'end_address', 'status', 'role', 'tenant_group', 'tenant', 'mark_populated', - 'mark_utilized', 'description', 'comments', 'tags', + 'prefix', 'vrf', 'start_address', 'end_address', 'status', 'role', 'tenant_group', 'tenant', + 'mark_populated', 'mark_utilized', 'description', 'comments', 'tags', ] diff --git a/netbox/ipam/migrations/0082_ipaddress_iprange_prefix_parent.py b/netbox/ipam/migrations/0082_ipaddress_iprange_prefix_parent.py new file mode 100644 index 000000000..d86c18670 --- /dev/null +++ b/netbox/ipam/migrations/0082_ipaddress_iprange_prefix_parent.py @@ -0,0 +1,58 @@ +# Generated by Django 5.0.9 on 2025-02-20 16:49 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('ipam', '0081_remove_service_device_virtual_machine_add_parent_gfk_index'), + ] + + operations = [ + migrations.AddField( + model_name='prefix', + name='parent', + field=models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name='children', + to='ipam.prefix', + ), + ), + 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.AddField( + model_name='iprange', + name='prefix', + field=models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name='ip_ranges', + to='ipam.prefix', + ), + ), + migrations.AddField( + model_name='prefix', + name='aggregate', + field=models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name='prefixes', + to='ipam.aggregate', + ), + ), + ] diff --git a/netbox/ipam/migrations/0082_ipaddress_prefix.py b/netbox/ipam/migrations/0083_ipaddress_iprange_prefix_parent_data.py similarity index 68% rename from netbox/ipam/migrations/0082_ipaddress_prefix.py rename to netbox/ipam/migrations/0083_ipaddress_iprange_prefix_parent_data.py index 7f9e78e24..321c51f38 100644 --- a/netbox/ipam/migrations/0082_ipaddress_prefix.py +++ b/netbox/ipam/migrations/0083_ipaddress_iprange_prefix_parent_data.py @@ -3,11 +3,14 @@ import sys import time -import django.db.models.deletion from django.db import migrations, models +from ipam.choices import PrefixStatusChoices + def draw_progress(count, total, length=20): + if total == 0: + return progress = count / total percent = int(progress * 100) bar = int(progress * length) @@ -24,8 +27,9 @@ def set_ipaddress_prefix(apps, schema_editor): addresses = IPAddress.objects.all() i = 0 total = addresses.count() - if total > 0: - print('\r\n') + if total == 0: + return + print('\r\n') draw_progress(i, total, 50) for address in addresses: i += 1 @@ -55,8 +59,10 @@ def set_iprange_prefix(apps, schema_editor): addresses = IPRange.objects.all() i = 0 total = addresses.count() - if total > 0: - print('\r\n') + if total == 0: + return + + print('\r\n') draw_progress(i, total, 50) for address in addresses: i += 1 @@ -86,8 +92,10 @@ def set_prefix_aggregate(apps, schema_editor): addresses = Prefix.objects.all() i = 0 total = addresses.count() - if total > 0: - print('\r\n') + if total == 0: + return + + print('\r\n') draw_progress(i, total, 50) for address in addresses: i += 1 @@ -108,47 +116,54 @@ def unset_prefix_aggregate(apps, schema_editor): Prefix.objects.update(aggregate=None) +def set_prefix_parent(apps, schema_editor): + Prefix = apps.get_model('ipam', 'Prefix') + start = time.time() + addresses = Prefix.objects.all() + i = 0 + total = addresses.count() + if total == 0: + return + + print('\r\n') + draw_progress(i, total, 50) + for address in addresses: + i += 1 + prefixes = Prefix.objects.exclude(pk=address.pk).filter( + models.Q( + vrf=address.vrf, + prefix__net_contains=str(address.prefix.ip) + ) | models.Q( + vrf=None, + status=PrefixStatusChoices.STATUS_CONTAINER, + prefix__net_contains=str(address.prefix.ip), + ) + ) + if not prefixes.exists(): + draw_progress(i, total, 50) + continue + + address.parent = prefixes.last() + address.save() + draw_progress(i, total, 50) + end = time.time() + print(f"\r\nElapsed Time: {end - start:.2f}s") + + +def unset_prefix_parent(apps, schema_editor): + Prefix = apps.get_model('ipam', 'Prefix') + Prefix.objects.update(parent=None) + + class Migration(migrations.Migration): dependencies = [ - ('ipam', '0081_remove_service_device_virtual_machine_add_parent_gfk_index'), + ('ipam', '0082_ipaddress_iprange_prefix_parent'), ] 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.AddField( - model_name='iprange', - name='prefix', - field=models.ForeignKey( - blank=True, - null=True, - on_delete=django.db.models.deletion.PROTECT, - related_name='ip_ranges', - to='ipam.prefix', - ), - ), - migrations.AddField( - model_name='prefix', - name='aggregate', - field=models.ForeignKey( - blank=True, - null=True, - on_delete=django.db.models.deletion.PROTECT, - related_name='prefixes', - to='ipam.aggregate', - ), - ), migrations.RunPython(set_ipaddress_prefix, unset_ipaddress_prefix), migrations.RunPython(set_iprange_prefix, unset_iprange_prefix), migrations.RunPython(set_prefix_aggregate, unset_prefix_aggregate), + migrations.RunPython(set_prefix_parent, unset_prefix_parent), ] diff --git a/netbox/ipam/models/ip.py b/netbox/ipam/models/ip.py index a3f5451b2..45bce76c9 100644 --- a/netbox/ipam/models/ip.py +++ b/netbox/ipam/models/ip.py @@ -6,6 +6,7 @@ from django.db.models import F from django.db.models.functions import Cast from django.utils.functional import cached_property from django.utils.translation import gettext_lazy as _ +from netaddr.ip import IPNetwork from core.models import ObjectType from dcim.models.mixins import CachedScopeMixin @@ -206,12 +207,20 @@ class Prefix(ContactsMixin, GetAvailablePrefixesMixin, CachedScopeMixin, Primary """ aggregate = models.ForeignKey( to='ipam.Aggregate', - on_delete=models.PROTECT, + on_delete=models.SET_NULL, related_name='prefixes', blank=True, null=True, verbose_name=_('aggregate') ) + parent = models.ForeignKey( + to='ipam.Prefix', + on_delete=models.SET_NULL, + related_name='children', + blank=True, + null=True, + verbose_name=_('Prefix') + ) prefix = IPNetworkField( verbose_name=_('prefix'), help_text=_('IPv4 or IPv6 network with mask') @@ -299,6 +308,8 @@ class Prefix(ContactsMixin, GetAvailablePrefixesMixin, CachedScopeMixin, Primary super().clean() if self.prefix: + if not isinstance(self.prefix, IPNetwork): + self.prefix = IPNetwork(self.prefix) # /0 masks are not acceptable if self.prefix.prefixlen == 0: @@ -306,6 +317,17 @@ class Prefix(ContactsMixin, GetAvailablePrefixesMixin, CachedScopeMixin, Primary 'prefix': _("Cannot create prefix with /0 mask.") }) + if self.parent: + if self.prefix not in self.parent.prefix: + raise ValidationError({ + 'parent': _("Prefix must be part of parent prefix.") + }) + + if self.parent.status != PrefixStatusChoices.STATUS_CONTAINER and self.vrf != self.parent.vrf: + raise ValidationError({ + 'vrf': _("VRF must match the parent VRF.") + }) + # Enforce unique IP space (if applicable) if (self.vrf is None and get_config().ENFORCE_GLOBAL_UNIQUE) or (self.vrf and self.vrf.enforce_unique): duplicate_prefixes = self.get_duplicates() @@ -319,6 +341,14 @@ class Prefix(ContactsMixin, GetAvailablePrefixesMixin, CachedScopeMixin, Primary }) def save(self, *args, **kwargs): + vrf_id = self.vrf.pk if self.vrf else None + + if not self.pk and not self.parent: + parent = self.find_parent_prefix(self) + self.parent = parent + elif self.parent and (self.prefix != self._prefix or vrf_id != self._vrf_id): + parent = self.find_parent_prefix(self) + self.parent = parent if isinstance(self.prefix, netaddr.IPNetwork): @@ -483,6 +513,20 @@ class Prefix(ContactsMixin, GetAvailablePrefixesMixin, CachedScopeMixin, Primary return min(utilization, 100) + @classmethod + def find_parent_prefix(cls, network): + prefixes = Prefix.objects.filter( + models.Q( + vrf=network.vrf, + prefix__net_contains=str(network) + ) | models.Q( + vrf=None, + status=PrefixStatusChoices.STATUS_CONTAINER, + prefix__net_contains=str(network), + ) + ) + return prefixes.last() + class IPRange(ContactsMixin, PrimaryModel): """ @@ -490,7 +534,7 @@ class IPRange(ContactsMixin, PrimaryModel): """ prefix = models.ForeignKey( to='ipam.Prefix', - on_delete=models.PROTECT, + on_delete=models.SET_NULL, related_name='ip_ranges', null=True, blank=True, @@ -565,6 +609,27 @@ class IPRange(ContactsMixin, PrimaryModel): super().clean() if self.start_address and self.end_address: + # If prefix is set, validate suitability + if self.prefix: + # Check that start address and end address are within the prefix range + if self.start_address not in self.prefix.prefix and self.end_address not in self.prefix.prefix: + raise ValidationError({ + 'start_address': _("Start address must be part of the selected prefix"), + 'end_address': _("End address must be part of the selected prefix.") + }) + elif self.start_address not in self.prefix.prefix: + raise ValidationError({ + 'start_address': _("Start address must be part of the selected prefix") + }) + elif self.end_address not in self.prefix.prefix: + raise ValidationError({ + 'end_address': _("End address must be part of the selected prefix.") + }) + # Check that VRF matches prefix VRF + if self.vrf != self.prefix.vrf: + raise ValidationError({ + 'vrf': _("VRF must match the prefix VRF.") + }) # Check that start & end IP versions match if self.start_address.version != self.end_address.version: @@ -828,6 +893,7 @@ class IPAddress(ContactsMixin, PrimaryModel): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) + self._address = self.address # Denote the original assigned object (if any) for validation in clean() self._original_assigned_object_id = self.__dict__.get('assigned_object_id') self._original_assigned_object_type_id = self.__dict__.get('assigned_object_type_id') @@ -869,11 +935,16 @@ class IPAddress(ContactsMixin, PrimaryModel): super().clean() if self.address: + # If prefix is set, validate suitability if self.prefix: if self.address not in self.prefix.prefix: raise ValidationError({ 'prefix': _("IP address must be part of the selected prefix.") }) + if self.vrf != self.prefix.vrf: + raise ValidationError({ + 'vrf': _("IP address VRF must match the prefix VRF.") + }) # /0 masks are not acceptable if self.address.prefixlen == 0: diff --git a/netbox/ipam/search.py b/netbox/ipam/search.py index 953cb5fb9..664165d73 100644 --- a/netbox/ipam/search.py +++ b/netbox/ipam/search.py @@ -66,10 +66,11 @@ class IPRangeIndex(SearchIndex): fields = ( ('start_address', 100), ('end_address', 300), + ('prefix', 400), ('description', 500), ('comments', 5000), ) - display_attrs = ('vrf', 'tenant', 'status', 'role', 'description') + display_attrs = ('prefix', 'vrf', 'tenant', 'status', 'role', 'description') @register_search @@ -77,10 +78,12 @@ class PrefixIndex(SearchIndex): model = models.Prefix fields = ( ('prefix', 110), + ('parent', 200), + ('aggregate', 300), ('description', 500), ('comments', 5000), ) - display_attrs = ('scope', 'vrf', 'tenant', 'vlan', 'status', 'role', 'description') + display_attrs = ('scope', 'aggregate', 'parent', 'vrf', 'tenant', 'vlan', 'status', 'role', 'description') @register_search diff --git a/netbox/ipam/signals.py b/netbox/ipam/signals.py index 25fde9ff2..450e89758 100644 --- a/netbox/ipam/signals.py +++ b/netbox/ipam/signals.py @@ -1,10 +1,12 @@ from django.db.models import Q from django.db.models.signals import post_delete, post_save, pre_delete from django.dispatch import receiver +from netaddr.ip import IPNetwork from dcim.models import Device from virtualization.models import VirtualMachine -from .models import IPAddress, Prefix +from .choices import PrefixStatusChoices +from .models import IPAddress, Prefix, IPRange def update_parents_children(prefix): @@ -46,6 +48,7 @@ def update_ipaddress_prefix(prefix, delete=False): 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 @@ -65,6 +68,126 @@ def update_ipaddress_prefix(prefix, delete=False): IPAddress.objects.bulk_update(addresses, ['prefix'], batch_size=100) +def update_iprange_prefix(prefix, delete=False): + if delete: + # Get all possible addresses + addresses = IPRange.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 = IPRange.objects.filter( + Q(start_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.start_address in prefix.prefix and + address.end_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(Q(prefix__net_contains_or_equals=address.start_address) & + Q(prefix__net_contains_or_equals=address.end_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) + + +def update_prefix_parents(prefix, delete=False): + if delete: + # Get all possible addresses + prefixes = prefix.children.all() + + for pfx in prefixes: + parent = Prefix.objects.exclude(pk=pfx.pk).exclude(pk=prefix.pk).filter( + Q( + vrf=pfx.vrf, + prefix__net_contains=str(pfx.prefix) + ) | Q( + vrf=None, + status=PrefixStatusChoices.STATUS_CONTAINER, + prefix__net_contains=str(pfx.prefix), + ) + ).last() + # Set contained addresses to the containing prefix if it exists + pfx.parent = parent + else: + # Get all possible addresses + prefixes = prefix.children.all() | Prefix.objects.filter( + Q( + parent=prefix.parent, + vrf=prefix.vrf, + prefix__net_contained=str(prefix.prefix) + ) | Q( + parent=prefix.parent, + vrf=None, + status=PrefixStatusChoices.STATUS_CONTAINER, + prefix__net_contained=str(prefix.prefix), + ) + ) + + if isinstance(prefix.prefix, str): + prefix.prefix = IPNetwork(prefix.prefix) + for pfx in prefixes: + if isinstance(pfx.prefix, str): + pfx.prefix = IPNetwork(pfx.prefix) + + if pfx.parent == prefix and pfx.prefix.ip not in prefix.prefix: + # Find new parents for orphaned prefixes + parent = Prefix.objects.exclude(pk=pfx.pk).filter( + Q( + vrf=pfx.vrf, + prefix__net_contains=str(pfx.prefix) + ) | Q( + vrf=None, + status=PrefixStatusChoices.STATUS_CONTAINER, + prefix__net_contains=str(pfx.prefix), + ) + ).last() + # Set contained addresses to the containing prefix if it exists + pfx.parent = parent + elif pfx.parent == prefix and pfx.vrf != prefix.vrf: + # Find new parents for orphaned prefixes + parent = Prefix.objects.exclude(pk=pfx.pk).filter( + Q( + vrf=pfx.vrf, + prefix__net_contains=str(pfx.prefix) + ) | Q( + vrf=None, + status=PrefixStatusChoices.STATUS_CONTAINER, + prefix__net_contains=str(pfx.prefix), + ) + ).last() + # Set contained addresses to the containing prefix if it exists + pfx.parent = parent + elif pfx.parent != prefix and pfx.vrf == prefix.vrf and pfx.prefix in prefix.prefix: + # Set the parent to the prefix + pfx.parent = prefix + else: + # No-OP as the prefix does not require modification + pass + + # Update the prefixes + Prefix.objects.bulk_update(prefixes, ['parent'], batch_size=100) + + @receiver(post_save, sender=Prefix) def handle_prefix_saved(instance, created, **kwargs): @@ -72,6 +195,7 @@ def handle_prefix_saved(instance, created, **kwargs): if created or instance.vrf_id != instance._vrf_id or instance.prefix != instance._prefix: update_ipaddress_prefix(instance) + update_prefix_parents(instance) update_parents_children(instance) update_children_depth(instance) @@ -85,6 +209,7 @@ def handle_prefix_saved(instance, created, **kwargs): @receiver(pre_delete, sender=Prefix) def pre_handle_prefix_deleted(instance, **kwargs): update_ipaddress_prefix(instance, True) + update_prefix_parents(instance, delete=True) @receiver(post_delete, sender=Prefix) @@ -93,6 +218,7 @@ def handle_prefix_deleted(instance, **kwargs): update_parents_children(instance) update_children_depth(instance) update_ipaddress_prefix(instance, delete=True) + update_prefix_parents(instance, delete=True) @receiver(pre_delete, sender=IPAddress) diff --git a/netbox/ipam/tables/ip.py b/netbox/ipam/tables/ip.py index c0d7a1c2f..967fa009d 100644 --- a/netbox/ipam/tables/ip.py +++ b/netbox/ipam/tables/ip.py @@ -155,6 +155,7 @@ class PrefixUtilizationColumn(columns.UtilizationColumn): class PrefixTable(TenancyColumnsMixin, NetBoxTable): + # TODO: Alter for parent prefix prefix = columns.TemplateColumn( verbose_name=_('Prefix'), template_code=PREFIX_LINK_WITH_DEPTH, @@ -253,6 +254,7 @@ class PrefixTable(TenancyColumnsMixin, NetBoxTable): # IP ranges # class IPRangeTable(TenancyColumnsMixin, NetBoxTable): + # TODO: Alter for prefix start_address = tables.Column( verbose_name=_('Start address'), linkify=True @@ -309,6 +311,7 @@ class IPRangeTable(TenancyColumnsMixin, NetBoxTable): # class IPAddressTable(TenancyColumnsMixin, NetBoxTable): + # TODO: Alter for prefix address = tables.TemplateColumn( template_code=IPADDRESS_LINK, verbose_name=_('IP Address') diff --git a/netbox/ipam/tests/test_api.py b/netbox/ipam/tests/test_api.py index 4b9b340c4..907924f25 100644 --- a/netbox/ipam/tests/test_api.py +++ b/netbox/ipam/tests/test_api.py @@ -356,6 +356,7 @@ class RoleTest(APIViewTestCases.APIViewTestCase): class PrefixTest(APIViewTestCases.APIViewTestCase): model = Prefix + # TODO: Alter for parent prefix brief_fields = ['_depth', 'description', 'display', 'family', 'id', 'prefix', 'url'] create_data = [ { @@ -535,6 +536,7 @@ class PrefixTest(APIViewTestCases.APIViewTestCase): class IPRangeTest(APIViewTestCases.APIViewTestCase): model = IPRange + # TODO: Alter for parent prefix brief_fields = ['description', 'display', 'end_address', 'family', 'id', 'start_address', 'url'] create_data = [ { @@ -634,6 +636,7 @@ class IPRangeTest(APIViewTestCases.APIViewTestCase): class IPAddressTest(APIViewTestCases.APIViewTestCase): model = IPAddress + # TODO: Alter for parent prefix brief_fields = ['address', 'description', 'display', 'family', 'id', 'url'] create_data = [ { diff --git a/netbox/ipam/tests/test_filtersets.py b/netbox/ipam/tests/test_filtersets.py index c1b4acf60..94cb39a51 100644 --- a/netbox/ipam/tests/test_filtersets.py +++ b/netbox/ipam/tests/test_filtersets.py @@ -901,6 +901,10 @@ class PrefixTestCase(TestCase, ChangeLoggedFilterSetTests): params = {'description': ['foobar1', 'foobar2']} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + # TODO: Test for parent prefix + # TODO: Test for children? + # TODO: Test for aggregate + class IPRangeTestCase(TestCase, ChangeLoggedFilterSetTests): queryset = IPRange.objects.all() @@ -1079,6 +1083,7 @@ class IPRangeTestCase(TestCase, ChangeLoggedFilterSetTests): self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) def test_parent(self): + # TODO: Alter for prefix params = {'parent': ['10.0.1.0/24', '10.0.2.0/24']} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) params = {'parent': ['10.0.1.0/25']} # Range 10.0.1.100-199 is not fully contained by 10.0.1.0/25 @@ -1315,6 +1320,7 @@ class IPAddressTestCase(TestCase, ChangeLoggedFilterSetTests): self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) def test_parent(self): + # TODO: Alter for prefix params = {'parent': ['10.0.0.0/30', '2001:db8::/126']} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 8) diff --git a/netbox/ipam/tests/test_models.py b/netbox/ipam/tests/test_models.py index ec191edc2..c5efb8efa 100644 --- a/netbox/ipam/tests/test_models.py +++ b/netbox/ipam/tests/test_models.py @@ -39,6 +39,26 @@ class TestAggregate(TestCase): class TestIPRange(TestCase): + @classmethod + def setUpTestData(cls): + cls.vrf = VRF.objects.create(name='VRF A', rd='1:1') + + cls.prefixes = ( + + # IPv4 + Prefix(prefix='192.0.0.0/16'), + Prefix(prefix='192.0.2.0/24'), + Prefix(prefix='192.0.0.0/16', vrf=cls.vrf), + + # IPv6 + Prefix(prefix='2001:db8::/32'), + Prefix(prefix='2001:db8::/64'), + + ) + + for prefix in cls.prefixes: + prefix.clean() + prefix.save() def test_overlapping_range(self): iprange_192_168 = IPRange.objects.create( @@ -87,6 +107,69 @@ class TestIPRange(TestCase): ) iprange_4_198_201.clean() + def test_parent_prefix(self): + ranges = ( + IPRange( + start_address=IPNetwork('192.0.0.1/24'), + end_address=IPNetwork('192.0.0.254/24'), + prefix=self.prefixes[0] + ), + IPRange( + start_address=IPNetwork('192.0.2.1/24'), + end_address=IPNetwork('192.0.2.254/24'), + prefix=self.prefixes[1] + ), + IPRange( + start_address=IPNetwork('192.0.2.1/24'), + end_address=IPNetwork('192.0.2.254/24'), + vrf=self.vrf, + prefix=self.prefixes[2] + ), + IPRange( + start_address=IPNetwork('2001:db8::/64'), + end_address=IPNetwork('2001:db8::ffff/64'), + prefix=self.prefixes[4] + ), + IPRange( + start_address=IPNetwork('2001:db8:2::/64'), + end_address=IPNetwork('2001:db8:2::ffff/64'), + prefix=self.prefixes[3] + ), + ) + + for range in ranges: + range.clean() + range.save() + + self.assertEqual(ranges[0].prefix, self.prefixes[0]) + self.assertEqual(ranges[1].prefix, self.prefixes[1]) + self.assertEqual(ranges[2].prefix, self.prefixes[2]) + self.assertEqual(ranges[3].prefix, self.prefixes[4]) + + def test_parent_prefix_change(self): + + range = IPRange( + start_address=IPNetwork('192.0.1.1/24'), + end_address=IPNetwork('192.0.1.254/24'), + prefix=self.prefixes[0] + ) + range.clean() + range.save() + + prefix = Prefix(prefix='192.0.1.0/17') + prefix.clean() + prefix.save() + + range.refresh_from_db() + + self.assertEqual(range.prefix, prefix) + + # TODO: Prefix Altered + # TODO: Prefix Deleted + + # TODO: Prefix falls outside range + # TODO: Prefix VRF does not match range VRF + class TestPrefix(TestCase): @@ -344,17 +427,21 @@ class TestPrefixHierarchy(TestCase): prefixes = ( # IPv4 - Prefix(prefix='10.0.0.0/8', _depth=0, _children=2), - Prefix(prefix='10.0.0.0/16', _depth=1, _children=1), - Prefix(prefix='10.0.0.0/24', _depth=2, _children=0), + Prefix(prefix='10.0.0.0/8'), + Prefix(prefix='10.0.0.0/16'), + Prefix(prefix='10.0.0.0/24'), + Prefix(prefix='192.168.0.0/16'), # IPv6 - Prefix(prefix='2001:db8::/32', _depth=0, _children=2), - Prefix(prefix='2001:db8::/40', _depth=1, _children=1), - Prefix(prefix='2001:db8::/48', _depth=2, _children=0), + Prefix(prefix='2001:db8::/32'), + Prefix(prefix='2001:db8::/40'), + Prefix(prefix='2001:db8::/48'), ) - Prefix.objects.bulk_create(prefixes) + + for prefix in prefixes: + prefix.clean() + prefix.save() def test_create_prefix4(self): # Create 10.0.0.0/12 @@ -362,15 +449,19 @@ class TestPrefixHierarchy(TestCase): prefixes = Prefix.objects.filter(prefix__family=4) self.assertEqual(prefixes[0].prefix, IPNetwork('10.0.0.0/8')) + self.assertEqual(prefixes[0].parent, None) self.assertEqual(prefixes[0]._depth, 0) self.assertEqual(prefixes[0]._children, 3) self.assertEqual(prefixes[1].prefix, IPNetwork('10.0.0.0/12')) + self.assertEqual(prefixes[1].parent.prefix, IPNetwork('10.0.0.0/8')) self.assertEqual(prefixes[1]._depth, 1) self.assertEqual(prefixes[1]._children, 2) self.assertEqual(prefixes[2].prefix, IPNetwork('10.0.0.0/16')) + self.assertEqual(prefixes[2].parent.prefix, IPNetwork('10.0.0.0/12')) self.assertEqual(prefixes[2]._depth, 2) self.assertEqual(prefixes[2]._children, 1) self.assertEqual(prefixes[3].prefix, IPNetwork('10.0.0.0/24')) + self.assertEqual(prefixes[3].parent.prefix, IPNetwork('10.0.0.0/16')) self.assertEqual(prefixes[3]._depth, 3) self.assertEqual(prefixes[3]._children, 0) @@ -380,15 +471,19 @@ class TestPrefixHierarchy(TestCase): prefixes = Prefix.objects.filter(prefix__family=6) self.assertEqual(prefixes[0].prefix, IPNetwork('2001:db8::/32')) + self.assertEqual(prefixes[0].parent, None) self.assertEqual(prefixes[0]._depth, 0) self.assertEqual(prefixes[0]._children, 3) self.assertEqual(prefixes[1].prefix, IPNetwork('2001:db8::/36')) + self.assertEqual(prefixes[1].parent.prefix, IPNetwork('2001:db8::/32')) self.assertEqual(prefixes[1]._depth, 1) self.assertEqual(prefixes[1]._children, 2) self.assertEqual(prefixes[2].prefix, IPNetwork('2001:db8::/40')) + self.assertEqual(prefixes[2].parent.prefix, IPNetwork('2001:db8::/36')) self.assertEqual(prefixes[2]._depth, 2) self.assertEqual(prefixes[2]._children, 1) self.assertEqual(prefixes[3].prefix, IPNetwork('2001:db8::/48')) + self.assertEqual(prefixes[3].parent.prefix, IPNetwork('2001:db8::/40')) self.assertEqual(prefixes[3]._depth, 3) self.assertEqual(prefixes[3]._children, 0) @@ -400,12 +495,15 @@ class TestPrefixHierarchy(TestCase): prefixes = Prefix.objects.filter(prefix__family=4) self.assertEqual(prefixes[0].prefix, IPNetwork('10.0.0.0/8')) + self.assertEqual(prefixes[0].parent, None) self.assertEqual(prefixes[0]._depth, 0) self.assertEqual(prefixes[0]._children, 2) self.assertEqual(prefixes[1].prefix, IPNetwork('10.0.0.0/12')) + self.assertEqual(prefixes[1].parent.prefix, IPNetwork('10.0.0.0/8')) self.assertEqual(prefixes[1]._depth, 1) self.assertEqual(prefixes[1]._children, 1) self.assertEqual(prefixes[2].prefix, IPNetwork('10.0.0.0/16')) + self.assertEqual(prefixes[2].parent.prefix, IPNetwork('10.0.0.0/12')) self.assertEqual(prefixes[2]._depth, 2) self.assertEqual(prefixes[2]._children, 0) @@ -417,12 +515,15 @@ class TestPrefixHierarchy(TestCase): prefixes = Prefix.objects.filter(prefix__family=6) self.assertEqual(prefixes[0].prefix, IPNetwork('2001:db8::/32')) + self.assertEqual(prefixes[0].parent, None) self.assertEqual(prefixes[0]._depth, 0) self.assertEqual(prefixes[0]._children, 2) self.assertEqual(prefixes[1].prefix, IPNetwork('2001:db8::/36')) + self.assertEqual(prefixes[1].parent.prefix, IPNetwork('2001:db8::/32')) self.assertEqual(prefixes[1]._depth, 1) self.assertEqual(prefixes[1]._children, 1) self.assertEqual(prefixes[2].prefix, IPNetwork('2001:db8::/40')) + self.assertEqual(prefixes[2].parent.prefix, IPNetwork('2001:db8::/36')) self.assertEqual(prefixes[2]._depth, 2) self.assertEqual(prefixes[2]._children, 0) @@ -437,14 +538,17 @@ class TestPrefixHierarchy(TestCase): prefixes = Prefix.objects.filter(vrf__isnull=True, prefix__family=4) self.assertEqual(prefixes[0].prefix, IPNetwork('10.0.0.0/8')) + self.assertEqual(prefixes[0].parent, None) self.assertEqual(prefixes[0]._depth, 0) self.assertEqual(prefixes[0]._children, 1) self.assertEqual(prefixes[1].prefix, IPNetwork('10.0.0.0/24')) + self.assertEqual(prefixes[1].parent.prefix, IPNetwork('10.0.0.0/8')) self.assertEqual(prefixes[1]._depth, 1) self.assertEqual(prefixes[1]._children, 0) prefixes = Prefix.objects.filter(vrf=vrf) self.assertEqual(prefixes[0].prefix, IPNetwork('10.0.0.0/16')) + self.assertEqual(prefixes[0].parent, None) self.assertEqual(prefixes[0]._depth, 0) self.assertEqual(prefixes[0]._children, 0) @@ -459,14 +563,17 @@ class TestPrefixHierarchy(TestCase): prefixes = Prefix.objects.filter(vrf__isnull=True, prefix__family=6) self.assertEqual(prefixes[0].prefix, IPNetwork('2001:db8::/32')) + self.assertEqual(prefixes[0].parent, None) self.assertEqual(prefixes[0]._depth, 0) self.assertEqual(prefixes[0]._children, 1) self.assertEqual(prefixes[1].prefix, IPNetwork('2001:db8::/48')) + self.assertEqual(prefixes[1].parent.prefix, IPNetwork('2001:db8::/32')) self.assertEqual(prefixes[1]._depth, 1) self.assertEqual(prefixes[1]._children, 0) prefixes = Prefix.objects.filter(vrf=vrf) self.assertEqual(prefixes[0].prefix, IPNetwork('2001:db8::/40')) + self.assertEqual(prefixes[0].parent, None) self.assertEqual(prefixes[0]._depth, 0) self.assertEqual(prefixes[0]._children, 0) @@ -476,9 +583,11 @@ class TestPrefixHierarchy(TestCase): prefixes = Prefix.objects.filter(prefix__family=4) self.assertEqual(prefixes[0].prefix, IPNetwork('10.0.0.0/8')) + self.assertEqual(prefixes[0].parent, None) self.assertEqual(prefixes[0]._depth, 0) self.assertEqual(prefixes[0]._children, 1) self.assertEqual(prefixes[1].prefix, IPNetwork('10.0.0.0/24')) + self.assertEqual(prefixes[1].parent.prefix, IPNetwork('10.0.0.0/8')) self.assertEqual(prefixes[1]._depth, 1) self.assertEqual(prefixes[1]._children, 0) @@ -488,9 +597,11 @@ class TestPrefixHierarchy(TestCase): prefixes = Prefix.objects.filter(prefix__family=6) self.assertEqual(prefixes[0].prefix, IPNetwork('2001:db8::/32')) + self.assertEqual(prefixes[0].parent, None) self.assertEqual(prefixes[0]._depth, 0) self.assertEqual(prefixes[0]._children, 1) self.assertEqual(prefixes[1].prefix, IPNetwork('2001:db8::/48')) + self.assertEqual(prefixes[1].parent.prefix, IPNetwork('2001:db8::/32')) self.assertEqual(prefixes[1]._depth, 1) self.assertEqual(prefixes[1]._children, 0) @@ -500,15 +611,20 @@ class TestPrefixHierarchy(TestCase): prefixes = Prefix.objects.filter(prefix__family=4) self.assertEqual(prefixes[0].prefix, IPNetwork('10.0.0.0/8')) + self.assertEqual(prefixes[0].parent, None) self.assertEqual(prefixes[0]._depth, 0) self.assertEqual(prefixes[0]._children, 3) self.assertEqual(prefixes[1].prefix, IPNetwork('10.0.0.0/16')) + self.assertEqual(prefixes[1].parent.prefix, IPNetwork('10.0.0.0/8')) self.assertEqual(prefixes[1]._depth, 1) self.assertEqual(prefixes[1]._children, 1) self.assertEqual(prefixes[2].prefix, IPNetwork('10.0.0.0/16')) + self.assertEqual(prefixes[2].parent.prefix, IPNetwork('10.0.0.0/8')) self.assertEqual(prefixes[2]._depth, 1) self.assertEqual(prefixes[2]._children, 1) self.assertEqual(prefixes[3].prefix, IPNetwork('10.0.0.0/24')) + # TODO: How to we resolve the parent for duplicate prefixes + self.assertEqual(prefixes[3].parent.prefix, IPNetwork('10.0.0.0/16')) self.assertEqual(prefixes[3]._depth, 2) self.assertEqual(prefixes[3]._children, 0) @@ -518,20 +634,48 @@ class TestPrefixHierarchy(TestCase): prefixes = Prefix.objects.filter(prefix__family=6) self.assertEqual(prefixes[0].prefix, IPNetwork('2001:db8::/32')) + self.assertEqual(prefixes[0].parent, None) self.assertEqual(prefixes[0]._depth, 0) self.assertEqual(prefixes[0]._children, 3) self.assertEqual(prefixes[1].prefix, IPNetwork('2001:db8::/40')) + self.assertEqual(prefixes[1].parent.prefix, IPNetwork('2001:db8::/32')) self.assertEqual(prefixes[1]._depth, 1) self.assertEqual(prefixes[1]._children, 1) self.assertEqual(prefixes[2].prefix, IPNetwork('2001:db8::/40')) + self.assertEqual(prefixes[2].parent.prefix, IPNetwork('2001:db8::/32')) self.assertEqual(prefixes[2]._depth, 1) self.assertEqual(prefixes[2]._children, 1) self.assertEqual(prefixes[3].prefix, IPNetwork('2001:db8::/48')) + self.assertEqual(prefixes[3].parent.prefix, IPNetwork('2001:db8::/40')) self.assertEqual(prefixes[3]._depth, 2) self.assertEqual(prefixes[3]._children, 0) class TestIPAddress(TestCase): + """ + Test the automatic updating of depth and child count in response to changes made within + the prefix hierarchy. + """ + @classmethod + def setUpTestData(cls): + cls.vrf = VRF.objects.create(name='VRF A', rd='1:1') + + cls.prefixes = ( + + # IPv4 + Prefix(prefix='192.0.0.0/16'), + Prefix(prefix='192.0.2.0/24'), + Prefix(prefix='192.0.0.0/16', vrf=cls.vrf), + + # IPv6 + Prefix(prefix='2001:db8::/32'), + Prefix(prefix='2001:db8::/64'), + + ) + + for prefix in cls.prefixes: + prefix.clean() + prefix.save() def test_get_duplicates(self): ips = IPAddress.objects.bulk_create(( @@ -543,6 +687,44 @@ class TestIPAddress(TestCase): self.assertSetEqual(set(duplicate_ip_pks), {ips[1].pk, ips[2].pk}) + def test_parent_prefix(self): + ips = ( + IPAddress(address=IPNetwork('192.0.0.1/24'), prefix=self.prefixes[0]), + IPAddress(address=IPNetwork('192.0.2.1/24'), prefix=self.prefixes[1]), + IPAddress(address=IPNetwork('192.0.2.1/24'), vrf=self.vrf, prefix=self.prefixes[2]), + IPAddress(address=IPNetwork('2001:db8::/64'), prefix=self.prefixes[4]), + IPAddress(address=IPNetwork('2001:db8:2::/64'), prefix=self.prefixes[3]), + ) + + for ip in ips: + ip.clean() + ip.save() + + self.assertEqual(ips[0].prefix, self.prefixes[0]) + self.assertEqual(ips[1].prefix, self.prefixes[1]) + self.assertEqual(ips[2].prefix, self.prefixes[2]) + self.assertEqual(ips[3].prefix, self.prefixes[4]) + self.assertEqual(ips[4].prefix, self.prefixes[3]) + + def test_parent_prefix_change(self): + ip = IPAddress(address=IPNetwork('192.0.1.1/24'), prefix=self.prefixes[0]) + ip.clean() + ip.save() + + prefix = Prefix(prefix='192.0.1.0/17') + prefix.clean() + prefix.save() + + ip.refresh_from_db() + + self.assertEqual(ip.prefix, prefix) + + # TODO: Prefix Altered + # TODO: Prefix Deleted + + # TODO: Prefix does not contain IP Address + # TODO: Prefix VRF does not match IP Address VRF + # # Uniqueness enforcement tests # diff --git a/netbox/ipam/tests/test_views.py b/netbox/ipam/tests/test_views.py index 8666eafdd..a9b0dd227 100644 --- a/netbox/ipam/tests/test_views.py +++ b/netbox/ipam/tests/test_views.py @@ -421,6 +421,7 @@ class PrefixTestCase(ViewTestCases.PrimaryObjectViewTestCase): tags = create_tags('Alpha', 'Bravo', 'Charlie') + # TODO: Alter for prefix cls.form_data = { 'prefix': IPNetwork('192.0.2.0/24'), 'scope_type': ContentType.objects.get_for_model(Site).pk, @@ -436,6 +437,7 @@ class PrefixTestCase(ViewTestCases.PrimaryObjectViewTestCase): } site = sites[0].pk + # TODO: Alter for prefix cls.csv_data = ( "vrf,prefix,status,scope_type,scope_id", f"VRF 1,10.4.0.0/16,active,dcim.site,{site}", @@ -443,6 +445,7 @@ class PrefixTestCase(ViewTestCases.PrimaryObjectViewTestCase): f"VRF 1,10.6.0.0/16,active,dcim.site,{site}", ) + # TODO: Alter for prefix cls.csv_update_data = ( "id,description,status", f"{prefixes[0].pk},New description 7,{PrefixStatusChoices.STATUS_RESERVED}", @@ -450,6 +453,7 @@ class PrefixTestCase(ViewTestCases.PrimaryObjectViewTestCase): f"{prefixes[2].pk},New description 9,{PrefixStatusChoices.STATUS_RESERVED}", ) + # TODO: Alter for prefix cls.bulk_edit_data = { 'vrf': vrfs[1].pk, 'tenant': None, @@ -594,6 +598,7 @@ class IPRangeTestCase(ViewTestCases.PrimaryObjectViewTestCase): tags = create_tags('Alpha', 'Bravo', 'Charlie') + # TODO: Alter for prefix cls.form_data = { 'start_address': IPNetwork('192.0.5.10/24'), 'end_address': IPNetwork('192.0.5.100/24'), @@ -607,6 +612,7 @@ class IPRangeTestCase(ViewTestCases.PrimaryObjectViewTestCase): 'tags': [t.pk for t in tags], } + # TODO: Alter for prefix cls.csv_data = ( "vrf,start_address,end_address,status", "VRF 1,10.1.0.1/16,10.1.9.254/16,active", @@ -614,6 +620,7 @@ class IPRangeTestCase(ViewTestCases.PrimaryObjectViewTestCase): "VRF 1,10.3.0.1/16,10.3.9.254/16,active", ) + # TODO: Alter for prefix cls.csv_update_data = ( "id,description,status", f"{ip_ranges[0].pk},New description 7,{IPRangeStatusChoices.STATUS_RESERVED}", @@ -621,6 +628,7 @@ class IPRangeTestCase(ViewTestCases.PrimaryObjectViewTestCase): f"{ip_ranges[2].pk},New description 9,{IPRangeStatusChoices.STATUS_RESERVED}", ) + # TODO: Alter for prefix cls.bulk_edit_data = { 'vrf': vrfs[1].pk, 'tenant': None, @@ -687,6 +695,7 @@ class IPAddressTestCase(ViewTestCases.PrimaryObjectViewTestCase): ), ) FHRPGroup.objects.bulk_create(fhrp_groups) + # TODO: Alter for prefix cls.form_data = { 'vrf': vrfs[1].pk, 'address': IPNetwork('192.0.2.99/24'), @@ -699,6 +708,7 @@ class IPAddressTestCase(ViewTestCases.PrimaryObjectViewTestCase): 'tags': [t.pk for t in tags], } + # TODO: Alter for prefix cls.csv_data = ( "vrf,address,status,fhrp_group", "VRF 1,192.0.2.4/24,active,FHRP Group 1", @@ -706,6 +716,7 @@ class IPAddressTestCase(ViewTestCases.PrimaryObjectViewTestCase): "VRF 1,192.0.2.6/24,active,FHRP Group 3", ) + # TODO: Alter for prefix cls.csv_update_data = ( "id,description,status", f"{ipaddresses[0].pk},New description 7,{IPAddressStatusChoices.STATUS_RESERVED}", @@ -713,6 +724,7 @@ class IPAddressTestCase(ViewTestCases.PrimaryObjectViewTestCase): f"{ipaddresses[2].pk},New description 9,{IPAddressStatusChoices.STATUS_RESERVED}", ) + # TODO: Alter for prefix cls.bulk_edit_data = { 'vrf': vrfs[1].pk, 'tenant': None, From 697d5bd876cf2ad3526e68ab25602f9af375c68c Mon Sep 17 00:00:00 2001 From: Daniel Sheppard Date: Wed, 9 Jul 2025 10:52:24 -0500 Subject: [PATCH 06/10] Slightly DRY the migration --- ...83_ipaddress_iprange_prefix_parent_data.py | 76 +++++-------------- 1 file changed, 18 insertions(+), 58 deletions(-) diff --git a/netbox/ipam/migrations/0083_ipaddress_iprange_prefix_parent_data.py b/netbox/ipam/migrations/0083_ipaddress_iprange_prefix_parent_data.py index 321c51f38..45cf48c04 100644 --- a/netbox/ipam/migrations/0083_ipaddress_iprange_prefix_parent_data.py +++ b/netbox/ipam/migrations/0083_ipaddress_iprange_prefix_parent_data.py @@ -19,25 +19,28 @@ def draw_progress(count, total, length=20): sys.stdout.flush() -def set_ipaddress_prefix(apps, schema_editor): +def set_prefix(apps, schema_editor, model, attr='address', parent_model='Prefix'): start = time.time() - IPAddress = apps.get_model('ipam', 'IPAddress') - Prefix = apps.get_model('ipam', 'Prefix') + ChildModel = apps.get_model('ipam', model) + ParentModel = apps.get_model('ipam', parent_model) - addresses = IPAddress.objects.all() - i = 0 + addresses = ChildModel.objects.all() total = addresses.count() if total == 0: return + print('\r\n') + i = 0 draw_progress(i, total, 50) for address in addresses: i += 1 - prefixes = Prefix.objects.filter( - vrf=address.vrf, - prefix__net_contains_or_equals=str(address.address.ip), - prefix__net_mask_length__lte=address.address.prefixlen, + address_attr = getattr(address, attr) + prefixes = ParentModel.objects.filter( + prefix__net_contains_or_equals=str(address_attr.ip), + prefix__net_mask_length__lte=address_attr.prefixlen, ) + if hasattr(ParentModel, 'vrf'): + prefixes = prefixes.filter(vrf=address.vrf) address.prefix = prefixes.last() address.save() draw_progress(i, total, 50) @@ -46,37 +49,17 @@ def set_ipaddress_prefix(apps, schema_editor): print(f"\r\nElapsed Time: {end - start:.2f}s") +def set_ipaddress_prefix(apps, schema_editor): + set_prefix(apps, schema_editor, 'IPAddress') + + def unset_ipaddress_prefix(apps, schema_editor): IPAddress = apps.get_model('ipam', 'IPAddress') IPAddress.objects.update(prefix=None) def set_iprange_prefix(apps, schema_editor): - IPRange = apps.get_model('ipam', 'IPRange') - Prefix = apps.get_model('ipam', 'Prefix') - start = time.time() - - addresses = IPRange.objects.all() - i = 0 - total = addresses.count() - if total == 0: - return - - print('\r\n') - draw_progress(i, total, 50) - for address in addresses: - i += 1 - prefixes = Prefix.objects.filter( - vrf=address.vrf, - prefix__net_contains_or_equals=str(address.start_address.ip), - prefix__net_mask_length__lte=address.start_address.prefixlen, - ) - address.prefix = prefixes.last() - address.save() - draw_progress(i, total, 50) - - end = time.time() - print(f"\r\nElapsed Time: {end - start:.2f}s") + set_prefix(apps, schema_editor, 'IPRange', 'start_address') def unset_iprange_prefix(apps, schema_editor): @@ -85,30 +68,7 @@ def unset_iprange_prefix(apps, schema_editor): def set_prefix_aggregate(apps, schema_editor): - Prefix = apps.get_model('ipam', 'Prefix') - Aggregate = apps.get_model('ipam', 'Aggregate') - - start = time.time() - addresses = Prefix.objects.all() - i = 0 - total = addresses.count() - if total == 0: - return - - print('\r\n') - draw_progress(i, total, 50) - for address in addresses: - i += 1 - aggregates = Aggregate.objects.filter( - prefix__net_contains_or_equals=str(address.prefix.ip), - prefix__net_mask_length__lte=address.prefix.prefixlen, - ) - address.aggregate = aggregates.last() - address.save() - draw_progress(i, total, 50) - - end = time.time() - print(f"\r\nElapsed Time: {end - start:.2f}s") + set_prefix(apps, schema_editor, 'Prefix', 'prefix', 'Aggregate') def unset_prefix_aggregate(apps, schema_editor): From ade4354ca4ac43497353d0e7ee7ee96eeb00e666 Mon Sep 17 00:00:00 2001 From: Daniel Sheppard Date: Wed, 9 Jul 2025 11:12:52 -0500 Subject: [PATCH 07/10] Fix some test errors --- netbox/ipam/api/serializers_/ip.py | 9 ++++----- netbox/ipam/filtersets.py | 24 ++++++++++++++++++++---- netbox/ipam/forms/filtersets.py | 6 ++++++ netbox/ipam/graphql/filters.py | 6 ++++++ netbox/ipam/models/ip.py | 4 ++-- 5 files changed, 38 insertions(+), 11 deletions(-) diff --git a/netbox/ipam/api/serializers_/ip.py b/netbox/ipam/api/serializers_/ip.py index a07eadce6..b515838ff 100644 --- a/netbox/ipam/api/serializers_/ip.py +++ b/netbox/ipam/api/serializers_/ip.py @@ -44,7 +44,6 @@ class AggregateSerializer(NetBoxModelSerializer): class PrefixSerializer(NetBoxModelSerializer): - # TODO: Alter for parent prefix family = ChoiceField(choices=IPAddressFamilyChoices, read_only=True) vrf = VRFSerializer(nested=True, required=False, allow_null=True) scope_type = ContentTypeField( @@ -61,7 +60,7 @@ class PrefixSerializer(NetBoxModelSerializer): vlan = VLANSerializer(nested=True, required=False, allow_null=True) status = ChoiceField(choices=PrefixStatusChoices, required=False) role = RoleSerializer(nested=True, required=False, allow_null=True) - children = serializers.IntegerField(read_only=True) + _children = serializers.IntegerField(read_only=True) _depth = serializers.IntegerField(read_only=True) prefix = IPNetworkField() @@ -70,7 +69,7 @@ class PrefixSerializer(NetBoxModelSerializer): fields = [ 'id', 'url', 'display_url', 'display', 'family', 'prefix', 'vrf', 'scope_type', 'scope_id', 'scope', 'tenant', 'vlan', 'status', 'role', 'is_pool', 'mark_utilized', 'description', 'comments', 'tags', - 'custom_fields', 'created', 'last_updated', 'children', '_depth', + 'custom_fields', 'created', 'last_updated', '_children', '_depth', ] brief_fields = ('id', 'url', 'display', 'family', 'prefix', 'description', '_depth') @@ -135,7 +134,7 @@ class AvailablePrefixSerializer(serializers.Serializer): # class IPRangeSerializer(NetBoxModelSerializer): - # TODO: Alter for prefix + prefix = PrefixSerializer(nested=True, required=False, allow_null=True) family = ChoiceField(choices=IPAddressFamilyChoices, read_only=True) start_address = IPAddressField() end_address = IPAddressField() @@ -160,7 +159,7 @@ class IPRangeSerializer(NetBoxModelSerializer): # class IPAddressSerializer(NetBoxModelSerializer): - # TODO: Alter for prefix + prefix = PrefixSerializer(nested=True, required=False, allow_null=True) family = ChoiceField(choices=IPAddressFamilyChoices, read_only=True) address = IPAddressField() vrf = VRFSerializer(nested=True, required=False, allow_null=True) diff --git a/netbox/ipam/filtersets.py b/netbox/ipam/filtersets.py index 720c5d0bf..0b3ca4b26 100644 --- a/netbox/ipam/filtersets.py +++ b/netbox/ipam/filtersets.py @@ -291,8 +291,6 @@ class RoleFilterSet(OrganizationalModelFilterSet): class PrefixFilterSet(NetBoxModelFilterSet, ScopedFilterSet, TenancyFilterSet, ContactModelFilterSet): - # TODO: Alter for aggregate - # TODO: Alter for parent prefix family = django_filters.NumberFilter( field_name='prefix', lookup_expr='family' @@ -332,6 +330,26 @@ class PrefixFilterSet(NetBoxModelFilterSet, ScopedFilterSet, TenancyFilterSet, C field_name='prefix', lookup_expr='net_mask_length__lte' ) + aggregate_id = django_filters.ModelMultipleChoiceFilter( + queryset=Aggregate.objects.all(), + label=_('Aggregate'), + ) + aggregate = django_filters.ModelMultipleChoiceFilter( + field_name='aggregate__prefix', + queryset=Aggregate.objects.all(), + to_field_name='prefix', + label=_('Aggregate (Prefix)'), + ) + parent_id = django_filters.ModelMultipleChoiceFilter( + queryset=Prefix.objects.all(), + label=_('Parent Prefix'), + ) + parent = django_filters.ModelMultipleChoiceFilter( + field_name='parent__prefix', + queryset=Prefix.objects.all(), + to_field_name='prefix', + label=_('Parent Prefix (Prefix)'), + ) vrf_id = django_filters.ModelMultipleChoiceFilter( queryset=VRF.objects.all(), label=_('VRF'), @@ -459,7 +477,6 @@ class PrefixFilterSet(NetBoxModelFilterSet, ScopedFilterSet, TenancyFilterSet, C class IPRangeFilterSet(TenancyFilterSet, NetBoxModelFilterSet, ContactModelFilterSet): - # TODO: Alter for prefix family = django_filters.NumberFilter( field_name='start_address', lookup_expr='family' @@ -562,7 +579,6 @@ class IPRangeFilterSet(TenancyFilterSet, NetBoxModelFilterSet, ContactModelFilte class IPAddressFilterSet(NetBoxModelFilterSet, TenancyFilterSet, ContactModelFilterSet): - # TODO: Alter for prefix family = django_filters.NumberFilter( field_name='address', lookup_expr='family' diff --git a/netbox/ipam/forms/filtersets.py b/netbox/ipam/forms/filtersets.py index 780d43bb5..c96fbd471 100644 --- a/netbox/ipam/forms/filtersets.py +++ b/netbox/ipam/forms/filtersets.py @@ -204,6 +204,12 @@ class PrefixFilterForm(ContactModelFilterForm, TenancyFilterForm, NetBoxModelFil choices=PREFIX_MASK_LENGTH_CHOICES, label=_('Mask length') ) + aggregate_id = DynamicModelMultipleChoiceField( + queryset=Aggregate.objects.all(), + required=False, + label=_('Aggregate'), + null_option='Global' + ) vrf_id = DynamicModelMultipleChoiceField( queryset=VRF.objects.all(), required=False, diff --git a/netbox/ipam/graphql/filters.py b/netbox/ipam/graphql/filters.py index 4e1afc3ba..07a301b77 100644 --- a/netbox/ipam/graphql/filters.py +++ b/netbox/ipam/graphql/filters.py @@ -119,6 +119,7 @@ class FHRPGroupAssignmentFilter(BaseObjectTypeFilterMixin, ChangeLogFilterMixin) @strawberry_django.filter(models.IPAddress, lookups=True) class IPAddressFilter(ContactFilterMixin, TenancyFilterMixin, PrimaryModelFilterMixin): + prefix: Annotated['PrefixFilter', strawberry.lazy('ipam.graphql.filters')] | None = strawberry_django.filter_field() address: FilterLookup[str] | None = strawberry_django.filter_field() vrf: Annotated['VRFFilter', strawberry.lazy('ipam.graphql.filters')] | None = strawberry_django.filter_field() vrf_id: ID | None = strawberry_django.filter_field() @@ -158,6 +159,7 @@ class IPAddressFilter(ContactFilterMixin, TenancyFilterMixin, PrimaryModelFilter @strawberry_django.filter(models.IPRange, lookups=True) class IPRangeFilter(ContactFilterMixin, TenancyFilterMixin, PrimaryModelFilterMixin): + prefix: Annotated['PrefixFilter', strawberry.lazy('ipam.graphql.filters')] | None = strawberry_django.filter_field() start_address: FilterLookup[str] | None = strawberry_django.filter_field() end_address: FilterLookup[str] | None = strawberry_django.filter_field() size: Annotated['IntegerLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = ( @@ -189,6 +191,10 @@ class IPRangeFilter(ContactFilterMixin, TenancyFilterMixin, PrimaryModelFilterMi @strawberry_django.filter(models.Prefix, lookups=True) class PrefixFilter(ContactFilterMixin, ScopedFilterMixin, TenancyFilterMixin, PrimaryModelFilterMixin): + aggregate: Annotated['AggregateFilter', strawberry.lazy('ipam.graphql.filters')] | None = ( + strawberry_django.filter_field() + ) + parent: Annotated['PrefixFilter', strawberry.lazy('ipam.graphql.filters')] | None = strawberry_django.filter_field() prefix: FilterLookup[str] | None = strawberry_django.filter_field() vrf: Annotated['VRFFilter', strawberry.lazy('ipam.graphql.filters')] | None = strawberry_django.filter_field() vrf_id: ID | None = strawberry_django.filter_field() diff --git a/netbox/ipam/models/ip.py b/netbox/ipam/models/ip.py index 45bce76c9..4073bcf21 100644 --- a/netbox/ipam/models/ip.py +++ b/netbox/ipam/models/ip.py @@ -369,11 +369,11 @@ class Prefix(ContactsMixin, GetAvailablePrefixesMixin, CachedScopeMixin, Primary return self.prefix.prefixlen if self.prefix else None @property - def depth(self): + def depth_count(self): return self._depth @property - def children(self): + def children_count(self): return self._children def _set_prefix_length(self, value): From 7eb3a8d37933b051c61dd071fee2bad1466ed213 Mon Sep 17 00:00:00 2001 From: Daniel Sheppard Date: Wed, 9 Jul 2025 12:56:05 -0500 Subject: [PATCH 08/10] Fix some tests --- netbox/ipam/api/serializers_/ip.py | 24 +++++++++++++++--------- netbox/ipam/graphql/types.py | 3 +++ netbox/ipam/signals.py | 3 +++ netbox/ipam/tests/test_api.py | 6 +++--- netbox/ipam/tests/test_models.py | 8 ++++---- netbox/ipam/tests/test_views.py | 12 ++++++------ 6 files changed, 34 insertions(+), 22 deletions(-) diff --git a/netbox/ipam/api/serializers_/ip.py b/netbox/ipam/api/serializers_/ip.py index b515838ff..1efbdec70 100644 --- a/netbox/ipam/api/serializers_/ip.py +++ b/netbox/ipam/api/serializers_/ip.py @@ -67,11 +67,17 @@ class PrefixSerializer(NetBoxModelSerializer): class Meta: model = Prefix fields = [ - 'id', 'url', 'display_url', 'display', 'family', 'prefix', 'vrf', 'scope_type', 'scope_id', 'scope', - 'tenant', 'vlan', 'status', 'role', 'is_pool', 'mark_utilized', 'description', 'comments', 'tags', - 'custom_fields', 'created', 'last_updated', '_children', '_depth', + 'id', 'url', 'display_url', 'display', 'family', 'aggregate', 'parent', 'prefix', 'vrf', 'scope_type', + 'scope_id', 'scope', 'tenant', 'vlan', 'status', 'role', 'is_pool', 'mark_utilized', 'description', + 'comments', 'tags', 'custom_fields', 'created', 'last_updated', '_children', '_depth', ] - brief_fields = ('id', 'url', 'display', 'family', 'prefix', 'description', '_depth') + brief_fields = ('id', 'url', 'display', 'family', 'aggregate', 'parent', 'prefix', 'description', '_depth') + + def get_fields(self): + fields = super(PrefixSerializer, self).get_fields() + fields['parent'] = PrefixSerializer(nested=True, read_only=True) + + return fields @extend_schema_field(serializers.JSONField(allow_null=True)) def get_scope(self, obj): @@ -146,12 +152,12 @@ class IPRangeSerializer(NetBoxModelSerializer): class Meta: model = IPRange fields = [ - 'id', 'url', 'display_url', 'display', 'family', 'start_address', 'end_address', 'size', 'vrf', 'tenant', - 'status', 'role', 'description', 'comments', 'tags', 'custom_fields', 'created', 'last_updated', + 'id', 'url', 'display_url', 'display', 'family', 'prefix', 'start_address', 'end_address', 'size', 'vrf', + 'tenant', 'status', 'role', 'description', 'comments', 'tags', 'custom_fields', 'created', 'last_updated', 'mark_populated', 'mark_utilized', 'description', 'comments', 'tags', 'custom_fields', 'created', 'last_updated', ] - brief_fields = ('id', 'url', 'display', 'family', 'start_address', 'end_address', 'description') + brief_fields = ('id', 'url', 'display', 'family', 'prefix', 'start_address', 'end_address', 'description') # @@ -178,11 +184,11 @@ class IPAddressSerializer(NetBoxModelSerializer): class Meta: model = IPAddress fields = [ - 'id', 'url', 'display_url', 'display', 'family', 'address', 'vrf', 'tenant', 'status', 'role', + 'id', 'url', 'display_url', 'display', 'family', 'prefix', 'address', 'vrf', 'tenant', 'status', 'role', 'assigned_object_type', 'assigned_object_id', 'assigned_object', 'nat_inside', 'nat_outside', 'dns_name', 'description', 'comments', 'tags', 'custom_fields', 'created', 'last_updated', ] - brief_fields = ('id', 'url', 'display', 'family', 'address', 'description') + brief_fields = ('id', 'url', 'display', 'family', 'prefix', 'address', 'description') @extend_schema_field(serializers.JSONField(allow_null=True)) def get_assigned_object(self, obj): diff --git a/netbox/ipam/graphql/types.py b/netbox/ipam/graphql/types.py index b35d44df5..39eec74e5 100644 --- a/netbox/ipam/graphql/types.py +++ b/netbox/ipam/graphql/types.py @@ -169,6 +169,7 @@ class IPAddressType(NetBoxObjectType, ContactsMixin, BaseIPAddressFamilyType): pagination=True ) class IPRangeType(NetBoxObjectType, ContactsMixin): + prefix: Annotated["PrefixType", strawberry.lazy('ipam.graphql.types')] | None start_address: str end_address: str vrf: Annotated["VRFType", strawberry.lazy('ipam.graphql.types')] | None @@ -183,6 +184,8 @@ class IPRangeType(NetBoxObjectType, ContactsMixin): pagination=True ) class PrefixType(NetBoxObjectType, ContactsMixin, BaseIPAddressFamilyType): + aggregate: Annotated["AggregateType", strawberry.lazy('ipam.graphql.types')] | None + parent: Annotated["PrefixType", strawberry.lazy('ipam.graphql.types')] | None prefix: str vrf: Annotated["VRFType", strawberry.lazy('ipam.graphql.types')] | None tenant: Annotated["TenantType", strawberry.lazy('tenancy.graphql.types')] | None diff --git a/netbox/ipam/signals.py b/netbox/ipam/signals.py index 450e89758..d7085f8b5 100644 --- a/netbox/ipam/signals.py +++ b/netbox/ipam/signals.py @@ -195,6 +195,7 @@ def handle_prefix_saved(instance, created, **kwargs): if created or instance.vrf_id != instance._vrf_id or instance.prefix != instance._prefix: update_ipaddress_prefix(instance) + update_iprange_prefix(instance) update_prefix_parents(instance) update_parents_children(instance) update_children_depth(instance) @@ -209,6 +210,7 @@ def handle_prefix_saved(instance, created, **kwargs): @receiver(pre_delete, sender=Prefix) def pre_handle_prefix_deleted(instance, **kwargs): update_ipaddress_prefix(instance, True) + update_iprange_prefix(instance, True) update_prefix_parents(instance, delete=True) @@ -218,6 +220,7 @@ def handle_prefix_deleted(instance, **kwargs): update_parents_children(instance) update_children_depth(instance) update_ipaddress_prefix(instance, delete=True) + update_iprange_prefix(instance, delete=True) update_prefix_parents(instance, delete=True) diff --git a/netbox/ipam/tests/test_api.py b/netbox/ipam/tests/test_api.py index 907924f25..79616ff4e 100644 --- a/netbox/ipam/tests/test_api.py +++ b/netbox/ipam/tests/test_api.py @@ -357,7 +357,7 @@ class RoleTest(APIViewTestCases.APIViewTestCase): class PrefixTest(APIViewTestCases.APIViewTestCase): model = Prefix # TODO: Alter for parent prefix - brief_fields = ['_depth', 'description', 'display', 'family', 'id', 'prefix', 'url'] + brief_fields = ['_depth', 'aggregate', 'description', 'display', 'family', 'id', 'parent', 'prefix', 'url'] create_data = [ { 'prefix': '192.168.4.0/24', @@ -537,7 +537,7 @@ class PrefixTest(APIViewTestCases.APIViewTestCase): class IPRangeTest(APIViewTestCases.APIViewTestCase): model = IPRange # TODO: Alter for parent prefix - brief_fields = ['description', 'display', 'end_address', 'family', 'id', 'start_address', 'url'] + brief_fields = ['description', 'display', 'end_address', 'family', 'id', 'prefix', 'start_address', 'url'] create_data = [ { 'start_address': '192.168.4.10/24', @@ -637,7 +637,7 @@ class IPRangeTest(APIViewTestCases.APIViewTestCase): class IPAddressTest(APIViewTestCases.APIViewTestCase): model = IPAddress # TODO: Alter for parent prefix - brief_fields = ['address', 'description', 'display', 'family', 'id', 'url'] + brief_fields = ['address', 'description', 'display', 'family', 'id', 'prefix', 'url'] create_data = [ { 'address': '192.168.0.4/24', diff --git a/netbox/ipam/tests/test_models.py b/netbox/ipam/tests/test_models.py index c5efb8efa..764573b48 100644 --- a/netbox/ipam/tests/test_models.py +++ b/netbox/ipam/tests/test_models.py @@ -252,10 +252,10 @@ class TestPrefix(TestCase): prefix=IPNetwork('10.0.0.0/16'), status=PrefixStatusChoices.STATUS_CONTAINER ) ips = IPAddress.objects.bulk_create(( - IPAddress(address=IPNetwork('10.0.0.1/24'), vrf=None), - IPAddress(address=IPNetwork('10.0.1.1/24'), vrf=vrfs[0]), - IPAddress(address=IPNetwork('10.0.2.1/24'), vrf=vrfs[1]), - IPAddress(address=IPNetwork('10.0.3.1/24'), vrf=vrfs[2]), + IPAddress(prefix=parent_prefix, address=IPNetwork('10.0.0.1/24'), vrf=None), + IPAddress(prefix=parent_prefix, address=IPNetwork('10.0.1.1/24'), vrf=vrfs[0]), + IPAddress(prefix=parent_prefix, address=IPNetwork('10.0.2.1/24'), vrf=vrfs[1]), + IPAddress(prefix=parent_prefix, address=IPNetwork('10.0.3.1/24'), vrf=vrfs[2]), )) child_ip_pks = {p.pk for p in parent_prefix.ip_addresses.all()} diff --git a/netbox/ipam/tests/test_views.py b/netbox/ipam/tests/test_views.py index a9b0dd227..ba739779a 100644 --- a/netbox/ipam/tests/test_views.py +++ b/netbox/ipam/tests/test_views.py @@ -481,9 +481,9 @@ class PrefixTestCase(ViewTestCases.PrimaryObjectViewTestCase): def test_prefix_ipranges(self): prefix = Prefix.objects.create(prefix=IPNetwork('192.168.0.0/16')) ip_ranges = ( - IPRange(start_address='192.168.0.1/24', end_address='192.168.0.100/24', size=99), - IPRange(start_address='192.168.1.1/24', end_address='192.168.1.100/24', size=99), - IPRange(start_address='192.168.2.1/24', end_address='192.168.2.100/24', size=99), + IPRange(prefix=prefix, start_address='192.168.0.1/24', end_address='192.168.0.100/24', size=99), + IPRange(prefix=prefix, start_address='192.168.1.1/24', end_address='192.168.1.100/24', size=99), + IPRange(prefix=prefix, start_address='192.168.2.1/24', end_address='192.168.2.100/24', size=99), ) IPRange.objects.bulk_create(ip_ranges) self.assertEqual(prefix.get_child_ranges().count(), 3) @@ -495,9 +495,9 @@ class PrefixTestCase(ViewTestCases.PrimaryObjectViewTestCase): def test_prefix_ipaddresses(self): prefix = Prefix.objects.create(prefix=IPNetwork('192.168.0.0/16')) ip_addresses = ( - IPAddress(address=IPNetwork('192.168.0.1/16')), - IPAddress(address=IPNetwork('192.168.0.2/16')), - IPAddress(address=IPNetwork('192.168.0.3/16')), + IPAddress(prefix=prefix, address=IPNetwork('192.168.0.1/16')), + IPAddress(prefix=prefix, address=IPNetwork('192.168.0.2/16')), + IPAddress(prefix=prefix, address=IPNetwork('192.168.0.3/16')), ) IPAddress.objects.bulk_create(ip_addresses) self.assertEqual(prefix.ip_addresses.all().count(), 3) From 76e85683acc3585a12ab39151e81098eef6d56a9 Mon Sep 17 00:00:00 2001 From: Daniel Sheppard Date: Wed, 9 Jul 2025 13:18:28 -0500 Subject: [PATCH 09/10] Re-apply de-duplication to IPRangeSerializer --- netbox/ipam/api/serializers_/ip.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/netbox/ipam/api/serializers_/ip.py b/netbox/ipam/api/serializers_/ip.py index 1efbdec70..ea587eb95 100644 --- a/netbox/ipam/api/serializers_/ip.py +++ b/netbox/ipam/api/serializers_/ip.py @@ -154,8 +154,7 @@ class IPRangeSerializer(NetBoxModelSerializer): fields = [ 'id', 'url', 'display_url', 'display', 'family', 'prefix', 'start_address', 'end_address', 'size', 'vrf', 'tenant', 'status', 'role', 'description', 'comments', 'tags', 'custom_fields', 'created', 'last_updated', - 'mark_populated', 'mark_utilized', 'description', 'comments', 'tags', 'custom_fields', 'created', - 'last_updated', + 'mark_populated', 'mark_utilized', ] brief_fields = ('id', 'url', 'display', 'family', 'prefix', 'start_address', 'end_address', 'description') From 0d31449df8452d71c7f0b3050b26b0cefdf34d43 Mon Sep 17 00:00:00 2001 From: Daniel Sheppard Date: Thu, 10 Jul 2025 12:59:59 -0500 Subject: [PATCH 10/10] Optimize prefix assignment. Fix tests --- netbox/ipam/models/ip.py | 13 +++++ netbox/ipam/signals.py | 84 ++++++++++---------------------- netbox/ipam/tests/test_models.py | 15 ++++-- 3 files changed, 50 insertions(+), 62 deletions(-) diff --git a/netbox/ipam/models/ip.py b/netbox/ipam/models/ip.py index 4073bcf21..b2ef42941 100644 --- a/netbox/ipam/models/ip.py +++ b/netbox/ipam/models/ip.py @@ -786,6 +786,14 @@ class IPRange(ContactsMixin, PrimaryModel): return min(float(child_count) / self.size * 100, 100) + @classmethod + def find_prefix(self, address): + prefixes = Prefix.objects.filter( + models.Q(prefix__net_contains=address.start_address) & Q(prefix__net_contains=address.end_address), + vrf=address.vrf, + ) + return prefixes.last() + class IPAddress(ContactsMixin, PrimaryModel): """ @@ -1085,3 +1093,8 @@ class IPAddress(ContactsMixin, PrimaryModel): def get_role_color(self): return IPAddressRoleChoices.colors.get(self.role) + + @classmethod + def find_prefix(self, address): + prefixes = Prefix.objects.filter(prefix__net_contains=address.address, vrf=address.vrf) + return prefixes.last() diff --git a/netbox/ipam/signals.py b/netbox/ipam/signals.py index d7085f8b5..7e210a756 100644 --- a/netbox/ipam/signals.py +++ b/netbox/ipam/signals.py @@ -29,12 +29,11 @@ def update_children_depth(prefix): Prefix.objects.bulk_update(children, ['_depth'], batch_size=100) -def update_ipaddress_prefix(prefix, delete=False): +def update_object_prefix(prefix, delete=False, parent_model=Prefix, child_model=IPAddress): if delete: # Get all possible addresses - addresses = IPAddress.objects.filter(prefix=prefix) - # Find a new containing prefix - prefix = Prefix.objects.filter( + addresses = child_model.objects.filter(prefix=prefix) + prefix = parent_model.objects.filter( prefix__net_contains_or_equals=prefix.prefix, vrf=prefix.vrf ).exclude(pk=prefix.pk).last() @@ -43,71 +42,38 @@ def update_ipaddress_prefix(prefix, delete=False): # 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) - ) + filter = Q(prefix=prefix) + if child_model == IPAddress: + filter |= Q(address__net_contained_or_equal=prefix.prefix, vrf=prefix.vrf) + elif child_model == IPRange: + filter |= Q( + start_address__net_contained_or_equal=prefix.prefix, + end_address__net_contained_or_equal=prefix.prefix, + vrf=prefix.vrf + ) + + addresses = child_model.objects.filter(filter) 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 + # If addresses prefix is not set then this model is the only option + if not address.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() + # This address has a different VRF so the prefix cannot be the parent prefix + elif address.prefix != address.find_prefix(address): + address.prefix = address.find_prefix(address) else: - # No-OP as the prefix does not require modification pass # Update the addresses - IPAddress.objects.bulk_update(addresses, ['prefix'], batch_size=100) + child_model.objects.bulk_update(addresses, ['prefix'], batch_size=100) + + +def update_ipaddress_prefix(prefix, delete=False): + update_object_prefix(prefix, delete, child_model=IPAddress) def update_iprange_prefix(prefix, delete=False): - if delete: - # Get all possible addresses - addresses = IPRange.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 = IPRange.objects.filter( - Q(start_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.start_address in prefix.prefix and - address.end_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(Q(prefix__net_contains_or_equals=address.start_address) & - Q(prefix__net_contains_or_equals=address.end_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) + update_object_prefix(prefix, delete, child_model=IPRange) def update_prefix_parents(prefix, delete=False): diff --git a/netbox/ipam/tests/test_models.py b/netbox/ipam/tests/test_models.py index 764573b48..f348a238b 100644 --- a/netbox/ipam/tests/test_models.py +++ b/netbox/ipam/tests/test_models.py @@ -156,7 +156,7 @@ class TestIPRange(TestCase): range.clean() range.save() - prefix = Prefix(prefix='192.0.1.0/17') + prefix = Prefix(prefix='192.0.0.0/17') prefix.clean() prefix.save() @@ -264,6 +264,8 @@ class TestPrefix(TestCase): parent_prefix.vrf = vrfs[0] parent_prefix.save() + + parent_prefix.refresh_from_db() child_ip_pks = {p.pk for p in parent_prefix.ip_addresses.all()} # VRF container is limited to its own VRF @@ -741,13 +743,20 @@ class TestIPAddress(TestCase): self.assertRaises(ValidationError, duplicate_ip.clean) def test_duplicate_vrf(self): - vrf = VRF.objects.create(name='Test', rd='1:1', enforce_unique=False) + vrf = VRF.objects.get(rd='1:1') + vrf.enforce_unique = False + vrf.clean() + vrf.save() + IPAddress.objects.create(vrf=vrf, address=IPNetwork('192.0.2.1/24')) duplicate_ip = IPAddress(vrf=vrf, address=IPNetwork('192.0.2.1/24')) self.assertIsNone(duplicate_ip.clean()) def test_duplicate_vrf_unique(self): - vrf = VRF.objects.create(name='Test', rd='1:1', enforce_unique=True) + vrf = VRF.objects.get(rd='1:1') + vrf.enforce_unique = True + vrf.clean() + vrf.save() IPAddress.objects.create(vrf=vrf, address=IPNetwork('192.0.2.1/24')) duplicate_ip = IPAddress(vrf=vrf, address=IPNetwork('192.0.2.1/24')) self.assertRaises(ValidationError, duplicate_ip.clean)