feat: redesign export form

Two-column layout with format toggle buttons, section tiles with short
descriptions and "Alle/Keine" per group, device roles/columns and rack
faces as toggle pills, option switches, device/rack cards dimmed when
their section is deselected and a sticky action bar.

Bump version to 0.1.1 (includes the rack elevation fix).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-09-23 13:17:09 +02:00
co-authored by Claude Opus 5.5
parent c37626aff1
commit 0c6bebe2e3
7 changed files with 157 additions and 68 deletions
+1 -1
View File
@@ -5,7 +5,7 @@ class CustomerExportConfig(PluginConfig):
name = "netbox_customer_export"
verbose_name = "Kunden-Export"
description = "Kundendokumentation als PDF oder Word exportieren"
version = "0.1.0"
version = "0.1.1"
author = "Claude"
base_url = "customer-export"
min_version = "4.6.8"
+4
View File
@@ -71,6 +71,10 @@ class ExportForm(forms.Form):
for field in self.fields.values():
if isinstance(field.widget, (forms.CheckboxInput, forms.CheckboxSelectMultiple)):
field.widget.attrs["class"] = "form-check-input"
elif isinstance(field.widget, forms.Select):
field.widget.attrs["class"] = "form-select"
elif isinstance(field.widget, forms.TextInput):
field.widget.attrs["class"] = "form-control"
self.fields["branding"].choices = [(k, b["name"]) for k, b in get_brandings().items()]
if scope is not None:
role_ids = scope.devices().values("role")
+16 -15
View File
@@ -19,14 +19,15 @@ class Section:
group: str
default: bool
func: callable
hint: str = ""
SECTIONS = OrderedDict()
def section(key, label, group, default=True):
def section(key, label, group, default=True, hint=""):
def deco(func):
SECTIONS[key] = Section(key, label, group, default, func)
SECTIONS[key] = Section(key, label, group, default, func, hint)
return func
return deco
@@ -75,7 +76,7 @@ def _empty(opts, blocks, text="Keine Einträge vorhanden."):
# --- Abschnitte -------------------------------------------------------------
@section("overview", "Übersicht & Standortdaten", "Allgemein")
@section("overview", "Übersicht & Standortdaten", "Allgemein", hint="Kennzahlen, Adressen, Lokationsbaum")
def overview(scope, opts):
blocks = [H(1, "Übersicht")]
counts = [
@@ -118,7 +119,7 @@ def overview(scope, opts):
return blocks
@section("contacts", "Ansprechpartner", "Allgemein")
@section("contacts", "Ansprechpartner", "Allgemein", hint="Kontakte an Standorten, Lokationen, Mandanten")
def contacts(scope, opts):
ContactAssignment = apps.get_model("tenancy", "ContactAssignment")
targets = [
@@ -143,7 +144,7 @@ def contacts(scope, opts):
return blocks
@section("racks", "Racks (nach Standort & Lokation, inkl. Ansichten)", "Infrastruktur")
@section("racks", "Racks", "Infrastruktur", hint="Nach Standort & Lokation, mit Rack-Ansicht")
def racks(scope, opts):
qs = (
scope.racks()
@@ -211,7 +212,7 @@ def racks(scope, opts):
return blocks
@section("devices", "Geräte (nach Geräterolle – Auswahl unten)", "Infrastruktur")
@section("devices", "Geräte", "Infrastruktur", hint="Tabellen je Geräterolle")
def devices(scope, opts):
blocks = [H(1, "Geräte")]
base = scope.devices().select_related(
@@ -234,7 +235,7 @@ def devices(scope, opts):
return blocks if found else _empty(opts, blocks)
@section("power", "Stromversorgung (Power Feeds)", "Infrastruktur", default=False)
@section("power", "Stromversorgung", "Infrastruktur", default=False, hint="Power Panels & Feeds")
def power(scope, opts):
qs = scope.power_feeds().select_related("power_panel", "rack").order_by("power_panel__name", "name")
blocks = [H(1, "Stromversorgung")]
@@ -249,7 +250,7 @@ def power(scope, opts):
return blocks
@section("vms", "Virtuelle Maschinen", "Infrastruktur", default=False)
@section("vms", "Virtuelle Maschinen", "Infrastruktur", default=False, hint="Cluster, Ressourcen, IP")
def vms(scope, opts):
qs = scope.virtual_machines().select_related("cluster", "role", "platform", "primary_ip4", "primary_ip6").order_by("cluster__name", "name")
blocks = [H(1, "Virtuelle Maschinen")]
@@ -264,7 +265,7 @@ def vms(scope, opts):
return blocks
@section("vrfs", "VRFs", "Netzwerk")
@section("vrfs", "VRFs", "Netzwerk", hint="Inkl. Route Targets")
def vrfs(scope, opts):
qs = scope.vrfs().select_related("tenant").prefetch_related("import_targets", "export_targets").order_by("name")
blocks = [H(1, "VRFs")]
@@ -279,7 +280,7 @@ def vrfs(scope, opts):
return blocks
@section("prefixes", "IP-Netze / Präfixe (nach VRF)", "Netzwerk")
@section("prefixes", "IP-Netze", "Netzwerk", hint="Präfixe gruppiert nach VRF")
def prefixes(scope, opts):
qs = scope.prefixes().select_related("vrf", "vlan", "role", "tenant").order_by("vrf__name", "prefix")
blocks = [H(1, "IP-Netze")]
@@ -296,7 +297,7 @@ def prefixes(scope, opts):
return blocks
@section("vlans", "VLANs", "Netzwerk")
@section("vlans", "VLANs", "Netzwerk", hint="VID, Name, Gruppe, Rolle")
def vlans(scope, opts):
qs = scope.vlans().select_related("group", "site", "role", "tenant").order_by("group__name", "vid")
blocks = [H(1, "VLANs")]
@@ -311,7 +312,7 @@ def vlans(scope, opts):
return blocks
@section("ips", "IP-Adressen (ausführlich)", "Netzwerk", default=False)
@section("ips", "IP-Adressen", "Netzwerk", default=False, hint="Ausführlich – kann lang werden")
def ips(scope, opts):
qs = scope.ip_addresses().select_related("vrf", "tenant").prefetch_related("assigned_object").order_by("vrf__name", "address")
blocks = [H(1, "IP-Adressen")]
@@ -328,7 +329,7 @@ def ips(scope, opts):
return blocks
@section("wireless", "WLANs / SSIDs", "Netzwerk", default=False)
@section("wireless", "WLANs", "Netzwerk", default=False, hint="SSIDs, Authentifizierung")
def wireless(scope, opts):
qs = scope.wireless_lans().select_related("group", "vlan").order_by("ssid")
blocks = [H(1, "WLANs")]
@@ -348,7 +349,7 @@ def wireless(scope, opts):
return blocks
@section("circuits", "Leitungen / Provider", "Netzwerk", default=False)
@section("circuits", "Leitungen", "Netzwerk", default=False, hint="Provider, Leitungs-ID, Bandbreite")
def circuits(scope, opts):
qs = scope.circuits().select_related("provider", "type", "tenant").order_by("provider__name", "cid")
blocks = [H(1, "Leitungen")]
@@ -381,7 +382,7 @@ DEFAULT_LICENSE_SOURCES = [
]
@section("licenses", "Lizenzen", "Verträge & Lizenzen")
@section("licenses", "Lizenzen", "Verträge & Lizenzen", hint="Aus Lizenz-Plugin (z.B. netbox-lifecycle)")
def licenses(scope, opts):
sources = get_plugin_config("netbox_customer_export", "license_sources") or DEFAULT_LICENSE_SOURCES
blocks = [H(1, "Lizenzen")]
@@ -1,7 +0,0 @@
<div class="row">
{% for cb in field %}
<div class="col-md-{{ cols|default:6 }}">
<div class="form-check">{{ cb.tag }} <label class="form-check-label" for="{{ cb.id_for_label }}">{{ cb.choice_label }}</label></div>
</div>
{% empty %}<div class="text-muted">Keine Einträge im Scope.</div>{% endfor %}
</div>
@@ -0,0 +1,6 @@
<div class="ce-pills">
{% for cb in field %}
<input type="checkbox" class="btn-check" name="{{ cb.data.name }}" value="{{ cb.data.value }}" id="{{ cb.id_for_label }}" autocomplete="off"{% if cb.data.selected %} checked{% endif %}>
<label class="btn btn-sm btn-outline-primary" for="{{ cb.id_for_label }}">{{ cb.choice_label }}</label>
{% empty %}<span class="text-muted small">Keine Einträge im Scope.</span>{% endfor %}
</div>
@@ -1,66 +1,151 @@
{% extends 'generic/_base.html' %}
{% load form_helpers %}
{% block title %}Kundendoku: {{ object }}{% endblock %}
{% block subtitle %}<div class="text-muted">{{ scope.label }}</div>{% endblock %}
{% block title %}Kundendokumentation: {{ object }}{% endblock %}
{% block subtitle %}
<div class="text-muted"><span class="badge text-bg-secondary">{{ scope.label }}</span> <a href="{{ object.get_absolute_url }}">{{ object }}</a></div>
{% endblock %}
{% block content %}
<form method="post" class="container-fluid my-3">
<style>
.ce-pills { display: flex; flex-wrap: wrap; gap: .4rem; }
.ce-pills .btn { border-radius: 2rem; }
.ce-tiles { display: grid; grid-template-columns: repeat(auto-fill, minmax(230px, 1fr)); gap: .5rem; }
.ce-tile { display: flex; gap: .6rem; align-items: flex-start; padding: .6rem .75rem; border: 1px solid var(--tblr-border-color); border-radius: .5rem; cursor: pointer; margin: 0; transition: border-color .15s, background-color .15s; }
.ce-tile:hover { border-color: var(--tblr-primary); }
.ce-tile:has(input:checked) { border-color: var(--tblr-primary); background: rgba(var(--tblr-primary-rgb), .06); }
.ce-tile .form-check-input { margin: .15rem 0 0 0; flex-shrink: 0; }
.ce-tile small { display: block; color: var(--tblr-secondary); }
.ce-group + .ce-group { margin-top: 1rem; }
.ce-group-head { display: flex; justify-content: space-between; align-items: baseline; margin-bottom: .4rem; }
.ce-group-head h5 { margin: 0; font-size: .8rem; text-transform: uppercase; letter-spacing: .04em; color: var(--tblr-secondary); }
.ce-group-head a { font-size: .8rem; cursor: pointer; }
.ce-switch { display: flex; gap: .6rem; align-items: center; margin-bottom: .4rem; }
.ce-switch .form-check-input { margin: 0; }
.ce-label { font-size: .8rem; text-transform: uppercase; letter-spacing: .04em; color: var(--tblr-secondary); margin: .9rem 0 .4rem; }
.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; }
</style>
<form method="post" class="mt-3">
{% csrf_token %}
{% if form.errors %}<div class="alert alert-danger">{{ form.errors }}</div>{% endif %}
<div class="row">
<div class="col-lg-5">
<div class="card mb-3">
<h2 class="card-header">Dokument</h2>
<div class="row g-3">
{# --- links: Dokument --- #}
<div class="col-xl-4">
<div class="card">
<h2 class="card-header"><i class="mdi mdi-file-document-outline"></i> Dokument</h2>
<div class="card-body">
{% render_field form.format %}
{% render_field form.title %}
{% render_field form.subtitle %}
{% render_field form.author %}
{% if form.branding.field.choices|length > 1 %}{% render_field form.branding %}{% else %}<input type="hidden" name="branding" value="{{ form.branding.field.choices.0.0 }}">{% endif %}
</div>
</div>
<div class="card mb-3">
<h2 class="card-header">Inhalte</h2>
<div class="card-body">
{% for group, sections in section_groups.items %}
<h6 class="mt-2">{{ group }}</h6>
{% for cb in form.sections %}{% for s in sections %}{% if s.key == cb.data.value %}
<div class="form-check">{{ cb.tag }} <label class="form-check-label" for="{{ cb.id_for_label }}">{{ cb.choice_label }}</label></div>
{% endif %}{% endfor %}{% endfor %}
<div class="ce-label mt-0">Format</div>
<div class="btn-group w-100 ce-format mb-3" role="group">
{% 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 %}
{{ label }}
</label>
{% endfor %}
<hr>
<div class="form-check">{{ form.include_empty }} <label class="form-check-label" for="{{ form.include_empty.id_for_label }}">{{ form.include_empty.label }}</label></div>
</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>
{% if form.branding.field.choices|length > 1 %}
<div class="mb-3"><label class="form-label" for="{{ form.branding.id_for_label }}">Design / Firma</label>{{ form.branding }}</div>
{% else %}
<input type="hidden" name="branding" value="{{ form.branding.field.choices.0.0 }}">
{% endif %}
<div class="ce-label">Optionen</div>
<label class="ce-switch">{{ form.include_empty }} {{ form.include_empty.label }}</label>
{% if form.include_secrets %}
<div class="form-check">{{ form.include_secrets }} <label class="form-check-label text-danger" for="{{ form.include_secrets.id_for_label }}">{{ form.include_secrets.label }}</label></div>
<label class="ce-switch text-danger">{{ form.include_secrets }} {{ form.include_secrets.label }}</label>
{% endif %}
</div>
</div>
</div>
{# --- rechts: Inhalte + Detailoptionen --- #}
<div class="col-xl-8">
<div class="card">
<h2 class="card-header"><i class="mdi mdi-format-list-checks"></i> Inhalte</h2>
<div class="card-body">
{% for group, sections in section_groups.items %}
<div class="ce-group" data-ce-group>
<div class="ce-group-head">
<h5>{{ group }}</h5>
<span><a data-ce-all>Alle</a> · <a data-ce-none>Keine</a></span>
</div>
<div class="ce-tiles">
{% for cb in form.sections %}{% for s in sections %}{% if s.key == cb.data.value %}
<label class="ce-tile" for="{{ cb.id_for_label }}">
<input type="checkbox" class="form-check-input" name="sections" value="{{ s.key }}" id="{{ cb.id_for_label }}"{% if cb.data.selected %} checked{% endif %}>
<span><strong>{{ s.label }}</strong>{% if s.hint %}<small>{{ s.hint }}</small>{% endif %}</span>
</label>
{% endif %}{% endfor %}{% endfor %}
</div>
</div>
{% endfor %}
</div>
</div>
<div class="row g-3 mt-0">
<div class="col-lg-7">
<div class="card mb-3">
<h2 class="card-header">Geräte</h2>
<div class="card ce-card h-100" data-ce-depends="devices">
<h2 class="card-header"><i class="mdi mdi-server-network"></i> Geräte</h2>
<div class="card-body">
<h6>Geräterollen</h6>
{% include 'netbox_customer_export/_checkboxes.html' with field=form.roles cols=4 %}
<h6 class="mt-3">Spalten</h6>
{% include 'netbox_customer_export/_checkboxes.html' with field=form.device_columns cols=4 %}
<div class="form-check mt-2">{{ form.group_by_site }} <label class="form-check-label" for="{{ form.group_by_site.id_for_label }}">{{ form.group_by_site.label }}</label></div>
<div data-ce-group>
<div class="ce-group-head"><h5>Geräterollen</h5><span><a data-ce-all>Alle</a> · <a data-ce-none>Keine</a></span></div>
{% include 'netbox_customer_export/_pills.html' with field=form.roles %}
</div>
<div class="ce-label">Spalten</div>
{% include 'netbox_customer_export/_pills.html' with field=form.device_columns %}
<label class="ce-switch mt-3">{{ form.group_by_site }} {{ form.group_by_site.label }}</label>
</div>
</div>
<div class="card mb-3">
<h2 class="card-header">Racks</h2>
</div>
<div class="col-lg-5">
<div class="card ce-card h-100" data-ce-depends="racks">
<h2 class="card-header"><i class="mdi mdi-server"></i> Racks</h2>
<div class="card-body">
<div class="form-check">{{ form.rack_svg }} <label class="form-check-label" for="{{ form.rack_svg.id_for_label }}">{{ form.rack_svg.label }}</label></div>
<div class="form-check">{{ form.rack_devices }} <label class="form-check-label" for="{{ form.rack_devices.id_for_label }}">{{ form.rack_devices.label }}</label></div>
<div class="form-check">{{ form.rack_pagebreak }} <label class="form-check-label" for="{{ form.rack_pagebreak.id_for_label }}">{{ form.rack_pagebreak.label }}</label></div>
<h6 class="mt-2">Seiten</h6>
{% include 'netbox_customer_export/_checkboxes.html' with field=form.rack_faces cols=6 %}
<label class="ce-switch">{{ form.rack_svg }} {{ form.rack_svg.label }}</label>
<div class="ce-label">Seiten</div>
{% include 'netbox_customer_export/_pills.html' with field=form.rack_faces %}
<div class="ce-label">Weiteres</div>
<label class="ce-switch">{{ form.rack_devices }} {{ form.rack_devices.label }}</label>
<label class="ce-switch">{{ form.rack_pagebreak }} {{ form.rack_pagebreak.label }}</label>
</div>
</div>
</div>
</div>
<div class="text-end">
</div>
</div>
<div class="ce-actions mt-3">
<a href="{{ object.get_absolute_url }}" class="btn btn-outline-secondary">Abbrechen</a>
<button class="btn btn-primary"><i class="mdi mdi-download"></i> Exportieren</button>
<button class="btn btn-primary"><i class="mdi mdi-download"></i> Dokument erstellen</button>
</div>
</form>
<script>
(function () {
document.querySelectorAll('[data-ce-group]').forEach(function (group) {
function setAll(state) {
group.querySelectorAll('input[type=checkbox]').forEach(function (cb) { cb.checked = state; cb.dispatchEvent(new Event('change', {bubbles: true})); });
}
var all = group.querySelector('[data-ce-all]'), none = group.querySelector('[data-ce-none]');
if (all) all.addEventListener('click', function () { setAll(true); });
if (none) none.addEventListener('click', function () { setAll(false); });
});
// Detailkarten ausgrauen, wenn der zugehörige Abschnitt abgewählt ist
function sync() {
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);
});
}
document.addEventListener('change', sync);
sync();
})();
</script>
{% endblock %}
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "netbox-customer-export"
version = "0.1.0"
version = "0.1.1"
description = "NetBox-Plugin: Kundendokumentation (PDF/Word) für Standorte, Lokationen, Mandanten und Mandantengruppen"
readme = "README.md"
requires-python = ">=3.10"