From 747fef0bc2c2c13b4db9ea7aa9a1c261fc7ee3af Mon Sep 17 00:00:00 2001 From: Daniel Sheppard Date: Mon, 24 Feb 2025 14:03:18 -0600 Subject: [PATCH] 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" %}