Просмотр исходного кода

#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().
Jason Novinger 1 неделя назад
Родитель
Сommit
07d92c9501
2 измененных файлов с 42 добавлено и 6 удалено
  1. 17 6
      netbox/ipam/models/services.py
  2. 25 0
      netbox/ipam/tests/test_models.py

+ 17 - 6
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

+ 25 - 0
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()