diff --git a/README.md b/README.md index d771451..3dd6fd1 100644 --- a/README.md +++ b/README.md @@ -12,6 +12,9 @@ Der Techniker wählt vor dem Export aus, welche Inhalte das Dokument enthalten s | 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`) | +Über **IP-Versionen** (IPv4, IPv6 oder beides) steuert man, welche Adressen im Dokument erscheinen: Management-/OOB-IPs, +VMs, Topologie-Beschriftung, IP-Netze und IP-Adressen. + Alle Abfragen laufen über die NetBox-Berechtigungen (`.restrict(user, "view")`). Ein Techniker exportiert also nur, was er auch sehen darf. ## Aufruf @@ -223,6 +226,7 @@ Der Abschnitt **Netzwerk-Topologie** zeichnet je Standort ein Diagramm mit Symbo Danach geht das Dokument im Hochformat weiter. * Provider-Leitungen erscheinen als Wolke, Geräte anderer Standorte gestrichelt (abschaltbar). * Beschriftung wählbar: Management-IP, Modell, Ports an den Verbindungen. +* Gerätenamen über 15 Zeichen (z.B. länger als „poller-thlg-ase“) werden umgebrochen, bevorzugt an `-`, `_`, `.` oder Leerzeichen. **Symbole** (Menü **Plugins → Topologie-Symbole**, oder Button „Topologie-Symbol“ auf Gerätetyp- und Geräterollen-Seiten): diff --git a/netbox_customer_export/__init__.py b/netbox_customer_export/__init__.py index bea5b87..d8fbecb 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.6.2" + version = "0.7.0" author = "Claude" base_url = "customer-export" min_version = "4.6.8" diff --git a/netbox_customer_export/forms.py b/netbox_customer_export/forms.py index 1651729..ed688ad 100644 --- a/netbox_customer_export/forms.py +++ b/netbox_customer_export/forms.py @@ -77,6 +77,14 @@ class ExportForm(forms.Form): ) topo_isolated = forms.BooleanField(required=False, initial=False, label="Unverkabelte Geräte anzeigen") + ip_versions = forms.MultipleChoiceField( + choices=(("4", "IPv4"), ("6", "IPv6")), + initial=["4", "6"], + required=False, + widget=forms.CheckboxSelectMultiple, + label="IP-Versionen", + ) + 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") diff --git a/netbox_customer_export/sections.py b/netbox_customer_export/sections.py index 24f2b1d..4d1a3e4 100644 --- a/netbox_customer_export/sections.py +++ b/netbox_customer_export/sections.py @@ -35,7 +35,24 @@ def section(key, label, group, default=True, hint=""): # --- Gerätespalten (vom Techniker wählbar) ------------------------------------ -def _rack_pos(d): +def ip_versions(opts): + """Gewählte IP-Versionen als Menge {4, 6}. Ohne Formular (z.B. Vorschau) beide.""" + versions = opts.get("ip_versions") + return {4, 6} if versions is None else {int(v) for v in versions} + + +def ip_text(ips, opts, sep=", "): + """IP-Objekte gefiltert nach gewählter Version als Text.""" + versions = ip_versions(opts) + return sep.join(str(ip.address) for ip in ips if ip and ip.address.version in versions) or None + + +def primary_ips(obj, opts, sep=", "): + """Primäre IPv4 und/oder IPv6 eines Geräts oder einer VM, je nach Auswahl.""" + return ip_text([obj.primary_ip4, obj.primary_ip6], opts, sep) + + +def _rack_pos(d, o=None): if not d.rack: return None pos = f" / HE {d.position:g}" if d.position else "" @@ -44,27 +61,27 @@ def _rack_pos(d): DEVICE_COLUMNS = OrderedDict( [ - ("name", ("Name", lambda d: d.name or str(d))), - ("model", ("Hersteller / Modell", lambda d: f"{d.device_type.manufacturer} {d.device_type.model}")), - ("site", ("Standort", lambda d: d.site)), - ("location", ("Lokation", lambda d: d.location)), + ("name", ("Name", lambda d, o: d.name or str(d))), + ("model", ("Hersteller / Modell", lambda d, o: f"{d.device_type.manufacturer} {d.device_type.model}")), + ("site", ("Standort", lambda d, o: d.site)), + ("location", ("Lokation", lambda d, o: d.location)), ("rack", ("Rack / HE", _rack_pos)), - ("status", ("Status", lambda d: d.get_status_display())), - ("platform", ("Plattform / OS", lambda d: d.platform)), - ("primary_ip", ("Management-IP", lambda d: d.primary_ip.address if d.primary_ip else None)), - ("oob_ip", ("OOB-IP", lambda d: d.oob_ip.address if d.oob_ip else None)), - ("serial", ("Seriennummer", lambda d: d.serial)), - ("asset_tag", ("Inventarnr.", lambda d: d.asset_tag)), - ("tenant", ("Mandant", lambda d: d.tenant)), - ("description", ("Beschreibung", lambda d: d.description)), + ("status", ("Status", lambda d, o: d.get_status_display())), + ("platform", ("Plattform / OS", lambda d, o: d.platform)), + ("primary_ip", ("Management-IP", lambda d, o: primary_ips(d, o))), + ("oob_ip", ("OOB-IP", lambda d, o: ip_text([d.oob_ip], o))), + ("serial", ("Seriennummer", lambda d, o: d.serial)), + ("asset_tag", ("Inventarnr.", lambda d, o: d.asset_tag)), + ("tenant", ("Mandant", lambda d, o: d.tenant)), + ("description", ("Beschreibung", lambda d, o: d.description)), ] ) DEFAULT_DEVICE_COLUMNS = ["name", "model", "location", "rack", "primary_ip", "serial"] -def _device_table(devices, columns): +def _device_table(devices, columns, opts): cols = [DEVICE_COLUMNS[c] for c in columns if c in DEVICE_COLUMNS] - return T([c[0] for c in cols], [[c[1](d) for c in cols] for d in devices]) + return T([c[0] for c in cols], [[c[1](d, opts) for c in cols] for d in devices]) def _empty(opts, blocks, text="Keine Einträge vorhanden."): @@ -230,9 +247,9 @@ def devices(scope, opts): if opts.get("group_by_site"): for site, site_devs in groupby(devs, key=lambda d: d.site): blocks.append(H(3, f"Standort {site}")) - blocks.append(_device_table(list(site_devs), columns)) + blocks.append(_device_table(list(site_devs), columns, opts)) else: - blocks.append(_device_table(devs, columns)) + blocks.append(_device_table(devs, columns, opts)) return blocks if found else _empty(opts, blocks) @@ -260,7 +277,7 @@ def vms(scope, opts): blocks.append( T( ["Name", "Cluster", "Rolle", "Status", "OS", "vCPU", "RAM (MB)", "Disk (MB)", "IP"], - [[vm.name, vm.cluster, vm.role, get(vm, "status"), vm.platform, vm.vcpus, vm.memory, vm.disk, vm.primary_ip.address if vm.primary_ip else None] for vm in qs], + [[vm.name, vm.cluster, vm.role, get(vm, "status"), vm.platform, vm.vcpus, vm.memory, vm.disk, primary_ips(vm, opts)] for vm in qs], ) ) return blocks @@ -284,6 +301,7 @@ def vrfs(scope, opts): @section("prefixes", "IP-Netze", "Netzwerk", hint="Präfixe gruppiert nach VRF") def prefixes(scope, opts): qs = scope.prefixes().select_related("vrf", "vlan", "role", "tenant").order_by("vrf__name", "prefix") + qs = qs.filter(prefix__family__in=ip_versions(opts)) blocks = [H(1, "IP-Netze")] if not qs.exists(): return _empty(opts, blocks) @@ -316,6 +334,7 @@ def vlans(scope, opts): @section("ips", "IP-Adressen", "Netzwerk", default=False, hint="Ausführlich – kann lang werden") def ips(scope, opts): qs = scope.ip_addresses().select_related("vrf", "tenant").prefetch_related("assigned_object").order_by("vrf__name", "address") + qs = qs.filter(address__family__in=ip_versions(opts)) blocks = [H(1, "IP-Adressen")] if not qs.exists(): return _empty(opts, blocks) diff --git a/netbox_customer_export/templates/netbox_customer_export/export.html b/netbox_customer_export/templates/netbox_customer_export/export.html index 2cd1ce0..5ac7f04 100644 --- a/netbox_customer_export/templates/netbox_customer_export/export.html +++ b/netbox_customer_export/templates/netbox_customer_export/export.html @@ -56,6 +56,10 @@ {% endif %} +
IP-Versionen
+ {% include 'netbox_customer_export/_pills.html' with field=form.ip_versions %} +
Gilt für Management-/OOB-IPs, VMs, Topologie, IP-Netze und IP-Adressen.
+
Optionen
{% if form.include_secrets %} diff --git a/netbox_customer_export/topology.py b/netbox_customer_export/topology.py index f4fd0f2..8ef6805 100644 --- a/netbox_customer_export/topology.py +++ b/netbox_customer_export/topology.py @@ -1,4 +1,5 @@ """Netzwerk-Topologie: Graph aus den Kabelverbindungen, Ebenen-Layout mit rechtwinkligen Leitungen, PNG-Ausgabe.""" +import re from collections import OrderedDict, defaultdict, deque from dataclasses import dataclass, field from io import BytesIO @@ -37,12 +38,41 @@ class Graph: # --- Graph aufbauen ----------------------------------------------------------- -def _device_sub(device, details): +NAME_MAX = 15 # Zeichen je Zeile, z.B. „poller-thlg-ase“; längere Namen werden umgebrochen + + +def wrap_name(name, limit=NAME_MAX): + """Namen in Zeilen zu höchstens `limit` Zeichen teilen, bevorzugt nach „-“, „_“, „.“ oder Leerzeichen.""" + if len(name) <= limit: + return [name] + lines, cur = [], "" + for token in re.findall(r"[^-_. ]+[-_. ]*|[-_. ]+", name): + if len((cur + token).rstrip()) <= limit: + cur += token + continue + if cur.strip(): + lines.append(cur.rstrip()) + while len(token.rstrip()) > limit: # Wort ohne Trennzeichen: hart teilen + lines.append(token[:limit]) + token = token[limit:] + cur = token + if cur.strip(): + lines.append(cur.rstrip()) + return lines + + +def _device_sub(device, opts): + from .sections import ip_versions + + details = set(opts.get("topo_details") or []) 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)) + if "primary_ip" in details: + versions = ip_versions(opts) + for ip in (device.primary_ip4, device.primary_ip6): + if ip and ip.address.version in versions: + sub.append(str(ip.address.ip)) return sub @@ -50,7 +80,6 @@ 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( @@ -120,7 +149,7 @@ def build_graphs(scope, opts): 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)) + nodes[f"d{pk}"] = Node(f"d{pk}", d.name or str(d), kind, icon, _device_sub(d, opts)) edges = [] for (a, b), labels in links.items(): in_a, in_b = a in nodes, b in nodes @@ -152,8 +181,11 @@ LANE = 11 # Abstand der Sammelschienen im Zwischenraum PORT_H = 13 # Zeilenhöhe der Port-Beschriftungen +NAME_LINE_H = 15 # Zeilenhöhe des Gerätenamens + + def _label_h(n): - return 20 + 13 * len(n.sub) + return 20 + NAME_LINE_H * (len(wrap_name(n.label)) - 1) + 13 * len(n.sub) def _layout(nodes, edges): @@ -428,12 +460,15 @@ def render_png(graph, show_ports=False, scale=2): pic.putalpha(pic.getchannel("A").point(lambda v: int(v * 0.5))) img.paste(pic, (int((n.x - ICON / 2) * S), int(n.y * S)), pic) ty = (n.y + ICON + 3) * S - d.text( - (n.x * S, ty), _clip(d, n.label, f_name, (CELL_W - 8) * S), - fill=(120, 120, 120) if n.external else (20, 20, 20), font=f_name, anchor="ma", - ) + name_lines = wrap_name(n.label) + for i, part in enumerate(name_lines): + d.text( + (n.x * S, ty + i * NAME_LINE_H * S), _clip(d, part, f_name, (CELL_W - 8) * S), + fill=(120, 120, 120) if n.external else (20, 20, 20), font=f_name, anchor="ma", + ) + sub_y = ty + (16 + NAME_LINE_H * (len(name_lines) - 1)) * S for i, sub in enumerate(n.sub): - d.text((n.x * S, ty + (16 + i * 13) * S), _clip(d, sub, f_sub, (CELL_W - 8) * S), fill=(100, 100, 100), font=f_sub, anchor="ma") + d.text((n.x * S, sub_y + i * 13 * S), _clip(d, sub, f_sub, (CELL_W - 8) * S), fill=(100, 100, 100), font=f_sub, anchor="ma") for text, x, y, anchor in labels: tag(text, x, y, anchor) diff --git a/pyproject.toml b/pyproject.toml index d0bf369..0c10e91 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "netbox-customer-export" -version = "0.6.2" +version = "0.7.0" description = "NetBox-Plugin: Kundendokumentation (PDF/Word) für Standorte, Lokationen, Mandanten und Mandantengruppen" readme = "README.md" requires-python = ">=3.10"