Files
NetBox-Customer-Export/netbox_customer_export/renderers/docx.py
T
MrBlakeandClaude Opus 5.5 e477aa9b49 feat: network topology diagram with Packet-Tracer-style symbols
- New section "Netzwerk-Topologie": one diagram per site built from
  NetBox cable paths (traced through patch panels), layered top-down by
  cabling (provider > firewall/router > core > access > endpoints),
  barycenter ordering, "2x" for parallel links, provider circuits as
  clouds and devices outside the scope dashed. Labels: management IP,
  model, ports.
- New export format "draw.io": the topology as an editable .drawio file
  (one page per site, symbols embedded).
- 14 built-in symbols drawn with Pillow; automatic mapping from role,
  model and manufacturer, passive gear (patch panels, PDUs, UPS) hidden.
- New TopologySymbol model (migration 0002) with list/edit/delete UI,
  symbol gallery, custom icon upload and a "Topologie-Symbol" button on
  device type and device role pages.

Bump version to 0.4.0 - requires "manage.py migrate".

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-23 14:12:55 +02:00

183 lines
7.2 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import zipfile
from io import BytesIO
from . import MissingDependency
DOTX_TYPE = b"application/vnd.openxmlformats-officedocument.wordprocessingml.template.main+xml"
DOCX_TYPE = b"application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml"
def _svg_to_png(svg):
try:
import cairosvg
except (ImportError, OSError):
return None
try:
return cairosvg.svg2png(bytestring=svg.encode("utf-8"), scale=2)
except Exception: # noqa: BLE001 – dann greift das Pillow-PNG
return None
def _load_template(path):
"""Öffnet eine .docx/.dotx-Vorlage und leert deren Textkörper (Formatvorlagen, Kopf-/Fußzeile bleiben)."""
from docx import Document as Docx
buf = BytesIO()
with zipfile.ZipFile(path) as src, zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as dst:
for item in src.infolist():
data = src.read(item.filename)
if item.filename == "[Content_Types].xml":
data = data.replace(DOTX_TYPE, DOCX_TYPE) # .dotx -> .docx, sonst lehnt python-docx ab
dst.writestr(item, data)
buf.seek(0)
d = Docx(buf)
body = d.element.body
for child in list(body):
if not child.tag.endswith("}sectPr"):
body.remove(child)
return d
def render_docx(doc):
try:
from docx import Document as Docx
from docx.enum.text import WD_ALIGN_PARAGRAPH
from docx.oxml import OxmlElement
from docx.oxml.ns import qn
from docx.shared import Cm, Pt, RGBColor
except ImportError as e:
raise MissingDependency(f"Word-Export benötigt python-docx (pip install python-docx cairosvg): {e}")
b = doc.branding
primary = RGBColor.from_string(b["primary_color"])
template = b.get("docx_template")
if template:
try:
d = _load_template(template)
except (OSError, KeyError, zipfile.BadZipFile) as e:
raise MissingDependency(f"Word-Vorlage {template!r} konnte nicht geladen werden: {e}")
else:
# Ohne Vorlage: Standarddokument mit Farben/Schrift aus dem Branding
d = Docx()
sec = d.sections[0]
sec.left_margin = sec.right_margin = Cm(2)
sec.top_margin = sec.bottom_margin = Cm(2)
d.styles["Normal"].font.name = b["font_family"]
d.styles["Normal"].font.size = Pt(b["font_size"])
for level, color in ((1, b["primary_color"]), (2, b["primary_color"]), (3, b["accent_color"]), (4, b["accent_color"])):
style = d.styles[f"Heading {level}"]
style.font.name = b["font_family"]
style.font.color.rgb = RGBColor.from_string(color)
sec = d.sections[0]
style_names = {s.name for s in d.styles}
table_style = b.get("docx_table_style") if b.get("docx_table_style") in style_names else None
def shade(cell, color):
tc_pr = cell._tc.get_or_add_tcPr()
shd = OxmlElement("w:shd")
shd.set(qn("w:val"), "clear")
shd.set(qn("w:fill"), color)
tc_pr.append(shd)
def set_style(table):
if table_style:
table.style = table_style
elif "Table Grid" in style_names:
table.style = "Table Grid"
# Deckblatt
meta = doc.meta
if b.get("logo_path"):
d.add_picture(b["logo_path"], width=Cm(5))
for _ in range(6):
d.add_paragraph()
title = d.add_paragraph()
run = title.add_run(doc.title)
run.bold, run.font.size = True, Pt(28)
run.font.color.rgb = primary
if doc.subtitle:
d.add_paragraph().add_run(doc.subtitle).font.size = Pt(16)
d.add_paragraph()
info = d.add_paragraph()
for label, value in (("Stand", meta.get("date")), ("Erstellt von", meta.get("author")), ("", b.get("company_name"))):
if value:
info.add_run(f"{label + ': ' if label else ''}{value}\n")
d.add_page_break()
# Inhaltsverzeichnis (wird beim Öffnen in Word aktualisiert)
d.add_heading("Inhaltsverzeichnis", level=1)
fld = OxmlElement("w:fldSimple")
fld.set(qn("w:instr"), 'TOC \\o "1-2" \\h \\z \\u')
d.add_paragraph()._p.append(fld)
upd = OxmlElement("w:updateFields")
upd.set(qn("w:val"), "true")
d.settings.element.append(upd)
d.add_page_break()
for blk in doc.blocks:
if blk.kind == "heading":
d.add_heading(blk.text, level=min(blk.level, 4))
elif blk.kind == "paragraph":
d.add_paragraph(blk.text)
elif blk.kind == "pagebreak":
d.add_page_break()
elif blk.kind in ("table", "kv"):
cols = len(blk.headers) if blk.kind == "table" else 2
t = d.add_table(rows=0, cols=cols)
set_style(t)
if blk.kind == "table":
cells = t.add_row().cells
for c, h in zip(cells, blk.headers):
c.text = h
r = c.paragraphs[0].runs[0]
r.bold = True
if not table_style: # eine Tabellenformatvorlage bringt eigene Kopfzeilenfarben mit
r.font.color.rgb = RGBColor.from_string(b["header_text_color"])
shade(c, b["primary_color"])
# Kopfzeile auf jeder Seite wiederholen
tr_pr = t.rows[0]._tr.get_or_add_trPr()
hdr = OxmlElement("w:tblHeader")
hdr.set(qn("w:val"), "true")
tr_pr.append(hdr)
for row in blk.rows:
cells = t.add_row().cells
for i, (c, v) in enumerate(zip(cells, row)):
c.text = v
for p in c.paragraphs:
for r in p.runs:
r.font.size = Pt(max(b["font_size"] - 1, 7))
if blk.kind == "kv" and i == 0:
c.paragraphs[0].runs[0].bold = True
if not table_style:
shade(c, b["label_bg_color"])
d.add_paragraph()
elif blk.kind == "images":
t = d.add_table(rows=2, cols=len(blk.images))
for i, img in enumerate(blk.images):
png = (_svg_to_png(img["svg"]) if img["svg"] else None) or img["png"]
cell = t.cell(0, i).paragraphs[0]
cell.alignment = WD_ALIGN_PARAGRAPH.CENTER
if png:
# Topologie: volle Breite; Racks: 7 cm (Höhe skaliert mit der Rackgröße)
cell.add_run().add_picture(BytesIO(png), width=Cm(16.5 if blk.level == 1 else 7))
else:
cell.add_run("(Rack-Grafik nicht verfügbar)")
cap = t.cell(1, i).paragraphs[0]
cap.alignment = WD_ALIGN_PARAGRAPH.CENTER
if img["caption"]:
cap.add_run(img["caption"]).italic = True
d.add_paragraph()
# Fußzeile nur setzen, wenn die Vorlage keine eigene mitbringt
footer = sec.footer
if not any(p.text.strip() for p in footer.paragraphs):
text = " · ".join(x for x in (b.get("company_name"), b.get("footer_text"), doc.title) if x)
p = footer.paragraphs[0] if footer.paragraphs else footer.add_paragraph()
p.add_run(text).font.size = Pt(8)
out = BytesIO()
d.save(out)
return out.getvalue()