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_id = serializers.IntegerField(allow_null=True, required=False, default=None)
scope = GFKSerializerField(read_only=True) scope = GFKSerializerField(read_only=True)
vid_ranges = IntegerRangeSerializer(many=True, required=False) vid_ranges = IntegerRangeSerializer(many=True, required=False)
total_vlan_ids = serializers.IntegerField(read_only=True)
utilization = serializers.CharField(read_only=True) utilization = serializers.CharField(read_only=True)
tenant = TenantSerializer(nested=True, required=False, allow_null=True) tenant = TenantSerializer(nested=True, required=False, allow_null=True)
@@ -46,7 +47,8 @@ class VLANGroupSerializer(OrganizationalModelSerializer):
model = VLANGroup model = VLANGroup
fields = [ fields = [
'id', 'url', 'display_url', 'display', 'name', 'slug', 'scope_type', 'scope_id', 'scope', 'vid_ranges', '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', 'vlan_count', 'utilization',
] ]
brief_fields = ('id', 'url', 'display', 'name', 'slug', 'description', 'vlan_count') brief_fields = ('id', 'url', 'display', 'name', 'slug', 'description', 'vlan_count')
+1 -1
View File
@@ -962,7 +962,7 @@ class VLANGroupFilterSet(OrganizationalModelFilterSet, TenancyFilterSet):
class Meta: class Meta:
model = VLANGroup 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): def search(self, queryset, name, value):
if not value.strip(): if not value.strip():
+2 -1
View File
@@ -7,7 +7,7 @@ import strawberry_django
from django.db.models import Q from django.db.models import Q
from netaddr.core import AddrFormatError from netaddr.core import AddrFormatError
from strawberry.scalars import ID 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.graphql.filter_mixins import ScopedFilterMixin
from dcim.models import Device from dcim.models import Device
@@ -397,6 +397,7 @@ class VLANGroupFilter(ScopedFilterMixin, OrganizationalModelFilter):
vid_ranges: Annotated['IntegerRangeArrayLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = ( vid_ranges: Annotated['IntegerRangeArrayLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = (
strawberry_django.filter_field() strawberry_django.filter_field()
) )
total_vlan_ids: ComparisonFilterLookup[int] | None = strawberry_django.filter_field()
@strawberry_django.filter_type(models.VLANTranslationPolicy, lookups=True) @strawberry_django.filter_type(models.VLANTranslationPolicy, lookups=True)
+1
View File
@@ -305,6 +305,7 @@ class VLANGroupType(OrganizationalObjectType):
vlans: list[VLANType] vlans: list[VLANType]
vid_ranges: list[str] vid_ranges: list[str]
total_vlan_ids: BigInt
tenant: Annotated['TenantType', strawberry.lazy('tenancy.graphql.types')] | None tenant: Annotated['TenantType', strawberry.lazy('tenancy.graphql.types')] | None
@strawberry_django.field @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'), verbose_name=_('VLAN ID ranges'),
default=default_vid_ranges default=default_vid_ranges
) )
total_vlan_ids = models.PositiveBigIntegerField(
default=VLAN_VID_MAX - VLAN_VID_MIN + 1,
)
tenant = models.ForeignKey( tenant = models.ForeignKey(
to='tenancy.Tenant', to='tenancy.Tenant',
on_delete=models.PROTECT, on_delete=models.PROTECT,
@@ -69,9 +72,6 @@ class VLANGroup(OrganizationalModel):
blank=True, blank=True,
null=True null=True
) )
_total_vlan_ids = models.PositiveBigIntegerField(
default=VLAN_VID_MAX - VLAN_VID_MIN + 1
)
objects = VLANGroupQuerySet.as_manager() objects = VLANGroupQuerySet.as_manager()
@@ -130,10 +130,10 @@ class VLANGroup(OrganizationalModel):
raise ValidationError({'vid_ranges': _("Ranges cannot overlap.")}) raise ValidationError({'vid_ranges': _("Ranges cannot overlap.")})
def save(self, *args, **kwargs): def save(self, *args, **kwargs):
self._total_vlan_ids = 0 self.total_vlan_ids = 0
for vid_range in self.vid_ranges: for vid_range in self.vid_ranges:
# VID range is inclusive on lower-bound, exclusive on upper-bound # 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) super().save(*args, **kwargs)
+1 -1
View File
@@ -64,7 +64,7 @@ class VLANGroupQuerySet(RestrictedQuerySet):
return self.annotate( return self.annotate(
vlan_count=count_related(VLAN, 'group'), 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'}, url_params={'group_id': 'pk'},
verbose_name=_('VLANs') verbose_name=_('VLANs')
) )
total_vlan_ids = tables.Column(
verbose_name=_('Total VLAN IDs'),
)
utilization = columns.UtilizationColumn( utilization = columns.UtilizationColumn(
orderable=False, orderable=False,
verbose_name=_('Utilization') verbose_name=_('Utilization')
@@ -67,8 +70,9 @@ class VLANGroupTable(TenancyColumnsMixin, OrganizationalModelTable):
class Meta(OrganizationalModelTable.Meta): class Meta(OrganizationalModelTable.Meta):
model = VLANGroup model = VLANGroup
fields = ( fields = (
'pk', 'id', 'name', 'scope_type', 'scope', 'vid_ranges_list', 'vlan_count', 'slug', 'description', 'pk', 'id', 'name', 'slug', 'description', 'scope_type', 'scope', 'vid_ranges_list', 'vlan_count',
'tenant', 'tenant_group', 'comments', 'tags', 'created', 'last_updated', 'actions', 'utilization', 'total_vlan_ids', 'tenant', 'tenant_group', 'comments', 'tags', 'created', 'last_updated', 'actions',
'utilization',
) )
default_columns = ( default_columns = (
'pk', 'name', 'scope_type', 'scope', 'vlan_count', 'utilization', 'tenant', 'description' '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): def test_total_vlan_ids(self):
vlangroup = VLANGroup.objects.first() vlangroup = VLANGroup.objects.first()
self.assertEqual(vlangroup._total_vlan_ids, 100) self.assertEqual(vlangroup.total_vlan_ids, 100)
class TestVLAN(TestCase): class TestVLAN(TestCase):