- 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>
92 lines
2.6 KiB
Python
92 lines
2.6 KiB
Python
"""Neutrales Dokumentmodell, das sowohl der HTML/PDF- als auch der Word-Renderer versteht."""
|
|
import base64
|
|
from dataclasses import dataclass, field
|
|
|
|
from django.utils.text import slugify
|
|
|
|
EMPTY = "—"
|
|
|
|
|
|
@dataclass
|
|
class Block:
|
|
kind: str # heading | paragraph | table | kv | images | pagebreak
|
|
level: int = 0
|
|
text: str = ""
|
|
headers: list = field(default_factory=list)
|
|
rows: list = field(default_factory=list)
|
|
images: list = field(default_factory=list)
|
|
anchor: str = ""
|
|
|
|
|
|
@dataclass
|
|
class Document:
|
|
title: str
|
|
subtitle: str
|
|
blocks: list
|
|
meta: dict
|
|
branding: dict = field(default_factory=dict)
|
|
|
|
@property
|
|
def toc(self):
|
|
return [b for b in self.blocks if b.kind == "heading" and b.level <= 2]
|
|
|
|
|
|
_anchor_counter = {"n": 0}
|
|
|
|
|
|
def H(level, text):
|
|
_anchor_counter["n"] += 1
|
|
return Block("heading", level=level, text=str(text), anchor=f"s{_anchor_counter['n']}-{slugify(str(text))[:40]}")
|
|
|
|
|
|
def P(text):
|
|
return Block("paragraph", text=str(text))
|
|
|
|
|
|
def T(headers, rows):
|
|
return Block("table", headers=list(headers), rows=[[fmt(c) for c in r] for r in rows])
|
|
|
|
|
|
def KV(pairs, skip_empty=True):
|
|
rows = [[k, fmt(v)] for k, v in pairs if not (skip_empty and v in (None, "", [], EMPTY))]
|
|
return Block("kv", rows=rows)
|
|
|
|
|
|
def IMAGES(images, wide=False):
|
|
"""images: Liste von (svg_string oder None, caption, png_bytes_oder_None)."""
|
|
out = []
|
|
for svg, caption, png in images:
|
|
if svg:
|
|
data_uri = "data:image/svg+xml;base64," + base64.b64encode(svg.encode("utf-8")).decode("ascii")
|
|
elif png:
|
|
data_uri = "data:image/png;base64," + base64.b64encode(png).decode("ascii")
|
|
else:
|
|
continue
|
|
out.append({"svg": svg, "png": png, "caption": caption, "data_uri": data_uri})
|
|
return Block("images", images=out, level=1 if wide else 0) # level=1: volle Seitenbreite
|
|
|
|
|
|
def PAGEBREAK():
|
|
return Block("pagebreak")
|
|
|
|
|
|
def fmt(value):
|
|
if value is None or value == "":
|
|
return EMPTY
|
|
if isinstance(value, bool):
|
|
return "Ja" if value else "Nein"
|
|
if isinstance(value, (list, tuple, set)) or hasattr(value, "all"):
|
|
items = value.all() if hasattr(value, "all") else value
|
|
return ", ".join(str(i) for i in items) or EMPTY
|
|
return str(value)
|
|
|
|
|
|
def get(obj, path):
|
|
"""Dotted-getattr, bevorzugt get_<feld>_display() für Choice-Felder."""
|
|
for part in path.split("."):
|
|
if obj is None:
|
|
return None
|
|
display = getattr(obj, f"get_{part}_display", None)
|
|
obj = display() if callable(display) else getattr(obj, part, None)
|
|
return obj
|