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
+5
View File
@@ -0,0 +1,5 @@
__pycache__/
*.egg-info/
build/
dist/
.venv/
+114
View File
@@ -0,0 +1,114 @@
# NetBox Customer Export
NetBox-Plugin (4.6.8 – 4.6.99) zum Export einer verständlichen **Kundendokumentation als PDF oder Word**
für einen **Standort, eine Lokation, einen Mandanten oder eine Mandantengruppe**.
Der Techniker wählt vor dem Export aus, welche Inhalte das Dokument enthalten soll:
| 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 |
| 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`) |
Alle Abfragen laufen über die NetBox-Berechtigungen (`.restrict(user, "view")`). Ein Techniker exportiert also nur, was er auch sehen darf.
## Aufruf
* Button **„Kundendoku“** auf Standort-, Lokations-, Mandanten- und Mandantengruppen-Seiten
* oder Menü **Plugins → Kundendokumentation**
## Installation
```bash
source /opt/netbox/venv/bin/activate
pip install "netbox-customer-export[all] @ git+https://<dein-git>/NetBox-Customer-Export.git"
# WeasyPrint/cairosvg benötigen Systembibliotheken:
sudo apt install libpango-1.0-0 libpangoft2-1.0-0 libcairo2
```
In `local_requirements.txt` eintragen, damit das Plugin NetBox-Upgrades übersteht.
`configuration.py`:
```python
PLUGINS = ["netbox_customer_export"]
PLUGINS_CONFIG = {
"netbox_customer_export": {
"company_name": "Meine IT GmbH", # Branding-Optionen siehe unten
"logo_path": "/opt/netbox/branding/logo.png",
"footer_text": "Vertraulich",
# Offline-Rack-SVG aus Netbox-Utilities (mehrere Geräte pro HE):
# Funktion mit Signatur fn(rack, face, user) -> SVG-String
"rack_svg_renderer": None, # z.B. "netbox_utilities.<modul>.<funktion>"
"allow_secrets": False, # WLAN-PSK-Export erlauben
# "license_sources": [ { "model": "app.model", "device_field": "device",
# "tenant_field": "tenant", "columns": [("Lizenz", "license"), ...] } ],
}
}
```
Danach `systemctl restart netbox`.
### Design / Branding
Farben, Schrift, Logo und eine Word-Vorlage lassen sich festlegen, entweder global oder als **mehrere Profile**
(z.B. pro eigener Firma oder Marke). Bei mehr als einem Profil erscheint im Exportformular das Auswahlfeld „Design / Firma“.
Die globalen Werte gelten als Basis, jedes Profil überschreibt nur die Werte, die es selbst setzt.
```python
PLUGINS_CONFIG = {
"netbox_customer_export": {
"company_name": "Meine IT GmbH", # Basis für alle Profile
"brandings": {
"standard": {"name": "Meine IT GmbH", "logo_path": "/opt/netbox/branding/logo.png"},
"partner": {
"name": "Partner AG",
"company_name": "Partner AG",
"logo_path": "/opt/netbox/branding/partner.png",
"primary_color": "#C00000",
"accent_color": "#E46C0A",
"font_family": "Arial",
"docx_template": "/opt/netbox/branding/partner.dotx",
"docx_table_style": "Gitternetztabelle 4 – Akzent 1",
},
},
}
}
```
| Schlüssel | Standard | Wirkung |
|---|---|---|
| `name` | Profilschlüssel | Anzeigename im Formular |
| `company_name`, `logo_path`, `footer_text` | – | Deckblatt und Fußzeile |
| `primary_color` | `1F4E79` | Überschriften, Tabellenköpfe |
| `accent_color` | `2E75B6` | Unterüberschriften (Ebene 3/4) |
| `header_text_color` | `FFFFFF` | Schrift im Tabellenkopf |
| `label_bg_color` | `DEEAF6` | Beschriftungsspalte der Detailtabellen |
| `stripe_color` | `F3F7FB` | Zebrastreifen (PDF) |
| `font_family`, `font_size` | `Calibri`, `10` | Grundschrift |
| `docx_template` | – | `.docx`/`.dotx`: Formatvorlagen, Kopf-/Fußzeile und Ränder werden übernommen, der Textinhalt der Vorlage wird verworfen |
| `docx_table_style` | – | Name einer Tabellenformatvorlage aus der Vorlage (so, wie er in Word angezeigt wird). Ist er gesetzt, entfällt die eigene Einfärbung. |
| `extra_css` | – | zusätzliches CSS für PDF/HTML |
Farben werden als Hex angegeben, mit oder ohne `#`. Ungültige Werte führen beim Export zu einer klaren Fehlermeldung.
Mit einer Word-Vorlage bestimmt die Vorlage die Überschriften-Formate. Ohne Vorlage färbt das Plugin die Überschriften selbst ein.
Bringt die Vorlage eine eigene Fußzeile mit, wird diese beibehalten.
### Rack-Ansichten
Ohne Konfiguration nutzt das Plugin NetBox' eigenen `dcim.svg.RackElevationSVG`. Er läuft im selben Prozess, also ohne HTTP-Aufruf der API.
Soll stattdessen der Offline-Renderer aus [Netbox-Utilities](https://git.mrblake.cc/MrBlake/Netbox-Utilities) verwendet werden,
trägt man unter `rack_svg_renderer` den Pfad zu dessen Render-Funktion ein. Falls die Signatur abweicht, braucht es einen kleinen Adapter.
## Architektur
```
scope.py welche Objekte gehören zum Scope (Standort/Lokation/Mandant/-gruppe)
sections.py Registry der Abschnitte (@section-Dekorator) → neutrale Blöcke
blocks.py Dokumentmodell (Überschrift, Tabelle, Key/Value, Bilder, Umbruch)
renderers/ html (→ WeasyPrint-PDF), docx (python-docx + cairosvg)
```
Einen neuen Abschnitt hinzufügen: in `sections.py` eine Funktion mit `@section("key", "Label", "Gruppe")` schreiben, die eine Liste von Blöcken zurückgibt. Er erscheint automatisch im Formular und in beiden Formaten.
+27
View File
@@ -0,0 +1,27 @@
from netbox.plugins import PluginConfig
class CustomerExportConfig(PluginConfig):
name = "netbox_customer_export"
verbose_name = "Kunden-Export"
description = "Kundendokumentation als PDF oder Word exportieren"
version = "0.1.0"
author = "Claude"
base_url = "customer-export"
min_version = "4.6.8"
max_version = "4.6.99"
default_settings = {
# Branding: Einzelschlüssel (company_name, logo_path, primary_color, docx_template, …)
# gelten global; "brandings" definiert mehrere Profile. Defaults siehe branding.py.
"brandings": None,
# Dotted path zu einer Funktion fn(rack, face, user) -> SVG-String,
# z.B. der Offline-Renderer aus Netbox-Utilities. None = NetBox-eigener Renderer.
"rack_svg_renderer": None,
# Lizenzquellen (Plugin-Modelle), None = Auto-Erkennung (netbox_lifecycle)
"license_sources": None,
# Darf der Techniker WLAN-PSKs o.ä. mit exportieren?
"allow_secrets": False,
}
config = CustomerExportConfig
+86
View File
@@ -0,0 +1,86 @@
"""Neutrales Dokumentmodell, das sowohl der HTML/PDF- als auch der Word-Renderer versteht."""
import base64
from dataclasses import dataclass, field
from django.utils.text import slugify
EMPTY = "—"
@dataclass
class Block:
kind: str # heading | paragraph | table | kv | images | pagebreak
level: int = 0
text: str = ""
headers: list = field(default_factory=list)
rows: list = field(default_factory=list)
images: list = field(default_factory=list)
anchor: str = ""
@dataclass
class Document:
title: str
subtitle: str
blocks: list
meta: dict
branding: dict = field(default_factory=dict)
@property
def toc(self):
return [b for b in self.blocks if b.kind == "heading" and b.level <= 2]
_anchor_counter = {"n": 0}
def H(level, text):
_anchor_counter["n"] += 1
return Block("heading", level=level, text=str(text), anchor=f"s{_anchor_counter['n']}-{slugify(str(text))[:40]}")
def P(text):
return Block("paragraph", text=str(text))
def T(headers, rows):
return Block("table", headers=list(headers), rows=[[fmt(c) for c in r] for r in rows])
def KV(pairs, skip_empty=True):
rows = [[k, fmt(v)] for k, v in pairs if not (skip_empty and v in (None, "", [], EMPTY))]
return Block("kv", rows=rows)
def IMAGES(images):
"""images: Liste von (svg_string, caption)."""
out = []
for svg, caption in images:
data = base64.b64encode(svg.encode("utf-8")).decode("ascii")
out.append({"svg": svg, "caption": caption, "data_uri": f"data:image/svg+xml;base64,{data}"})
return Block("images", images=out)
def PAGEBREAK():
return Block("pagebreak")
def fmt(value):
if value is None or value == "":
return EMPTY
if isinstance(value, bool):
return "Ja" if value else "Nein"
if isinstance(value, (list, tuple, set)) or hasattr(value, "all"):
items = value.all() if hasattr(value, "all") else value
return ", ".join(str(i) for i in items) or EMPTY
return str(value)
def get(obj, path):
"""Dotted-getattr, bevorzugt get_<feld>_display() für Choice-Felder."""
for part in path.split("."):
if obj is None:
return None
display = getattr(obj, f"get_{part}_display", None)
obj = display() if callable(display) else getattr(obj, part, None)
return obj
+54
View File
@@ -0,0 +1,54 @@
"""Branding-Profile (Logo, Farben, Schrift, Word-Vorlage) aus PLUGINS_CONFIG."""
import re
from netbox.plugins import get_plugin_config
PLUGIN = "netbox_customer_export"
DEFAULT_BRANDING = {
"name": "Standard",
"company_name": "",
"logo_path": None,
"footer_text": "Vertraulich – nur für den internen Gebrauch des Kunden",
"primary_color": "1F4E79", # Überschriften, Tabellenköpfe
"accent_color": "2E75B6", # Unterüberschriften
"header_text_color": "FFFFFF", # Schrift im Tabellenkopf
"label_bg_color": "DEEAF6", # Beschriftungsspalte in Key/Value-Tabellen
"stripe_color": "F3F7FB", # jede zweite Tabellenzeile (PDF)
"font_family": "Calibri",
"font_size": 10,
# Word: .docx/.dotx, deren Formatvorlagen, Kopf-/Fußzeile und Seitenränder übernommen werden
"docx_template": None,
"docx_table_style": None, # z.B. "Gitternetztabelle 4 – Akzent 1" aus der Vorlage
"extra_css": "", # zusätzliches CSS für PDF/HTML
}
COLOR_KEYS = ("primary_color", "accent_color", "header_text_color", "label_bg_color", "stripe_color")
_HEX = re.compile(r"^[0-9A-Fa-f]{6}$")
def _normalize(profile):
for key in COLOR_KEYS:
value = str(profile[key]).lstrip("#")
if not _HEX.match(value):
raise ValueError(f"netbox_customer_export: {key}={profile[key]!r} ist keine Hex-Farbe (z.B. '1F4E79')")
profile[key] = value.upper()
return profile
def get_brandings():
"""Alle Profile als {key: dict}. Top-Level-Einstellungen gelten als Basis für jedes Profil."""
base = dict(DEFAULT_BRANDING)
for key in DEFAULT_BRANDING:
value = get_plugin_config(PLUGIN, key)
if value is not None:
base[key] = value
profiles = get_plugin_config(PLUGIN, "brandings") or {}
if not profiles:
return {"default": _normalize(base)}
return {key: _normalize({**base, "name": key, **profile}) for key, profile in profiles.items()}
def get_branding(key=None):
brandings = get_brandings()
return brandings.get(key) or next(iter(brandings.values()))
+79
View File
@@ -0,0 +1,79 @@
from django import forms
from dcim.models import DeviceRole, Location, Site
from netbox.plugins import get_plugin_config
from tenancy.models import Tenant, TenantGroup
from utilities.forms.fields import DynamicModelChoiceField
from .branding import get_brandings
from .sections import DEFAULT_DEVICE_COLUMNS, DEVICE_COLUMNS, SECTIONS
FORMAT_CHOICES = (("pdf", "PDF"), ("docx", "Word (.docx)"), ("html", "HTML-Vorschau"))
class ScopeSelectForm(forms.Form):
site = DynamicModelChoiceField(queryset=Site.objects.all(), required=False, label="Standort")
location = DynamicModelChoiceField(queryset=Location.objects.all(), required=False, label="Lokation")
tenant = DynamicModelChoiceField(queryset=Tenant.objects.all(), required=False, label="Mandant")
tenantgroup = DynamicModelChoiceField(queryset=TenantGroup.objects.all(), required=False, label="Mandantengruppe")
def clean(self):
data = super().clean()
chosen = [(k, v) for k, v in data.items() if v]
if len(chosen) != 1:
raise forms.ValidationError("Bitte genau einen Scope auswählen.")
data["kind"], data["obj"] = chosen[0]
return data
class ExportForm(forms.Form):
format = forms.ChoiceField(choices=FORMAT_CHOICES, initial="pdf", label="Format")
title = forms.CharField(max_length=200, label="Titel")
subtitle = forms.CharField(max_length=200, required=False, label="Untertitel")
author = forms.CharField(max_length=100, required=False, label="Erstellt von")
branding = forms.ChoiceField(choices=(), label="Design / Firma")
sections = forms.MultipleChoiceField(
choices=[(s.key, s.label) for s in SECTIONS.values()],
initial=[s.key for s in SECTIONS.values() if s.default],
widget=forms.CheckboxSelectMultiple,
label="Inhalte",
)
roles = forms.ModelMultipleChoiceField(
queryset=DeviceRole.objects.none(), required=False, widget=forms.CheckboxSelectMultiple, label="Geräterollen"
)
device_columns = forms.MultipleChoiceField(
choices=[(k, v[0]) for k, v in DEVICE_COLUMNS.items()],
initial=DEFAULT_DEVICE_COLUMNS,
required=False,
widget=forms.CheckboxSelectMultiple,
label="Gerätespalten",
)
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_faces = forms.MultipleChoiceField(
choices=(("front", "Vorderseite"), ("rear", "Rückseite")),
initial=["front"],
required=False,
widget=forms.CheckboxSelectMultiple,
label="Rack-Seiten",
)
rack_devices = forms.BooleanField(required=False, initial=True, label="Geräteliste je Rack")
rack_pagebreak = forms.BooleanField(required=False, initial=True, label="Seitenumbruch nach jedem Rack")
include_empty = forms.BooleanField(required=False, label="Leere Abschnitte trotzdem aufführen")
include_secrets = forms.BooleanField(required=False, label="Geheimnisse (z.B. WLAN-PSK) mit exportieren")
def __init__(self, *args, scope=None, **kwargs):
super().__init__(*args, **kwargs)
self.fields["branding"].choices = [(k, b["name"]) for k, b in get_brandings().items()]
if scope is not None:
role_ids = scope.devices().values("role")
roles = DeviceRole.objects.filter(pk__in=role_ids).order_by("name")
self.fields["roles"].queryset = roles
self.fields["roles"].initial = list(roles)
self.fields["title"].initial = f"Netzwerkdokumentation {scope.obj}"
self.fields["subtitle"].initial = f"{scope.label}: {scope.obj}"
if not get_plugin_config("netbox_customer_export", "allow_secrets"):
del self.fields["include_secrets"]
+9
View File
@@ -0,0 +1,9 @@
from netbox.plugins import PluginMenuItem
menu_items = (
PluginMenuItem(
link="plugins:netbox_customer_export:select",
link_text="Kundendokumentation",
permissions=["dcim.view_site"],
),
)
+28
View File
@@ -0,0 +1,28 @@
"""Rack-Ansichten offline (ohne HTTP-Aufruf der API) als SVG erzeugen."""
import logging
from django.utils.module_loading import import_string
from netbox.plugins import get_plugin_config
logger = logging.getLogger("netbox_customer_export")
def render_rack_svg(rack, face, user):
"""Gibt das SVG als String zurück oder None bei Fehlern.
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=...)``.
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)
else:
from dcim.svg import RackElevationSVG
svg = RackElevationSVG(rack, user=user, include_images=False, base_url="").render(face)
return svg.tostring() if hasattr(svg, "tostring") else str(svg)
except Exception: # noqa: BLE001 – ein defektes Rack soll nicht den ganzen Export abbrechen
logger.exception("Rack-SVG für %s (%s) fehlgeschlagen", rack, face)
return None
@@ -0,0 +1,23 @@
class MissingDependency(Exception):
pass
def render(doc, fmt, request=None):
"""Gibt (bytes, content_type, dateiendung) zurück."""
if fmt == "docx":
from .docx import render_docx
return render_docx(doc), "application/vnd.openxmlformats-officedocument.wordprocessingml.document", "docx"
from .html import render_html
html = render_html(doc)
if fmt == "html":
return html, "text/html; charset=utf-8", "html"
try:
from weasyprint import HTML
except (ImportError, OSError) as e:
raise MissingDependency(f"PDF-Export benötigt WeasyPrint (pip install weasyprint + Pango): {e}")
base_url = request.build_absolute_uri("/") if request else None
return HTML(string=html, base_url=base_url).write_pdf(), "application/pdf", "pdf"
+177
View File
@@ -0,0 +1,177 @@
import zipfile
from io import BytesIO
from . import MissingDependency
DOTX_TYPE = b"application/vnd.openxmlformats-officedocument.wordprocessingml.template.main+xml"
DOCX_TYPE = b"application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml"
def _svg_to_png(svg):
try:
import cairosvg
except (ImportError, OSError):
return None
return cairosvg.svg2png(bytestring=svg.encode("utf-8"), scale=2)
def _load_template(path):
"""Öffnet eine .docx/.dotx-Vorlage und leert deren Textkörper (Formatvorlagen, Kopf-/Fußzeile bleiben)."""
from docx import Document as Docx
buf = BytesIO()
with zipfile.ZipFile(path) as src, zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as dst:
for item in src.infolist():
data = src.read(item.filename)
if item.filename == "[Content_Types].xml":
data = data.replace(DOTX_TYPE, DOCX_TYPE) # .dotx -> .docx, sonst lehnt python-docx ab
dst.writestr(item, data)
buf.seek(0)
d = Docx(buf)
body = d.element.body
for child in list(body):
if not child.tag.endswith("}sectPr"):
body.remove(child)
return d
def render_docx(doc):
try:
from docx import Document as Docx
from docx.enum.text import WD_ALIGN_PARAGRAPH
from docx.oxml import OxmlElement
from docx.oxml.ns import qn
from docx.shared import Cm, Pt, RGBColor
except ImportError as e:
raise MissingDependency(f"Word-Export benötigt python-docx (pip install python-docx cairosvg): {e}")
b = doc.branding
primary = RGBColor.from_string(b["primary_color"])
template = b.get("docx_template")
if template:
try:
d = _load_template(template)
except (OSError, KeyError, zipfile.BadZipFile) as e:
raise MissingDependency(f"Word-Vorlage {template!r} konnte nicht geladen werden: {e}")
else:
# Ohne Vorlage: Standarddokument mit Farben/Schrift aus dem Branding
d = Docx()
sec = d.sections[0]
sec.left_margin = sec.right_margin = Cm(2)
sec.top_margin = sec.bottom_margin = Cm(2)
d.styles["Normal"].font.name = b["font_family"]
d.styles["Normal"].font.size = Pt(b["font_size"])
for level, color in ((1, b["primary_color"]), (2, b["primary_color"]), (3, b["accent_color"]), (4, b["accent_color"])):
style = d.styles[f"Heading {level}"]
style.font.name = b["font_family"]
style.font.color.rgb = RGBColor.from_string(color)
sec = d.sections[0]
style_names = {s.name for s in d.styles}
table_style = b.get("docx_table_style") if b.get("docx_table_style") in style_names else None
def shade(cell, color):
tc_pr = cell._tc.get_or_add_tcPr()
shd = OxmlElement("w:shd")
shd.set(qn("w:val"), "clear")
shd.set(qn("w:fill"), color)
tc_pr.append(shd)
def set_style(table):
if table_style:
table.style = table_style
elif "Table Grid" in style_names:
table.style = "Table Grid"
# Deckblatt
meta = doc.meta
if b.get("logo_path"):
d.add_picture(b["logo_path"], width=Cm(5))
for _ in range(6):
d.add_paragraph()
title = d.add_paragraph()
run = title.add_run(doc.title)
run.bold, run.font.size = True, Pt(28)
run.font.color.rgb = primary
if doc.subtitle:
d.add_paragraph().add_run(doc.subtitle).font.size = Pt(16)
d.add_paragraph()
info = d.add_paragraph()
for label, value in (("Stand", meta.get("date")), ("Erstellt von", meta.get("author")), ("", b.get("company_name"))):
if value:
info.add_run(f"{label + ': ' if label else ''}{value}\n")
d.add_page_break()
# Inhaltsverzeichnis (wird beim Öffnen in Word aktualisiert)
d.add_heading("Inhaltsverzeichnis", level=1)
fld = OxmlElement("w:fldSimple")
fld.set(qn("w:instr"), 'TOC \\o "1-2" \\h \\z \\u')
d.add_paragraph()._p.append(fld)
upd = OxmlElement("w:updateFields")
upd.set(qn("w:val"), "true")
d.settings.element.append(upd)
d.add_page_break()
for blk in doc.blocks:
if blk.kind == "heading":
d.add_heading(blk.text, level=min(blk.level, 4))
elif blk.kind == "paragraph":
d.add_paragraph(blk.text)
elif blk.kind == "pagebreak":
d.add_page_break()
elif blk.kind in ("table", "kv"):
cols = len(blk.headers) if blk.kind == "table" else 2
t = d.add_table(rows=0, cols=cols)
set_style(t)
if blk.kind == "table":
cells = t.add_row().cells
for c, h in zip(cells, blk.headers):
c.text = h
r = c.paragraphs[0].runs[0]
r.bold = True
if not table_style: # eine Tabellenformatvorlage bringt eigene Kopfzeilenfarben mit
r.font.color.rgb = RGBColor.from_string(b["header_text_color"])
shade(c, b["primary_color"])
# Kopfzeile auf jeder Seite wiederholen
tr_pr = t.rows[0]._tr.get_or_add_trPr()
hdr = OxmlElement("w:tblHeader")
hdr.set(qn("w:val"), "true")
tr_pr.append(hdr)
for row in blk.rows:
cells = t.add_row().cells
for i, (c, v) in enumerate(zip(cells, row)):
c.text = v
for p in c.paragraphs:
for r in p.runs:
r.font.size = Pt(max(b["font_size"] - 1, 7))
if blk.kind == "kv" and i == 0:
c.paragraphs[0].runs[0].bold = True
if not table_style:
shade(c, b["label_bg_color"])
d.add_paragraph()
elif blk.kind == "images":
t = d.add_table(rows=2, cols=len(blk.images))
for i, img in enumerate(blk.images):
png = _svg_to_png(img["svg"])
cell = t.cell(0, i).paragraphs[0]
cell.alignment = WD_ALIGN_PARAGRAPH.CENTER
if png:
cell.add_run().add_picture(BytesIO(png), height=Cm(18))
else:
cell.add_run("(Rack-Grafik benötigt cairosvg)")
cap = t.cell(1, i).paragraphs[0]
cap.alignment = WD_ALIGN_PARAGRAPH.CENTER
cap.add_run(img["caption"]).italic = True
d.add_paragraph()
# Fußzeile nur setzen, wenn die Vorlage keine eigene mitbringt
footer = sec.footer
if not any(p.text.strip() for p in footer.paragraphs):
text = " · ".join(x for x in (b.get("company_name"), b.get("footer_text"), doc.title) if x)
p = footer.paragraphs[0] if footer.paragraphs else footer.add_paragraph()
p.add_run(text).font.size = Pt(8)
out = BytesIO()
d.save(out)
return out.getvalue()
+19
View File
@@ -0,0 +1,19 @@
import base64
import mimetypes
from django.template.loader import render_to_string
def _logo_data_uri(path):
if not path:
return None
mime = mimetypes.guess_type(path)[0] or "image/png"
with open(path, "rb") as f:
return f"data:{mime};base64,{base64.b64encode(f.read()).decode('ascii')}"
def render_html(doc):
return render_to_string(
"netbox_customer_export/document.html",
{"doc": doc, "b": doc.branding, "logo": _logo_data_uri(doc.branding.get("logo_path"))},
)
+129
View File
@@ -0,0 +1,129 @@
"""Ermittelt, welche Objekte zu einem Export-Scope (Standort, Lokation, Mandant, Mandantengruppe) gehören."""
from django.db.models import Q
from circuits.models import Circuit
from dcim.models import Device, Location, PowerFeed, Rack, Site
from ipam.models import VLAN, VRF, IPAddress, Prefix
from tenancy.models import Tenant, TenantGroup
from virtualization.models import VirtualMachine
from wireless.models import WirelessLAN
SCOPE_MODELS = {
"site": Site,
"location": Location,
"tenant": Tenant,
"tenantgroup": TenantGroup,
}
SCOPE_LABELS = {
"site": "Standort",
"location": "Lokation",
"tenant": "Mandant",
"tenantgroup": "Mandantengruppe",
}
NONE = Q(pk__in=[])
class Scope:
def __init__(self, kind, obj, user):
self.kind = kind
self.obj = obj
self.user = user
self.label = SCOPE_LABELS[kind]
self.locations = None
self.tenants = None
if kind == "location":
self.locations = obj.get_descendants(include_self=True)
elif kind == "tenant":
self.tenants = Tenant.objects.filter(pk=obj.pk)
elif kind == "tenantgroup":
self.tenants = Tenant.objects.filter(group__in=obj.get_descendants(include_self=True))
# -- Hilfsfunktionen --------------------------------------------------
def restrict(self, qs):
if hasattr(qs, "restrict"):
return qs.restrict(self.user, "view")
return qs
def _q(self, site=(), location=(), tenant=("tenant",)):
"""Baut einen Q-Filter; jedes Argument ist eine Liste von Feldpfaden, die ODER-verknüpft werden."""
if self.kind == "site":
paths, value = site, self.obj
elif self.kind == "location":
paths, value = [f"{p}__in" for p in location], self.locations
else:
paths, value = [f"{p}__in" for p in tenant], self.tenants
q = NONE
for p in paths:
q |= Q(**{p: value})
return q
def _qs(self, model, **paths):
return self.restrict(model.objects.filter(self._q(**paths)).distinct())
# -- Objekte ----------------------------------------------------------
def sites(self):
if self.kind == "site":
return self.restrict(Site.objects.filter(pk=self.obj.pk))
if self.kind == "location":
return self.restrict(Site.objects.filter(pk=self.obj.site_id))
return self._qs(Site, tenant=("tenant", "devices__tenant", "racks__tenant"))
def location_objects(self):
return self._qs(Location, site=("site",), location=("pk",), tenant=("tenant", "devices__tenant"))
def racks(self):
return self._qs(Rack, site=("site",), location=("location",), tenant=("tenant", "devices__tenant"))
def devices(self):
return self._qs(Device, site=("site",), location=("location",))
def virtual_machines(self):
return self._qs(
VirtualMachine,
site=("site", "cluster___site"),
location=("cluster___location", "device__location"),
)
def prefixes(self):
return self._qs(Prefix, site=("_site",), location=("_location",))
def vlans(self):
return self._qs(
VLAN,
site=("site", "prefixes___site"),
location=(
"prefixes___location",
"interfaces_as_untagged__device__location",
"interfaces_as_tagged__device__location",
),
)
def vrfs(self):
q = Q(prefixes__in=self.prefixes())
if self.tenants is not None:
q |= Q(tenant__in=self.tenants)
return self.restrict(VRF.objects.filter(q).distinct())
def ip_addresses(self):
q = Q(interface__device__in=self.devices()) | Q(vminterface__virtual_machine__in=self.virtual_machines())
if self.tenants is not None:
q |= Q(tenant__in=self.tenants)
return self.restrict(IPAddress.objects.filter(q).distinct())
def circuits(self):
return self._qs(Circuit, site=("terminations___site",), location=("terminations___location",))
def wireless_lans(self):
return self._qs(WirelessLAN, site=("_site",), location=("_location",))
def power_feeds(self):
return self._qs(PowerFeed, site=("power_panel__site",), location=("power_panel__location",))
def tenant_objects(self):
if self.tenants is not None:
return self.restrict(self.tenants)
return self.restrict(Tenant.objects.filter(devices__in=self.devices()).distinct())
+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)
@@ -0,0 +1,21 @@
from django.urls import reverse
from django.utils.html import format_html
from netbox.plugins import PluginTemplateExtension
class CustomerExportButton(PluginTemplateExtension):
models = ["dcim.site", "dcim.location", "tenancy.tenant", "tenancy.tenantgroup"]
def buttons(self):
obj = self.context["object"]
url = reverse(
"plugins:netbox_customer_export:export",
kwargs={"kind": obj._meta.model_name, "pk": obj.pk},
)
return format_html(
'<a href="{}" class="btn btn-primary"><i class="mdi mdi-file-document-outline"></i> Kundendoku</a>',
url,
)
template_extensions = [CustomerExportButton]
@@ -0,0 +1,7 @@
<div class="row">
{% for cb in field %}
<div class="col-md-{{ cols|default:6 }}">
<div class="form-check">{{ cb.tag }} <label class="form-check-label" for="{{ cb.id_for_label }}">{{ cb.choice_label }}</label></div>
</div>
{% empty %}<div class="text-muted">Keine Einträge im Scope.</div>{% endfor %}
</div>
@@ -0,0 +1,77 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="utf-8">
<title>{{ doc.title }}</title>
<style>
:root { --primary: #{{ b.primary_color }}; --accent: #{{ b.accent_color }}; --head-text: #{{ b.header_text_color }}; --label-bg: #{{ b.label_bg_color }}; --stripe: #{{ b.stripe_color }}; }
@page { size: A4; margin: 20mm 15mm 20mm 15mm;
@bottom-left { content: element(footer); }
@bottom-right { content: "Seite " counter(page) " / " counter(pages); font-size: 7pt; color: #777; } }
@page cover { @bottom-left { content: none } @bottom-right { content: none } }
body { font-family: "{{ b.font_family }}", "Segoe UI", "DejaVu Sans", Arial, sans-serif; font-size: {{ b.font_size }}pt; color: #222; }
.cover { page: cover; padding-top: 60mm; page-break-after: always; }
.cover img { max-width: 60mm; max-height: 30mm; margin-bottom: 30mm; }
.cover h1 { font-size: 30pt; color: var(--primary); margin: 0 0 8pt 0; border: 0; page-break-before: avoid; }
.cover .subtitle { font-size: 16pt; color: #555; }
.cover .meta { margin-top: 30mm; color: #555; line-height: 1.6; }
.toc { page-break-after: always; }
.toc a { color: #222; text-decoration: none; }
.toc a::after { content: leader(".") target-counter(attr(href), page); }
.toc .l2 { padding-left: 14pt; }
h1 { color: var(--primary); border-bottom: 2px solid var(--primary); padding-bottom: 3pt; page-break-before: always; }
h2 { color: var(--primary); margin-top: 16pt; }
h3 { color: var(--accent); }
h4 { margin-bottom: 4pt; }
h1, h2, h3, h4 { page-break-after: avoid; }
table { width: 100%; border-collapse: collapse; margin: 4pt 0 10pt; font-size: 8.5pt; }
thead { display: table-header-group; }
th { background: var(--primary); color: var(--head-text); text-align: left; padding: 3pt 5pt; }
td { border-bottom: 1px solid #ddd; padding: 3pt 5pt; vertical-align: top; }
tr { page-break-inside: avoid; }
tbody tr:nth-child(even) td { background: var(--stripe); }
table.kv { width: auto; min-width: 50%; }
table.kv td:first-child { font-weight: 600; background: var(--label-bg); width: 35%; }
.images { display: flex; gap: 12mm; justify-content: center; page-break-inside: avoid; }
.images figure { margin: 0; text-align: center; }
.images img { max-height: 190mm; max-width: 80mm; }
figcaption { font-style: italic; color: #555; }
.footer { position: running(footer); font-size: 7pt; color: #777; }
@media screen { .footer { display: none; } }
.pagebreak { page-break-after: always; }
@media screen { body { max-width: 1100px; margin: auto; padding: 16px; } }
{{ b.extra_css|safe }}
</style>
</head>
<body>
<div class="footer">{{ b.company_name }}{% if b.footer_text %} · {{ b.footer_text }}{% endif %}</div>
<section class="cover">
{% if logo %}<img src="{{ logo }}" alt="">{% endif %}
<h1>{{ doc.title }}</h1>
{% if doc.subtitle %}<div class="subtitle">{{ doc.subtitle }}</div>{% endif %}
<div class="meta">
Stand: {{ doc.meta.date }}<br>
Erstellt von: {{ doc.meta.author }}<br>
{{ b.company_name }}
</div>
</section>
<nav class="toc">
<h2>Inhaltsverzeichnis</h2>
{% for h in doc.toc %}<div class="l{{ h.level }}"><a href="#{{ h.anchor }}">{{ h.text }}</a></div>{% endfor %}
</nav>
{% for b in doc.blocks %}
{% if b.kind == 'heading' %}
{% if b.level == 1 %}<h1 id="{{ b.anchor }}">{{ b.text }}</h1>{% elif b.level == 2 %}<h2 id="{{ b.anchor }}">{{ b.text }}</h2>{% elif b.level == 3 %}<h3 id="{{ b.anchor }}">{{ b.text }}</h3>{% else %}<h4 id="{{ b.anchor }}">{{ b.text }}</h4>{% endif %}
{% elif b.kind == 'paragraph' %}<p>{{ b.text|linebreaksbr }}</p>
{% elif b.kind == 'table' %}
<table><thead><tr>{% for h in b.headers %}<th>{{ h }}</th>{% endfor %}</tr></thead>
<tbody>{% for row in b.rows %}<tr>{% for c in row %}<td>{{ c }}</td>{% endfor %}</tr>{% endfor %}</tbody></table>
{% elif b.kind == 'kv' %}
<table class="kv"><tbody>{% for row in b.rows %}<tr><td>{{ row.0 }}</td><td>{{ row.1|linebreaksbr }}</td></tr>{% endfor %}</tbody></table>
{% elif b.kind == 'images' %}
<div class="images">{% for img in b.images %}<figure><img src="{{ img.data_uri }}" alt="{{ img.caption }}"><figcaption>{{ img.caption }}</figcaption></figure>{% endfor %}</div>
{% elif b.kind == 'pagebreak' %}<div class="pagebreak"></div>
{% endif %}
{% endfor %}
</body>
</html>
@@ -0,0 +1,66 @@
{% extends 'generic/_base.html' %}
{% load form_helpers %}
{% block title %}Kundendoku: {{ object }}{% endblock %}
{% block subtitle %}<div class="text-muted">{{ scope.label }}</div>{% endblock %}
{% block content %}
<form method="post" class="container-fluid my-3">
{% csrf_token %}
{% if form.errors %}<div class="alert alert-danger">{{ form.errors }}</div>{% endif %}
<div class="row">
<div class="col-lg-5">
<div class="card mb-3">
<h2 class="card-header">Dokument</h2>
<div class="card-body">
{% render_field form.format %}
{% render_field form.title %}
{% render_field form.subtitle %}
{% render_field form.author %}
{% if form.branding.field.choices|length > 1 %}{% render_field form.branding %}{% else %}<input type="hidden" name="branding" value="{{ form.branding.field.choices.0.0 }}">{% endif %}
</div>
</div>
<div class="card mb-3">
<h2 class="card-header">Inhalte</h2>
<div class="card-body">
{% for group, sections in section_groups.items %}
<h6 class="mt-2">{{ group }}</h6>
{% for cb in form.sections %}{% for s in sections %}{% if s.key == cb.data.value %}
<div class="form-check">{{ cb.tag }} <label class="form-check-label" for="{{ cb.id_for_label }}">{{ cb.choice_label }}</label></div>
{% endif %}{% endfor %}{% endfor %}
{% endfor %}
<hr>
<div class="form-check">{{ form.include_empty }} <label class="form-check-label" for="{{ form.include_empty.id_for_label }}">{{ form.include_empty.label }}</label></div>
{% if form.include_secrets %}
<div class="form-check">{{ form.include_secrets }} <label class="form-check-label text-danger" for="{{ form.include_secrets.id_for_label }}">{{ form.include_secrets.label }}</label></div>
{% endif %}
</div>
</div>
</div>
<div class="col-lg-7">
<div class="card mb-3">
<h2 class="card-header">Geräte</h2>
<div class="card-body">
<h6>Geräterollen</h6>
{% include 'netbox_customer_export/_checkboxes.html' with field=form.roles cols=4 %}
<h6 class="mt-3">Spalten</h6>
{% include 'netbox_customer_export/_checkboxes.html' with field=form.device_columns cols=4 %}
<div class="form-check mt-2">{{ form.group_by_site }} <label class="form-check-label" for="{{ form.group_by_site.id_for_label }}">{{ form.group_by_site.label }}</label></div>
</div>
</div>
<div class="card mb-3">
<h2 class="card-header">Racks</h2>
<div class="card-body">
<div class="form-check">{{ form.rack_svg }} <label class="form-check-label" for="{{ form.rack_svg.id_for_label }}">{{ form.rack_svg.label }}</label></div>
<div class="form-check">{{ form.rack_devices }} <label class="form-check-label" for="{{ form.rack_devices.id_for_label }}">{{ form.rack_devices.label }}</label></div>
<div class="form-check">{{ form.rack_pagebreak }} <label class="form-check-label" for="{{ form.rack_pagebreak.id_for_label }}">{{ form.rack_pagebreak.label }}</label></div>
<h6 class="mt-2">Seiten</h6>
{% include 'netbox_customer_export/_checkboxes.html' with field=form.rack_faces cols=6 %}
</div>
</div>
</div>
</div>
<div class="text-end">
<a href="{{ object.get_absolute_url }}" class="btn btn-outline-secondary">Abbrechen</a>
<button class="btn btn-primary"><i class="mdi mdi-download"></i> Exportieren</button>
</div>
</form>
{% endblock %}
@@ -0,0 +1,20 @@
{% extends 'generic/_base.html' %}
{% load form_helpers %}
{% block title %}Kundendokumentation{% endblock %}
{% block content %}
<form method="post" class="container-fluid my-3">
{% csrf_token %}
<div class="card">
<h2 class="card-header">Scope wählen</h2>
<div class="card-body">
<p class="text-muted">Genau eines der Felder ausfüllen.</p>
{% if form.non_field_errors %}<div class="alert alert-danger">{{ form.non_field_errors }}</div>{% endif %}
{% render_field form.site %}
{% render_field form.location %}
{% render_field form.tenant %}
{% render_field form.tenantgroup %}
</div>
<div class="card-footer text-end"><button class="btn btn-primary">Weiter</button></div>
</div>
</form>
{% endblock %}
+8
View File
@@ -0,0 +1,8 @@
from django.urls import path
from . import views
urlpatterns = [
path("", views.ScopeSelectView.as_view(), name="select"),
path("export/<str:kind>/<int:pk>/", views.ExportView.as_view(), name="export"),
]
+81
View File
@@ -0,0 +1,81 @@
from django.contrib import messages
from django.contrib.auth.mixins import LoginRequiredMixin
from django.http import Http404, HttpResponse
from django.shortcuts import get_object_or_404, redirect, render
from django.utils import timezone
from django.utils.text import slugify
from django.views import View
from .blocks import Document
from .branding import get_branding
from .forms import ExportForm, ScopeSelectForm
from .renderers import MissingDependency, render as render_document
from .scope import SCOPE_MODELS, Scope
from .sections import SECTIONS
class ScopeSelectView(LoginRequiredMixin, View):
template_name = "netbox_customer_export/select.html"
def get(self, request):
return render(request, self.template_name, {"form": ScopeSelectForm()})
def post(self, request):
form = ScopeSelectForm(request.POST)
if form.is_valid():
return redirect("plugins:netbox_customer_export:export", kind=form.cleaned_data["kind"], pk=form.cleaned_data["obj"].pk)
return render(request, self.template_name, {"form": form})
class ExportView(LoginRequiredMixin, View):
template_name = "netbox_customer_export/export.html"
def _scope(self, request, kind, pk):
model = SCOPE_MODELS.get(kind)
if model is None:
raise Http404
obj = get_object_or_404(model.objects.restrict(request.user, "view"), pk=pk)
return Scope(kind, obj, request.user)
def _render_form(self, request, scope, form):
grouped = {}
for s in SECTIONS.values():
grouped.setdefault(s.group, []).append(s)
return render(request, self.template_name, {"object": scope.obj, "scope": scope, "form": form, "section_groups": grouped})
def get(self, request, kind, pk):
scope = self._scope(request, kind, pk)
return self._render_form(request, scope, ExportForm(scope=scope))
def post(self, request, kind, pk):
scope = self._scope(request, kind, pk)
form = ExportForm(request.POST, scope=scope)
if not form.is_valid():
return self._render_form(request, scope, form)
opts = form.cleaned_data
blocks = []
for key in opts["sections"]:
blocks += SECTIONS[key].func(scope, opts)
doc = Document(
title=opts["title"],
subtitle=opts.get("subtitle", ""),
blocks=blocks,
meta={
"author": opts.get("author") or request.user.get_full_name() or request.user.username,
"date": timezone.localtime().strftime("%d.%m.%Y"),
},
branding=get_branding(opts["branding"]),
)
try:
content, content_type, ext = render_document(doc, opts["format"], request)
except MissingDependency as e:
messages.error(request, str(e))
return self._render_form(request, scope, form)
response = HttpResponse(content, content_type=content_type)
if ext != "html":
filename = f"{slugify(opts['title']) or 'dokumentation'}_{timezone.localdate():%Y%m%d}.{ext}"
response["Content-Disposition"] = f'attachment; filename="{filename}"'
return response
+23
View File
@@ -0,0 +1,23 @@
[build-system]
requires = ["setuptools>=64"]
build-backend = "setuptools.build_meta"
[project]
name = "netbox-customer-export"
version = "0.1.0"
description = "NetBox-Plugin: Kundendokumentation (PDF/Word) für Standorte, Lokationen, Mandanten und Mandantengruppen"
readme = "README.md"
requires-python = ">=3.10"
license = { text = "Apache-2.0" }
dependencies = []
[project.optional-dependencies]
pdf = ["weasyprint>=62"]
docx = ["python-docx>=1.1", "cairosvg>=2.7"]
all = ["weasyprint>=62", "python-docx>=1.1", "cairosvg>=2.7"]
[tool.setuptools.packages.find]
include = ["netbox_customer_export*"]
[tool.setuptools.package-data]
netbox_customer_export = ["templates/**/*.html"]