#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().
This commit is contained in:
Jason Novinger 2026-08-13 11:54:38 -05:00
parent af59d71642
commit 07d92c9501
2 changed files with 42 additions and 6 deletions

View File

@ -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

View File

@ -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()