فهرست منبع

feat(ipam): Expose total_vlan_ids on VLAN groups

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
Martin Hauser 18 ساعت پیش
والد
کامیت
883ecd0acc

+ 4 - 0
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.

+ 3 - 1
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')

+ 1 - 1
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():

+ 6 - 1
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)
 

+ 2 - 1
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)

+ 1 - 0
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

+ 15 - 0
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',
+        ),
+    ]

+ 6 - 8
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)
 

+ 1 - 1
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),
         )
 
 

+ 6 - 2
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'

+ 9 - 1
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)

+ 1 - 1
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):