From 8f45bffa999067bb71822f9254e40d985874e44b Mon Sep 17 00:00:00 2001 From: Louis Date: Wed, 23 Sep 2026 12:43:19 +0200 Subject: [PATCH] NetBox Customer Export plugin with configurable branding MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .gitignore | 5 + README.md | 114 +++++ netbox_customer_export/__init__.py | 27 ++ netbox_customer_export/blocks.py | 86 ++++ netbox_customer_export/branding.py | 54 +++ netbox_customer_export/forms.py | 79 ++++ netbox_customer_export/navigation.py | 9 + netbox_customer_export/rack_svg.py | 28 ++ netbox_customer_export/renderers/__init__.py | 23 ++ netbox_customer_export/renderers/docx.py | 177 ++++++++ netbox_customer_export/renderers/html.py | 19 + netbox_customer_export/scope.py | 129 ++++++ netbox_customer_export/sections.py | 391 ++++++++++++++++++ netbox_customer_export/template_content.py | 21 + .../netbox_customer_export/_checkboxes.html | 7 + .../netbox_customer_export/document.html | 77 ++++ .../netbox_customer_export/export.html | 66 +++ .../netbox_customer_export/select.html | 20 + netbox_customer_export/urls.py | 8 + netbox_customer_export/views.py | 81 ++++ pyproject.toml | 23 ++ 21 files changed, 1444 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 netbox_customer_export/__init__.py create mode 100644 netbox_customer_export/blocks.py create mode 100644 netbox_customer_export/branding.py create mode 100644 netbox_customer_export/forms.py create mode 100644 netbox_customer_export/navigation.py create mode 100644 netbox_customer_export/rack_svg.py create mode 100644 netbox_customer_export/renderers/__init__.py create mode 100644 netbox_customer_export/renderers/docx.py create mode 100644 netbox_customer_export/renderers/html.py create mode 100644 netbox_customer_export/scope.py create mode 100644 netbox_customer_export/sections.py create mode 100644 netbox_customer_export/template_content.py create mode 100644 netbox_customer_export/templates/netbox_customer_export/_checkboxes.html create mode 100644 netbox_customer_export/templates/netbox_customer_export/document.html create mode 100644 netbox_customer_export/templates/netbox_customer_export/export.html create mode 100644 netbox_customer_export/templates/netbox_customer_export/select.html create mode 100644 netbox_customer_export/urls.py create mode 100644 netbox_customer_export/views.py create mode 100644 pyproject.toml diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..47584ad --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +__pycache__/ +*.egg-info/ +build/ +dist/ +.venv/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..d515697 --- /dev/null +++ b/README.md @@ -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:///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.." + "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. diff --git a/netbox_customer_export/__init__.py b/netbox_customer_export/__init__.py new file mode 100644 index 0000000..c33df15 --- /dev/null +++ b/netbox_customer_export/__init__.py @@ -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 diff --git a/netbox_customer_export/blocks.py b/netbox_customer_export/blocks.py new file mode 100644 index 0000000..baed8a2 --- /dev/null +++ b/netbox_customer_export/blocks.py @@ -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__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 diff --git a/netbox_customer_export/branding.py b/netbox_customer_export/branding.py new file mode 100644 index 0000000..11a5cbb --- /dev/null +++ b/netbox_customer_export/branding.py @@ -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())) diff --git a/netbox_customer_export/forms.py b/netbox_customer_export/forms.py new file mode 100644 index 0000000..eb29700 --- /dev/null +++ b/netbox_customer_export/forms.py @@ -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"] diff --git a/netbox_customer_export/navigation.py b/netbox_customer_export/navigation.py new file mode 100644 index 0000000..e483294 --- /dev/null +++ b/netbox_customer_export/navigation.py @@ -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"], + ), +) diff --git a/netbox_customer_export/rack_svg.py b/netbox_customer_export/rack_svg.py new file mode 100644 index 0000000..6ced7d8 --- /dev/null +++ b/netbox_customer_export/rack_svg.py @@ -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 diff --git a/netbox_customer_export/renderers/__init__.py b/netbox_customer_export/renderers/__init__.py new file mode 100644 index 0000000..55febd4 --- /dev/null +++ b/netbox_customer_export/renderers/__init__.py @@ -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" diff --git a/netbox_customer_export/renderers/docx.py b/netbox_customer_export/renderers/docx.py new file mode 100644 index 0000000..32292fc --- /dev/null +++ b/netbox_customer_export/renderers/docx.py @@ -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() diff --git a/netbox_customer_export/renderers/html.py b/netbox_customer_export/renderers/html.py new file mode 100644 index 0000000..6f8f030 --- /dev/null +++ b/netbox_customer_export/renderers/html.py @@ -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"))}, + ) diff --git a/netbox_customer_export/scope.py b/netbox_customer_export/scope.py new file mode 100644 index 0000000..28ab4c6 --- /dev/null +++ b/netbox_customer_export/scope.py @@ -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()) diff --git a/netbox_customer_export/sections.py b/netbox_customer_export/sections.py new file mode 100644 index 0000000..b940afe --- /dev/null +++ b/netbox_customer_export/sections.py @@ -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) diff --git a/netbox_customer_export/template_content.py b/netbox_customer_export/template_content.py new file mode 100644 index 0000000..ee72778 --- /dev/null +++ b/netbox_customer_export/template_content.py @@ -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( + ' Kundendoku', + url, + ) + + +template_extensions = [CustomerExportButton] diff --git a/netbox_customer_export/templates/netbox_customer_export/_checkboxes.html b/netbox_customer_export/templates/netbox_customer_export/_checkboxes.html new file mode 100644 index 0000000..2d0cfa4 --- /dev/null +++ b/netbox_customer_export/templates/netbox_customer_export/_checkboxes.html @@ -0,0 +1,7 @@ +
+ {% for cb in field %} +
+
{{ cb.tag }}
+
+ {% empty %}
Keine Einträge im Scope.
{% endfor %} +
diff --git a/netbox_customer_export/templates/netbox_customer_export/document.html b/netbox_customer_export/templates/netbox_customer_export/document.html new file mode 100644 index 0000000..a5f4d12 --- /dev/null +++ b/netbox_customer_export/templates/netbox_customer_export/document.html @@ -0,0 +1,77 @@ + + + + +{{ doc.title }} + + + + +
+ {% if logo %}{% endif %} +

{{ doc.title }}

+ {% if doc.subtitle %}
{{ doc.subtitle }}
{% endif %} +
+ Stand: {{ doc.meta.date }}
+ Erstellt von: {{ doc.meta.author }}
+ {{ b.company_name }} +
+
+ +{% for b in doc.blocks %} + {% if b.kind == 'heading' %} + {% if b.level == 1 %}

{{ b.text }}

{% elif b.level == 2 %}

{{ b.text }}

{% elif b.level == 3 %}

{{ b.text }}

{% else %}

{{ b.text }}

{% endif %} + {% elif b.kind == 'paragraph' %}

{{ b.text|linebreaksbr }}

+ {% elif b.kind == 'table' %} + {% for h in b.headers %}{% endfor %} + {% for row in b.rows %}{% for c in row %}{% endfor %}{% endfor %}
{{ h }}
{{ c }}
+ {% elif b.kind == 'kv' %} + {% for row in b.rows %}{% endfor %}
{{ row.0 }}{{ row.1|linebreaksbr }}
+ {% elif b.kind == 'images' %} +
{% for img in b.images %}
{{ img.caption }}
{{ img.caption }}
{% endfor %}
+ {% elif b.kind == 'pagebreak' %}
+ {% endif %} +{% endfor %} + + diff --git a/netbox_customer_export/templates/netbox_customer_export/export.html b/netbox_customer_export/templates/netbox_customer_export/export.html new file mode 100644 index 0000000..a94aec9 --- /dev/null +++ b/netbox_customer_export/templates/netbox_customer_export/export.html @@ -0,0 +1,66 @@ +{% extends 'generic/_base.html' %} +{% load form_helpers %} +{% block title %}Kundendoku: {{ object }}{% endblock %} +{% block subtitle %}
{{ scope.label }}
{% endblock %} +{% block content %} +
+ {% csrf_token %} + {% if form.errors %}
{{ form.errors }}
{% endif %} +
+
+
+

Dokument

+
+ {% 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 %}{% endif %} +
+
+
+

Inhalte

+
+ {% for group, sections in section_groups.items %} +
{{ group }}
+ {% for cb in form.sections %}{% for s in sections %}{% if s.key == cb.data.value %} +
{{ cb.tag }}
+ {% endif %}{% endfor %}{% endfor %} + {% endfor %} +
+
{{ form.include_empty }}
+ {% if form.include_secrets %} +
{{ form.include_secrets }}
+ {% endif %} +
+
+
+
+
+

Geräte

+
+
Geräterollen
+ {% include 'netbox_customer_export/_checkboxes.html' with field=form.roles cols=4 %} +
Spalten
+ {% include 'netbox_customer_export/_checkboxes.html' with field=form.device_columns cols=4 %} +
{{ form.group_by_site }}
+
+
+
+

Racks

+
+
{{ form.rack_svg }}
+
{{ form.rack_devices }}
+
{{ form.rack_pagebreak }}
+
Seiten
+ {% include 'netbox_customer_export/_checkboxes.html' with field=form.rack_faces cols=6 %} +
+
+
+
+
+ Abbrechen + +
+
+{% endblock %} diff --git a/netbox_customer_export/templates/netbox_customer_export/select.html b/netbox_customer_export/templates/netbox_customer_export/select.html new file mode 100644 index 0000000..09c6ed4 --- /dev/null +++ b/netbox_customer_export/templates/netbox_customer_export/select.html @@ -0,0 +1,20 @@ +{% extends 'generic/_base.html' %} +{% load form_helpers %} +{% block title %}Kundendokumentation{% endblock %} +{% block content %} +
+ {% csrf_token %} +
+

Scope wählen

+
+

Genau eines der Felder ausfüllen.

+ {% if form.non_field_errors %}
{{ form.non_field_errors }}
{% endif %} + {% render_field form.site %} + {% render_field form.location %} + {% render_field form.tenant %} + {% render_field form.tenantgroup %} +
+ +
+
+{% endblock %} diff --git a/netbox_customer_export/urls.py b/netbox_customer_export/urls.py new file mode 100644 index 0000000..9c0ff55 --- /dev/null +++ b/netbox_customer_export/urls.py @@ -0,0 +1,8 @@ +from django.urls import path + +from . import views + +urlpatterns = [ + path("", views.ScopeSelectView.as_view(), name="select"), + path("export///", views.ExportView.as_view(), name="export"), +] diff --git a/netbox_customer_export/views.py b/netbox_customer_export/views.py new file mode 100644 index 0000000..8a2151b --- /dev/null +++ b/netbox_customer_export/views.py @@ -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 diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..feb4532 --- /dev/null +++ b/pyproject.toml @@ -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"]