diff --git a/README.md b/README.md index db61bd1..354b68c 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/netbox_customer_export/__init__.py b/netbox_customer_export/__init__.py index 77be11c..b926fad 100644 --- a/netbox_customer_export/__init__.py +++ b/netbox_customer_export/__init__.py @@ -5,7 +5,7 @@ class CustomerExportConfig(PluginConfig): name = "netbox_customer_export" verbose_name = "Kunden-Export" description = "Kundendokumentation als PDF oder Word exportieren" - version = "0.3.1" + version = "0.4.0" author = "Claude" base_url = "customer-export" min_version = "4.6.8" diff --git a/netbox_customer_export/blocks.py b/netbox_customer_export/blocks.py index 0bb05c8..94bd6e4 100644 --- a/netbox_customer_export/blocks.py +++ b/netbox_customer_export/blocks.py @@ -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(): diff --git a/netbox_customer_export/forms.py b/netbox_customer_export/forms.py index 9f876dc..3399715 100644 --- a/netbox_customer_export/forms.py +++ b/netbox_customer_export/forms.py @@ -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" diff --git a/netbox_customer_export/migrations/0002_topologysymbol.py b/netbox_customer_export/migrations/0002_topologysymbol.py new file mode 100644 index 0000000..117ae26 --- /dev/null +++ b/netbox_customer_export/migrations/0002_topologysymbol.py @@ -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"), + }, + ), + ] diff --git a/netbox_customer_export/models.py b/netbox_customer_export/models.py index 9c8a30f..cb2f09e 100644 --- a/netbox_customer_export/models.py +++ b/netbox_customer_export/models.py @@ -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.") diff --git a/netbox_customer_export/navigation.py b/netbox_customer_export/navigation.py index b641c7b..6bc922e 100644 --- a/netbox_customer_export/navigation.py +++ b/netbox_customer_export/navigation.py @@ -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", diff --git a/netbox_customer_export/renderers/docx.py b/netbox_customer_export/renderers/docx.py index bb9f362..b14d400 100644 --- a/netbox_customer_export/renderers/docx.py +++ b/netbox_customer_export/renderers/docx.py @@ -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 diff --git a/netbox_customer_export/sections.py b/netbox_customer_export/sections.py index fd9fdbf..37d0247 100644 --- a/netbox_customer_export/sections.py +++ b/netbox_customer_export/sections.py @@ -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 diff --git a/netbox_customer_export/symbol_views.py b/netbox_customer_export/symbol_views.py new file mode 100644 index 0000000..6df5418 --- /dev/null +++ b/netbox_customer_export/symbol_views.py @@ -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") diff --git a/netbox_customer_export/symbols.py b/netbox_customer_export/symbols.py new file mode 100644 index 0000000..4617f89 --- /dev/null +++ b/netbox_customer_export/symbols.py @@ -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] diff --git a/netbox_customer_export/template_content.py b/netbox_customer_export/template_content.py index ee72778..b6a82b8 100644 --- a/netbox_customer_export/template_content.py +++ b/netbox_customer_export/template_content.py @@ -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( + ' Topologie-Symbol', url + ) + + +template_extensions = [CustomerExportButton, TopologySymbolButton] diff --git a/netbox_customer_export/templates/netbox_customer_export/_symbol_table.html b/netbox_customer_export/templates/netbox_customer_export/_symbol_table.html new file mode 100644 index 0000000..39eb966 --- /dev/null +++ b/netbox_customer_export/templates/netbox_customer_export/_symbol_table.html @@ -0,0 +1,27 @@ +
+ + + + {% for s, preview in rows %} + + + + + + + {% empty %} + + {% endfor %} + +
ZuordnungSymbol
{% if preview %}{% else %}{% endif %} + {% if s.device_type %}{{ s.device_type.manufacturer }} {{ s.device_type.model }} + {% else %}{{ s.role }}{% endif %} + {% if s.icon %}Eigenes Icon{% endif %} {{ s.get_kind_display }} + {% if perms.netbox_customer_export.change_topologysymbol %} + + {% endif %} + {% if perms.netbox_customer_export.delete_topologysymbol %} + + {% endif %} +
{{ empty }}
+
diff --git a/netbox_customer_export/templates/netbox_customer_export/document.html b/netbox_customer_export/templates/netbox_customer_export/document.html index a5f4d12..f5a4869 100644 --- a/netbox_customer_export/templates/netbox_customer_export/document.html +++ b/netbox_customer_export/templates/netbox_customer_export/document.html @@ -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' %} {% for row in b.rows %}{% endfor %}
{{ row.0 }}{{ row.1|linebreaksbr }}
{% elif b.kind == 'images' %} -
{% for img in b.images %}
{{ img.caption }}
{{ img.caption }}
{% endfor %}
+
{% for img in b.images %}
{{ img.caption }}{% if img.caption %}
{{ img.caption }}
{% endif %}
{% endfor %}
{% elif b.kind == 'pagebreak' %}
{% endif %} {% endfor %} diff --git a/netbox_customer_export/templates/netbox_customer_export/export.html b/netbox_customer_export/templates/netbox_customer_export/export.html index 0e76a2b..4aa83bc 100644 --- a/netbox_customer_export/templates/netbox_customer_export/export.html +++ b/netbox_customer_export/templates/netbox_customer_export/export.html @@ -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; }
@@ -42,11 +42,12 @@ {% for value, label in form.format.field.choices %} {% endfor %} +
{{ form.title }}
{{ form.subtitle }}
{{ form.author }}
Leer = dein Benutzername
@@ -119,6 +120,21 @@ +
+

+ Topologie + {% if perms.netbox_customer_export.view_topologysymbol %} + Symbole verwalten + {% endif %} +

+
+
Beschriftung
+ {% include 'netbox_customer_export/_pills.html' with field=form.topo_details %} +
Weiteres
+ + +
+
@@ -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(); diff --git a/netbox_customer_export/templates/netbox_customer_export/symbol_delete.html b/netbox_customer_export/templates/netbox_customer_export/symbol_delete.html new file mode 100644 index 0000000..8ad4a18 --- /dev/null +++ b/netbox_customer_export/templates/netbox_customer_export/symbol_delete.html @@ -0,0 +1,17 @@ +{% extends 'generic/_base.html' %} +{% block title %}Symbol-Zuordnung löschen{% endblock %} +{% block content %} + + {% csrf_token %} +
+
+

Soll die Symbol-Zuordnung für {{ object.target_label }} {{ object }} gelöscht werden? + Danach gilt wieder die automatische Erkennung. Ein hochgeladenes Icon wird ebenfalls entfernt.

+
+ +
+
+{% endblock %} diff --git a/netbox_customer_export/templates/netbox_customer_export/symbol_edit.html b/netbox_customer_export/templates/netbox_customer_export/symbol_edit.html new file mode 100644 index 0000000..0aee0a9 --- /dev/null +++ b/netbox_customer_export/templates/netbox_customer_export/symbol_edit.html @@ -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 %} + + +
+ {% csrf_token %} + {% if form.non_field_errors %}
{% for e in form.non_field_errors %}{{ e }}
{% endfor %}
{% endif %} +
+
+
+

Gilt für

+
+

Entweder einen Gerätetyp oder eine Geräterolle wählen. Gerätetyp hat Vorrang.

+ {% render_field form.device_type %} + {% render_field form.role %} +
+
+
+
+
+

Symbol

+
+
+ {% for radio in form.kind %} + + {% endfor %} +
+ {% for e in form.kind.errors %}
{{ e }}
{% endfor %} + + +
+ {% if object.icon %}Aktuelles Icon{% endif %} +
+ {{ form.icon }} +
{{ form.icon.help_text }} Ist kein Symbol gewählt, bestimmt die Automatik die Ebene im Diagramm.
+ {% for e in form.icon.errors %}
{{ e }}
{% endfor %} +
+
+
+
+
+
+
+ Abbrechen + +
+
+{% endblock %} diff --git a/netbox_customer_export/templates/netbox_customer_export/symbol_list.html b/netbox_customer_export/templates/netbox_customer_export/symbol_list.html new file mode 100644 index 0000000..f69c3c7 --- /dev/null +++ b/netbox_customer_export/templates/netbox_customer_export/symbol_list.html @@ -0,0 +1,54 @@ +{% extends 'generic/_base.html' %} +{% block title %}Topologie-Symbole{% endblock %} +{% block controls %} + {% if perms.netbox_customer_export.add_topologysymbol %} +
+ Zuordnung hinzufügen +
+ {% endif %} +{% endblock %} +{% block content %} + + +
+
+
+

Zuordnungen je Gerätetyp

+ {% include 'netbox_customer_export/_symbol_table.html' with rows=types empty="Noch keine Gerätetypen zugeordnet." %} +
+
+

Zuordnungen je Geräterolle

+ {% include 'netbox_customer_export/_symbol_table.html' with rows=roles empty="Noch keine Geräterollen zugeordnet." %} +
+
+
+
+

So wird das Symbol bestimmt

+
+
    +
  1. Gerätetyp-Zuordnung (eigenes Icon oder Symbol)
  2. +
  3. Geräterolle-Zuordnung
  4. +
  5. Automatisch anhand von Rolle, Modell und Hersteller, z.B. „Switch“, „Firewall“, „Sophos“, „Access Point“. + Patchpanels, Steckdosenleisten, PDUs und USVs werden automatisch ausgeblendet.
  6. +
+
+
+
+

Eingebaute Symbole

+
+ +
+
+
+
+{% endblock %} diff --git a/netbox_customer_export/topology.py b/netbox_customer_export/topology.py new file mode 100644 index 0000000..40b06e4 --- /dev/null +++ b/netbox_customer_export/topology.py @@ -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 = ['', ''] + for n in g.nodes.values(): + key = id(n.icon) + if key not in icon_cache: + icon_cache[key] = _small_icon(n.icon) + label = escape(n.label) + "".join(f"
{escape(s)}" for s in n.sub) + style = ( + "shape=image;html=1;verticalLabelPosition=bottom;verticalAlign=top;labelBackgroundColor=none;" + f"imageAspect=0;aspect=fixed;fontStyle=1;{'opacity=55;' if n.external else ''}" + f"image=data:image/png,{icon_cache[key]};" + ) + cells.append( + f'' + f'' + ) + for i, e in enumerate(g.edges): + dashed = g.nodes[e.a].external or g.nodes[e.b].external + label = "
".join(escape(x) for x in _oriented(g, e)) if show_ports else (f"{len(e.labels)}×" if len(e.labels) > 1 else "") + style = f"endArrow=none;html=1;strokeWidth=2;strokeColor={'#999999' if dashed else '#333333'};{'dashed=1;' if dashed else ''}fontSize=9;labelBackgroundColor=#ffffff;" + cells.append( + f'' + '' + ) + pages.append( + f'' + f'{"".join(cells)}' + ) + return f'{"".join(pages)}'.encode("utf-8") + + +__all__ = ["build_graphs", "render_png", "render_drawio", "SIZE"] diff --git a/netbox_customer_export/urls.py b/netbox_customer_export/urls.py index f5db161..01a1c50 100644 --- a/netbox_customer_export/urls.py +++ b/netbox_customer_export/urls.py @@ -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//", branding_views.BrandingEditView.as_view(), name="branding_edit"), path("branding//delete/", branding_views.BrandingDeleteView.as_view(), name="branding_delete"), path("branding//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//", symbol_views.SymbolEditView.as_view(), name="symbol_edit"), + path("symbols//delete/", symbol_views.SymbolDeleteView.as_view(), name="symbol_delete"), + path("symbols/builtin/.png", symbol_views.BuiltinIconView.as_view(), name="symbol_builtin"), ] diff --git a/netbox_customer_export/views.py b/netbox_customer_export/views.py index 8a2151b..615b3b3 100644 --- a/netbox_customer_export/views.py +++ b/netbox_customer_export/views.py @@ -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) diff --git a/pyproject.toml b/pyproject.toml index caff09e..7095a9c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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"