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 %}

{{ h.text }}

{% elif h.level == 2 %}

{{ h.text }}

{% else %}

{{ h.text }}

{% endif %}{% endfor %}
{% endif %} diff --git a/netbox_customer_export/templates/netbox_customer_export/export.html b/netbox_customer_export/templates/netbox_customer_export/export.html index 5ac7f04..6502e82 100644 --- a/netbox_customer_export/templates/netbox_customer_export/export.html +++ b/netbox_customer_export/templates/netbox_customer_export/export.html @@ -131,7 +131,10 @@ {% endif %}
-
Beschriftung
+
Umfang
+ {% include 'netbox_customer_export/_pills.html' with field=form.topo_scope %} +
„Je Rack“ zeigt zusätzlich jedes Rack einzeln; verbundene Geräte außerhalb erscheinen gestrichelt.
+
Beschriftung
{% 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"