Fix #17654: Add Role to ASN (#21582)

Co-authored-by: github-actions <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: Jason Novinger <jnovinger@gmail.com>
Closes #21571: Bump minimatch and markdown-it to resolve security alerts (#21573)
This commit is contained in:
Arthur Hanson 2026-03-10 08:00:28 -07:00 committed by GitHub
parent 719effb548
commit e3d9fe622d
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
20 changed files with 176 additions and 38 deletions

View File

@ -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.

View File

@ -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')

View File

@ -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')

View File

@ -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

View File

@ -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):

View File

@ -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):

View File

@ -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,

View File

@ -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(),

View File

@ -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()
)

View File

@ -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]

View File

@ -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'
),
),
]

View File

@ -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,

View File

@ -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

View File

@ -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',
)

View File

@ -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')
#

View File

@ -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

View File

@ -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]}

View File

@ -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',
}

View File

@ -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

View File

@ -29,6 +29,16 @@
<a href="{% url 'ipam:asn_list' %}?rir={{ object.rir.slug }}">{{ object.rir }}</a>
</td>
</tr>
<tr>
<th scope="row">{% trans "Role" %}</th>
<td>
{% if object.role %}
<a href="{% url 'ipam:asn_list' %}?role={{ object.role.slug }}">{{ object.role }}</a>
{% else %}
{{ ''|placeholder }}
{% endif %}
</td>
</tr>
<tr>
<th scope="row">{% trans "Tenant" %}</th>
<td>