feat: tidy topology layout, symbols for all device roles, drop draw.io

Topology:
- Orthogonal routing: each parent drops to its own bus lane in the gap
  below it (interval coloring prevents overlaps), color-coded per
  parent; parallel links drawn thicker. Adjacent same-row links are
  straight, other same-row links use a lane above the row.
- Port labels sit at the target device, stacked per uplink, instead of
  piling up at line midpoints.
- Crossing reduction via repeated down/up barycenter sweeps; rows wrap
  at 12 devices.
- Wide diagrams scale to page width in HTML/PDF instead of being cut off.

Symbols:
- New built-in symbols: modem/NTU (RAD), radio link, PoE injector, NAS,
  tape library, UPS/ATS and PBX.
- Detection checks the device role first, then model/manufacturer, and
  covers the common typos "Acces Point", "Controler" and "Libary".
  Rooms and cable management are hidden; UPS devices are now shown.

Removed the draw.io export format.

Bump version to 0.5.0.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-09-23 14:55:06 +02:00
co-authored by Claude Opus 5.5
parent e477aa9b49
commit 062413a2cd
9 changed files with 362 additions and 188 deletions
+1 -1
View File
@@ -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"
+1 -1
View File
@@ -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):
+115 -21
View File
@@ -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,
}
@@ -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; } }
@@ -42,12 +42,11 @@
{% for value, label in form.format.field.choices %}
<input type="radio" class="btn-check" name="format" id="fmt-{{ value }}" value="{{ value }}" autocomplete="off"{% if form.format.value == value %} checked{% endif %}>
<label class="btn btn-outline-primary" for="fmt-{{ value }}">
{% if value == 'pdf' %}<i class="mdi mdi-file-pdf-box"></i>{% elif value == 'docx' %}<i class="mdi mdi-file-word-box"></i>{% elif value == 'drawio' %}<i class="mdi mdi-sitemap"></i>{% else %}<i class="mdi mdi-web"></i>{% endif %}
{% if value == 'pdf' %}<i class="mdi mdi-file-pdf-box"></i>{% elif value == 'docx' %}<i class="mdi mdi-file-word-box"></i>{% else %}<i class="mdi mdi-web"></i>{% endif %}
{{ label }}
</label>
{% endfor %}
</div>
<div class="form-text mt-n2 mb-3" id="ce-drawio-hint" hidden>draw.io exportiert nur die Topologie als bearbeitbares Diagramm (Optionen unten bei „Topologie“).</div>
<div class="mb-3"><label class="form-label" for="{{ form.title.id_for_label }}">Titel</label>{{ form.title }}</div>
<div class="mb-3"><label class="form-label" for="{{ form.subtitle.id_for_label }}">Untertitel</label>{{ form.subtitle }}</div>
<div class="mb-3"><label class="form-label" for="{{ form.author.id_for_label }}">Erstellt von</label>{{ form.author }}<div class="form-text">Leer = dein Benutzername</div></div>
@@ -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();
+230 -134
View File
@@ -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 = ['<mxCell id="0"/>', '<mxCell id="1" parent="0"/>']
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"<br><font style=\"font-size:10px\" color=\"#666666\">{escape(s)}</font>" 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'<mxCell id="{n.id}" value={quoteattr(label)} style={quoteattr(style)} vertex="1" parent="1">'
f'<mxGeometry x="{n.x - 32:.0f}" y="{n.y - 32:.0f}" width="64" height="64" as="geometry"/></mxCell>'
)
for i, e in enumerate(g.edges):
dashed = g.nodes[e.a].external or g.nodes[e.b].external
label = "<br>".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'<mxCell id="e{i}" value={quoteattr(label)} style={quoteattr(style)} edge="1" parent="1" source="{e.a}" target="{e.b}">'
'<mxGeometry relative="1" as="geometry"/></mxCell>'
)
pages.append(
f'<diagram id="page{gi}" name={quoteattr(g.title)}><mxGraphModel dx="1200" dy="800" grid="1" gridSize="10" '
f'guides="1" tooltips="1" connect="1" arrows="1" fold="1" page="0" pageScale="1" math="0" shadow="0">'
f'<root>{"".join(cells)}</root></mxGraphModel></diagram>'
)
return f'<mxfile host="netbox-customer-export">{"".join(pages)}</mxfile>'.encode("utf-8")
__all__ = ["build_graphs", "render_png", "render_drawio", "SIZE"]
-16
View File
@@ -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)