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:
@@ -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"
|
||||
|
||||
@@ -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")
|
||||
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user