Add Vorlagen editor, upload portal, onboarding, and participant import command
- 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:
211
app/stiftung/views/vorlagen.py
Normal file
211
app/stiftung/views/vorlagen.py
Normal 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
|
||||
Reference in New Issue
Block a user