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):
|
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)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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")
|
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)
|
||||||
|
|||||||
@@ -12,7 +12,10 @@ def _svg_to_png(svg):
|
|||||||
import cairosvg
|
import cairosvg
|
||||||
except (ImportError, OSError):
|
except (ImportError, OSError):
|
||||||
return None
|
return None
|
||||||
|
try:
|
||||||
return cairosvg.svg2png(bytestring=svg.encode("utf-8"), scale=2)
|
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
|
||||||
|
|||||||
@@ -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]
|
||||||
|
|||||||
Reference in New Issue
Block a user