diff --git a/docs/models/ipam/vlangroup.md b/docs/models/ipam/vlangroup.md index 819d45982..2840fafed 100644 --- a/docs/models/ipam/vlangroup.md +++ b/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. diff --git a/docs/release-notes/version-3.2.md b/docs/release-notes/version-3.2.md index f03f1924f..693335ae9 100644 --- a/docs/release-notes/version-3.2.md +++ b/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 diff --git a/netbox/ipam/api/serializers.py b/netbox/ipam/api/serializers.py index 14bad10b7..c028a3d5d 100644 --- a/netbox/ipam/api/serializers.py +++ b/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 = [] diff --git a/netbox/ipam/filtersets.py b/netbox/ipam/filtersets.py index df6ee1055..8a10a7b24 100644 --- a/netbox/ipam/filtersets.py +++ b/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(): diff --git a/netbox/ipam/forms/bulk_edit.py b/netbox/ipam/forms/bulk_edit.py index edb14a25c..971becaed 100644 --- a/netbox/ipam/forms/bulk_edit.py +++ b/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 diff --git a/netbox/ipam/forms/bulk_import.py b/netbox/ipam/forms/bulk_import.py index 65fc35c34..a4fdaa3ae 100644 --- a/netbox/ipam/forms/bulk_import.py +++ b/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', } diff --git a/netbox/ipam/forms/filtersets.py b/netbox/ipam/forms/filtersets.py index b21dbd6cd..a7732fe9a 100644 --- a/netbox/ipam/forms/filtersets.py +++ b/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) diff --git a/netbox/ipam/forms/models.py b/netbox/ipam/forms/models.py index c5e3146e9..68eac5456 100644 --- a/netbox/ipam/forms/models.py +++ b/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 = { diff --git a/netbox/ipam/migrations/0054_vlangroup_min_max_vids.py b/netbox/ipam/migrations/0054_vlangroup_min_max_vids.py new file mode 100644 index 000000000..adbe69f4c --- /dev/null +++ b/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)]), + ), + ] diff --git a/netbox/ipam/models/vlans.py b/netbox/ipam/models/vlans.py index c31bb49fd..31c8da2b6 100644 --- a/netbox/ipam/models/vlans.py +++ b/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') diff --git a/netbox/ipam/tables/vlans.py b/netbox/ipam/tables/vlans.py index 365c6119b..ca8d22552 100644 --- a/netbox/ipam/tables/vlans.py +++ b/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') diff --git a/netbox/ipam/tests/test_models.py b/netbox/ipam/tests/test_models.py index f6130f1c1..06ac9b843 100644 --- a/netbox/ipam/tests/test_models.py +++ b/netbox/ipam/tests/test_models.py @@ -497,18 +497,32 @@ class TestIPAddress(TestCase): class TestVLANGroup(TestCase): + @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 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), + )) + + 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) - vlangroup = VLANGroup.objects.create(name='VLAN Group 1', slug='vlan-group-1') - 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), - )) - 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) + VLAN.objects.create(name='VLAN 104', vid=104, group=vlangroup) + self.assertEqual(vlangroup.get_next_available_vid(), 105) diff --git a/netbox/ipam/tests/test_views.py b/netbox/ipam/tests/test_views.py index 022ea13c3..1e7a72389 100644 --- a/netbox/ipam/tests/test_views.py +++ b/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], } diff --git a/netbox/templates/ipam/vlangroup.html b/netbox/templates/ipam/vlangroup.html index b0e2b1a21..f92afce46 100644 --- a/netbox/templates/ipam/vlangroup.html +++ b/netbox/templates/ipam/vlangroup.html @@ -23,9 +23,7 @@
-
- VLAN Group -
+
VLAN Group
@@ -45,6 +43,10 @@ {% endif %} + + + +
Permitted VIDs{{ object.min_vid }} - {{ object.max_vid }}
VLANs