diff --git a/netbox/ipam/api/serializers_/ip.py b/netbox/ipam/api/serializers_/ip.py index 5337b86f1ad..ea587eb95d5 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 @@ def to_representation(self, instance): # 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 Meta: # 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 7f8cd2f04fc..36e195900f3 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 864630bd451..e727785e17f 100644 --- a/netbox/ipam/forms/bulk_edit.py +++ b/netbox/ipam/forms/bulk_edit.py @@ -207,6 +207,11 @@ class RoleBulkEditForm(NetBoxModelBulkEditForm): class PrefixBulkEditForm(ScopedBulkEditForm, NetBoxModelBulkEditForm): + parent = DynamicModelChoiceField( + queryset=Prefix.objects.all(), + required=False, + label=_('Parent Prefix') + ) vlan_group = DynamicModelChoiceField( queryset=VLANGroup.objects.all(), required=False, @@ -266,7 +271,7 @@ class PrefixBulkEditForm(ScopedBulkEditForm, NetBoxModelBulkEditForm): model = Prefix fieldsets = ( FieldSet('tenant', 'status', 'role', 'description'), - FieldSet('vrf', 'prefix_length', 'is_pool', 'mark_utilized', name=_('Addressing')), + FieldSet('parent', 'vrf', 'prefix_length', 'is_pool', 'mark_utilized', name=_('Addressing')), FieldSet('scope_type', 'scope', name=_('Scope')), FieldSet('vlan_group', 'vlan', name=_('VLAN Assignment')), ) @@ -276,6 +281,11 @@ class PrefixBulkEditForm(ScopedBulkEditForm, NetBoxModelBulkEditForm): class IPRangeBulkEditForm(NetBoxModelBulkEditForm): + prefix = DynamicModelChoiceField( + queryset=Prefix.objects.all(), + required=False, + label=_('Prefix') + ) vrf = DynamicModelChoiceField( queryset=VRF.objects.all(), required=False, @@ -323,6 +333,16 @@ class IPRangeBulkEditForm(NetBoxModelBulkEditForm): class IPAddressBulkEditForm(NetBoxModelBulkEditForm): + prefix = DynamicModelChoiceField( + queryset=Prefix.objects.all(), + required=False, + label=_('Prefix') + ) + prefix = DynamicModelChoiceField( + queryset=Prefix.objects.all(), + required=False, + label=_('Prefix') + ) vrf = DynamicModelChoiceField( queryset=VRF.objects.all(), required=False, @@ -364,10 +384,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 c0aa4346190..b0f7d222941 100644 --- a/netbox/ipam/forms/bulk_import.py +++ b/netbox/ipam/forms/bulk_import.py @@ -156,6 +156,18 @@ class Meta: class PrefixImportForm(ScopedImportForm, NetBoxModelImportForm): + aggregate = CSVModelChoiceField( + label=_('Aggregate'), + queryset=Aggregate.objects.all(), + to_field_name='prefix', + required=False + ) + parent = CSVModelChoiceField( + label=_('Prefix'), + queryset=Prefix.objects.all(), + to_field_name='prefix', + required=False + ) vrf = CSVModelChoiceField( label=_('VRF'), queryset=VRF.objects.all(), @@ -243,8 +255,26 @@ def __init__(self, data=None, *args, **kwargs): queryset = self.fields['vlan'].queryset.filter(query) self.fields['vlan'].queryset = queryset + # Limit Prefix queryset by assigned vrf + vrf = data.get('vrf') + query = Q() + if vrf: + query &= Q(**{ + f"vrf__{self.fields['vrf'].to_field_name}": vrf + }) + + queryset = self.fields['parent'].queryset.filter(query) + self.fields['parent'].queryset = queryset + class IPRangeImportForm(NetBoxModelImportForm): + prefix = CSVModelChoiceField( + label=_('Prefix'), + queryset=Prefix.objects.all(), + to_field_name='prefix', + required=True, + help_text=_('Assigned prefix') + ) vrf = CSVModelChoiceField( label=_('VRF'), queryset=VRF.objects.all(), @@ -279,8 +309,29 @@ class Meta: 'description', 'comments', 'tags', ) + def __init__(self, data=None, *args, **kwargs): + super().__init__(data, *args, **kwargs) + + # Limit Prefix queryset by assigned vrf + vrf = data.get('vrf') + query = Q() + if vrf: + query &= Q(**{ + f"vrf__{self.fields['vrf'].to_field_name}": vrf + }) + + queryset = self.fields['prefix'].queryset.filter(query) + self.fields['prefix'].queryset = queryset + 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(), @@ -348,8 +399,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): @@ -357,6 +408,15 @@ def __init__(self, data=None, *args, **kwargs): if data: + # Limit Prefix queryset by assigned vrf + vrf = data.get('vrf') + query = Q() + if vrf: + query &= Q(**{f"vrf__{self.fields['vrf'].to_field_name}": vrf}) + + queryset = self.fields['prefix'].queryset.filter(query) + self.fields['prefix'].queryset = queryset + # Limit interface queryset by assigned device if data.get('device'): self.fields['interface'].queryset = Interface.objects.filter( diff --git a/netbox/ipam/forms/filtersets.py b/netbox/ipam/forms/filtersets.py index dcd9ab5e25f..c96fbd47146 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 aaf7fe7d3c3..5ee073bc93c 100644 --- a/netbox/ipam/forms/model_forms.py +++ b/netbox/ipam/forms/model_forms.py @@ -251,6 +251,11 @@ def __init__(self, *args, **kwargs): class IPRangeForm(TenancyForm, NetBoxModelForm): + prefix = DynamicModelChoiceField( + queryset=Prefix.objects.all(), + required=False, + label=_('Prefix') + ) vrf = DynamicModelChoiceField( queryset=VRF.objects.all(), required=False, @@ -266,8 +271,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')), ) @@ -275,12 +280,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, @@ -327,7 +341,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( @@ -343,8 +357,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): @@ -469,6 +483,15 @@ def save(self, *args, **kwargs): 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, @@ -478,7 +501,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 c66b8d48392..6f39ee310d9 100644 --- a/netbox/ipam/graphql/filters.py +++ b/netbox/ipam/graphql/filters.py @@ -145,6 +145,7 @@ def filter_device(self, field, value) -> Q: @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 @@ def family( @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 @@ def parent(self, value: list[str], prefix) -> Q: @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 e8f98eabe33..39eec74e51a 100644 --- a/netbox/ipam/graphql/types.py +++ b/netbox/ipam/graphql/types.py @@ -144,6 +144,7 @@ def interface(self) -> Annotated[Union[ ) 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 @@ def assigned_object(self) -> Annotated[Union[ 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 00000000000..d86c18670fc --- /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 00000000000..45cf48c04c7 --- /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 73c3310dcf0..9f9c8884c16 100644 --- a/netbox/ipam/models/ip.py +++ b/netbox/ipam/models/ip.py @@ -7,6 +7,7 @@ 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 dcim.models.mixins import CachedScopeMixin from ipam.choices import * @@ -209,6 +210,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') @@ -296,6 +313,8 @@ def clean(self): 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: @@ -303,6 +322,17 @@ def clean(self): '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() @@ -316,6 +346,14 @@ def clean(self): }) 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): @@ -341,11 +379,11 @@ def ipv6_full(self): return netaddr.IPAddress(self.prefix).format(netaddr.ipv6_full) @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): @@ -485,11 +523,33 @@ def get_utilization(self): 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)') @@ -559,6 +619,27 @@ def clean(self): 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: @@ -715,6 +796,14 @@ def utilization(self): 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): """ @@ -727,6 +816,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)') @@ -814,6 +911,7 @@ def __str__(self): 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') @@ -860,6 +958,16 @@ def clean(self): 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: @@ -1000,3 +1108,8 @@ def get_status_color(self): 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 63437e417e3..664165d731a 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 3b36b561f55..7e210a75681 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 03365a44292..3b1f66c37c5 100644 --- a/netbox/ipam/tables/ip.py +++ b/netbox/ipam/tables/ip.py @@ -155,6 +155,10 @@ class PrefixUtilizationColumn(columns.UtilizationColumn): class PrefixTable(TenancyColumnsMixin, NetBoxTable): + parent = tables.Column( + verbose_name=_('Parent'), + linkify=True + ) prefix = columns.TemplateColumn( verbose_name=_('Prefix'), template_code=PREFIX_LINK_WITH_DEPTH, @@ -236,9 +240,9 @@ class PrefixTable(TenancyColumnsMixin, NetBoxTable): class Meta(NetBoxTable.Meta): model = Prefix fields = ( - 'pk', 'id', 'prefix', 'prefix_flat', 'status', 'children', 'vrf', 'utilization', 'tenant', 'tenant_group', - 'scope', 'scope_type', 'vlan_group', 'vlan', 'role', 'is_pool', 'mark_utilized', 'description', 'comments', - 'tags', 'created', 'last_updated', + 'pk', 'id', 'prefix', 'status', 'parent', 'parent_flat', 'children', 'vrf', 'utilization', + 'tenant', 'tenant_group', 'scope', 'scope_type', 'vlan_group', 'vlan', 'role', 'is_pool', 'mark_utilized', + 'description', 'comments', 'tags', 'created', 'last_updated', ) default_columns = ( 'pk', 'prefix', 'status', 'children', 'vrf', 'utilization', 'tenant', 'scope', 'vlan', 'role', @@ -253,6 +257,10 @@ class Meta(NetBoxTable.Meta): # IP ranges # class IPRangeTable(TenancyColumnsMixin, NetBoxTable): + prefix = tables.Column( + verbose_name=_('Prefix'), + linkify=True + ) start_address = tables.Column( verbose_name=_('Start address'), linkify=True @@ -292,9 +300,9 @@ class IPRangeTable(TenancyColumnsMixin, NetBoxTable): class Meta(NetBoxTable.Meta): model = IPRange fields = ( - 'pk', 'id', 'start_address', 'end_address', 'size', 'vrf', 'status', 'role', 'tenant', 'tenant_group', - 'mark_populated', 'mark_utilized', 'utilization', 'description', 'comments', 'tags', 'created', - 'last_updated', + 'pk', 'id', 'start_address', 'end_address', 'prefix', 'size', 'vrf', 'status', 'role', 'tenant', + 'tenant_group', 'mark_populated', 'mark_utilized', 'utilization', 'description', 'comments', 'tags', + 'created', 'last_updated', ) default_columns = ( 'pk', 'start_address', 'end_address', 'size', 'vrf', 'status', 'role', 'tenant', 'description', @@ -309,10 +317,18 @@ class Meta(NetBoxTable.Meta): # class IPAddressTable(TenancyColumnsMixin, NetBoxTable): + prefix = tables.Column( + verbose_name=_('Prefix'), + linkify=True + ) 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') @@ -364,8 +380,8 @@ class IPAddressTable(TenancyColumnsMixin, NetBoxTable): class Meta(NetBoxTable.Meta): model = IPAddress fields = ( - 'pk', 'id', 'address', 'vrf', 'status', 'role', 'tenant', 'tenant_group', 'nat_inside', 'nat_outside', - 'assigned', 'dns_name', 'description', 'comments', 'tags', 'created', 'last_updated', + 'pk', 'id', 'address', 'vrf', 'prefix', 'status', 'role', 'tenant', 'tenant_group', 'nat_inside', + 'nat_outside', 'assigned', 'dns_name', 'description', 'comments', 'tags', 'created', 'last_updated', ) default_columns = ( 'pk', 'address', 'vrf', 'status', 'role', 'tenant', 'assigned', 'dns_name', 'description', diff --git a/netbox/ipam/tables/template_code.py b/netbox/ipam/tables/template_code.py index 14b73b28ddf..87c42600989 100644 --- a/netbox/ipam/tables/template_code.py +++ b/netbox/ipam/tables/template_code.py @@ -16,12 +16,20 @@ PREFIX_LINK_WITH_DEPTH = """ {% load helpers %} -{% if record.depth %} -