diff --git a/base_requirements.txt b/base_requirements.txt index fd20eae0921..4ee93d1f347 100644 --- a/base_requirements.txt +++ b/base_requirements.txt @@ -36,6 +36,11 @@ django-mptt # https://github.com/Xof/django-pglocks/blob/master/CHANGES.txt django-pglocks + +# Manager for managing PostgreSQL triggers +# https://github.com/AmbitionEng/django-pgtrigger/blob/main/CHANGELOG.md +django-pgtrigger + # Prometheus metrics library for Django # https://github.com/korfuri/django-prometheus/blob/master/CHANGELOG.md django-prometheus 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 1b4a3d596b5..13b43ea422c 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 35ddd47e483..1df61efe893 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 = ( @@ -238,6 +240,10 @@ def contains(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/0083_ipaddress_iprange_prefix_parent.py b/netbox/ipam/migrations/0083_ipaddress_iprange_prefix_parent.py new file mode 100644 index 00000000000..d97a6ba73b9 --- /dev/null +++ b/netbox/ipam/migrations/0083_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', '0082_add_prefix_network_containment_indexes'), + ] + + 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..e3d75bd5b61 --- /dev/null +++ b/netbox/ipam/migrations/0083_ipaddress_iprange_prefix_parent_data.py @@ -0,0 +1,134 @@ +# 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_attr='prefix', 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') + print(f'Migrating {parent_model}') + 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, + ) + + setattr(address, parent_attr, prefixes.last()) + try: + address.save() + except Exception as e: + print(f'Error at {address}') + raise e + 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', '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', '0083_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/migrations/0084_prefix_ipam_prefix_delete_prefix_ipam_prefix_insert.py b/netbox/ipam/migrations/0084_prefix_ipam_prefix_delete_prefix_ipam_prefix_insert.py new file mode 100644 index 00000000000..d8be0a0a26d --- /dev/null +++ b/netbox/ipam/migrations/0084_prefix_ipam_prefix_delete_prefix_ipam_prefix_insert.py @@ -0,0 +1,43 @@ +# Generated by Django 5.2.5 on 2025-11-06 03:24 + +import pgtrigger.compiler +import pgtrigger.migrations +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('ipam', '0083_ipaddress_iprange_prefix_parent_data'), + ] + + operations = [ + pgtrigger.migrations.AddTrigger( + model_name='prefix', + trigger=pgtrigger.compiler.Trigger( + name='ipam_prefix_delete', + sql=pgtrigger.compiler.UpsertTriggerSql( + func="\n-- Update Child Prefix's with Prefix's PARENT\nUPDATE ipam_prefix SET parent_id=OLD.parent_id WHERE parent_id=OLD.id;\nRETURN OLD;\n", # noqa: E501 + hash='899e1943cb201118be7ef02f36f49747224774f2', + operation='DELETE', + pgid='pgtrigger_ipam_prefix_delete_e7810', + table='ipam_prefix', + when='BEFORE', + ), + ), + ), + pgtrigger.migrations.AddTrigger( + model_name='prefix', + trigger=pgtrigger.compiler.Trigger( + name='ipam_prefix_insert', + sql=pgtrigger.compiler.UpsertTriggerSql( + func="\nUPDATE ipam_prefix\nSET parent_id=NEW.id \nWHERE \n prefix << NEW.prefix\n AND\n (\n (vrf_id = NEW.vrf_id OR (vrf_id IS NULL AND NEW.vrf_id IS NULL))\n OR\n (\n NEW.vrf_id IS NULL\n AND\n NEW.status = 'container'\n AND\n NOT EXISTS(\n SELECT 1 FROM ipam_prefix p WHERE p.prefix >> ipam_prefix.prefix AND p.vrf_id = ipam_prefix.vrf_id\n )\n )\n )\n AND id != NEW.id\n AND NOT EXISTS (\n SELECT 1 FROM ipam_prefix p\n WHERE\n p.prefix >> ipam_prefix.prefix\n AND p.prefix << NEW.prefix\n AND (\n (p.vrf_id = ipam_prefix.vrf_id OR (p.vrf_id IS NULL AND ipam_prefix.vrf_id IS NULL))\n OR\n (p.vrf_id IS NULL AND p.status = 'container')\n )\n AND p.id != NEW.id\n )\n;\nRETURN NEW;\n", # noqa: E501 + hash='0e05bbe61861227a9eb710b6c94bae9e0cc7119e', + operation='INSERT', + pgid='pgtrigger_ipam_prefix_insert_46c72', + table='ipam_prefix', + when='AFTER', + ), + ), + ), + ] diff --git a/netbox/ipam/migrations/0085_alter_prefix_parent.py b/netbox/ipam/migrations/0085_alter_prefix_parent.py new file mode 100644 index 00000000000..b2deef156d1 --- /dev/null +++ b/netbox/ipam/migrations/0085_alter_prefix_parent.py @@ -0,0 +1,25 @@ +# Generated by Django 5.2.5 on 2025-11-25 03:53 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('ipam', '0084_prefix_ipam_prefix_delete_prefix_ipam_prefix_insert'), + ] + + operations = [ + migrations.AlterField( + model_name='prefix', + name='parent', + field=models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.DO_NOTHING, + related_name='children', + to='ipam.prefix', + ), + ), + ] diff --git a/netbox/ipam/migrations/0086_update_trigger.py b/netbox/ipam/migrations/0086_update_trigger.py new file mode 100644 index 00000000000..9aaa041cdde --- /dev/null +++ b/netbox/ipam/migrations/0086_update_trigger.py @@ -0,0 +1,65 @@ +# Generated by Django 5.2.5 on 2025-11-25 06:00 + +import pgtrigger.compiler +import pgtrigger.migrations +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('ipam', '0085_alter_prefix_parent'), + ] + + operations = [ + pgtrigger.migrations.RemoveTrigger( + model_name='prefix', + name='ipam_prefix_delete', + ), + pgtrigger.migrations.RemoveTrigger( + model_name='prefix', + name='ipam_prefix_insert', + ), + pgtrigger.migrations.AddTrigger( + model_name='prefix', + trigger=pgtrigger.compiler.Trigger( + name='ipam_prefix_delete', + sql=pgtrigger.compiler.UpsertTriggerSql( + func="\n-- Update Child Prefix's with Prefix's PARENT This is a safe assumption based on the fact that the parent would be the\n-- next direct parent for anything else that could contain this prefix\nUPDATE ipam_prefix SET parent_id=OLD.parent_id WHERE parent_id=OLD.id;\nRETURN OLD;\n", # noqa: E501 + hash='ee3f890009c05a3617428158e7b6f3d77317885d', + operation='DELETE', + pgid='pgtrigger_ipam_prefix_delete_e7810', + table='ipam_prefix', + when='BEFORE', + ), + ), + ), + pgtrigger.migrations.AddTrigger( + model_name='prefix', + trigger=pgtrigger.compiler.Trigger( + name='ipam_prefix_insert', + sql=pgtrigger.compiler.UpsertTriggerSql( + func="\n-- Update the prefix with the new parent if the parent is the most appropriate prefix\nUPDATE ipam_prefix\nSET parent_id=NEW.id\nWHERE\n prefix << NEW.prefix\n AND\n (\n (vrf_id = NEW.vrf_id OR (vrf_id IS NULL AND NEW.vrf_id IS NULL))\n OR\n (\n NEW.vrf_id IS NULL\n AND\n NEW.status = 'container'\n AND\n NOT EXISTS(\n SELECT 1 FROM ipam_prefix p WHERE p.prefix >> ipam_prefix.prefix AND p.vrf_id = ipam_prefix.vrf_id\n )\n )\n )\n AND id != NEW.id\n AND NOT EXISTS (\n SELECT 1 FROM ipam_prefix p\n WHERE\n p.prefix >> ipam_prefix.prefix\n AND p.prefix << NEW.prefix\n AND (\n (p.vrf_id = ipam_prefix.vrf_id OR (p.vrf_id IS NULL AND ipam_prefix.vrf_id IS NULL))\n OR\n (p.vrf_id IS NULL AND p.status = 'container')\n )\n AND p.id != NEW.id\n )\n;\nRETURN NEW;\n", # noqa: E501 + hash='1d71498f09e767183d3b0d29c06c9ac9e2cc000a', + operation='INSERT', + pgid='pgtrigger_ipam_prefix_insert_46c72', + table='ipam_prefix', + when='AFTER', + ), + ), + ), + pgtrigger.migrations.AddTrigger( + model_name='prefix', + trigger=pgtrigger.compiler.Trigger( + name='ipam_prefix_update', + sql=pgtrigger.compiler.UpsertTriggerSql( + func="\n-- When a prefix changes, reassign any IPAddresses that no longer\n-- fall within the new prefix range to the parent prefix (or set null if no parent exists)\nUPDATE ipam_prefix\nSET parent_id = OLD.parent_id\nWHERE\n parent_id = NEW.id\n -- IP address no longer contained within the updated prefix\n AND NOT (prefix << NEW.prefix);\n\n-- Update the prefix with the new parent if the parent is the most appropriate prefix\nUPDATE ipam_prefix\nSET parent_id=NEW.id\nWHERE\n prefix << NEW.prefix\n AND\n (\n (vrf_id = NEW.vrf_id OR (vrf_id IS NULL AND NEW.vrf_id IS NULL))\n OR\n (\n NEW.vrf_id IS NULL\n AND\n NEW.status = 'container'\n AND\n NOT EXISTS(\n SELECT 1 FROM ipam_prefix p WHERE p.prefix >> ipam_prefix.prefix AND p.vrf_id = ipam_prefix.vrf_id\n )\n )\n )\n AND id != NEW.id\n AND NOT EXISTS (\n SELECT 1 FROM ipam_prefix p\n WHERE\n p.prefix >> ipam_prefix.prefix\n AND p.prefix << NEW.prefix\n AND (\n (p.vrf_id = ipam_prefix.vrf_id OR (p.vrf_id IS NULL AND ipam_prefix.vrf_id IS NULL))\n OR\n (p.vrf_id IS NULL AND p.status = 'container')\n )\n AND p.id != NEW.id\n )\n;\nRETURN NEW;\n", # noqa: E501 + hash='747230a84703df5a4aa3d32e7f45b5a32525b799', + operation='UPDATE', + pgid='pgtrigger_ipam_prefix_update_e5fca', + table='ipam_prefix', + when='AFTER', + ), + ), + ), + ] diff --git a/netbox/ipam/models/ip.py b/netbox/ipam/models/ip.py index cef979d3f68..529611815bc 100644 --- a/netbox/ipam/models/ip.py +++ b/netbox/ipam/models/ip.py @@ -1,4 +1,5 @@ import netaddr +import pgtrigger from django.contrib.contenttypes.fields import GenericForeignKey from django.contrib.contenttypes.models import ContentType from django.contrib.postgres.indexes import GistIndex @@ -8,6 +9,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 * @@ -16,6 +18,8 @@ from ipam.lookups import Host from ipam.managers import IPAddressManager from ipam.querysets import PrefixQuerySet +from ipam.triggers import ipam_prefix_delete_adjust_prefix_parent, ipam_prefix_insert_adjust_prefix_parent, \ + ipam_prefix_update_adjust_prefix_parent from ipam.validators import DNSValidator from netbox.config import get_config from netbox.models import OrganizationalModel, PrimaryModel @@ -185,31 +189,28 @@ def get_utilization(self): return min(utilization, 100) -class Role(OrganizationalModel): - """ - A Role represents the functional role of a Prefix or VLAN; for example, "Customer," "Infrastructure," or - "Management." - """ - weight = models.PositiveSmallIntegerField( - verbose_name=_('weight'), - default=1000 - ) - - class Meta: - ordering = ('weight', 'name') - verbose_name = _('role') - verbose_name_plural = _('roles') - - def __str__(self): - return self.name - - class Prefix(ContactsMixin, GetAvailablePrefixesMixin, CachedScopeMixin, PrimaryModel): """ A Prefix represents an IPv4 or IPv6 network, including mask length. Prefixes can optionally be scoped to certain 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, # This is handled by triggers + related_name='prefixes', + blank=True, + null=True, + verbose_name=_('aggregate') + ) + parent = models.ForeignKey( + to='ipam.Prefix', + on_delete=models.DO_NOTHING, + related_name='children', + blank=True, + null=True, + verbose_name=_('Prefix') + ) prefix = IPNetworkField( verbose_name=_('prefix'), help_text=_('IPv4 or IPv6 network with mask') @@ -289,6 +290,26 @@ class Meta: opclasses=['inet_ops'], ), ] + triggers = [ + pgtrigger.Trigger( + name='ipam_prefix_delete', + operation=pgtrigger.Delete, + when=pgtrigger.Before, + func=ipam_prefix_delete_adjust_prefix_parent, + ), + pgtrigger.Trigger( + name='ipam_prefix_insert', + operation=pgtrigger.Insert, + when=pgtrigger.After, + func=ipam_prefix_insert_adjust_prefix_parent, + ), + pgtrigger.Trigger( + name='ipam_prefix_update', + operation=pgtrigger.Update, + when=pgtrigger.After, + func=ipam_prefix_update_adjust_prefix_parent, + ), + ] def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) @@ -304,6 +325,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: @@ -311,6 +334,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() @@ -324,6 +358,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): @@ -349,11 +391,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): @@ -493,11 +535,52 @@ 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.prefix) + ) | models.Q( + vrf=None, + status=PrefixStatusChoices.STATUS_CONTAINER, + prefix__net_contains=str(network.prefix), + ) + ) + return prefixes.last() + + +class Role(OrganizationalModel): + """ + A Role represents the functional role of a Prefix or VLAN; for example, "Customer," "Infrastructure," or + "Management." + """ + weight = models.PositiveSmallIntegerField( + verbose_name=_('weight'), + default=1000 + ) + + class Meta: + ordering = ('weight', 'name') + verbose_name = _('role') + verbose_name_plural = _('roles') + + def __str__(self): + return self.name + 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)') @@ -567,6 +650,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: @@ -723,6 +827,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): """ @@ -735,6 +847,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)') @@ -822,6 +942,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') @@ -868,6 +989,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: @@ -1008,3 +1139,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/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 %} -