NetBox Customer Export plugin with configurable branding

Plugin for NetBox 4.6.8–4.6.99 that exports customer documentation
(PDF, Word, HTML preview) for a site, location, tenant or tenant group.
Technicians pick sections, device roles and columns before exporting.

Branding profiles (colors, font, logo, footer, Word template incl. .dotx,
table style, extra CSS) are configurable via PLUGINS_CONFIG and selectable
per export.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-09-23 12:43:19 +02:00
co-authored by Claude Opus 5.5
commit 8f45bffa99
21 changed files with 1444 additions and 0 deletions
+81
View File
@@ -0,0 +1,81 @@
from django.contrib import messages
from django.contrib.auth.mixins import LoginRequiredMixin
from django.http import Http404, HttpResponse
from django.shortcuts import get_object_or_404, redirect, render
from django.utils import timezone
from django.utils.text import slugify
from django.views import View
from .blocks import Document
from .branding import get_branding
from .forms import ExportForm, ScopeSelectForm
from .renderers import MissingDependency, render as render_document
from .scope import SCOPE_MODELS, Scope
from .sections import SECTIONS
class ScopeSelectView(LoginRequiredMixin, View):
template_name = "netbox_customer_export/select.html"
def get(self, request):
return render(request, self.template_name, {"form": ScopeSelectForm()})
def post(self, request):
form = ScopeSelectForm(request.POST)
if form.is_valid():
return redirect("plugins:netbox_customer_export:export", kind=form.cleaned_data["kind"], pk=form.cleaned_data["obj"].pk)
return render(request, self.template_name, {"form": form})
class ExportView(LoginRequiredMixin, View):
template_name = "netbox_customer_export/export.html"
def _scope(self, request, kind, pk):
model = SCOPE_MODELS.get(kind)
if model is None:
raise Http404
obj = get_object_or_404(model.objects.restrict(request.user, "view"), pk=pk)
return Scope(kind, obj, request.user)
def _render_form(self, request, scope, form):
grouped = {}
for s in SECTIONS.values():
grouped.setdefault(s.group, []).append(s)
return render(request, self.template_name, {"object": scope.obj, "scope": scope, "form": form, "section_groups": grouped})
def get(self, request, kind, pk):
scope = self._scope(request, kind, pk)
return self._render_form(request, scope, ExportForm(scope=scope))
def post(self, request, kind, pk):
scope = self._scope(request, kind, pk)
form = ExportForm(request.POST, scope=scope)
if not form.is_valid():
return self._render_form(request, scope, form)
opts = form.cleaned_data
blocks = []
for key in opts["sections"]:
blocks += SECTIONS[key].func(scope, opts)
doc = Document(
title=opts["title"],
subtitle=opts.get("subtitle", ""),
blocks=blocks,
meta={
"author": opts.get("author") or request.user.get_full_name() or request.user.username,
"date": timezone.localtime().strftime("%d.%m.%Y"),
},
branding=get_branding(opts["branding"]),
)
try:
content, content_type, ext = render_document(doc, opts["format"], request)
except MissingDependency as e:
messages.error(request, str(e))
return self._render_form(request, scope, form)
response = HttpResponse(content, content_type=content_type)
if ext != "html":
filename = f"{slugify(opts['title']) or 'dokumentation'}_{timezone.localdate():%Y%m%d}.{ext}"
response["Content-Disposition"] = f'attachment; filename="{filename}"'
return response