9627 use ArrayField on VLANGroup for allowed VLANs (#16710)

* 9627 initial commit

* 9627 numeric range field

* 9627 numeric range field

* 9627 numeric range field

* 9627 numeric range field

* 9627 add stuff for utilization calc

* 9627 update views

* 9627 fixes

* 9627 available_vlans

* 9627 available_vlans

* 9627 fixes

* 9627 bulk import / edit

* 9627 test fixes

* 9627 test fixes

* 9627 update validation

* 9627 fix tests

* 9627 fix tests

* 9627 fix tests

* 9627 fix tests

* 9627 fix tests

* 9627 fix tests

* 9627 review changes

* 9627 temp vlan_id filter

* Clean up labels

* Remove annotate_vlan_ranges() from VLANGroupQuerySet

* Misc cleanup

* Implement contains_vid filter

* Serialize VID ranges as integer lists in REST API

* Remove default value from vlan_id_ranges

* 9627 fix typo in test

* Require vlan_id_ranges & set default value

* Fix logic for upper range boundaries

* Add field to VLANGroup model documentation

* Rename vlan_id_ranges to vid_ranges

* Fix computation of available VLAN IDs

* Clean up migration

* Add tests for range utility functions

* Clean up add_available_vlans()

* Misc cleanup, add test for VID validation

---------

Co-authored-by: Jeremy Stretch <jstretch@netboxlabs.com>
This commit is contained in:
Arthur Hanson 2024-07-16 21:47:18 +07:00 committed by GitHub
parent 5a6ffde67e
commit cf1024a12b
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
25 changed files with 429 additions and 104 deletions

View File

@ -14,9 +14,9 @@ A unique human-friendly name.
A unique URL-friendly identifier. (This value can be used for filtering.) A unique URL-friendly identifier. (This value can be used for filtering.)
### Minimum & Maximum VLAN IDs ### VLAN ID Ranges
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). 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.
### Scope ### Scope

View File

@ -6,7 +6,7 @@ from dcim.api.serializers_.sites import SiteSerializer
from ipam.choices import * from ipam.choices import *
from ipam.constants import VLANGROUP_SCOPE_TYPES from ipam.constants import VLANGROUP_SCOPE_TYPES
from ipam.models import VLAN, VLANGroup from ipam.models import VLAN, VLANGroup
from netbox.api.fields import ChoiceField, ContentTypeField, RelatedObjectCountField from netbox.api.fields import ChoiceField, ContentTypeField, IntegerRangeSerializer, RelatedObjectCountField
from netbox.api.serializers import NetBoxModelSerializer from netbox.api.serializers import NetBoxModelSerializer
from tenancy.api.serializers_.tenants import TenantSerializer from tenancy.api.serializers_.tenants import TenantSerializer
from utilities.api import get_serializer_for_model from utilities.api import get_serializer_for_model
@ -32,6 +32,7 @@ class VLANGroupSerializer(NetBoxModelSerializer):
) )
scope_id = serializers.IntegerField(allow_null=True, required=False, default=None) scope_id = serializers.IntegerField(allow_null=True, required=False, default=None)
scope = serializers.SerializerMethodField(read_only=True) scope = serializers.SerializerMethodField(read_only=True)
vid_ranges = IntegerRangeSerializer(many=True, required=False)
utilization = serializers.CharField(read_only=True) utilization = serializers.CharField(read_only=True)
# Related object counts # Related object counts
@ -40,8 +41,8 @@ class VLANGroupSerializer(NetBoxModelSerializer):
class Meta: class Meta:
model = VLANGroup model = VLANGroup
fields = [ fields = [
'id', 'url', 'display_url', 'display', 'name', 'slug', 'scope_type', 'scope_id', 'scope', 'min_vid', 'id', 'url', 'display_url', 'display', 'name', 'slug', 'scope_type', 'scope_id', 'scope', 'vid_ranges',
'max_vid', 'description', 'tags', 'custom_fields', 'created', 'last_updated', 'vlan_count', 'utilization' 'description', 'tags', 'custom_fields', 'created', 'last_updated', 'vlan_count', 'utilization'
] ]
brief_fields = ('id', 'url', 'display', 'name', 'slug', 'description', 'vlan_count') brief_fields = ('id', 'url', 'display', 'name', 'slug', 'description', 'vlan_count')
validators = [] validators = []

View File

@ -911,10 +911,13 @@ class VLANGroupFilterSet(OrganizationalModelFilterSet):
cluster = django_filters.NumberFilter( cluster = django_filters.NumberFilter(
method='filter_scope' method='filter_scope'
) )
contains_vid = django_filters.NumberFilter(
method='filter_contains_vid'
)
class Meta: class Meta:
model = VLANGroup model = VLANGroup
fields = ('id', 'name', 'slug', 'min_vid', 'max_vid', 'description', 'scope_id') fields = ('id', 'name', 'slug', 'description', 'scope_id')
def search(self, queryset, name, value): def search(self, queryset, name, value):
if not value.strip(): if not value.strip():
@ -932,6 +935,21 @@ class VLANGroupFilterSet(OrganizationalModelFilterSet):
scope_id=value scope_id=value
) )
def filter_contains_vid(self, queryset, name, value):
"""
Return all VLANGroups which contain the given VLAN ID.
"""
table_name = VLANGroup._meta.db_table
# TODO: See if this can be optimized without compromising queryset integrity
# Expand VLAN ID ranges to query by integer
groups = VLANGroup.objects.raw(
f'SELECT id FROM {table_name}, unnest(vid_ranges) vid_range WHERE %s <@ vid_range',
params=(value,)
)
return queryset.filter(
pk__in=[g.id for g in groups]
)
class VLANFilterSet(NetBoxModelFilterSet, TenancyFilterSet): class VLANFilterSet(NetBoxModelFilterSet, TenancyFilterSet):
region_id = TreeNodeMultipleChoiceFilter( region_id = TreeNodeMultipleChoiceFilter(

View File

@ -12,6 +12,7 @@ from tenancy.models import Tenant
from utilities.forms import add_blank_choice from utilities.forms import add_blank_choice
from utilities.forms.fields import ( from utilities.forms.fields import (
CommentField, ContentTypeChoiceField, DynamicModelChoiceField, DynamicModelMultipleChoiceField, NumericArrayField, CommentField, ContentTypeChoiceField, DynamicModelChoiceField, DynamicModelMultipleChoiceField, NumericArrayField,
NumericRangeArrayField,
) )
from utilities.forms.rendering import FieldSet from utilities.forms.rendering import FieldSet
from utilities.forms.widgets import BulkEditNullBooleanSelect from utilities.forms.widgets import BulkEditNullBooleanSelect
@ -408,18 +409,6 @@ class FHRPGroupBulkEditForm(NetBoxModelBulkEditForm):
class VLANGroupBulkEditForm(NetBoxModelBulkEditForm): class VLANGroupBulkEditForm(NetBoxModelBulkEditForm):
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(
label=_('Description'), label=_('Description'),
max_length=200, max_length=200,
@ -483,10 +472,14 @@ class VLANGroupBulkEditForm(NetBoxModelBulkEditForm):
'group_id': '$clustergroup', 'group_id': '$clustergroup',
} }
) )
vid_ranges = NumericRangeArrayField(
label=_('VLAN ID ranges'),
required=False
)
model = VLANGroup model = VLANGroup
fieldsets = ( fieldsets = (
FieldSet('site', 'min_vid', 'max_vid', 'description'), FieldSet('site', 'vid_ranges', 'description'),
FieldSet( FieldSet(
'scope_type', 'region', 'sitegroup', 'site', 'location', 'rack', 'clustergroup', 'cluster', name=_('Scope') 'scope_type', 'region', 'sitegroup', 'site', 'location', 'rack', 'clustergroup', 'cluster', name=_('Scope')
), ),

View File

@ -9,7 +9,8 @@ from ipam.models import *
from netbox.forms import NetBoxModelImportForm from netbox.forms import NetBoxModelImportForm
from tenancy.models import Tenant from tenancy.models import Tenant
from utilities.forms.fields import ( from utilities.forms.fields import (
CSVChoiceField, CSVContentTypeField, CSVModelChoiceField, CSVModelMultipleChoiceField, SlugField CSVChoiceField, CSVContentTypeField, CSVModelChoiceField, CSVModelMultipleChoiceField, SlugField,
NumericRangeArrayField,
) )
from virtualization.models import VirtualMachine, VMInterface from virtualization.models import VirtualMachine, VMInterface
@ -411,22 +412,13 @@ class VLANGroupImportForm(NetBoxModelImportForm):
required=False, required=False,
label=_('Scope type (app & model)') label=_('Scope type (app & model)')
) )
min_vid = forms.IntegerField( vid_ranges = NumericRangeArrayField(
min_value=VLAN_VID_MIN, required=False
max_value=VLAN_VID_MAX,
required=False,
label=_('Minimum child VLAN VID (default: {minimum})').format(minimum=VLAN_VID_MIN)
)
max_vid = forms.IntegerField(
min_value=VLAN_VID_MIN,
max_value=VLAN_VID_MAX,
required=False,
label=_('Maximum child VLAN VID (default: {maximum})').format(maximum=VLAN_VID_MIN)
) )
class Meta: class Meta:
model = VLANGroup model = VLANGroup
fields = ('name', 'slug', 'scope_type', 'scope_id', 'min_vid', 'max_vid', 'description', 'tags') fields = ('name', 'slug', 'scope_type', 'scope_id', 'vid_ranges', 'description', 'tags')
labels = { labels = {
'scope_id': 'Scope ID', 'scope_id': 'Scope ID',
} }

View File

@ -413,7 +413,7 @@ class VLANGroupFilterForm(NetBoxModelFilterSetForm):
FieldSet('q', 'filter_id', 'tag'), FieldSet('q', 'filter_id', 'tag'),
FieldSet('region', 'sitegroup', 'site', 'location', 'rack', name=_('Location')), FieldSet('region', 'sitegroup', 'site', 'location', 'rack', name=_('Location')),
FieldSet('cluster_group', 'cluster', name=_('Cluster')), FieldSet('cluster_group', 'cluster', name=_('Cluster')),
FieldSet('min_vid', 'max_vid', name=_('VLAN ID')), FieldSet('contains_vid', name=_('VLANs')),
) )
model = VLANGroup model = VLANGroup
region = DynamicModelMultipleChoiceField( region = DynamicModelMultipleChoiceField(
@ -441,18 +441,6 @@ class VLANGroupFilterForm(NetBoxModelFilterSetForm):
required=False, required=False,
label=_('Rack') label=_('Rack')
) )
min_vid = forms.IntegerField(
required=False,
min_value=VLAN_VID_MIN,
max_value=VLAN_VID_MAX,
label=_('Minimum VID')
)
max_vid = forms.IntegerField(
required=False,
min_value=VLAN_VID_MIN,
max_value=VLAN_VID_MAX,
label=_('Maximum VID')
)
cluster = DynamicModelMultipleChoiceField( cluster = DynamicModelMultipleChoiceField(
queryset=Cluster.objects.all(), queryset=Cluster.objects.all(),
required=False, required=False,
@ -463,6 +451,11 @@ class VLANGroupFilterForm(NetBoxModelFilterSetForm):
required=False, required=False,
label=_('Cluster group') label=_('Cluster group')
) )
contains_vid = forms.IntegerField(
min_value=0,
required=False,
label=_('Contains VLAN ID')
)
tag = TagFilterField(model) tag = TagFilterField(model)

View File

@ -1,5 +1,6 @@
from django import forms from django import forms
from django.contrib.contenttypes.models import ContentType from django.contrib.contenttypes.models import ContentType
from django.contrib.postgres.forms import IntegerRangeField, SimpleArrayField
from django.core.exceptions import ValidationError from django.core.exceptions import ValidationError
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
@ -14,7 +15,7 @@ from utilities.exceptions import PermissionsViolation
from utilities.forms import add_blank_choice from utilities.forms import add_blank_choice
from utilities.forms.fields import ( from utilities.forms.fields import (
CommentField, ContentTypeChoiceField, DynamicModelChoiceField, DynamicModelMultipleChoiceField, NumericArrayField, CommentField, ContentTypeChoiceField, DynamicModelChoiceField, DynamicModelMultipleChoiceField, NumericArrayField,
SlugField, NumericRangeArrayField, SlugField
) )
from utilities.forms.rendering import FieldSet, InlineFields, ObjectAttribute, TabbedGroups from utilities.forms.rendering import FieldSet, InlineFields, ObjectAttribute, TabbedGroups
from utilities.forms.widgets import DatePicker from utilities.forms.widgets import DatePicker
@ -632,10 +633,13 @@ class VLANGroupForm(NetBoxModelForm):
} }
) )
slug = SlugField() slug = SlugField()
vid_ranges = NumericRangeArrayField(
label=_('VLAN IDs')
)
fieldsets = ( fieldsets = (
FieldSet('name', 'slug', 'description', 'tags', name=_('VLAN Group')), FieldSet('name', 'slug', 'description', 'tags', name=_('VLAN Group')),
FieldSet('min_vid', 'max_vid', name=_('Child VLANs')), FieldSet('vid_ranges', name=_('Child VLANs')),
FieldSet( FieldSet(
'scope_type', 'region', 'sitegroup', 'site', 'location', 'rack', 'clustergroup', 'cluster', 'scope_type', 'region', 'sitegroup', 'site', 'location', 'rack', 'clustergroup', 'cluster',
name=_('Scope') name=_('Scope')
@ -646,7 +650,7 @@ class VLANGroupForm(NetBoxModelForm):
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', 'min_vid', 'max_vid', 'tags', 'clustergroup', 'cluster', 'vid_ranges', 'tags',
] ]
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):

View File

@ -251,6 +251,7 @@ class VLANType(NetBoxObjectType):
class VLANGroupType(OrganizationalObjectType): class VLANGroupType(OrganizationalObjectType):
vlans: List[VLANType] vlans: List[VLANType]
vid_ranges: List[str]
@strawberry_django.field @strawberry_django.field
def scope(self) -> Annotated[Union[ def scope(self) -> Annotated[Union[

View File

@ -0,0 +1,55 @@
import django.contrib.postgres.fields
import django.contrib.postgres.fields.ranges
from django.db import migrations, models
from django.db.backends.postgresql.psycopg_any import NumericRange
import ipam.models.vlans
def set_vid_ranges(apps, schema_editor):
"""
Convert the min_vid & max_vid fields to a range in the new vid_ranges ArrayField.
"""
VLANGroup = apps.get_model('ipam', 'VLANGroup')
for group in VLANGroup.objects.all():
group.vid_ranges = [
NumericRange(group.min_vid, group.max_vid, bounds='[]')
]
group._total_vlan_ids = group.max_vid - group.min_vid + 1
group.save()
class Migration(migrations.Migration):
dependencies = [
('ipam', '0069_gfk_indexes'),
]
operations = [
migrations.AddField(
model_name='vlangroup',
name='vid_ranges',
field=django.contrib.postgres.fields.ArrayField(
base_field=django.contrib.postgres.fields.ranges.IntegerRangeField(),
default=ipam.models.vlans.default_vid_ranges,
size=None
),
),
migrations.AddField(
model_name='vlangroup',
name='_total_vlan_ids',
field=models.PositiveBigIntegerField(default=4094),
),
migrations.RunPython(
code=set_vid_ranges,
reverse_code=migrations.RunPython.noop
),
migrations.RemoveField(
model_name='vlangroup',
name='max_vid',
),
migrations.RemoveField(
model_name='vlangroup',
name='min_vid',
),
]

View File

@ -1,7 +1,9 @@
from django.contrib.contenttypes.fields import GenericForeignKey, GenericRelation from django.contrib.contenttypes.fields import GenericForeignKey, GenericRelation
from django.contrib.postgres.fields import ArrayField, IntegerRangeField
from django.core.exceptions import ValidationError from django.core.exceptions import ValidationError
from django.core.validators import MaxValueValidator, MinValueValidator from django.core.validators import MaxValueValidator, MinValueValidator
from django.db import models from django.db import models
from django.db.backends.postgresql.psycopg_any import NumericRange
from django.urls import reverse from django.urls import reverse
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
@ -10,6 +12,7 @@ from ipam.choices import *
from ipam.constants import * from ipam.constants import *
from ipam.querysets import VLANQuerySet, VLANGroupQuerySet from ipam.querysets import VLANQuerySet, VLANGroupQuerySet
from netbox.models import OrganizationalModel, PrimaryModel from netbox.models import OrganizationalModel, PrimaryModel
from utilities.data import check_ranges_overlap, ranges_to_string
from virtualization.models import VMInterface from virtualization.models import VMInterface
__all__ = ( __all__ = (
@ -18,9 +21,16 @@ __all__ = (
) )
def default_vid_ranges():
return [
NumericRange(VLAN_VID_MIN, VLAN_VID_MAX, bounds='[]')
]
class VLANGroup(OrganizationalModel): class VLANGroup(OrganizationalModel):
""" """
A VLAN group is an arbitrary collection of VLANs within which VLAN IDs and names must be unique. A VLAN group is an arbitrary collection of VLANs within which VLAN IDs and names must be unique. Each group must
define one or more ranges of valid VLAN IDs, and may be assigned a specific scope.
""" """
name = models.CharField( name = models.CharField(
verbose_name=_('name'), verbose_name=_('name'),
@ -45,23 +55,13 @@ class VLANGroup(OrganizationalModel):
ct_field='scope_type', ct_field='scope_type',
fk_field='scope_id' fk_field='scope_id'
) )
min_vid = models.PositiveSmallIntegerField( vid_ranges = ArrayField(
verbose_name=_('minimum VLAN ID'), IntegerRangeField(),
default=VLAN_VID_MIN, verbose_name=_('VLAN ID ranges'),
validators=( default=default_vid_ranges
MinValueValidator(VLAN_VID_MIN),
MaxValueValidator(VLAN_VID_MAX)
),
help_text=_('Lowest permissible ID of a child VLAN')
) )
max_vid = models.PositiveSmallIntegerField( _total_vlan_ids = models.PositiveBigIntegerField(
verbose_name=_('maximum VLAN ID'), default=VLAN_VID_MAX - VLAN_VID_MIN + 1
default=VLAN_VID_MAX,
validators=(
MinValueValidator(VLAN_VID_MIN),
MaxValueValidator(VLAN_VID_MAX)
),
help_text=_('Highest permissible ID of a child VLAN')
) )
objects = VLANGroupQuerySet.as_manager() objects = VLANGroupQuerySet.as_manager()
@ -96,17 +96,33 @@ 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 # Validate VID ranges
if self.max_vid < self.min_vid: if self.vid_ranges and check_ranges_overlap(self.vid_ranges):
raise ValidationError({ raise ValidationError({'vid_ranges': _("Ranges cannot overlap.")})
'max_vid': _("Maximum child VID must be greater than or equal to minimum child VID") for vid_range in self.vid_ranges:
}) if vid_range.lower >= vid_range.upper:
raise ValidationError({
'vid_ranges': _(
"Maximum child VID must be greater than or equal to minimum child VID ({value})"
).format(value=vid_range)
})
def save(self, *args, **kwargs):
self._total_vlan_ids = 0
for vid_range in self.vid_ranges:
self._total_vlan_ids += vid_range.upper - vid_range.lower + 1
super().save(*args, **kwargs)
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(self.min_vid, self.max_vid + 1)} available_vlans = set()
for vlan_range in self.vid_ranges:
available_vlans = available_vlans.union({
vid for vid in range(vlan_range.lower, vlan_range.upper)
})
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))
return sorted(available_vlans) return sorted(available_vlans)
@ -126,6 +142,10 @@ class VLANGroup(OrganizationalModel):
""" """
return VLAN.objects.filter(group=self).order_by('vid') return VLAN.objects.filter(group=self).order_by('vid')
@property
def vid_ranges_list(self):
return ranges_to_string(self.vid_ranges)
class VLAN(PrimaryModel): class VLAN(PrimaryModel):
""" """
@ -231,13 +251,14 @@ class VLAN(PrimaryModel):
).format(group=self.group, scope=self.group.scope, site=self.site) ).format(group=self.group, scope=self.group.scope, site=self.site)
) )
# Validate group min/max VIDs # Check that the VLAN ID is permitted in the assigned group (if any)
if self.group and not self.group.min_vid <= self.vid <= self.group.max_vid: if self.group:
raise ValidationError({ if not any([self.vid in r for r in self.group.vid_ranges]):
'vid': _( raise ValidationError({
"VID must be between {minimum} and {maximum} for VLANs in group {group}" 'vid': _(
).format(minimum=self.group.min_vid, maximum=self.group.max_vid, group=self.group) "VID must be in ranges {ranges} for VLANs in group {group}"
}) ).format(ranges=ranges_to_string(self.group.vid_ranges), group=self.group)
})
def get_status_color(self): def get_status_color(self):
return VLANStatusChoices.colors.get(self.status) return VLANStatusChoices.colors.get(self.status)

View File

@ -9,6 +9,7 @@ from utilities.querysets import RestrictedQuerySet
__all__ = ( __all__ = (
'ASNRangeQuerySet', 'ASNRangeQuerySet',
'PrefixQuerySet', 'PrefixQuerySet',
'VLANGroupQuerySet',
'VLANQuerySet', 'VLANQuerySet',
) )
@ -63,7 +64,7 @@ class VLANGroupQuerySet(RestrictedQuerySet):
return self.annotate( return self.annotate(
vlan_count=count_related(VLAN, 'group'), vlan_count=count_related(VLAN, 'group'),
utilization=Round(F('vlan_count') / (F('max_vid') - F('min_vid') + 1.0) * 100, 2) utilization=Round(F('vlan_count') * 100 / F('_total_vlan_ids'), 2)
) )

View File

@ -154,9 +154,8 @@ class VLANGroupIndex(SearchIndex):
('name', 100), ('name', 100),
('slug', 110), ('slug', 110),
('description', 500), ('description', 500),
('max_vid', 2000),
) )
display_attrs = ('scope_type', 'min_vid', 'max_vid', 'description') display_attrs = ('scope_type', 'description')
@register_search @register_search

View File

@ -72,6 +72,10 @@ class VLANGroupTable(NetBoxTable):
linkify=True, linkify=True,
orderable=False orderable=False
) )
vid_ranges_list = tables.Column(
verbose_name=_('VID Ranges'),
orderable=False
)
vlan_count = columns.LinkedCountColumn( vlan_count = columns.LinkedCountColumn(
viewname='ipam:vlan_list', viewname='ipam:vlan_list',
url_params={'group_id': 'pk'}, url_params={'group_id': 'pk'},
@ -91,7 +95,7 @@ class VLANGroupTable(NetBoxTable):
class Meta(NetBoxTable.Meta): class Meta(NetBoxTable.Meta):
model = VLANGroup model = VLANGroup
fields = ( fields = (
'pk', 'id', 'name', 'scope_type', 'scope', 'min_vid', 'max_vid', 'vlan_count', 'slug', 'description', 'pk', 'id', 'name', 'scope_type', 'scope', 'vid_ranges_list', 'vlan_count', 'slug', 'description',
'tags', 'created', 'last_updated', 'actions', 'utilization', 'tags', 'created', 'last_updated', 'actions', 'utilization',
) )
default_columns = ('pk', 'name', 'scope_type', 'scope', 'vlan_count', 'utilization', 'description') default_columns = ('pk', 'name', 'scope_type', 'scope', 'vlan_count', 'utilization', 'description')

View File

@ -8,6 +8,7 @@ from dcim.models import Device, DeviceRole, DeviceType, Interface, Manufacturer,
from ipam.choices import * from ipam.choices import *
from ipam.models import * from ipam.models import *
from tenancy.models import Tenant from tenancy.models import Tenant
from utilities.data import string_to_ranges
from utilities.testing import APITestCase, APIViewTestCases, create_test_device, disable_warnings from utilities.testing import APITestCase, APIViewTestCases, create_test_device, disable_warnings
@ -882,8 +883,7 @@ class VLANGroupTest(APIViewTestCases.APIViewTestCase):
vlangroup = VLANGroup.objects.create( vlangroup = VLANGroup.objects.create(
name='VLAN Group X', name='VLAN Group X',
slug='vlan-group-x', slug='vlan-group-x',
min_vid=MIN_VID, vid_ranges=string_to_ranges(f"{MIN_VID}-{MAX_VID}")
max_vid=MAX_VID
) )
# Create a set of VLANs within the group # Create a set of VLANs within the group

View File

@ -1,4 +1,5 @@
from django.contrib.contenttypes.models import ContentType from django.contrib.contenttypes.models import ContentType
from django.db.backends.postgresql.psycopg_any import NumericRange
from django.test import TestCase from django.test import TestCase
from netaddr import IPNetwork from netaddr import IPNetwork
@ -1465,6 +1466,7 @@ class FHRPGroupAssignmentTestCase(TestCase, ChangeLoggedFilterSetTests):
class VLANGroupTestCase(TestCase, ChangeLoggedFilterSetTests): class VLANGroupTestCase(TestCase, ChangeLoggedFilterSetTests):
queryset = VLANGroup.objects.all() queryset = VLANGroup.objects.all()
filterset = VLANGroupFilterSet filterset = VLANGroupFilterSet
ignore_fields = ('vid_ranges',)
@classmethod @classmethod
def setUpTestData(cls): def setUpTestData(cls):
@ -1494,14 +1496,55 @@ class VLANGroupTestCase(TestCase, ChangeLoggedFilterSetTests):
cluster.save() cluster.save()
vlan_groups = ( vlan_groups = (
VLANGroup(name='VLAN Group 1', slug='vlan-group-1', scope=region, description='foobar1'), VLANGroup(
VLANGroup(name='VLAN Group 2', slug='vlan-group-2', scope=sitegroup, description='foobar2'), name='VLAN Group 1',
VLANGroup(name='VLAN Group 3', slug='vlan-group-3', scope=site, description='foobar3'), slug='vlan-group-1',
VLANGroup(name='VLAN Group 4', slug='vlan-group-4', scope=location), vid_ranges=[NumericRange(1, 11), NumericRange(100, 200)],
VLANGroup(name='VLAN Group 5', slug='vlan-group-5', scope=rack), scope=region,
VLANGroup(name='VLAN Group 6', slug='vlan-group-6', scope=clustergroup), description='foobar1'
VLANGroup(name='VLAN Group 7', slug='vlan-group-7', scope=cluster), ),
VLANGroup(name='VLAN Group 8', slug='vlan-group-8'), VLANGroup(
name='VLAN Group 2',
slug='vlan-group-2',
vid_ranges=[NumericRange(1, 11), NumericRange(200, 300)],
scope=sitegroup,
description='foobar2'
),
VLANGroup(
name='VLAN Group 3',
slug='vlan-group-3',
vid_ranges=[NumericRange(1, 11), NumericRange(300, 400)],
scope=site,
description='foobar3'
),
VLANGroup(
name='VLAN Group 4',
slug='vlan-group-4',
vid_ranges=[NumericRange(1, 11), NumericRange(400, 500)],
scope=location
),
VLANGroup(
name='VLAN Group 5',
slug='vlan-group-5',
vid_ranges=[NumericRange(1, 11), NumericRange(500, 600)],
scope=rack
),
VLANGroup(
name='VLAN Group 6',
slug='vlan-group-6',
vid_ranges=[NumericRange(1, 11), NumericRange(600, 700)],
scope=clustergroup
),
VLANGroup(
name='VLAN Group 7',
slug='vlan-group-7',
vid_ranges=[NumericRange(1, 11), NumericRange(700, 800)],
scope=cluster
),
VLANGroup(
name='VLAN Group 8',
slug='vlan-group-8'
),
) )
VLANGroup.objects.bulk_create(vlan_groups) VLANGroup.objects.bulk_create(vlan_groups)
@ -1521,6 +1564,12 @@ class VLANGroupTestCase(TestCase, ChangeLoggedFilterSetTests):
params = {'description': ['foobar1', 'foobar2']} params = {'description': ['foobar1', 'foobar2']}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_contains_vid(self):
params = {'contains_vid': 123}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
params = {'contains_vid': 1}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 8)
def test_region(self): def test_region(self):
params = {'region': Region.objects.first().pk} params = {'region': Region.objects.first().pk}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1) self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)

View File

@ -1,6 +1,7 @@
from django.core.exceptions import ValidationError from django.core.exceptions import ValidationError
from django.test import TestCase, override_settings from django.test import TestCase, override_settings
from netaddr import IPNetwork, IPSet from netaddr import IPNetwork, IPSet
from utilities.data import string_to_ranges
from ipam.choices import * from ipam.choices import *
from ipam.models import * from ipam.models import *
@ -509,8 +510,7 @@ class TestVLANGroup(TestCase):
vlangroup = VLANGroup.objects.create( vlangroup = VLANGroup.objects.create(
name='VLAN Group 1', name='VLAN Group 1',
slug='vlan-group-1', slug='vlan-group-1',
min_vid=100, vid_ranges=string_to_ranges('100-199'),
max_vid=199
) )
VLAN.objects.bulk_create(( VLAN.objects.bulk_create((
VLAN(name='VLAN 100', vid=100, group=vlangroup), VLAN(name='VLAN 100', vid=100, group=vlangroup),
@ -533,3 +533,13 @@ class TestVLANGroup(TestCase):
VLAN.objects.create(name='VLAN 104', vid=104, group=vlangroup) VLAN.objects.create(name='VLAN 104', vid=104, group=vlangroup)
self.assertEqual(vlangroup.get_next_available_vid(), 105) self.assertEqual(vlangroup.get_next_available_vid(), 105)
def test_vid_validation(self):
vlangroup = VLANGroup.objects.first()
vlan = VLAN(vid=1, name='VLAN 1', group=vlangroup)
with self.assertRaises(ValidationError):
vlan.full_clean()
vlan = VLAN(vid=109, name='VLAN 109', group=vlangroup)
vlan.full_clean()

View File

@ -764,9 +764,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',
'vid_ranges': '100-199,300-399',
'tags': [t.pk for t in tags], 'tags': [t.pk for t in tags],
} }

View File

@ -90,12 +90,12 @@ def add_available_ipaddresses(prefix, ipaddress_list, is_pool=False):
return output return output
def add_available_vlans(vlans, vlan_group=None): def available_vlans_from_range(vlans, vlan_group, vlan_range):
""" """
Create fake records for all gaps between used VLANs Create fake records for all gaps between used VLANs
""" """
min_vid = vlan_group.min_vid if vlan_group else VLAN_VID_MIN min_vid = int(vlan_range.lower) if vlan_range else VLAN_VID_MIN
max_vid = vlan_group.max_vid if vlan_group else VLAN_VID_MAX max_vid = int(vlan_range.upper) if vlan_range else VLAN_VID_MAX
if not vlans: if not vlans:
return [{ return [{
@ -128,6 +128,17 @@ def add_available_vlans(vlans, vlan_group=None):
'available': max_vid - prev_vid, 'available': max_vid - prev_vid,
}) })
return new_vlans
def add_available_vlans(vlans, vlan_group):
"""
Create fake records for all gaps between used VLANs
"""
new_vlans = []
for vlan_range in vlan_group.vid_ranges:
new_vlans.extend(available_vlans_from_range(vlans, vlan_group, vlan_range))
vlans = list(vlans) + new_vlans vlans = list(vlans) + new_vlans
vlans.sort(key=lambda v: v.vid if type(v) is VLAN else v['vid']) vlans.sort(key=lambda v: v.vid if type(v) is VLAN else v['vid'])

View File

@ -915,7 +915,7 @@ class IPAddressContactsView(ObjectContactsView):
# #
class VLANGroupListView(generic.ObjectListView): class VLANGroupListView(generic.ObjectListView):
queryset = VLANGroup.objects.annotate_utilization().prefetch_related('tags') queryset = VLANGroup.objects.annotate_utilization()
filterset = filtersets.VLANGroupFilterSet filterset = filtersets.VLANGroupFilterSet
filterset_form = forms.VLANGroupFilterForm filterset_form = forms.VLANGroupFilterForm
table = tables.VLANGroupTable table = tables.VLANGroupTable
@ -923,7 +923,7 @@ class VLANGroupListView(generic.ObjectListView):
@register_model_view(VLANGroup) @register_model_view(VLANGroup)
class VLANGroupView(GetRelatedModelsMixin, generic.ObjectView): class VLANGroupView(GetRelatedModelsMixin, generic.ObjectView):
queryset = VLANGroup.objects.annotate_utilization().prefetch_related('tags') queryset = VLANGroup.objects.annotate_utilization()
def get_extra_context(self, request, instance): def get_extra_context(self, request, instance):
return { return {

View File

@ -1,4 +1,5 @@
from django.core.exceptions import ObjectDoesNotExist from django.core.exceptions import ObjectDoesNotExist
from django.db.backends.postgresql.psycopg_any import NumericRange
from django.utils.translation import gettext as _ from django.utils.translation import gettext as _
from drf_spectacular.types import OpenApiTypes from drf_spectacular.types import OpenApiTypes
from drf_spectacular.utils import extend_schema_field from drf_spectacular.utils import extend_schema_field
@ -11,6 +12,7 @@ __all__ = (
'ChoiceField', 'ChoiceField',
'ContentTypeField', 'ContentTypeField',
'IPNetworkSerializer', 'IPNetworkSerializer',
'IntegerRangeSerializer',
'RelatedObjectCountField', 'RelatedObjectCountField',
'SerializedPKRelatedField', 'SerializedPKRelatedField',
) )
@ -154,3 +156,19 @@ class RelatedObjectCountField(serializers.ReadOnlyField):
self.relation = relation self.relation = relation
super().__init__(**kwargs) super().__init__(**kwargs)
class IntegerRangeSerializer(serializers.Serializer):
"""
Represents a range of integers.
"""
def to_internal_value(self, data):
if not isinstance(data, (list, tuple)) or len(data) != 2:
raise ValidationError(_("Ranges must be specified in the form (lower, upper)."))
if type(data[0]) is not int or type(data[1]) is not int:
raise ValidationError(_("Range boundaries must be defined as integers."))
return NumericRange(data[0], data[1], bounds='[]')
def to_representation(self, instance):
return instance.lower, instance.upper - 1

View File

@ -39,8 +39,8 @@
<td>{{ object.scope|linkify|placeholder }}</td> <td>{{ object.scope|linkify|placeholder }}</td>
</tr> </tr>
<tr> <tr>
<th scope="row">{% trans "Permitted VIDs" %}</th> <th scope="row">{% trans "VLAN IDs" %}</th>
<td>{{ object.min_vid }} - {{ object.max_vid }}</td> <td>{{ object.vid_ranges_list }}</td>
</tr> </tr>
<tr> <tr>
<th scope="row">Utilization</th> <th scope="row">Utilization</th>

View File

@ -1,13 +1,17 @@
import decimal import decimal
from django.db.backends.postgresql.psycopg_any import NumericRange
from itertools import count, groupby from itertools import count, groupby
__all__ = ( __all__ = (
'array_to_ranges', 'array_to_ranges',
'array_to_string', 'array_to_string',
'check_ranges_overlap',
'deepmerge', 'deepmerge',
'drange', 'drange',
'flatten_dict', 'flatten_dict',
'ranges_to_string',
'shallow_compare_dict', 'shallow_compare_dict',
'string_to_ranges',
) )
@ -113,3 +117,52 @@ def drange(start, end, step=decimal.Decimal(1)):
while start > end: while start > end:
yield start yield start
start += step start += step
def check_ranges_overlap(ranges):
"""
Check for overlap in an iterable of NumericRanges.
"""
ranges.sort(key=lambda x: x.lower)
for i in range(1, len(ranges)):
prev_range = ranges[i - 1]
prev_upper = prev_range.upper if prev_range.upper_inc else prev_range.upper - 1
lower = ranges[i].lower if ranges[i].lower_inc else ranges[i].lower + 1
if prev_upper >= lower:
return True
return False
def ranges_to_string(ranges):
"""
Generate a human-friendly string from a set of ranges. Intended for use with ArrayField. For example:
[[1, 100)], [200, 300)] => "1-99,200-299"
"""
if not ranges:
return ''
output = []
for r in ranges:
lower = r.lower if r.lower_inc else r.lower + 1
upper = r.upper if r.upper_inc else r.upper - 1
output.append(f'{lower}-{upper}')
return ','.join(output)
def string_to_ranges(value):
"""
Given a string in the format "1-100, 200-300" return an list of NumericRanges. Intended for use with ArrayField.
For example:
"1-99,200-299" => [NumericRange(1, 100), NumericRange(200, 300)]
"""
if not value:
return None
value.replace(' ', '') # Remove whitespace
values = []
for dash_range in value.split(','):
if '-' not in dash_range:
return None
lower, upper = dash_range.split('-')
values.append(NumericRange(int(lower), int(upper), bounds='[]'))
return values

View File

@ -1,11 +1,14 @@
from django import forms from django import forms
from django.contrib.postgres.forms import SimpleArrayField from django.contrib.postgres.forms import SimpleArrayField
from django.utils.safestring import mark_safe
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from utilities.data import ranges_to_string, string_to_ranges
from ..utils import parse_numeric_range from ..utils import parse_numeric_range
__all__ = ( __all__ = (
'NumericArrayField', 'NumericArrayField',
'NumericRangeArrayField',
) )
@ -24,3 +27,31 @@ class NumericArrayField(SimpleArrayField):
if isinstance(value, str): if isinstance(value, str):
value = ','.join([str(n) for n in parse_numeric_range(value)]) value = ','.join([str(n) for n in parse_numeric_range(value)])
return super().to_python(value) return super().to_python(value)
class NumericRangeArrayField(forms.CharField):
"""
A field which allows for array of numeric ranges:
Example: 1-5,7-20,30-50
"""
def __init__(self, *args, help_text='', **kwargs):
if not help_text:
help_text = mark_safe(
_("Specify one or more numeric ranges separated by commas. Example: " + "<code>1-5,20-30</code>")
)
super().__init__(*args, help_text=help_text, **kwargs)
def clean(self, value):
if value and not self.to_python(value):
raise forms.ValidationError(
_("Invalid ranges ({value}). Must be a range of integers in ascending order.").format(value=value)
)
return super().clean(value)
def prepare_value(self, value):
if isinstance(value, str):
return value
return ranges_to_string(value)
def to_python(self, value):
return string_to_ranges(value)

View File

@ -2,7 +2,7 @@ import json
from django.contrib.auth import get_user_model from django.contrib.auth import get_user_model
from django.contrib.contenttypes.models import ContentType from django.contrib.contenttypes.models import ContentType
from django.contrib.postgres.fields import ArrayField from django.contrib.postgres.fields import ArrayField, RangeField
from django.core.exceptions import FieldDoesNotExist from django.core.exceptions import FieldDoesNotExist
from django.db.models import ManyToManyField, ManyToManyRel, JSONField from django.db.models import ManyToManyField, ManyToManyRel, JSONField
from django.forms.models import model_to_dict from django.forms.models import model_to_dict
@ -12,6 +12,7 @@ from taggit.managers import TaggableManager
from core.models import ObjectType from core.models import ObjectType
from users.models import ObjectPermission from users.models import ObjectPermission
from utilities.data import ranges_to_string
from utilities.object_types import object_type_identifier from utilities.object_types import object_type_identifier
from utilities.permissions import resolve_permission_type from utilities.permissions import resolve_permission_type
from .utils import DUMMY_CF_DATA, extract_form_failures from .utils import DUMMY_CF_DATA, extract_form_failures
@ -139,6 +140,9 @@ class ModelTestCase(TestCase):
if type(field.base_field) is ArrayField: if type(field.base_field) is ArrayField:
# Handle nested arrays (e.g. choice sets) # Handle nested arrays (e.g. choice sets)
model_dict[key] = '\n'.join([f'{k},{v}' for k, v in value]) model_dict[key] = '\n'.join([f'{k},{v}' for k, v in value])
elif issubclass(type(field.base_field), RangeField):
# Handle arrays of numeric ranges (e.g. VLANGroup VLAN ID ranges)
model_dict[key] = ranges_to_string(value)
else: else:
model_dict[key] = ','.join([str(v) for v in value]) model_dict[key] = ','.join([str(v) for v in value])

View File

@ -0,0 +1,68 @@
from django.db.backends.postgresql.psycopg_any import NumericRange
from django.test import TestCase
from utilities.data import check_ranges_overlap, ranges_to_string, string_to_ranges
class RangeFunctionsTestCase(TestCase):
def test_check_ranges_overlap(self):
# Non-overlapping ranges
self.assertFalse(
check_ranges_overlap([
NumericRange(9, 19, bounds='(]'), # 10-19
NumericRange(19, 30, bounds='(]'), # 20-29
])
)
self.assertFalse(
check_ranges_overlap([
NumericRange(10, 19, bounds='[]'), # 10-19
NumericRange(20, 29, bounds='[]'), # 20-29
])
)
self.assertFalse(
check_ranges_overlap([
NumericRange(10, 20, bounds='[)'), # 10-19
NumericRange(20, 30, bounds='[)'), # 20-29
])
)
# Overlapping ranges
self.assertTrue(
check_ranges_overlap([
NumericRange(9, 20, bounds='(]'), # 10-20
NumericRange(19, 30, bounds='(]'), # 20-30
])
)
self.assertTrue(
check_ranges_overlap([
NumericRange(10, 20, bounds='[]'), # 10-20
NumericRange(20, 30, bounds='[]'), # 20-30
])
)
self.assertTrue(
check_ranges_overlap([
NumericRange(10, 21, bounds='[)'), # 10-20
NumericRange(20, 31, bounds='[)'), # 10-30
])
)
def test_ranges_to_string(self):
self.assertEqual(
ranges_to_string([
NumericRange(10, 20), # 10-19
NumericRange(30, 40), # 30-39
NumericRange(100, 200), # 100-199
]),
'10-19,30-39,100-199'
)
def test_string_to_ranges(self):
self.assertEqual(
string_to_ranges('10-19, 30-39, 100-199'),
[
NumericRange(10, 19, bounds='[]'), # 10-19
NumericRange(30, 39, bounds='[]'), # 30-39
NumericRange(100, 199, bounds='[]'), # 100-199
]
)