From 1fc43026d00bd96f1eea42103efc25568948581a Mon Sep 17 00:00:00 2001 From: Martin Hauser Date: Fri, 13 Mar 2026 21:10:56 +0100 Subject: [PATCH] Closes #20698: Expose total_vlan_ids on VLAN groups (#21574) Fixes #20698 --- docs/models/ipam/vlangroup.md | 4 ++++ netbox/ipam/api/serializers_/vlans.py | 4 +++- netbox/ipam/filtersets.py | 2 +- netbox/ipam/forms/filtersets.py | 7 ++++++- netbox/ipam/graphql/filters.py | 3 ++- netbox/ipam/graphql/types.py | 1 + .../0088_rename_vlangroup_total_vlan_ids.py | 15 +++++++++++++++ netbox/ipam/models/vlans.py | 14 ++++++-------- netbox/ipam/querysets.py | 2 +- netbox/ipam/tables/vlans.py | 8 ++++++-- netbox/ipam/tests/test_filtersets.py | 10 +++++++++- netbox/ipam/tests/test_models.py | 2 +- 12 files changed, 55 insertions(+), 17 deletions(-) create mode 100644 netbox/ipam/migrations/0088_rename_vlangroup_total_vlan_ids.py diff --git a/docs/models/ipam/vlangroup.md b/docs/models/ipam/vlangroup.md index 67050ab4c..6a4e7bf15 100644 --- a/docs/models/ipam/vlangroup.md +++ b/docs/models/ipam/vlangroup.md @@ -18,6 +18,10 @@ A unique URL-friendly identifier. (This value can be used for filtering.) The set of VLAN IDs which are encompassed by the group. By default, this will be the entire range of valid IEEE 802.1Q VLAN IDs (1 to 4094, inclusive). VLANs created within a group must have a VID that falls within one of these ranges. Ranges may not overlap. +### Total VLAN IDs + +A read-only integer indicating the total count of VLAN IDs available within the group, calculated from the configured VLAN ID Ranges. For example, a group with ranges `100-199` and `300-399` would have a total of 200 VLAN IDs. This value is automatically computed and updated whenever the VLAN ID ranges are modified. + ### Scope The domain covered by a VLAN group, defined as one of the supported object types. This conveys the context in which a VLAN group applies. diff --git a/netbox/ipam/api/serializers_/vlans.py b/netbox/ipam/api/serializers_/vlans.py index 5e03074cd..a5ae39077 100644 --- a/netbox/ipam/api/serializers_/vlans.py +++ b/netbox/ipam/api/serializers_/vlans.py @@ -36,6 +36,7 @@ class VLANGroupSerializer(OrganizationalModelSerializer): scope_id = serializers.IntegerField(allow_null=True, required=False, default=None) scope = GFKSerializerField(read_only=True) vid_ranges = IntegerRangeSerializer(many=True, required=False) + total_vlan_ids = serializers.IntegerField(read_only=True) utilization = serializers.CharField(read_only=True) tenant = TenantSerializer(nested=True, required=False, allow_null=True) @@ -46,7 +47,8 @@ class VLANGroupSerializer(OrganizationalModelSerializer): model = VLANGroup fields = [ 'id', 'url', 'display_url', 'display', 'name', 'slug', 'scope_type', 'scope_id', 'scope', 'vid_ranges', - 'tenant', 'description', 'owner', 'comments', 'tags', 'custom_fields', 'created', 'last_updated', + 'total_vlan_ids', 'tenant', 'description', 'owner', 'comments', 'tags', 'custom_fields', + 'created', 'last_updated', 'vlan_count', 'utilization', ] brief_fields = ('id', 'url', 'display', 'name', 'slug', 'description', 'vlan_count') diff --git a/netbox/ipam/filtersets.py b/netbox/ipam/filtersets.py index d4dd39433..bba050215 100644 --- a/netbox/ipam/filtersets.py +++ b/netbox/ipam/filtersets.py @@ -977,7 +977,7 @@ class VLANGroupFilterSet(OrganizationalModelFilterSet, TenancyFilterSet): class Meta: model = VLANGroup - fields = ('id', 'name', 'slug', 'description', 'scope_id') + fields = ('id', 'name', 'slug', 'description', 'scope_id', 'total_vlan_ids') def search(self, queryset, name, value): if not value.strip(): diff --git a/netbox/ipam/forms/filtersets.py b/netbox/ipam/forms/filtersets.py index a141cb491..9433f3cb3 100644 --- a/netbox/ipam/forms/filtersets.py +++ b/netbox/ipam/forms/filtersets.py @@ -460,7 +460,7 @@ class VLANGroupFilterForm(TenancyFilterForm, OrganizationalModelFilterSetForm): FieldSet('q', 'filter_id', 'tag'), FieldSet('region', 'site_group', 'site', 'location', 'rack_group', 'rack', name=_('Location')), FieldSet('cluster_group', 'cluster', name=_('Cluster')), - FieldSet('contains_vid', name=_('VLANs')), + FieldSet('contains_vid', 'total_vlan_ids', name=_('VLANs')), FieldSet('tenant_group_id', 'tenant_id', name=_('Tenant')), FieldSet('owner_group_id', 'owner_id', name=_('Ownership')), ) @@ -510,6 +510,11 @@ class VLANGroupFilterForm(TenancyFilterForm, OrganizationalModelFilterSetForm): required=False, label=_('Contains VLAN ID') ) + total_vlan_ids = forms.IntegerField( + min_value=0, + required=False, + label=_('Total VLAN IDs') + ) tag = TagFilterField(model) diff --git a/netbox/ipam/graphql/filters.py b/netbox/ipam/graphql/filters.py index 664e6be65..f3c6637fa 100644 --- a/netbox/ipam/graphql/filters.py +++ b/netbox/ipam/graphql/filters.py @@ -7,7 +7,7 @@ import strawberry_django from django.db.models import Q from netaddr.core import AddrFormatError from strawberry.scalars import ID -from strawberry_django import BaseFilterLookup, DateFilterLookup, FilterLookup, StrFilterLookup +from strawberry_django import BaseFilterLookup, ComparisonFilterLookup, DateFilterLookup, FilterLookup, StrFilterLookup from dcim.graphql.filter_mixins import ScopedFilterMixin from dcim.models import Device @@ -399,6 +399,7 @@ class VLANGroupFilter(ScopedFilterMixin, OrganizationalModelFilter): vid_ranges: Annotated['IntegerRangeArrayLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = ( strawberry_django.filter_field() ) + total_vlan_ids: ComparisonFilterLookup[int] | None = strawberry_django.filter_field() @strawberry_django.filter_type(models.VLANTranslationPolicy, lookups=True) diff --git a/netbox/ipam/graphql/types.py b/netbox/ipam/graphql/types.py index 1c281cedf..2bfdc7d65 100644 --- a/netbox/ipam/graphql/types.py +++ b/netbox/ipam/graphql/types.py @@ -306,6 +306,7 @@ class VLANGroupType(OrganizationalObjectType): vlans: list[VLANType] vid_ranges: list[str] + total_vlan_ids: BigInt tenant: Annotated['TenantType', strawberry.lazy('tenancy.graphql.types')] | None @strawberry_django.field diff --git a/netbox/ipam/migrations/0088_rename_vlangroup_total_vlan_ids.py b/netbox/ipam/migrations/0088_rename_vlangroup_total_vlan_ids.py new file mode 100644 index 000000000..c84624c50 --- /dev/null +++ b/netbox/ipam/migrations/0088_rename_vlangroup_total_vlan_ids.py @@ -0,0 +1,15 @@ +from django.db import migrations + + +class Migration(migrations.Migration): + dependencies = [ + ('ipam', '0087_add_asn_role'), + ] + + operations = [ + migrations.RenameField( + model_name='vlangroup', + old_name='_total_vlan_ids', + new_name='total_vlan_ids', + ), + ] diff --git a/netbox/ipam/models/vlans.py b/netbox/ipam/models/vlans.py index b60b2cb97..dea94743a 100644 --- a/netbox/ipam/models/vlans.py +++ b/netbox/ipam/models/vlans.py @@ -24,9 +24,7 @@ __all__ = ( def default_vid_ranges(): - return [ - NumericRange(VLAN_VID_MIN, VLAN_VID_MAX, bounds='[]') - ] + return [NumericRange(VLAN_VID_MIN, VLAN_VID_MAX + 1)] class VLANGroup(OrganizationalModel): @@ -62,6 +60,9 @@ class VLANGroup(OrganizationalModel): verbose_name=_('VLAN ID ranges'), default=default_vid_ranges ) + total_vlan_ids = models.PositiveBigIntegerField( + default=VLAN_VID_MAX - VLAN_VID_MIN + 1, + ) tenant = models.ForeignKey( to='tenancy.Tenant', on_delete=models.PROTECT, @@ -69,9 +70,6 @@ class VLANGroup(OrganizationalModel): blank=True, null=True ) - _total_vlan_ids = models.PositiveBigIntegerField( - default=VLAN_VID_MAX - VLAN_VID_MIN + 1 - ) objects = VLANGroupQuerySet.as_manager() @@ -130,10 +128,10 @@ class VLANGroup(OrganizationalModel): raise ValidationError({'vid_ranges': _("Ranges cannot overlap.")}) def save(self, *args, **kwargs): - self._total_vlan_ids = 0 + self.total_vlan_ids = 0 for vid_range in self.vid_ranges: # VID range is inclusive on lower-bound, exclusive on upper-bound - self._total_vlan_ids += vid_range.upper - vid_range.lower + self.total_vlan_ids += vid_range.upper - vid_range.lower super().save(*args, **kwargs) diff --git a/netbox/ipam/querysets.py b/netbox/ipam/querysets.py index 89013aa31..ae02b4922 100644 --- a/netbox/ipam/querysets.py +++ b/netbox/ipam/querysets.py @@ -64,7 +64,7 @@ class VLANGroupQuerySet(RestrictedQuerySet): return self.annotate( vlan_count=count_related(VLAN, 'group'), - utilization=Round(F('vlan_count') * 100.0 / F('_total_vlan_ids'), 2) + utilization=Round(F('vlan_count') * 100.0 / F('total_vlan_ids'), 2), ) diff --git a/netbox/ipam/tables/vlans.py b/netbox/ipam/tables/vlans.py index c7c75f91e..64d759077 100644 --- a/netbox/ipam/tables/vlans.py +++ b/netbox/ipam/tables/vlans.py @@ -53,6 +53,9 @@ class VLANGroupTable(TenancyColumnsMixin, OrganizationalModelTable): url_params={'group_id': 'pk'}, verbose_name=_('VLANs') ) + total_vlan_ids = tables.Column( + verbose_name=_('Total VLAN IDs'), + ) utilization = columns.UtilizationColumn( orderable=False, verbose_name=_('Utilization') @@ -67,8 +70,9 @@ class VLANGroupTable(TenancyColumnsMixin, OrganizationalModelTable): class Meta(OrganizationalModelTable.Meta): model = VLANGroup fields = ( - 'pk', 'id', 'name', 'scope_type', 'scope', 'vid_ranges_list', 'vlan_count', 'slug', 'description', - 'tenant', 'tenant_group', 'comments', 'tags', 'created', 'last_updated', 'actions', 'utilization', + 'pk', 'id', 'name', 'slug', 'description', 'scope_type', 'scope', 'vid_ranges_list', 'vlan_count', + 'total_vlan_ids', 'tenant', 'tenant_group', 'comments', 'tags', 'created', 'last_updated', 'actions', + 'utilization', ) default_columns = ( 'pk', 'name', 'scope_type', 'scope', 'vlan_count', 'utilization', 'tenant', 'description' diff --git a/netbox/ipam/tests/test_filtersets.py b/netbox/ipam/tests/test_filtersets.py index 68b61142a..856f8d4b5 100644 --- a/netbox/ipam/tests/test_filtersets.py +++ b/netbox/ipam/tests/test_filtersets.py @@ -1714,7 +1714,9 @@ class VLANGroupTestCase(TestCase, ChangeLoggedFilterSetTests): slug='vlan-group-8' ), ) - VLANGroup.objects.bulk_create(vlan_groups) + # Ensure the total_vlan_ids field is populated + for vlan_group in vlan_groups: + vlan_group.save() def test_q(self): params = {'q': 'foobar1'} @@ -1742,6 +1744,12 @@ class VLANGroupTestCase(TestCase, ChangeLoggedFilterSetTests): params = {'contains_vid': 4095} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 0) + def test_total_vlan_ids(self): + params = {'total_vlan_ids': [110]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 7) + params = {'total_vlan_ids': [4094]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1) + def test_region(self): params = {'region': Region.objects.first().pk} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1) diff --git a/netbox/ipam/tests/test_models.py b/netbox/ipam/tests/test_models.py index 80ac92ef2..634d6338c 100644 --- a/netbox/ipam/tests/test_models.py +++ b/netbox/ipam/tests/test_models.py @@ -663,7 +663,7 @@ class TestVLANGroup(TestCase): def test_total_vlan_ids(self): vlangroup = VLANGroup.objects.first() - self.assertEqual(vlangroup._total_vlan_ids, 100) + self.assertEqual(vlangroup.total_vlan_ids, 100) class TestVLAN(TestCase):