diff --git a/netbox/ipam/models/vlans.py b/netbox/ipam/models/vlans.py index a5e06bc29..ee994cf0c 100644 --- a/netbox/ipam/models/vlans.py +++ b/netbox/ipam/models/vlans.py @@ -95,7 +95,7 @@ class VLANGroup(OrganizationalModel): if self.scope_id and not self.scope_type: raise ValidationError(_("Cannot set scope_id without scope_type.")) - # Validate vlan ranges + # Validate VID ranges if self.vid_ranges and check_ranges_overlap(self.vid_ranges): raise ValidationError({'vid_ranges': _("Ranges cannot overlap.")}) diff --git a/netbox/ipam/tests/test_api.py b/netbox/ipam/tests/test_api.py index d2c90c8ad..00c240769 100644 --- a/netbox/ipam/tests/test_api.py +++ b/netbox/ipam/tests/test_api.py @@ -8,7 +8,7 @@ from dcim.models import Device, DeviceRole, DeviceType, Interface, Manufacturer, from ipam.choices import * from ipam.models import * from tenancy.models import Tenant -from utilities.data import string_to_range_array +from utilities.data import string_to_ranges from utilities.testing import APITestCase, APIViewTestCases, create_test_device, disable_warnings @@ -883,7 +883,7 @@ class VLANGroupTest(APIViewTestCases.APIViewTestCase): vlangroup = VLANGroup.objects.create( name='VLAN Group X', slug='vlan-group-x', - vid_ranges=string_to_range_array(f"{MIN_VID}-{MAX_VID}") + vid_ranges=string_to_ranges(f"{MIN_VID}-{MAX_VID}") ) # Create a set of VLANs within the group diff --git a/netbox/ipam/tests/test_models.py b/netbox/ipam/tests/test_models.py index 0af23bb57..f7c165066 100644 --- a/netbox/ipam/tests/test_models.py +++ b/netbox/ipam/tests/test_models.py @@ -1,7 +1,7 @@ from django.core.exceptions import ValidationError from django.test import TestCase, override_settings from netaddr import IPNetwork, IPSet -from utilities.data import string_to_range_array +from utilities.data import string_to_ranges from ipam.choices import * from ipam.models import * @@ -510,7 +510,7 @@ class TestVLANGroup(TestCase): vlangroup = VLANGroup.objects.create( name='VLAN Group 1', slug='vlan-group-1', - vid_ranges=string_to_range_array('100-199'), + vid_ranges=string_to_ranges('100-199'), ) VLAN.objects.bulk_create(( VLAN(name='VLAN 100', vid=100, group=vlangroup), diff --git a/netbox/templates/ipam/vlangroup.html b/netbox/templates/ipam/vlangroup.html index f44bcd319..efd2edcfc 100644 --- a/netbox/templates/ipam/vlangroup.html +++ b/netbox/templates/ipam/vlangroup.html @@ -40,7 +40,7 @@ {% trans "VLAN IDs" %} - {{ object.vlan_ranges }} + {{ object.vid_ranges_list }} Utilization diff --git a/netbox/utilities/data.py b/netbox/utilities/data.py index 1e610e8a0..73c8476ec 100644 --- a/netbox/utilities/data.py +++ b/netbox/utilities/data.py @@ -11,7 +11,7 @@ __all__ = ( 'flatten_dict', 'ranges_to_string', 'shallow_compare_dict', - 'string_to_range_array', + 'string_to_ranges', ) @@ -121,14 +121,15 @@ def drange(start, end, step=decimal.Decimal(1)): def check_ranges_overlap(ranges): """ - Check if array of ranges overlap + Check for overlap in an iterable of NumericRanges. """ - - # sort the ranges in increasing order ranges.sort(key=lambda x: x.lower) for i in range(1, len(ranges)): - if (ranges[i - 1].upper >= ranges[i].lower): + 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 @@ -136,8 +137,7 @@ def check_ranges_overlap(ranges): def ranges_to_string(ranges): """ - Generate a human-friendly string from a set of ranges. Intended for use with ArrayField. - For example: + 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: @@ -150,14 +150,15 @@ def ranges_to_string(ranges): return ','.join(output) -def string_to_range_array(value): +def string_to_ranges(value): """ - Given a string in the format "1-100, 200-300" create an array of ranges. Intended for use with ArrayField. + 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: diff --git a/netbox/utilities/forms/fields/array.py b/netbox/utilities/forms/fields/array.py index eef85672b..e6de2d89f 100644 --- a/netbox/utilities/forms/fields/array.py +++ b/netbox/utilities/forms/fields/array.py @@ -2,7 +2,7 @@ from django import forms from django.contrib.postgres.forms import SimpleArrayField from django.utils.safestring import mark_safe from django.utils.translation import gettext_lazy as _ -from utilities.data import ranges_to_string, string_to_range_array +from utilities.data import ranges_to_string, string_to_ranges from ..utils import parse_numeric_range @@ -54,4 +54,4 @@ class NumericRangeArrayField(forms.CharField): return ranges_to_string(value) def to_python(self, value): - return string_to_range_array(value) + return string_to_ranges(value) diff --git a/netbox/utilities/tests/test_data.py b/netbox/utilities/tests/test_data.py new file mode 100644 index 000000000..c83885233 --- /dev/null +++ b/netbox/utilities/tests/test_data.py @@ -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 + ] + )