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** |
| 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):
+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.6.2"
version = "0.7.0"
author = "Claude"
base_url = "customer-export"
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")
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")
+37 -18
View File
@@ -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)
@@ -56,6 +56,10 @@
<input type="hidden" name="branding" value="{{ form.branding.field.choices.0.0 }}">
{% 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>
<label class="ce-switch">{{ form.include_empty }} {{ form.include_empty.label }}</label>
{% 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."""
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)
+1 -1
View File
@@ -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"