From 5b2d68356cb9c5c1f8d9e018ce39567c125daeaa Mon Sep 17 00:00:00 2001 From: Louis Date: Wed, 23 Sep 2026 13:56:39 +0200 Subject: [PATCH] feat: optional device images in rack elevations, HTML preview in new tab MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - New export option "Gerätebilder anzeigen": the SVG is rendered with include_images and the /media/ image links are replaced by embedded data URIs, since Word, WeasyPrint and SVG-as-image cannot load login-protected files. The Pillow PNG renderer draws the front/rear images too, with the device name on a dark label. - Custom rack_svg_renderer adapters receive images=..., older adapters without that parameter keep working. - The HTML preview now opens in a new browser tab. Bump version to 0.3.0. Co-Authored-By: Claude Opus 5.5 --- README.md | 2 +- netbox_customer_export/__init__.py | 2 +- netbox_customer_export/forms.py | 1 + netbox_customer_export/rack_png.py | 41 +++++++++++++-- netbox_customer_export/rack_svg.py | 50 +++++++++++++++++-- netbox_customer_export/sections.py | 5 +- .../netbox_customer_export/export.html | 11 +++- pyproject.toml | 2 +- 8 files changed, 97 insertions(+), 17 deletions(-) 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; } -
+ {% csrf_token %} {% if form.errors %}
{{ form.errors }}
{% endif %} @@ -108,7 +108,8 @@

Racks

- + +
Seiten
{% include 'netbox_customer_export/_pills.html' with field=form.rack_faces %}
Weiteres
@@ -146,6 +147,12 @@ } document.addEventListener('change', sync); sync(); + // HTML-Vorschau in neuem Tab öffnen, PDF/Word laden wie gewohnt herunter + var form = document.getElementById('ce-export-form'); + form.addEventListener('submit', function () { + var fmt = form.querySelector('input[name=format]:checked'); + form.target = fmt && fmt.value === 'html' ? '_blank' : ''; + }); })(); {% endblock %} diff --git a/pyproject.toml b/pyproject.toml index 7175012..e7180f1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "netbox-customer-export" -version = "0.2.0" +version = "0.3.0" description = "NetBox-Plugin: Kundendokumentation (PDF/Word) für Standorte, Lokationen, Mandanten und Mandantengruppen" readme = "README.md" requires-python = ">=3.10"