feat: network topology diagram with Packet-Tracer-style symbols

- New section "Netzwerk-Topologie": one diagram per site built from
  NetBox cable paths (traced through patch panels), layered top-down by
  cabling (provider > firewall/router > core > access > endpoints),
  barycenter ordering, "2x" for parallel links, provider circuits as
  clouds and devices outside the scope dashed. Labels: management IP,
  model, ports.
- New export format "draw.io": the topology as an editable .drawio file
  (one page per site, symbols embedded).
- 14 built-in symbols drawn with Pillow; automatic mapping from role,
  model and manufacturer, passive gear (patch panels, PDUs, UPS) hidden.
- New TopologySymbol model (migration 0002) with list/edit/delete UI,
  symbol gallery, custom icon upload and a "Topologie-Symbol" button on
  device type and device role pages.

Bump version to 0.4.0 - requires "manage.py migrate".

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-09-23 14:12:55 +02:00
co-authored by Claude Opus 5.5
parent 219a429f3f
commit e477aa9b49
22 changed files with 1147 additions and 17 deletions
+25 -2
View File
@@ -9,7 +9,7 @@ Der Techniker wählt vor dem Export aus, welche Inhalte das Dokument enthalten s
|---|---|
| Allgemein | Übersicht & Standortdaten (Adresse, Lokationsbaum, Kennzahlen), Ansprechpartner |
| Infrastruktur | Racks nach Standort → Lokation mit Rack-Ansicht (Vorder-/Rückseite, optional mit Gerätebildern wie in NetBox) und Belegungsliste, Geräte nach **frei wählbaren Geräterollen** (Server, Switche, APs, Router …) mit **frei wählbaren Spalten**, Stromversorgung, VMs |
| Netzwerk | VRFs (inkl. RTs), Präfixe nach VRF, VLANs, IP-Adressen (ausführlich), WLANs/SSIDs, Leitungen |
| Netzwerk | VRFs (inkl. RTs), Präfixe nach VRF, VLANs, IP-Adressen (ausführlich), WLANs/SSIDs, Leitungen, **Netzwerk-Topologie** |
| Verträge & Lizenzen | Lizenzen aus einem Lizenz-Plugin (Standard: `netbox-lifecycle`) |
Alle Abfragen laufen über die NetBox-Berechtigungen (`.restrict(user, "view")`). Ein Techniker exportiert also nur, was er auch sehen darf.
@@ -19,6 +19,8 @@ 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).
## Installation
Die Pfade gehen von einer Standardinstallation unter `/opt/netbox` aus.
@@ -92,7 +94,7 @@ python3 manage.py clearsessions
sudo systemctl restart netbox netbox-rq
```
`migrate` ist ab Version 0.2.0 **Pflicht**, denn es legt die Tabelle für die Branding-Profile an. Die übrigen Befehle gehören zum
`migrate` ist ab Version 0.2.0 **Pflicht**, denn es legt die Tabellen für Branding-Profile und (ab 0.4.0) Topologie-Symbole an. Die übrigen Befehle gehören zum
üblichen NetBox-Ablauf nach jeder Plugin-Installation.
## Update
@@ -208,6 +210,27 @@ Farben werden als Hex angegeben, mit oder ohne `#`. Ungültige Werte führen bei
Mit einer Word-Vorlage bestimmt die Vorlage die Überschriften-Formate. Ohne Vorlage färbt das Plugin die Überschriften selbst ein.
Bringt die Vorlage eine eigene Fußzeile mit, wird diese beibehalten.
### Netzwerk-Topologie
Der Abschnitt **Netzwerk-Topologie** zeichnet je Standort ein Diagramm mit Symbolen im Stil von Cisco Packet Tracer:
* 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.
* 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“)
Patchpanels, Steckdosenleisten, PDUs und USVs 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.
### Rack-Ansichten
Ohne Konfiguration nutzt das Plugin NetBox' eigenen `dcim.svg.RackElevationSVG`. Er läuft im selben Prozess, also ohne HTTP-Aufruf der API.
+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.3.1"
version = "0.4.0"
author = "Claude"
base_url = "customer-export"
min_version = "4.6.8"
+2 -2
View File
@@ -52,7 +52,7 @@ def KV(pairs, skip_empty=True):
return Block("kv", rows=rows)
def IMAGES(images):
def IMAGES(images, wide=False):
"""images: Liste von (svg_string oder None, caption, png_bytes_oder_None)."""
out = []
for svg, caption, png in images:
@@ -63,7 +63,7 @@ def IMAGES(images):
else:
continue
out.append({"svg": svg, "png": png, "caption": caption, "data_uri": data_uri})
return Block("images", images=out)
return Block("images", images=out, level=1 if wide else 0) # level=1: volle Seitenbreite
def PAGEBREAK():
+35 -3
View File
@@ -1,14 +1,15 @@
from django import forms
from dcim.models import DeviceRole, Location, Site
from dcim.models import DeviceRole, DeviceType, Location, Site
from netbox.plugins import get_plugin_config
from tenancy.models import Tenant, TenantGroup
from utilities.forms.fields import DynamicModelChoiceField
from .branding import get_brandings
from .models import BrandingProfile
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 (.docx)"), ("html", "HTML-Vorschau"))
FORMAT_CHOICES = (("pdf", "PDF"), ("docx", "Word"), ("html", "HTML-Vorschau"), ("drawio", "draw.io"))
class ScopeSelectForm(forms.Form):
@@ -64,6 +65,18 @@ class ExportForm(forms.Form):
rack_devices = forms.BooleanField(required=False, initial=True, label="Geräteliste je Rack")
rack_pagebreak = forms.BooleanField(required=False, initial=True, label="Seitenumbruch nach jedem Rack")
topo_details = forms.MultipleChoiceField(
choices=(("model", "Modell"), ("primary_ip", "Management-IP"), ("ports", "Ports an Verbindungen")),
initial=["primary_ip"],
required=False,
widget=forms.CheckboxSelectMultiple,
label="Beschriftung",
)
topo_external = forms.BooleanField(
required=False, initial=True, label="Provider-Leitungen & Geräte außerhalb anzeigen"
)
topo_isolated = forms.BooleanField(required=False, initial=False, label="Unverkabelte Geräte anzeigen")
include_empty = forms.BooleanField(required=False, label="Leere Abschnitte trotzdem aufführen")
include_secrets = forms.BooleanField(required=False, label="Geheimnisse (z.B. WLAN-PSK) mit exportieren")
@@ -128,3 +141,22 @@ class BrandingProfileForm(forms.ModelForm):
w.attrs.setdefault("class", "form-check-input")
else:
w.attrs.setdefault("class", "form-control")
class TopologySymbolForm(forms.ModelForm):
device_type = DynamicModelChoiceField(queryset=DeviceType.objects.all(), required=False, label="Gerätetyp")
role = DynamicModelChoiceField(queryset=DeviceRole.objects.all(), required=False, label="Geräterolle")
kind = forms.ChoiceField(
choices=[("", "Automatisch")] + KIND_CHOICES, required=False, widget=forms.RadioSelect, label="Symbol"
)
class Meta:
model = TopologySymbol
fields = ("device_type", "role", "kind", "icon")
help_texts = {
"icon": "PNG, JPG oder GIF, am besten quadratisch mit transparentem Hintergrund. Ersetzt das gewählte Symbol.",
}
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.fields["icon"].widget.attrs["class"] = "form-control"
@@ -0,0 +1,28 @@
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("dcim", "__first__"),
("netbox_customer_export", "0001_brandingprofile"),
]
operations = [
migrations.CreateModel(
name="TopologySymbol",
fields=[
("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")),
("kind", models.CharField(blank=True, max_length=20, verbose_name="Symbol")),
("icon", models.ImageField(blank=True, upload_to="customer-export/symbols/", verbose_name="Eigenes Icon")),
("last_updated", models.DateTimeField(auto_now=True)),
("device_type", models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name="+", to="dcim.devicetype", verbose_name="Gerätetyp")),
("role", models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name="+", to="dcim.devicerole", verbose_name="Geräterolle")),
],
options={
"verbose_name": "Topologie-Symbol",
"verbose_name_plural": "Topologie-Symbole",
"ordering": ("device_type__model", "role__name"),
},
),
]
+42
View File
@@ -74,3 +74,45 @@ class BrandingProfile(models.Model):
"docx_table_style": self.docx_table_style,
"extra_css": self.extra_css,
}
class TopologySymbol(models.Model):
"""Topologie-Symbol für einen Gerätetyp oder eine Geräterolle (Gerätetyp hat Vorrang)."""
device_type = models.OneToOneField(
"dcim.DeviceType", on_delete=models.CASCADE, null=True, blank=True, related_name="+", verbose_name="Gerätetyp"
)
role = models.OneToOneField(
"dcim.DeviceRole", on_delete=models.CASCADE, null=True, blank=True, related_name="+", verbose_name="Geräterolle"
)
kind = models.CharField(max_length=20, blank=True, verbose_name="Symbol")
icon = models.ImageField(upload_to="customer-export/symbols/", blank=True, verbose_name="Eigenes Icon")
last_updated = models.DateTimeField(auto_now=True)
class Meta:
ordering = ("device_type__model", "role__name")
verbose_name = "Topologie-Symbol"
verbose_name_plural = "Topologie-Symbole"
def __str__(self):
return str(self.device_type or self.role)
def get_absolute_url(self):
return reverse("plugins:netbox_customer_export:symbol_edit", kwargs={"pk": self.pk})
@property
def target_label(self):
return "Gerätetyp" if self.device_type_id else "Geräterolle"
def get_kind_display(self):
from .symbols import KIND_CHOICES
return dict(KIND_CHOICES).get(self.kind, "Automatisch")
def clean(self):
from django.core.exceptions import ValidationError
if bool(self.device_type_id) == bool(self.role_id):
raise ValidationError("Bitte genau einen Gerätetyp oder eine Geräterolle wählen.")
if not self.kind and not self.icon:
raise ValidationError("Bitte ein Symbol wählen oder ein eigenes Icon hochladen.")
+13
View File
@@ -6,6 +6,19 @@ menu_items = (
link_text="Kundendokumentation",
permissions=["dcim.view_site"],
),
PluginMenuItem(
link="plugins:netbox_customer_export:symbol_list",
link_text="Topologie-Symbole",
permissions=["netbox_customer_export.view_topologysymbol"],
buttons=(
PluginMenuButton(
link="plugins:netbox_customer_export:symbol_add",
title="Hinzufügen",
icon_class="mdi mdi-plus-thick",
permissions=["netbox_customer_export.add_topologysymbol"],
),
),
),
PluginMenuItem(
link="plugins:netbox_customer_export:branding_list",
link_text="Branding-Profile",
+3 -1
View File
@@ -160,11 +160,13 @@ def render_docx(doc):
cell = t.cell(0, i).paragraphs[0]
cell.alignment = WD_ALIGN_PARAGRAPH.CENTER
if png:
cell.add_run().add_picture(BytesIO(png), width=Cm(7)) # Höhe skaliert mit der Rackgröße
# Topologie: volle Breite; Racks: 7 cm (Höhe skaliert mit der Rackgröße)
cell.add_run().add_picture(BytesIO(png), width=Cm(16.5 if blk.level == 1 else 7))
else:
cell.add_run("(Rack-Grafik nicht verfügbar)")
cap = t.cell(1, i).paragraphs[0]
cap.alignment = WD_ALIGN_PARAGRAPH.CENTER
if img["caption"]:
cap.add_run(img["caption"]).italic = True
d.add_paragraph()
+15
View File
@@ -406,3 +406,18 @@ def licenses(scope, opts):
if not found_source:
return _empty(opts, blocks, "Kein Lizenz-Plugin installiert (z.B. netbox-lifecycle) – siehe README.")
return blocks if len(blocks) > 1 else _empty(opts, blocks)
@section("topology", "Netzwerk-Topologie", "Netzwerk", default=False, hint="Diagramm mit Symbolen, wie Packet Tracer")
def topology(scope, opts):
from .topology import build_graphs, render_png
blocks = [H(1, "Netzwerk-Topologie")]
graphs = build_graphs(scope, opts)
if not graphs:
return _empty(opts, blocks, "Keine verkabelten Geräte gefunden.")
ports = "ports" in (opts.get("topo_details") or [])
for g in graphs:
blocks.append(H(2, g.title))
blocks.append(IMAGES([(None, "", render_png(g, show_ports=ports))], wide=True))
return blocks
+96
View File
@@ -0,0 +1,96 @@
"""Verwaltung der Topologie-Symbole je Gerätetyp / Geräterolle."""
from django.contrib import messages
from django.contrib.auth.mixins import LoginRequiredMixin, PermissionRequiredMixin
from django.http import Http404, HttpResponse
from django.shortcuts import get_object_or_404, redirect, render
from django.urls import reverse
from django.views import View
from .forms import TopologySymbolForm
from .models import TopologySymbol
from .symbols import HIDDEN, KINDS, builtin_icon
APP = "netbox_customer_export"
def _builtin_kinds():
return [(k, label, reverse(f"plugins:{APP}:symbol_builtin", kwargs={"kind": k})) for k, (label, _) in KINDS.items()]
class BuiltinIconView(LoginRequiredMixin, View):
"""PNG eines eingebauten Symbols (für Galerie und Vorschau)."""
def get(self, request, kind):
if kind not in KINDS:
raise Http404
response = HttpResponse(builtin_icon(kind), content_type="image/png")
response["Cache-Control"] = "max-age=86400"
return response
class SymbolListView(PermissionRequiredMixin, View):
permission_required = f"{APP}.view_topologysymbol"
def get(self, request):
symbols = TopologySymbol.objects.select_related("device_type__manufacturer", "role")
builtin = {k: url for k, _, url in _builtin_kinds()}
rows = []
for s in symbols:
if s.icon:
preview = s.icon.url
elif s.kind and s.kind != HIDDEN:
preview = builtin[s.kind]
else:
preview = None
rows.append((s, preview))
return render(
request,
f"{APP}/symbol_list.html",
{"rows": rows, "builtin": _builtin_kinds(), "types": [r for r in rows if r[0].device_type_id], "roles": [r for r in rows if r[0].role_id]},
)
class SymbolEditView(PermissionRequiredMixin, View):
template_name = f"{APP}/symbol_edit.html"
def get_permission_required(self):
return [f"{APP}.{'change' if self.kwargs.get('pk') else 'add'}_topologysymbol"]
def _obj(self, pk):
return get_object_or_404(TopologySymbol, pk=pk) if pk else TopologySymbol()
def _render(self, request, obj, form):
return render(request, self.template_name, {"object": obj, "form": form, "builtin": _builtin_kinds()})
def get(self, request, pk=None):
obj = self._obj(pk)
initial = {}
for key in ("device_type", "role"): # Vorbelegung z.B. aus dem Link auf der Gerätetyp-Seite
if request.GET.get(key):
initial[key] = request.GET[key]
return self._render(request, obj, TopologySymbolForm(instance=obj, initial=initial))
def post(self, request, pk=None):
obj = self._obj(pk)
form = TopologySymbolForm(request.POST, request.FILES, instance=obj)
if form.is_valid():
obj = form.save()
messages.success(request, f"Topologie-Symbol für „{obj}“ gespeichert.")
return redirect(f"plugins:{APP}:symbol_list")
return self._render(request, obj, form)
class SymbolDeleteView(PermissionRequiredMixin, View):
permission_required = f"{APP}.delete_topologysymbol"
def get(self, request, pk):
return render(request, f"{APP}/symbol_delete.html", {"object": get_object_or_404(TopologySymbol, pk=pk)})
def post(self, request, pk):
obj = get_object_or_404(TopologySymbol, pk=pk)
name = str(obj)
if obj.icon:
obj.icon.delete(save=False)
obj.delete()
messages.success(request, f"Topologie-Symbol für „{name}“ gelöscht – es gilt wieder die automatische Erkennung.")
return redirect(f"plugins:{APP}:symbol_list")
+295
View File
@@ -0,0 +1,295 @@
"""Topologie-Symbole im Stil von Cisco Packet Tracer (mit Pillow gezeichnet) und ihre Zuordnung zu Geräten."""
import re
from collections import OrderedDict
from functools import lru_cache
from io import BytesIO
# Schlüssel -> (Bezeichnung, Ebene im Diagramm; kleiner = weiter oben)
KINDS = OrderedDict(
[
("cloud", ("Internet / Provider", 0)),
("firewall", ("Firewall", 1)),
("router", ("Router", 1)),
("l3switch", ("Layer-3-Switch / Core", 2)),
("switch", ("Switch", 3)),
("wlc", ("WLAN-Controller", 3)),
("ap", ("Access Point", 4)),
("server", ("Server", 4)),
("storage", ("Storage / NAS", 4)),
("pc", ("PC / Client", 4)),
("phone", ("IP-Telefon", 4)),
("printer", ("Drucker", 4)),
("camera", ("Kamera", 4)),
("generic", ("Sonstiges Gerät", 4)),
]
)
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.
_RULES = [
(r"patch|panel|pdu|steckdose|power|strom|kabel|cable|blank|blind|shelf|fachboden|ups|usv|rangier", None),
(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"\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"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"),
]
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()
for pattern, kind in _RULES:
if re.search(pattern, text):
return kind
return "generic"
# --- Zeichnen -----------------------------------------------------------------
SIZE = 128 # Kantenlänge eines Symbols in Pixeln
BLUE = (29, 96, 158)
BLUE_LIGHT = (86, 149, 206)
BLUE_TOP = (122, 178, 226)
WHITE = (255, 255, 255)
OUTLINE = (20, 55, 95)
def _arrow(d, start, end, color=WHITE, width=5, head=10):
import math
d.line([start, end], fill=color, width=width)
ang = math.atan2(end[1] - start[1], end[0] - start[0])
left = (end[0] - head * math.cos(ang - 0.5), end[1] - head * math.sin(ang - 0.5))
right = (end[0] - head * math.cos(ang + 0.5), end[1] - head * math.sin(ang + 0.5))
d.polygon([end, left, right], fill=color)
def _iso_box(d, x1, y1, x2, y2, depth, front, top, side=None):
"""Quader in leichter Aufsicht: Front + Oberseite."""
d.polygon([(x1, y1), (x1 + depth, y1 - depth), (x2 + depth, y1 - depth), (x2, y1)], fill=top, outline=OUTLINE)
if side:
d.polygon([(x2, y1), (x2 + depth, y1 - depth), (x2 + depth, y2 - depth), (x2, y2)], fill=side, outline=OUTLINE)
d.rectangle([x1, y1, x2, y2], fill=front, outline=OUTLINE, width=2)
def _cylinder(d, x1, y1, x2, y2, ry, body, top):
d.rectangle([x1, y1 + ry, x2, y2 - ry], fill=body)
d.ellipse([x1, y2 - 2 * ry, x2, y2], fill=body, outline=OUTLINE, width=2)
d.rectangle([x1 + 1, y1 + ry, x2 - 1, y2 - ry], fill=body)
d.line([(x1, y1 + ry), (x1, y2 - ry)], fill=OUTLINE, width=2)
d.line([(x2, y1 + ry), (x2, y2 - ry)], fill=OUTLINE, width=2)
d.ellipse([x1, y1, x2, y1 + 2 * ry], fill=top, outline=OUTLINE, width=2)
def _draw_router(d):
_cylinder(d, 14, 34, 114, 100, 18, BLUE, BLUE_TOP)
# Packet-Tracer-typisch: zwei Pfeile nach außen, zwei nach innen (auf der Oberseite)
cx, cy = 64, 52
_arrow(d, (cx + 8, cy + 3), (cx + 40, cy + 12), width=4, head=9)
_arrow(d, (cx - 8, cy - 3), (cx - 40, cy - 12), width=4, head=9)
_arrow(d, (cx + 40, cy - 12), (cx + 10, cy - 3), width=4, head=9)
_arrow(d, (cx - 40, cy + 12), (cx - 10, cy + 3), width=4, head=9)
def _draw_switch(d, l3=False):
_iso_box(d, 8, 64, 98, 98, 24, BLUE, BLUE_TOP, BLUE_LIGHT)
# Doppelpfeile auf der Oberseite (Packet-Tracer-Switch)
_arrow(d, (28, 47), (90, 47), width=5, head=11)
_arrow(d, (92, 56), (30, 56), width=5, head=11)
if l3:
from PIL import ImageFont
try:
font = ImageFont.load_default(size=20)
except TypeError: # Pillow < 10.1
font = ImageFont.load_default()
d.text((53, 81), "L3", fill=WHITE, font=font, anchor="mm")
else:
for i in range(8): # Ports
d.rectangle([16 + i * 10, 84, 22 + i * 10, 90], fill=BLUE_TOP)
def _draw_firewall(d):
red, mortar = (196, 64, 44), (240, 220, 200)
_iso_box(d, 16, 40, 100, 104, 12, red, (226, 110, 88), (160, 50, 36))
for row, y in enumerate(range(40, 104, 13)):
d.line([(16, y), (100, y)], fill=mortar, width=2)
off = 0 if row % 2 else 14
for x in range(16 + off, 100, 28):
d.line([(x, y), (x, min(y + 13, 104))], fill=mortar, width=2)
d.rectangle([16, 40, 100, 104], outline=OUTLINE, width=2)
def _draw_ap(d):
d.rounded_rectangle([22, 72, 106, 96], radius=10, fill=BLUE, outline=OUTLINE, width=2)
d.ellipse([56, 78, 72, 90], fill=BLUE_TOP)
for r in (16, 28, 40):
d.arc([64 - r, 62 - r, 64 + r, 62 + r], start=220, end=320, fill=BLUE, width=5)
def _draw_wlc(d):
_iso_box(d, 10, 66, 104, 96, 12, BLUE, BLUE_TOP, BLUE_LIGHT)
for r in (12, 22, 32):
d.arc([57 - r, 52 - r, 57 + r, 52 + r], start=215, end=325, fill=BLUE, width=5)
def _draw_server(d):
d.rectangle([38, 14, 90, 114], fill=(84, 98, 112), outline=OUTLINE, width=2)
d.polygon([(38, 14), (48, 6), (100, 6), (90, 14)], fill=(140, 154, 168), outline=OUTLINE)
d.polygon([(90, 14), (100, 6), (100, 106), (90, 114)], fill=(60, 72, 84), outline=OUTLINE)
for y in range(24, 96, 14):
d.rectangle([45, y, 83, y + 8], fill=(200, 208, 216))
d.ellipse([76, y + 2, 80, y + 6], fill=(60, 200, 90))
def _draw_storage(d):
grey, top = (104, 116, 130), (176, 188, 200)
for y in (70, 46, 22):
_cylinder(d, 22, y, 106, y + 36, 10, grey, top)
def _draw_pc(d):
d.rounded_rectangle([18, 16, 110, 82], radius=6, fill=(50, 56, 64), outline=OUTLINE, width=2)
d.rectangle([26, 24, 102, 74], fill=BLUE_LIGHT)
d.polygon([(54, 82), (74, 82), (80, 100), (48, 100)], fill=(84, 92, 100))
d.rounded_rectangle([34, 100, 94, 108], radius=3, fill=(84, 92, 100))
def _draw_phone(d):
d.rounded_rectangle([30, 52, 104, 106], radius=8, fill=(60, 66, 74), outline=OUTLINE, width=2)
d.rectangle([62, 60, 96, 76], fill=BLUE_LIGHT)
for i in range(3):
for j in range(3):
d.rectangle([64 + j * 11, 82 + i * 7, 70 + j * 11, 86 + i * 7], fill=(200, 200, 200))
d.rounded_rectangle([18, 32, 50, 100], radius=10, fill=(40, 44, 50), outline=OUTLINE, width=2)
def _draw_printer(d):
d.rectangle([40, 18, 88, 50], fill=WHITE, outline=OUTLINE, width=2)
_iso_box(d, 16, 50, 104, 94, 10, (96, 104, 114), (150, 158, 168))
d.rectangle([40, 86, 88, 108], fill=WHITE, outline=OUTLINE, width=2)
d.ellipse([90, 60, 98, 68], fill=(60, 200, 90))
def _draw_camera(d):
d.rounded_rectangle([20, 44, 92, 80], radius=10, fill=(230, 232, 236), outline=OUTLINE, width=2)
d.ellipse([84, 48, 110, 76], fill=(40, 44, 50), outline=OUTLINE, width=2)
d.ellipse([92, 56, 102, 68], fill=BLUE_LIGHT)
d.rectangle([48, 80, 58, 104], fill=(150, 156, 164))
d.rectangle([34, 104, 74, 110], fill=(150, 156, 164))
def _draw_cloud(d):
blobs = [(14, 52, 58, 96), (36, 30, 84, 78), (68, 38, 112, 86), (46, 60, 100, 104), (22, 64, 62, 104)]
for x1, y1, x2, y2 in blobs:
d.ellipse([x1 - 3, y1 - 3, x2 + 3, y2 + 3], fill=BLUE)
for x1, y1, x2, y2 in blobs:
d.ellipse([x1, y1, x2, y2], fill=(236, 244, 252))
def _draw_generic(d):
_iso_box(d, 16, 44, 104, 96, 12, (120, 132, 146), (176, 186, 198), (96, 106, 118))
for i in range(4):
d.rectangle([26 + i * 18, 76, 38 + i * 18, 86], fill=(200, 208, 216))
_DRAWERS = {
"router": _draw_router,
"switch": _draw_switch,
"l3switch": lambda d: _draw_switch(d, l3=True),
"firewall": _draw_firewall,
"ap": _draw_ap,
"wlc": _draw_wlc,
"server": _draw_server,
"storage": _draw_storage,
"pc": _draw_pc,
"phone": _draw_phone,
"printer": _draw_printer,
"camera": _draw_camera,
"cloud": _draw_cloud,
"generic": _draw_generic,
}
@lru_cache(maxsize=None)
def builtin_icon(kind):
"""PNG-Bytes eines eingebauten Symbols (SIZE x SIZE, transparent)."""
from PIL import Image, ImageDraw
img = Image.new("RGBA", (SIZE, SIZE), (0, 0, 0, 0))
_DRAWERS.get(kind, _draw_generic)(ImageDraw.Draw(img))
out = BytesIO()
img.save(out, "PNG")
return out.getvalue()
def fit_icon(data):
"""Beliebiges Bild (Bytes) auf SIZE x SIZE mit Seitenverhältnis einpassen, transparent auffüllen."""
from PIL import Image
src = Image.open(BytesIO(data)).convert("RGBA")
src.thumbnail((SIZE, SIZE), Image.LANCZOS)
img = Image.new("RGBA", (SIZE, SIZE), (0, 0, 0, 0))
img.paste(src, ((SIZE - src.width) // 2, (SIZE - src.height) // 2), src)
out = BytesIO()
img.save(out, "PNG")
return out.getvalue()
class SymbolResolver:
"""Ermittelt Symbol (kind) und Icon für ein Gerät: Gerätetyp-Zuordnung > Rollen-Zuordnung > Heuristik."""
def __init__(self):
from django.db import DatabaseError
from .models import TopologySymbol
self.by_type, self.by_role = {}, {}
try:
for s in TopologySymbol.objects.all():
if s.device_type_id:
self.by_type[s.device_type_id] = s
elif s.role_id:
self.by_role[s.role_id] = s
except DatabaseError: # Migration noch nicht ausgeführt
pass
self._icons = {}
def resolve(self, device):
"""(kind, icon_png_bytes) oder (None, None), wenn das Gerät nicht gezeichnet wird."""
sym = self.by_type.get(device.device_type_id) or self.by_role.get(device.role_id)
if sym:
if sym.kind == HIDDEN:
return None, None
kind = sym.kind or guess_kind(device) or "generic"
if sym.icon:
return kind, self._custom_icon(sym)
return kind, builtin_icon(kind)
kind = guess_kind(device)
return (kind, builtin_icon(kind)) if kind else (None, None)
def _custom_icon(self, sym):
if sym.pk not in self._icons:
try:
with sym.icon.open("rb") as f:
self._icons[sym.pk] = fit_icon(f.read())
except (OSError, ValueError):
self._icons[sym.pk] = builtin_icon(sym.kind or "generic")
return self._icons[sym.pk]
+24 -1
View File
@@ -18,4 +18,27 @@ class CustomerExportButton(PluginTemplateExtension):
)
template_extensions = [CustomerExportButton]
class TopologySymbolButton(PluginTemplateExtension):
"""Auf Gerätetyp- und Geräterollen-Seiten: Topologie-Symbol festlegen."""
models = ["dcim.devicetype", "dcim.devicerole"]
def buttons(self):
from .models import TopologySymbol
user = self.context["request"].user
if not user.has_perm("netbox_customer_export.add_topologysymbol"):
return ""
obj = self.context["object"]
field = "device_type" if obj._meta.model_name == "devicetype" else "role"
existing = TopologySymbol.objects.filter(**{field: obj}).first()
if existing:
url = existing.get_absolute_url()
else:
url = f"{reverse('plugins:netbox_customer_export:symbol_add')}?{field}={obj.pk}"
return format_html(
'<a href="{}" class="btn btn-outline-primary"><i class="mdi mdi-shape-outline"></i> Topologie-Symbol</a>', url
)
template_extensions = [CustomerExportButton, TopologySymbolButton]
@@ -0,0 +1,27 @@
<div class="table-responsive">
<table class="table table-hover card-table align-middle">
<thead><tr><th style="width:56px"></th><th>Zuordnung</th><th>Symbol</th><th></th></tr></thead>
<tbody>
{% for s, preview in rows %}
<tr>
<td>{% if preview %}<img src="{{ preview }}" class="ce-sym" alt="">{% else %}<i class="mdi mdi-eye-off-outline fs-2 text-muted"></i>{% endif %}</td>
<td>
{% if s.device_type %}<a href="{{ s.device_type.get_absolute_url }}">{{ s.device_type.manufacturer }} {{ s.device_type.model }}</a>
{% else %}<a href="{{ s.role.get_absolute_url }}">{{ s.role }}</a>{% endif %}
</td>
<td>{% if s.icon %}<span class="badge text-bg-blue">Eigenes Icon</span>{% endif %} {{ s.get_kind_display }}</td>
<td class="text-end text-nowrap">
{% if perms.netbox_customer_export.change_topologysymbol %}
<a href="{{ s.get_absolute_url }}" class="btn btn-sm btn-warning" title="Bearbeiten"><i class="mdi mdi-pencil"></i></a>
{% endif %}
{% if perms.netbox_customer_export.delete_topologysymbol %}
<a href="{% url 'plugins:netbox_customer_export:symbol_delete' pk=s.pk %}" class="btn btn-sm btn-danger" title="Löschen"><i class="mdi mdi-trash-can-outline"></i></a>
{% endif %}
</td>
</tr>
{% empty %}
<tr><td colspan="4" class="text-center text-muted py-3">{{ empty }}</td></tr>
{% endfor %}
</tbody>
</table>
</div>
@@ -35,6 +35,7 @@
.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; }
figcaption { font-style: italic; color: #555; }
.footer { position: running(footer); font-size: 7pt; color: #777; }
@media screen { .footer { display: none; } }
@@ -69,7 +70,7 @@
{% elif b.kind == 'kv' %}
<table class="kv"><tbody>{% for row in b.rows %}<tr><td>{{ row.0 }}</td><td>{{ row.1|linebreaksbr }}</td></tr>{% endfor %}</tbody></table>
{% elif b.kind == 'images' %}
<div class="images">{% for img in b.images %}<figure><img src="{{ img.data_uri }}" alt="{{ img.caption }}"><figcaption>{{ img.caption }}</figcaption></figure>{% endfor %}</div>
<div class="images{% if b.level == 1 %} wide{% endif %}">{% for img in b.images %}<figure><img src="{{ img.data_uri }}" alt="{{ img.caption }}">{% if img.caption %}<figcaption>{{ img.caption }}</figcaption>{% endif %}</figure>{% endfor %}</div>
{% elif b.kind == 'pagebreak' %}<div class="pagebreak"></div>
{% endif %}
{% endfor %}
@@ -24,7 +24,7 @@
.ce-dim { opacity: .45; pointer-events: none; }
.ce-card { transition: opacity .15s; }
.ce-actions { position: sticky; bottom: 0; z-index: 10; background: var(--tblr-bg-surface); border-top: 1px solid var(--tblr-border-color); padding: .75rem 1rem; display: flex; justify-content: flex-end; gap: .5rem; }
.ce-format .btn { flex: 1; }
.ce-format .btn { flex: 1; padding-left: .25rem; padding-right: .25rem; }
</style>
<form method="post" class="mt-3" id="ce-export-form">
@@ -42,11 +42,12 @@
{% 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>{% 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>{% elif value == 'drawio' %}<i class="mdi mdi-sitemap"></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>
@@ -119,6 +120,21 @@
</div>
</div>
</div>
<div class="card ce-card mt-3" data-ce-depends="topology">
<h2 class="card-header d-flex justify-content-between align-items-center">
<span><i class="mdi mdi-sitemap"></i> Topologie</span>
{% if perms.netbox_customer_export.view_topologysymbol %}
<a href="{% url 'plugins:netbox_customer_export:symbol_list' %}" class="btn btn-sm btn-outline-secondary" target="_blank"><i class="mdi mdi-shape-outline"></i> Symbole verwalten</a>
{% endif %}
</h2>
<div class="card-body">
<div class="ce-label mt-0">Beschriftung</div>
{% include 'netbox_customer_export/_pills.html' with field=form.topo_details %}
<div class="ce-label">Weiteres</div>
<label class="ce-switch">{{ form.topo_external }} {{ form.topo_external.label }}</label>
<label class="ce-switch">{{ form.topo_isolated }} {{ form.topo_isolated.label }}</label>
</div>
</div>
</div>
</div>
@@ -140,10 +156,15 @@
});
// 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 + '"]');
card.classList.toggle('ce-dim', !!cb && !cb.checked);
// draw.io nutzt nur die Topologie-Optionen
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);
sync();
@@ -0,0 +1,17 @@
{% extends 'generic/_base.html' %}
{% block title %}Symbol-Zuordnung löschen{% endblock %}
{% block content %}
<form method="post" class="mt-3">
{% csrf_token %}
<div class="card border-danger">
<div class="card-body">
<p>Soll die Symbol-Zuordnung für <strong>{{ object.target_label }} {{ object }}</strong> gelöscht werden?
Danach gilt wieder die automatische Erkennung. Ein hochgeladenes Icon wird ebenfalls entfernt.</p>
</div>
<div class="card-footer text-end">
<a href="{% url 'plugins:netbox_customer_export:symbol_list' %}" class="btn btn-outline-secondary">Abbrechen</a>
<button class="btn btn-danger"><i class="mdi mdi-trash-can-outline"></i> Löschen</button>
</div>
</div>
</form>
{% endblock %}
@@ -0,0 +1,65 @@
{% extends 'generic/_base.html' %}
{% load form_helpers %}
{% block title %}{% if object.pk %}Symbol für {{ object }}{% else %}Neue Symbol-Zuordnung{% endif %}{% endblock %}
{% block content %}
<style>
.ce-kinds { display: grid; grid-template-columns: repeat(auto-fill, minmax(118px, 1fr)); gap: .5rem; }
.ce-kind { display: flex; flex-direction: column; align-items: center; gap: .25rem; padding: .6rem .4rem; border: 2px solid var(--tblr-border-color); border-radius: .5rem; cursor: pointer; text-align: center; font-size: .8rem; margin: 0; background: #fff; color: #222; }
.ce-kind:hover { border-color: var(--tblr-primary); }
.ce-kind:has(input:checked) { border-color: var(--tblr-primary); box-shadow: 0 0 0 3px rgba(var(--tblr-primary-rgb), .2); }
.ce-kind input { position: absolute; opacity: 0; pointer-events: none; }
.ce-kind img, .ce-kind .ce-kind-icon { width: 56px; height: 56px; }
.ce-kind .ce-kind-icon { display: flex; align-items: center; justify-content: center; font-size: 2rem; color: #888; }
.ce-icon-preview { width: 72px; height: 72px; object-fit: contain; border: 1px solid var(--tblr-border-color); border-radius: .25rem; background: #fff; }
</style>
<form method="post" enctype="multipart/form-data" class="mt-3">
{% csrf_token %}
{% if form.non_field_errors %}<div class="alert alert-danger">{% for e in form.non_field_errors %}{{ e }}<br>{% endfor %}</div>{% endif %}
<div class="row g-3">
<div class="col-lg-4">
<div class="card h-100">
<h2 class="card-header"><i class="mdi mdi-link-variant"></i> Gilt für</h2>
<div class="card-body">
<p class="text-muted small">Entweder einen Gerätetyp <em>oder</em> eine Geräterolle wählen. Gerätetyp hat Vorrang.</p>
{% render_field form.device_type %}
{% render_field form.role %}
</div>
</div>
</div>
<div class="col-lg-8">
<div class="card">
<h2 class="card-header"><i class="mdi mdi-shape-outline"></i> Symbol</h2>
<div class="card-body">
<div class="ce-kinds mb-3">
{% for radio in form.kind %}
<label class="ce-kind" for="{{ radio.id_for_label }}">
{{ radio.tag }}
{% if radio.data.value == '' %}<span class="ce-kind-icon"><i class="mdi mdi-auto-fix"></i></span>
{% elif radio.data.value == 'hidden' %}<span class="ce-kind-icon"><i class="mdi mdi-eye-off-outline"></i></span>
{% else %}{% for key, label, url in builtin %}{% if key == radio.data.value %}<img src="{{ url }}" alt="">{% endif %}{% endfor %}{% endif %}
{{ radio.choice_label }}
</label>
{% endfor %}
</div>
{% for e in form.kind.errors %}<div class="text-danger small">{{ e }}</div>{% endfor %}
<label class="form-label" for="{{ form.icon.id_for_label }}">Eigenes Icon (optional)</label>
<div class="d-flex gap-3 align-items-start">
{% if object.icon %}<img src="{{ object.icon.url }}" class="ce-icon-preview" alt="Aktuelles Icon">{% endif %}
<div class="flex-grow-1">
{{ form.icon }}
<div class="form-text">{{ form.icon.help_text }} Ist kein Symbol gewählt, bestimmt die Automatik die Ebene im Diagramm.</div>
{% for e in form.icon.errors %}<div class="text-danger small">{{ e }}</div>{% endfor %}
</div>
</div>
</div>
</div>
</div>
</div>
<div class="d-flex justify-content-end gap-2 mt-3">
<a href="{% url 'plugins:netbox_customer_export:symbol_list' %}" class="btn btn-outline-secondary">Abbrechen</a>
<button class="btn btn-primary"><i class="mdi mdi-content-save"></i> Speichern</button>
</div>
</form>
{% endblock %}
@@ -0,0 +1,54 @@
{% extends 'generic/_base.html' %}
{% block title %}Topologie-Symbole{% endblock %}
{% block controls %}
{% if perms.netbox_customer_export.add_topologysymbol %}
<div class="btn-list">
<a href="{% url 'plugins:netbox_customer_export:symbol_add' %}" class="btn btn-primary"><i class="mdi mdi-plus-thick"></i> Zuordnung hinzufügen</a>
</div>
{% endif %}
{% endblock %}
{% block content %}
<style>
.ce-gallery { display: grid; grid-template-columns: repeat(auto-fill, minmax(110px, 1fr)); gap: .5rem; }
.ce-gallery figure { margin: 0; text-align: center; padding: .5rem; border: 1px solid var(--tblr-border-color); border-radius: .5rem; background: #fff; }
.ce-gallery img { width: 64px; height: 64px; }
.ce-gallery figcaption { font-size: .75rem; color: #444; margin-top: .25rem; }
.ce-sym { width: 40px; height: 40px; object-fit: contain; background: #fff; border-radius: .25rem; }
</style>
<div class="row g-3 mt-1">
<div class="col-xl-7">
<div class="card mb-3">
<h2 class="card-header"><i class="mdi mdi-shape-outline"></i> Zuordnungen je Gerätetyp</h2>
{% include 'netbox_customer_export/_symbol_table.html' with rows=types empty="Noch keine Gerätetypen zugeordnet." %}
</div>
<div class="card">
<h2 class="card-header"><i class="mdi mdi-tag-outline"></i> Zuordnungen je Geräterolle</h2>
{% include 'netbox_customer_export/_symbol_table.html' with rows=roles empty="Noch keine Geräterollen zugeordnet." %}
</div>
</div>
<div class="col-xl-5">
<div class="card mb-3">
<h2 class="card-header"><i class="mdi mdi-information-outline"></i> So wird das Symbol bestimmt</h2>
<div class="card-body">
<ol class="mb-0">
<li><strong>Gerätetyp</strong>-Zuordnung (eigenes Icon oder Symbol)</li>
<li><strong>Geräterolle</strong>-Zuordnung</li>
<li><strong>Automatisch</strong> anhand von Rolle, Modell und Hersteller, z.B. „Switch“, „Firewall“, „Sophos“, „Access Point“.
Patchpanels, Steckdosenleisten, PDUs und USVs werden automatisch ausgeblendet.</li>
</ol>
</div>
</div>
<div class="card">
<h2 class="card-header"><i class="mdi mdi-view-grid-outline"></i> Eingebaute Symbole</h2>
<div class="card-body">
<div class="ce-gallery">
{% for key, label, url in builtin %}
<figure><img src="{{ url }}" alt="{{ label }}"><figcaption>{{ label }}</figcaption></figure>
{% endfor %}
</div>
</div>
</div>
</div>
</div>
{% endblock %}
+355
View File
@@ -0,0 +1,355 @@
"""Netzwerk-Topologie: Graph aus den Kabelverbindungen, Ebenen-Layout, PNG- und draw.io-Ausgabe."""
import base64
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
MAX_PER_ROW = 8
@dataclass
class Node:
id: str
label: str
kind: str
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
@dataclass
class Edge:
a: str
b: str
labels: list = field(default_factory=list)
@dataclass
class Graph:
title: str
nodes: "OrderedDict[str, Node]"
edges: list
# --- Graph aufbauen -----------------------------------------------------------
def _device_sub(device, details):
sub = []
if "model" in details:
sub.append(str(device.device_type.model))
if "primary_ip" in details and device.primary_ip:
sub.append(str(device.primary_ip.address.ip))
return sub
def build_graphs(scope, opts):
"""Ein Graph je Standort. Geräte anderer Standorte und Provider-Leitungen erscheinen als externe Knoten."""
from dcim.models import Interface
details = set(opts.get("topo_details") or [])
resolver = SymbolResolver()
devices = list(
scope.devices().select_related(
"role", "device_type__manufacturer", "site", "primary_ip4", "primary_ip6"
).order_by("site__name", "name")
)
drawn = {} # device.pk -> (kind, icon)
for d in devices:
kind, icon = resolver.resolve(d)
if kind:
drawn[d.pk] = (kind, icon)
by_pk = {d.pk: d for d in devices}
# Verbindungen: vollständige Kabelpfade (auch über Patchpanels) zwischen Interfaces
links = defaultdict(list) # (a, b) -> ["Gi1/0/1 – eth0", …]
external = {} # node_id -> Node-Vorlage
ifaces = scope.restrict(
Interface.objects.filter(device_id__in=list(drawn), cable__isnull=False)
).select_related("device")
for iface in ifaces:
try:
endpoints = iface.connected_endpoints or []
except Exception: # noqa: BLE001 – defekte Pfade überspringen
continue
for ep in endpoints:
model = ep._meta.model_name
a = f"d{iface.device_id}"
if model == "interface":
peer = ep.device
if peer.pk == iface.device_id:
continue
if peer.pk not in by_pk: # Gerät außerhalb des Scopes
if not opts.get("topo_external"):
continue
kind, icon = resolver.resolve(peer)
if not kind:
continue
external[f"d{peer.pk}"] = Node(f"d{peer.pk}", peer.name or str(peer), kind, icon, [str(peer.site)], external=True)
elif peer.pk not in drawn:
continue
b, label = f"d{peer.pk}", f"{iface.name} – {ep.name}"
elif model in ("circuittermination", "providernetwork"):
if not opts.get("topo_external"):
continue
circuit = getattr(ep, "circuit", None)
b = f"c{circuit.pk}" if circuit else f"p{ep.pk}"
title = str(circuit.provider) if circuit else str(ep)
sub = [str(circuit.cid)] if circuit else []
external[b] = Node(b, title, "cloud", builtin_icon("cloud"), sub, external=True)
label = iface.name
else:
continue
key = tuple(sorted((a, b)))
# Beschriftung immer aus Sicht von key[0]; jedes Kabel wird von beiden Enden gefunden
if key[0] != a:
label = " – ".join(reversed(label.split(" – ")))
if label not in links[key]:
links[key].append(label)
# Knoten je Standort
graphs = []
sites = OrderedDict()
for pk in drawn:
sites.setdefault(by_pk[pk].site, []).append(pk)
for site, pks in sites.items():
nodes = OrderedDict()
for pk in pks:
d = by_pk[pk]
kind, icon = drawn[pk]
nodes[f"d{pk}"] = Node(f"d{pk}", d.name or str(d), kind, icon, _device_sub(d, details))
edges = []
for (a, b), labels in links.items():
in_a, in_b = a in nodes, b in nodes
if not (in_a or in_b):
continue
for nid, inside in ((a, in_a), (b, in_b)):
if not inside:
other = external.get(nid)
if other is None: # Gerät eines anderen Standorts im selben Scope
d = by_pk[int(nid[1:])]
kind, icon = drawn[d.pk]
other = Node(nid, d.name or str(d), kind, icon, [str(d.site)], external=True)
nodes[nid] = Node(**{**other.__dict__})
edges.append(Edge(a, b, labels))
if not opts.get("topo_isolated"):
linked = {n for e in edges for n in (e.a, e.b)}
nodes = OrderedDict((k, v) for k, v in nodes.items() if k in linked)
if nodes:
_layout(nodes, edges)
graphs.append(Graph(f"Standort {site}", nodes, edges))
return graphs
# --- Layout -------------------------------------------------------------------
def _layout(nodes, edges):
"""Ebenen per Breitensuche ab den „höchsten“ Geräten (Provider, Firewall, Router …), dann Reihenfolge nach Nachbarn."""
adj = defaultdict(set)
for e in edges:
adj[e.a].add(e.b)
adj[e.b].add(e.a)
tier = {nid: KINDS.get(n.kind, ("", 4))[1] for nid, n in nodes.items()}
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
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
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)
for nid in nodes:
rows[rank[layer[nid]] if layer[nid] is not None else bottom].append(nid)
# 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
# Zeilen mit zu vielen Knoten umbrechen, dann Koordinaten
CELL_W, CELL_H = 200, 190
lines = []
for r in sorted(rows):
row = rows[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].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“)."""
a, b = graph.nodes[e.a], graph.nodes[e.b]
if a.y <= b.y:
return e.labels
return [" – ".join(reversed(x.split(" – "))) for x in e.labels]
# --- PNG ----------------------------------------------------------------------
def _font(size, bold=False):
from PIL import ImageFont
names = ("DejaVuSans-Bold.ttf", "Arial Bold.ttf", "arialbd.ttf") if bold else ("DejaVuSans.ttf", "Arial.ttf", "arial.ttf")
for name in names:
try:
return ImageFont.truetype(name, size)
except OSError:
continue
try:
return ImageFont.load_default(size=size)
except TypeError:
return ImageFont.load_default()
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))
d = ImageDraw.Draw(img)
f_name, f_sub, f_port = _font(12 * scale, bold=True), _font(10 * scale), _font(8 * scale)
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
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")
for n in nodes:
x, y = c(n)
pic = Image.open(BytesIO(n.icon)).convert("RGBA").resize((icon * scale, icon * scale), 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")
out = BytesIO()
img.save(out, "PNG", optimize=True)
return out.getvalue()
def _clip(d, text, font, width):
if d.textlength(text, font=font) <= width:
return text
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"]
+6 -1
View File
@@ -1,6 +1,6 @@
from django.urls import path
from . import branding_views, views
from . import branding_views, symbol_views, views
urlpatterns = [
path("", views.ScopeSelectView.as_view(), name="select"),
@@ -10,4 +10,9 @@ urlpatterns = [
path("branding/<int:pk>/", branding_views.BrandingEditView.as_view(), name="branding_edit"),
path("branding/<int:pk>/delete/", branding_views.BrandingDeleteView.as_view(), name="branding_delete"),
path("branding/<int:pk>/preview/", branding_views.BrandingPreviewView.as_view(), name="branding_preview"),
path("symbols/", symbol_views.SymbolListView.as_view(), name="symbol_list"),
path("symbols/add/", symbol_views.SymbolEditView.as_view(), name="symbol_add"),
path("symbols/<int:pk>/", symbol_views.SymbolEditView.as_view(), name="symbol_edit"),
path("symbols/<int:pk>/delete/", symbol_views.SymbolDeleteView.as_view(), name="symbol_delete"),
path("symbols/builtin/<str:kind>.png", symbol_views.BuiltinIconView.as_view(), name="symbol_builtin"),
]
+16
View File
@@ -28,6 +28,20 @@ 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):
@@ -54,6 +68,8 @@ 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)
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "netbox-customer-export"
version = "0.3.1"
version = "0.4.0"
description = "NetBox-Plugin: Kundendokumentation (PDF/Word) für Standorte, Lokationen, Mandanten und Mandantengruppen"
readme = "README.md"
requires-python = ">=3.10"