NetBox Customer Export plugin with configurable branding

Plugin for NetBox 4.6.8–4.6.99 that exports customer documentation
(PDF, Word, HTML preview) for a site, location, tenant or tenant group.
Technicians pick sections, device roles and columns before exporting.

Branding profiles (colors, font, logo, footer, Word template incl. .dotx,
table style, extra CSS) are configurable via PLUGINS_CONFIG and selectable
per export.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-09-23 12:43:19 +02:00
co-authored by Claude Opus 5.5
commit 8f45bffa99
21 changed files with 1444 additions and 0 deletions
+391
View File
@@ -0,0 +1,391 @@
"""Registry der auswählbaren Dokumentabschnitte."""
from collections import OrderedDict
from dataclasses import dataclass
from itertools import groupby
from django.apps import apps
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_svg import render_rack_svg
@dataclass
class Section:
key: str
label: str
group: str
default: bool
func: callable
SECTIONS = OrderedDict()
def section(key, label, group, default=True):
def deco(func):
SECTIONS[key] = Section(key, label, group, default, func)
return func
return deco
# --- Gerätespalten (vom Techniker wählbar) ------------------------------------
def _rack_pos(d):
if not d.rack:
return None
pos = f" / HE {d.position:g}" if d.position else ""
return f"{d.rack}{pos}"
DEVICE_COLUMNS = OrderedDict(
[
("name", ("Name", lambda d: d.name or str(d))),
("model", ("Hersteller / Modell", lambda d: f"{d.device_type.manufacturer} {d.device_type.model}")),
("site", ("Standort", lambda d: d.site)),
("location", ("Lokation", lambda d: d.location)),
("rack", ("Rack / HE", _rack_pos)),
("status", ("Status", lambda d: d.get_status_display())),
("platform", ("Plattform / OS", lambda d: d.platform)),
("primary_ip", ("Management-IP", lambda d: d.primary_ip.address if d.primary_ip else None)),
("oob_ip", ("OOB-IP", lambda d: d.oob_ip.address if d.oob_ip else None)),
("serial", ("Seriennummer", lambda d: d.serial)),
("asset_tag", ("Inventarnr.", lambda d: d.asset_tag)),
("tenant", ("Mandant", lambda d: d.tenant)),
("description", ("Beschreibung", lambda d: d.description)),
]
)
DEFAULT_DEVICE_COLUMNS = ["name", "model", "location", "rack", "primary_ip", "serial"]
def _device_table(devices, columns):
cols = [DEVICE_COLUMNS[c] for c in columns if c in DEVICE_COLUMNS]
return T([c[0] for c in cols], [[c[1](d) for c in cols] for d in devices])
def _empty(opts, blocks, text="Keine Einträge vorhanden."):
if opts.get("include_empty"):
blocks.append(P(text))
return blocks
return []
# --- Abschnitte -------------------------------------------------------------
@section("overview", "Übersicht & Standortdaten", "Allgemein")
def overview(scope, opts):
blocks = [H(1, "Übersicht")]
counts = [
("Standorte", scope.sites().count()),
("Racks", scope.racks().count()),
("Geräte", scope.devices().count()),
("Virtuelle Maschinen", scope.virtual_machines().count()),
("Präfixe", scope.prefixes().count()),
("VLANs", scope.vlans().count()),
("Leitungen", scope.circuits().count()),
]
blocks.append(KV([(f"{scope.label}", scope.obj)] + counts))
if getattr(scope.obj, "description", ""):
blocks.append(P(scope.obj.description))
for site in scope.sites().select_related("region", "group", "tenant"):
blocks.append(H(2, f"Standort {site.name}"))
blocks.append(
KV(
[
("Status", site.get_status_display()),
("Region", site.region),
("Mandant", site.tenant),
("Einrichtung", site.facility),
("Anschrift", site.physical_address),
("Lieferanschrift", site.shipping_address),
("Zeitzone", site.time_zone),
("Beschreibung", site.description),
]
)
)
locs = scope.location_objects().filter(site=site)
if locs.exists():
blocks.append(
T(
["Lokation", "Übergeordnet", "Status", "Beschreibung"],
[[(" " * loc.level) + loc.name, loc.parent, loc.get_status_display(), loc.description] for loc in locs],
)
)
return blocks
@section("contacts", "Ansprechpartner", "Allgemein")
def contacts(scope, opts):
ContactAssignment = apps.get_model("tenancy", "ContactAssignment")
targets = [
(scope.sites(), "Standort"),
(scope.location_objects(), "Lokation"),
(scope.tenant_objects(), "Mandant"),
]
rows = []
for qs, label in targets:
ct = ContentType.objects.get_for_model(qs.model)
assignments = scope.restrict(
ContactAssignment.objects.filter(object_type=ct, object_id__in=qs.values("pk"))
).select_related("contact", "role")
for a in assignments:
rows.append(
[f"{label}: {a.object}", a.contact.name, a.role, a.get_priority_display(), a.contact.phone, a.contact.email]
)
blocks = [H(1, "Ansprechpartner")]
if not rows:
return _empty(opts, blocks)
blocks.append(T(["Zuordnung", "Name", "Rolle", "Priorität", "Telefon", "E-Mail"], rows))
return blocks
@section("racks", "Racks (nach Standort & Lokation, inkl. Ansichten)", "Infrastruktur")
def racks(scope, opts):
qs = (
scope.racks()
.select_related("site", "location", "role", "tenant", "rack_type")
.order_by("site__name", "location__name", "name")
)
blocks = [H(1, "Racks")]
if not qs.exists():
return _empty(opts, blocks)
for site, site_racks in groupby(qs, key=lambda r: r.site):
blocks.append(H(2, f"Standort {site}"))
for location, loc_racks in groupby(site_racks, key=lambda r: r.location):
blocks.append(H(3, f"Lokation {location}" if location else "Ohne Lokation"))
for rack in loc_racks:
blocks.append(H(4, f"Rack {rack.name}"))
blocks.append(
KV(
[
("Status", rack.get_status_display()),
("Rolle", rack.role),
("Typ", rack.rack_type),
("Höhe", f"{rack.u_height} HE"),
("Facility-ID", rack.facility_id),
("Mandant", rack.tenant),
("Beschreibung", rack.description),
]
)
)
if opts.get("rack_svg"):
images = []
for face in opts.get("rack_faces") or ["front"]:
svg = render_rack_svg(rack, face, scope.user)
if svg:
images.append((svg, "Vorderseite" if face == "front" else "Rückseite"))
if images:
blocks.append(IMAGES(images))
if opts.get("rack_devices"):
devs = scope.restrict(rack.devices.all()).select_related("device_type__manufacturer").order_by("-position", "name")
if devs.exists():
blocks.append(
T(
["HE", "Gerät", "Hersteller / Modell", "Seite"],
[
[f"{d.position:g}" if d.position else EMPTY, d.name, f"{d.device_type.manufacturer} {d.device_type.model}", d.get_face_display() if d.face else EMPTY]
for d in devs
],
)
)
if opts.get("rack_pagebreak"):
blocks.append(PAGEBREAK())
return blocks
@section("devices", "Geräte (nach Geräterolle – Auswahl unten)", "Infrastruktur")
def devices(scope, opts):
blocks = [H(1, "Geräte")]
base = scope.devices().select_related(
"device_type__manufacturer", "role", "site", "location", "rack", "platform", "primary_ip4", "primary_ip6", "oob_ip", "tenant"
)
columns = opts.get("device_columns") or DEFAULT_DEVICE_COLUMNS
found = False
for role in opts.get("roles") or []:
devs = base.filter(role=role).order_by("site__name", "location__name", "rack__name", "-position", "name")
if not devs.exists():
continue
found = True
blocks.append(H(2, role.name))
if opts.get("group_by_site"):
for site, site_devs in groupby(devs, key=lambda d: d.site):
blocks.append(H(3, f"Standort {site}"))
blocks.append(_device_table(list(site_devs), columns))
else:
blocks.append(_device_table(devs, columns))
return blocks if found else _empty(opts, blocks)
@section("power", "Stromversorgung (Power Feeds)", "Infrastruktur", default=False)
def power(scope, opts):
qs = scope.power_feeds().select_related("power_panel", "rack").order_by("power_panel__name", "name")
blocks = [H(1, "Stromversorgung")]
if not qs.exists():
return _empty(opts, blocks)
blocks.append(
T(
["Panel", "Feed", "Rack", "Status", "Typ", "Phase", "Spannung", "Ampere"],
[[f.power_panel, f.name, f.rack, get(f, "status"), get(f, "type"), get(f, "phase"), f"{f.voltage} V", f"{f.amperage} A"] for f in qs],
)
)
return blocks
@section("vms", "Virtuelle Maschinen", "Infrastruktur", default=False)
def vms(scope, opts):
qs = scope.virtual_machines().select_related("cluster", "role", "platform", "primary_ip4", "primary_ip6").order_by("cluster__name", "name")
blocks = [H(1, "Virtuelle Maschinen")]
if not qs.exists():
return _empty(opts, blocks)
blocks.append(
T(
["Name", "Cluster", "Rolle", "Status", "OS", "vCPU", "RAM (MB)", "Disk (MB)", "IP"],
[[vm.name, vm.cluster, vm.role, get(vm, "status"), vm.platform, vm.vcpus, vm.memory, vm.disk, vm.primary_ip.address if vm.primary_ip else None] for vm in qs],
)
)
return blocks
@section("vrfs", "VRFs", "Netzwerk")
def vrfs(scope, opts):
qs = scope.vrfs().select_related("tenant").prefetch_related("import_targets", "export_targets").order_by("name")
blocks = [H(1, "VRFs")]
if not qs.exists():
return _empty(opts, blocks)
blocks.append(
T(
["Name", "RD", "Mandant", "Import-RT", "Export-RT", "Beschreibung"],
[[v.name, v.rd, v.tenant, v.import_targets, v.export_targets, v.description] for v in qs],
)
)
return blocks
@section("prefixes", "IP-Netze / Präfixe (nach VRF)", "Netzwerk")
def prefixes(scope, opts):
qs = scope.prefixes().select_related("vrf", "vlan", "role", "tenant").order_by("vrf__name", "prefix")
blocks = [H(1, "IP-Netze")]
if not qs.exists():
return _empty(opts, blocks)
for vrf, items in groupby(qs, key=lambda p: p.vrf):
blocks.append(H(2, f"VRF {vrf}" if vrf else "Global"))
blocks.append(
T(
["Präfix", "VLAN", "Rolle", "Status", "Scope", "Beschreibung"],
[[p.prefix, p.vlan, p.role, get(p, "status"), p.scope, p.description] for p in items],
)
)
return blocks
@section("vlans", "VLANs", "Netzwerk")
def vlans(scope, opts):
qs = scope.vlans().select_related("group", "site", "role", "tenant").order_by("group__name", "vid")
blocks = [H(1, "VLANs")]
if not qs.exists():
return _empty(opts, blocks)
blocks.append(
T(
["VID", "Name", "Gruppe", "Standort", "Rolle", "Status", "Beschreibung"],
[[v.vid, v.name, v.group, v.site, v.role, get(v, "status"), v.description] for v in qs],
)
)
return blocks
@section("ips", "IP-Adressen (ausführlich)", "Netzwerk", default=False)
def ips(scope, opts):
qs = scope.ip_addresses().select_related("vrf", "tenant").prefetch_related("assigned_object").order_by("vrf__name", "address")
blocks = [H(1, "IP-Adressen")]
if not qs.exists():
return _empty(opts, blocks)
for vrf, items in groupby(qs, key=lambda i: i.vrf):
blocks.append(H(2, f"VRF {vrf}" if vrf else "Global"))
rows = []
for ip in items:
obj = ip.assigned_object
parent = getattr(obj, "parent_object", None)
rows.append([ip.address, get(ip, "status"), get(ip, "role"), ip.dns_name, f"{parent} – {obj}" if parent else obj, ip.description])
blocks.append(T(["Adresse", "Status", "Rolle", "DNS-Name", "Zugewiesen an", "Beschreibung"], rows))
return blocks
@section("wireless", "WLANs / SSIDs", "Netzwerk", default=False)
def wireless(scope, opts):
qs = scope.wireless_lans().select_related("group", "vlan").order_by("ssid")
blocks = [H(1, "WLANs")]
if not qs.exists():
return _empty(opts, blocks)
headers = ["SSID", "Gruppe", "VLAN", "Status", "Authentifizierung", "Verschlüsselung"]
show_psk = opts.get("include_secrets")
if show_psk:
headers.append("PSK")
rows = []
for w in qs:
row = [w.ssid, w.group, w.vlan, get(w, "status"), get(w, "auth_type"), get(w, "auth_cipher")]
if show_psk:
row.append(w.auth_psk)
rows.append(row)
blocks.append(T(headers, rows))
return blocks
@section("circuits", "Leitungen / Provider", "Netzwerk", default=False)
def circuits(scope, opts):
qs = scope.circuits().select_related("provider", "type", "tenant").order_by("provider__name", "cid")
blocks = [H(1, "Leitungen")]
if not qs.exists():
return _empty(opts, blocks)
blocks.append(
T(
["Leitungs-ID", "Provider", "Typ", "Status", "Bandbreite (Kbit/s)", "Beschreibung"],
[[c.cid, c.provider, c.type, get(c, "status"), c.commit_rate, c.description] for c in qs],
)
)
return blocks
# --- Lizenzen (über Plugin-Modelle) ---------------------------------------
DEFAULT_LICENSE_SOURCES = [
{
"model": "netbox_lifecycle.licenseassignment",
"device_field": "device",
"columns": [
("Lizenz", "license"),
("Hersteller", "license.manufacturer"),
("Gerät", "device"),
("Anzahl", "quantity"),
("Vertrag", "license.contract"),
("Beschreibung", "description"),
],
},
]
@section("licenses", "Lizenzen", "Verträge & Lizenzen")
def licenses(scope, opts):
sources = get_plugin_config("netbox_customer_export", "license_sources") or DEFAULT_LICENSE_SOURCES
blocks = [H(1, "Lizenzen")]
found_source = False
for src in sources:
try:
model = apps.get_model(src["model"])
except (LookupError, ValueError):
continue
found_source = True
from django.db.models import Q
q = Q(**{f"{src['device_field']}__in": scope.devices()})
if src.get("tenant_field") and scope.tenants is not None:
q |= Q(**{f"{src['tenant_field']}__in": scope.tenants})
qs = scope.restrict(model.objects.filter(q).distinct())
if qs.exists():
cols = src["columns"]
blocks.append(T([c[0] for c in cols], [[get(o, c[1]) for c in cols] for o in qs]))
if not found_source:
return _empty(opts, blocks, "Kein Lizenz-Plugin installiert (z.B. netbox-lifecycle) – siehe README.")
return blocks if len(blocks) > 1 else _empty(opts, blocks)