feat: IPv4/IPv6 selection and wrapped device names in topology

- New export option "IP-Versionen" (IPv4, IPv6 or both, default both).
  Applies to management/OOB IP columns, VMs, topology labels and filters
  the prefix and IP address sections by family.
- Topology device names longer than 15 characters are wrapped onto
  several lines, preferably after "-", "_", "." or spaces; row heights
  adapt to the number of lines.

Bump version to 0.7.0.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-09-23 15:50:32 +02:00
co-authored by Claude Opus 5.5
parent cbf09c219c
commit 3ad7cc6810
7 changed files with 101 additions and 31 deletions
+4
View File
@@ -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** | | 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`) | | 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. Alle Abfragen laufen über die NetBox-Berechtigungen (`.restrict(user, "view")`). Ein Techniker exportiert also nur, was er auch sehen darf.
## Aufruf ## Aufruf
@@ -223,6 +226,7 @@ Der Abschnitt **Netzwerk-Topologie** zeichnet je Standort ein Diagramm mit Symbo
Danach geht das Dokument im Hochformat weiter. Danach geht das Dokument im Hochformat weiter.
* Provider-Leitungen erscheinen als Wolke, Geräte anderer Standorte gestrichelt (abschaltbar). * Provider-Leitungen erscheinen als Wolke, Geräte anderer Standorte gestrichelt (abschaltbar).
* Beschriftung wählbar: Management-IP, Modell, Ports an den Verbindungen. * Beschriftung wählbar: Management-IP, Modell, Ports an den Verbindungen.
* 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): **Symbole** (Menü **Plugins → Topologie-Symbole**, oder Button „Topologie-Symbol“ auf Gerätetyp- und Geräterollen-Seiten):
+1 -1
View File
@@ -5,7 +5,7 @@ class CustomerExportConfig(PluginConfig):
name = "netbox_customer_export" name = "netbox_customer_export"
verbose_name = "Kunden-Export" verbose_name = "Kunden-Export"
description = "Kundendokumentation als PDF oder Word exportieren" description = "Kundendokumentation als PDF oder Word exportieren"
version = "0.6.2" version = "0.7.0"
author = "Claude" author = "Claude"
base_url = "customer-export" base_url = "customer-export"
min_version = "4.6.8" min_version = "4.6.8"
+8
View File
@@ -77,6 +77,14 @@ class ExportForm(forms.Form):
) )
topo_isolated = forms.BooleanField(required=False, initial=False, label="Unverkabelte Geräte anzeigen") 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_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") include_secrets = forms.BooleanField(required=False, label="Geheimnisse (z.B. WLAN-PSK) mit exportieren")
+37 -18
View File
@@ -35,7 +35,24 @@ def section(key, label, group, default=True, hint=""):
# --- Gerätespalten (vom Techniker wählbar) ------------------------------------ # --- 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: if not d.rack:
return None return None
pos = f" / HE {d.position:g}" if d.position else "" pos = f" / HE {d.position:g}" if d.position else ""
@@ -44,27 +61,27 @@ def _rack_pos(d):
DEVICE_COLUMNS = OrderedDict( DEVICE_COLUMNS = OrderedDict(
[ [
("name", ("Name", lambda d: d.name or str(d))), ("name", ("Name", lambda d, o: d.name or str(d))),
("model", ("Hersteller / Modell", lambda d: f"{d.device_type.manufacturer} {d.device_type.model}")), ("model", ("Hersteller / Modell", lambda d, o: f"{d.device_type.manufacturer} {d.device_type.model}")),
("site", ("Standort", lambda d: d.site)), ("site", ("Standort", lambda d, o: d.site)),
("location", ("Lokation", lambda d: d.location)), ("location", ("Lokation", lambda d, o: d.location)),
("rack", ("Rack / HE", _rack_pos)), ("rack", ("Rack / HE", _rack_pos)),
("status", ("Status", lambda d: d.get_status_display())), ("status", ("Status", lambda d, o: d.get_status_display())),
("platform", ("Plattform / OS", lambda d: d.platform)), ("platform", ("Plattform / OS", lambda d, o: d.platform)),
("primary_ip", ("Management-IP", lambda d: d.primary_ip.address if d.primary_ip else None)), ("primary_ip", ("Management-IP", lambda d, o: primary_ips(d, o))),
("oob_ip", ("OOB-IP", lambda d: d.oob_ip.address if d.oob_ip else None)), ("oob_ip", ("OOB-IP", lambda d, o: ip_text([d.oob_ip], o))),
("serial", ("Seriennummer", lambda d: d.serial)), ("serial", ("Seriennummer", lambda d, o: d.serial)),
("asset_tag", ("Inventarnr.", lambda d: d.asset_tag)), ("asset_tag", ("Inventarnr.", lambda d, o: d.asset_tag)),
("tenant", ("Mandant", lambda d: d.tenant)), ("tenant", ("Mandant", lambda d, o: d.tenant)),
("description", ("Beschreibung", lambda d: d.description)), ("description", ("Beschreibung", lambda d, o: d.description)),
] ]
) )
DEFAULT_DEVICE_COLUMNS = ["name", "model", "location", "rack", "primary_ip", "serial"] 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] 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."): def _empty(opts, blocks, text="Keine Einträge vorhanden."):
@@ -230,9 +247,9 @@ def devices(scope, opts):
if opts.get("group_by_site"): if opts.get("group_by_site"):
for site, site_devs in groupby(devs, key=lambda d: d.site): for site, site_devs in groupby(devs, key=lambda d: d.site):
blocks.append(H(3, f"Standort {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: else:
blocks.append(_device_table(devs, columns)) blocks.append(_device_table(devs, columns, opts))
return blocks if found else _empty(opts, blocks) return blocks if found else _empty(opts, blocks)
@@ -260,7 +277,7 @@ def vms(scope, opts):
blocks.append( blocks.append(
T( T(
["Name", "Cluster", "Rolle", "Status", "OS", "vCPU", "RAM (MB)", "Disk (MB)", "IP"], ["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 return blocks
@@ -284,6 +301,7 @@ def vrfs(scope, opts):
@section("prefixes", "IP-Netze", "Netzwerk", hint="Präfixe gruppiert nach VRF") @section("prefixes", "IP-Netze", "Netzwerk", hint="Präfixe gruppiert nach VRF")
def prefixes(scope, opts): def prefixes(scope, opts):
qs = scope.prefixes().select_related("vrf", "vlan", "role", "tenant").order_by("vrf__name", "prefix") 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")] blocks = [H(1, "IP-Netze")]
if not qs.exists(): if not qs.exists():
return _empty(opts, blocks) 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") @section("ips", "IP-Adressen", "Netzwerk", default=False, hint="Ausführlich – kann lang werden")
def ips(scope, opts): def ips(scope, opts):
qs = scope.ip_addresses().select_related("vrf", "tenant").prefetch_related("assigned_object").order_by("vrf__name", "address") 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")] blocks = [H(1, "IP-Adressen")]
if not qs.exists(): if not qs.exists():
return _empty(opts, blocks) return _empty(opts, blocks)
@@ -56,6 +56,10 @@
<input type="hidden" name="branding" value="{{ form.branding.field.choices.0.0 }}"> <input type="hidden" name="branding" value="{{ form.branding.field.choices.0.0 }}">
{% endif %} {% endif %}
<div class="ce-label">IP-Versionen</div>
{% include 'netbox_customer_export/_pills.html' with field=form.ip_versions %}
<div class="form-text mb-2">Gilt für Management-/OOB-IPs, VMs, Topologie, IP-Netze und IP-Adressen.</div>
<div class="ce-label">Optionen</div> <div class="ce-label">Optionen</div>
<label class="ce-switch">{{ form.include_empty }} {{ form.include_empty.label }}</label> <label class="ce-switch">{{ form.include_empty }} {{ form.include_empty.label }}</label>
{% if form.include_secrets %} {% if form.include_secrets %}
+46 -11
View File
@@ -1,4 +1,5 @@
"""Netzwerk-Topologie: Graph aus den Kabelverbindungen, Ebenen-Layout mit rechtwinkligen Leitungen, PNG-Ausgabe.""" """Netzwerk-Topologie: Graph aus den Kabelverbindungen, Ebenen-Layout mit rechtwinkligen Leitungen, PNG-Ausgabe."""
import re
from collections import OrderedDict, defaultdict, deque from collections import OrderedDict, defaultdict, deque
from dataclasses import dataclass, field from dataclasses import dataclass, field
from io import BytesIO from io import BytesIO
@@ -37,12 +38,41 @@ class Graph:
# --- Graph aufbauen ----------------------------------------------------------- # --- 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 = [] sub = []
if "model" in details: if "model" in details:
sub.append(str(device.device_type.model)) sub.append(str(device.device_type.model))
if "primary_ip" in details and device.primary_ip: if "primary_ip" in details:
sub.append(str(device.primary_ip.address.ip)) 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 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.""" """Ein Graph je Standort. Geräte anderer Standorte und Provider-Leitungen erscheinen als externe Knoten."""
from dcim.models import Interface from dcim.models import Interface
details = set(opts.get("topo_details") or [])
resolver = SymbolResolver() resolver = SymbolResolver()
devices = list( devices = list(
scope.devices().select_related( scope.devices().select_related(
@@ -120,7 +149,7 @@ def build_graphs(scope, opts):
for pk in pks: for pk in pks:
d = by_pk[pk] d = by_pk[pk]
kind, icon = drawn[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 = [] edges = []
for (a, b), labels in links.items(): for (a, b), labels in links.items():
in_a, in_b = a in nodes, b in nodes 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 PORT_H = 13 # Zeilenhöhe der Port-Beschriftungen
NAME_LINE_H = 15 # Zeilenhöhe des Gerätenamens
def _label_h(n): 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): 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))) 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) img.paste(pic, (int((n.x - ICON / 2) * S), int(n.y * S)), pic)
ty = (n.y + ICON + 3) * S ty = (n.y + ICON + 3) * S
d.text( name_lines = wrap_name(n.label)
(n.x * S, ty), _clip(d, n.label, f_name, (CELL_W - 8) * S), for i, part in enumerate(name_lines):
fill=(120, 120, 120) if n.external else (20, 20, 20), font=f_name, anchor="ma", 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): 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: for text, x, y, anchor in labels:
tag(text, x, y, anchor) tag(text, x, y, anchor)
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project] [project]
name = "netbox-customer-export" name = "netbox-customer-export"
version = "0.6.2" version = "0.7.0"
description = "NetBox-Plugin: Kundendokumentation (PDF/Word) für Standorte, Lokationen, Mandanten und Mandantengruppen" description = "NetBox-Plugin: Kundendokumentation (PDF/Word) für Standorte, Lokationen, Mandanten und Mandantengruppen"
readme = "README.md" readme = "README.md"
requires-python = ">=3.10" requires-python = ">=3.10"