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. 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. 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 ### Enhancements
* [#7650](https://github.com/netbox-community/netbox/issues/7650) - Add support for local account password validation * [#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 ### Other Changes
@ -53,3 +54,6 @@ FIELD_CHOICES = {
* dcim.Site * dcim.Site
* Removed the `asn`, `contact_name`, `contact_phone`, and `contact_email` fields * 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: class Meta:
model = VLANGroup model = VLANGroup
fields = [ fields = [
'id', 'url', 'display', 'name', 'slug', 'scope_type', 'scope_id', 'scope', 'description', 'tags', 'id', 'url', 'display', 'name', 'slug', 'scope_type', 'scope_id', 'scope', 'min_vid', 'max_vid',
'custom_fields', 'created', 'last_updated', 'vlan_count', 'description', 'tags', 'custom_fields', 'created', 'last_updated', 'vlan_count',
] ]
validators = [] validators = []

View File

@ -740,7 +740,7 @@ class VLANGroupFilterSet(OrganizationalModelFilterSet):
class Meta: class Meta:
model = VLANGroup 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): def search(self, queryset, name, value):
if not value.strip(): if not value.strip():

View File

@ -359,6 +359,18 @@ class VLANGroupBulkEditForm(AddRemoveTagsForm, CustomFieldModelBulkEditForm):
queryset=Site.objects.all(), queryset=Site.objects.all(),
required=False 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( description = forms.CharField(
max_length=200, max_length=200,
required=False required=False

View File

@ -332,10 +332,22 @@ class VLANGroupCSVForm(CustomFieldModelCSVForm):
required=False, required=False,
label='Scope type (app & model)' 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: class Meta:
model = VLANGroup model = VLANGroup
fields = ('name', 'slug', 'scope_type', 'scope_id', 'description') fields = ('name', 'slug', 'scope_type', 'scope_id', 'min_vid', 'max_vid', 'description')
labels = { labels = {
'scope_id': 'Scope ID', 'scope_id': 'Scope ID',
} }

View File

@ -370,7 +370,8 @@ class FHRPGroupFilterForm(CustomFieldModelFilterForm):
class VLANGroupFilterForm(CustomFieldModelFilterForm): class VLANGroupFilterForm(CustomFieldModelFilterForm):
field_groups = [ field_groups = [
['q', 'tag'], ['q', 'tag'],
['region', 'sitegroup', 'site', 'location', 'rack'] ['region', 'sitegroup', 'site', 'location', 'rack'],
['min_vid', 'max_vid'],
] ]
model = VLANGroup model = VLANGroup
region = DynamicModelMultipleChoiceField( region = DynamicModelMultipleChoiceField(
@ -403,6 +404,14 @@ class VLANGroupFilterForm(CustomFieldModelFilterForm):
label=_('Rack'), label=_('Rack'),
fetch_trigger='open' 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) tag = TagFilterField(model)

View File

@ -700,10 +700,11 @@ class VLANGroupForm(CustomFieldModelForm):
model = VLANGroup model = VLANGroup
fields = [ fields = [
'name', 'slug', 'description', 'scope_type', 'region', 'sitegroup', 'site', 'location', 'rack', 'name', 'slug', 'description', 'scope_type', 'region', 'sitegroup', 'site', 'location', 'rack',
'clustergroup', 'cluster', 'tags', 'clustergroup', 'cluster', 'min_vid', 'max_vid', 'tags',
] ]
fieldsets = ( fieldsets = (
('VLAN Group', ('name', 'slug', 'description', 'tags')), ('VLAN Group', ('name', 'slug', 'description', 'tags')),
('Child VLANs', ('min_vid', 'max_vid')),
('Scope', ('scope_type', 'region', 'sitegroup', 'site', 'location', 'rack', 'clustergroup', 'cluster')), ('Scope', ('scope_type', 'region', 'sitegroup', 'site', 'location', 'rack', 'clustergroup', 'cluster')),
) )
widgets = { 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', ct_field='scope_type',
fk_field='scope_id' 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( description = models.CharField(
max_length=200, max_length=200,
blank=True blank=True
@ -75,24 +93,28 @@ class VLANGroup(OrganizationalModel):
if self.scope_id and not self.scope_type: if self.scope_id and not self.scope_type:
raise ValidationError("Cannot set scope_id without 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): def get_available_vids(self):
""" """
Return all available VLANs within this group. 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)) available_vlans -= set(VLAN.objects.filter(group=self).values_list('vid', flat=True))
# TODO: Check ordering return sorted(available_vlans)
return list(available_vlans)
def get_next_available_vid(self): def get_next_available_vid(self):
""" """
Return the first available VLAN ID (1-4094) in the group. Return the first available VLAN ID (1-4094) in the group.
""" """
vlan_ids = VLAN.objects.filter(group=self).values_list('vid', flat=True) available_vids = self.get_available_vids()
for i in range(1, 4095): if available_vids:
if i not in vlan_ids: return available_vids[0]
return i
return None return None
@ -122,7 +144,10 @@ class VLAN(PrimaryModel):
) )
vid = models.PositiveSmallIntegerField( vid = models.PositiveSmallIntegerField(
verbose_name='ID', verbose_name='ID',
validators=[MinValueValidator(1), MaxValueValidator(4094)] validators=(
MinValueValidator(VLAN_VID_MIN),
MaxValueValidator(VLAN_VID_MAX)
)
) )
name = models.CharField( name = models.CharField(
max_length=64 max_length=64
@ -182,6 +207,13 @@ class VLAN(PrimaryModel):
f"site {self.site}." 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): def get_status_class(self):
return VLANStatusChoices.colors.get(self.status, 'secondary') return VLANStatusChoices.colors.get(self.status, 'secondary')

View File

@ -84,7 +84,10 @@ class VLANGroupTable(BaseTable):
class Meta(BaseTable.Meta): class Meta(BaseTable.Meta):
model = VLANGroup 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') default_columns = ('pk', 'name', 'scope_type', 'scope', 'vlan_count', 'description', 'actions')

View File

@ -497,18 +497,32 @@ class TestIPAddress(TestCase):
class TestVLANGroup(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): 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.create(name='VLAN 104', vid=104, group=vlangroup)
VLAN.objects.bulk_create(( self.assertEqual(vlangroup.get_next_available_vid(), 105)
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)

View File

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

View File

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