Add Vorlagen editor, upload portal, onboarding, and participant import command
Some checks failed
CI/CD Pipeline / test (push) Has been cancelled
CI/CD Pipeline / deploy (push) Has been cancelled
Code Quality / quality (push) Has been cancelled

- Dokument-Vorlagen-Editor: create/edit/reset document templates (admin)
- Upload-Portal: public portal for Nachweis uploads via token
- Onboarding: invite Destinatäre via email with multi-step wizard
- Bestätigungsschreiben: preview and send confirmation letters
- Email settings: SMTP configuration UI
- Management command: import_veranstaltung_teilnehmer for bulk participant import

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
SysAdmin Agent
2026-03-21 09:25:18 +00:00
parent fdf078fa10
commit aed540fe4b
51 changed files with 5335 additions and 33 deletions

View File

@@ -0,0 +1,211 @@
"""Views für den web-basierten Dokument-Vorlagen-Editor."""
from django.contrib import messages
from django.contrib.auth.decorators import login_required
from django.http import HttpResponse, JsonResponse
from django.shortcuts import get_object_or_404, redirect, render
from django.views.decorators.http import require_POST
from stiftung.models import DokumentVorlage
from stiftung.utils.vorlagen import get_vorlage_original
@login_required
def vorlagen_liste(request):
"""Übersicht aller Dokument-Vorlagen nach Kategorie."""
vorlagen = DokumentVorlage.objects.select_related("zuletzt_bearbeitet_von").all()
kategorien = {}
for v in vorlagen:
if v.kategorie not in kategorien:
kategorien[v.kategorie] = []
kategorien[v.kategorie].append(v)
# Kategorie-Labels
kategorie_labels = dict(DokumentVorlage.KATEGORIE_CHOICES)
return render(request, "stiftung/vorlagen_liste.html", {
"kategorien": kategorien,
"kategorie_labels": kategorie_labels,
"vorlagen_count": vorlagen.count(),
})
@login_required
def vorlage_editor(request, pk):
"""Editor für eine einzelne Vorlage."""
vorlage = get_object_or_404(DokumentVorlage, pk=pk)
if request.method == "POST":
html_inhalt = request.POST.get("html_inhalt", "")
vorlage.html_inhalt = html_inhalt
vorlage.zuletzt_bearbeitet_von = request.user
vorlage.save()
messages.success(request, f'Vorlage „{vorlage.bezeichnung}" wurde gespeichert.')
return redirect("stiftung:vorlage_editor", pk=pk)
try:
original = get_vorlage_original(vorlage.schluessel)
hat_original = True
except FileNotFoundError:
original = None
hat_original = False
import json
from django.utils.safestring import mark_safe
# JSON-encode and escape </script> to prevent XSS in script tag
html_json = json.dumps(vorlage.html_inhalt)
html_json = html_json.replace("<", "\\u003c").replace(">", "\\u003e")
# Serienbrief templates are full HTML documents with Django template tags
# ({% for %}, {% if %}) — Summernote WYSIWYG mangles these.
# Use a plain code editor textarea instead.
use_code_editor = vorlage.kategorie == "serienbrief"
return render(request, "stiftung/vorlage_editor.html", {
"vorlage": vorlage,
"hat_original": hat_original,
"variablen": vorlage.verfuegbare_variablen,
"html_inhalt_json": mark_safe(html_json),
"use_code_editor": use_code_editor,
})
@login_required
@require_POST
def vorlage_zuruecksetzen(request, pk):
"""Setzt eine Vorlage auf den Datei-Original-Inhalt zurück."""
vorlage = get_object_or_404(DokumentVorlage, pk=pk)
try:
original = get_vorlage_original(vorlage.schluessel)
vorlage.html_inhalt = original
vorlage.zuletzt_bearbeitet_von = request.user
vorlage.save()
messages.success(request, f'Vorlage „{vorlage.bezeichnung}" wurde auf die Original-Datei zurückgesetzt.')
except FileNotFoundError:
messages.error(request, "Original-Datei nicht gefunden. Zurücksetzen nicht möglich.")
return redirect("stiftung:vorlage_editor", pk=pk)
@login_required
@require_POST
def vorlagen_alle_zuruecksetzen(request):
"""Setzt ALLE Vorlagen auf die Original-Datei-Inhalte zurück."""
vorlagen = DokumentVorlage.objects.all()
restored = 0
for vorlage in vorlagen:
try:
original = get_vorlage_original(vorlage.schluessel)
vorlage.html_inhalt = original
vorlage.zuletzt_bearbeitet_von = request.user
vorlage.save()
restored += 1
except FileNotFoundError:
pass
messages.success(request, f"{restored} Vorlage(n) auf Original zurückgesetzt.")
return redirect("stiftung:vorlagen_liste")
@login_required
def vorlage_vorschau(request, pk):
"""Rendert eine Vorschau der Vorlage mit Beispieldaten (JSON-Response)."""
vorlage = get_object_or_404(DokumentVorlage, pk=pk)
# Rohinhalt aus POST (live-preview) oder aus DB
html_inhalt = request.POST.get("html_inhalt") if request.method == "POST" else vorlage.html_inhalt
# Einfache Beispieldaten je Kategorie
beispiel_context = _get_beispiel_context(vorlage.schluessel)
try:
from django.template import Context, Engine
engine = Engine.get_default()
t = engine.from_string(html_inhalt)
rendered = t.render(Context(beispiel_context))
return HttpResponse(rendered, content_type="text/html; charset=utf-8")
except Exception as exc:
return HttpResponse(
f"<pre style='color:red'>Template-Fehler: {exc}</pre>",
content_type="text/html; charset=utf-8",
)
def _get_beispiel_context(schluessel: str) -> dict:
"""Gibt Beispieldaten für Vorschau-Rendering zurück."""
from datetime import date, time
class FakeObj(dict):
def __getattr__(self, k):
return self.get(k, "")
destinataer = FakeObj(
vorname="Maria",
nachname="Mustermann",
anrede="Frau",
strasse="Musterstraße 1",
plz="46499",
ort="Hamminkeln",
email="m.mustermann@example.com",
)
einladung = FakeObj(
vorname="Maria",
nachname="Mustermann",
email="m.mustermann@example.com",
)
base = {
"destinataer": destinataer,
"einladung": einladung,
"datum": date.today(),
"zeitraum": "01.01.2025 31.12.2025",
"betrag_quartal": 500,
"betrag_jaehrlich": 2000,
"gesamtbetrag": 2000,
"zweck": "Studienförderung",
"unterstuetzungen": [],
"halbjahr_label": "1. Halbjahr 2025",
"upload_url": "https://vhtv-stiftung.de/portal/upload/beispiel-token/",
"gueltig_bis": date.today(),
"qr_code_base64": "",
"ist_erinnerung": False,
"onboarding_url": "https://vhtv-stiftung.de/portal/onboarding/beispiel/",
"veranstaltung": FakeObj(titel="Stiftungsessen 2025"),
"teilnehmer_list": [],
}
# Serienbrief-Vorlage: vollständige Veranstaltungs- und Teilnehmer-Beispieldaten
if "serienbrief" in schluessel:
base["veranstaltung"] = FakeObj(
titel="Stiftungsessen 2025",
datum=date.today(),
uhrzeit=time(18, 0),
ort="Gasthaus zur Linde",
adresse="Lindenstraße 12, 46499 Hamminkeln",
betreff="",
briefvorlage="",
unterschrift_1_name="Katrin Kleinpaß",
unterschrift_1_titel="Rentmeisterin",
unterschrift_2_name="Jan Remmer Siebels",
unterschrift_2_titel="Rentmeister",
)
base["teilnehmer"] = [
FakeObj(
anrede="Frau",
vorname="Maria",
nachname="Mustermann",
strasse="Musterstraße 1",
plz="46499",
ort="Hamminkeln",
),
FakeObj(
anrede="Herr",
vorname="Hans",
nachname="Beispiel",
strasse="Beispielweg 7",
plz="46499",
ort="Hamminkeln",
),
]
return base