diff --git a/README.md b/README.md
index 3dd6fd1..76a72f9 100644
--- a/README.md
+++ b/README.md
@@ -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.
* 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.
+* **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.
Danach geht das Dokument im Hochformat weiter.
* Provider-Leitungen erscheinen als Wolke, Geräte anderer Standorte gestrichelt (abschaltbar).
diff --git a/netbox_customer_export/__init__.py b/netbox_customer_export/__init__.py
index d8fbecb..ca4fbc4 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.7.0"
+ version = "0.8.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 ed688ad..a11f755 100644
--- a/netbox_customer_export/forms.py
+++ b/netbox_customer_export/forms.py
@@ -72,6 +72,13 @@ class ExportForm(forms.Form):
widget=forms.CheckboxSelectMultiple,
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(
required=False, initial=True, label="Provider-Leitungen & Geräte außerhalb anzeigen"
)
diff --git a/netbox_customer_export/sections.py b/netbox_customer_export/sections.py
index 4d1a3e4..514484f 100644
--- a/netbox_customer_export/sections.py
+++ b/netbox_customer_export/sections.py
@@ -432,12 +432,19 @@ def topology(scope, opts):
from .topology import build_graphs, render_png
title = H(1, "Netzwerk-Topologie")
- graphs = build_graphs(scope, opts)
- if not graphs:
- return _empty(opts, [title], "Keine verkabelten Geräte gefunden.")
+ scopes = opts.get("topo_scope") or ["site"]
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 [
- LANDSCAPE(([title] if i == 0 else []) + [H(2, g.title)], render_png(g, show_ports=ports))
- for i, g in enumerate(graphs)
+ LANDSCAPE(([title] if i == 0 else []) + headings, render_png(g, show_ports=ports))
+ for i, (headings, g) in enumerate(pages)
]
diff --git a/netbox_customer_export/templates/netbox_customer_export/document.html b/netbox_customer_export/templates/netbox_customer_export/document.html
index 2dabbe5..4abeaab 100644
--- a/netbox_customer_export/templates/netbox_customer_export/document.html
+++ b/netbox_customer_export/templates/netbox_customer_export/document.html
@@ -45,6 +45,7 @@
.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 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; }
@media screen { .landscape img { max-height: none; width: 100%; } }
.pagebreak { page-break-after: always; }
@@ -82,7 +83,7 @@
{% elif b.kind == 'pagebreak' %}
{% elif b.kind == 'landscape' %}
- {% for h in b.rows %}{% if h.level == 1 %}
{{ h.text }}
{% else %}
{{ h.text }}
{% endif %}{% endfor %}
+ {% for h in b.rows %}{% if h.level == 1 %}
{% include 'netbox_customer_export/_pills.html' with field=form.topo_details %}
Weiteres
diff --git a/netbox_customer_export/topology.py b/netbox_customer_export/topology.py
index 8ef6805..edbd5bf 100644
--- a/netbox_customer_export/topology.py
+++ b/netbox_customer_export/topology.py
@@ -76,14 +76,78 @@ def _device_sub(device, opts):
return sub
-def build_graphs(scope, opts):
- """Ein Graph je Standort. Geräte anderer Standorte und Provider-Leitungen erscheinen als externe Knoten."""
+def build_graphs(scope, opts, per="site"):
+ """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
resolver = SymbolResolver()
devices = list(
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")
)
drawn = {} # device.pk -> (kind, icon)
@@ -139,38 +203,7 @@ def build_graphs(scope, opts):
if label not in links[key]:
links[key].append(label)
- # Knoten je Standort
- 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
+ return by_pk, drawn, links, external
# --- Layout -------------------------------------------------------------------
diff --git a/pyproject.toml b/pyproject.toml
index 0c10e91..226dadc 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
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"
readme = "README.md"
requires-python = ">=3.10"