2
0
Эх сурвалжийг харах

Closes #8168: Add min/max VID fields to VLANGroup

jeremystretch 4 жил өмнө
parent
commit
544d991e1e

+ 2 - 0
docs/models/ipam/vlangroup.md

@@ -2,4 +2,6 @@
 
 VLAN groups can be used to organize VLANs within NetBox. Each VLAN group can be scoped to a particular region, site group, site, location, rack, cluster group, or cluster. Member VLANs will be available for assignment to devices and/or virtual machines within the specified scope.
 
+A minimum and maximum child VLAN ID must be set for each group. (These default to 1 and 4094 respectively.) VLANs created within a group must have a VID that falls between these values (inclusive).
+
 Groups can also be used to enforce uniqueness: Each VLAN within a group must have a unique ID and name. VLANs which are not assigned to a group may have overlapping names and IDs (including VLANs which belong to a common site). For example, you can create two VLANs with ID 123, but they cannot both be assigned to the same group.

+ 4 - 0
docs/release-notes/version-3.2.md

@@ -41,6 +41,7 @@ FIELD_CHOICES = {
 ### Enhancements
 
 * [#7650](https://github.com/netbox-community/netbox/issues/7650) - Add support for local account password validation
+* [#8168](https://github.com/netbox-community/netbox/issues/8168) - Add `min_vid` and `max_vid` fields to VLAN group
 
 ### Other Changes
 
@@ -53,3 +54,6 @@ FIELD_CHOICES = {
 
 * dcim.Site
     * Removed the `asn`, `contact_name`, `contact_phone`, and `contact_email` fields
+* ipam.VLANGroup
+    * Added the `/availables-vlans/` endpoint
+    * Added the `min_vid` and `max_vid` fields

+ 2 - 2
netbox/ipam/api/serializers.py

@@ -182,8 +182,8 @@ class VLANGroupSerializer(PrimaryModelSerializer):
     class Meta:
         model = VLANGroup
         fields = [
-            'id', 'url', 'display', 'name', 'slug', 'scope_type', 'scope_id', 'scope', 'description', 'tags',
-            'custom_fields', 'created', 'last_updated', 'vlan_count',
+            'id', 'url', 'display', 'name', 'slug', 'scope_type', 'scope_id', 'scope', 'min_vid', 'max_vid',
+            'description', 'tags', 'custom_fields', 'created', 'last_updated', 'vlan_count',
         ]
         validators = []
 

+ 1 - 1
netbox/ipam/filtersets.py

@@ -740,7 +740,7 @@ class VLANGroupFilterSet(OrganizationalModelFilterSet):
 
     class Meta:
         model = VLANGroup
-        fields = ['id', 'name', 'slug', 'description', 'scope_id']
+        fields = ['id', 'name', 'slug', 'min_vid', 'max_vid', 'description', 'scope_id']
 
     def search(self, queryset, name, value):
         if not value.strip():

+ 12 - 0
netbox/ipam/forms/bulk_edit.py

@@ -359,6 +359,18 @@ class VLANGroupBulkEditForm(AddRemoveTagsForm, CustomFieldModelBulkEditForm):
         queryset=Site.objects.all(),
         required=False
     )
+    min_vid = forms.IntegerField(
+        min_value=VLAN_VID_MIN,
+        max_value=VLAN_VID_MAX,
+        required=False,
+        label='Minimum child VLAN VID'
+    )
+    max_vid = forms.IntegerField(
+        min_value=VLAN_VID_MIN,
+        max_value=VLAN_VID_MAX,
+        required=False,
+        label='Maximum child VLAN VID'
+    )
     description = forms.CharField(
         max_length=200,
         required=False

+ 13 - 1
netbox/ipam/forms/bulk_import.py

@@ -332,10 +332,22 @@ class VLANGroupCSVForm(CustomFieldModelCSVForm):
         required=False,
         label='Scope type (app & model)'
     )
+    min_vid = forms.IntegerField(
+        min_value=VLAN_VID_MIN,
+        max_value=VLAN_VID_MAX,
+        required=False,
+        label=f'Minimum child VLAN VID (default: {VLAN_VID_MIN})'
+    )
+    max_vid = forms.IntegerField(
+        min_value=VLAN_VID_MIN,
+        max_value=VLAN_VID_MAX,
+        required=False,
+        label=f'Maximum child VLAN VID (default: {VLAN_VID_MIN})'
+    )
 
     class Meta:
         model = VLANGroup
-        fields = ('name', 'slug', 'scope_type', 'scope_id', 'description')
+        fields = ('name', 'slug', 'scope_type', 'scope_id', 'min_vid', 'max_vid', 'description')
         labels = {
             'scope_id': 'Scope ID',
         }

+ 10 - 1
netbox/ipam/forms/filtersets.py

@@ -370,7 +370,8 @@ class FHRPGroupFilterForm(CustomFieldModelFilterForm):
 class VLANGroupFilterForm(CustomFieldModelFilterForm):
     field_groups = [
         ['q', 'tag'],
-        ['region', 'sitegroup', 'site', 'location', 'rack']
+        ['region', 'sitegroup', 'site', 'location', 'rack'],
+        ['min_vid', 'max_vid'],
     ]
     model = VLANGroup
     region = DynamicModelMultipleChoiceField(
@@ -403,6 +404,14 @@ class VLANGroupFilterForm(CustomFieldModelFilterForm):
         label=_('Rack'),
         fetch_trigger='open'
     )
+    min_vid = forms.IntegerField(
+        min_value=VLAN_VID_MIN,
+        max_value=VLAN_VID_MAX,
+    )
+    max_vid = forms.IntegerField(
+        min_value=VLAN_VID_MIN,
+        max_value=VLAN_VID_MAX,
+    )
     tag = TagFilterField(model)
 
 

+ 2 - 1
netbox/ipam/forms/models.py

@@ -700,10 +700,11 @@ class VLANGroupForm(CustomFieldModelForm):
         model = VLANGroup
         fields = [
             'name', 'slug', 'description', 'scope_type', 'region', 'sitegroup', 'site', 'location', 'rack',
-            'clustergroup', 'cluster', 'tags',
+            'clustergroup', 'cluster', 'min_vid', 'max_vid', 'tags',
         ]
         fieldsets = (
             ('VLAN Group', ('name', 'slug', 'description', 'tags')),
+            ('Child VLANs', ('min_vid', 'max_vid')),
             ('Scope', ('scope_type', 'region', 'sitegroup', 'site', 'location', 'rack', 'clustergroup', 'cluster')),
         )
         widgets = {

+ 24 - 0
netbox/ipam/migrations/0054_vlangroup_min_max_vids.py

@@ -0,0 +1,24 @@
+# Generated by Django 3.2.10 on 2021-12-23 15:24
+
+import django.core.validators
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('ipam', '0053_asn_model'),
+    ]
+
+    operations = [
+        migrations.AddField(
+            model_name='vlangroup',
+            name='max_vid',
+            field=models.PositiveSmallIntegerField(default=4094, validators=[django.core.validators.MinValueValidator(1), django.core.validators.MaxValueValidator(4094)]),
+        ),
+        migrations.AddField(
+            model_name='vlangroup',
+            name='min_vid',
+            field=models.PositiveSmallIntegerField(default=1, validators=[django.core.validators.MinValueValidator(1), django.core.validators.MaxValueValidator(4094)]),
+        ),
+    ]

+ 40 - 8
netbox/ipam/models/vlans.py

@@ -46,6 +46,24 @@ class VLANGroup(OrganizationalModel):
         ct_field='scope_type',
         fk_field='scope_id'
     )
+    min_vid = models.PositiveSmallIntegerField(
+        verbose_name='Minimum VLAN ID',
+        default=VLAN_VID_MIN,
+        validators=(
+            MinValueValidator(VLAN_VID_MIN),
+            MaxValueValidator(VLAN_VID_MAX)
+        ),
+        help_text='Lowest permissible ID of a child VLAN'
+    )
+    max_vid = models.PositiveSmallIntegerField(
+        verbose_name='Maximum VLAN ID',
+        default=VLAN_VID_MAX,
+        validators=(
+            MinValueValidator(VLAN_VID_MIN),
+            MaxValueValidator(VLAN_VID_MAX)
+        ),
+        help_text='Highest permissible ID of a child VLAN'
+    )
     description = models.CharField(
         max_length=200,
         blank=True
@@ -75,24 +93,28 @@ class VLANGroup(OrganizationalModel):
         if self.scope_id and not self.scope_type:
             raise ValidationError("Cannot set scope_id without scope_type.")
 
+        # Validate min/max child VID limits
+        if self.max_vid < self.min_vid:
+            raise ValidationError({
+                'max_vid': "Maximum child VID must be greater than or equal to minimum child VID"
+            })
+
     def get_available_vids(self):
         """
         Return all available VLANs within this group.
         """
-        available_vlans = {vid for vid in range(VLAN_VID_MIN, VLAN_VID_MAX + 1)}
+        available_vlans = {vid for vid in range(self.min_vid, self.max_vid + 1)}
         available_vlans -= set(VLAN.objects.filter(group=self).values_list('vid', flat=True))
 
-        # TODO: Check ordering
-        return list(available_vlans)
+        return sorted(available_vlans)
 
     def get_next_available_vid(self):
         """
         Return the first available VLAN ID (1-4094) in the group.
         """
-        vlan_ids = VLAN.objects.filter(group=self).values_list('vid', flat=True)
-        for i in range(1, 4095):
-            if i not in vlan_ids:
-                return i
+        available_vids = self.get_available_vids()
+        if available_vids:
+            return available_vids[0]
         return None
 
 
@@ -122,7 +144,10 @@ class VLAN(PrimaryModel):
     )
     vid = models.PositiveSmallIntegerField(
         verbose_name='ID',
-        validators=[MinValueValidator(1), MaxValueValidator(4094)]
+        validators=(
+            MinValueValidator(VLAN_VID_MIN),
+            MaxValueValidator(VLAN_VID_MAX)
+        )
     )
     name = models.CharField(
         max_length=64
@@ -182,6 +207,13 @@ class VLAN(PrimaryModel):
                          f"site {self.site}."
             })
 
+        # Validate group min/max VIDs
+        if self.group and not self.group.min_vid <= self.vid <= self.group.max_vid:
+            raise ValidationError({
+                'vid': f"VID must be between {self.group.min_vid} and {self.group.max_vid} for VLANs in group "
+                       f"{self.group}"
+            })
+
     def get_status_class(self):
         return VLANStatusChoices.colors.get(self.status, 'secondary')
 

+ 4 - 1
netbox/ipam/tables/vlans.py

@@ -84,7 +84,10 @@ class VLANGroupTable(BaseTable):
 
     class Meta(BaseTable.Meta):
         model = VLANGroup
-        fields = ('pk', 'id', 'name', 'scope_type', 'scope', 'vlan_count', 'slug', 'description', 'tags', 'actions')
+        fields = (
+            'pk', 'id', 'name', 'scope_type', 'scope', 'min_vid', 'max_vid', 'vlan_count', 'slug', 'description',
+            'tags', 'actions',
+        )
         default_columns = ('pk', 'name', 'scope_type', 'scope', 'vlan_count', 'description', 'actions')
 
 

+ 26 - 12
netbox/ipam/tests/test_models.py

@@ -497,18 +497,32 @@ class TestIPAddress(TestCase):
 
 class TestVLANGroup(TestCase):
 
-    def test_get_next_available_vid(self):
-
-        vlangroup = VLANGroup.objects.create(name='VLAN Group 1', slug='vlan-group-1')
+    @classmethod
+    def setUpTestData(cls):
+        vlangroup = VLANGroup.objects.create(
+            name='VLAN Group 1',
+            slug='vlan-group-1',
+            min_vid=100,
+            max_vid=199
+        )
         VLAN.objects.bulk_create((
-            VLAN(name='VLAN 1', vid=1, group=vlangroup),
-            VLAN(name='VLAN 2', vid=2, group=vlangroup),
-            VLAN(name='VLAN 3', vid=3, group=vlangroup),
-            VLAN(name='VLAN 5', vid=5, group=vlangroup),
+            VLAN(name='VLAN 100', vid=100, group=vlangroup),
+            VLAN(name='VLAN 101', vid=101, group=vlangroup),
+            VLAN(name='VLAN 102', vid=102, group=vlangroup),
+            VLAN(name='VLAN 103', vid=103, group=vlangroup),
         ))
-        self.assertEqual(vlangroup.get_next_available_vid(), 4)
 
-        VLAN.objects.bulk_create((
-            VLAN(name='VLAN 4', vid=4, group=vlangroup),
-        ))
-        self.assertEqual(vlangroup.get_next_available_vid(), 6)
+    def test_get_available_vids(self):
+        vlangroup = VLANGroup.objects.first()
+        child_vids = VLAN.objects.filter(group=vlangroup).values_list('vid', flat=True)
+        self.assertEqual(len(child_vids), 4)
+
+        available_vids = vlangroup.get_available_vids()
+        self.assertListEqual(available_vids, list(range(104, 200)))
+
+    def test_get_next_available_vid(self):
+        vlangroup = VLANGroup.objects.first()
+        self.assertEqual(vlangroup.get_next_available_vid(), 104)
+
+        VLAN.objects.create(name='VLAN 104', vid=104, group=vlangroup)
+        self.assertEqual(vlangroup.get_next_available_vid(), 105)

+ 2 - 0
netbox/ipam/tests/test_views.py

@@ -485,6 +485,8 @@ class VLANGroupTestCase(ViewTestCases.OrganizationalObjectViewTestCase):
         cls.form_data = {
             'name': 'VLAN Group X',
             'slug': 'vlan-group-x',
+            'min_vid': 1,
+            'max_vid': 4094,
             'description': 'A new VLAN group',
             'tags': [t.pk for t in tags],
         }

+ 5 - 3
netbox/templates/ipam/vlangroup.html

@@ -23,9 +23,7 @@
 <div class="row mb-3">
 	<div class="col col-md-6">
     <div class="card">
-      <h5 class="card-header">
-        VLAN Group
-      </h5>
+      <h5 class="card-header">VLAN Group</h5>
       <div class="card-body">
         <table class="table table-hover attr-table">
           <tr>
@@ -45,6 +43,10 @@
               <span class="text-muted">&mdash;</span>
             {% endif %}
           </tr>
+          <tr>
+            <th scope="row">Permitted VIDs</th>
+            <td>{{ object.min_vid }} - {{ object.max_vid }}</td>
+          </tr>
           <tr>
             <th scope="row">VLANs</th>
             <td>