From 07d92c95012d28a1f9a502564412fffed7edd588 Mon Sep 17 00:00:00 2001 From: Jason Novinger Date: Thu, 13 Aug 2026 11:54:38 -0500 Subject: [PATCH] #20285: Collapse consecutive ports into ranges in port_mappings_list port_mappings_list rendered one token per port, so a service exposing a large contiguous range (e.g. tcp/8000-8100) filled the list column and detail panel with hundreds of tokens. Collapse consecutive ports within a protocol into a range for display, matching the compact form the pre-multi-protocol Service model rendered via array_to_string(). --- netbox/ipam/models/services.py | 23 +++++++++++++++++------ netbox/ipam/tests/test_models.py | 25 +++++++++++++++++++++++++ 2 files changed, 42 insertions(+), 6 deletions(-) diff --git a/netbox/ipam/models/services.py b/netbox/ipam/models/services.py index ebea82ea8..d881f95cb 100644 --- a/netbox/ipam/models/services.py +++ b/netbox/ipam/models/services.py @@ -7,10 +7,11 @@ from django.utils.translation import gettext_lazy as _ from ipam.choices import * from ipam.constants import * -from ipam.utils import legacy_protocol_and_ports, split_port_mapping +from ipam.utils import group_port_mappings, legacy_protocol_and_ports, split_port_mapping from ipam.validators import validate_port_mappings from netbox.models import PrimaryModel from netbox.models.features import ContactsMixin +from utilities.data import array_to_ranges __all__ = ( 'Service', @@ -93,12 +94,22 @@ class ServiceBase(models.Model): @property def port_mappings_list(self): """ - Return a user-friendly list of port mappings, e.g. "TCP/80, TCP/443, UDP/53". + Return a user-friendly list of port mappings, collapsing consecutive ports within a protocol into + a range, e.g. "TCP/80, TCP/443, UDP/53" or "TCP/8000-8100". """ - return ', '.join( - f'{SERVICE_PROTOCOL_LABELS.get(protocol, protocol)}/{port}' - for protocol, port in (split_port_mapping(mapping) for mapping in self.port_mappings) - ) + parts = [] + for protocol, ports in group_port_mappings(self.port_mappings).items(): + label = SERVICE_PROTOCOL_LABELS.get(protocol, protocol) + int_ports = [int(port) for port in ports if port.isdigit()] + for port_range in array_to_ranges(int_ports): + if len(port_range) == 1: + parts.append(f'{label}/{port_range[0]}') + else: + parts.append(f'{label}/{port_range[0]}-{port_range[1]}') + # A port that isn't a plain integer is only reachable via a write that bypassed validation; + # render it verbatim rather than raising, matching sorted_int_ports and normalize_port_mapping. + parts.extend(f'{label}/{port}' for port in ports if not port.isdigit()) + return ', '.join(parts) # Read-only legacy accessors mirroring the deprecated REST/GraphQL protocol/ports fields, retained # for backward compatibility with code that read the old single-protocol fields. A multi-protocol diff --git a/netbox/ipam/tests/test_models.py b/netbox/ipam/tests/test_models.py index 147af9b0a..7842e8b79 100644 --- a/netbox/ipam/tests/test_models.py +++ b/netbox/ipam/tests/test_models.py @@ -2058,6 +2058,31 @@ class ServiceTestCase(TestCase): ) self.assertEqual(service.port_mappings_list, 'TCP/53, UDP/53') + def test_port_mappings_list_collapses_ranges(self): + vm = VirtualMachine.objects.first() + + big = Service.objects.create( + name='big', + parent=vm, + port_mappings=[f'tcp/{port}' for port in range(8000, 8101)], + ) + self.assertEqual(big.port_mappings_list, 'TCP/8000-8100') + + mixed = Service.objects.create( + name='mixed', + parent=vm, + port_mappings=['tcp/82', 'tcp/80', 'tcp/81', 'tcp/443', 'udp/68', 'udp/67'], + ) + self.assertEqual(mixed.port_mappings_list, 'TCP/80-82, TCP/443, UDP/67-68') + + def test_port_mappings_list_tolerates_malformed_ports(self): + service = Service.objects.create( + name='malformed', + parent=VirtualMachine.objects.first(), + port_mappings=['tcp/80', 'tcp/abc'], + ) + self.assertEqual(service.port_mappings_list, 'TCP/80, TCP/abc') + def test_legacy_protocol_ports_properties(self): """The read-only protocol/ports properties expose the deprecated single-protocol representation.""" vm = VirtualMachine.objects.first()