diff --git a/README.md b/README.md
index 354b68c..f5d7351 100644
--- a/README.md
+++ b/README.md
@@ -19,7 +19,7 @@ Alle Abfragen laufen über die NetBox-Berechtigungen (`.restrict(user, "view")`)
* Button **„Kundendoku“** auf Standort-, Lokations-, Mandanten- und Mandantengruppen-Seiten
* oder Menü **Plugins → Kundendokumentation**
-Formate: **PDF**, **Word**, **HTML-Vorschau** (öffnet in neuem Tab) und **draw.io** (nur Topologie, editierbar).
+Formate: **PDF**, **Word** und **HTML-Vorschau** (öffnet in neuem Tab).
## Installation
@@ -216,18 +216,23 @@ Der Abschnitt **Netzwerk-Topologie** zeichnet je Standort ein Diagramm mit Symbo
* Verbindungen stammen aus den **Kabelpfaden** in NetBox. Pfade über Patchpanels werden bis zum Endgerät verfolgt.
* Die Anordnung von oben nach unten ergibt sich aus der Verkabelung: Provider → Firewall/Router → Core → Access → Endgeräte.
- Mehrfach-Uplinks werden als „2ד markiert.
+ Die Reihenfolge innerhalb einer Ebene wird so optimiert, dass sich möglichst wenige Leitungen kreuzen.
+* Leitungen werden **rechtwinklig** über eigene Sammelschienen geführt, farbig je übergeordnetem Gerät. Portnamen stehen
+ direkt am Zielgerät, Mehrfach-Uplinks werden dicker gezeichnet und als „2ד markiert.
* Provider-Leitungen erscheinen als Wolke, Geräte anderer Standorte gestrichelt (abschaltbar).
* Beschriftung wählbar: Management-IP, Modell, Ports an den Verbindungen.
-* Mit dem Format **draw.io** wird die Topologie als editierbare `.drawio`-Datei exportiert, eine Seite je Standort.
**Symbole** (Menü **Plugins → Topologie-Symbole**, oder Button „Topologie-Symbol“ auf Gerätetyp- und Geräterollen-Seiten):
1. Zuordnung je **Gerätetyp**: eingebautes Symbol wählen oder **eigenes Icon hochladen** (PNG/JPG, idealerweise transparent)
2. Zuordnung je **Geräterolle**
-3. sonst **automatisch** anhand von Rolle, Modell und Hersteller (z.B. „Switch“, „Sophos“, „Access Point“, „Core“)
+3. sonst **automatisch**, zuerst anhand der Rolle, dann anhand von Modell und Hersteller
-Patchpanels, Steckdosenleisten, PDUs und USVs werden automatisch ausgeblendet. Über „In Topologie ausblenden“ lassen sich
+Eingebaute Symbole: Internet/Provider, Modem/NTU/RAD, Firewall, Router, Layer-3-Switch, Switch, Funkstrecke,
+WLAN-Controller, PoE-Injektor, Access Point, Server, Storage/SAN (MSA), NAS, Bandlaufwerk/Tape Library, USV/ATS,
+Telefonanlage, IP-Telefon, PC/Client, Drucker, Kamera und „Sonstiges“.
+
+Patchpanels, Steckdosen(leisten), PDUs, Kabelmanagement und Räume werden automatisch ausgeblendet. Über „In Topologie ausblenden“ lassen sich
weitere Gerätetypen oder Rollen ausblenden. Eigene Icons liegen unter `MEDIA_ROOT/customer-export/symbols/`.
Berechtigungen: Objekttyp *Customer Export | Topologie-Symbol*, analog zu den Branding-Profilen.
diff --git a/netbox_customer_export/__init__.py b/netbox_customer_export/__init__.py
index b926fad..276965b 100644
--- a/netbox_customer_export/__init__.py
+++ b/netbox_customer_export/__init__.py
@@ -5,7 +5,7 @@ class CustomerExportConfig(PluginConfig):
name = "netbox_customer_export"
verbose_name = "Kunden-Export"
description = "Kundendokumentation als PDF oder Word exportieren"
- version = "0.4.0"
+ version = "0.5.0"
author = "Claude"
base_url = "customer-export"
min_version = "4.6.8"
diff --git a/netbox_customer_export/forms.py b/netbox_customer_export/forms.py
index 3399715..1651729 100644
--- a/netbox_customer_export/forms.py
+++ b/netbox_customer_export/forms.py
@@ -9,7 +9,7 @@ from .models import BrandingProfile, TopologySymbol
from .sections import DEFAULT_DEVICE_COLUMNS, DEVICE_COLUMNS, SECTIONS
from .symbols import KIND_CHOICES
-FORMAT_CHOICES = (("pdf", "PDF"), ("docx", "Word"), ("html", "HTML-Vorschau"), ("drawio", "draw.io"))
+FORMAT_CHOICES = (("pdf", "PDF"), ("docx", "Word"), ("html", "HTML-Vorschau"))
class ScopeSelectForm(forms.Form):
diff --git a/netbox_customer_export/symbols.py b/netbox_customer_export/symbols.py
index 4617f89..41e1c06 100644
--- a/netbox_customer_export/symbols.py
+++ b/netbox_customer_export/symbols.py
@@ -8,16 +8,23 @@ from io import BytesIO
KINDS = OrderedDict(
[
("cloud", ("Internet / Provider", 0)),
+ ("modem", ("Modem / NTU / Medienkonverter", 1)),
("firewall", ("Firewall", 1)),
("router", ("Router", 1)),
("l3switch", ("Layer-3-Switch / Core", 2)),
("switch", ("Switch", 3)),
+ ("radio", ("Funkstrecke / Richtfunk", 3)),
("wlc", ("WLAN-Controller", 3)),
+ ("poe", ("PoE-Injektor", 4)),
("ap", ("Access Point", 4)),
("server", ("Server", 4)),
- ("storage", ("Storage / NAS", 4)),
- ("pc", ("PC / Client", 4)),
+ ("storage", ("Storage / SAN (MSA)", 4)),
+ ("nas", ("NAS", 4)),
+ ("tape", ("Bandlaufwerk / Tape Library", 4)),
+ ("ups", ("USV / ATS", 4)),
+ ("pbx", ("Telefonanlage", 4)),
("phone", ("IP-Telefon", 4)),
+ ("pc", ("PC / Client", 4)),
("printer", ("Drucker", 4)),
("camera", ("Kamera", 4)),
("generic", ("Sonstiges Gerät", 4)),
@@ -27,38 +34,50 @@ HIDDEN = "hidden"
KIND_CHOICES = [(k, v[0]) for k, v in KINDS.items() if k != "cloud"] + [(HIDDEN, "In Topologie ausblenden")]
# Reihenfolge wichtig: erster Treffer gewinnt. None = passiv, nicht zeichnen.
+# Tippvarianten („Acces Point“, „Controler“, „Libary“) sind absichtlich mit abgedeckt.
_RULES = [
- (r"patch|panel|pdu|steckdose|power|strom|kabel|cable|blank|blind|shelf|fachboden|ups|usv|rangier", None),
+ (r"patch|panel|\bpdu\b|steckdose|kabel|cable|blank|blind|shelf|fachboden|rangier|\braum\b|\broom\b|halterung|\bmount", None),
+ (r"\busv\b|\bups\b|\bats\b|smart-?ups|eaton|\bapc\b|online usv", "ups"),
+ (r"poe|injector|injektor", "poe"),
+ (r"funk|richtfunk|\bbridge\b|airmax|nanobeam|litebeam|powerbeam|\bptp\b|\bptmp\b|airfiber|siklu", "radio"),
+ (r"\brad\b|modem|\bntu\b|\bnt\b|\bont\b|media ?conver|medienkonverter|konverter|\betx-", "modem"),
(r"firewall|\bfw\b|fw-|sophos|fortigate|fortinet|palo ?alto|\butm\b|\bxgs\b|\bxg\b|watchguard|checkpoint|opnsense|pfsense", "firewall"),
(r"router|\brtr\b|gateway|\bgw\b|lancom|draytek|fritz|isr\d|asr\d|mikrotik", "router"),
- (r"controller|\bwlc\b|mobility", "wlc"),
- (r"access ?point|accesspoint|\bap\b|\bap-|-ap\b|\bwap\b|wlan|wifi|aironet|unifi ?ap|\biap\b|instant on ap", "ap"),
+ (r"control+er|\bwlc\b|mobility", "wlc"),
+ (r"acc?ess? ?point|\bap\b|\bap-|-ap\b|\bwap\b|wlan|wifi|aironet|unifi ?ap|\biap\b", "ap"),
(r"\bcore\b|\bl3\b|layer ?3|distribution|\bdist\b|backbone", "l3switch"),
(r"switch|\bsw\b|\bsw-|-sw\b|catalyst|aruba \d{4}|procurve|\bcbs\d|\bsg\d", "switch"),
- (r"storage|\bnas\b|\bsan\b|synology|qnap|netapp", "storage"),
- (r"server|\besx|hyper-?v|\bhost\b|proliant|poweredge|\bsrv\b|\bvm-?host", "server"),
- (r"telefon|phone|\bvoip\b|yealink|snom|gigaset", "phone"),
+ (r"tape|band|\blto\b|libr?ary|storeever|\bmsl\d", "tape"),
+ (r"\bnas\b|diskstation|synology|qnap|terastation|readynas|\btvs-|\bds\d{3,4}", "nas"),
+ (r"storage|\bsan\b|\bmsa\b|netapp|powervault|eternus|\bme\d{4}", "storage"),
+ (r"server|\besx|hyper-?v|\bhost\b|proliant|poweredge|primergy|\bsrv\b|\bvm-?host", "server"),
+ (r"telefonanlage|\bpbx\b|\btk-?anlage|starface|3cx|innovaphone|agfeo|auerswald|unify|openscape", "pbx"),
+ (r"telefon|phone|\bvoip\b|yealink|snom|gigaset|grandstream", "phone"),
(r"drucker|printer|\bmfp\b|kyocera|brother|ricoh", "printer"),
(r"kamera|camera|\bcam\b|axis|hikvision|dahua", "camera"),
(r"\bpc\b|client|workstation|laptop|notebook|thin ?client|desktop", "pc"),
+ (r"sonstig|other|misc", "generic"),
]
-def guess_kind(device):
- """Symbol aus Rolle, Gerätetyp und Hersteller raten. None = passives Gerät (nicht zeichnen)."""
- dt = device.device_type
- role = device.role
- text = " ".join(
- str(x or "")
- for x in (
- getattr(role, "slug", ""), getattr(role, "name", ""),
- dt.model, dt.slug, getattr(dt.manufacturer, "name", ""),
- )
- ).lower()
+def _match(text):
+ """(True, kind) beim ersten Treffer, sonst (False, None)."""
+ text = text.lower()
for pattern, kind in _RULES:
if re.search(pattern, text):
- return kind
- return "generic"
+ return True, kind
+ return False, None
+
+
+def guess_kind(device):
+ """Symbol raten: zuerst anhand der Rolle, dann Modell/Hersteller. None = passives Gerät (nicht zeichnen)."""
+ role = device.role
+ found, kind = _match(f"{getattr(role, 'name', '')} {getattr(role, 'slug', '')}")
+ if found:
+ return kind
+ dt = device.device_type
+ found, kind = _match(f"{dt.model} {dt.slug} {getattr(dt.manufacturer, 'name', '')}")
+ return kind if found else "generic"
# --- Zeichnen -----------------------------------------------------------------
@@ -210,6 +229,74 @@ def _draw_generic(d):
d.rectangle([26 + i * 18, 76, 38 + i * 18, 86], fill=(200, 208, 216))
+def _draw_modem(d):
+ # flache Box mit LED-Reihe und Glasfaser-/Leitungsanschluss (Modem, NTU, RAD, Medienkonverter)
+ _iso_box(d, 12, 58, 100, 88, 12, (70, 84, 100), (130, 146, 162), (52, 64, 78))
+ for i, c in enumerate(((60, 200, 90), (60, 200, 90), (250, 190, 40), (60, 200, 90))):
+ d.ellipse([22 + i * 12, 68, 28 + i * 12, 74], fill=c)
+ d.rectangle([76, 66, 92, 80], fill=(30, 36, 44))
+ d.line([(84, 58), (84, 34), (112, 34)], fill=(250, 170, 30), width=4)
+
+
+def _draw_radio(d):
+ # Richtfunk-Antenne auf Mast mit Funkwellen
+ d.rectangle([58, 64, 66, 116], fill=(120, 128, 138), outline=OUTLINE)
+ d.rectangle([40, 112, 84, 118], fill=(120, 128, 138), outline=OUTLINE)
+ d.ellipse([22, 26, 70, 90], fill=(236, 240, 244), outline=OUTLINE, width=3)
+ d.ellipse([36, 44, 56, 72], fill=(200, 208, 216), outline=OUTLINE, width=2)
+ for r in (14, 24, 34):
+ d.arc([74 - r, 58 - r, 74 + r, 58 + r], start=-50, end=50, fill=BLUE, width=5)
+
+
+def _draw_poe(d):
+ # kleine Box mit Blitz
+ _iso_box(d, 24, 50, 96, 98, 12, (58, 70, 84), (120, 134, 150), (42, 52, 64))
+ d.polygon([(66, 56), (48, 78), (60, 78), (54, 94), (76, 70), (63, 70), (70, 56)], fill=(250, 200, 40), outline=(160, 110, 0))
+ d.rectangle([30, 86, 40, 92], fill=(180, 190, 200))
+ d.rectangle([80, 86, 90, 92], fill=(180, 190, 200))
+
+
+def _draw_nas(d):
+ # kompakte Box mit vier senkrechten Laufwerksschächten
+ _iso_box(d, 22, 30, 98, 108, 12, (54, 60, 70), (110, 118, 130), (38, 44, 52))
+ for i in range(4):
+ x = 30 + i * 17
+ d.rounded_rectangle([x, 40, x + 12, 96], radius=2, fill=(150, 158, 168))
+ d.ellipse([x + 4, 88, x + 8, 92], fill=(60, 200, 90))
+
+
+def _draw_tape(d):
+ # Laufwerk mit Kassettenschacht, davor eine LTO-Kassette
+ _iso_box(d, 10, 44, 102, 82, 12, (84, 98, 112), (150, 162, 176), (60, 72, 84))
+ d.rectangle([22, 56, 72, 64], fill=(24, 28, 34))
+ d.ellipse([84, 56, 92, 64], fill=(60, 200, 90))
+ d.rounded_rectangle([36, 84, 92, 118], radius=4, fill=(40, 44, 50), outline=OUTLINE, width=2)
+ d.rectangle([44, 90, 84, 100], fill=(250, 190, 40))
+ d.ellipse([52, 104, 62, 114], fill=(150, 158, 168))
+ d.ellipse([68, 104, 78, 114], fill=(150, 158, 168))
+
+
+def _draw_ups(d):
+ # Tower mit Batterie-Symbol
+ _iso_box(d, 32, 22, 88, 112, 12, (52, 58, 66), (110, 118, 128), (36, 42, 50))
+ d.rounded_rectangle([42, 36, 78, 60], radius=3, fill=(30, 36, 44))
+ d.rectangle([46, 42, 70, 54], outline=(60, 200, 90), width=2)
+ d.rectangle([70, 45, 74, 51], fill=(60, 200, 90))
+ d.rectangle([48, 44, 62, 52], fill=(60, 200, 90))
+ d.polygon([(62, 70), (50, 88), (58, 88), (54, 102), (70, 82), (62, 82), (68, 70)], fill=(250, 200, 40))
+
+
+def _draw_pbx(d):
+ # Anlage (Box) mit Hörer-Symbol
+ _iso_box(d, 12, 50, 104, 104, 12, (62, 70, 80), (126, 136, 148), (44, 52, 60))
+ d.rounded_rectangle([34, 62, 82, 92], radius=12, fill=(236, 240, 244))
+ d.rounded_rectangle([40, 70, 50, 86], radius=4, fill=(40, 44, 50))
+ d.rounded_rectangle([66, 70, 76, 86], radius=4, fill=(40, 44, 50))
+ d.rectangle([46, 70, 70, 76], fill=(40, 44, 50))
+ for i in range(3):
+ d.ellipse([88, 60 + i * 12, 96, 68 + i * 12], fill=(60, 200, 90))
+
+
_DRAWERS = {
"router": _draw_router,
"switch": _draw_switch,
@@ -225,6 +312,13 @@ _DRAWERS = {
"camera": _draw_camera,
"cloud": _draw_cloud,
"generic": _draw_generic,
+ "modem": _draw_modem,
+ "radio": _draw_radio,
+ "poe": _draw_poe,
+ "nas": _draw_nas,
+ "tape": _draw_tape,
+ "ups": _draw_ups,
+ "pbx": _draw_pbx,
}
diff --git a/netbox_customer_export/templates/netbox_customer_export/document.html b/netbox_customer_export/templates/netbox_customer_export/document.html
index f5a4869..78781e4 100644
--- a/netbox_customer_export/templates/netbox_customer_export/document.html
+++ b/netbox_customer_export/templates/netbox_customer_export/document.html
@@ -35,7 +35,8 @@
.images { display: flex; gap: 12mm; justify-content: center; page-break-inside: avoid; }
.images figure { margin: 0; text-align: center; }
.images img { max-height: 190mm; max-width: 80mm; }
- .images.wide img { max-width: 100%; max-height: 240mm; }
+ .images.wide figure { width: 100%; min-width: 0; }
+ .images.wide img { width: 100%; height: auto; max-height: 240mm; object-fit: contain; }
figcaption { font-style: italic; color: #555; }
.footer { position: running(footer); font-size: 7pt; color: #777; }
@media screen { .footer { display: none; } }
diff --git a/netbox_customer_export/templates/netbox_customer_export/export.html b/netbox_customer_export/templates/netbox_customer_export/export.html
index 4aa83bc..2cd1ce0 100644
--- a/netbox_customer_export/templates/netbox_customer_export/export.html
+++ b/netbox_customer_export/templates/netbox_customer_export/export.html
@@ -42,12 +42,11 @@
{% for value, label in form.format.field.choices %}
{% endfor %}
-
draw.io exportiert nur die Topologie als bearbeitbares Diagramm (Optionen unten bei „Topologie“).
{{ form.title }}
{{ form.subtitle }}
{{ form.author }}
Leer = dein Benutzername
@@ -156,15 +155,10 @@
});
// Detailkarten ausgrauen, wenn der zugehörige Abschnitt abgewählt ist
function sync() {
- var fmt = document.querySelector('input[name=format]:checked');
- var drawio = fmt && fmt.value === 'drawio';
document.querySelectorAll('[data-ce-depends]').forEach(function (card) {
var cb = document.querySelector('input[name=sections][value="' + card.dataset.ceDepends + '"]');
- // draw.io nutzt nur die Topologie-Optionen
- var active = drawio ? card.dataset.ceDepends === 'topology' : (!cb || cb.checked);
- card.classList.toggle('ce-dim', !active);
+ card.classList.toggle('ce-dim', !!cb && !cb.checked);
});
- document.getElementById('ce-drawio-hint').hidden = !drawio;
}
document.addEventListener('change', sync);
sync();
diff --git a/netbox_customer_export/topology.py b/netbox_customer_export/topology.py
index 40b06e4..215c281 100644
--- a/netbox_customer_export/topology.py
+++ b/netbox_customer_export/topology.py
@@ -1,13 +1,11 @@
-"""Netzwerk-Topologie: Graph aus den Kabelverbindungen, Ebenen-Layout, PNG- und draw.io-Ausgabe."""
-import base64
+"""Netzwerk-Topologie: Graph aus den Kabelverbindungen, Ebenen-Layout mit rechtwinkligen Leitungen, PNG-Ausgabe."""
from collections import OrderedDict, defaultdict, deque
from dataclasses import dataclass, field
from io import BytesIO
-from xml.sax.saxutils import escape, quoteattr
-from .symbols import KINDS, SIZE, SymbolResolver, builtin_icon
+from .symbols import KINDS, SymbolResolver, builtin_icon
-MAX_PER_ROW = 8
+MAX_PER_ROW = 12 # breitere Zeilen werden umgebrochen
@dataclass
@@ -18,9 +16,9 @@ class Node:
icon: bytes
sub: list = field(default_factory=list)
external: bool = False # Gerät eines anderen Standorts / Provider
- layer: int = 0
- x: float = 0
- y: float = 0
+ row: int = 0 # Zeile im Diagramm (nach Umbruch)
+ x: float = 0 # Mitte, in Layout-Pixeln
+ y: float = 0 # Oberkante des Symbols, wird beim Zeichnen gesetzt
@dataclass
@@ -148,8 +146,23 @@ def build_graphs(scope, opts):
# --- Layout -------------------------------------------------------------------
+CELL_W = 190 # Spaltenbreite
+ICON = 64 # Symbolgröße
+LANE = 11 # Abstand der Sammelschienen im Zwischenraum
+PORT_H = 13 # Zeilenhöhe der Port-Beschriftungen
+
+
+def _label_h(n):
+ return 20 + 13 * len(n.sub)
+
+
def _layout(nodes, edges):
- """Ebenen per Breitensuche ab den „höchsten“ Geräten (Provider, Firewall, Router …), dann Reihenfolge nach Nachbarn."""
+ """Ebenen per Breitensuche ab den „höchsten“ Geräten (Provider, Firewall, Router …).
+
+ Die Breitensuche garantiert, dass verbundene Geräte höchstens eine Ebene auseinanderliegen – so bleiben
+ alle Leitungen kurz. Danach wird die Reihenfolge in mehreren Durchläufen (abwärts/aufwärts) nach dem
+ Schwerpunkt der Nachbarn sortiert, um Kreuzungen zu minimieren.
+ """
adj = defaultdict(set)
for e in edges:
adj[e.a].add(e.b)
@@ -159,65 +172,85 @@ def _layout(nodes, edges):
layer = {n: None for n in nodes if not adj[n]} # isolierte Knoten: unterste Zeile
remaining = set(nodes) - set(layer)
while remaining:
- # jede Zusammenhangskomponente ab ihren am höchsten eingestuften Knoten
top = min(tier[n] for n in remaining)
- roots = [n for n in nodes if n in remaining and tier[n] == top]
queue = deque()
- for r in roots:
- layer[r] = tier[r] if tier[r] <= 1 else 0
+ for r in [n for n in nodes if n in remaining and tier[n] == top]:
+ layer[r] = 0
queue.append(r)
while queue:
cur = queue.popleft()
remaining.discard(cur)
for nb in adj[cur]:
if nb not in layer:
- layer[nb] = layer[cur] + 1 # Ebene folgt der Verkabelung
+ layer[nb] = layer[cur] + 1
queue.append(nb)
used = sorted({v for v in layer.values() if v is not None})
rank = {v: i for i, v in enumerate(used)}
- bottom = len(used)
- rows = defaultdict(list)
+ order = defaultdict(list)
for nid in nodes:
- rows[rank[layer[nid]] if layer[nid] is not None else bottom].append(nid)
+ order[rank[layer[nid]] if layer[nid] is not None else len(used)].append(nid)
+ rows = sorted(order)
- # Reihenfolge: Schwerpunkt der Nachbarn in der Zeile darüber (reduziert Kreuzungen)
- pos = {}
- for r in sorted(rows):
- row = rows[r]
- if r > 0:
- def bary(n):
- ps = [pos[p] for p in adj[n] if p in pos]
- return sum(ps) / len(ps) if ps else 1e9
- row.sort(key=bary)
- for i, n in enumerate(row):
- pos[n] = i
+ def centered(r):
+ row = order[r]
+ return {n: i - (len(row) - 1) / 2 for i, n in enumerate(row)}
- # Zeilen mit zu vielen Knoten umbrechen, dann Koordinaten
- CELL_W, CELL_H = 200, 190
+ def sweep(r, ref):
+ if ref not in order:
+ return
+ pos, cur = centered(ref), centered(r)
+
+ def key(n):
+ ps = [pos[p] for p in adj[n] if p in pos]
+ return sum(ps) / len(ps) if ps else cur[n]
+
+ order[r].sort(key=key)
+
+ for _ in range(4):
+ for r in rows[1:]:
+ sweep(r, r - 1)
+ for r in reversed(rows[:-1]):
+ sweep(r, r + 1)
+
+ # Zeilen umbrechen und Spalten vergeben
lines = []
- for r in sorted(rows):
- row = rows[r]
+ for r in rows:
+ row = order[r]
for i in range(0, len(row), MAX_PER_ROW):
lines.append(row[i : i + MAX_PER_ROW])
width = max(len(line) for line in lines) * CELL_W
for li, line in enumerate(lines):
offset = (width - len(line) * CELL_W) / 2
for i, nid in enumerate(line):
+ nodes[nid].row = li
nodes[nid].x = offset + i * CELL_W + CELL_W / 2
- nodes[nid].y = li * CELL_H + 70
def _oriented(graph, e):
- """Port-Beschriftungen aus Sicht des oberen Knotens („Uplink-Port – Downlink-Port“)."""
+ """Port-Beschriftungen aus Sicht des oberen (bzw. linken) Knotens: „Uplink-Port – Downlink-Port“."""
a, b = graph.nodes[e.a], graph.nodes[e.b]
- if a.y <= b.y:
+ if (a.row, a.x) <= (b.row, b.x):
return e.labels
return [" – ".join(reversed(x.split(" – "))) for x in e.labels]
+def _edge_text(graph, e, show_ports):
+ if show_ports:
+ labels = _oriented(graph, e)
+ return labels[0] + (f" (+{len(labels) - 1})" if len(labels) > 1 else "")
+ return f"{len(e.labels)}×" if len(e.labels) > 1 else None
+
+
# --- PNG ----------------------------------------------------------------------
+PALETTE = [
+ (31, 119, 180), (214, 39, 40), (44, 160, 44), (148, 103, 189), (255, 127, 14),
+ (23, 150, 170), (140, 86, 75), (188, 150, 20), (200, 80, 160), (90, 90, 90),
+]
+EXTERNAL = (160, 160, 160)
+
+
def _font(size, bold=False):
from PIL import ImageFont
@@ -233,64 +266,177 @@ def _font(size, bold=False):
return ImageFont.load_default()
+def _plan(graph, show_ports):
+ """Ordnet jede Verbindung einem Zwischenraum und einer Sammelschiene zu und berechnet die Zeilenhöhen."""
+ nodes = graph.nodes
+ rows = defaultdict(list)
+ for n in nodes.values():
+ rows[n.row].append(n)
+ n_rows = max(rows) + 1
+
+ down = defaultdict(lambda: defaultdict(list)) # Zwischenraum über Zeile r -> oberer Knoten -> [(Kind, Kante)]
+ same = defaultdict(list) # Zeile -> Kanten innerhalb der Zeile, nicht benachbart
+ direct = [] # benachbarte Knoten derselben Zeile: gerade Linie
+ for e in graph.edges:
+ a, b = nodes[e.a], nodes[e.b]
+ if a.row == b.row:
+ (direct if abs(a.x - b.x) <= CELL_W + 1 else same[a.row]).append(e)
+ else:
+ up, lo = (a, b) if a.row < b.row else (b, a)
+ down[lo.row][up.id].append((lo, e))
+
+ gaps = {}
+ for r in range(n_rows):
+ items = []
+ for uid, lst in down[r].items():
+ xs = [nodes[uid].x] + [c.x for c, _ in lst]
+ items.append((min(xs), max(xs), ("d", uid)))
+ for i, e in enumerate(same[r]):
+ xs = [nodes[e.a].x, nodes[e.b].x]
+ items.append((min(xs), max(xs), ("s", i)))
+ # Intervall-Färbung: überlappende Schienen bekommen verschiedene Höhen
+ lane_end, lane_of = [], {}
+ for xmin, xmax, key in sorted(items):
+ for li, end in enumerate(lane_end):
+ if end < xmin - 14:
+ lane_end[li], lane_of[key] = xmax, li
+ break
+ else:
+ lane_of[key] = len(lane_end)
+ lane_end.append(xmax)
+ parents = defaultdict(list)
+ for uid, lst in down[r].items():
+ for c, e in lst:
+ parents[c.id].append(uid)
+ for c in parents:
+ parents[c].sort(key=lambda uid: nodes[uid].x)
+ labelled = [e for lst in down[r].values() for _, e in lst if _edge_text(graph, e, show_ports)]
+ stack = max((len(v) for v in parents.values()), default=0) if labelled else 0
+ label_room = stack * PORT_H + 4
+ height = 16 + len(lane_end) * LANE + label_room if lane_end else (26 if r else 12)
+ gaps[r] = {"lane_of": lane_of, "parents": parents, "height": height}
+
+ y = 16
+ for r in range(n_rows):
+ gaps[r]["top"] = y + 8
+ y += gaps[r]["height"]
+ for n in rows[r]:
+ n.y = y
+ y += ICON + max(_label_h(n) for n in rows[r])
+ width = max(n.x for n in nodes.values()) + CELL_W / 2
+ return down, same, direct, gaps, width, y + 16
+
+
def render_png(graph, show_ports=False, scale=2):
from PIL import Image, ImageDraw
- icon = 64
- nodes = graph.nodes.values()
- w = int((max(n.x for n in nodes) + 100) * scale)
- h = int((max(n.y for n in nodes) + 110) * scale)
- img = Image.new("RGB", (w, h), (255, 255, 255))
+ nodes = graph.nodes
+ down, same, direct, gaps, width, height = _plan(graph, show_ports)
+ S = scale
+ img = Image.new("RGB", (int(width * S), int(height * S)), (255, 255, 255))
d = ImageDraw.Draw(img)
- f_name, f_sub, f_port = _font(12 * scale, bold=True), _font(10 * scale), _font(8 * scale)
+ f_name, f_sub, f_port = _font(12 * S, bold=True), _font(10 * S), _font(8 * S)
- def c(n):
- return n.x * scale, n.y * scale
-
- def anchors(a, b):
- """Linie unterhalb der Beschriftung des oberen Knotens beginnen, oben am unteren Symbol enden."""
- if a.y > b.y:
- a, b = b, a
- if a.y == b.y:
- return c(a), c(b)
- label_h = 22 + 13 * len(a.sub)
- return (a.x * scale, (a.y + icon / 2 + label_h) * scale), (b.x * scale, (b.y - icon / 2 - 2) * scale)
-
- for e in graph.edges:
- a, b = graph.nodes[e.a], graph.nodes[e.b]
- dashed = a.external or b.external
- color = (150, 150, 150) if dashed else (60, 60, 60)
- (x1, y1), (x2, y2) = anchors(a, b)
- if dashed:
- steps = int(max(abs(x2 - x1), abs(y2 - y1)) / (8 * scale)) or 1
+ def line(points, color, w=2, dashed=False):
+ pts = [(x * S, y * S) for x, y in points]
+ if not dashed:
+ d.line(pts, fill=color, width=w * S, joint="curve")
+ return
+ for (x1, y1), (x2, y2) in zip(pts, pts[1:]):
+ length = max(abs(x2 - x1), abs(y2 - y1))
+ steps = max(int(length / (6 * S)), 1)
for i in range(0, steps, 2):
t1, t2 = i / steps, min((i + 1) / steps, 1)
- d.line([(x1 + (x2 - x1) * t1, y1 + (y2 - y1) * t1), (x1 + (x2 - x1) * t2, y1 + (y2 - y1) * t2)], fill=color, width=2 * scale)
- else:
- d.line([(x1, y1), (x2, y2)], fill=color, width=2 * scale)
- mx, my = (x1 + x2) / 2, (y1 + y2) / 2
- text = None
- if show_ports:
- labels = _oriented(graph, e)
- text = labels[0] + (f" (+{len(labels) - 1})" if len(labels) > 1 else "")
- elif len(e.labels) > 1:
- text = f"{len(e.labels)}×"
- if text:
- tw = d.textlength(text, font=f_port)
- d.rounded_rectangle([mx - tw / 2 - 4 * scale, my - 7 * scale, mx + tw / 2 + 4 * scale, my + 7 * scale], radius=3 * scale, fill=(255, 255, 255), outline=(200, 200, 200))
- d.text((mx, my), text, fill=(40, 40, 40), font=f_port, anchor="mm")
+ d.line(
+ [(x1 + (x2 - x1) * t1, y1 + (y2 - y1) * t1), (x1 + (x2 - x1) * t2, y1 + (y2 - y1) * t2)],
+ fill=color, width=w * S,
+ )
- for n in nodes:
- x, y = c(n)
- pic = Image.open(BytesIO(n.icon)).convert("RGBA").resize((icon * scale, icon * scale), Image.LANCZOS)
+ def tag(text, x, y, anchor):
+ text = _clip(d, text, f_port, (CELL_W - 12) * S)
+ tw = d.textlength(text, font=f_port)
+ cx = x * S
+ cy = y * S - (6 * S if anchor == "mb" else 0)
+ d.rounded_rectangle(
+ [cx - tw / 2 - 3 * S, cy - 6 * S, cx + tw / 2 + 3 * S, cy + 6 * S],
+ radius=2 * S, fill=(255, 255, 255), outline=(215, 215, 215),
+ )
+ d.text((cx, cy), text, fill=(40, 40, 40), font=f_port, anchor="mm")
+
+ colors = {}
+
+ def color_for(uid):
+ if uid not in colors:
+ colors[uid] = PALETTE[len(colors) % len(PALETTE)]
+ return colors[uid]
+
+ labels = [] # erst nach allen Linien zeichnen, damit sie oben liegen
+
+ for r, groups in sorted(down.items()):
+ gap = gaps[r]
+ for uid, lst in groups.items():
+ u = nodes[uid]
+ ly = gap["top"] + gap["lane_of"][("d", uid)] * LANE
+ col = EXTERNAL if u.external else color_for(uid)
+ entries = []
+ for c, e in lst:
+ plist = gap["parents"][c.id]
+ k = plist.index(uid)
+ # mehrere Uplinks in dasselbe Gerät: leicht versetzt einführen
+ entries.append((c, e, c.x + (k - (len(plist) - 1) / 2) * 9, len(plist) - 1 - k))
+ xs = [u.x] + [ex for _, _, ex, _ in entries]
+ u_bottom = u.y + ICON + _label_h(u) - 2
+ line([(u.x, u_bottom), (u.x, ly)], col, dashed=u.external)
+ if max(xs) > min(xs):
+ line([(min(xs), ly), (max(xs), ly)], col, dashed=u.external)
+ for c, e, ex, stack_pos in entries:
+ dashed = u.external or c.external
+ line([(ex, ly), (ex, c.y - 2)], EXTERNAL if dashed else col, w=3 if len(e.labels) > 1 else 2, dashed=dashed)
+ if min(xs) < ex < max(xs): # Abzweig auf der Schiene markieren
+ d.ellipse([(ex - 2.5) * S, (ly - 2.5) * S, (ex + 2.5) * S, (ly + 2.5) * S], fill=col)
+ text = _edge_text(graph, e, show_ports)
+ if text:
+ labels.append((text, ex, c.y - 3 - stack_pos * PORT_H, "mb"))
+
+ for r, lst in same.items():
+ gap = gaps[r]
+ for i, e in enumerate(lst):
+ a, b = nodes[e.a], nodes[e.b]
+ ly = gap["top"] + gap["lane_of"][("s", i)] * LANE
+ dashed = a.external or b.external
+ line([(a.x, a.y - 2), (a.x, ly), (b.x, ly), (b.x, b.y - 2)], EXTERNAL if dashed else (70, 70, 70), dashed=dashed)
+ text = _edge_text(graph, e, show_ports)
+ if text:
+ labels.append((text, (a.x + b.x) / 2, ly, "mm"))
+
+ for e in direct:
+ a, b = nodes[e.a], nodes[e.b]
+ dashed = a.external or b.external
+ cy = a.y + ICON / 2
+ left, right = (a, b) if a.x < b.x else (b, a)
+ line(
+ [(left.x + ICON / 2 + 4, cy), (right.x - ICON / 2 - 4, cy)],
+ EXTERNAL if dashed else (70, 70, 70), w=3 if len(e.labels) > 1 else 2, dashed=dashed,
+ )
+ text = _edge_text(graph, e, show_ports)
+ if text:
+ labels.append((text, (a.x + b.x) / 2, cy - 4, "mb"))
+
+ for n in nodes.values():
+ pic = Image.open(BytesIO(n.icon)).convert("RGBA").resize((ICON * S, ICON * S), Image.LANCZOS)
if n.external:
- alpha = pic.getchannel("A").point(lambda v: int(v * 0.55))
- pic.putalpha(alpha)
- img.paste(pic, (int(x - icon * scale / 2), int(y - icon * scale / 2)), pic)
- ty = y + icon * scale / 2 + 4 * scale
- d.text((x, ty), _clip(d, n.label, f_name, 190 * scale), fill=(110, 110, 110) if n.external else (20, 20, 20), font=f_name, anchor="ma")
- for i, line in enumerate(n.sub):
- d.text((x, ty + (16 + i * 13) * scale), _clip(d, line, f_sub, 190 * scale), fill=(100, 100, 100), font=f_sub, anchor="ma")
+ pic.putalpha(pic.getchannel("A").point(lambda v: int(v * 0.5)))
+ img.paste(pic, (int((n.x - ICON / 2) * S), int(n.y * S)), pic)
+ ty = (n.y + ICON + 3) * S
+ d.text(
+ (n.x * S, ty), _clip(d, n.label, f_name, (CELL_W - 8) * S),
+ fill=(120, 120, 120) if n.external else (20, 20, 20), font=f_name, anchor="ma",
+ )
+ for i, sub in enumerate(n.sub):
+ d.text((n.x * S, ty + (16 + i * 13) * S), _clip(d, sub, f_sub, (CELL_W - 8) * S), fill=(100, 100, 100), font=f_sub, anchor="ma")
+
+ for text, x, y, anchor in labels:
+ tag(text, x, y, anchor)
out = BytesIO()
img.save(out, "PNG", optimize=True)
@@ -303,53 +449,3 @@ def _clip(d, text, font, width):
while text and d.textlength(text + "…", font=font) > width:
text = text[:-1]
return text + "…"
-
-
-# --- draw.io ------------------------------------------------------------------
-
-def _small_icon(png, px=64):
- from PIL import Image
-
- img = Image.open(BytesIO(png)).convert("RGBA").resize((px, px), Image.LANCZOS)
- out = BytesIO()
- img.save(out, "PNG", optimize=True)
- return base64.b64encode(out.getvalue()).decode("ascii")
-
-
-def render_drawio(graphs, show_ports=False):
- """draw.io-Datei (mxfile) mit einer Seite je Standort. Symbole als eingebettete Bilder."""
- icon_cache = {}
- pages = []
- for gi, g in enumerate(graphs):
- cells = ['', '']
- for n in g.nodes.values():
- key = id(n.icon)
- if key not in icon_cache:
- icon_cache[key] = _small_icon(n.icon)
- label = escape(n.label) + "".join(f"
{escape(s)}" for s in n.sub)
- style = (
- "shape=image;html=1;verticalLabelPosition=bottom;verticalAlign=top;labelBackgroundColor=none;"
- f"imageAspect=0;aspect=fixed;fontStyle=1;{'opacity=55;' if n.external else ''}"
- f"image=data:image/png,{icon_cache[key]};"
- )
- cells.append(
- f''
- f''
- )
- for i, e in enumerate(g.edges):
- dashed = g.nodes[e.a].external or g.nodes[e.b].external
- label = "
".join(escape(x) for x in _oriented(g, e)) if show_ports else (f"{len(e.labels)}×" if len(e.labels) > 1 else "")
- style = f"endArrow=none;html=1;strokeWidth=2;strokeColor={'#999999' if dashed else '#333333'};{'dashed=1;' if dashed else ''}fontSize=9;labelBackgroundColor=#ffffff;"
- cells.append(
- f''
- ''
- )
- pages.append(
- f''
- f'{"".join(cells)}'
- )
- return f'{"".join(pages)}'.encode("utf-8")
-
-
-__all__ = ["build_graphs", "render_png", "render_drawio", "SIZE"]
diff --git a/netbox_customer_export/views.py b/netbox_customer_export/views.py
index 615b3b3..8a2151b 100644
--- a/netbox_customer_export/views.py
+++ b/netbox_customer_export/views.py
@@ -28,20 +28,6 @@ class ScopeSelectView(LoginRequiredMixin, View):
class ExportView(LoginRequiredMixin, View):
- def _drawio(self, request, scope, form, opts):
- """Nur die Topologie – als editierbare draw.io-Datei (eine Seite je Standort)."""
- from .topology import build_graphs, render_drawio
-
- graphs = build_graphs(scope, opts)
- if not graphs:
- messages.warning(request, "Keine verkabelten Geräte gefunden – es gibt keine Topologie zu exportieren.")
- return self._render_form(request, scope, form)
- content = render_drawio(graphs, show_ports="ports" in (opts.get("topo_details") or []))
- response = HttpResponse(content, content_type="application/xml")
- filename = f"topologie_{slugify(str(scope.obj)) or 'export'}_{timezone.localdate():%Y%m%d}.drawio"
- response["Content-Disposition"] = f'attachment; filename="{filename}"'
- return response
-
template_name = "netbox_customer_export/export.html"
def _scope(self, request, kind, pk):
@@ -68,8 +54,6 @@ class ExportView(LoginRequiredMixin, View):
return self._render_form(request, scope, form)
opts = form.cleaned_data
- if opts["format"] == "drawio":
- return self._drawio(request, scope, form, opts)
blocks = []
for key in opts["sections"]:
blocks += SECTIONS[key].func(scope, opts)
diff --git a/pyproject.toml b/pyproject.toml
index 7095a9c..0c5fc78 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "netbox-customer-export"
-version = "0.4.0"
+version = "0.5.0"
description = "NetBox-Plugin: Kundendokumentation (PDF/Word) für Standorte, Lokationen, Mandanten und Mandantengruppen"
readme = "README.md"
requires-python = ">=3.10"