feat: license export compatible with NetBox-SLM
When the netbox_slm plugin is installed, the "Lizenzen" section reads its SoftwareLicense objects automatically: - Scope matching via installations on devices, VMs or clusters in the exported site/location/tenant, plus licenses assigned to the tenant and tenant-less licenses of its tenant group (incl. parent groups) or, for tenant group exports, of the group and its subgroups. - One table per manufacturer: license, product/version, type, amount, validity incl. renewal interval, status (active, expires in N days, expired, lifetime), support, installation target and tenant. - License keys only with the "include secrets" option. - Other configured license_sources keep working alongside. Table cells in HTML/PDF now preserve line breaks. Bump version to 0.9.0. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -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 |
|
| 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 |
|
| 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** |
|
| 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,
|
Ü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.
|
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/`.
|
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.
|
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
|
### Rack-Ansichten
|
||||||
|
|
||||||
Ohne Konfiguration nutzt das Plugin NetBox' eigenen `dcim.svg.RackElevationSVG`. Er läuft im selben Prozess, also ohne HTTP-Aufruf der API.
|
Ohne Konfiguration nutzt das Plugin NetBox' eigenen `dcim.svg.RackElevationSVG`. Er läuft im selben Prozess, also ohne HTTP-Aufruf der API.
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ class CustomerExportConfig(PluginConfig):
|
|||||||
name = "netbox_customer_export"
|
name = "netbox_customer_export"
|
||||||
verbose_name = "Kunden-Export"
|
verbose_name = "Kunden-Export"
|
||||||
description = "Kundendokumentation als PDF oder Word exportieren"
|
description = "Kundendokumentation als PDF oder Word exportieren"
|
||||||
version = "0.8.0"
|
version = "0.9.0"
|
||||||
author = "Claude"
|
author = "Claude"
|
||||||
base_url = "customer-export"
|
base_url = "customer-export"
|
||||||
min_version = "4.6.8"
|
min_version = "4.6.8"
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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):
|
def licenses(scope, opts):
|
||||||
|
from . import licenses_slm
|
||||||
|
|
||||||
sources = get_plugin_config("netbox_customer_export", "license_sources") or DEFAULT_LICENSE_SOURCES
|
sources = get_plugin_config("netbox_customer_export", "license_sources") or DEFAULT_LICENSE_SOURCES
|
||||||
blocks = [H(1, "Lizenzen")]
|
blocks = [H(1, "Lizenzen")]
|
||||||
found_source = False
|
found_source = False
|
||||||
|
if licenses_slm.is_installed():
|
||||||
|
found_source = True
|
||||||
|
blocks += licenses_slm.slm_blocks(scope, opts)
|
||||||
for src in sources:
|
for src in sources:
|
||||||
try:
|
try:
|
||||||
model = apps.get_model(src["model"])
|
model = apps.get_model(src["model"])
|
||||||
@@ -423,7 +428,7 @@ def licenses(scope, opts):
|
|||||||
cols = src["columns"]
|
cols = src["columns"]
|
||||||
blocks.append(T([c[0] for c in cols], [[get(o, c[1]) for c in cols] for o in qs]))
|
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:
|
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)
|
return blocks if len(blocks) > 1 else _empty(opts, blocks)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -75,7 +75,7 @@
|
|||||||
{% elif b.kind == 'paragraph' %}<p>{{ b.text|linebreaksbr }}</p>
|
{% elif b.kind == 'paragraph' %}<p>{{ b.text|linebreaksbr }}</p>
|
||||||
{% elif b.kind == 'table' %}
|
{% elif b.kind == 'table' %}
|
||||||
<table><thead><tr>{% for h in b.headers %}<th>{{ h }}</th>{% endfor %}</tr></thead>
|
<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>
|
<tbody>{% for row in b.rows %}<tr>{% for c in row %}<td>{{ c|linebreaksbr }}</td>{% endfor %}</tr>{% endfor %}</tbody></table>
|
||||||
{% elif b.kind == 'kv' %}
|
{% 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>
|
<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' %}
|
{% elif b.kind == 'images' %}
|
||||||
|
|||||||
+1
-1
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "netbox-customer-export"
|
name = "netbox-customer-export"
|
||||||
version = "0.8.0"
|
version = "0.9.0"
|
||||||
description = "NetBox-Plugin: Kundendokumentation (PDF/Word) für Standorte, Lokationen, Mandanten und Mandantengruppen"
|
description = "NetBox-Plugin: Kundendokumentation (PDF/Word) für Standorte, Lokationen, Mandanten und Mandantengruppen"
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
requires-python = ">=3.10"
|
requires-python = ">=3.10"
|
||||||
|
|||||||
Reference in New Issue
Block a user