Closes #20698: Expose total_vlan_ids on VLAN groups (#21574)

Fixes #20698
This commit is contained in:
Martin Hauser
2026-03-13 21:10:56 +01:00
committed by GitHub
parent b01d92c98b
commit 1fc43026d0
12 changed files with 55 additions and 17 deletions
+4
View File
@@ -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.
+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
@@ -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():
+6 -1
View File
@@ -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)
+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
@@ -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)
+1
View File
@@ -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
@@ -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',
),
]
+6 -8
View File
@@ -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)
+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'
+9 -1
View File
@@ -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)
+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):