feat: optional topology per rack

- New topology option "Umfang": whole network (per site) and/or per
  rack, one landscape page each. Rack pages are grouped under
  "Topologie je Rack" with the rack titles one heading level lower to
  keep the table of contents short.
- Directly connected devices outside the rack are shown dimmed and
  dashed with their rack as subtitle, so uplinks stay visible.
- Cable paths are collected once per export and reused for all views.

Fix: devices from other sites were able to pull in their own neighbors
as external nodes; only direct neighbors of a group's devices are shown
now.

Bump version to 0.8.0.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-09-23 16:01:06 +02:00
co-authored by Claude Opus 5.5
parent 3ad7cc6810
commit 0cd72824d5
8 changed files with 98 additions and 45 deletions
+2
View File
@@ -222,6 +222,8 @@ Der Abschnitt **Netzwerk-Topologie** zeichnet je Standort ein Diagramm mit Symbo
Die Reihenfolge innerhalb einer Ebene wird so optimiert, dass sich möglichst wenige Leitungen kreuzen. Die Reihenfolge innerhalb einer Ebene wird so optimiert, dass sich möglichst wenige Leitungen kreuzen.
* Leitungen werden **rechtwinklig** über eigene Sammelschienen geführt, farbig je übergeordnetem Gerät. Portnamen stehen * Leitungen werden **rechtwinklig** über eigene Sammelschienen geführt, farbig je übergeordnetem Gerät. Portnamen stehen
direkt am Zielgerät, Mehrfach-Uplinks werden dicker gezeichnet und als „2ד markiert. direkt am Zielgerät, Mehrfach-Uplinks werden dicker gezeichnet und als „2ד markiert.
* **Umfang** wählbar: *Gesamtes Netzwerk* (eine Seite je Standort) und/oder *Je Rack* (eine Seite je Rack). In der
Rack-Ansicht erscheinen direkt verbundene Geräte außerhalb des Racks blass und gestrichelt, mit Angabe ihres Racks.
* Jede Topologie steht auf einer **eigenen Seite im Querformat** (PDF und Word) und wird so groß wie möglich skaliert. * Jede Topologie steht auf einer **eigenen Seite im Querformat** (PDF und Word) und wird so groß wie möglich skaliert.
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).
+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.7.0" version = "0.8.0"
author = "Claude" author = "Claude"
base_url = "customer-export" base_url = "customer-export"
min_version = "4.6.8" min_version = "4.6.8"
+7
View File
@@ -72,6 +72,13 @@ class ExportForm(forms.Form):
widget=forms.CheckboxSelectMultiple, widget=forms.CheckboxSelectMultiple,
label="Beschriftung", label="Beschriftung",
) )
topo_scope = forms.MultipleChoiceField(
choices=(("site", "Gesamtes Netzwerk (je Standort)"), ("rack", "Je Rack")),
initial=["site"],
required=False,
widget=forms.CheckboxSelectMultiple,
label="Umfang",
)
topo_external = forms.BooleanField( topo_external = forms.BooleanField(
required=False, initial=True, label="Provider-Leitungen & Geräte außerhalb anzeigen" required=False, initial=True, label="Provider-Leitungen & Geräte außerhalb anzeigen"
) )
+13 -6
View File
@@ -432,12 +432,19 @@ def topology(scope, opts):
from .topology import build_graphs, render_png from .topology import build_graphs, render_png
title = H(1, "Netzwerk-Topologie") title = H(1, "Netzwerk-Topologie")
graphs = build_graphs(scope, opts) scopes = opts.get("topo_scope") or ["site"]
if not graphs:
return _empty(opts, [title], "Keine verkabelten Geräte gefunden.")
ports = "ports" in (opts.get("topo_details") or []) ports = "ports" in (opts.get("topo_details") or [])
# Je Standort eine Querformat-Seite; die Abschnittsüberschrift steht mit auf der ersten pages = [] # (Überschriften, Graph) – je eine Querformat-Seite
if "site" in scopes:
pages += [([H(2, g.title)], g) for g in build_graphs(scope, opts, per="site")]
if "rack" in scopes:
racks = build_graphs(scope, opts, per="rack")
# Rack-Seiten unter einer gemeinsamen Zwischenüberschrift, Racks selbst eine Ebene tiefer (kürzeres Inhaltsverzeichnis)
pages += [(([H(2, "Topologie je Rack")] if i == 0 else []) + [H(3, g.title)], g) for i, g in enumerate(racks)]
if not pages:
return _empty(opts, [title], "Keine verkabelten Geräte gefunden.")
# Die Abschnittsüberschrift steht mit auf der ersten Seite
return [ return [
LANDSCAPE(([title] if i == 0 else []) + [H(2, g.title)], render_png(g, show_ports=ports)) LANDSCAPE(([title] if i == 0 else []) + headings, render_png(g, show_ports=ports))
for i, g in enumerate(graphs) for i, (headings, g) in enumerate(pages)
] ]
@@ -45,6 +45,7 @@
.landscape { page: landscape; page-break-before: always; page-break-after: always; } .landscape { page: landscape; page-break-before: always; page-break-after: always; }
.landscape h1 { page-break-before: avoid; margin: 0 0 2pt 0; font-size: 15pt; padding-bottom: 1pt; } .landscape h1 { page-break-before: avoid; margin: 0 0 2pt 0; font-size: 15pt; padding-bottom: 1pt; }
.landscape h2 { margin: 2pt 0 3pt 0; font-size: 12pt; } .landscape h2 { margin: 2pt 0 3pt 0; font-size: 12pt; }
.landscape h3 { margin: 2pt 0 3pt 0; font-size: 11pt; }
.landscape img { display: block; margin: 0 auto; max-width: 100%; max-height: 172mm; } .landscape img { display: block; margin: 0 auto; max-width: 100%; max-height: 172mm; }
@media screen { .landscape img { max-height: none; width: 100%; } } @media screen { .landscape img { max-height: none; width: 100%; } }
.pagebreak { page-break-after: always; } .pagebreak { page-break-after: always; }
@@ -82,7 +83,7 @@
{% elif b.kind == 'pagebreak' %}<div class="pagebreak"></div> {% elif b.kind == 'pagebreak' %}<div class="pagebreak"></div>
{% elif b.kind == 'landscape' %} {% elif b.kind == 'landscape' %}
<section class="landscape"> <section class="landscape">
{% for h in b.rows %}{% if h.level == 1 %}<h1 id="{{ h.anchor }}">{{ h.text }}</h1>{% else %}<h2 id="{{ h.anchor }}">{{ h.text }}</h2>{% endif %}{% endfor %} {% for h in b.rows %}{% if h.level == 1 %}<h1 id="{{ h.anchor }}">{{ h.text }}</h1>{% elif h.level == 2 %}<h2 id="{{ h.anchor }}">{{ h.text }}</h2>{% else %}<h3 id="{{ h.anchor }}">{{ h.text }}</h3>{% endif %}{% endfor %}
<img src="{{ b.images.0.data_uri }}" alt=""> <img src="{{ b.images.0.data_uri }}" alt="">
</section> </section>
{% endif %} {% endif %}
@@ -131,7 +131,10 @@
{% endif %} {% endif %}
</h2> </h2>
<div class="card-body"> <div class="card-body">
<div class="ce-label mt-0">Beschriftung</div> <div class="ce-label mt-0">Umfang</div>
{% include 'netbox_customer_export/_pills.html' with field=form.topo_scope %}
<div class="form-text mb-2">„Je Rack“ zeigt zusätzlich jedes Rack einzeln; verbundene Geräte außerhalb erscheinen gestrichelt.</div>
<div class="ce-label">Beschriftung</div>
{% include 'netbox_customer_export/_pills.html' with field=form.topo_details %} {% include 'netbox_customer_export/_pills.html' with field=form.topo_details %}
<div class="ce-label">Weiteres</div> <div class="ce-label">Weiteres</div>
<label class="ce-switch">{{ form.topo_external }} {{ form.topo_external.label }}</label> <label class="ce-switch">{{ form.topo_external }} {{ form.topo_external.label }}</label>
+68 -35
View File
@@ -76,14 +76,78 @@ def _device_sub(device, opts):
return sub return sub
def build_graphs(scope, opts): def build_graphs(scope, opts, per="site"):
"""Ein Graph je Standort. Geräte anderer Standorte und Provider-Leitungen erscheinen als externe Knoten.""" """Ein Graph je Standort (per="site") oder je Rack (per="rack").
Verbundene Geräte außerhalb der Gruppe (anderer Standort/anderes Rack) und Provider-Leitungen erscheinen
als externe, gestrichelte Knoten – so sieht man auch in der Rack-Ansicht, wohin die Uplinks gehen.
"""
cache = getattr(scope, "_topology_cache", None)
if cache is None:
cache = scope._topology_cache = _collect(scope, opts)
by_pk, drawn, links, external = cache
groups = OrderedDict()
for pk in drawn:
d = by_pk[pk]
key = d.site if per == "site" else d.rack
if key is not None:
groups.setdefault(key, []).append(pk)
if per == "rack":
groups = OrderedDict(
sorted(groups.items(), key=lambda kv: (str(kv[0].site), str(kv[0].location or ""), kv[0].name))
)
def context(d):
"""Untertitel eines externen Geräts: wo es steht."""
if per == "rack":
return [f"Rack {d.rack}" if d.rack else str(d.site)]
return [str(d.site)]
graphs = []
for group, pks in groups.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, opts))
members = set(nodes) # nur Geräte der Gruppe – externe Nachbarn ziehen keine weiteren Nachbarn nach
edges = []
for (a, b), labels in links.items():
in_a, in_b = a in members, b in members
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 im selben Scope, aber anderer Standort / anderes Rack
d = by_pk[int(nid[1:])]
kind, icon = drawn[d.pk]
other = Node(nid, d.name or str(d), kind, icon, context(d), 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)
if per == "site":
title = f"Standort {group}"
else:
where = " / ".join(str(x) for x in (group.site, group.location) if x)
title = f"Rack {group.name} ({where})"
graphs.append(Graph(title, nodes, edges))
return graphs
def _collect(scope, opts):
"""Geräte, Symbole und Verbindungen (Kabelpfade) des Scopes einmalig einlesen."""
from dcim.models import Interface from dcim.models import Interface
resolver = SymbolResolver() resolver = SymbolResolver()
devices = list( devices = list(
scope.devices().select_related( scope.devices().select_related(
"role", "device_type__manufacturer", "site", "primary_ip4", "primary_ip6" "role", "device_type__manufacturer", "site", "rack__site", "rack__location", "primary_ip4", "primary_ip6"
).order_by("site__name", "name") ).order_by("site__name", "name")
) )
drawn = {} # device.pk -> (kind, icon) drawn = {} # device.pk -> (kind, icon)
@@ -139,38 +203,7 @@ def build_graphs(scope, opts):
if label not in links[key]: if label not in links[key]:
links[key].append(label) links[key].append(label)
# Knoten je Standort return by_pk, drawn, links, external
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, opts))
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 ------------------------------------------------------------------- # --- Layout -------------------------------------------------------------------
+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.7.0" version = "0.8.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"