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

This commit is contained in:
jeremystretch 2021-12-23 11:13:28 -05:00
parent 083fda3172
commit 544d991e1e
14 changed files with 148 additions and 31 deletions

View File

@ -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.

View File

@ -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

View File

@ -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 = []

View File

@ -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():

View File

@ -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

View File

@ -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',
}

View File

@ -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)

View File

@ -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 = {

View File

@ -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)]),
),
]

View File

@ -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')

View File

@ -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')

View File

@ -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)

View File

@ -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],
}

View File

@ -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>