diff --git a/docs/models/ipam/asn.md b/docs/models/ipam/asn.md index 17fa5ebe3..33f8b13ed 100644 --- a/docs/models/ipam/asn.md +++ b/docs/models/ipam/asn.md @@ -14,6 +14,10 @@ The 16- or 32-bit AS number. The [Regional Internet Registry](./rir.md) or similar authority responsible for the allocation of this particular ASN. +### Role + +The user-defined functional [role](./role.md) assigned to this ASN. + ### Sites The [site(s)](../dcim/site.md) to which this ASN is assigned. diff --git a/netbox/ipam/api/serializers_/asns.py b/netbox/ipam/api/serializers_/asns.py index 334755734..c5c95bf19 100644 --- a/netbox/ipam/api/serializers_/asns.py +++ b/netbox/ipam/api/serializers_/asns.py @@ -6,6 +6,8 @@ from netbox.api.fields import RelatedObjectCountField, SerializedPKRelatedField from netbox.api.serializers import OrganizationalModelSerializer, PrimaryModelSerializer from tenancy.api.serializers_.tenants import TenantSerializer +from .roles import RoleSerializer + __all__ = ( 'ASNRangeSerializer', 'ASNSerializer', @@ -56,6 +58,7 @@ class ASNSiteSerializer(PrimaryModelSerializer): class ASNSerializer(PrimaryModelSerializer): rir = RIRSerializer(nested=True, required=False, allow_null=True) + role = RoleSerializer(nested=True, required=False, allow_null=True) tenant = TenantSerializer(nested=True, required=False, allow_null=True) sites = SerializedPKRelatedField( queryset=Site.objects.all(), @@ -72,8 +75,8 @@ class ASNSerializer(PrimaryModelSerializer): class Meta: model = ASN fields = [ - 'id', 'url', 'display_url', 'display', 'asn', 'rir', 'tenant', 'description', 'owner', 'comments', 'tags', - 'custom_fields', 'created', 'last_updated', 'site_count', 'provider_count', 'sites', + 'id', 'url', 'display_url', 'display', 'asn', 'rir', 'role', 'tenant', 'description', 'owner', 'comments', + 'tags', 'custom_fields', 'created', 'last_updated', 'site_count', 'provider_count', 'sites', ] brief_fields = ('id', 'url', 'display', 'asn', 'description') diff --git a/netbox/ipam/api/serializers_/roles.py b/netbox/ipam/api/serializers_/roles.py index 80a892659..8e62e4095 100644 --- a/netbox/ipam/api/serializers_/roles.py +++ b/netbox/ipam/api/serializers_/roles.py @@ -12,11 +12,13 @@ class RoleSerializer(OrganizationalModelSerializer): # Related object counts prefix_count = RelatedObjectCountField('prefixes') vlan_count = RelatedObjectCountField('vlans') + asn_count = RelatedObjectCountField('asns') class Meta: model = Role fields = [ 'id', 'url', 'display_url', 'display', 'name', 'slug', 'weight', 'description', 'owner', 'comments', 'tags', - 'custom_fields', 'created', 'last_updated', 'prefix_count', 'vlan_count', + 'custom_fields', 'created', 'last_updated', 'prefix_count', 'vlan_count', 'asn_count', ] - brief_fields = ('id', 'url', 'display', 'name', 'slug', 'description', 'prefix_count', 'vlan_count') + brief_fields = ('id', 'url', 'display', 'name', 'slug', 'description', 'prefix_count', 'vlan_count', + 'asn_count') diff --git a/netbox/ipam/filtersets.py b/netbox/ipam/filtersets.py index 56d2fb2b7..4e8ec5d88 100644 --- a/netbox/ipam/filtersets.py +++ b/netbox/ipam/filtersets.py @@ -289,6 +289,18 @@ class ASNFilterSet(PrimaryModelFilterSet, TenancyFilterSet): to_field_name='slug', label=_('Provider (slug)'), ) + role_id = django_filters.ModelMultipleChoiceFilter( + queryset=Role.objects.all(), + distinct=False, + label=_('Role (ID)'), + ) + role = django_filters.ModelMultipleChoiceFilter( + field_name='role__slug', + queryset=Role.objects.all(), + distinct=False, + to_field_name='slug', + label=_('Role (slug)'), + ) class Meta: model = ASN diff --git a/netbox/ipam/forms/bulk_edit.py b/netbox/ipam/forms/bulk_edit.py index 02e18bbe3..fd0afffa1 100644 --- a/netbox/ipam/forms/bulk_edit.py +++ b/netbox/ipam/forms/bulk_edit.py @@ -121,6 +121,11 @@ class ASNBulkEditForm(PrimaryModelBulkEditForm): required=False, label=_('RIR') ) + role = DynamicModelChoiceField( + queryset=Role.objects.all(), + required=False, + label=_('Role') + ) tenant = DynamicModelChoiceField( label=_('Tenant'), queryset=Tenant.objects.all(), @@ -129,9 +134,9 @@ class ASNBulkEditForm(PrimaryModelBulkEditForm): model = ASN fieldsets = ( - FieldSet('sites', 'rir', 'tenant', 'description'), + FieldSet('sites', 'rir', 'role', 'tenant', 'description'), ) - nullable_fields = ('tenant', 'description', 'comments') + nullable_fields = ('role', 'tenant', 'description', 'comments') class AggregateBulkEditForm(PrimaryModelBulkEditForm): diff --git a/netbox/ipam/forms/bulk_import.py b/netbox/ipam/forms/bulk_import.py index 7f9b5a00c..77aca5b83 100644 --- a/netbox/ipam/forms/bulk_import.py +++ b/netbox/ipam/forms/bulk_import.py @@ -138,6 +138,13 @@ class ASNImportForm(PrimaryModelImportForm): to_field_name='name', help_text=_('Assigned RIR') ) + role = CSVModelChoiceField( + label=_('Role'), + queryset=Role.objects.all(), + required=False, + to_field_name='name', + help_text=_('Functional role') + ) tenant = CSVModelChoiceField( label=_('Tenant'), queryset=Tenant.objects.all(), @@ -148,7 +155,7 @@ class ASNImportForm(PrimaryModelImportForm): class Meta: model = ASN - fields = ('asn', 'rir', 'tenant', 'description', 'owner', 'comments', 'tags') + fields = ('asn', 'rir', 'role', 'tenant', 'description', 'owner', 'comments', 'tags') class RoleImportForm(OrganizationalModelImportForm): diff --git a/netbox/ipam/forms/filtersets.py b/netbox/ipam/forms/filtersets.py index 0bfbd53a4..dbb9765a7 100644 --- a/netbox/ipam/forms/filtersets.py +++ b/netbox/ipam/forms/filtersets.py @@ -151,7 +151,7 @@ class ASNFilterForm(TenancyFilterForm, PrimaryModelFilterSetForm): model = ASN fieldsets = ( FieldSet('q', 'filter_id', 'tag'), - FieldSet('rir_id', 'site_group_id', 'site_id', name=_('Assignment')), + FieldSet('rir_id', 'role_id', 'site_group_id', 'site_id', name=_('Assignment')), FieldSet('tenant_group_id', 'tenant_id', name=_('Tenant')), FieldSet('owner_group_id', 'owner_id', name=_('Ownership')), ) @@ -160,6 +160,11 @@ class ASNFilterForm(TenancyFilterForm, PrimaryModelFilterSetForm): required=False, label=_('RIR') ) + role_id = DynamicModelMultipleChoiceField( + queryset=Role.objects.all(), + required=False, + label=_('Role') + ) site_group_id = DynamicModelMultipleChoiceField( queryset=SiteGroup.objects.all(), required=False, diff --git a/netbox/ipam/forms/model_forms.py b/netbox/ipam/forms/model_forms.py index 1bdcff2d8..3748f2901 100644 --- a/netbox/ipam/forms/model_forms.py +++ b/netbox/ipam/forms/model_forms.py @@ -152,6 +152,12 @@ class ASNForm(TenancyForm, PrimaryModelForm): label=_('RIR'), quick_add=True ) + role = DynamicModelChoiceField( + queryset=Role.objects.all(), + label=_('Role'), + required=False, + quick_add=True + ) sites = DynamicModelMultipleChoiceField( queryset=Site.objects.all(), label=_('Sites'), @@ -159,14 +165,14 @@ class ASNForm(TenancyForm, PrimaryModelForm): ) fieldsets = ( - FieldSet('asn', 'rir', 'sites', 'description', 'tags', name=_('ASN')), + FieldSet('asn', 'rir', 'role', 'sites', 'description', 'tags', name=_('ASN')), FieldSet('tenant_group', 'tenant', name=_('Tenancy')), ) class Meta: model = ASN fields = [ - 'asn', 'rir', 'sites', 'tenant_group', 'tenant', 'description', 'owner', 'comments', 'tags' + 'asn', 'rir', 'role', 'sites', 'tenant_group', 'tenant', 'description', 'owner', 'comments', 'tags' ] widgets = { 'date_added': DatePicker(), diff --git a/netbox/ipam/graphql/filters.py b/netbox/ipam/graphql/filters.py index 6126c3c45..664e6be65 100644 --- a/netbox/ipam/graphql/filters.py +++ b/netbox/ipam/graphql/filters.py @@ -57,6 +57,8 @@ __all__ = ( class ASNFilter(TenancyFilterMixin, PrimaryModelFilter): rir: Annotated['RIRFilter', strawberry.lazy('ipam.graphql.filters')] | None = strawberry_django.filter_field() rir_id: ID | None = strawberry_django.filter_field() + role: Annotated['RoleFilter', strawberry.lazy('ipam.graphql.filters')] | None = strawberry_django.filter_field() + role_id: ID | None = strawberry_django.filter_field() asn: Annotated['BigIntegerLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = ( strawberry_django.filter_field() ) diff --git a/netbox/ipam/graphql/types.py b/netbox/ipam/graphql/types.py index 98aa91939..1c281cedf 100644 --- a/netbox/ipam/graphql/types.py +++ b/netbox/ipam/graphql/types.py @@ -77,6 +77,7 @@ class BaseIPAddressFamilyType: class ASNType(ContactsMixin, PrimaryObjectType): asn: BigInt rir: Annotated["RIRType", strawberry.lazy('ipam.graphql.types')] | None + role: Annotated["RoleType", strawberry.lazy('ipam.graphql.types')] | None tenant: Annotated["TenantType", strawberry.lazy('tenancy.graphql.types')] | None sites: list[SiteType] diff --git a/netbox/ipam/migrations/0087_add_asn_role.py b/netbox/ipam/migrations/0087_add_asn_role.py new file mode 100644 index 000000000..890a8a40d --- /dev/null +++ b/netbox/ipam/migrations/0087_add_asn_role.py @@ -0,0 +1,21 @@ +# Generated by Django 5.2.11 on 2026-03-04 19:14 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('ipam', '0086_gfk_indexes'), + ] + + operations = [ + migrations.AddField( + model_name='asn', + name='role', + field=models.ForeignKey( + blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='asns', to='ipam.role' + ), + ), + ] diff --git a/netbox/ipam/models/asns.py b/netbox/ipam/models/asns.py index 1b4459921..709314944 100644 --- a/netbox/ipam/models/asns.py +++ b/netbox/ipam/models/asns.py @@ -137,6 +137,15 @@ class ASN(ContactsMixin, PrimaryModel): verbose_name=_('ASN'), help_text=_('16- or 32-bit autonomous system number') ) + role = models.ForeignKey( + to='ipam.Role', + on_delete=models.SET_NULL, + related_name='asns', + blank=True, + null=True, + verbose_name=_('role'), + help_text=_("The primary function of this ASN") + ) tenant = models.ForeignKey( to='tenancy.Tenant', on_delete=models.PROTECT, diff --git a/netbox/ipam/search.py b/netbox/ipam/search.py index 17259071f..ea0d41db3 100644 --- a/netbox/ipam/search.py +++ b/netbox/ipam/search.py @@ -23,7 +23,7 @@ class ASNIndex(SearchIndex): ('prefixed_name', 110), ('description', 500), ) - display_attrs = ('rir', 'tenant', 'description') + display_attrs = ('rir', 'role', 'tenant', 'description') @register_search diff --git a/netbox/ipam/tables/asn.py b/netbox/ipam/tables/asn.py index 94e80bfdf..b8f2e4b60 100644 --- a/netbox/ipam/tables/asn.py +++ b/netbox/ipam/tables/asn.py @@ -71,6 +71,10 @@ class ASNTable(TenancyColumnsMixin, ContactsColumnMixin, PrimaryModelTable): url_params={'asn_id': 'pk'}, verbose_name=_('Provider Count') ) + role = tables.Column( + verbose_name=_('Role'), + linkify=True + ) sites = columns.ManyToManyColumn( linkify_item=True, verbose_name=_('Sites') @@ -82,9 +86,9 @@ class ASNTable(TenancyColumnsMixin, ContactsColumnMixin, PrimaryModelTable): class Meta(PrimaryModelTable.Meta): model = ASN fields = ( - 'pk', 'asn', 'asn_asdot', 'rir', 'site_count', 'provider_count', 'tenant', 'tenant_group', 'description', - 'contacts', 'comments', 'sites', 'tags', 'created', 'last_updated', 'actions', + 'pk', 'asn', 'asn_asdot', 'rir', 'role', 'site_count', 'provider_count', 'tenant', 'tenant_group', + 'description', 'contacts', 'comments', 'sites', 'tags', 'created', 'last_updated', 'actions', ) default_columns = ( - 'pk', 'asn', 'rir', 'site_count', 'provider_count', 'sites', 'description', 'tenant', + 'pk', 'asn', 'rir', 'role', 'site_count', 'provider_count', 'sites', 'description', 'tenant', ) diff --git a/netbox/ipam/tables/ip.py b/netbox/ipam/tables/ip.py index ee923c3d8..56f2771f0 100644 --- a/netbox/ipam/tables/ip.py +++ b/netbox/ipam/tables/ip.py @@ -120,6 +120,11 @@ class RoleTable(OrganizationalModelTable): url_params={'role_id': 'pk'}, verbose_name=_('VLANs') ) + asn_count = columns.LinkedCountColumn( + viewname='ipam:asn_list', + url_params={'role_id': 'pk'}, + verbose_name=_('ASNs') + ) tags = columns.TagColumn( url_name='ipam:role_list' ) @@ -127,10 +132,10 @@ class RoleTable(OrganizationalModelTable): class Meta(OrganizationalModelTable.Meta): model = Role fields = ( - 'pk', 'id', 'name', 'slug', 'prefix_count', 'iprange_count', 'vlan_count', 'description', 'weight', - 'comments', 'tags', 'created', 'last_updated', 'actions', + 'pk', 'id', 'name', 'slug', 'prefix_count', 'iprange_count', 'vlan_count', 'asn_count', 'description', + 'weight', 'comments', 'tags', 'created', 'last_updated', 'actions', ) - default_columns = ('pk', 'name', 'prefix_count', 'iprange_count', 'vlan_count', 'description') + default_columns = ('pk', 'name', 'prefix_count', 'iprange_count', 'vlan_count', 'asn_count', 'description') # diff --git a/netbox/ipam/tests/test_api.py b/netbox/ipam/tests/test_api.py index 3a75347ae..87bfa54fb 100644 --- a/netbox/ipam/tests/test_api.py +++ b/netbox/ipam/tests/test_api.py @@ -151,6 +151,12 @@ class ASNTest(APIViewTestCases.APIViewTestCase): ) RIR.objects.bulk_create(rirs) + roles = ( + Role(name='Role 1', slug='role-1'), + Role(name='Role 2', slug='role-2'), + ) + Role.objects.bulk_create(roles) + sites = ( Site(name='Site 1', slug='site-1'), Site(name='Site 2', slug='site-2') @@ -164,10 +170,10 @@ class ASNTest(APIViewTestCases.APIViewTestCase): Tenant.objects.bulk_create(tenants) asns = ( - ASN(asn=65000, rir=rirs[0], tenant=tenants[0]), - ASN(asn=65001, rir=rirs[0], tenant=tenants[1]), - ASN(asn=4200000000, rir=rirs[1], tenant=tenants[0]), - ASN(asn=4200000001, rir=rirs[1], tenant=tenants[1]), + ASN(asn=65000, rir=rirs[0], role=roles[0], tenant=tenants[0]), + ASN(asn=65001, rir=rirs[0], role=roles[0], tenant=tenants[1]), + ASN(asn=4200000000, rir=rirs[1], role=roles[1], tenant=tenants[0]), + ASN(asn=4200000001, rir=rirs[1], role=roles[1], tenant=tenants[1]), ) ASN.objects.bulk_create(asns) @@ -180,10 +186,12 @@ class ASNTest(APIViewTestCases.APIViewTestCase): { 'asn': 64512, 'rir': rirs[0].pk, + 'role': roles[0].pk, }, { 'asn': 65002, 'rir': rirs[0].pk, + 'role': roles[1].pk, }, { 'asn': 4200000002, @@ -375,7 +383,7 @@ class AggregateTest(APIViewTestCases.APIViewTestCase): class RoleTest(APIViewTestCases.APIViewTestCase): model = Role - brief_fields = ['description', 'display', 'id', 'name', 'prefix_count', 'slug', 'url', 'vlan_count'] + brief_fields = ['asn_count', 'description', 'display', 'id', 'name', 'prefix_count', 'slug', 'url', 'vlan_count'] create_data = [ { 'name': 'Role 4', @@ -404,6 +412,17 @@ class RoleTest(APIViewTestCases.APIViewTestCase): ) Role.objects.bulk_create(roles) + rirs = ( + RIR(name='RIR 1', slug='rir-1', is_private=True), + ) + RIR.objects.bulk_create(rirs) + + asns = ( + ASN(asn=65000, rir=rirs[0], role=roles[0]), + ASN(asn=65001, rir=rirs[0], role=roles[0]), + ) + ASN.objects.bulk_create(asns) + class PrefixTest(APIViewTestCases.APIViewTestCase): model = Prefix diff --git a/netbox/ipam/tests/test_filtersets.py b/netbox/ipam/tests/test_filtersets.py index c77b05dcf..68b61142a 100644 --- a/netbox/ipam/tests/test_filtersets.py +++ b/netbox/ipam/tests/test_filtersets.py @@ -114,6 +114,13 @@ class ASNTestCase(TestCase, ChangeLoggedFilterSetTests): ] RIR.objects.bulk_create(rirs) + roles = [ + Role(name='Role 1', slug='role-1'), + Role(name='Role 2', slug='role-2'), + Role(name='Role 3', slug='role-3'), + ] + Role.objects.bulk_create(roles) + tenants = [ Tenant(name='Tenant 1', slug='tenant-1'), Tenant(name='Tenant 2', slug='tenant-2'), @@ -124,12 +131,12 @@ class ASNTestCase(TestCase, ChangeLoggedFilterSetTests): Tenant.objects.bulk_create(tenants) asns = ( - ASN(asn=65001, rir=rirs[0], tenant=tenants[0], description='foobar1'), - ASN(asn=65002, rir=rirs[1], tenant=tenants[1], description='foobar2'), - ASN(asn=65003, rir=rirs[2], tenant=tenants[2], description='foobar3'), - ASN(asn=4200000000, rir=rirs[0], tenant=tenants[0]), - ASN(asn=4200000001, rir=rirs[1], tenant=tenants[1]), - ASN(asn=4200000002, rir=rirs[2], tenant=tenants[2]), + ASN(asn=65001, rir=rirs[0], role=roles[0], tenant=tenants[0], description='foobar1'), + ASN(asn=65002, rir=rirs[1], role=roles[1], tenant=tenants[1], description='foobar2'), + ASN(asn=65003, rir=rirs[2], role=roles[2], tenant=tenants[2], description='foobar3'), + ASN(asn=4200000000, rir=rirs[0], role=roles[0], tenant=tenants[0]), + ASN(asn=4200000001, rir=rirs[1], role=roles[1], tenant=tenants[1]), + ASN(asn=4200000002, rir=rirs[2], role=roles[2], tenant=tenants[2]), ) ASN.objects.bulk_create(asns) @@ -186,6 +193,13 @@ class ASNTestCase(TestCase, ChangeLoggedFilterSetTests): params = {'rir': [rirs[0].slug, rirs[1].slug]} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4) + def test_role(self): + roles = Role.objects.all()[:2] + params = {'role_id': [roles[0].pk, roles[1].pk]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4) + params = {'role': [roles[0].slug, roles[1].slug]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4) + def test_site_group(self): site_groups = SiteGroup.objects.all()[:2] params = {'site_group_id': [site_groups[0].pk, site_groups[1].pk]} diff --git a/netbox/ipam/tests/test_views.py b/netbox/ipam/tests/test_views.py index e61287d76..5154397bb 100644 --- a/netbox/ipam/tests/test_views.py +++ b/netbox/ipam/tests/test_views.py @@ -84,6 +84,12 @@ class ASNTestCase(ViewTestCases.PrimaryObjectViewTestCase): ] RIR.objects.bulk_create(rirs) + roles = ( + Role(name='Role 1', slug='role-1'), + Role(name='Role 2', slug='role-2'), + ) + Role.objects.bulk_create(roles) + sites = ( Site(name='Site 1', slug='site-1'), Site(name='Site 2', slug='site-2') @@ -97,10 +103,10 @@ class ASNTestCase(ViewTestCases.PrimaryObjectViewTestCase): Tenant.objects.bulk_create(tenants) asns = ( - ASN(asn=65001, rir=rirs[0], tenant=tenants[0]), - ASN(asn=65002, rir=rirs[1], tenant=tenants[1]), - ASN(asn=4200000001, rir=rirs[0], tenant=tenants[0]), - ASN(asn=4200000002, rir=rirs[1], tenant=tenants[1]), + ASN(asn=65001, rir=rirs[0], role=roles[0], tenant=tenants[0]), + ASN(asn=65002, rir=rirs[1], role=roles[1], tenant=tenants[1]), + ASN(asn=4200000001, rir=rirs[0], role=roles[0], tenant=tenants[0]), + ASN(asn=4200000002, rir=rirs[1], role=roles[1], tenant=tenants[1]), ) ASN.objects.bulk_create(asns) @@ -114,6 +120,7 @@ class ASNTestCase(ViewTestCases.PrimaryObjectViewTestCase): cls.form_data = { 'asn': 65000, 'rir': rirs[0].pk, + 'role': roles[0].pk, 'tenant': tenants[0].pk, 'site': sites[0].pk, 'description': 'A new ASN', @@ -121,11 +128,11 @@ class ASNTestCase(ViewTestCases.PrimaryObjectViewTestCase): } cls.csv_data = ( - "asn,rir", - "65003,RIR 1", - "65004,RIR 2", - "4200000003,RIR 1", - "4200000004,RIR 2", + "asn,rir,role", + f"65003,RIR 1,{roles[0].name}", + f"65004,RIR 2,{roles[1].name}", + f"4200000003,RIR 1,{roles[0].name}", + f"4200000004,RIR 2,{roles[1].name}", ) cls.csv_update_data = ( @@ -137,6 +144,7 @@ class ASNTestCase(ViewTestCases.PrimaryObjectViewTestCase): cls.bulk_edit_data = { 'rir': rirs[1].pk, + 'role': roles[1].pk, 'description': 'Next description', } diff --git a/netbox/ipam/views.py b/netbox/ipam/views.py index cbea4f318..6ceb0c53a 100644 --- a/netbox/ipam/views.py +++ b/netbox/ipam/views.py @@ -496,7 +496,8 @@ class RoleListView(generic.ObjectListView): queryset = Role.objects.annotate( prefix_count=count_related(Prefix, 'role'), iprange_count=count_related(IPRange, 'role'), - vlan_count=count_related(VLAN, 'role') + vlan_count=count_related(VLAN, 'role'), + asn_count=count_related(ASN, 'role') ) filterset = filtersets.RoleFilterSet filterset_form = forms.RoleFilterForm diff --git a/netbox/templates/ipam/asn.html b/netbox/templates/ipam/asn.html index 3a54e453b..26c6e0c3c 100644 --- a/netbox/templates/ipam/asn.html +++ b/netbox/templates/ipam/asn.html @@ -29,6 +29,16 @@ {{ object.rir }} +