feat: network topology diagram with Packet-Tracer-style symbols
- New section "Netzwerk-Topologie": one diagram per site built from NetBox cable paths (traced through patch panels), layered top-down by cabling (provider > firewall/router > core > access > endpoints), barycenter ordering, "2x" for parallel links, provider circuits as clouds and devices outside the scope dashed. Labels: management IP, model, ports. - New export format "draw.io": the topology as an editable .drawio file (one page per site, symbols embedded). - 14 built-in symbols drawn with Pillow; automatic mapping from role, model and manufacturer, passive gear (patch panels, PDUs, UPS) hidden. - New TopologySymbol model (migration 0002) with list/edit/delete UI, symbol gallery, custom icon upload and a "Topologie-Symbol" button on device type and device role pages. Bump version to 0.4.0 - requires "manage.py migrate". Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -9,7 +9,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 | 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`) |
|
||||
|
||||
Alle Abfragen laufen über die NetBox-Berechtigungen (`.restrict(user, "view")`). Ein Techniker exportiert also nur, was er auch sehen darf.
|
||||
@@ -19,6 +19,8 @@ Alle Abfragen laufen über die NetBox-Berechtigungen (`.restrict(user, "view")`)
|
||||
* Button **„Kundendoku“** auf Standort-, Lokations-, Mandanten- und Mandantengruppen-Seiten
|
||||
* oder Menü **Plugins → Kundendokumentation**
|
||||
|
||||
Formate: **PDF**, **Word**, **HTML-Vorschau** (öffnet in neuem Tab) und **draw.io** (nur Topologie, editierbar).
|
||||
|
||||
## Installation
|
||||
|
||||
Die Pfade gehen von einer Standardinstallation unter `/opt/netbox` aus.
|
||||
@@ -92,7 +94,7 @@ python3 manage.py clearsessions
|
||||
sudo systemctl restart netbox netbox-rq
|
||||
```
|
||||
|
||||
`migrate` ist ab Version 0.2.0 **Pflicht**, denn es legt die Tabelle für die Branding-Profile an. Die übrigen Befehle gehören zum
|
||||
`migrate` ist ab Version 0.2.0 **Pflicht**, denn es legt die Tabellen für Branding-Profile und (ab 0.4.0) Topologie-Symbole an. Die übrigen Befehle gehören zum
|
||||
üblichen NetBox-Ablauf nach jeder Plugin-Installation.
|
||||
|
||||
## Update
|
||||
@@ -208,6 +210,27 @@ Farben werden als Hex angegeben, mit oder ohne `#`. Ungültige Werte führen bei
|
||||
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.
|
||||
|
||||
### Netzwerk-Topologie
|
||||
|
||||
Der Abschnitt **Netzwerk-Topologie** zeichnet je Standort ein Diagramm mit Symbolen im Stil von Cisco Packet Tracer:
|
||||
|
||||
* Verbindungen stammen aus den **Kabelpfaden** in NetBox. Pfade über Patchpanels werden bis zum Endgerät verfolgt.
|
||||
* Die Anordnung von oben nach unten ergibt sich aus der Verkabelung: Provider → Firewall/Router → Core → Access → Endgeräte.
|
||||
Mehrfach-Uplinks werden als „2ד markiert.
|
||||
* Provider-Leitungen erscheinen als Wolke, Geräte anderer Standorte gestrichelt (abschaltbar).
|
||||
* Beschriftung wählbar: Management-IP, Modell, Ports an den Verbindungen.
|
||||
* Mit dem Format **draw.io** wird die Topologie als editierbare `.drawio`-Datei exportiert, eine Seite je Standort.
|
||||
|
||||
**Symbole** (Menü **Plugins → Topologie-Symbole**, oder Button „Topologie-Symbol“ auf Gerätetyp- und Geräterollen-Seiten):
|
||||
|
||||
1. Zuordnung je **Gerätetyp**: eingebautes Symbol wählen oder **eigenes Icon hochladen** (PNG/JPG, idealerweise transparent)
|
||||
2. Zuordnung je **Geräterolle**
|
||||
3. sonst **automatisch** anhand von Rolle, Modell und Hersteller (z.B. „Switch“, „Sophos“, „Access Point“, „Core“)
|
||||
|
||||
Patchpanels, Steckdosenleisten, PDUs und USVs werden automatisch ausgeblendet. Über „In Topologie ausblenden“ lassen sich
|
||||
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.
|
||||
|
||||
### Rack-Ansichten
|
||||
|
||||
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"
|
||||
verbose_name = "Kunden-Export"
|
||||
description = "Kundendokumentation als PDF oder Word exportieren"
|
||||
version = "0.3.1"
|
||||
version = "0.4.0"
|
||||
author = "Claude"
|
||||
base_url = "customer-export"
|
||||
min_version = "4.6.8"
|
||||
|
||||
@@ -52,7 +52,7 @@ def KV(pairs, skip_empty=True):
|
||||
return Block("kv", rows=rows)
|
||||
|
||||
|
||||
def IMAGES(images):
|
||||
def IMAGES(images, wide=False):
|
||||
"""images: Liste von (svg_string oder None, caption, png_bytes_oder_None)."""
|
||||
out = []
|
||||
for svg, caption, png in images:
|
||||
@@ -63,7 +63,7 @@ def IMAGES(images):
|
||||
else:
|
||||
continue
|
||||
out.append({"svg": svg, "png": png, "caption": caption, "data_uri": data_uri})
|
||||
return Block("images", images=out)
|
||||
return Block("images", images=out, level=1 if wide else 0) # level=1: volle Seitenbreite
|
||||
|
||||
|
||||
def PAGEBREAK():
|
||||
|
||||
@@ -1,14 +1,15 @@
|
||||
from django import forms
|
||||
from dcim.models import DeviceRole, Location, Site
|
||||
from dcim.models import DeviceRole, DeviceType, 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 .models import BrandingProfile
|
||||
from .models import BrandingProfile, TopologySymbol
|
||||
from .sections import DEFAULT_DEVICE_COLUMNS, DEVICE_COLUMNS, SECTIONS
|
||||
from .symbols import KIND_CHOICES
|
||||
|
||||
FORMAT_CHOICES = (("pdf", "PDF"), ("docx", "Word (.docx)"), ("html", "HTML-Vorschau"))
|
||||
FORMAT_CHOICES = (("pdf", "PDF"), ("docx", "Word"), ("html", "HTML-Vorschau"), ("drawio", "draw.io"))
|
||||
|
||||
|
||||
class ScopeSelectForm(forms.Form):
|
||||
@@ -64,6 +65,18 @@ class ExportForm(forms.Form):
|
||||
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")
|
||||
|
||||
topo_details = forms.MultipleChoiceField(
|
||||
choices=(("model", "Modell"), ("primary_ip", "Management-IP"), ("ports", "Ports an Verbindungen")),
|
||||
initial=["primary_ip"],
|
||||
required=False,
|
||||
widget=forms.CheckboxSelectMultiple,
|
||||
label="Beschriftung",
|
||||
)
|
||||
topo_external = forms.BooleanField(
|
||||
required=False, initial=True, label="Provider-Leitungen & Geräte außerhalb anzeigen"
|
||||
)
|
||||
topo_isolated = forms.BooleanField(required=False, initial=False, label="Unverkabelte Geräte anzeigen")
|
||||
|
||||
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")
|
||||
|
||||
@@ -128,3 +141,22 @@ class BrandingProfileForm(forms.ModelForm):
|
||||
w.attrs.setdefault("class", "form-check-input")
|
||||
else:
|
||||
w.attrs.setdefault("class", "form-control")
|
||||
|
||||
|
||||
class TopologySymbolForm(forms.ModelForm):
|
||||
device_type = DynamicModelChoiceField(queryset=DeviceType.objects.all(), required=False, label="Gerätetyp")
|
||||
role = DynamicModelChoiceField(queryset=DeviceRole.objects.all(), required=False, label="Geräterolle")
|
||||
kind = forms.ChoiceField(
|
||||
choices=[("", "Automatisch")] + KIND_CHOICES, required=False, widget=forms.RadioSelect, label="Symbol"
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = TopologySymbol
|
||||
fields = ("device_type", "role", "kind", "icon")
|
||||
help_texts = {
|
||||
"icon": "PNG, JPG oder GIF, am besten quadratisch mit transparentem Hintergrund. Ersetzt das gewählte Symbol.",
|
||||
}
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
self.fields["icon"].widget.attrs["class"] = "form-control"
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
("dcim", "__first__"),
|
||||
("netbox_customer_export", "0001_brandingprofile"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name="TopologySymbol",
|
||||
fields=[
|
||||
("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")),
|
||||
("kind", models.CharField(blank=True, max_length=20, verbose_name="Symbol")),
|
||||
("icon", models.ImageField(blank=True, upload_to="customer-export/symbols/", verbose_name="Eigenes Icon")),
|
||||
("last_updated", models.DateTimeField(auto_now=True)),
|
||||
("device_type", models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name="+", to="dcim.devicetype", verbose_name="Gerätetyp")),
|
||||
("role", models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name="+", to="dcim.devicerole", verbose_name="Geräterolle")),
|
||||
],
|
||||
options={
|
||||
"verbose_name": "Topologie-Symbol",
|
||||
"verbose_name_plural": "Topologie-Symbole",
|
||||
"ordering": ("device_type__model", "role__name"),
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -74,3 +74,45 @@ class BrandingProfile(models.Model):
|
||||
"docx_table_style": self.docx_table_style,
|
||||
"extra_css": self.extra_css,
|
||||
}
|
||||
|
||||
|
||||
class TopologySymbol(models.Model):
|
||||
"""Topologie-Symbol für einen Gerätetyp oder eine Geräterolle (Gerätetyp hat Vorrang)."""
|
||||
|
||||
device_type = models.OneToOneField(
|
||||
"dcim.DeviceType", on_delete=models.CASCADE, null=True, blank=True, related_name="+", verbose_name="Gerätetyp"
|
||||
)
|
||||
role = models.OneToOneField(
|
||||
"dcim.DeviceRole", on_delete=models.CASCADE, null=True, blank=True, related_name="+", verbose_name="Geräterolle"
|
||||
)
|
||||
kind = models.CharField(max_length=20, blank=True, verbose_name="Symbol")
|
||||
icon = models.ImageField(upload_to="customer-export/symbols/", blank=True, verbose_name="Eigenes Icon")
|
||||
last_updated = models.DateTimeField(auto_now=True)
|
||||
|
||||
class Meta:
|
||||
ordering = ("device_type__model", "role__name")
|
||||
verbose_name = "Topologie-Symbol"
|
||||
verbose_name_plural = "Topologie-Symbole"
|
||||
|
||||
def __str__(self):
|
||||
return str(self.device_type or self.role)
|
||||
|
||||
def get_absolute_url(self):
|
||||
return reverse("plugins:netbox_customer_export:symbol_edit", kwargs={"pk": self.pk})
|
||||
|
||||
@property
|
||||
def target_label(self):
|
||||
return "Gerätetyp" if self.device_type_id else "Geräterolle"
|
||||
|
||||
def get_kind_display(self):
|
||||
from .symbols import KIND_CHOICES
|
||||
|
||||
return dict(KIND_CHOICES).get(self.kind, "Automatisch")
|
||||
|
||||
def clean(self):
|
||||
from django.core.exceptions import ValidationError
|
||||
|
||||
if bool(self.device_type_id) == bool(self.role_id):
|
||||
raise ValidationError("Bitte genau einen Gerätetyp oder eine Geräterolle wählen.")
|
||||
if not self.kind and not self.icon:
|
||||
raise ValidationError("Bitte ein Symbol wählen oder ein eigenes Icon hochladen.")
|
||||
|
||||
@@ -6,6 +6,19 @@ menu_items = (
|
||||
link_text="Kundendokumentation",
|
||||
permissions=["dcim.view_site"],
|
||||
),
|
||||
PluginMenuItem(
|
||||
link="plugins:netbox_customer_export:symbol_list",
|
||||
link_text="Topologie-Symbole",
|
||||
permissions=["netbox_customer_export.view_topologysymbol"],
|
||||
buttons=(
|
||||
PluginMenuButton(
|
||||
link="plugins:netbox_customer_export:symbol_add",
|
||||
title="Hinzufügen",
|
||||
icon_class="mdi mdi-plus-thick",
|
||||
permissions=["netbox_customer_export.add_topologysymbol"],
|
||||
),
|
||||
),
|
||||
),
|
||||
PluginMenuItem(
|
||||
link="plugins:netbox_customer_export:branding_list",
|
||||
link_text="Branding-Profile",
|
||||
|
||||
@@ -160,11 +160,13 @@ def render_docx(doc):
|
||||
cell = t.cell(0, i).paragraphs[0]
|
||||
cell.alignment = WD_ALIGN_PARAGRAPH.CENTER
|
||||
if png:
|
||||
cell.add_run().add_picture(BytesIO(png), width=Cm(7)) # Höhe skaliert mit der Rackgröße
|
||||
# Topologie: volle Breite; Racks: 7 cm (Höhe skaliert mit der Rackgröße)
|
||||
cell.add_run().add_picture(BytesIO(png), width=Cm(16.5 if blk.level == 1 else 7))
|
||||
else:
|
||||
cell.add_run("(Rack-Grafik nicht verfügbar)")
|
||||
cap = t.cell(1, i).paragraphs[0]
|
||||
cap.alignment = WD_ALIGN_PARAGRAPH.CENTER
|
||||
if img["caption"]:
|
||||
cap.add_run(img["caption"]).italic = True
|
||||
d.add_paragraph()
|
||||
|
||||
|
||||
@@ -406,3 +406,18 @@ def licenses(scope, opts):
|
||||
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)
|
||||
|
||||
|
||||
@section("topology", "Netzwerk-Topologie", "Netzwerk", default=False, hint="Diagramm mit Symbolen, wie Packet Tracer")
|
||||
def topology(scope, opts):
|
||||
from .topology import build_graphs, render_png
|
||||
|
||||
blocks = [H(1, "Netzwerk-Topologie")]
|
||||
graphs = build_graphs(scope, opts)
|
||||
if not graphs:
|
||||
return _empty(opts, blocks, "Keine verkabelten Geräte gefunden.")
|
||||
ports = "ports" in (opts.get("topo_details") or [])
|
||||
for g in graphs:
|
||||
blocks.append(H(2, g.title))
|
||||
blocks.append(IMAGES([(None, "", render_png(g, show_ports=ports))], wide=True))
|
||||
return blocks
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
"""Verwaltung der Topologie-Symbole je Gerätetyp / Geräterolle."""
|
||||
from django.contrib import messages
|
||||
from django.contrib.auth.mixins import LoginRequiredMixin, PermissionRequiredMixin
|
||||
from django.http import Http404, HttpResponse
|
||||
from django.shortcuts import get_object_or_404, redirect, render
|
||||
from django.urls import reverse
|
||||
from django.views import View
|
||||
|
||||
from .forms import TopologySymbolForm
|
||||
from .models import TopologySymbol
|
||||
from .symbols import HIDDEN, KINDS, builtin_icon
|
||||
|
||||
APP = "netbox_customer_export"
|
||||
|
||||
|
||||
def _builtin_kinds():
|
||||
return [(k, label, reverse(f"plugins:{APP}:symbol_builtin", kwargs={"kind": k})) for k, (label, _) in KINDS.items()]
|
||||
|
||||
|
||||
class BuiltinIconView(LoginRequiredMixin, View):
|
||||
"""PNG eines eingebauten Symbols (für Galerie und Vorschau)."""
|
||||
|
||||
def get(self, request, kind):
|
||||
if kind not in KINDS:
|
||||
raise Http404
|
||||
response = HttpResponse(builtin_icon(kind), content_type="image/png")
|
||||
response["Cache-Control"] = "max-age=86400"
|
||||
return response
|
||||
|
||||
|
||||
class SymbolListView(PermissionRequiredMixin, View):
|
||||
permission_required = f"{APP}.view_topologysymbol"
|
||||
|
||||
def get(self, request):
|
||||
symbols = TopologySymbol.objects.select_related("device_type__manufacturer", "role")
|
||||
builtin = {k: url for k, _, url in _builtin_kinds()}
|
||||
rows = []
|
||||
for s in symbols:
|
||||
if s.icon:
|
||||
preview = s.icon.url
|
||||
elif s.kind and s.kind != HIDDEN:
|
||||
preview = builtin[s.kind]
|
||||
else:
|
||||
preview = None
|
||||
rows.append((s, preview))
|
||||
return render(
|
||||
request,
|
||||
f"{APP}/symbol_list.html",
|
||||
{"rows": rows, "builtin": _builtin_kinds(), "types": [r for r in rows if r[0].device_type_id], "roles": [r for r in rows if r[0].role_id]},
|
||||
)
|
||||
|
||||
|
||||
class SymbolEditView(PermissionRequiredMixin, View):
|
||||
template_name = f"{APP}/symbol_edit.html"
|
||||
|
||||
def get_permission_required(self):
|
||||
return [f"{APP}.{'change' if self.kwargs.get('pk') else 'add'}_topologysymbol"]
|
||||
|
||||
def _obj(self, pk):
|
||||
return get_object_or_404(TopologySymbol, pk=pk) if pk else TopologySymbol()
|
||||
|
||||
def _render(self, request, obj, form):
|
||||
return render(request, self.template_name, {"object": obj, "form": form, "builtin": _builtin_kinds()})
|
||||
|
||||
def get(self, request, pk=None):
|
||||
obj = self._obj(pk)
|
||||
initial = {}
|
||||
for key in ("device_type", "role"): # Vorbelegung z.B. aus dem Link auf der Gerätetyp-Seite
|
||||
if request.GET.get(key):
|
||||
initial[key] = request.GET[key]
|
||||
return self._render(request, obj, TopologySymbolForm(instance=obj, initial=initial))
|
||||
|
||||
def post(self, request, pk=None):
|
||||
obj = self._obj(pk)
|
||||
form = TopologySymbolForm(request.POST, request.FILES, instance=obj)
|
||||
if form.is_valid():
|
||||
obj = form.save()
|
||||
messages.success(request, f"Topologie-Symbol für „{obj}“ gespeichert.")
|
||||
return redirect(f"plugins:{APP}:symbol_list")
|
||||
return self._render(request, obj, form)
|
||||
|
||||
|
||||
class SymbolDeleteView(PermissionRequiredMixin, View):
|
||||
permission_required = f"{APP}.delete_topologysymbol"
|
||||
|
||||
def get(self, request, pk):
|
||||
return render(request, f"{APP}/symbol_delete.html", {"object": get_object_or_404(TopologySymbol, pk=pk)})
|
||||
|
||||
def post(self, request, pk):
|
||||
obj = get_object_or_404(TopologySymbol, pk=pk)
|
||||
name = str(obj)
|
||||
if obj.icon:
|
||||
obj.icon.delete(save=False)
|
||||
obj.delete()
|
||||
messages.success(request, f"Topologie-Symbol für „{name}“ gelöscht – es gilt wieder die automatische Erkennung.")
|
||||
return redirect(f"plugins:{APP}:symbol_list")
|
||||
@@ -0,0 +1,295 @@
|
||||
"""Topologie-Symbole im Stil von Cisco Packet Tracer (mit Pillow gezeichnet) und ihre Zuordnung zu Geräten."""
|
||||
import re
|
||||
from collections import OrderedDict
|
||||
from functools import lru_cache
|
||||
from io import BytesIO
|
||||
|
||||
# Schlüssel -> (Bezeichnung, Ebene im Diagramm; kleiner = weiter oben)
|
||||
KINDS = OrderedDict(
|
||||
[
|
||||
("cloud", ("Internet / Provider", 0)),
|
||||
("firewall", ("Firewall", 1)),
|
||||
("router", ("Router", 1)),
|
||||
("l3switch", ("Layer-3-Switch / Core", 2)),
|
||||
("switch", ("Switch", 3)),
|
||||
("wlc", ("WLAN-Controller", 3)),
|
||||
("ap", ("Access Point", 4)),
|
||||
("server", ("Server", 4)),
|
||||
("storage", ("Storage / NAS", 4)),
|
||||
("pc", ("PC / Client", 4)),
|
||||
("phone", ("IP-Telefon", 4)),
|
||||
("printer", ("Drucker", 4)),
|
||||
("camera", ("Kamera", 4)),
|
||||
("generic", ("Sonstiges Gerät", 4)),
|
||||
]
|
||||
)
|
||||
HIDDEN = "hidden"
|
||||
KIND_CHOICES = [(k, v[0]) for k, v in KINDS.items() if k != "cloud"] + [(HIDDEN, "In Topologie ausblenden")]
|
||||
|
||||
# Reihenfolge wichtig: erster Treffer gewinnt. None = passiv, nicht zeichnen.
|
||||
_RULES = [
|
||||
(r"patch|panel|pdu|steckdose|power|strom|kabel|cable|blank|blind|shelf|fachboden|ups|usv|rangier", None),
|
||||
(r"firewall|\bfw\b|fw-|sophos|fortigate|fortinet|palo ?alto|\butm\b|\bxgs\b|\bxg\b|watchguard|checkpoint|opnsense|pfsense", "firewall"),
|
||||
(r"router|\brtr\b|gateway|\bgw\b|lancom|draytek|fritz|isr\d|asr\d|mikrotik", "router"),
|
||||
(r"controller|\bwlc\b|mobility", "wlc"),
|
||||
(r"access ?point|accesspoint|\bap\b|\bap-|-ap\b|\bwap\b|wlan|wifi|aironet|unifi ?ap|\biap\b|instant on ap", "ap"),
|
||||
(r"\bcore\b|\bl3\b|layer ?3|distribution|\bdist\b|backbone", "l3switch"),
|
||||
(r"switch|\bsw\b|\bsw-|-sw\b|catalyst|aruba \d{4}|procurve|\bcbs\d|\bsg\d", "switch"),
|
||||
(r"storage|\bnas\b|\bsan\b|synology|qnap|netapp", "storage"),
|
||||
(r"server|\besx|hyper-?v|\bhost\b|proliant|poweredge|\bsrv\b|\bvm-?host", "server"),
|
||||
(r"telefon|phone|\bvoip\b|yealink|snom|gigaset", "phone"),
|
||||
(r"drucker|printer|\bmfp\b|kyocera|brother|ricoh", "printer"),
|
||||
(r"kamera|camera|\bcam\b|axis|hikvision|dahua", "camera"),
|
||||
(r"\bpc\b|client|workstation|laptop|notebook|thin ?client|desktop", "pc"),
|
||||
]
|
||||
|
||||
|
||||
def guess_kind(device):
|
||||
"""Symbol aus Rolle, Gerätetyp und Hersteller raten. None = passives Gerät (nicht zeichnen)."""
|
||||
dt = device.device_type
|
||||
role = device.role
|
||||
text = " ".join(
|
||||
str(x or "")
|
||||
for x in (
|
||||
getattr(role, "slug", ""), getattr(role, "name", ""),
|
||||
dt.model, dt.slug, getattr(dt.manufacturer, "name", ""),
|
||||
)
|
||||
).lower()
|
||||
for pattern, kind in _RULES:
|
||||
if re.search(pattern, text):
|
||||
return kind
|
||||
return "generic"
|
||||
|
||||
|
||||
# --- Zeichnen -----------------------------------------------------------------
|
||||
|
||||
SIZE = 128 # Kantenlänge eines Symbols in Pixeln
|
||||
BLUE = (29, 96, 158)
|
||||
BLUE_LIGHT = (86, 149, 206)
|
||||
BLUE_TOP = (122, 178, 226)
|
||||
WHITE = (255, 255, 255)
|
||||
OUTLINE = (20, 55, 95)
|
||||
|
||||
|
||||
def _arrow(d, start, end, color=WHITE, width=5, head=10):
|
||||
import math
|
||||
|
||||
d.line([start, end], fill=color, width=width)
|
||||
ang = math.atan2(end[1] - start[1], end[0] - start[0])
|
||||
left = (end[0] - head * math.cos(ang - 0.5), end[1] - head * math.sin(ang - 0.5))
|
||||
right = (end[0] - head * math.cos(ang + 0.5), end[1] - head * math.sin(ang + 0.5))
|
||||
d.polygon([end, left, right], fill=color)
|
||||
|
||||
|
||||
def _iso_box(d, x1, y1, x2, y2, depth, front, top, side=None):
|
||||
"""Quader in leichter Aufsicht: Front + Oberseite."""
|
||||
d.polygon([(x1, y1), (x1 + depth, y1 - depth), (x2 + depth, y1 - depth), (x2, y1)], fill=top, outline=OUTLINE)
|
||||
if side:
|
||||
d.polygon([(x2, y1), (x2 + depth, y1 - depth), (x2 + depth, y2 - depth), (x2, y2)], fill=side, outline=OUTLINE)
|
||||
d.rectangle([x1, y1, x2, y2], fill=front, outline=OUTLINE, width=2)
|
||||
|
||||
|
||||
def _cylinder(d, x1, y1, x2, y2, ry, body, top):
|
||||
d.rectangle([x1, y1 + ry, x2, y2 - ry], fill=body)
|
||||
d.ellipse([x1, y2 - 2 * ry, x2, y2], fill=body, outline=OUTLINE, width=2)
|
||||
d.rectangle([x1 + 1, y1 + ry, x2 - 1, y2 - ry], fill=body)
|
||||
d.line([(x1, y1 + ry), (x1, y2 - ry)], fill=OUTLINE, width=2)
|
||||
d.line([(x2, y1 + ry), (x2, y2 - ry)], fill=OUTLINE, width=2)
|
||||
d.ellipse([x1, y1, x2, y1 + 2 * ry], fill=top, outline=OUTLINE, width=2)
|
||||
|
||||
|
||||
def _draw_router(d):
|
||||
_cylinder(d, 14, 34, 114, 100, 18, BLUE, BLUE_TOP)
|
||||
# Packet-Tracer-typisch: zwei Pfeile nach außen, zwei nach innen (auf der Oberseite)
|
||||
cx, cy = 64, 52
|
||||
_arrow(d, (cx + 8, cy + 3), (cx + 40, cy + 12), width=4, head=9)
|
||||
_arrow(d, (cx - 8, cy - 3), (cx - 40, cy - 12), width=4, head=9)
|
||||
_arrow(d, (cx + 40, cy - 12), (cx + 10, cy - 3), width=4, head=9)
|
||||
_arrow(d, (cx - 40, cy + 12), (cx - 10, cy + 3), width=4, head=9)
|
||||
|
||||
|
||||
def _draw_switch(d, l3=False):
|
||||
_iso_box(d, 8, 64, 98, 98, 24, BLUE, BLUE_TOP, BLUE_LIGHT)
|
||||
# Doppelpfeile auf der Oberseite (Packet-Tracer-Switch)
|
||||
_arrow(d, (28, 47), (90, 47), width=5, head=11)
|
||||
_arrow(d, (92, 56), (30, 56), width=5, head=11)
|
||||
if l3:
|
||||
from PIL import ImageFont
|
||||
|
||||
try:
|
||||
font = ImageFont.load_default(size=20)
|
||||
except TypeError: # Pillow < 10.1
|
||||
font = ImageFont.load_default()
|
||||
d.text((53, 81), "L3", fill=WHITE, font=font, anchor="mm")
|
||||
else:
|
||||
for i in range(8): # Ports
|
||||
d.rectangle([16 + i * 10, 84, 22 + i * 10, 90], fill=BLUE_TOP)
|
||||
|
||||
|
||||
def _draw_firewall(d):
|
||||
red, mortar = (196, 64, 44), (240, 220, 200)
|
||||
_iso_box(d, 16, 40, 100, 104, 12, red, (226, 110, 88), (160, 50, 36))
|
||||
for row, y in enumerate(range(40, 104, 13)):
|
||||
d.line([(16, y), (100, y)], fill=mortar, width=2)
|
||||
off = 0 if row % 2 else 14
|
||||
for x in range(16 + off, 100, 28):
|
||||
d.line([(x, y), (x, min(y + 13, 104))], fill=mortar, width=2)
|
||||
d.rectangle([16, 40, 100, 104], outline=OUTLINE, width=2)
|
||||
|
||||
|
||||
def _draw_ap(d):
|
||||
d.rounded_rectangle([22, 72, 106, 96], radius=10, fill=BLUE, outline=OUTLINE, width=2)
|
||||
d.ellipse([56, 78, 72, 90], fill=BLUE_TOP)
|
||||
for r in (16, 28, 40):
|
||||
d.arc([64 - r, 62 - r, 64 + r, 62 + r], start=220, end=320, fill=BLUE, width=5)
|
||||
|
||||
|
||||
def _draw_wlc(d):
|
||||
_iso_box(d, 10, 66, 104, 96, 12, BLUE, BLUE_TOP, BLUE_LIGHT)
|
||||
for r in (12, 22, 32):
|
||||
d.arc([57 - r, 52 - r, 57 + r, 52 + r], start=215, end=325, fill=BLUE, width=5)
|
||||
|
||||
|
||||
def _draw_server(d):
|
||||
d.rectangle([38, 14, 90, 114], fill=(84, 98, 112), outline=OUTLINE, width=2)
|
||||
d.polygon([(38, 14), (48, 6), (100, 6), (90, 14)], fill=(140, 154, 168), outline=OUTLINE)
|
||||
d.polygon([(90, 14), (100, 6), (100, 106), (90, 114)], fill=(60, 72, 84), outline=OUTLINE)
|
||||
for y in range(24, 96, 14):
|
||||
d.rectangle([45, y, 83, y + 8], fill=(200, 208, 216))
|
||||
d.ellipse([76, y + 2, 80, y + 6], fill=(60, 200, 90))
|
||||
|
||||
|
||||
def _draw_storage(d):
|
||||
grey, top = (104, 116, 130), (176, 188, 200)
|
||||
for y in (70, 46, 22):
|
||||
_cylinder(d, 22, y, 106, y + 36, 10, grey, top)
|
||||
|
||||
|
||||
def _draw_pc(d):
|
||||
d.rounded_rectangle([18, 16, 110, 82], radius=6, fill=(50, 56, 64), outline=OUTLINE, width=2)
|
||||
d.rectangle([26, 24, 102, 74], fill=BLUE_LIGHT)
|
||||
d.polygon([(54, 82), (74, 82), (80, 100), (48, 100)], fill=(84, 92, 100))
|
||||
d.rounded_rectangle([34, 100, 94, 108], radius=3, fill=(84, 92, 100))
|
||||
|
||||
|
||||
def _draw_phone(d):
|
||||
d.rounded_rectangle([30, 52, 104, 106], radius=8, fill=(60, 66, 74), outline=OUTLINE, width=2)
|
||||
d.rectangle([62, 60, 96, 76], fill=BLUE_LIGHT)
|
||||
for i in range(3):
|
||||
for j in range(3):
|
||||
d.rectangle([64 + j * 11, 82 + i * 7, 70 + j * 11, 86 + i * 7], fill=(200, 200, 200))
|
||||
d.rounded_rectangle([18, 32, 50, 100], radius=10, fill=(40, 44, 50), outline=OUTLINE, width=2)
|
||||
|
||||
|
||||
def _draw_printer(d):
|
||||
d.rectangle([40, 18, 88, 50], fill=WHITE, outline=OUTLINE, width=2)
|
||||
_iso_box(d, 16, 50, 104, 94, 10, (96, 104, 114), (150, 158, 168))
|
||||
d.rectangle([40, 86, 88, 108], fill=WHITE, outline=OUTLINE, width=2)
|
||||
d.ellipse([90, 60, 98, 68], fill=(60, 200, 90))
|
||||
|
||||
|
||||
def _draw_camera(d):
|
||||
d.rounded_rectangle([20, 44, 92, 80], radius=10, fill=(230, 232, 236), outline=OUTLINE, width=2)
|
||||
d.ellipse([84, 48, 110, 76], fill=(40, 44, 50), outline=OUTLINE, width=2)
|
||||
d.ellipse([92, 56, 102, 68], fill=BLUE_LIGHT)
|
||||
d.rectangle([48, 80, 58, 104], fill=(150, 156, 164))
|
||||
d.rectangle([34, 104, 74, 110], fill=(150, 156, 164))
|
||||
|
||||
|
||||
def _draw_cloud(d):
|
||||
blobs = [(14, 52, 58, 96), (36, 30, 84, 78), (68, 38, 112, 86), (46, 60, 100, 104), (22, 64, 62, 104)]
|
||||
for x1, y1, x2, y2 in blobs:
|
||||
d.ellipse([x1 - 3, y1 - 3, x2 + 3, y2 + 3], fill=BLUE)
|
||||
for x1, y1, x2, y2 in blobs:
|
||||
d.ellipse([x1, y1, x2, y2], fill=(236, 244, 252))
|
||||
|
||||
|
||||
def _draw_generic(d):
|
||||
_iso_box(d, 16, 44, 104, 96, 12, (120, 132, 146), (176, 186, 198), (96, 106, 118))
|
||||
for i in range(4):
|
||||
d.rectangle([26 + i * 18, 76, 38 + i * 18, 86], fill=(200, 208, 216))
|
||||
|
||||
|
||||
_DRAWERS = {
|
||||
"router": _draw_router,
|
||||
"switch": _draw_switch,
|
||||
"l3switch": lambda d: _draw_switch(d, l3=True),
|
||||
"firewall": _draw_firewall,
|
||||
"ap": _draw_ap,
|
||||
"wlc": _draw_wlc,
|
||||
"server": _draw_server,
|
||||
"storage": _draw_storage,
|
||||
"pc": _draw_pc,
|
||||
"phone": _draw_phone,
|
||||
"printer": _draw_printer,
|
||||
"camera": _draw_camera,
|
||||
"cloud": _draw_cloud,
|
||||
"generic": _draw_generic,
|
||||
}
|
||||
|
||||
|
||||
@lru_cache(maxsize=None)
|
||||
def builtin_icon(kind):
|
||||
"""PNG-Bytes eines eingebauten Symbols (SIZE x SIZE, transparent)."""
|
||||
from PIL import Image, ImageDraw
|
||||
|
||||
img = Image.new("RGBA", (SIZE, SIZE), (0, 0, 0, 0))
|
||||
_DRAWERS.get(kind, _draw_generic)(ImageDraw.Draw(img))
|
||||
out = BytesIO()
|
||||
img.save(out, "PNG")
|
||||
return out.getvalue()
|
||||
|
||||
|
||||
def fit_icon(data):
|
||||
"""Beliebiges Bild (Bytes) auf SIZE x SIZE mit Seitenverhältnis einpassen, transparent auffüllen."""
|
||||
from PIL import Image
|
||||
|
||||
src = Image.open(BytesIO(data)).convert("RGBA")
|
||||
src.thumbnail((SIZE, SIZE), Image.LANCZOS)
|
||||
img = Image.new("RGBA", (SIZE, SIZE), (0, 0, 0, 0))
|
||||
img.paste(src, ((SIZE - src.width) // 2, (SIZE - src.height) // 2), src)
|
||||
out = BytesIO()
|
||||
img.save(out, "PNG")
|
||||
return out.getvalue()
|
||||
|
||||
|
||||
class SymbolResolver:
|
||||
"""Ermittelt Symbol (kind) und Icon für ein Gerät: Gerätetyp-Zuordnung > Rollen-Zuordnung > Heuristik."""
|
||||
|
||||
def __init__(self):
|
||||
from django.db import DatabaseError
|
||||
|
||||
from .models import TopologySymbol
|
||||
|
||||
self.by_type, self.by_role = {}, {}
|
||||
try:
|
||||
for s in TopologySymbol.objects.all():
|
||||
if s.device_type_id:
|
||||
self.by_type[s.device_type_id] = s
|
||||
elif s.role_id:
|
||||
self.by_role[s.role_id] = s
|
||||
except DatabaseError: # Migration noch nicht ausgeführt
|
||||
pass
|
||||
self._icons = {}
|
||||
|
||||
def resolve(self, device):
|
||||
"""(kind, icon_png_bytes) oder (None, None), wenn das Gerät nicht gezeichnet wird."""
|
||||
sym = self.by_type.get(device.device_type_id) or self.by_role.get(device.role_id)
|
||||
if sym:
|
||||
if sym.kind == HIDDEN:
|
||||
return None, None
|
||||
kind = sym.kind or guess_kind(device) or "generic"
|
||||
if sym.icon:
|
||||
return kind, self._custom_icon(sym)
|
||||
return kind, builtin_icon(kind)
|
||||
kind = guess_kind(device)
|
||||
return (kind, builtin_icon(kind)) if kind else (None, None)
|
||||
|
||||
def _custom_icon(self, sym):
|
||||
if sym.pk not in self._icons:
|
||||
try:
|
||||
with sym.icon.open("rb") as f:
|
||||
self._icons[sym.pk] = fit_icon(f.read())
|
||||
except (OSError, ValueError):
|
||||
self._icons[sym.pk] = builtin_icon(sym.kind or "generic")
|
||||
return self._icons[sym.pk]
|
||||
@@ -18,4 +18,27 @@ class CustomerExportButton(PluginTemplateExtension):
|
||||
)
|
||||
|
||||
|
||||
template_extensions = [CustomerExportButton]
|
||||
class TopologySymbolButton(PluginTemplateExtension):
|
||||
"""Auf Gerätetyp- und Geräterollen-Seiten: Topologie-Symbol festlegen."""
|
||||
|
||||
models = ["dcim.devicetype", "dcim.devicerole"]
|
||||
|
||||
def buttons(self):
|
||||
from .models import TopologySymbol
|
||||
|
||||
user = self.context["request"].user
|
||||
if not user.has_perm("netbox_customer_export.add_topologysymbol"):
|
||||
return ""
|
||||
obj = self.context["object"]
|
||||
field = "device_type" if obj._meta.model_name == "devicetype" else "role"
|
||||
existing = TopologySymbol.objects.filter(**{field: obj}).first()
|
||||
if existing:
|
||||
url = existing.get_absolute_url()
|
||||
else:
|
||||
url = f"{reverse('plugins:netbox_customer_export:symbol_add')}?{field}={obj.pk}"
|
||||
return format_html(
|
||||
'<a href="{}" class="btn btn-outline-primary"><i class="mdi mdi-shape-outline"></i> Topologie-Symbol</a>', url
|
||||
)
|
||||
|
||||
|
||||
template_extensions = [CustomerExportButton, TopologySymbolButton]
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover card-table align-middle">
|
||||
<thead><tr><th style="width:56px"></th><th>Zuordnung</th><th>Symbol</th><th></th></tr></thead>
|
||||
<tbody>
|
||||
{% for s, preview in rows %}
|
||||
<tr>
|
||||
<td>{% if preview %}<img src="{{ preview }}" class="ce-sym" alt="">{% else %}<i class="mdi mdi-eye-off-outline fs-2 text-muted"></i>{% endif %}</td>
|
||||
<td>
|
||||
{% if s.device_type %}<a href="{{ s.device_type.get_absolute_url }}">{{ s.device_type.manufacturer }} {{ s.device_type.model }}</a>
|
||||
{% else %}<a href="{{ s.role.get_absolute_url }}">{{ s.role }}</a>{% endif %}
|
||||
</td>
|
||||
<td>{% if s.icon %}<span class="badge text-bg-blue">Eigenes Icon</span>{% endif %} {{ s.get_kind_display }}</td>
|
||||
<td class="text-end text-nowrap">
|
||||
{% if perms.netbox_customer_export.change_topologysymbol %}
|
||||
<a href="{{ s.get_absolute_url }}" class="btn btn-sm btn-warning" title="Bearbeiten"><i class="mdi mdi-pencil"></i></a>
|
||||
{% endif %}
|
||||
{% if perms.netbox_customer_export.delete_topologysymbol %}
|
||||
<a href="{% url 'plugins:netbox_customer_export:symbol_delete' pk=s.pk %}" class="btn btn-sm btn-danger" title="Löschen"><i class="mdi mdi-trash-can-outline"></i></a>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% empty %}
|
||||
<tr><td colspan="4" class="text-center text-muted py-3">{{ empty }}</td></tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
@@ -35,6 +35,7 @@
|
||||
.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; }
|
||||
.images.wide img { max-width: 100%; max-height: 240mm; }
|
||||
figcaption { font-style: italic; color: #555; }
|
||||
.footer { position: running(footer); font-size: 7pt; color: #777; }
|
||||
@media screen { .footer { display: none; } }
|
||||
@@ -69,7 +70,7 @@
|
||||
{% 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>
|
||||
<div class="images{% if b.level == 1 %} wide{% endif %}">{% for img in b.images %}<figure><img src="{{ img.data_uri }}" alt="{{ img.caption }}">{% if img.caption %}<figcaption>{{ img.caption }}</figcaption>{% endif %}</figure>{% endfor %}</div>
|
||||
{% elif b.kind == 'pagebreak' %}<div class="pagebreak"></div>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
|
||||
@@ -24,7 +24,7 @@
|
||||
.ce-dim { opacity: .45; pointer-events: none; }
|
||||
.ce-card { transition: opacity .15s; }
|
||||
.ce-actions { position: sticky; bottom: 0; z-index: 10; background: var(--tblr-bg-surface); border-top: 1px solid var(--tblr-border-color); padding: .75rem 1rem; display: flex; justify-content: flex-end; gap: .5rem; }
|
||||
.ce-format .btn { flex: 1; }
|
||||
.ce-format .btn { flex: 1; padding-left: .25rem; padding-right: .25rem; }
|
||||
</style>
|
||||
|
||||
<form method="post" class="mt-3" id="ce-export-form">
|
||||
@@ -42,11 +42,12 @@
|
||||
{% for value, label in form.format.field.choices %}
|
||||
<input type="radio" class="btn-check" name="format" id="fmt-{{ value }}" value="{{ value }}" autocomplete="off"{% if form.format.value == value %} checked{% endif %}>
|
||||
<label class="btn btn-outline-primary" for="fmt-{{ value }}">
|
||||
{% if value == 'pdf' %}<i class="mdi mdi-file-pdf-box"></i>{% elif value == 'docx' %}<i class="mdi mdi-file-word-box"></i>{% else %}<i class="mdi mdi-web"></i>{% endif %}
|
||||
{% if value == 'pdf' %}<i class="mdi mdi-file-pdf-box"></i>{% elif value == 'docx' %}<i class="mdi mdi-file-word-box"></i>{% elif value == 'drawio' %}<i class="mdi mdi-sitemap"></i>{% else %}<i class="mdi mdi-web"></i>{% endif %}
|
||||
{{ label }}
|
||||
</label>
|
||||
{% endfor %}
|
||||
</div>
|
||||
<div class="form-text mt-n2 mb-3" id="ce-drawio-hint" hidden>draw.io exportiert nur die Topologie als bearbeitbares Diagramm (Optionen unten bei „Topologie“).</div>
|
||||
<div class="mb-3"><label class="form-label" for="{{ form.title.id_for_label }}">Titel</label>{{ form.title }}</div>
|
||||
<div class="mb-3"><label class="form-label" for="{{ form.subtitle.id_for_label }}">Untertitel</label>{{ form.subtitle }}</div>
|
||||
<div class="mb-3"><label class="form-label" for="{{ form.author.id_for_label }}">Erstellt von</label>{{ form.author }}<div class="form-text">Leer = dein Benutzername</div></div>
|
||||
@@ -119,6 +120,21 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card ce-card mt-3" data-ce-depends="topology">
|
||||
<h2 class="card-header d-flex justify-content-between align-items-center">
|
||||
<span><i class="mdi mdi-sitemap"></i> Topologie</span>
|
||||
{% if perms.netbox_customer_export.view_topologysymbol %}
|
||||
<a href="{% url 'plugins:netbox_customer_export:symbol_list' %}" class="btn btn-sm btn-outline-secondary" target="_blank"><i class="mdi mdi-shape-outline"></i> Symbole verwalten</a>
|
||||
{% endif %}
|
||||
</h2>
|
||||
<div class="card-body">
|
||||
<div class="ce-label mt-0">Beschriftung</div>
|
||||
{% include 'netbox_customer_export/_pills.html' with field=form.topo_details %}
|
||||
<div class="ce-label">Weiteres</div>
|
||||
<label class="ce-switch">{{ form.topo_external }} {{ form.topo_external.label }}</label>
|
||||
<label class="ce-switch">{{ form.topo_isolated }} {{ form.topo_isolated.label }}</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -140,10 +156,15 @@
|
||||
});
|
||||
// Detailkarten ausgrauen, wenn der zugehörige Abschnitt abgewählt ist
|
||||
function sync() {
|
||||
var fmt = document.querySelector('input[name=format]:checked');
|
||||
var drawio = fmt && fmt.value === 'drawio';
|
||||
document.querySelectorAll('[data-ce-depends]').forEach(function (card) {
|
||||
var cb = document.querySelector('input[name=sections][value="' + card.dataset.ceDepends + '"]');
|
||||
card.classList.toggle('ce-dim', !!cb && !cb.checked);
|
||||
// draw.io nutzt nur die Topologie-Optionen
|
||||
var active = drawio ? card.dataset.ceDepends === 'topology' : (!cb || cb.checked);
|
||||
card.classList.toggle('ce-dim', !active);
|
||||
});
|
||||
document.getElementById('ce-drawio-hint').hidden = !drawio;
|
||||
}
|
||||
document.addEventListener('change', sync);
|
||||
sync();
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
{% extends 'generic/_base.html' %}
|
||||
{% block title %}Symbol-Zuordnung löschen{% endblock %}
|
||||
{% block content %}
|
||||
<form method="post" class="mt-3">
|
||||
{% csrf_token %}
|
||||
<div class="card border-danger">
|
||||
<div class="card-body">
|
||||
<p>Soll die Symbol-Zuordnung für <strong>{{ object.target_label }} {{ object }}</strong> gelöscht werden?
|
||||
Danach gilt wieder die automatische Erkennung. Ein hochgeladenes Icon wird ebenfalls entfernt.</p>
|
||||
</div>
|
||||
<div class="card-footer text-end">
|
||||
<a href="{% url 'plugins:netbox_customer_export:symbol_list' %}" class="btn btn-outline-secondary">Abbrechen</a>
|
||||
<button class="btn btn-danger"><i class="mdi mdi-trash-can-outline"></i> Löschen</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,65 @@
|
||||
{% extends 'generic/_base.html' %}
|
||||
{% load form_helpers %}
|
||||
{% block title %}{% if object.pk %}Symbol für {{ object }}{% else %}Neue Symbol-Zuordnung{% endif %}{% endblock %}
|
||||
{% block content %}
|
||||
<style>
|
||||
.ce-kinds { display: grid; grid-template-columns: repeat(auto-fill, minmax(118px, 1fr)); gap: .5rem; }
|
||||
.ce-kind { display: flex; flex-direction: column; align-items: center; gap: .25rem; padding: .6rem .4rem; border: 2px solid var(--tblr-border-color); border-radius: .5rem; cursor: pointer; text-align: center; font-size: .8rem; margin: 0; background: #fff; color: #222; }
|
||||
.ce-kind:hover { border-color: var(--tblr-primary); }
|
||||
.ce-kind:has(input:checked) { border-color: var(--tblr-primary); box-shadow: 0 0 0 3px rgba(var(--tblr-primary-rgb), .2); }
|
||||
.ce-kind input { position: absolute; opacity: 0; pointer-events: none; }
|
||||
.ce-kind img, .ce-kind .ce-kind-icon { width: 56px; height: 56px; }
|
||||
.ce-kind .ce-kind-icon { display: flex; align-items: center; justify-content: center; font-size: 2rem; color: #888; }
|
||||
.ce-icon-preview { width: 72px; height: 72px; object-fit: contain; border: 1px solid var(--tblr-border-color); border-radius: .25rem; background: #fff; }
|
||||
</style>
|
||||
|
||||
<form method="post" enctype="multipart/form-data" class="mt-3">
|
||||
{% csrf_token %}
|
||||
{% if form.non_field_errors %}<div class="alert alert-danger">{% for e in form.non_field_errors %}{{ e }}<br>{% endfor %}</div>{% endif %}
|
||||
<div class="row g-3">
|
||||
<div class="col-lg-4">
|
||||
<div class="card h-100">
|
||||
<h2 class="card-header"><i class="mdi mdi-link-variant"></i> Gilt für</h2>
|
||||
<div class="card-body">
|
||||
<p class="text-muted small">Entweder einen Gerätetyp <em>oder</em> eine Geräterolle wählen. Gerätetyp hat Vorrang.</p>
|
||||
{% render_field form.device_type %}
|
||||
{% render_field form.role %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-lg-8">
|
||||
<div class="card">
|
||||
<h2 class="card-header"><i class="mdi mdi-shape-outline"></i> Symbol</h2>
|
||||
<div class="card-body">
|
||||
<div class="ce-kinds mb-3">
|
||||
{% for radio in form.kind %}
|
||||
<label class="ce-kind" for="{{ radio.id_for_label }}">
|
||||
{{ radio.tag }}
|
||||
{% if radio.data.value == '' %}<span class="ce-kind-icon"><i class="mdi mdi-auto-fix"></i></span>
|
||||
{% elif radio.data.value == 'hidden' %}<span class="ce-kind-icon"><i class="mdi mdi-eye-off-outline"></i></span>
|
||||
{% else %}{% for key, label, url in builtin %}{% if key == radio.data.value %}<img src="{{ url }}" alt="">{% endif %}{% endfor %}{% endif %}
|
||||
{{ radio.choice_label }}
|
||||
</label>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% for e in form.kind.errors %}<div class="text-danger small">{{ e }}</div>{% endfor %}
|
||||
|
||||
<label class="form-label" for="{{ form.icon.id_for_label }}">Eigenes Icon (optional)</label>
|
||||
<div class="d-flex gap-3 align-items-start">
|
||||
{% if object.icon %}<img src="{{ object.icon.url }}" class="ce-icon-preview" alt="Aktuelles Icon">{% endif %}
|
||||
<div class="flex-grow-1">
|
||||
{{ form.icon }}
|
||||
<div class="form-text">{{ form.icon.help_text }} Ist kein Symbol gewählt, bestimmt die Automatik die Ebene im Diagramm.</div>
|
||||
{% for e in form.icon.errors %}<div class="text-danger small">{{ e }}</div>{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="d-flex justify-content-end gap-2 mt-3">
|
||||
<a href="{% url 'plugins:netbox_customer_export:symbol_list' %}" class="btn btn-outline-secondary">Abbrechen</a>
|
||||
<button class="btn btn-primary"><i class="mdi mdi-content-save"></i> Speichern</button>
|
||||
</div>
|
||||
</form>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,54 @@
|
||||
{% extends 'generic/_base.html' %}
|
||||
{% block title %}Topologie-Symbole{% endblock %}
|
||||
{% block controls %}
|
||||
{% if perms.netbox_customer_export.add_topologysymbol %}
|
||||
<div class="btn-list">
|
||||
<a href="{% url 'plugins:netbox_customer_export:symbol_add' %}" class="btn btn-primary"><i class="mdi mdi-plus-thick"></i> Zuordnung hinzufügen</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
{% block content %}
|
||||
<style>
|
||||
.ce-gallery { display: grid; grid-template-columns: repeat(auto-fill, minmax(110px, 1fr)); gap: .5rem; }
|
||||
.ce-gallery figure { margin: 0; text-align: center; padding: .5rem; border: 1px solid var(--tblr-border-color); border-radius: .5rem; background: #fff; }
|
||||
.ce-gallery img { width: 64px; height: 64px; }
|
||||
.ce-gallery figcaption { font-size: .75rem; color: #444; margin-top: .25rem; }
|
||||
.ce-sym { width: 40px; height: 40px; object-fit: contain; background: #fff; border-radius: .25rem; }
|
||||
</style>
|
||||
|
||||
<div class="row g-3 mt-1">
|
||||
<div class="col-xl-7">
|
||||
<div class="card mb-3">
|
||||
<h2 class="card-header"><i class="mdi mdi-shape-outline"></i> Zuordnungen je Gerätetyp</h2>
|
||||
{% include 'netbox_customer_export/_symbol_table.html' with rows=types empty="Noch keine Gerätetypen zugeordnet." %}
|
||||
</div>
|
||||
<div class="card">
|
||||
<h2 class="card-header"><i class="mdi mdi-tag-outline"></i> Zuordnungen je Geräterolle</h2>
|
||||
{% include 'netbox_customer_export/_symbol_table.html' with rows=roles empty="Noch keine Geräterollen zugeordnet." %}
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-xl-5">
|
||||
<div class="card mb-3">
|
||||
<h2 class="card-header"><i class="mdi mdi-information-outline"></i> So wird das Symbol bestimmt</h2>
|
||||
<div class="card-body">
|
||||
<ol class="mb-0">
|
||||
<li><strong>Gerätetyp</strong>-Zuordnung (eigenes Icon oder Symbol)</li>
|
||||
<li><strong>Geräterolle</strong>-Zuordnung</li>
|
||||
<li><strong>Automatisch</strong> anhand von Rolle, Modell und Hersteller, z.B. „Switch“, „Firewall“, „Sophos“, „Access Point“.
|
||||
Patchpanels, Steckdosenleisten, PDUs und USVs werden automatisch ausgeblendet.</li>
|
||||
</ol>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<h2 class="card-header"><i class="mdi mdi-view-grid-outline"></i> Eingebaute Symbole</h2>
|
||||
<div class="card-body">
|
||||
<div class="ce-gallery">
|
||||
{% for key, label, url in builtin %}
|
||||
<figure><img src="{{ url }}" alt="{{ label }}"><figcaption>{{ label }}</figcaption></figure>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,355 @@
|
||||
"""Netzwerk-Topologie: Graph aus den Kabelverbindungen, Ebenen-Layout, PNG- und draw.io-Ausgabe."""
|
||||
import base64
|
||||
from collections import OrderedDict, defaultdict, deque
|
||||
from dataclasses import dataclass, field
|
||||
from io import BytesIO
|
||||
from xml.sax.saxutils import escape, quoteattr
|
||||
|
||||
from .symbols import KINDS, SIZE, SymbolResolver, builtin_icon
|
||||
|
||||
MAX_PER_ROW = 8
|
||||
|
||||
|
||||
@dataclass
|
||||
class Node:
|
||||
id: str
|
||||
label: str
|
||||
kind: str
|
||||
icon: bytes
|
||||
sub: list = field(default_factory=list)
|
||||
external: bool = False # Gerät eines anderen Standorts / Provider
|
||||
layer: int = 0
|
||||
x: float = 0
|
||||
y: float = 0
|
||||
|
||||
|
||||
@dataclass
|
||||
class Edge:
|
||||
a: str
|
||||
b: str
|
||||
labels: list = field(default_factory=list)
|
||||
|
||||
|
||||
@dataclass
|
||||
class Graph:
|
||||
title: str
|
||||
nodes: "OrderedDict[str, Node]"
|
||||
edges: list
|
||||
|
||||
|
||||
# --- Graph aufbauen -----------------------------------------------------------
|
||||
|
||||
def _device_sub(device, details):
|
||||
sub = []
|
||||
if "model" in details:
|
||||
sub.append(str(device.device_type.model))
|
||||
if "primary_ip" in details and device.primary_ip:
|
||||
sub.append(str(device.primary_ip.address.ip))
|
||||
return sub
|
||||
|
||||
|
||||
def build_graphs(scope, opts):
|
||||
"""Ein Graph je Standort. Geräte anderer Standorte und Provider-Leitungen erscheinen als externe Knoten."""
|
||||
from dcim.models import Interface
|
||||
|
||||
details = set(opts.get("topo_details") or [])
|
||||
resolver = SymbolResolver()
|
||||
devices = list(
|
||||
scope.devices().select_related(
|
||||
"role", "device_type__manufacturer", "site", "primary_ip4", "primary_ip6"
|
||||
).order_by("site__name", "name")
|
||||
)
|
||||
drawn = {} # device.pk -> (kind, icon)
|
||||
for d in devices:
|
||||
kind, icon = resolver.resolve(d)
|
||||
if kind:
|
||||
drawn[d.pk] = (kind, icon)
|
||||
by_pk = {d.pk: d for d in devices}
|
||||
|
||||
# Verbindungen: vollständige Kabelpfade (auch über Patchpanels) zwischen Interfaces
|
||||
links = defaultdict(list) # (a, b) -> ["Gi1/0/1 – eth0", …]
|
||||
external = {} # node_id -> Node-Vorlage
|
||||
ifaces = scope.restrict(
|
||||
Interface.objects.filter(device_id__in=list(drawn), cable__isnull=False)
|
||||
).select_related("device")
|
||||
for iface in ifaces:
|
||||
try:
|
||||
endpoints = iface.connected_endpoints or []
|
||||
except Exception: # noqa: BLE001 – defekte Pfade überspringen
|
||||
continue
|
||||
for ep in endpoints:
|
||||
model = ep._meta.model_name
|
||||
a = f"d{iface.device_id}"
|
||||
if model == "interface":
|
||||
peer = ep.device
|
||||
if peer.pk == iface.device_id:
|
||||
continue
|
||||
if peer.pk not in by_pk: # Gerät außerhalb des Scopes
|
||||
if not opts.get("topo_external"):
|
||||
continue
|
||||
kind, icon = resolver.resolve(peer)
|
||||
if not kind:
|
||||
continue
|
||||
external[f"d{peer.pk}"] = Node(f"d{peer.pk}", peer.name or str(peer), kind, icon, [str(peer.site)], external=True)
|
||||
elif peer.pk not in drawn:
|
||||
continue
|
||||
b, label = f"d{peer.pk}", f"{iface.name} – {ep.name}"
|
||||
elif model in ("circuittermination", "providernetwork"):
|
||||
if not opts.get("topo_external"):
|
||||
continue
|
||||
circuit = getattr(ep, "circuit", None)
|
||||
b = f"c{circuit.pk}" if circuit else f"p{ep.pk}"
|
||||
title = str(circuit.provider) if circuit else str(ep)
|
||||
sub = [str(circuit.cid)] if circuit else []
|
||||
external[b] = Node(b, title, "cloud", builtin_icon("cloud"), sub, external=True)
|
||||
label = iface.name
|
||||
else:
|
||||
continue
|
||||
key = tuple(sorted((a, b)))
|
||||
# Beschriftung immer aus Sicht von key[0]; jedes Kabel wird von beiden Enden gefunden
|
||||
if key[0] != a:
|
||||
label = " – ".join(reversed(label.split(" – ")))
|
||||
if label not in links[key]:
|
||||
links[key].append(label)
|
||||
|
||||
# Knoten je Standort
|
||||
graphs = []
|
||||
sites = OrderedDict()
|
||||
for pk in drawn:
|
||||
sites.setdefault(by_pk[pk].site, []).append(pk)
|
||||
for site, pks in sites.items():
|
||||
nodes = OrderedDict()
|
||||
for pk in pks:
|
||||
d = by_pk[pk]
|
||||
kind, icon = drawn[pk]
|
||||
nodes[f"d{pk}"] = Node(f"d{pk}", d.name or str(d), kind, icon, _device_sub(d, details))
|
||||
edges = []
|
||||
for (a, b), labels in links.items():
|
||||
in_a, in_b = a in nodes, b in nodes
|
||||
if not (in_a or in_b):
|
||||
continue
|
||||
for nid, inside in ((a, in_a), (b, in_b)):
|
||||
if not inside:
|
||||
other = external.get(nid)
|
||||
if other is None: # Gerät eines anderen Standorts im selben Scope
|
||||
d = by_pk[int(nid[1:])]
|
||||
kind, icon = drawn[d.pk]
|
||||
other = Node(nid, d.name or str(d), kind, icon, [str(d.site)], external=True)
|
||||
nodes[nid] = Node(**{**other.__dict__})
|
||||
edges.append(Edge(a, b, labels))
|
||||
if not opts.get("topo_isolated"):
|
||||
linked = {n for e in edges for n in (e.a, e.b)}
|
||||
nodes = OrderedDict((k, v) for k, v in nodes.items() if k in linked)
|
||||
if nodes:
|
||||
_layout(nodes, edges)
|
||||
graphs.append(Graph(f"Standort {site}", nodes, edges))
|
||||
return graphs
|
||||
|
||||
|
||||
# --- Layout -------------------------------------------------------------------
|
||||
|
||||
def _layout(nodes, edges):
|
||||
"""Ebenen per Breitensuche ab den „höchsten“ Geräten (Provider, Firewall, Router …), dann Reihenfolge nach Nachbarn."""
|
||||
adj = defaultdict(set)
|
||||
for e in edges:
|
||||
adj[e.a].add(e.b)
|
||||
adj[e.b].add(e.a)
|
||||
tier = {nid: KINDS.get(n.kind, ("", 4))[1] for nid, n in nodes.items()}
|
||||
|
||||
layer = {n: None for n in nodes if not adj[n]} # isolierte Knoten: unterste Zeile
|
||||
remaining = set(nodes) - set(layer)
|
||||
while remaining:
|
||||
# jede Zusammenhangskomponente ab ihren am höchsten eingestuften Knoten
|
||||
top = min(tier[n] for n in remaining)
|
||||
roots = [n for n in nodes if n in remaining and tier[n] == top]
|
||||
queue = deque()
|
||||
for r in roots:
|
||||
layer[r] = tier[r] if tier[r] <= 1 else 0
|
||||
queue.append(r)
|
||||
while queue:
|
||||
cur = queue.popleft()
|
||||
remaining.discard(cur)
|
||||
for nb in adj[cur]:
|
||||
if nb not in layer:
|
||||
layer[nb] = layer[cur] + 1 # Ebene folgt der Verkabelung
|
||||
queue.append(nb)
|
||||
|
||||
used = sorted({v for v in layer.values() if v is not None})
|
||||
rank = {v: i for i, v in enumerate(used)}
|
||||
bottom = len(used)
|
||||
rows = defaultdict(list)
|
||||
for nid in nodes:
|
||||
rows[rank[layer[nid]] if layer[nid] is not None else bottom].append(nid)
|
||||
|
||||
# Reihenfolge: Schwerpunkt der Nachbarn in der Zeile darüber (reduziert Kreuzungen)
|
||||
pos = {}
|
||||
for r in sorted(rows):
|
||||
row = rows[r]
|
||||
if r > 0:
|
||||
def bary(n):
|
||||
ps = [pos[p] for p in adj[n] if p in pos]
|
||||
return sum(ps) / len(ps) if ps else 1e9
|
||||
row.sort(key=bary)
|
||||
for i, n in enumerate(row):
|
||||
pos[n] = i
|
||||
|
||||
# Zeilen mit zu vielen Knoten umbrechen, dann Koordinaten
|
||||
CELL_W, CELL_H = 200, 190
|
||||
lines = []
|
||||
for r in sorted(rows):
|
||||
row = rows[r]
|
||||
for i in range(0, len(row), MAX_PER_ROW):
|
||||
lines.append(row[i : i + MAX_PER_ROW])
|
||||
width = max(len(line) for line in lines) * CELL_W
|
||||
for li, line in enumerate(lines):
|
||||
offset = (width - len(line) * CELL_W) / 2
|
||||
for i, nid in enumerate(line):
|
||||
nodes[nid].x = offset + i * CELL_W + CELL_W / 2
|
||||
nodes[nid].y = li * CELL_H + 70
|
||||
|
||||
|
||||
def _oriented(graph, e):
|
||||
"""Port-Beschriftungen aus Sicht des oberen Knotens („Uplink-Port – Downlink-Port“)."""
|
||||
a, b = graph.nodes[e.a], graph.nodes[e.b]
|
||||
if a.y <= b.y:
|
||||
return e.labels
|
||||
return [" – ".join(reversed(x.split(" – "))) for x in e.labels]
|
||||
|
||||
|
||||
# --- PNG ----------------------------------------------------------------------
|
||||
|
||||
def _font(size, bold=False):
|
||||
from PIL import ImageFont
|
||||
|
||||
names = ("DejaVuSans-Bold.ttf", "Arial Bold.ttf", "arialbd.ttf") if bold else ("DejaVuSans.ttf", "Arial.ttf", "arial.ttf")
|
||||
for name in names:
|
||||
try:
|
||||
return ImageFont.truetype(name, size)
|
||||
except OSError:
|
||||
continue
|
||||
try:
|
||||
return ImageFont.load_default(size=size)
|
||||
except TypeError:
|
||||
return ImageFont.load_default()
|
||||
|
||||
|
||||
def render_png(graph, show_ports=False, scale=2):
|
||||
from PIL import Image, ImageDraw
|
||||
|
||||
icon = 64
|
||||
nodes = graph.nodes.values()
|
||||
w = int((max(n.x for n in nodes) + 100) * scale)
|
||||
h = int((max(n.y for n in nodes) + 110) * scale)
|
||||
img = Image.new("RGB", (w, h), (255, 255, 255))
|
||||
d = ImageDraw.Draw(img)
|
||||
f_name, f_sub, f_port = _font(12 * scale, bold=True), _font(10 * scale), _font(8 * scale)
|
||||
|
||||
def c(n):
|
||||
return n.x * scale, n.y * scale
|
||||
|
||||
def anchors(a, b):
|
||||
"""Linie unterhalb der Beschriftung des oberen Knotens beginnen, oben am unteren Symbol enden."""
|
||||
if a.y > b.y:
|
||||
a, b = b, a
|
||||
if a.y == b.y:
|
||||
return c(a), c(b)
|
||||
label_h = 22 + 13 * len(a.sub)
|
||||
return (a.x * scale, (a.y + icon / 2 + label_h) * scale), (b.x * scale, (b.y - icon / 2 - 2) * scale)
|
||||
|
||||
for e in graph.edges:
|
||||
a, b = graph.nodes[e.a], graph.nodes[e.b]
|
||||
dashed = a.external or b.external
|
||||
color = (150, 150, 150) if dashed else (60, 60, 60)
|
||||
(x1, y1), (x2, y2) = anchors(a, b)
|
||||
if dashed:
|
||||
steps = int(max(abs(x2 - x1), abs(y2 - y1)) / (8 * scale)) or 1
|
||||
for i in range(0, steps, 2):
|
||||
t1, t2 = i / steps, min((i + 1) / steps, 1)
|
||||
d.line([(x1 + (x2 - x1) * t1, y1 + (y2 - y1) * t1), (x1 + (x2 - x1) * t2, y1 + (y2 - y1) * t2)], fill=color, width=2 * scale)
|
||||
else:
|
||||
d.line([(x1, y1), (x2, y2)], fill=color, width=2 * scale)
|
||||
mx, my = (x1 + x2) / 2, (y1 + y2) / 2
|
||||
text = None
|
||||
if show_ports:
|
||||
labels = _oriented(graph, e)
|
||||
text = labels[0] + (f" (+{len(labels) - 1})" if len(labels) > 1 else "")
|
||||
elif len(e.labels) > 1:
|
||||
text = f"{len(e.labels)}×"
|
||||
if text:
|
||||
tw = d.textlength(text, font=f_port)
|
||||
d.rounded_rectangle([mx - tw / 2 - 4 * scale, my - 7 * scale, mx + tw / 2 + 4 * scale, my + 7 * scale], radius=3 * scale, fill=(255, 255, 255), outline=(200, 200, 200))
|
||||
d.text((mx, my), text, fill=(40, 40, 40), font=f_port, anchor="mm")
|
||||
|
||||
for n in nodes:
|
||||
x, y = c(n)
|
||||
pic = Image.open(BytesIO(n.icon)).convert("RGBA").resize((icon * scale, icon * scale), Image.LANCZOS)
|
||||
if n.external:
|
||||
alpha = pic.getchannel("A").point(lambda v: int(v * 0.55))
|
||||
pic.putalpha(alpha)
|
||||
img.paste(pic, (int(x - icon * scale / 2), int(y - icon * scale / 2)), pic)
|
||||
ty = y + icon * scale / 2 + 4 * scale
|
||||
d.text((x, ty), _clip(d, n.label, f_name, 190 * scale), fill=(110, 110, 110) if n.external else (20, 20, 20), font=f_name, anchor="ma")
|
||||
for i, line in enumerate(n.sub):
|
||||
d.text((x, ty + (16 + i * 13) * scale), _clip(d, line, f_sub, 190 * scale), fill=(100, 100, 100), font=f_sub, anchor="ma")
|
||||
|
||||
out = BytesIO()
|
||||
img.save(out, "PNG", optimize=True)
|
||||
return out.getvalue()
|
||||
|
||||
|
||||
def _clip(d, text, font, width):
|
||||
if d.textlength(text, font=font) <= width:
|
||||
return text
|
||||
while text and d.textlength(text + "…", font=font) > width:
|
||||
text = text[:-1]
|
||||
return text + "…"
|
||||
|
||||
|
||||
# --- draw.io ------------------------------------------------------------------
|
||||
|
||||
def _small_icon(png, px=64):
|
||||
from PIL import Image
|
||||
|
||||
img = Image.open(BytesIO(png)).convert("RGBA").resize((px, px), Image.LANCZOS)
|
||||
out = BytesIO()
|
||||
img.save(out, "PNG", optimize=True)
|
||||
return base64.b64encode(out.getvalue()).decode("ascii")
|
||||
|
||||
|
||||
def render_drawio(graphs, show_ports=False):
|
||||
"""draw.io-Datei (mxfile) mit einer Seite je Standort. Symbole als eingebettete Bilder."""
|
||||
icon_cache = {}
|
||||
pages = []
|
||||
for gi, g in enumerate(graphs):
|
||||
cells = ['<mxCell id="0"/>', '<mxCell id="1" parent="0"/>']
|
||||
for n in g.nodes.values():
|
||||
key = id(n.icon)
|
||||
if key not in icon_cache:
|
||||
icon_cache[key] = _small_icon(n.icon)
|
||||
label = escape(n.label) + "".join(f"<br><font style=\"font-size:10px\" color=\"#666666\">{escape(s)}</font>" for s in n.sub)
|
||||
style = (
|
||||
"shape=image;html=1;verticalLabelPosition=bottom;verticalAlign=top;labelBackgroundColor=none;"
|
||||
f"imageAspect=0;aspect=fixed;fontStyle=1;{'opacity=55;' if n.external else ''}"
|
||||
f"image=data:image/png,{icon_cache[key]};"
|
||||
)
|
||||
cells.append(
|
||||
f'<mxCell id="{n.id}" value={quoteattr(label)} style={quoteattr(style)} vertex="1" parent="1">'
|
||||
f'<mxGeometry x="{n.x - 32:.0f}" y="{n.y - 32:.0f}" width="64" height="64" as="geometry"/></mxCell>'
|
||||
)
|
||||
for i, e in enumerate(g.edges):
|
||||
dashed = g.nodes[e.a].external or g.nodes[e.b].external
|
||||
label = "<br>".join(escape(x) for x in _oriented(g, e)) if show_ports else (f"{len(e.labels)}×" if len(e.labels) > 1 else "")
|
||||
style = f"endArrow=none;html=1;strokeWidth=2;strokeColor={'#999999' if dashed else '#333333'};{'dashed=1;' if dashed else ''}fontSize=9;labelBackgroundColor=#ffffff;"
|
||||
cells.append(
|
||||
f'<mxCell id="e{i}" value={quoteattr(label)} style={quoteattr(style)} edge="1" parent="1" source="{e.a}" target="{e.b}">'
|
||||
'<mxGeometry relative="1" as="geometry"/></mxCell>'
|
||||
)
|
||||
pages.append(
|
||||
f'<diagram id="page{gi}" name={quoteattr(g.title)}><mxGraphModel dx="1200" dy="800" grid="1" gridSize="10" '
|
||||
f'guides="1" tooltips="1" connect="1" arrows="1" fold="1" page="0" pageScale="1" math="0" shadow="0">'
|
||||
f'<root>{"".join(cells)}</root></mxGraphModel></diagram>'
|
||||
)
|
||||
return f'<mxfile host="netbox-customer-export">{"".join(pages)}</mxfile>'.encode("utf-8")
|
||||
|
||||
|
||||
__all__ = ["build_graphs", "render_png", "render_drawio", "SIZE"]
|
||||
@@ -1,6 +1,6 @@
|
||||
from django.urls import path
|
||||
|
||||
from . import branding_views, views
|
||||
from . import branding_views, symbol_views, views
|
||||
|
||||
urlpatterns = [
|
||||
path("", views.ScopeSelectView.as_view(), name="select"),
|
||||
@@ -10,4 +10,9 @@ urlpatterns = [
|
||||
path("branding/<int:pk>/", branding_views.BrandingEditView.as_view(), name="branding_edit"),
|
||||
path("branding/<int:pk>/delete/", branding_views.BrandingDeleteView.as_view(), name="branding_delete"),
|
||||
path("branding/<int:pk>/preview/", branding_views.BrandingPreviewView.as_view(), name="branding_preview"),
|
||||
path("symbols/", symbol_views.SymbolListView.as_view(), name="symbol_list"),
|
||||
path("symbols/add/", symbol_views.SymbolEditView.as_view(), name="symbol_add"),
|
||||
path("symbols/<int:pk>/", symbol_views.SymbolEditView.as_view(), name="symbol_edit"),
|
||||
path("symbols/<int:pk>/delete/", symbol_views.SymbolDeleteView.as_view(), name="symbol_delete"),
|
||||
path("symbols/builtin/<str:kind>.png", symbol_views.BuiltinIconView.as_view(), name="symbol_builtin"),
|
||||
]
|
||||
|
||||
@@ -28,6 +28,20 @@ class ScopeSelectView(LoginRequiredMixin, View):
|
||||
|
||||
|
||||
class ExportView(LoginRequiredMixin, View):
|
||||
def _drawio(self, request, scope, form, opts):
|
||||
"""Nur die Topologie – als editierbare draw.io-Datei (eine Seite je Standort)."""
|
||||
from .topology import build_graphs, render_drawio
|
||||
|
||||
graphs = build_graphs(scope, opts)
|
||||
if not graphs:
|
||||
messages.warning(request, "Keine verkabelten Geräte gefunden – es gibt keine Topologie zu exportieren.")
|
||||
return self._render_form(request, scope, form)
|
||||
content = render_drawio(graphs, show_ports="ports" in (opts.get("topo_details") or []))
|
||||
response = HttpResponse(content, content_type="application/xml")
|
||||
filename = f"topologie_{slugify(str(scope.obj)) or 'export'}_{timezone.localdate():%Y%m%d}.drawio"
|
||||
response["Content-Disposition"] = f'attachment; filename="{filename}"'
|
||||
return response
|
||||
|
||||
template_name = "netbox_customer_export/export.html"
|
||||
|
||||
def _scope(self, request, kind, pk):
|
||||
@@ -54,6 +68,8 @@ class ExportView(LoginRequiredMixin, View):
|
||||
return self._render_form(request, scope, form)
|
||||
|
||||
opts = form.cleaned_data
|
||||
if opts["format"] == "drawio":
|
||||
return self._drawio(request, scope, form, opts)
|
||||
blocks = []
|
||||
for key in opts["sections"]:
|
||||
blocks += SECTIONS[key].func(scope, opts)
|
||||
|
||||
+1
-1
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "netbox-customer-export"
|
||||
version = "0.3.1"
|
||||
version = "0.4.0"
|
||||
description = "NetBox-Plugin: Kundendokumentation (PDF/Word) für Standorte, Lokationen, Mandanten und Mandantengruppen"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.10"
|
||||
|
||||
Reference in New Issue
Block a user