feat: topology on its own landscape page, as large as possible
The topology image was shrunk inside the flex container used for rack images. Each site's topology now gets a dedicated A4 landscape page (named @page in PDF, landscape section in Word) with the section and site headings, scaled to the full usable width or height depending on its aspect ratio. The document switches back to portrait only when more content follows, so no blank pages are produced. The TOC includes headings on landscape pages. Also fix Word exports without a template using US Letter instead of A4. Bump version to 0.6.0. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -219,6 +219,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.
|
||||||
|
* 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).
|
* Provider-Leitungen erscheinen als Wolke, Geräte anderer Standorte gestrichelt (abschaltbar).
|
||||||
* Beschriftung wählbar: Management-IP, Modell, Ports an den Verbindungen.
|
* Beschriftung wählbar: Management-IP, Modell, Ports an den Verbindungen.
|
||||||
|
|
||||||
|
|||||||
@@ -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.5.0"
|
version = "0.6.0"
|
||||||
author = "Claude"
|
author = "Claude"
|
||||||
base_url = "customer-export"
|
base_url = "customer-export"
|
||||||
min_version = "4.6.8"
|
min_version = "4.6.8"
|
||||||
|
|||||||
@@ -28,7 +28,11 @@ class Document:
|
|||||||
|
|
||||||
@property
|
@property
|
||||||
def toc(self):
|
def toc(self):
|
||||||
return [b for b in self.blocks if b.kind == "heading" and b.level <= 2]
|
out = []
|
||||||
|
for b in self.blocks:
|
||||||
|
headings = b.rows if b.kind == "landscape" else [b]
|
||||||
|
out += [h for h in headings if h.kind == "heading" and h.level <= 2]
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
_anchor_counter = {"n": 0}
|
_anchor_counter = {"n": 0}
|
||||||
@@ -66,6 +70,12 @@ def IMAGES(images, wide=False):
|
|||||||
return Block("images", images=out, level=1 if wide else 0) # level=1: volle Seitenbreite
|
return Block("images", images=out, level=1 if wide else 0) # level=1: volle Seitenbreite
|
||||||
|
|
||||||
|
|
||||||
|
def LANDSCAPE(headings, png):
|
||||||
|
"""Eigene Seite im Querformat: Überschriften + Bild so groß wie möglich (z.B. Topologie)."""
|
||||||
|
data_uri = "data:image/png;base64," + base64.b64encode(png).decode("ascii")
|
||||||
|
return Block("landscape", rows=list(headings), images=[{"svg": None, "png": png, "caption": "", "data_uri": data_uri}])
|
||||||
|
|
||||||
|
|
||||||
def PAGEBREAK():
|
def PAGEBREAK():
|
||||||
return Block("pagebreak")
|
return Block("pagebreak")
|
||||||
|
|
||||||
|
|||||||
@@ -61,6 +61,7 @@ def render_docx(doc):
|
|||||||
# Ohne Vorlage: Standarddokument mit Farben/Schrift aus dem Branding
|
# Ohne Vorlage: Standarddokument mit Farben/Schrift aus dem Branding
|
||||||
d = Docx()
|
d = Docx()
|
||||||
sec = d.sections[0]
|
sec = d.sections[0]
|
||||||
|
sec.page_width, sec.page_height = Cm(21), Cm(29.7) # A4 statt US-Letter aus der python-docx-Vorlage
|
||||||
sec.left_margin = sec.right_margin = Cm(2)
|
sec.left_margin = sec.right_margin = Cm(2)
|
||||||
sec.top_margin = sec.bottom_margin = Cm(2)
|
sec.top_margin = sec.bottom_margin = Cm(2)
|
||||||
d.styles["Normal"].font.name = b["font_family"]
|
d.styles["Normal"].font.name = b["font_family"]
|
||||||
@@ -116,8 +117,42 @@ def render_docx(doc):
|
|||||||
d.settings.element.append(upd)
|
d.settings.element.append(upd)
|
||||||
d.add_page_break()
|
d.add_page_break()
|
||||||
|
|
||||||
|
from docx.enum.section import WD_ORIENT, WD_SECTION
|
||||||
|
|
||||||
|
portrait = d.sections[-1]
|
||||||
|
portrait_size = (portrait.page_width, portrait.page_height)
|
||||||
|
landscape_open = False
|
||||||
|
|
||||||
|
def new_section(landscape):
|
||||||
|
s = d.add_section(WD_SECTION.NEW_PAGE)
|
||||||
|
w, h = portrait_size
|
||||||
|
s.orientation = WD_ORIENT.LANDSCAPE if landscape else WD_ORIENT.PORTRAIT
|
||||||
|
s.page_width, s.page_height = (h, w) if landscape else (w, h)
|
||||||
|
return s
|
||||||
|
|
||||||
for blk in doc.blocks:
|
for blk in doc.blocks:
|
||||||
if blk.kind == "heading":
|
if landscape_open and blk.kind != "landscape":
|
||||||
|
# erst beim nächsten Inhalt zurück ins Hochformat – vermeidet leere Seiten
|
||||||
|
new_section(landscape=False)
|
||||||
|
landscape_open = False
|
||||||
|
if blk.kind == "landscape":
|
||||||
|
s = new_section(landscape=True)
|
||||||
|
landscape_open = True
|
||||||
|
for h in blk.rows:
|
||||||
|
d.add_heading(h.text, level=h.level)
|
||||||
|
png = blk.images[0]["png"]
|
||||||
|
from PIL import Image
|
||||||
|
|
||||||
|
px_w, px_h = Image.open(BytesIO(png)).size
|
||||||
|
avail_w = s.page_width - s.left_margin - s.right_margin
|
||||||
|
avail_h = s.page_height - s.top_margin - s.bottom_margin - Cm(1.4 * len(blk.rows) + 0.8)
|
||||||
|
para = d.add_paragraph()
|
||||||
|
para.alignment = WD_ALIGN_PARAGRAPH.CENTER
|
||||||
|
if px_w / px_h >= avail_w / avail_h:
|
||||||
|
para.add_run().add_picture(BytesIO(png), width=avail_w)
|
||||||
|
else:
|
||||||
|
para.add_run().add_picture(BytesIO(png), height=avail_h)
|
||||||
|
elif blk.kind == "heading":
|
||||||
d.add_heading(blk.text, level=min(blk.level, 4))
|
d.add_heading(blk.text, level=min(blk.level, 4))
|
||||||
elif blk.kind == "paragraph":
|
elif blk.kind == "paragraph":
|
||||||
d.add_paragraph(blk.text)
|
d.add_paragraph(blk.text)
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ from django.apps import apps
|
|||||||
from django.contrib.contenttypes.models import ContentType
|
from django.contrib.contenttypes.models import ContentType
|
||||||
from netbox.plugins import get_plugin_config
|
from netbox.plugins import get_plugin_config
|
||||||
|
|
||||||
from .blocks import EMPTY, H, IMAGES, KV, P, PAGEBREAK, T, get
|
from .blocks import EMPTY, H, IMAGES, KV, LANDSCAPE, P, PAGEBREAK, T, get
|
||||||
from .rack_png import render_rack_png
|
from .rack_png import render_rack_png
|
||||||
from .rack_svg import render_rack_svg
|
from .rack_svg import render_rack_svg
|
||||||
|
|
||||||
@@ -412,12 +412,13 @@ def licenses(scope, opts):
|
|||||||
def topology(scope, opts):
|
def topology(scope, opts):
|
||||||
from .topology import build_graphs, render_png
|
from .topology import build_graphs, render_png
|
||||||
|
|
||||||
blocks = [H(1, "Netzwerk-Topologie")]
|
title = H(1, "Netzwerk-Topologie")
|
||||||
graphs = build_graphs(scope, opts)
|
graphs = build_graphs(scope, opts)
|
||||||
if not graphs:
|
if not graphs:
|
||||||
return _empty(opts, blocks, "Keine verkabelten Geräte gefunden.")
|
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 [])
|
||||||
for g in graphs:
|
# Je Standort eine Querformat-Seite; die Abschnittsüberschrift steht mit auf der ersten
|
||||||
blocks.append(H(2, g.title))
|
return [
|
||||||
blocks.append(IMAGES([(None, "", render_png(g, show_ports=ports))], wide=True))
|
LANDSCAPE(([title] if i == 0 else []) + [H(2, g.title)], render_png(g, show_ports=ports))
|
||||||
return blocks
|
for i, g in enumerate(graphs)
|
||||||
|
]
|
||||||
|
|||||||
@@ -8,6 +8,7 @@
|
|||||||
@page { size: A4; margin: 20mm 15mm 20mm 15mm;
|
@page { size: A4; margin: 20mm 15mm 20mm 15mm;
|
||||||
@bottom-left { content: element(footer); }
|
@bottom-left { content: element(footer); }
|
||||||
@bottom-right { content: "Seite " counter(page) " / " counter(pages); font-size: 7pt; color: #777; } }
|
@bottom-right { content: "Seite " counter(page) " / " counter(pages); font-size: 7pt; color: #777; } }
|
||||||
|
@page landscape { size: A4 landscape; margin: 12mm 15mm 16mm 15mm; }
|
||||||
@page cover { @bottom-left { content: none } @bottom-right { content: none } }
|
@page cover { @bottom-left { content: none } @bottom-right { content: none } }
|
||||||
body { font-family: "{{ b.font_family }}", "Segoe UI", "DejaVu Sans", Arial, sans-serif; font-size: {{ b.font_size }}pt; color: #222; }
|
body { font-family: "{{ b.font_family }}", "Segoe UI", "DejaVu Sans", Arial, sans-serif; font-size: {{ b.font_size }}pt; color: #222; }
|
||||||
.cover { page: cover; padding-top: 60mm; page-break-after: always; }
|
.cover { page: cover; padding-top: 60mm; page-break-after: always; }
|
||||||
@@ -40,6 +41,11 @@
|
|||||||
figcaption { font-style: italic; color: #555; }
|
figcaption { font-style: italic; color: #555; }
|
||||||
.footer { position: running(footer); font-size: 7pt; color: #777; }
|
.footer { position: running(footer); font-size: 7pt; color: #777; }
|
||||||
@media screen { .footer { display: none; } }
|
@media screen { .footer { display: none; } }
|
||||||
|
.landscape { page: landscape; page-break-before: always; page-break-after: always; }
|
||||||
|
.landscape h1 { page-break-before: avoid; margin-top: 0; }
|
||||||
|
.landscape h2 { margin-top: 4pt; }
|
||||||
|
.landscape img { display: block; margin: 0 auto; max-width: 100%; max-height: 152mm; }
|
||||||
|
@media screen { .landscape img { max-height: none; width: 100%; } }
|
||||||
.pagebreak { page-break-after: always; }
|
.pagebreak { page-break-after: always; }
|
||||||
@media screen { body { max-width: 1100px; margin: auto; padding: 16px; } }
|
@media screen { body { max-width: 1100px; margin: auto; padding: 16px; } }
|
||||||
{{ b.extra_css|safe }}
|
{{ b.extra_css|safe }}
|
||||||
@@ -73,6 +79,11 @@
|
|||||||
{% elif b.kind == 'images' %}
|
{% elif b.kind == 'images' %}
|
||||||
<div class="images{% if b.level == 1 %} wide{% endif %}">{% for img in b.images %}<figure><img src="{{ img.data_uri }}" alt="{{ img.caption }}">{% if img.caption %}<figcaption>{{ img.caption }}</figcaption>{% endif %}</figure>{% endfor %}</div>
|
<div class="images{% if b.level == 1 %} wide{% endif %}">{% for img in b.images %}<figure><img src="{{ img.data_uri }}" alt="{{ img.caption }}">{% if img.caption %}<figcaption>{{ img.caption }}</figcaption>{% endif %}</figure>{% endfor %}</div>
|
||||||
{% elif b.kind == 'pagebreak' %}<div class="pagebreak"></div>
|
{% elif b.kind == 'pagebreak' %}<div class="pagebreak"></div>
|
||||||
|
{% elif b.kind == '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 %}
|
||||||
|
<img src="{{ b.images.0.data_uri }}" alt="">
|
||||||
|
</section>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
+1
-1
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "netbox-customer-export"
|
name = "netbox-customer-export"
|
||||||
version = "0.5.0"
|
version = "0.6.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"
|
||||||
|
|||||||
Reference in New Issue
Block a user