feat: manage branding profiles in the NetBox UI

New BrandingProfile model (migration 0001) with logo upload, color
pickers, font, Word template upload, table style and extra CSS, plus
list/edit/delete views, an HTML preview and a menu entry with add
button. Access is controlled via the model's Django permissions.

Profiles from the database are offered first in the export form (the
default profile preselected); profiles from PLUGINS_CONFIG still work.

Bump version to 0.2.0 - requires "manage.py migrate".

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-09-23 13:21:11 +02:00
co-authored by Claude Opus 5.5
parent 0c6bebe2e3
commit f24e42a516
15 changed files with 491 additions and 12 deletions
+93
View File
@@ -0,0 +1,93 @@
"""Verwaltung der Branding-Profile in der NetBox-Oberfläche."""
from django.contrib import messages
from django.contrib.auth.mixins import PermissionRequiredMixin
from django.http import HttpResponse
from django.shortcuts import get_object_or_404, redirect, render
from django.utils import timezone
from django.views import View
from .blocks import KV, P, T, Document, H
from .branding import _normalize
from .forms import BrandingProfileForm
from .models import BrandingProfile
from .renderers.html import render_html
APP = "netbox_customer_export"
class BrandingListView(PermissionRequiredMixin, View):
permission_required = f"{APP}.view_brandingprofile"
def get(self, request):
return render(request, f"{APP}/branding_list.html", {"profiles": BrandingProfile.objects.all()})
class BrandingEditView(PermissionRequiredMixin, View):
template_name = f"{APP}/branding_edit.html"
def get_permission_required(self):
return [f"{APP}.{'change' if self.kwargs.get('pk') else 'add'}_brandingprofile"]
def _obj(self, pk):
return get_object_or_404(BrandingProfile, pk=pk) if pk else BrandingProfile()
def get(self, request, pk=None):
obj = self._obj(pk)
return render(request, self.template_name, {"object": obj, "form": BrandingProfileForm(instance=obj)})
def post(self, request, pk=None):
obj = self._obj(pk)
form = BrandingProfileForm(request.POST, request.FILES, instance=obj)
if form.is_valid():
obj = form.save()
messages.success(request, f"Branding-Profil „{obj}“ gespeichert.")
return redirect(f"plugins:{APP}:branding_list")
return render(request, self.template_name, {"object": obj, "form": form})
class BrandingDeleteView(PermissionRequiredMixin, View):
permission_required = f"{APP}.delete_brandingprofile"
def get(self, request, pk):
return render(request, f"{APP}/branding_delete.html", {"object": get_object_or_404(BrandingProfile, pk=pk)})
def post(self, request, pk):
obj = get_object_or_404(BrandingProfile, pk=pk)
name = str(obj)
for f in (obj.logo, obj.docx_template):
if f:
f.delete(save=False)
obj.delete()
messages.success(request, f"Branding-Profil „{name}“ gelöscht.")
return redirect(f"plugins:{APP}:branding_list")
class BrandingPreviewView(PermissionRequiredMixin, View):
"""Beispieldokument (HTML) mit dem Profil – zum Prüfen von Farben und Logo."""
permission_required = f"{APP}.view_brandingprofile"
def get(self, request, pk):
obj = get_object_or_404(BrandingProfile, pk=pk)
blocks = [
H(1, "Übersicht"),
KV([("Standort", "Musterstandort"), ("Status", "Aktiv"), ("Anschrift", "Musterstraße 1\n12345 Musterstadt")]),
H(2, "Geräte"),
T(
["Name", "Hersteller / Modell", "Rack / HE", "Management-IP"],
[
["SW-CORE-01", "HPE Aruba 6100", "Rack A / HE 20", "10.0.0.2/24"],
["SW-ACC-01", "HPE Aruba 6000", "Rack A / HE 18", "10.0.0.3/24"],
["FW-01", "Sophos XGS 2100", "Rack A / HE 16", "10.0.0.1/24"],
],
),
P("So sehen Überschriften, Tabellen und Fließtext mit diesem Profil aus."),
]
doc = Document(
title="Netzwerkdokumentation Vorschau",
subtitle="Beispielkunde",
blocks=blocks,
meta={"author": request.user.username, "date": timezone.localtime().strftime("%d.%m.%Y")},
branding=_normalize(obj.as_branding()),
)
return HttpResponse(render_html(doc), content_type="text/html; charset=utf-8")