diff --git a/netbox/ipam/api/serializers_/ip.py b/netbox/ipam/api/serializers_/ip.py index 5337b86f1..ea587eb95 100644 --- a/netbox/ipam/api/serializers_/ip.py +++ b/netbox/ipam/api/serializers_/ip.py @@ -60,18 +60,24 @@ 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() 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): @@ -134,6 +140,7 @@ class AvailablePrefixSerializer(serializers.Serializer): # class IPRangeSerializer(NetBoxModelSerializer): + prefix = PrefixSerializer(nested=True, required=False, allow_null=True) family = ChoiceField(choices=IPAddressFamilyChoices, read_only=True) start_address = IPAddressField() end_address = IPAddressField() @@ -145,11 +152,11 @@ 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', ] - brief_fields = ('id', 'url', 'display', 'family', 'start_address', 'end_address', 'description') + brief_fields = ('id', 'url', 'display', 'family', 'prefix', 'start_address', 'end_address', 'description') # @@ -157,6 +164,7 @@ class IPRangeSerializer(NetBoxModelSerializer): # class IPAddressSerializer(NetBoxModelSerializer): + 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) @@ -175,11 +183,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/filtersets.py b/netbox/ipam/filtersets.py index 5e6ffb2ac..9c351a119 100644 --- a/netbox/ipam/filtersets.py +++ b/netbox/ipam/filtersets.py @@ -330,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'), @@ -473,6 +493,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'), @@ -557,6 +587,16 @@ class IPAddressFilterSet(NetBoxModelFilterSet, TenancyFilterSet, ContactModelFil 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 864630bd4..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,12 @@ class IPRangeBulkEditForm(NetBoxModelBulkEditForm): class IPAddressBulkEditForm(NetBoxModelBulkEditForm): + # TODO: Alter for prefix + prefix = DynamicModelChoiceField( + queryset=Prefix.objects.all(), + required=False, + label=_('Prefix') + ) vrf = DynamicModelChoiceField( queryset=VRF.objects.all(), required=False, @@ -364,10 +372,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 c0aa43461..a632e00a5 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,14 @@ class IPRangeImportForm(NetBoxModelImportForm): class IPAddressImportForm(NetBoxModelImportForm): + # TODO: Alter for prefix + 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(), @@ -348,8 +359,8 @@ class IPAddressImportForm(NetBoxModelImportForm): class Meta: model = IPAddress fields = [ - 'address', 'vrf', 'tenant', 'status', 'role', 'device', 'virtual_machine', 'interface', 'fhrp_group', - 'is_primary', 'is_oob', 'dns_name', 'description', 'comments', 'tags', + 'prefix', 'address', 'vrf', 'tenant', 'status', 'role', 'device', 'virtual_machine', 'interface', + 'fhrp_group', '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 dcd9ab5e2..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, @@ -278,10 +284,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), @@ -326,7 +340,7 @@ class IPAddressFilterForm(ContactModelFilterForm, TenancyFilterForm, NetBoxModel 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')), @@ -334,7 +348,7 @@ class IPAddressFilterForm(ContactModelFilterForm, TenancyFilterForm, NetBoxModel FieldSet('device_id', 'virtual_machine_id', name=_('Device/VM')), FieldSet('contact', 'contact_role', 'contact_group', name=_('Contacts')), ) - 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( @@ -354,6 +368,11 @@ class IPAddressFilterForm(ContactModelFilterForm, TenancyFilterForm, NetBoxModel 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 83bb42a4f..96bfae34c 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,12 +279,21 @@ 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', ] 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, @@ -326,7 +340,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( @@ -342,8 +356,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): @@ -468,6 +482,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, @@ -477,7 +500,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/filters.py b/netbox/ipam/graphql/filters.py index c66b8d483..6f39ee310 100644 --- a/netbox/ipam/graphql/filters.py +++ b/netbox/ipam/graphql/filters.py @@ -145,6 +145,7 @@ class FHRPGroupAssignmentFilter(BaseObjectTypeFilterMixin, ChangeLogFilterMixin) @strawberry_django.filter_type(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() @@ -196,6 +197,7 @@ class IPAddressFilter(ContactFilterMixin, TenancyFilterMixin, PrimaryModelFilter @strawberry_django.filter_type(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 = ( @@ -225,6 +227,10 @@ class IPRangeFilter(ContactFilterMixin, TenancyFilterMixin, PrimaryModelFilterMi @strawberry_django.filter_type(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/graphql/types.py b/netbox/ipam/graphql/types.py index e8f98eabe..39eec74e5 100644 --- a/netbox/ipam/graphql/types.py +++ b/netbox/ipam/graphql/types.py @@ -144,6 +144,7 @@ class FHRPGroupAssignmentType(BaseObjectType): ) class IPAddressType(NetBoxObjectType, ContactsMixin, 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 @@ -168,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 @@ -182,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/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/0083_ipaddress_iprange_prefix_parent_data.py b/netbox/ipam/migrations/0083_ipaddress_iprange_prefix_parent_data.py new file mode 100644 index 000000000..45cf48c04 --- /dev/null +++ b/netbox/ipam/migrations/0083_ipaddress_iprange_prefix_parent_data.py @@ -0,0 +1,129 @@ +# Generated by Django 5.0.9 on 2025-02-20 16:49 + +import sys +import time + +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) + sys.stdout.write('\r') + sys.stdout.write(f"[{'=' * bar:{length}s}] {percent}%") + sys.stdout.flush() + + +def set_prefix(apps, schema_editor, model, attr='address', parent_model='Prefix'): + start = time.time() + ChildModel = apps.get_model('ipam', model) + ParentModel = apps.get_model('ipam', parent_model) + + 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 + 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) + + end = time.time() + 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): + set_prefix(apps, schema_editor, 'IPRange', 'start_address') + + +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): + set_prefix(apps, schema_editor, 'Prefix', 'prefix', 'Aggregate') + + +def unset_prefix_aggregate(apps, schema_editor): + Prefix = apps.get_model('ipam', 'Prefix') + 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', '0082_ipaddress_iprange_prefix_parent'), + ] + + operations = [ + 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 ab2481d90..b2ef42941 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 @@ -204,6 +205,22 @@ 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.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') @@ -291,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: @@ -298,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() @@ -311,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): @@ -331,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): @@ -475,11 +513,33 @@ 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): """ A range of IP addresses, defined by start and end addresses. """ + prefix = models.ForeignKey( + to='ipam.Prefix', + on_delete=models.SET_NULL, + 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)') @@ -549,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: @@ -705,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): """ @@ -717,6 +806,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)') @@ -804,6 +901,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') @@ -845,6 +943,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: @@ -985,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/search.py b/netbox/ipam/search.py index 63437e417..664165d73 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 @@ -65,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 @@ -76,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 3b36b561f..7e210a756 100644 --- a/netbox/ipam/signals.py +++ b/netbox/ipam/signals.py @@ -1,9 +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): @@ -26,12 +29,140 @@ def update_children_depth(prefix): Prefix.objects.bulk_update(children, ['_depth'], batch_size=100) +def update_object_prefix(prefix, delete=False, parent_model=Prefix, child_model=IPAddress): + if delete: + # Get all possible addresses + 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() + + for address in addresses: + # Set contained addresses to the containing prefix if it exists + address.prefix = prefix + else: + 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 addresses prefix is not set then this model is the only option + if not address.prefix: + address.prefix = prefix + # 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: + pass + + # Update the addresses + 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): + update_object_prefix(prefix, delete, child_model=IPRange) + + +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): # 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_iprange_prefix(instance) + update_prefix_parents(instance) update_parents_children(instance) update_children_depth(instance) @@ -42,11 +173,21 @@ 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) + update_iprange_prefix(instance, True) + update_prefix_parents(instance, delete=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) + update_iprange_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 03365a442..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,10 +311,15 @@ class IPRangeTable(TenancyColumnsMixin, NetBoxTable): # class IPAddressTable(TenancyColumnsMixin, NetBoxTable): + # TODO: Alter for prefix address = tables.TemplateColumn( 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_api.py b/netbox/ipam/tests/test_api.py index a7562a53b..79d524e87 100644 --- a/netbox/ipam/tests/test_api.py +++ b/netbox/ipam/tests/test_api.py @@ -358,7 +358,8 @@ class RoleTest(APIViewTestCases.APIViewTestCase): class PrefixTest(APIViewTestCases.APIViewTestCase): model = Prefix - brief_fields = ['_depth', 'description', 'display', 'family', 'id', 'prefix', 'url'] + # TODO: Alter for parent prefix + brief_fields = ['_depth', 'aggregate', 'description', 'display', 'family', 'id', 'parent', 'prefix', 'url'] create_data = [ { 'prefix': '192.168.4.0/24', @@ -549,7 +550,8 @@ class PrefixTest(APIViewTestCases.APIViewTestCase): class IPRangeTest(APIViewTestCases.APIViewTestCase): model = IPRange - brief_fields = ['description', 'display', 'end_address', 'family', 'id', 'start_address', 'url'] + # TODO: Alter for parent prefix + brief_fields = ['description', 'display', 'end_address', 'family', 'id', 'prefix', 'start_address', 'url'] create_data = [ { 'start_address': '192.168.4.10/24', @@ -648,7 +650,8 @@ class IPRangeTest(APIViewTestCases.APIViewTestCase): class IPAddressTest(APIViewTestCases.APIViewTestCase): model = IPAddress - brief_fields = ['address', 'description', 'display', 'family', 'id', 'url'] + # TODO: Alter for parent prefix + brief_fields = ['address', 'description', 'display', 'family', 'id', 'prefix', 'url'] create_data = [ { 'address': '192.168.0.4/24', diff --git a/netbox/ipam/tests/test_filtersets.py b/netbox/ipam/tests/test_filtersets.py index 852fd3ea9..e068f8e06 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 656f62d87..f348a238b 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.0.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): @@ -169,19 +252,21 @@ 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.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()} + + 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 self.assertSetEqual(child_ip_pks, {ips[1].pk}) @@ -344,17 +429,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 +451,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 +473,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 +497,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 +517,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 +540,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 +565,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 +585,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 +599,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 +613,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 +636,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 +689,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 # @@ -559,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) diff --git a/netbox/ipam/tests/test_views.py b/netbox/ipam/tests/test_views.py index 0b4f3b740..ac674d0ea 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, @@ -477,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) @@ -491,12 +495,12 @@ 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.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) @@ -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, diff --git a/netbox/ipam/views.py b/netbox/ipam/views.py index 61a5ce563..c0f250648 100644 --- a/netbox/ipam/views.py +++ b/netbox/ipam/views.py @@ -663,13 +663,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 42e61040a..f0befd32d 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 a582561bd..0fafe255e 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" %}