From dcd6aa56cd82811782c1da7a3826f335deba938e Mon Sep 17 00:00:00 2001 From: Martin Hauser Date: Thu, 9 Oct 2025 20:35:12 +0200 Subject: [PATCH] feat(utilities): Add ranges_to_string_list Introduce `ranges_to_string_list` for converting numeric ranges into a list of readable strings. Update the `vid_ranges_list` property and templates to use this method for better readability and maintainability. Add related tests to ensure functionality. Closes #20516 --- netbox/ipam/models/vlans.py | 14 +++++++-- netbox/ipam/tables/vlans.py | 3 +- netbox/templates/ipam/vlangroup.html | 2 +- netbox/utilities/data.py | 43 +++++++++++++++++++++------- netbox/utilities/tests/test_data.py | 22 ++++++++++++-- 5 files changed, 67 insertions(+), 17 deletions(-) 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 Group" %}

{% 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):