From c37626aff1203cc8d67e8b1cf756ef7ebdb1a5f9 Mon Sep 17 00:00:00 2001 From: Louis Date: Wed, 23 Sep 2026 13:15:34 +0200 Subject: [PATCH] fix: rack elevations missing in all export formats RackElevationSVG falls back to RACK_ELEVATION_DEFAULT_* config parameters, which raise AttributeError on the target NetBox version. Pass the dimensions explicitly instead. Add a Pillow-based PNG rack renderer (role colors, unit legend, full-depth devices shown dimmed from the other side). It is used for Word when cairosvg is unavailable and replaces a failed SVG in all formats. Co-Authored-By: Claude Opus 5.5 --- netbox_customer_export/blocks.py | 13 ++- netbox_customer_export/rack_png.py | 110 +++++++++++++++++++++++ netbox_customer_export/rack_svg.py | 19 +++- netbox_customer_export/renderers/docx.py | 11 ++- netbox_customer_export/sections.py | 19 +++- 5 files changed, 159 insertions(+), 13 deletions(-) create mode 100644 netbox_customer_export/rack_png.py diff --git a/netbox_customer_export/blocks.py b/netbox_customer_export/blocks.py index baed8a2..0bb05c8 100644 --- a/netbox_customer_export/blocks.py +++ b/netbox_customer_export/blocks.py @@ -53,11 +53,16 @@ def KV(pairs, skip_empty=True): def IMAGES(images): - """images: Liste von (svg_string, caption).""" + """images: Liste von (svg_string oder None, caption, png_bytes_oder_None).""" out = [] - for svg, caption in images: - data = base64.b64encode(svg.encode("utf-8")).decode("ascii") - out.append({"svg": svg, "caption": caption, "data_uri": f"data:image/svg+xml;base64,{data}"}) + 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) diff --git a/netbox_customer_export/rack_png.py b/netbox_customer_export/rack_png.py new file mode 100644 index 0000000..84cff61 --- /dev/null +++ b/netbox_customer_export/rack_png.py @@ -0,0 +1,110 @@ +"""Rack-Ansicht als PNG mit Pillow (in NetBox ohnehin installiert). + +Wird für Word genutzt, wenn cairosvg nicht verfügbar ist oder das SVG nicht erzeugt werden konnte. +""" +from io import BytesIO + +SCALE = 2 +UNIT_H = 22 * SCALE +BODY_W = 220 * SCALE +LEGEND_W = 30 * SCALE +MARGIN = 15 * SCALE +FRAME = 3 * SCALE + +BG = (255, 255, 255) +RACK_BG = (245, 245, 245) +FRAME_COLOR = (80, 80, 80) +GRID = (215, 215, 215) +LEGEND_TEXT = (110, 110, 110) +FALLBACK_ROLE = (158, 158, 158) + + +def _font(size): + from PIL import ImageFont + + for name in ("DejaVuSans.ttf", "Arial.ttf", "arial.ttf", "LiberationSans-Regular.ttf"): + try: + return ImageFont.truetype(name, size) + except OSError: + continue + try: + return ImageFont.load_default(size=size) + except TypeError: # Pillow < 10.1 + return ImageFont.load_default() + + +def _hex(color): + color = (color or "").lstrip("#") + if len(color) != 6: + return FALLBACK_ROLE + return tuple(int(color[i : i + 2], 16) for i in (0, 2, 4)) + + +def _text_color(rgb): + r, g, b = rgb + return (0, 0, 0) if (r * 299 + g * 587 + b * 114) / 1000 > 140 else (255, 255, 255) + + +def _fit(draw, text, font, width): + if draw.textlength(text, font=font) <= width: + return text + while text and draw.textlength(text + "…", font=font) > width: + text = text[:-1] + return text + "…" + + +def render_rack_png(rack, face, devices): + """devices: iterierbare Geräte des Racks (bereits nach Berechtigung gefiltert).""" + from PIL import Image, ImageDraw + + units = int(rack.u_height) + start = int(getattr(rack, "starting_unit", 1) or 1) + desc = bool(getattr(rack, "desc_units", False)) + width = MARGIN * 2 + LEGEND_W + BODY_W + height = MARGIN * 2 + units * UNIT_H + img = Image.new("RGB", (width, height), BG) + draw = ImageDraw.Draw(img) + small, normal = _font(10 * SCALE), _font(11 * SCALE) + + left, top = MARGIN + LEGEND_W, MARGIN + draw.rectangle([left, top, left + BODY_W, top + units * UNIT_H], fill=RACK_BG) + + def y_of(unit): + """Obere Kante einer Höheneinheit (unit = Nummer wie in NetBox).""" + idx = unit - start # 0 = unterste HE + row = idx if desc else units - 1 - idx + return top + row * UNIT_H + + for i in range(units): + unit = start + i + y = y_of(unit) + draw.line([left, y, left + BODY_W, y], fill=GRID) + label = str(unit) + draw.text((left - 6 * SCALE - draw.textlength(label, font=small), y + UNIT_H / 2), label, fill=LEGEND_TEXT, font=small, anchor="lm") + + for d in devices: + if d.position is None: + continue + on_face = d.face == face or getattr(d.device_type, "is_full_depth", False) + if not on_face: + continue + pos, u = float(d.position), float(d.device_type.u_height or 0) + if u <= 0: + continue + # Gerät belegt [pos, pos+u); stetig gerechnet, damit auch halbe HE passen + offset = (pos - start) if desc else (units - (pos - start) - u) + y1 = top + offset * UNIT_H + y2 = y1 + u * UNIT_H + fill = _hex(d.role.color if d.role else None) + rear_only = d.face != face # Full-Depth-Gerät von der anderen Seite: aufgehellt + if rear_only: + fill = tuple(int(c + (255 - c) * 0.55) for c in fill) + draw.rectangle([left + 1, y1 + 1, left + BODY_W - 1, y2 - 1], fill=fill, outline=FRAME_COLOR) + name = _fit(draw, d.name or str(d.device_type), normal, BODY_W - 12 * SCALE) + draw.text((left + BODY_W / 2, (y1 + y2) / 2), name, fill=_text_color(fill), font=normal, anchor="mm") + + draw.rectangle([left, top, left + BODY_W, top + units * UNIT_H], outline=FRAME_COLOR, width=FRAME) + + out = BytesIO() + img.save(out, format="PNG", optimize=True) + return out.getvalue() diff --git a/netbox_customer_export/rack_svg.py b/netbox_customer_export/rack_svg.py index 7701bdf..8486ba0 100644 --- a/netbox_customer_export/rack_svg.py +++ b/netbox_customer_export/rack_svg.py @@ -6,6 +6,12 @@ from netbox.plugins import get_plugin_config logger = logging.getLogger("netbox_customer_export") +# NetBox-Standardwerte der Rack-Ansicht +UNIT_WIDTH = 220 +UNIT_HEIGHT = 22 +LEGEND_WIDTH = 30 +MARGIN_WIDTH = 15 + def render_rack_svg(rack, face, user): """Gibt (svg, fehler) zurück – genau einer der beiden Werte ist None. @@ -21,7 +27,18 @@ def render_rack_svg(rack, face, user): else: from dcim.svg import RackElevationSVG - svg = RackElevationSVG(rack, user=user, include_images=False, base_url="").render(face) + # Maße explizit übergeben: je nach NetBox-Version fehlen die RACK_ELEVATION_DEFAULT_*-Parameter + # in der Konfiguration, dann bricht der Renderer mit AttributeError ab. + svg = RackElevationSVG( + rack, + unit_width=UNIT_WIDTH, + unit_height=UNIT_HEIGHT, + legend_width=LEGEND_WIDTH, + margin_width=MARGIN_WIDTH, + user=user, + include_images=False, + base_url="", + ).render(face) return (svg.tostring() if hasattr(svg, "tostring") else str(svg)), None except Exception as e: # noqa: BLE001 – ein defektes Rack soll nicht den ganzen Export abbrechen logger.exception("Rack-SVG für %s (%s) fehlgeschlagen", rack, face) diff --git a/netbox_customer_export/renderers/docx.py b/netbox_customer_export/renderers/docx.py index 32292fc..bb9f362 100644 --- a/netbox_customer_export/renderers/docx.py +++ b/netbox_customer_export/renderers/docx.py @@ -12,7 +12,10 @@ def _svg_to_png(svg): import cairosvg except (ImportError, OSError): return None - return cairosvg.svg2png(bytestring=svg.encode("utf-8"), scale=2) + 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): @@ -153,13 +156,13 @@ def render_docx(doc): 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"]) + 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: - cell.add_run().add_picture(BytesIO(png), height=Cm(18)) + cell.add_run().add_picture(BytesIO(png), width=Cm(7)) # Höhe skaliert mit der Rackgröße else: - cell.add_run("(Rack-Grafik benötigt cairosvg)") + cell.add_run("(Rack-Grafik nicht verfügbar)") cap = t.cell(1, i).paragraphs[0] cap.alignment = WD_ALIGN_PARAGRAPH.CENTER cap.add_run(img["caption"]).italic = True diff --git a/netbox_customer_export/sections.py b/netbox_customer_export/sections.py index 1425a42..a2800ed 100644 --- a/netbox_customer_export/sections.py +++ b/netbox_customer_export/sections.py @@ -8,6 +8,7 @@ from django.contrib.contenttypes.models import ContentType from netbox.plugins import get_plugin_config from .blocks import EMPTY, H, IMAGES, KV, P, PAGEBREAK, T, get +from .rack_png import render_rack_png from .rack_svg import render_rack_svg @@ -173,13 +174,23 @@ def racks(scope, opts): ) if opts.get("rack_svg"): images, errors = [], [] + rack_devs = list( + scope.restrict(rack.devices.all()).select_related("device_type", "role") + ) for face in opts.get("rack_faces") or ["front"]: caption = "Vorderseite" if face == "front" else "Rückseite" - svg, error = render_rack_svg(rack, face, scope.user) - if svg: - images.append((svg, caption)) + svg, svg_error = render_rack_svg(rack, face, scope.user) + png = None + # PNG wird für Word gebraucht (falls kein cairosvg) und ersetzt ein fehlgeschlagenes SVG + if opts.get("format") == "docx" or not svg: + try: + png = render_rack_png(rack, face, rack_devs) + except Exception as e: # noqa: BLE001 + svg_error = f"{svg_error or ''} / PNG: {type(e).__name__}: {e}".strip(" /") + if svg or png: + images.append((svg, caption, png)) else: - errors.append(f"Rack-Ansicht ({caption}) konnte nicht erzeugt werden: {error}") + errors.append(f"Rack-Ansicht ({caption}) konnte nicht erzeugt werden: {svg_error}") if images: blocks.append(IMAGES(images)) blocks += [P(e) for e in errors]