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
+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",
+4 -2
View File
@@ -160,12 +160,14 @@ 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
cap.add_run(img["caption"]).italic = True
if img["caption"]:
cap.add_run(img["caption"]).italic = True
d.add_paragraph()
# Fußzeile nur setzen, wenn die Vorlage keine eigene mitbringt
+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)