Просмотр исходного кода

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)
Arthur Hanson 1 день назад
Родитель
Сommit
e3d9fe622d

+ 4 - 0
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.

+ 5 - 2
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')
 

+ 4 - 2
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')

+ 12 - 0
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

+ 7 - 2
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):

+ 8 - 1
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):

+ 6 - 1
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,

+ 8 - 2
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(),

+ 2 - 0
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()
     )

+ 1 - 0
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]

+ 21 - 0
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'
+            ),
+        ),
+    ]

+ 9 - 0
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,

+ 1 - 1
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

+ 7 - 3
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',
         )

+ 8 - 3
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')
 
 
 #

+ 24 - 5
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

+ 20 - 6
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]}

+ 17 - 9
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',
         }
 

+ 2 - 1
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

+ 10 - 0
netbox/templates/ipam/asn.html

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