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:
2026-09-23 14:12:55 +02:00
co-authored by Claude Opus 5.5
parent 219a429f3f
commit e477aa9b49
22 changed files with 1147 additions and 17 deletions
+96
View File
@@ -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")