diff --git a/README.md b/README.md
index 4bedc4d..db61bd1 100644
--- a/README.md
+++ b/README.md
@@ -8,7 +8,7 @@ Der Techniker wählt vor dem Export aus, welche Inhalte das Dokument enthalten s
| Gruppe | Inhalte |
|---|---|
| Allgemein | Übersicht & Standortdaten (Adresse, Lokationsbaum, Kennzahlen), Ansprechpartner |
-| Infrastruktur | Racks nach Standort → Lokation mit Rack-Ansicht (SVG, Vorder-/Rückseite) und Belegungsliste, Geräte nach **frei wählbaren Geräterollen** (Server, Switche, APs, Router …) mit **frei wählbaren Spalten**, Stromversorgung, VMs |
+| Infrastruktur | Racks nach Standort → Lokation mit Rack-Ansicht (Vorder-/Rückseite, optional mit Gerätebildern wie in NetBox) und Belegungsliste, Geräte nach **frei wählbaren Geräterollen** (Server, Switche, APs, Router …) mit **frei wählbaren Spalten**, Stromversorgung, VMs |
| Netzwerk | VRFs (inkl. RTs), Präfixe nach VRF, VLANs, IP-Adressen (ausführlich), WLANs/SSIDs, Leitungen |
| Verträge & Lizenzen | Lizenzen aus einem Lizenz-Plugin (Standard: `netbox-lifecycle`) |
diff --git a/netbox_customer_export/__init__.py b/netbox_customer_export/__init__.py
index 56785bd..00b8b74 100644
--- a/netbox_customer_export/__init__.py
+++ b/netbox_customer_export/__init__.py
@@ -5,7 +5,7 @@ class CustomerExportConfig(PluginConfig):
name = "netbox_customer_export"
verbose_name = "Kunden-Export"
description = "Kundendokumentation als PDF oder Word exportieren"
- version = "0.2.0"
+ version = "0.3.0"
author = "Claude"
base_url = "customer-export"
min_version = "4.6.8"
diff --git a/netbox_customer_export/forms.py b/netbox_customer_export/forms.py
index d99887d..9f876dc 100644
--- a/netbox_customer_export/forms.py
+++ b/netbox_customer_export/forms.py
@@ -53,6 +53,7 @@ class ExportForm(forms.Form):
group_by_site = forms.BooleanField(required=False, initial=True, label="Geräte nach Standort gruppieren")
rack_svg = forms.BooleanField(required=False, initial=True, label="Rack-Ansichten (SVG) einbetten")
+ rack_images = forms.BooleanField(required=False, initial=False, label="Gerätebilder anzeigen (wie in NetBox)")
rack_faces = forms.MultipleChoiceField(
choices=(("front", "Vorderseite"), ("rear", "Rückseite")),
initial=["front"],
diff --git a/netbox_customer_export/rack_png.py b/netbox_customer_export/rack_png.py
index 84cff61..82d3269 100644
--- a/netbox_customer_export/rack_png.py
+++ b/netbox_customer_export/rack_png.py
@@ -53,8 +53,28 @@ def _fit(draw, text, font, width):
return text + "…"
-def render_rack_png(rack, face, devices):
- """devices: iterierbare Geräte des Racks (bereits nach Berechtigung gefiltert)."""
+def _paste_image(img, image_field, box):
+ """Gerätebild in das Rechteck box=(x1, y1, x2, y2) einpassen. False, wenn nicht lesbar."""
+ from PIL import Image
+
+ x1, y1, x2, y2 = (int(round(v)) for v in box)
+ try:
+ with image_field.open("rb") as f:
+ pic = Image.open(f).convert("RGBA")
+ pic = pic.resize((max(x2 - x1, 1), max(y2 - y1, 1)), Image.LANCZOS)
+ except (OSError, ValueError):
+ return False
+ img.paste(pic, (x1, y1), pic)
+ return True
+
+
+def render_rack_png(rack, face, devices, images=False):
+ """devices: iterierbare Geräte des Racks (bereits nach Berechtigung gefiltert).
+
+ images=True: Front-/Rückbilder der Gerätetypen einzeichnen (wie die NetBox-Rackansicht).
+ """
+ from .rack_svg import device_image
+
from PIL import Image, ImageDraw
units = int(rack.u_height)
@@ -99,9 +119,20 @@ def render_rack_png(rack, face, devices):
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")
+ box = (left + 1, y1 + 1, left + BODY_W - 1, y2 - 1)
+ image = device_image(d.device_type, face) if images and not rear_only else None
+ name = _fit(draw, d.name or str(d.device_type), normal, BODY_W - 20 * SCALE)
+ if image and _paste_image(img, image, box):
+ draw.rectangle(box, outline=FRAME_COLOR)
+ # Name auf dunklem Balken, damit er auf jedem Bild lesbar bleibt
+ tw = draw.textlength(name, font=normal)
+ cx, cy = left + BODY_W / 2, (y1 + y2) / 2
+ pad_x, half_h = 5 * SCALE, 7 * SCALE
+ draw.rounded_rectangle([cx - tw / 2 - pad_x, cy - half_h, cx + tw / 2 + pad_x, cy + half_h], radius=3 * SCALE, fill=(30, 30, 30))
+ draw.text((cx, cy), name, fill=(255, 255, 255), font=normal, anchor="mm")
+ else:
+ draw.rectangle(box, fill=fill, outline=FRAME_COLOR)
+ 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)
diff --git a/netbox_customer_export/rack_svg.py b/netbox_customer_export/rack_svg.py
index 8486ba0..5be56d1 100644
--- a/netbox_customer_export/rack_svg.py
+++ b/netbox_customer_export/rack_svg.py
@@ -1,5 +1,7 @@
"""Rack-Ansichten offline (ohne HTTP-Aufruf der API) als SVG erzeugen."""
+import base64
import logging
+import mimetypes
from django.utils.module_loading import import_string
from netbox.plugins import get_plugin_config
@@ -13,17 +15,52 @@ LEGEND_WIDTH = 30
MARGIN_WIDTH = 15
-def render_rack_svg(rack, face, user):
+def device_image(device_type, face):
+ """Bilddatei (FieldFile) des Gerätetyps für die Seite oder None."""
+ image = getattr(device_type, "front_image" if face == "front" else "rear_image", None)
+ return image if image else None
+
+
+def _data_uri(image):
+ mime = mimetypes.guess_type(image.name)[0] or "image/png"
+ with image.open("rb") as f:
+ return f"data:{mime};base64,{base64.b64encode(f.read()).decode('ascii')}"
+
+
+def _inline_images(svg, devices):
+ """Ersetzt die /media/…-Links der Gerätebilder durch eingebettete Daten.
+
+ Word, WeasyPrint und laden keine externen (login-geschützten) Dateien nach.
+ """
+ seen = set()
+ for d in devices:
+ for face in ("front", "rear"):
+ image = device_image(d.device_type, face)
+ if not image or image.name in seen:
+ continue
+ seen.add(image.name)
+ try:
+ svg = svg.replace(f'"{image.url}"', f'"{_data_uri(image)}"')
+ except OSError:
+ logger.warning("Gerätebild %s nicht lesbar", image.name)
+ return svg
+
+
+def render_rack_svg(rack, face, user, images=False, devices=()):
"""Gibt (svg, fehler) zurück – genau einer der beiden Werte ist None.
Ist ``rack_svg_renderer`` gesetzt (z.B. der Offline-Renderer aus Netbox-Utilities, der mehrere
- Geräte pro HE korrekt darstellt), wird dieser aufgerufen: ``fn(rack=..., face=..., user=...)``.
+ Geräte pro HE korrekt darstellt), wird dieser aufgerufen: ``fn(rack=..., face=..., user=..., images=...)``.
Andernfalls wird NetBox' eigener RackElevationSVG direkt in-process genutzt.
"""
try:
renderer = get_plugin_config("netbox_customer_export", "rack_svg_renderer")
if renderer:
- svg = import_string(renderer)(rack=rack, face=face, user=user)
+ fn = import_string(renderer)
+ try:
+ svg = fn(rack=rack, face=face, user=user, images=images)
+ except TypeError: # ältere Adapter ohne images-Parameter
+ svg = fn(rack=rack, face=face, user=user)
else:
from dcim.svg import RackElevationSVG
@@ -36,10 +73,13 @@ def render_rack_svg(rack, face, user):
legend_width=LEGEND_WIDTH,
margin_width=MARGIN_WIDTH,
user=user,
- include_images=False,
+ include_images=images,
base_url="",
).render(face)
- return (svg.tostring() if hasattr(svg, "tostring") else str(svg)), None
+ svg = svg.tostring() if hasattr(svg, "tostring") else str(svg)
+ if images:
+ svg = _inline_images(svg, devices)
+ return 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)
return None, f"{type(e).__name__}: {e}"
diff --git a/netbox_customer_export/sections.py b/netbox_customer_export/sections.py
index 37733e8..fd9fdbf 100644
--- a/netbox_customer_export/sections.py
+++ b/netbox_customer_export/sections.py
@@ -178,14 +178,15 @@ def racks(scope, opts):
rack_devs = list(
scope.restrict(rack.devices.all()).select_related("device_type", "role")
)
+ with_images = bool(opts.get("rack_images"))
for face in opts.get("rack_faces") or ["front"]:
caption = "Vorderseite" if face == "front" else "Rückseite"
- svg, svg_error = render_rack_svg(rack, face, scope.user)
+ svg, svg_error = render_rack_svg(rack, face, scope.user, images=with_images, devices=rack_devs)
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)
+ png = render_rack_png(rack, face, rack_devs, images=with_images)
except Exception as e: # noqa: BLE001
svg_error = f"{svg_error or ''} / PNG: {type(e).__name__}: {e}".strip(" /")
if svg or png:
diff --git a/netbox_customer_export/templates/netbox_customer_export/export.html b/netbox_customer_export/templates/netbox_customer_export/export.html
index 3358f41..0e76a2b 100644
--- a/netbox_customer_export/templates/netbox_customer_export/export.html
+++ b/netbox_customer_export/templates/netbox_customer_export/export.html
@@ -27,7 +27,7 @@
.ce-format .btn { flex: 1; }
-