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:
@@ -92,8 +92,8 @@ python3 manage.py clearsessions
|
||||
sudo systemctl restart netbox netbox-rq
|
||||
```
|
||||
|
||||
Die Befehle gehören zum üblichen NetBox-Ablauf nach jeder Plugin-Installation und laufen auch dann fehlerfrei, wenn es nichts zu tun gibt.
|
||||
Die aktuelle Version hat noch keine eigenen Datenbanktabellen und keine statischen Dateien. Spätere Versionen können sie aber mitbringen.
|
||||
`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
|
||||
üblichen NetBox-Ablauf nach jeder Plugin-Installation.
|
||||
|
||||
## Update
|
||||
|
||||
@@ -142,11 +142,32 @@ sudo systemctl restart netbox netbox-rq
|
||||
|
||||
## Konfiguration
|
||||
|
||||
### Design / Branding
|
||||
### Design / Branding in NetBox (empfohlen)
|
||||
|
||||
Farben, Schrift, Logo und eine Word-Vorlage lassen sich festlegen, entweder global oder als **mehrere Profile**
|
||||
(z.B. pro eigener Firma oder Marke). Bei mehr als einem Profil erscheint im Exportformular das Auswahlfeld „Design / Firma“.
|
||||
Die globalen Werte gelten als Basis, jedes Profil überschreibt nur die Werte, die es selbst setzt.
|
||||
Unter **Plugins → Branding-Profile** legen Admins Design-Profile direkt in NetBox an:
|
||||
|
||||
* Name, Firmenname, Fußzeile und **Logo-Upload** (PNG/JPG)
|
||||
* Farben per Farbwähler: Primär, Akzent, Tabellenkopf-Schrift, Beschriftungen, Zebrastreifen
|
||||
* Schriftart und -größe
|
||||
* **Upload einer Word-Vorlage** (.docx/.dotx), optional mit dem Namen einer Tabellenformatvorlage
|
||||
* zusätzliches CSS für PDF und HTML
|
||||
* **Vorschau**-Button, der ein Beispieldokument im gewählten Design öffnet
|
||||
* Ein Profil lässt sich als **Standard** markieren und ist dann im Exportformular vorausgewählt.
|
||||
|
||||
Uploads landen unter `MEDIA_ROOT/customer-export/` (Standard: `/opt/netbox/netbox/media/customer-export/`) und sollten mitgesichert werden.
|
||||
Beim Löschen eines Profils werden Logo und Vorlage mit entfernt.
|
||||
|
||||
**Berechtigungen:** Superuser dürfen alles. Anderen Benutzern oder Gruppen weist man unter *Admin → Berechtigungen* eine
|
||||
Berechtigung für den Objekttyp *Customer Export | Branding-Profil* zu. Mit *Ansehen* sieht man Liste und Vorschau, zum Pflegen
|
||||
braucht es zusätzlich *Hinzufügen*, *Ändern* und *Löschen*. Techniker, die nur exportieren, benötigen keine davon.
|
||||
|
||||
Sind in NetBox Profile angelegt, erscheinen sie zuerst in der Auswahl „Design / Firma“. Profile aus `configuration.py`
|
||||
(siehe unten) werden zusätzlich angeboten, wenn dort `brandings` definiert ist.
|
||||
|
||||
### Design / Branding per `configuration.py`
|
||||
|
||||
Alternativ oder ergänzend lassen sich Profile in der Konfiguration festlegen, entweder global oder als **mehrere Profile**
|
||||
(z.B. pro eigener Firma oder Marke). Die globalen Werte gelten als Basis, jedes Profil überschreibt nur die Werte, die es selbst setzt.
|
||||
|
||||
```python
|
||||
PLUGINS_CONFIG = {
|
||||
|
||||
@@ -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.1"
|
||||
version = "0.2.0"
|
||||
author = "Claude"
|
||||
base_url = "customer-export"
|
||||
min_version = "4.6.8"
|
||||
|
||||
@@ -36,8 +36,8 @@ def _normalize(profile):
|
||||
return profile
|
||||
|
||||
|
||||
def get_brandings():
|
||||
"""Alle Profile als {key: dict}. Top-Level-Einstellungen gelten als Basis für jedes Profil."""
|
||||
def _config_brandings():
|
||||
"""Profile aus PLUGINS_CONFIG. Top-Level-Einstellungen gelten als Basis für jedes Profil."""
|
||||
base = dict(DEFAULT_BRANDING)
|
||||
for key in DEFAULT_BRANDING:
|
||||
value = get_plugin_config(PLUGIN, key)
|
||||
@@ -49,6 +49,31 @@ def get_brandings():
|
||||
return {key: _normalize({**base, "name": key, **profile}) for key, profile in profiles.items()}
|
||||
|
||||
|
||||
def _db_brandings():
|
||||
"""In NetBox gepflegte Profile; das Standardprofil zuerst."""
|
||||
from django.db import DatabaseError
|
||||
|
||||
from .models import BrandingProfile
|
||||
|
||||
try:
|
||||
return {f"db-{p.pk}": _normalize(p.as_branding()) for p in BrandingProfile.objects.all()}
|
||||
except DatabaseError: # Migration noch nicht ausgeführt
|
||||
return {}
|
||||
|
||||
|
||||
def get_brandings():
|
||||
"""Alle Profile als {key: dict}: zuerst die aus NetBox, dann die aus der Konfiguration.
|
||||
|
||||
Sobald es Profile in NetBox gibt, entfällt das implizite Konfigurations-Profil "default"
|
||||
(nur Top-Level-Werte, kein "brandings"), damit die Auswahl nicht doppelt ist.
|
||||
"""
|
||||
db = _db_brandings()
|
||||
config = _config_brandings()
|
||||
if db and not get_plugin_config(PLUGIN, "brandings"):
|
||||
config = {}
|
||||
return {**db, **config}
|
||||
|
||||
|
||||
def get_branding(key=None):
|
||||
brandings = get_brandings()
|
||||
return brandings.get(key) or next(iter(brandings.values()))
|
||||
|
||||
@@ -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")
|
||||
@@ -5,6 +5,7 @@ from tenancy.models import Tenant, TenantGroup
|
||||
from utilities.forms.fields import DynamicModelChoiceField
|
||||
|
||||
from .branding import get_brandings
|
||||
from .models import BrandingProfile
|
||||
from .sections import DEFAULT_DEVICE_COLUMNS, DEVICE_COLUMNS, SECTIONS
|
||||
|
||||
FORMAT_CHOICES = (("pdf", "PDF"), ("docx", "Word (.docx)"), ("html", "HTML-Vorschau"))
|
||||
@@ -85,3 +86,44 @@ class ExportForm(forms.Form):
|
||||
self.fields["subtitle"].initial = f"{scope.label}: {scope.obj}"
|
||||
if not get_plugin_config("netbox_customer_export", "allow_secrets"):
|
||||
del self.fields["include_secrets"]
|
||||
|
||||
|
||||
COLOR_FIELDS = ("primary_color", "accent_color", "header_text_color", "label_bg_color", "stripe_color")
|
||||
|
||||
|
||||
class ColorInput(forms.TextInput):
|
||||
"""<input type="color">: Anzeige mit #, gespeichert wird ohne #."""
|
||||
|
||||
input_type = "color"
|
||||
|
||||
def format_value(self, value):
|
||||
return f"#{value.lstrip('#')}" if value else None
|
||||
|
||||
def value_from_datadict(self, data, files, name):
|
||||
value = super().value_from_datadict(data, files, name)
|
||||
return value.lstrip("#").upper() if value else value
|
||||
|
||||
|
||||
class BrandingProfileForm(forms.ModelForm):
|
||||
class Meta:
|
||||
model = BrandingProfile
|
||||
exclude = ("last_updated",)
|
||||
widgets = {
|
||||
**{f: ColorInput(attrs={"class": "form-control form-control-color"}) for f in COLOR_FIELDS},
|
||||
"extra_css": forms.Textarea(attrs={"rows": 4, "class": "form-control font-monospace"}),
|
||||
}
|
||||
help_texts = {
|
||||
"is_default": "Wird im Exportformular vorausgewählt.",
|
||||
"logo": "PNG oder JPG, erscheint auf dem Deckblatt.",
|
||||
"docx_template": ".docx oder .dotx: Formatvorlagen, Kopf-/Fußzeile und Ränder werden übernommen.",
|
||||
"docx_table_style": "Name wie in Word angezeigt, z.B. Gitternetztabelle 4 – Akzent 1. Leer = eigene Einfärbung.",
|
||||
}
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
for field in self.fields.values():
|
||||
w = field.widget
|
||||
if isinstance(w, forms.CheckboxInput):
|
||||
w.attrs.setdefault("class", "form-check-input")
|
||||
else:
|
||||
w.attrs.setdefault("class", "form-control")
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
import django.core.validators
|
||||
from django.db import migrations, models
|
||||
|
||||
HEX = django.core.validators.RegexValidator("^[0-9A-Fa-f]{6}$", "Hex-Farbe ohne # erwartet, z.B. 1F4E79")
|
||||
|
||||
|
||||
def color(default, verbose_name):
|
||||
return models.CharField(default=default, max_length=6, validators=[HEX], verbose_name=verbose_name)
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
initial = True
|
||||
dependencies = []
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name="BrandingProfile",
|
||||
fields=[
|
||||
("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")),
|
||||
("name", models.CharField(max_length=100, unique=True, verbose_name="Name")),
|
||||
("is_default", models.BooleanField(default=False, verbose_name="Standardprofil")),
|
||||
("company_name", models.CharField(blank=True, max_length=200, verbose_name="Firmenname")),
|
||||
("footer_text", models.CharField(blank=True, default="Vertraulich – nur für den internen Gebrauch des Kunden", max_length=300, verbose_name="Fußzeile")),
|
||||
("logo", models.ImageField(blank=True, upload_to="customer-export/logos/", verbose_name="Logo")),
|
||||
("primary_color", color("1F4E79", "Primärfarbe")),
|
||||
("accent_color", color("2E75B6", "Akzentfarbe")),
|
||||
("header_text_color", color("FFFFFF", "Schrift Tabellenkopf")),
|
||||
("label_bg_color", color("DEEAF6", "Hintergrund Beschriftungen")),
|
||||
("stripe_color", color("F3F7FB", "Zebrastreifen")),
|
||||
("font_family", models.CharField(default="Calibri", max_length=100, verbose_name="Schriftart")),
|
||||
("font_size", models.PositiveSmallIntegerField(default=10, validators=[django.core.validators.MinValueValidator(7), django.core.validators.MaxValueValidator(16)], verbose_name="Schriftgröße (pt)")),
|
||||
("docx_template", models.FileField(blank=True, upload_to="customer-export/templates/", validators=[django.core.validators.FileExtensionValidator(["docx", "dotx"])], verbose_name="Word-Vorlage")),
|
||||
("docx_table_style", models.CharField(blank=True, max_length=100, verbose_name="Tabellenformatvorlage (Word)")),
|
||||
("extra_css", models.TextField(blank=True, verbose_name="Zusätzliches CSS (PDF/HTML)")),
|
||||
("last_updated", models.DateTimeField(auto_now=True)),
|
||||
],
|
||||
options={
|
||||
"verbose_name": "Branding-Profil",
|
||||
"verbose_name_plural": "Branding-Profile",
|
||||
"ordering": ("-is_default", "name"),
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,76 @@
|
||||
from django.core.validators import FileExtensionValidator, MaxValueValidator, MinValueValidator, RegexValidator
|
||||
from django.db import models
|
||||
from django.urls import reverse
|
||||
|
||||
hex_color = RegexValidator(r"^[0-9A-Fa-f]{6}$", "Hex-Farbe ohne # erwartet, z.B. 1F4E79")
|
||||
|
||||
|
||||
def _color(default, verbose_name):
|
||||
return models.CharField(max_length=6, default=default, validators=[hex_color], verbose_name=verbose_name)
|
||||
|
||||
|
||||
class BrandingProfile(models.Model):
|
||||
"""In NetBox gepflegtes Design-Profil (Alternative/Ergänzung zu PLUGINS_CONFIG['brandings'])."""
|
||||
|
||||
name = models.CharField(max_length=100, unique=True, verbose_name="Name")
|
||||
is_default = models.BooleanField(default=False, verbose_name="Standardprofil")
|
||||
company_name = models.CharField(max_length=200, blank=True, verbose_name="Firmenname")
|
||||
footer_text = models.CharField(
|
||||
max_length=300, blank=True, default="Vertraulich – nur für den internen Gebrauch des Kunden", verbose_name="Fußzeile"
|
||||
)
|
||||
logo = models.ImageField(upload_to="customer-export/logos/", blank=True, verbose_name="Logo")
|
||||
primary_color = _color("1F4E79", "Primärfarbe")
|
||||
accent_color = _color("2E75B6", "Akzentfarbe")
|
||||
header_text_color = _color("FFFFFF", "Schrift Tabellenkopf")
|
||||
label_bg_color = _color("DEEAF6", "Hintergrund Beschriftungen")
|
||||
stripe_color = _color("F3F7FB", "Zebrastreifen")
|
||||
font_family = models.CharField(max_length=100, default="Calibri", verbose_name="Schriftart")
|
||||
font_size = models.PositiveSmallIntegerField(
|
||||
default=10, validators=[MinValueValidator(7), MaxValueValidator(16)], verbose_name="Schriftgröße (pt)"
|
||||
)
|
||||
docx_template = models.FileField(
|
||||
upload_to="customer-export/templates/",
|
||||
blank=True,
|
||||
validators=[FileExtensionValidator(["docx", "dotx"])],
|
||||
verbose_name="Word-Vorlage",
|
||||
)
|
||||
docx_table_style = models.CharField(max_length=100, blank=True, verbose_name="Tabellenformatvorlage (Word)")
|
||||
extra_css = models.TextField(blank=True, verbose_name="Zusätzliches CSS (PDF/HTML)")
|
||||
last_updated = models.DateTimeField(auto_now=True)
|
||||
|
||||
class Meta:
|
||||
ordering = ("-is_default", "name")
|
||||
verbose_name = "Branding-Profil"
|
||||
verbose_name_plural = "Branding-Profile"
|
||||
|
||||
def __str__(self):
|
||||
return self.name
|
||||
|
||||
def get_absolute_url(self):
|
||||
return reverse("plugins:netbox_customer_export:branding_edit", kwargs={"pk": self.pk})
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
for field in ("primary_color", "accent_color", "header_text_color", "label_bg_color", "stripe_color"):
|
||||
setattr(self, field, getattr(self, field).lstrip("#").upper())
|
||||
super().save(*args, **kwargs)
|
||||
if self.is_default:
|
||||
BrandingProfile.objects.exclude(pk=self.pk).filter(is_default=True).update(is_default=False)
|
||||
|
||||
def as_branding(self):
|
||||
"""Dict im Format von branding.DEFAULT_BRANDING."""
|
||||
return {
|
||||
"name": self.name,
|
||||
"company_name": self.company_name,
|
||||
"footer_text": self.footer_text,
|
||||
"logo_path": self.logo.path if self.logo else None,
|
||||
"primary_color": self.primary_color,
|
||||
"accent_color": self.accent_color,
|
||||
"header_text_color": self.header_text_color,
|
||||
"label_bg_color": self.label_bg_color,
|
||||
"stripe_color": self.stripe_color,
|
||||
"font_family": self.font_family,
|
||||
"font_size": self.font_size,
|
||||
"docx_template": self.docx_template.path if self.docx_template else None,
|
||||
"docx_table_style": self.docx_table_style,
|
||||
"extra_css": self.extra_css,
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
from netbox.plugins import PluginMenuItem
|
||||
from netbox.plugins import PluginMenuButton, PluginMenuItem
|
||||
|
||||
menu_items = (
|
||||
PluginMenuItem(
|
||||
@@ -6,4 +6,17 @@ menu_items = (
|
||||
link_text="Kundendokumentation",
|
||||
permissions=["dcim.view_site"],
|
||||
),
|
||||
PluginMenuItem(
|
||||
link="plugins:netbox_customer_export:branding_list",
|
||||
link_text="Branding-Profile",
|
||||
permissions=["netbox_customer_export.view_brandingprofile"],
|
||||
buttons=(
|
||||
PluginMenuButton(
|
||||
link="plugins:netbox_customer_export:branding_add",
|
||||
title="Hinzufügen",
|
||||
icon_class="mdi mdi-plus-thick",
|
||||
permissions=["netbox_customer_export.add_brandingprofile"],
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
<div class="mb-3">
|
||||
<label class="form-label{% if field.field.required %} required{% endif %}" for="{{ field.id_for_label }}">{{ field.label }}</label>
|
||||
{{ field }}
|
||||
{% if field.help_text %}<div class="form-text">{{ field.help_text }}</div>{% endif %}
|
||||
{% for e in field.errors %}<div class="text-danger small">{{ e }}</div>{% endfor %}
|
||||
</div>
|
||||
@@ -0,0 +1,17 @@
|
||||
{% extends 'generic/_base.html' %}
|
||||
{% block title %}{{ object }} löschen{% endblock %}
|
||||
{% block content %}
|
||||
<form method="post" class="mt-3">
|
||||
{% csrf_token %}
|
||||
<div class="card border-danger">
|
||||
<div class="card-body">
|
||||
<p>Soll das Branding-Profil <strong>{{ object }}</strong> wirklich gelöscht werden?
|
||||
Logo und Word-Vorlage des Profils werden ebenfalls entfernt.</p>
|
||||
</div>
|
||||
<div class="card-footer text-end">
|
||||
<a href="{% url 'plugins:netbox_customer_export:branding_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,81 @@
|
||||
{% extends 'generic/_base.html' %}
|
||||
{% block title %}{% if object.pk %}{{ object }} bearbeiten{% else %}Neues Branding-Profil{% endif %}{% endblock %}
|
||||
{% block content %}
|
||||
<style>
|
||||
.ce-colors { display: grid; grid-template-columns: repeat(auto-fill, minmax(170px, 1fr)); gap: .75rem; }
|
||||
.ce-colors .form-control-color { width: 100%; height: 2.4rem; }
|
||||
.ce-logo-preview { max-height: 60px; max-width: 220px; border: 1px solid var(--tblr-border-color); border-radius: .25rem; padding: .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">{{ form.non_field_errors }}</div>{% endif %}
|
||||
|
||||
<div class="row g-3">
|
||||
<div class="col-lg-6">
|
||||
<div class="card h-100">
|
||||
<h2 class="card-header"><i class="mdi mdi-domain"></i> Allgemein</h2>
|
||||
<div class="card-body">
|
||||
{% include 'netbox_customer_export/_field.html' with field=form.name %}
|
||||
<div class="mb-3 d-flex gap-2 align-items-center">{{ form.is_default }} <label for="{{ form.is_default.id_for_label }}" class="m-0">{{ form.is_default.label }}</label>
|
||||
<small class="text-muted ms-2">{{ form.is_default.help_text }}</small></div>
|
||||
{% include 'netbox_customer_export/_field.html' with field=form.company_name %}
|
||||
{% include 'netbox_customer_export/_field.html' with field=form.footer_text %}
|
||||
<div class="mb-3">
|
||||
<label class="form-label" for="{{ form.logo.id_for_label }}">{{ form.logo.label }}</label>
|
||||
{% if object.logo %}<div class="mb-2"><img src="{{ object.logo.url }}" class="ce-logo-preview" alt="Aktuelles Logo"></div>{% endif %}
|
||||
{{ form.logo }}
|
||||
<div class="form-text">{{ form.logo.help_text }}</div>
|
||||
{% for e in form.logo.errors %}<div class="text-danger small">{{ e }}</div>{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-lg-6">
|
||||
<div class="card h-100">
|
||||
<h2 class="card-header"><i class="mdi mdi-palette"></i> Farben & Schrift</h2>
|
||||
<div class="card-body">
|
||||
<div class="ce-colors mb-3">
|
||||
{% for field in form %}{% if field.name in 'primary_color accent_color header_text_color label_bg_color stripe_color' %}
|
||||
<div>
|
||||
<label class="form-label small" for="{{ field.id_for_label }}">{{ field.label }}</label>
|
||||
{{ field }}
|
||||
{% for e in field.errors %}<div class="text-danger small">{{ e }}</div>{% endfor %}
|
||||
</div>
|
||||
{% endif %}{% endfor %}
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col-8">{% include 'netbox_customer_export/_field.html' with field=form.font_family %}</div>
|
||||
<div class="col-4">{% include 'netbox_customer_export/_field.html' with field=form.font_size %}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-12">
|
||||
<div class="card">
|
||||
<h2 class="card-header"><i class="mdi mdi-file-word-box"></i> Word & PDF</h2>
|
||||
<div class="card-body row">
|
||||
<div class="col-lg-6">
|
||||
<div class="mb-3">
|
||||
<label class="form-label" for="{{ form.docx_template.id_for_label }}">{{ form.docx_template.label }}</label>
|
||||
{{ form.docx_template }}
|
||||
<div class="form-text">{{ form.docx_template.help_text }}</div>
|
||||
{% for e in form.docx_template.errors %}<div class="text-danger small">{{ e }}</div>{% endfor %}
|
||||
</div>
|
||||
{% include 'netbox_customer_export/_field.html' with field=form.docx_table_style %}
|
||||
</div>
|
||||
<div class="col-lg-6">{% include 'netbox_customer_export/_field.html' with field=form.extra_css %}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="d-flex justify-content-end gap-2 mt-3">
|
||||
<a href="{% url 'plugins:netbox_customer_export:branding_list' %}" class="btn btn-outline-secondary">Abbrechen</a>
|
||||
{% if object.pk %}<a href="{% url 'plugins:netbox_customer_export:branding_preview' pk=object.pk %}" target="_blank" class="btn btn-outline-primary"><i class="mdi mdi-eye"></i> Vorschau</a>{% endif %}
|
||||
<button class="btn btn-primary"><i class="mdi mdi-content-save"></i> Speichern</button>
|
||||
</div>
|
||||
</form>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,57 @@
|
||||
{% extends 'generic/_base.html' %}
|
||||
{% block title %}Branding-Profile{% endblock %}
|
||||
{% block controls %}
|
||||
{% if perms.netbox_customer_export.add_brandingprofile %}
|
||||
<div class="btn-list">
|
||||
<a href="{% url 'plugins:netbox_customer_export:branding_add' %}" class="btn btn-primary"><i class="mdi mdi-plus-thick"></i> Hinzufügen</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
{% block content %}
|
||||
<style>
|
||||
.ce-swatch { display: inline-block; width: 1.1rem; height: 1.1rem; border-radius: .25rem; border: 1px solid var(--tblr-border-color); vertical-align: middle; }
|
||||
.ce-logo { max-height: 32px; max-width: 120px; }
|
||||
</style>
|
||||
<div class="card mt-3">
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover card-table">
|
||||
<thead>
|
||||
<tr><th>Name</th><th>Logo</th><th>Firma</th><th>Farben</th><th>Schrift</th><th>Word-Vorlage</th><th></th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for p in profiles %}
|
||||
<tr>
|
||||
<td>
|
||||
{% if perms.netbox_customer_export.change_brandingprofile %}<a href="{{ p.get_absolute_url }}">{{ p.name }}</a>{% else %}{{ p.name }}{% endif %}
|
||||
{% if p.is_default %}<span class="badge text-bg-green ms-1">Standard</span>{% endif %}
|
||||
</td>
|
||||
<td>{% if p.logo %}<img src="{{ p.logo.url }}" class="ce-logo" alt="">{% else %}<span class="text-muted">—</span>{% endif %}</td>
|
||||
<td>{{ p.company_name|default:"—" }}</td>
|
||||
<td>
|
||||
<span class="ce-swatch" style="background:#{{ p.primary_color }}" title="Primär #{{ p.primary_color }}"></span>
|
||||
<span class="ce-swatch" style="background:#{{ p.accent_color }}" title="Akzent #{{ p.accent_color }}"></span>
|
||||
<span class="ce-swatch" style="background:#{{ p.label_bg_color }}" title="Beschriftungen #{{ p.label_bg_color }}"></span>
|
||||
<span class="ce-swatch" style="background:#{{ p.stripe_color }}" title="Zebrastreifen #{{ p.stripe_color }}"></span>
|
||||
</td>
|
||||
<td>{{ p.font_family }}, {{ p.font_size }} pt</td>
|
||||
<td>{% if p.docx_template %}<i class="mdi mdi-file-word-box text-primary"></i> {{ p.docx_template.name|cut:"customer-export/templates/" }}{% else %}<span class="text-muted">—</span>{% endif %}</td>
|
||||
<td class="text-end text-nowrap">
|
||||
<a href="{% url 'plugins:netbox_customer_export:branding_preview' pk=p.pk %}" target="_blank" class="btn btn-sm btn-outline-secondary" title="Vorschau"><i class="mdi mdi-eye"></i></a>
|
||||
{% if perms.netbox_customer_export.change_brandingprofile %}
|
||||
<a href="{{ p.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_brandingprofile %}
|
||||
<a href="{% url 'plugins:netbox_customer_export:branding_delete' pk=p.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="7" class="text-center text-muted py-4">
|
||||
Noch keine Branding-Profile angelegt. Bis dahin gelten die Einstellungen aus <code>configuration.py</code>.
|
||||
</td></tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -1,8 +1,13 @@
|
||||
from django.urls import path
|
||||
|
||||
from . import views
|
||||
from . import branding_views, views
|
||||
|
||||
urlpatterns = [
|
||||
path("", views.ScopeSelectView.as_view(), name="select"),
|
||||
path("export/<str:kind>/<int:pk>/", views.ExportView.as_view(), name="export"),
|
||||
path("branding/", branding_views.BrandingListView.as_view(), name="branding_list"),
|
||||
path("branding/add/", branding_views.BrandingEditView.as_view(), name="branding_add"),
|
||||
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"),
|
||||
]
|
||||
|
||||
+1
-1
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "netbox-customer-export"
|
||||
version = "0.1.1"
|
||||
version = "0.2.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