fix: match patchpanel ports by identifier

This commit is contained in:
2026-08-06 15:17:37 +02:00
parent 0c4a737d41
commit e74d8fba31
6 changed files with 137 additions and 17 deletions
+8 -8
View File
@@ -35,7 +35,7 @@ Release-Tag oder ein bestimmter Commit verwendet werden:
```bash
/opt/netbox/venv/bin/pip install --upgrade --force-reinstall \
"git+https://git.mrblake.cc/MrBlake/Netbox-Utilities.git@v0.7.0"
"git+https://git.mrblake.cc/MrBlake/Netbox-Utilities.git@v0.7.1"
```
Alternativ kann hinter dem `@` die vollständige Commit-ID stehen.
@@ -136,19 +136,19 @@ empfohlen.
Geräte, deren NetBox-Geräterolle `Patchpanel` heißt, werden automatisch als
Patchfeld erkannt. Groß- und Kleinschreibung spielen bei der Rollenbezeichnung
keine Rolle. Das Plugin sortiert Front- und Rearports natürlich, sodass zum
Beispiel `1, 2, 10` in dieser Reihenfolge verarbeitet wird, und verknüpft sie
anschließend 1:1 auf Portposition 1. Der erste Frontport wird mit dem ersten
Rearport verbunden, der zweite mit dem zweiten und so weiter. Überzählige Ports
bleiben unverknüpft.
keine Rolle. Das Plugin verknüpft ausschließlich Ports mit derselben Kennung
auf Portposition 1: `Front 1` mit `Rear 1`, `Front 2` mit `Rear 2` und so
weiter. Auch Kurzformen wie `F01` und `R1` werden als dieselbe Kennung erkannt.
Fehlt die passende Gegenseite, bleibt der Port unverknüpft; die nachfolgenden
Nummern rutschen nicht auf.
Die Zuordnung wird nach Änderungen am Gerät, nach dem Einbau eines Moduls und
nach dem Anlegen oder Umbenennen einzelner Front- beziehungsweise Rearports
erneut hergestellt. Dabei ersetzt die Patchpanel-Automatik abweichende
vorhandene Port-Mappings auf diesen Geräten durch die feste 1:1-Zuordnung.
Beim Update auf Version `0.7.0` führt die Plugin-Migration dieselbe Zuordnung
einmalig für alle bereits vorhandenen Geräte mit dieser Rolle aus. Vor dem
Beim Update auf Version `0.7.1` korrigiert eine Plugin-Migration die Zuordnung
automatisch für alle bereits vorhandenen Geräte mit dieser Rolle. Vor dem
Produktivupdate sollte daher geprüft werden, ob Geräte mit bewusst
abweichenden oder mehrpoligen Mappings nicht die Rolle `Patchpanel` tragen
sollen.
+1 -1
View File
@@ -1,6 +1,6 @@
from netbox.plugins import PluginConfig, get_plugin_config
__version__ = "0.7.0"
__version__ = "0.7.1"
class NetBoxUtilitiesConfig(PluginConfig):
@@ -0,0 +1,81 @@
import re
from collections import defaultdict
from django.db import migrations
def natural_port_key(port):
parts = tuple(
int(part) if part.isdigit() else part.casefold()
for part in re.split(r"(\d+)", str(port.name))
)
return parts, port.pk
def port_identifier(port):
name = str(port.name).casefold()
name = re.sub(r"\b(?:front|rear)(?:[\s_-]*port)?(?=\b|\d)", " ", name)
name = re.sub(r"^(?:fp|rp|f|r)(?=\s*[-_.:/]?\s*\d)", "", name)
tokens = re.findall(r"\d+|[^\W\d_]+", name)
identifier = tuple(int(token) if token.isdigit() else token for token in tokens)
return identifier or None
def pair_ports(front_ports, rear_ports):
front_by_identifier = defaultdict(list)
rear_by_identifier = defaultdict(list)
for port in front_ports:
if identifier := port_identifier(port):
front_by_identifier[identifier].append(port)
for port in rear_ports:
if identifier := port_identifier(port):
rear_by_identifier[identifier].append(port)
pairs = []
for identifier, matching_front_ports in front_by_identifier.items():
pairs.extend(
zip(
sorted(matching_front_ports, key=natural_port_key),
sorted(rear_by_identifier.get(identifier, []), key=natural_port_key),
)
)
return pairs
def fix_existing_patchpanel_mappings(apps, schema_editor):
Device = apps.get_model("dcim", "Device")
FrontPort = apps.get_model("dcim", "FrontPort")
PortMapping = apps.get_model("dcim", "PortMapping")
RearPort = apps.get_model("dcim", "RearPort")
database = schema_editor.connection.alias
device_ids = Device.objects.using(database).filter(role__name__iexact="Patchpanel").values_list("pk", flat=True)
for device_id in device_ids.iterator():
front_ports = FrontPort.objects.using(database).filter(device_id=device_id).only("pk", "name")
rear_ports = RearPort.objects.using(database).filter(device_id=device_id).only("pk", "name")
pairs = pair_ports(front_ports, rear_ports)
PortMapping.objects.using(database).filter(device_id=device_id).delete()
PortMapping.objects.using(database).bulk_create(
[
PortMapping(
device_id=device_id,
front_port_id=front_port.pk,
rear_port_id=rear_port.pk,
front_port_position=1,
rear_port_position=1,
)
for front_port, rear_port in pairs
],
batch_size=1000,
)
class Migration(migrations.Migration):
dependencies = [
("netbox_utilities", "0004_patchpanel_port_mappings"),
]
operations = [
migrations.RunPython(fix_existing_patchpanel_mappings, migrations.RunPython.noop),
]
+31 -2
View File
@@ -1,4 +1,5 @@
import re
from collections import defaultdict
from contextvars import ContextVar
from functools import wraps
@@ -20,14 +21,42 @@ def natural_port_key(port):
return parts, getattr(port, "pk", 0) or 0
def port_identifier(port):
"""Normalize a front/rear port name to its side-independent identifier."""
name = str(getattr(port, "name", "")).casefold()
name = re.sub(r"\b(?:front|rear)(?:[\s_-]*port)?(?=\b|\d)", " ", name)
name = re.sub(r"^(?:fp|rp|f|r)(?=\s*[-_.:/]?\s*\d)", "", name)
tokens = re.findall(r"\d+|[^\W\d_]+", name)
identifier = tuple(int(token) if token.isdigit() else token for token in tokens)
return identifier or None
def is_patchpanel(device):
role = getattr(device, "role", None)
return bool(role and str(getattr(role, "name", "")).casefold() == PATCHPANEL_ROLE_NAME.casefold())
def pair_ports(front_ports, rear_ports):
"""Pair naturally sorted front and rear ports one-to-one."""
return list(zip(sorted(front_ports, key=natural_port_key), sorted(rear_ports, key=natural_port_key)))
"""Pair only front and rear ports with the same normalized identifier."""
front_by_identifier = defaultdict(list)
rear_by_identifier = defaultdict(list)
for port in front_ports:
if identifier := port_identifier(port):
front_by_identifier[identifier].append(port)
for port in rear_ports:
if identifier := port_identifier(port):
rear_by_identifier[identifier].append(port)
pairs = []
for identifier, matching_front_ports in front_by_identifier.items():
matching_rear_ports = rear_by_identifier.get(identifier, [])
pairs.extend(
zip(
sorted(matching_front_ports, key=natural_port_key),
sorted(matching_rear_ports, key=natural_port_key),
)
)
return sorted(pairs, key=lambda pair: natural_port_key(pair[0]))
def synchronize_patchpanel(device_id):
+15 -5
View File
@@ -2,7 +2,7 @@ from types import SimpleNamespace
from django.test import SimpleTestCase
from netbox_utilities.patchpanel import is_patchpanel, pair_ports
from netbox_utilities.patchpanel import is_patchpanel, pair_ports, port_identifier
def port(pk, name):
@@ -15,8 +15,8 @@ class PatchpanelPairingTest(SimpleTestCase):
self.assertTrue(is_patchpanel(SimpleNamespace(role=SimpleNamespace(name="PATCHPANEL"))))
self.assertFalse(is_patchpanel(SimpleNamespace(role=SimpleNamespace(name="Switch"))))
def test_pairs_front_and_rear_ports_in_natural_order(self):
front_ports = [port(10, "10"), port(2, "2"), port(1, "1")]
def test_pairs_front_and_rear_ports_by_matching_number(self):
front_ports = [port(10, "Front 10"), port(2, "Front 2"), port(1, "Front 1")]
rear_ports = [port(102, "Rear 2"), port(110, "Rear 10"), port(101, "Rear 1")]
pairs = pair_ports(front_ports, rear_ports)
@@ -24,6 +24,16 @@ class PatchpanelPairingTest(SimpleTestCase):
self.assertEqual([(front.pk, rear.pk) for front, rear in pairs], [(1, 101), (2, 102), (10, 110)])
def test_leaves_unmatched_ports_without_a_mapping(self):
pairs = pair_ports([port(1, "1"), port(2, "2")], [port(101, "1")])
pairs = pair_ports([port(1, "Front 1"), port(2, "Front 2")], [port(102, "Rear 2")])
self.assertEqual([(front.pk, rear.pk) for front, rear in pairs], [(1, 101)])
self.assertEqual([(front.pk, rear.pk) for front, rear in pairs], [(2, 102)])
def test_normalizes_common_side_prefixes_and_leading_zeroes(self):
self.assertEqual(port_identifier(port(1, "F01")), (1,))
self.assertEqual(port_identifier(port(2, "Rear Port 1")), (1,))
self.assertEqual(port_identifier(port(3, "FrontPort01")), (1,))
def test_does_not_pair_different_identifiers(self):
pairs = pair_ports([port(1, "Front 1")], [port(102, "Rear 2")])
self.assertEqual(pairs, [])
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
[project]
name = "netbox-utilities"
version = "0.7.0"
version = "0.7.1"
description = "Navigation, tenant utilities, patchpanel mapping, bulk uploads, and atomic rack reordering for NetBox 4.6"
readme = "README.md"
requires-python = ">=3.12"