diff --git a/README.md b/README.md index 76a72f9..43afefc 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,7 @@ Der Techniker wählt vor dem Export aus, welche Inhalte das Dokument enthalten s | Allgemein | Übersicht & Standortdaten (Adresse, Lokationsbaum, Kennzahlen), Ansprechpartner | | Infrastruktur | Racks nach Standort → Lokation mit Rack-Ansicht (Vorder-/Rückseite, optional mit Gerätebildern wie in NetBox) und Belegungsliste, Geräte nach **frei wählbaren Geräterollen** (Server, Switche, APs, Router …) mit **frei wählbaren Spalten**, Stromversorgung, VMs | | Netzwerk | VRFs (inkl. RTs), Präfixe nach VRF, VLANs, IP-Adressen (ausführlich), WLANs/SSIDs, Leitungen, **Netzwerk-Topologie** | -| Verträge & Lizenzen | Lizenzen aus einem Lizenz-Plugin (Standard: `netbox-lifecycle`) | +| Verträge & Lizenzen | Lizenzen aus [NetBox-SLM](https://git.mrblake.cc/MrBlake/NetBox-SLM) (automatisch erkannt) oder einem anderen Lizenz-Plugin (z.B. `netbox-lifecycle`) | Über **IP-Versionen** (IPv4, IPv6 oder beides) steuert man, welche Adressen im Dokument erscheinen: Management-/OOB-IPs, VMs, Topologie-Beschriftung, IP-Netze und IP-Adressen. @@ -244,6 +244,21 @@ Patchpanels, Steckdosen(leisten), PDUs, Kabelmanagement und Räume werden automa weitere Gerätetypen oder Rollen ausblenden. Eigene Icons liegen unter `MEDIA_ROOT/customer-export/symbols/`. Berechtigungen: Objekttyp *Customer Export | Topologie-Symbol*, analog zu den Branding-Profilen. +### Lizenzen (NetBox-SLM) + +Ist [NetBox-SLM](https://git.mrblake.cc/MrBlake/NetBox-SLM) installiert, liest der Abschnitt **Lizenzen** dessen Software-Lizenzen +automatisch. Eine Konfiguration ist nicht nötig. Zum Export gehört eine Lizenz, wenn + +* ihre **Installation** auf einem Gerät, einer VM oder einem Cluster im gewählten Standort/Lokation/Mandanten liegt, oder +* sie dem exportierten **Mandanten** zugeordnet ist, oder +* sie ohne Mandant einer **Mandantengruppe** zugeordnet ist, die zum Mandanten gehört (auch übergeordnete Gruppen) bzw. + beim Export einer Mandantengruppe darin liegt. + +Die Tabellen sind nach Hersteller gruppiert und zeigen Lizenz, Produkt/Version, Typ, Anzahl, Laufzeit (inkl. Verlängerung), +Status (*aktiv*, *läuft in N Tagen ab* ab 90 Tagen vor Ablauf, *abgelaufen*, *unbefristet*), Support, Installation und +Mandant. Der **Lizenzschlüssel** erscheint nur mit der Option „Geheimnisse exportieren“ (erfordert `allow_secrets: True`). +Zusätzlich konfigurierte `license_sources` werden weiterhin ausgegeben. + ### Rack-Ansichten Ohne Konfiguration nutzt das Plugin NetBox' eigenen `dcim.svg.RackElevationSVG`. Er läuft im selben Prozess, also ohne HTTP-Aufruf der API. diff --git a/netbox_customer_export/__init__.py b/netbox_customer_export/__init__.py index ca4fbc4..44e347a 100644 --- a/netbox_customer_export/__init__.py +++ b/netbox_customer_export/__init__.py @@ -5,7 +5,7 @@ class CustomerExportConfig(PluginConfig): name = "netbox_customer_export" verbose_name = "Kunden-Export" description = "Kundendokumentation als PDF oder Word exportieren" - version = "0.8.0" + version = "0.9.0" author = "Claude" base_url = "customer-export" min_version = "4.6.8" diff --git a/netbox_customer_export/licenses_slm.py b/netbox_customer_export/licenses_slm.py new file mode 100644 index 0000000..8a455b6 --- /dev/null +++ b/netbox_customer_export/licenses_slm.py @@ -0,0 +1,126 @@ +"""Lizenzen aus dem Plugin NetBox-SLM (netbox_slm.SoftwareLicense).""" +from datetime import date +from itertools import groupby + +from django.apps import apps +from django.db.models import Q + +from .blocks import EMPTY, H, T + +SOON_DAYS = 90 # „läuft bald ab“, wenn das Ablaufdatum näher liegt + + +def is_installed(): + return apps.is_installed("netbox_slm") + + +def _tenant_groups(scope): + """Mandantengruppen, deren mandantenlose Lizenzen zum Scope gehören.""" + from tenancy.models import TenantGroup + + if scope.kind == "tenantgroup": + return scope.obj.get_descendants(include_self=True) + if scope.kind == "tenant" and scope.obj.group_id: + # Lizenzen der eigenen Gruppe und übergeordneter Gruppen gelten auch für den Mandanten + return scope.obj.group.get_ancestors(include_self=True) + return TenantGroup.objects.none() + + +def licenses_for_scope(scope): + """SoftwareLicense-Queryset für den Scope: über Installationen (Gerät/VM/Cluster) und Mandant(engruppe).""" + from virtualization.models import Cluster + + SoftwareLicense = apps.get_model("netbox_slm", "SoftwareLicense") + devices = scope.devices() + vms = scope.virtual_machines() + clusters = Cluster.objects.filter(Q(virtual_machines__in=vms) | Q(devices__in=devices)).distinct() + + q = ( + Q(installation__device__in=devices) + | Q(installation__virtualmachine__in=vms) + | Q(installation__cluster__in=clusters) + ) + if scope.tenants is not None: + q |= Q(tenant__in=scope.tenants) | Q(tenant__isnull=True, tenant_group__in=_tenant_groups(scope)) + return ( + scope.restrict(SoftwareLicense.objects.filter(q).distinct()) + .select_related( + "software_product__manufacturer", "version", "tenant", "tenant_group", + "installation__device", "installation__virtualmachine", "installation__cluster", + ) + .order_by("software_product__name", "name") + ) + + +def _validity(lic, today): + """(Laufzeit-Text, Status-Text).""" + if lic.lifetime: + return "unbefristet", "aktiv" + start = lic.start_date.strftime("%d.%m.%Y") if lic.start_date else "" + end = lic.expiration_date.strftime("%d.%m.%Y") if lic.expiration_date else "" + period = f"{start} – {end}" if start and end else (f"bis {end}" if end else (f"ab {start}" if start else EMPTY)) + if lic.renewal_interval and lic.renewal_interval_unit: + if lic.renewal_interval == 1: + every = {"days": "täglich", "months": "monatlich", "years": "jährlich"}.get(lic.renewal_interval_unit, "") + else: + unit = {"days": "Tage", "months": "Monate", "years": "Jahre"}.get(lic.renewal_interval_unit, lic.renewal_interval_unit) + every = f"alle {lic.renewal_interval} {unit}" + period += f" (Verlängerung {every})" + if not lic.expiration_date: + return period, "aktiv" + days = (lic.expiration_date - today).days + if days < 0: + return period, "abgelaufen" + if days <= SOON_DAYS: + return period, f"läuft in {days} Tagen ab" + return period, "aktiv" + + +def _support(value): + return {True: "Ja", False: "Nein"}.get(value, EMPTY) + + +def _installed_on(lic): + inst = lic.installation + if not inst: + return None + target = inst.device or inst.virtualmachine or inst.cluster + kind = "Gerät" if inst.device_id else ("VM" if inst.virtualmachine_id else "Cluster") + return f"{target} ({kind})" if target else None + + +def slm_blocks(scope, opts): + """Tabellen je Hersteller; leer, wenn keine Lizenz zum Scope passt.""" + qs = list(licenses_for_scope(scope)) + if not qs: + return [] + today = date.today() + show_key = bool(opts.get("include_secrets")) + headers = ["Lizenz", "Produkt / Version", "Typ", "Anzahl", "Laufzeit", "Status", "Support", "Installiert auf", "Mandant"] + if show_key: + headers.append("Lizenzschlüssel") + + blocks = [] + by_manufacturer = sorted(qs, key=lambda lic: (str(lic.software_product.manufacturer or "~"), str(lic.software_product), lic.name)) + for manufacturer, items in groupby(by_manufacturer, key=lambda lic: lic.software_product.manufacturer): + rows = [] + for lic in items: + period, status = _validity(lic, today) + product = str(lic.software_product) + (f" {lic.version}" if lic.version else "") + row = [ + lic.name + (f"\n{lic.description}" if lic.description else ""), + product, + lic.type or lic.spdx_expression, + lic.license_amount, + period, + status, + _support(lic.support), + _installed_on(lic), + lic.tenant or lic.tenant_group, + ] + if show_key: + row.append(lic.license_key) + rows.append(row) + blocks.append(H(2, str(manufacturer) if manufacturer else "Ohne Hersteller")) + blocks.append(T(headers, rows)) + return blocks diff --git a/netbox_customer_export/sections.py b/netbox_customer_export/sections.py index 514484f..9bf666f 100644 --- a/netbox_customer_export/sections.py +++ b/netbox_customer_export/sections.py @@ -402,11 +402,16 @@ DEFAULT_LICENSE_SOURCES = [ ] -@section("licenses", "Lizenzen", "Verträge & Lizenzen", hint="Aus Lizenz-Plugin (z.B. netbox-lifecycle)") +@section("licenses", "Lizenzen", "Verträge & Lizenzen", hint="Aus NetBox-SLM oder anderem Lizenz-Plugin") def licenses(scope, opts): + from . import licenses_slm + sources = get_plugin_config("netbox_customer_export", "license_sources") or DEFAULT_LICENSE_SOURCES blocks = [H(1, "Lizenzen")] found_source = False + if licenses_slm.is_installed(): + found_source = True + blocks += licenses_slm.slm_blocks(scope, opts) for src in sources: try: model = apps.get_model(src["model"]) @@ -423,7 +428,7 @@ def licenses(scope, opts): 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 _empty(opts, blocks, "Kein Lizenz-Plugin installiert (NetBox-SLM oder netbox-lifecycle) – siehe README.") return blocks if len(blocks) > 1 else _empty(opts, blocks) diff --git a/netbox_customer_export/templates/netbox_customer_export/document.html b/netbox_customer_export/templates/netbox_customer_export/document.html index 4abeaab..8f8414e 100644 --- a/netbox_customer_export/templates/netbox_customer_export/document.html +++ b/netbox_customer_export/templates/netbox_customer_export/document.html @@ -75,7 +75,7 @@ {% elif b.kind == 'paragraph' %}
{{ b.text|linebreaksbr }}
{% elif b.kind == 'table' %}| {{ h }} | {% endfor %}
|---|
| {{ c }} | {% endfor %}
| {{ row.0 }} | {{ row.1|linebreaksbr }} |