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
+10 -5
View File
@@ -19,7 +19,7 @@ Alle Abfragen laufen über die NetBox-Berechtigungen (`.restrict(user, "view")`)
* Button **„Kundendoku“** auf Standort-, Lokations-, Mandanten- und Mandantengruppen-Seiten * Button **„Kundendoku“** auf Standort-, Lokations-, Mandanten- und Mandantengruppen-Seiten
* oder Menü **Plugins → Kundendokumentation** * 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 ## 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. * 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. * 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). * Provider-Leitungen erscheinen als Wolke, Geräte anderer Standorte gestrichelt (abschaltbar).
* Beschriftung wählbar: Management-IP, Modell, Ports an den Verbindungen. * 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): **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) 1. Zuordnung je **Gerätetyp**: eingebautes Symbol wählen oder **eigenes Icon hochladen** (PNG/JPG, idealerweise transparent)
2. Zuordnung je **Geräterolle** 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/`. 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. Berechtigungen: Objekttyp *Customer Export | Topologie-Symbol*, analog zu den Branding-Profilen.
+1 -1
View File
@@ -5,7 +5,7 @@ class CustomerExportConfig(PluginConfig):
name = "netbox_customer_export" name = "netbox_customer_export"
verbose_name = "Kunden-Export" verbose_name = "Kunden-Export"
description = "Kundendokumentation als PDF oder Word exportieren" description = "Kundendokumentation als PDF oder Word exportieren"
version = "0.4.0" version = "0.5.0"
author = "Claude" author = "Claude"
base_url = "customer-export" base_url = "customer-export"
min_version = "4.6.8" 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 .sections import DEFAULT_DEVICE_COLUMNS, DEVICE_COLUMNS, SECTIONS
from .symbols import KIND_CHOICES 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): class ScopeSelectForm(forms.Form):
+114 -20
View File
@@ -8,16 +8,23 @@ from io import BytesIO
KINDS = OrderedDict( KINDS = OrderedDict(
[ [
("cloud", ("Internet / Provider", 0)), ("cloud", ("Internet / Provider", 0)),
("modem", ("Modem / NTU / Medienkonverter", 1)),
("firewall", ("Firewall", 1)), ("firewall", ("Firewall", 1)),
("router", ("Router", 1)), ("router", ("Router", 1)),
("l3switch", ("Layer-3-Switch / Core", 2)), ("l3switch", ("Layer-3-Switch / Core", 2)),
("switch", ("Switch", 3)), ("switch", ("Switch", 3)),
("radio", ("Funkstrecke / Richtfunk", 3)),
("wlc", ("WLAN-Controller", 3)), ("wlc", ("WLAN-Controller", 3)),
("poe", ("PoE-Injektor", 4)),
("ap", ("Access Point", 4)), ("ap", ("Access Point", 4)),
("server", ("Server", 4)), ("server", ("Server", 4)),
("storage", ("Storage / NAS", 4)), ("storage", ("Storage / SAN (MSA)", 4)),
("pc", ("PC / Client", 4)), ("nas", ("NAS", 4)),
("tape", ("Bandlaufwerk / Tape Library", 4)),
("ups", ("USV / ATS", 4)),
("pbx", ("Telefonanlage", 4)),
("phone", ("IP-Telefon", 4)), ("phone", ("IP-Telefon", 4)),
("pc", ("PC / Client", 4)),
("printer", ("Drucker", 4)), ("printer", ("Drucker", 4)),
("camera", ("Kamera", 4)), ("camera", ("Kamera", 4)),
("generic", ("Sonstiges Gerät", 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")] 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. # Reihenfolge wichtig: erster Treffer gewinnt. None = passiv, nicht zeichnen.
# Tippvarianten („Acces Point“, „Controler“, „Libary“) sind absichtlich mit abgedeckt.
_RULES = [ _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"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"router|\brtr\b|gateway|\bgw\b|lancom|draytek|fritz|isr\d|asr\d|mikrotik", "router"),
(r"controller|\bwlc\b|mobility", "wlc"), (r"control+er|\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"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"\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"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"tape|band|\blto\b|libr?ary|storeever|\bmsl\d", "tape"),
(r"server|\besx|hyper-?v|\bhost\b|proliant|poweredge|\bsrv\b|\bvm-?host", "server"), (r"\bnas\b|diskstation|synology|qnap|terastation|readynas|\btvs-|\bds\d{3,4}", "nas"),
(r"telefon|phone|\bvoip\b|yealink|snom|gigaset", "phone"), (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"drucker|printer|\bmfp\b|kyocera|brother|ricoh", "printer"),
(r"kamera|camera|\bcam\b|axis|hikvision|dahua", "camera"), (r"kamera|camera|\bcam\b|axis|hikvision|dahua", "camera"),
(r"\bpc\b|client|workstation|laptop|notebook|thin ?client|desktop", "pc"), (r"\bpc\b|client|workstation|laptop|notebook|thin ?client|desktop", "pc"),
(r"sonstig|other|misc", "generic"),
] ]
def guess_kind(device): def _match(text):
"""Symbol aus Rolle, Gerätetyp und Hersteller raten. None = passives Gerät (nicht zeichnen).""" """(True, kind) beim ersten Treffer, sonst (False, None)."""
dt = device.device_type text = text.lower()
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()
for pattern, kind in _RULES: for pattern, kind in _RULES:
if re.search(pattern, text): if re.search(pattern, text):
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 return kind
return "generic" dt = device.device_type
found, kind = _match(f"{dt.model} {dt.slug} {getattr(dt.manufacturer, 'name', '')}")
return kind if found else "generic"
# --- Zeichnen ----------------------------------------------------------------- # --- Zeichnen -----------------------------------------------------------------
@@ -210,6 +229,74 @@ def _draw_generic(d):
d.rectangle([26 + i * 18, 76, 38 + i * 18, 86], fill=(200, 208, 216)) 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 = { _DRAWERS = {
"router": _draw_router, "router": _draw_router,
"switch": _draw_switch, "switch": _draw_switch,
@@ -225,6 +312,13 @@ _DRAWERS = {
"camera": _draw_camera, "camera": _draw_camera,
"cloud": _draw_cloud, "cloud": _draw_cloud,
"generic": _draw_generic, "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 { display: flex; gap: 12mm; justify-content: center; page-break-inside: avoid; }
.images figure { margin: 0; text-align: center; } .images figure { margin: 0; text-align: center; }
.images img { max-height: 190mm; max-width: 80mm; } .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; } figcaption { font-style: italic; color: #555; }
.footer { position: running(footer); font-size: 7pt; color: #777; } .footer { position: running(footer); font-size: 7pt; color: #777; }
@media screen { .footer { display: none; } } @media screen { .footer { display: none; } }
@@ -42,12 +42,11 @@
{% for value, label in form.format.field.choices %} {% 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 %}> <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 }}"> <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 }}
</label> </label>
{% endfor %} {% endfor %}
</div> </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.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.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> <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 // Detailkarten ausgrauen, wenn der zugehörige Abschnitt abgewählt ist
function sync() { function sync() {
var fmt = document.querySelector('input[name=format]:checked');
var drawio = fmt && fmt.value === 'drawio';
document.querySelectorAll('[data-ce-depends]').forEach(function (card) { document.querySelectorAll('[data-ce-depends]').forEach(function (card) {
var cb = document.querySelector('input[name=sections][value="' + card.dataset.ceDepends + '"]'); var cb = document.querySelector('input[name=sections][value="' + card.dataset.ceDepends + '"]');
// draw.io nutzt nur die Topologie-Optionen card.classList.toggle('ce-dim', !!cb && !cb.checked);
var active = drawio ? card.dataset.ceDepends === 'topology' : (!cb || cb.checked);
card.classList.toggle('ce-dim', !active);
}); });
document.getElementById('ce-drawio-hint').hidden = !drawio;
} }
document.addEventListener('change', sync); document.addEventListener('change', sync);
sync(); sync();
+229 -133
View File
@@ -1,13 +1,11 @@
"""Netzwerk-Topologie: Graph aus den Kabelverbindungen, Ebenen-Layout, PNG- und draw.io-Ausgabe.""" """Netzwerk-Topologie: Graph aus den Kabelverbindungen, Ebenen-Layout mit rechtwinkligen Leitungen, PNG-Ausgabe."""
import base64
from collections import OrderedDict, defaultdict, deque from collections import OrderedDict, defaultdict, deque
from dataclasses import dataclass, field from dataclasses import dataclass, field
from io import BytesIO 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 @dataclass
@@ -18,9 +16,9 @@ class Node:
icon: bytes icon: bytes
sub: list = field(default_factory=list) sub: list = field(default_factory=list)
external: bool = False # Gerät eines anderen Standorts / Provider external: bool = False # Gerät eines anderen Standorts / Provider
layer: int = 0 row: int = 0 # Zeile im Diagramm (nach Umbruch)
x: float = 0 x: float = 0 # Mitte, in Layout-Pixeln
y: float = 0 y: float = 0 # Oberkante des Symbols, wird beim Zeichnen gesetzt
@dataclass @dataclass
@@ -148,8 +146,23 @@ def build_graphs(scope, opts):
# --- Layout ------------------------------------------------------------------- # --- 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): 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) adj = defaultdict(set)
for e in edges: for e in edges:
adj[e.a].add(e.b) 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 layer = {n: None for n in nodes if not adj[n]} # isolierte Knoten: unterste Zeile
remaining = set(nodes) - set(layer) remaining = set(nodes) - set(layer)
while remaining: while remaining:
# jede Zusammenhangskomponente ab ihren am höchsten eingestuften Knoten
top = min(tier[n] for n in remaining) top = min(tier[n] for n in remaining)
roots = [n for n in nodes if n in remaining and tier[n] == top]
queue = deque() queue = deque()
for r in roots: for r in [n for n in nodes if n in remaining and tier[n] == top]:
layer[r] = tier[r] if tier[r] <= 1 else 0 layer[r] = 0
queue.append(r) queue.append(r)
while queue: while queue:
cur = queue.popleft() cur = queue.popleft()
remaining.discard(cur) remaining.discard(cur)
for nb in adj[cur]: for nb in adj[cur]:
if nb not in layer: if nb not in layer:
layer[nb] = layer[cur] + 1 # Ebene folgt der Verkabelung layer[nb] = layer[cur] + 1
queue.append(nb) queue.append(nb)
used = sorted({v for v in layer.values() if v is not None}) used = sorted({v for v in layer.values() if v is not None})
rank = {v: i for i, v in enumerate(used)} rank = {v: i for i, v in enumerate(used)}
bottom = len(used) order = defaultdict(list)
rows = defaultdict(list)
for nid in nodes: 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) def centered(r):
pos = {} row = order[r]
for r in sorted(rows): return {n: i - (len(row) - 1) / 2 for i, n in enumerate(row)}
row = rows[r]
if r > 0: def sweep(r, ref):
def bary(n): 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] ps = [pos[p] for p in adj[n] if p in pos]
return sum(ps) / len(ps) if ps else 1e9 return sum(ps) / len(ps) if ps else cur[n]
row.sort(key=bary)
for i, n in enumerate(row):
pos[n] = i
# Zeilen mit zu vielen Knoten umbrechen, dann Koordinaten order[r].sort(key=key)
CELL_W, CELL_H = 200, 190
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 = [] lines = []
for r in sorted(rows): for r in rows:
row = rows[r] row = order[r]
for i in range(0, len(row), MAX_PER_ROW): for i in range(0, len(row), MAX_PER_ROW):
lines.append(row[i : i + MAX_PER_ROW]) lines.append(row[i : i + MAX_PER_ROW])
width = max(len(line) for line in lines) * CELL_W width = max(len(line) for line in lines) * CELL_W
for li, line in enumerate(lines): for li, line in enumerate(lines):
offset = (width - len(line) * CELL_W) / 2 offset = (width - len(line) * CELL_W) / 2
for i, nid in enumerate(line): for i, nid in enumerate(line):
nodes[nid].row = li
nodes[nid].x = offset + i * CELL_W + CELL_W / 2 nodes[nid].x = offset + i * CELL_W + CELL_W / 2
nodes[nid].y = li * CELL_H + 70
def _oriented(graph, e): 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] 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 e.labels
return [" – ".join(reversed(x.split(" – "))) for x in 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 ---------------------------------------------------------------------- # --- 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): def _font(size, bold=False):
from PIL import ImageFont from PIL import ImageFont
@@ -233,64 +266,177 @@ def _font(size, bold=False):
return ImageFont.load_default() 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): def render_png(graph, show_ports=False, scale=2):
from PIL import Image, ImageDraw from PIL import Image, ImageDraw
icon = 64 nodes = graph.nodes
nodes = graph.nodes.values() down, same, direct, gaps, width, height = _plan(graph, show_ports)
w = int((max(n.x for n in nodes) + 100) * scale) S = scale
h = int((max(n.y for n in nodes) + 110) * scale) img = Image.new("RGB", (int(width * S), int(height * S)), (255, 255, 255))
img = Image.new("RGB", (w, h), (255, 255, 255))
d = ImageDraw.Draw(img) 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): def line(points, color, w=2, dashed=False):
return n.x * scale, n.y * scale pts = [(x * S, y * S) for x, y in points]
if not dashed:
def anchors(a, b): d.line(pts, fill=color, width=w * S, joint="curve")
"""Linie unterhalb der Beschriftung des oberen Knotens beginnen, oben am unteren Symbol enden.""" return
if a.y > b.y: for (x1, y1), (x2, y2) in zip(pts, pts[1:]):
a, b = b, a length = max(abs(x2 - x1), abs(y2 - y1))
if a.y == b.y: steps = max(int(length / (6 * S)), 1)
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
for i in range(0, steps, 2): for i in range(0, steps, 2):
t1, t2 = i / steps, min((i + 1) / steps, 1) 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) d.line(
else: [(x1 + (x2 - x1) * t1, y1 + (y2 - y1) * t1), (x1 + (x2 - x1) * t2, y1 + (y2 - y1) * t2)],
d.line([(x1, y1), (x2, y2)], fill=color, width=2 * scale) fill=color, width=w * S,
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")
for n in nodes: def tag(text, x, y, anchor):
x, y = c(n) text = _clip(d, text, f_port, (CELL_W - 12) * S)
pic = Image.open(BytesIO(n.icon)).convert("RGBA").resize((icon * scale, icon * scale), Image.LANCZOS) 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: if n.external:
alpha = pic.getchannel("A").point(lambda v: int(v * 0.55)) pic.putalpha(pic.getchannel("A").point(lambda v: int(v * 0.5)))
pic.putalpha(alpha) img.paste(pic, (int((n.x - ICON / 2) * S), int(n.y * S)), pic)
img.paste(pic, (int(x - icon * scale / 2), int(y - icon * scale / 2)), pic) ty = (n.y + ICON + 3) * S
ty = y + icon * scale / 2 + 4 * scale d.text(
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") (n.x * S, ty), _clip(d, n.label, f_name, (CELL_W - 8) * S),
for i, line in enumerate(n.sub): fill=(120, 120, 120) if n.external else (20, 20, 20), font=f_name, anchor="ma",
d.text((x, ty + (16 + i * 13) * scale), _clip(d, line, f_sub, 190 * scale), fill=(100, 100, 100), font=f_sub, 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() out = BytesIO()
img.save(out, "PNG", optimize=True) 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: while text and d.textlength(text + "…", font=font) > width:
text = text[:-1] text = text[:-1]
return text + "…" 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): 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" template_name = "netbox_customer_export/export.html"
def _scope(self, request, kind, pk): def _scope(self, request, kind, pk):
@@ -68,8 +54,6 @@ class ExportView(LoginRequiredMixin, View):
return self._render_form(request, scope, form) return self._render_form(request, scope, form)
opts = form.cleaned_data opts = form.cleaned_data
if opts["format"] == "drawio":
return self._drawio(request, scope, form, opts)
blocks = [] blocks = []
for key in opts["sections"]: for key in opts["sections"]:
blocks += SECTIONS[key].func(scope, opts) blocks += SECTIONS[key].func(scope, opts)
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project] [project]
name = "netbox-customer-export" 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" description = "NetBox-Plugin: Kundendokumentation (PDF/Word) für Standorte, Lokationen, Mandanten und Mandantengruppen"
readme = "README.md" readme = "README.md"
requires-python = ">=3.10" requires-python = ">=3.10"