- 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>
212 lines
7.2 KiB
Python
212 lines
7.2 KiB
Python
"""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
|