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 <noreply@anthropic.com>
This commit is contained in:
2026-09-23 13:15:34 +02:00
co-authored by Claude Opus 5.5
parent 8cc861e6af
commit c37626aff1
5 changed files with 159 additions and 13 deletions
+9 -4
View File
@@ -53,11 +53,16 @@ def KV(pairs, skip_empty=True):
def IMAGES(images): def IMAGES(images):
"""images: Liste von (svg_string, caption).""" """images: Liste von (svg_string oder None, caption, png_bytes_oder_None)."""
out = [] out = []
for svg, caption in images: for svg, caption, png in images:
data = base64.b64encode(svg.encode("utf-8")).decode("ascii") if svg:
out.append({"svg": svg, "caption": caption, "data_uri": f"data:image/svg+xml;base64,{data}"}) 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) return Block("images", images=out)
+110
View File
@@ -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()
+18 -1
View File
@@ -6,6 +6,12 @@ from netbox.plugins import get_plugin_config
logger = logging.getLogger("netbox_customer_export") 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): def render_rack_svg(rack, face, user):
"""Gibt (svg, fehler) zurück – genau einer der beiden Werte ist None. """Gibt (svg, fehler) zurück – genau einer der beiden Werte ist None.
@@ -21,7 +27,18 @@ def render_rack_svg(rack, face, user):
else: else:
from dcim.svg import RackElevationSVG 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 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 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) logger.exception("Rack-SVG für %s (%s) fehlgeschlagen", rack, face)
+7 -4
View File
@@ -12,7 +12,10 @@ def _svg_to_png(svg):
import cairosvg import cairosvg
except (ImportError, OSError): except (ImportError, OSError):
return None 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): def _load_template(path):
@@ -153,13 +156,13 @@ def render_docx(doc):
elif blk.kind == "images": elif blk.kind == "images":
t = d.add_table(rows=2, cols=len(blk.images)) t = d.add_table(rows=2, cols=len(blk.images))
for i, img in enumerate(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 = t.cell(0, i).paragraphs[0]
cell.alignment = WD_ALIGN_PARAGRAPH.CENTER cell.alignment = WD_ALIGN_PARAGRAPH.CENTER
if png: 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: 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 = t.cell(1, i).paragraphs[0]
cap.alignment = WD_ALIGN_PARAGRAPH.CENTER cap.alignment = WD_ALIGN_PARAGRAPH.CENTER
cap.add_run(img["caption"]).italic = True cap.add_run(img["caption"]).italic = True
+15 -4
View File
@@ -8,6 +8,7 @@ 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, P, PAGEBREAK, T, get
from .rack_png import render_rack_png
from .rack_svg import render_rack_svg from .rack_svg import render_rack_svg
@@ -173,13 +174,23 @@ def racks(scope, opts):
) )
if opts.get("rack_svg"): if opts.get("rack_svg"):
images, errors = [], [] images, errors = [], []
rack_devs = list(
scope.restrict(rack.devices.all()).select_related("device_type", "role")
)
for face in opts.get("rack_faces") or ["front"]: for face in opts.get("rack_faces") or ["front"]:
caption = "Vorderseite" if face == "front" else "Rückseite" caption = "Vorderseite" if face == "front" else "Rückseite"
svg, error = render_rack_svg(rack, face, scope.user) svg, svg_error = render_rack_svg(rack, face, scope.user)
if svg: png = None
images.append((svg, caption)) # 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: 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: if images:
blocks.append(IMAGES(images)) blocks.append(IMAGES(images))
blocks += [P(e) for e in errors] blocks += [P(e) for e in errors]