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:
@@ -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)
|
||||
|
||||
|
||||
|
||||
@@ -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()
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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]
|
||||
|
||||
Reference in New Issue
Block a user