diff --git a/netbox/ipam/models/vlans.py b/netbox/ipam/models/vlans.py
index 67c9d9414dd..efa1ed39ed3 100644
--- a/netbox/ipam/models/vlans.py
+++ b/netbox/ipam/models/vlans.py
@@ -10,9 +10,9 @@
from dcim.models import Interface, Site, SiteGroup
from ipam.choices import *
from ipam.constants import *
-from ipam.querysets import VLANQuerySet, VLANGroupQuerySet
+from ipam.querysets import VLANGroupQuerySet, VLANQuerySet
from netbox.models import OrganizationalModel, PrimaryModel, NetBoxModel
-from utilities.data import check_ranges_overlap, ranges_to_string
+from utilities.data import check_ranges_overlap, ranges_to_string, ranges_to_string_list
from virtualization.models import VMInterface
__all__ = (
@@ -164,8 +164,18 @@ def get_child_vlans(self):
"""
return VLAN.objects.filter(group=self).order_by('vid')
+ @property
+ def vid_ranges_items(self):
+ """
+ Property that converts VID ranges to a list of string representations.
+ """
+ return ranges_to_string_list(self.vid_ranges)
+
@property
def vid_ranges_list(self):
+ """
+ Property that converts VID ranges into a string representation.
+ """
return ranges_to_string(self.vid_ranges)
diff --git a/netbox/ipam/tables/vlans.py b/netbox/ipam/tables/vlans.py
index c22975be09e..c5e36abba5e 100644
--- a/netbox/ipam/tables/vlans.py
+++ b/netbox/ipam/tables/vlans.py
@@ -41,7 +41,8 @@ class VLANGroupTable(TenancyColumnsMixin, NetBoxTable):
linkify=True,
orderable=False
)
- vid_ranges_list = tables.Column(
+ vid_ranges_list = columns.ArrayColumn(
+ accessor='vid_ranges_items',
verbose_name=_('VID Ranges'),
orderable=False
)
diff --git a/netbox/templates/ipam/vlangroup.html b/netbox/templates/ipam/vlangroup.html
index ecd8c9ccaa0..c734e47903d 100644
--- a/netbox/templates/ipam/vlangroup.html
+++ b/netbox/templates/ipam/vlangroup.html
@@ -40,7 +40,7 @@
{% trans "VLAN IDs" %} |
- {{ object.vid_ranges_list }} |
+ {{ object.vid_ranges_items|join:", " }} |
Utilization |
diff --git a/netbox/utilities/data.py b/netbox/utilities/data.py
index 1e774619774..617a31cd6e6 100644
--- a/netbox/utilities/data.py
+++ b/netbox/utilities/data.py
@@ -1,7 +1,8 @@
import decimal
-from django.db.backends.postgresql.psycopg_any import NumericRange
from itertools import count, groupby
+from django.db.backends.postgresql.psycopg_any import NumericRange
+
__all__ = (
'array_to_ranges',
'array_to_string',
@@ -10,6 +11,7 @@
'drange',
'flatten_dict',
'ranges_to_string',
+ 'ranges_to_string_list',
'shallow_compare_dict',
'string_to_ranges',
)
@@ -73,8 +75,10 @@ def shallow_compare_dict(source_dict, destination_dict, exclude=tuple()):
def array_to_ranges(array):
"""
Convert an arbitrary array of integers to a list of consecutive values. Nonconsecutive values are returned as
- single-item tuples. For example:
- [0, 1, 2, 10, 14, 15, 16] => [(0, 2), (10,), (14, 16)]"
+ single-item tuples.
+
+ Example:
+ [0, 1, 2, 10, 14, 15, 16] => [(0, 2), (10,), (14, 16)]
"""
group = (
list(x) for _, x in groupby(sorted(array), lambda x, c=count(): next(c) - x)
@@ -87,7 +91,8 @@ def array_to_ranges(array):
def array_to_string(array):
"""
Generate an efficient, human-friendly string from a set of integers. Intended for use with ArrayField.
- For example:
+
+ Example:
[0, 1, 2, 10, 14, 15, 16] => "0-2, 10, 14-16"
"""
ret = []
@@ -135,6 +140,29 @@ def check_ranges_overlap(ranges):
return False
+def ranges_to_string_list(ranges):
+ """
+ Convert numeric ranges to a list of display strings.
+
+ Each range is rendered as "lower-upper" or "lower" (for singletons).
+ Bounds are normalized to inclusive values using ``lower_inc``/``upper_inc``.
+ This underpins ``ranges_to_string()``, which joins the result with commas.
+
+ Example:
+ [NumericRange(1, 6), NumericRange(8, 9), NumericRange(10, 13)] => ["1-5", "8", "10-12"]
+ """
+ if not ranges:
+ return []
+
+ output: list[str] = []
+ for r in ranges:
+ # Compute inclusive bounds regardless of how the DB range is stored.
+ 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}" if lower != upper else str(lower))
+ return output
+
+
def ranges_to_string(ranges):
"""
Converts a list of ranges into a string representation.
@@ -151,12 +179,7 @@ def ranges_to_string(ranges):
"""
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}" if lower != upper else str(lower))
- return ','.join(output)
+ return ','.join(ranges_to_string_list(ranges))
def string_to_ranges(value):
diff --git a/netbox/utilities/tests/test_data.py b/netbox/utilities/tests/test_data.py
index 5d211c7bd8c..4e0942e2ac5 100644
--- a/netbox/utilities/tests/test_data.py
+++ b/netbox/utilities/tests/test_data.py
@@ -1,7 +1,11 @@
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
+from utilities.data import (
+ check_ranges_overlap,
+ ranges_to_string,
+ ranges_to_string_list,
+ string_to_ranges,
+)
class RangeFunctionsTestCase(TestCase):
@@ -47,14 +51,26 @@ def test_check_ranges_overlap(self):
])
)
+ def test_ranges_to_string_list(self):
+ self.assertEqual(
+ ranges_to_string_list([
+ NumericRange(10, 20), # 10-19
+ NumericRange(30, 40), # 30-39
+ NumericRange(50, 51), # 50-50
+ NumericRange(100, 200), # 100-199
+ ]),
+ ['10-19', '30-39', '50', '100-199']
+ )
+
def test_ranges_to_string(self):
self.assertEqual(
ranges_to_string([
NumericRange(10, 20), # 10-19
NumericRange(30, 40), # 30-39
+ NumericRange(50, 51), # 50-50
NumericRange(100, 200), # 100-199
]),
- '10-19,30-39,100-199'
+ '10-19,30-39,50,100-199'
)
def test_string_to_ranges(self):