feat(ipam): Expose total_vlan_ids on VLAN groups
CI / build (20.x, 3.12) (push) Failing after 17s
CI / build (20.x, 3.13) (push) Failing after 18s
CI / build (20.x, 3.14) (push) Failing after 19s

Rename the internal `_total_vlan_ids` field to `total_vlan_ids` on
VLANGroup and expose it as a read-only integer field.
This change includes a migration to rename the database column,
adds `total_vlan_ids` to VLANGroup API representations as a read-only
attribute, updates the UI table to include a "Total VLAN IDs" column,
and adjusts related tests accordingly.

Fixes #20698
This commit is contained in:
Martin Hauser
2026-03-05 13:08:40 +01:00
parent 6eafffb497
commit 050ee5d254
9 changed files with 35 additions and 12 deletions
+3 -1
View File
@@ -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')
+1 -1
View File
@@ -962,7 +962,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():
+2 -1
View File
@@ -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
@@ -397,6 +397,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)
+1
View File
@@ -305,6 +305,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
@@ -0,0 +1,15 @@
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('ipam', '0086_gfk_indexes'),
]
operations = [
migrations.RenameField(
model_name='vlangroup',
old_name='_total_vlan_ids',
new_name='total_vlan_ids',
),
]
+5 -5
View File
@@ -62,6 +62,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 +72,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 +130,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)
+1 -1
View File
@@ -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),
)
+6 -2
View File
@@ -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'
+1 -1
View File
@@ -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):