feat: optional device images in rack elevations, HTML preview in new tab
- 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 <noreply@anthropic.com>
This commit is contained in:
@@ -8,7 +8,7 @@ Der Techniker wählt vor dem Export aus, welche Inhalte das Dokument enthalten s
|
|||||||
| Gruppe | Inhalte |
|
| Gruppe | Inhalte |
|
||||||
|---|---|
|
|---|---|
|
||||||
| Allgemein | Übersicht & Standortdaten (Adresse, Lokationsbaum, Kennzahlen), Ansprechpartner |
|
| 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 |
|
| 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`) |
|
| Verträge & Lizenzen | Lizenzen aus einem Lizenz-Plugin (Standard: `netbox-lifecycle`) |
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ class CustomerExportConfig(PluginConfig):
|
|||||||
name = "netbox_customer_export"
|
name = "netbox_customer_export"
|
||||||
verbose_name = "Kunden-Export"
|
verbose_name = "Kunden-Export"
|
||||||
description = "Kundendokumentation als PDF oder Word exportieren"
|
description = "Kundendokumentation als PDF oder Word exportieren"
|
||||||
version = "0.2.0"
|
version = "0.3.0"
|
||||||
author = "Claude"
|
author = "Claude"
|
||||||
base_url = "customer-export"
|
base_url = "customer-export"
|
||||||
min_version = "4.6.8"
|
min_version = "4.6.8"
|
||||||
|
|||||||
@@ -53,6 +53,7 @@ class ExportForm(forms.Form):
|
|||||||
group_by_site = forms.BooleanField(required=False, initial=True, label="Geräte nach Standort gruppieren")
|
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_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(
|
rack_faces = forms.MultipleChoiceField(
|
||||||
choices=(("front", "Vorderseite"), ("rear", "Rückseite")),
|
choices=(("front", "Vorderseite"), ("rear", "Rückseite")),
|
||||||
initial=["front"],
|
initial=["front"],
|
||||||
|
|||||||
@@ -53,8 +53,28 @@ def _fit(draw, text, font, width):
|
|||||||
return text + "…"
|
return text + "…"
|
||||||
|
|
||||||
|
|
||||||
def render_rack_png(rack, face, devices):
|
def _paste_image(img, image_field, box):
|
||||||
"""devices: iterierbare Geräte des Racks (bereits nach Berechtigung gefiltert)."""
|
"""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
|
from PIL import Image, ImageDraw
|
||||||
|
|
||||||
units = int(rack.u_height)
|
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
|
rear_only = d.face != face # Full-Depth-Gerät von der anderen Seite: aufgehellt
|
||||||
if rear_only:
|
if rear_only:
|
||||||
fill = tuple(int(c + (255 - c) * 0.55) for c in fill)
|
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)
|
box = (left + 1, y1 + 1, left + BODY_W - 1, y2 - 1)
|
||||||
name = _fit(draw, d.name or str(d.device_type), normal, BODY_W - 12 * SCALE)
|
image = device_image(d.device_type, face) if images and not rear_only else None
|
||||||
draw.text((left + BODY_W / 2, (y1 + y2) / 2), name, fill=_text_color(fill), font=normal, anchor="mm")
|
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)
|
draw.rectangle([left, top, left + BODY_W, top + units * UNIT_H], outline=FRAME_COLOR, width=FRAME)
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
"""Rack-Ansichten offline (ohne HTTP-Aufruf der API) als SVG erzeugen."""
|
"""Rack-Ansichten offline (ohne HTTP-Aufruf der API) als SVG erzeugen."""
|
||||||
|
import base64
|
||||||
import logging
|
import logging
|
||||||
|
import mimetypes
|
||||||
|
|
||||||
from django.utils.module_loading import import_string
|
from django.utils.module_loading import import_string
|
||||||
from netbox.plugins import get_plugin_config
|
from netbox.plugins import get_plugin_config
|
||||||
@@ -13,17 +15,52 @@ LEGEND_WIDTH = 30
|
|||||||
MARGIN_WIDTH = 15
|
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 <img src="data:image/svg+xml…"> 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.
|
"""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
|
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.
|
Andernfalls wird NetBox' eigener RackElevationSVG direkt in-process genutzt.
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
renderer = get_plugin_config("netbox_customer_export", "rack_svg_renderer")
|
renderer = get_plugin_config("netbox_customer_export", "rack_svg_renderer")
|
||||||
if 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:
|
else:
|
||||||
from dcim.svg import RackElevationSVG
|
from dcim.svg import RackElevationSVG
|
||||||
|
|
||||||
@@ -36,10 +73,13 @@ def render_rack_svg(rack, face, user):
|
|||||||
legend_width=LEGEND_WIDTH,
|
legend_width=LEGEND_WIDTH,
|
||||||
margin_width=MARGIN_WIDTH,
|
margin_width=MARGIN_WIDTH,
|
||||||
user=user,
|
user=user,
|
||||||
include_images=False,
|
include_images=images,
|
||||||
base_url="",
|
base_url="",
|
||||||
).render(face)
|
).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
|
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)
|
||||||
return None, f"{type(e).__name__}: {e}"
|
return None, f"{type(e).__name__}: {e}"
|
||||||
|
|||||||
@@ -178,14 +178,15 @@ def racks(scope, opts):
|
|||||||
rack_devs = list(
|
rack_devs = list(
|
||||||
scope.restrict(rack.devices.all()).select_related("device_type", "role")
|
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"]:
|
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, 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 = None
|
||||||
# PNG wird für Word gebraucht (falls kein cairosvg) und ersetzt ein fehlgeschlagenes SVG
|
# PNG wird für Word gebraucht (falls kein cairosvg) und ersetzt ein fehlgeschlagenes SVG
|
||||||
if opts.get("format") == "docx" or not svg:
|
if opts.get("format") == "docx" or not svg:
|
||||||
try:
|
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
|
except Exception as e: # noqa: BLE001
|
||||||
svg_error = f"{svg_error or ''} / PNG: {type(e).__name__}: {e}".strip(" /")
|
svg_error = f"{svg_error or ''} / PNG: {type(e).__name__}: {e}".strip(" /")
|
||||||
if svg or png:
|
if svg or png:
|
||||||
|
|||||||
@@ -27,7 +27,7 @@
|
|||||||
.ce-format .btn { flex: 1; }
|
.ce-format .btn { flex: 1; }
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
<form method="post" class="mt-3">
|
<form method="post" class="mt-3" id="ce-export-form">
|
||||||
{% csrf_token %}
|
{% csrf_token %}
|
||||||
{% if form.errors %}<div class="alert alert-danger">{{ form.errors }}</div>{% endif %}
|
{% if form.errors %}<div class="alert alert-danger">{{ form.errors }}</div>{% endif %}
|
||||||
|
|
||||||
@@ -108,7 +108,8 @@
|
|||||||
<div class="card ce-card h-100" data-ce-depends="racks">
|
<div class="card ce-card h-100" data-ce-depends="racks">
|
||||||
<h2 class="card-header"><i class="mdi mdi-server"></i> Racks</h2>
|
<h2 class="card-header"><i class="mdi mdi-server"></i> Racks</h2>
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<label class="ce-switch">{{ form.rack_svg }} {{ form.rack_svg.label }}</label>
|
<label class="ce-switch">{{ form.rack_svg }} Rack-Ansichten einbetten</label>
|
||||||
|
<label class="ce-switch">{{ form.rack_images }} {{ form.rack_images.label }}</label>
|
||||||
<div class="ce-label">Seiten</div>
|
<div class="ce-label">Seiten</div>
|
||||||
{% include 'netbox_customer_export/_pills.html' with field=form.rack_faces %}
|
{% include 'netbox_customer_export/_pills.html' with field=form.rack_faces %}
|
||||||
<div class="ce-label">Weiteres</div>
|
<div class="ce-label">Weiteres</div>
|
||||||
@@ -146,6 +147,12 @@
|
|||||||
}
|
}
|
||||||
document.addEventListener('change', sync);
|
document.addEventListener('change', sync);
|
||||||
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' : '';
|
||||||
|
});
|
||||||
})();
|
})();
|
||||||
</script>
|
</script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
+1
-1
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "netbox-customer-export"
|
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"
|
description = "NetBox-Plugin: Kundendokumentation (PDF/Word) für Standorte, Lokationen, Mandanten und Mandantengruppen"
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
requires-python = ">=3.10"
|
requires-python = ">=3.10"
|
||||||
|
|||||||
Reference in New Issue
Block a user