Compare commits

...

12 Commits

Author SHA1 Message Date
Arthur
c78972d4e9 Merge branch 'feature' into 17654-asn 2026-03-05 07:51:50 -08:00
Arthur
37dab713cb doc field 2026-03-04 15:31:47 -08:00
Arthur
9ca0424428 update 2026-03-04 15:14:46 -08:00
Arthur
25ab954992 update tests 2026-03-04 14:58:55 -08:00
Arthur
4b67f22c80 add to tests 2026-03-04 14:44:52 -08:00
Arthur
0072403677 fix 2026-03-04 14:18:23 -08:00
Arthur
37ce01a89f fix ruff issues 2026-03-04 11:21:29 -08:00
Arthur
28468c32c2 #17654 Add role to ASN 2026-03-04 11:20:56 -08:00
bctiemann
6eafffb497 Closes: #21304 - Add stronger deprecation warning on use of housekeeping management command (#21483)
* Add stronger deprecation warning on use of housekeeping management command

* Add stronger deprecation warning on use of housekeeping management command

* Rework deprecation warning to use FutureWarning (not DeprecationWarning as that is ignored in non-dev environments).
2026-03-03 16:12:39 -05:00
Jeremy Stretch
53ea48efa9 Merge branch 'main' into feature 2026-03-03 15:40:46 -05:00
Jeremy Stretch
1a404f5c0f Merge branch 'main' into feature 2026-02-25 17:07:26 -05:00
bctiemann
3320e07b70 Closes #21284: Add deprecation note to webhooks documentation (#21491)
* Add searchable deprecation comments on request_id and username fields in EventContext

* Add deprecation note in webhooks documentation

* Expand deprecation note/warning

* Add version number to deprecation warning

* Add deprecation warning to two other places
2026-02-20 19:52:42 +01:00
22 changed files with 178 additions and 31 deletions

View File

@@ -31,6 +31,11 @@ The following data is available as context for Jinja2 templates:
* `data` - A detailed representation of the object in its current state. This is typically equivalent to the model's representation in NetBox's REST API.
* `snapshots` - Minimal "snapshots" of the object state both before and after the change was made; provided as a dictionary with keys named `prechange` and `postchange`. These are not as extensive as the fully serialized representation, but contain enough information to convey what has changed.
!!! warning "Deprecation of legacy fields"
The "request_id" and "username" fields in the webhook payload above are deprecated and should no longer be used. Support for them will be removed in NetBox v4.7.0.
Use `request.user.username` and `request.request_id` from the `request` object included in the callback context instead.
### Default Request Body
If no body template is specified, the request body will be populated with a JSON object containing the context data. For example, a newly created site might appear as follows:

View File

@@ -88,3 +88,8 @@ The following context variables are available in to the text and link templates.
| `request_id` | The unique request ID |
| `data` | A complete serialized representation of the object |
| `snapshots` | Pre- and post-change snapshots of the object |
!!! warning "Deprecation of legacy fields"
The "request_id" and "username" fields in the webhook payload above are deprecated and should no longer be used. Support for them will be removed in NetBox v4.7.0.
Use `request.user.username` and `request.request_id` from the `request` object included in the callback context instead.

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

@@ -43,6 +43,11 @@ The resulting webhook payload will look like the following:
}
```
!!! warning "Deprecation of legacy fields"
The "request_id" and "username" fields in the webhook payload above are deprecated and should no longer be used. Support for them will be removed in NetBox v4.7.0.
Use `request.user.username` and `request.request_id` from the `request` object included in the callback context instead.
!!! note "Consider namespacing webhook data"
The data returned from all webhook callbacks will be compiled into a single `context` dictionary. Any existing keys within this dictionary will be overwritten by subsequent callbacks which include those keys. To avoid collisions with webhook data provided by other plugins, consider namespacing your plugin's data within a nested dictionary as such:

View File

@@ -1,3 +1,4 @@
import warnings
from datetime import timedelta
from importlib import import_module
@@ -17,11 +18,12 @@ class Command(BaseCommand):
help = "Perform nightly housekeeping tasks [DEPRECATED]"
def handle(self, *args, **options):
self.stdout.write(
warnings.warn(
"\n\nDEPRECATION WARNING\n"
"Running this command is no longer necessary: All housekeeping tasks\n"
"are addressed automatically via NetBox's built-in job scheduler. It\n"
"will be removed in a future release.",
self.style.WARNING
"will be removed in a future release.\n",
category=FutureWarning,
)
config = Config()

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

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

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