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

@@ -21,6 +21,9 @@ from .destinataere import ( # noqa: F401
destinataer_toggle_archiv,
destinataer_notiz_create,
destinataer_export,
# Bestätigungsschreiben
bestaetigung_vorschau,
bestaetigung_versenden,
)
@@ -181,6 +184,13 @@ from .unterstuetzungen import ( # noqa: F401
unterstuetzung_nachweis_eingereicht,
unterstuetzung_abschliessen,
sepa_xml_export,
# Phase 4: Upload-Portal (Admin-Seite)
nachweis_aufforderung_senden,
batch_nachweis_aufforderung_senden,
# Phase 5: Onboarding (Admin-Seite)
onboarding_einladung_senden,
onboarding_einladung_liste,
onboarding_einladung_widerrufen,
)
from .dms import ( # noqa: F401
@@ -213,5 +223,13 @@ from .import_export import ( # noqa: F401
csv_import_execute,
)
from .vorlagen import ( # noqa: F401
vorlagen_liste,
vorlage_editor,
vorlage_zuruecksetzen,
vorlagen_alle_zuruecksetzen,
vorlage_vorschau,
)
# Non-view exports (helpers used elsewhere)
from .system import GrampsClient, get_gramps_client, get_pdf_generator # noqa: F401

View File

@@ -31,6 +31,7 @@ from django_otp.util import random_hex
from rest_framework.decorators import api_view
from rest_framework.response import Response
from stiftung.audit import log_action
from stiftung.models import (AppConfiguration, AuditLog, BackupJob, BankTransaction,
BriefVorlage, CSVImport, Destinataer,
DestinataerEmailEingang, DestinataerNotiz,
@@ -476,11 +477,13 @@ def destinataer_toggle_archiv(request, pk):
destinataer.aktiv = not destinataer.aktiv
destinataer.save(update_fields=["aktiv"])
status_text = "aktiviert" if destinataer.aktiv else "archiviert (inaktiv gesetzt)"
AuditLog.objects.create(
user=request.user,
action=f"Destinatär {destinataer.get_full_name()} wurde {status_text}.",
model_name="Destinataer",
object_id=str(destinataer.pk),
log_action(
request,
action="update",
entity_type="destinataer",
entity_id=str(destinataer.pk),
entity_name=destinataer.get_full_name(),
description=f"Destinatär {destinataer.get_full_name()} wurde {status_text}.",
)
messages.success(request, f'Destinatär "{destinataer.get_full_name()}" wurde {status_text}.')
return redirect("stiftung:destinataer_detail", pk=destinataer.pk)
@@ -760,3 +763,101 @@ def destinataer_export(request, pk):
pass
# =============================================================================
# Bestätigungsschreiben
# =============================================================================
@login_required
def bestaetigung_vorschau(request, pk):
"""
PDF-Vorschau eines Bestätigungsschreibens für einen Destinatär im Browser.
Generiert das PDF on-the-fly via WeasyPrint.
"""
from decimal import Decimal
from django.template.loader import render_to_string
destinataer = get_object_or_404(Destinataer, id=pk)
unterstuetzungen = DestinataerUnterstuetzung.objects.filter(
destinataer=destinataer,
status__in=["ausgezahlt", "abgeschlossen"],
).order_by("faellig_am")
gesamtbetrag = unterstuetzungen.aggregate(total=Sum("betrag"))["total"] or Decimal("0")
zeitraum = None
if unterstuetzungen.exists():
erste = unterstuetzungen.first().faellig_am
letzte = unterstuetzungen.last().faellig_am
if erste == letzte:
zeitraum = erste.strftime("%d.%m.%Y")
else:
zeitraum = f"{erste.strftime('%d.%m.%Y')} {letzte.strftime('%d.%m.%Y')}"
betrag_quartal = destinataer.vierteljaehrlicher_betrag
betrag_jaehrlich = (betrag_quartal * 4) if betrag_quartal else None
zweck = destinataer.ausbildungsstand or (destinataer.get_berufsgruppe_display() if destinataer.berufsgruppe else None)
context = {
"destinataer": destinataer,
"unterstuetzungen": unterstuetzungen,
"gesamtbetrag": gesamtbetrag,
"datum": timezone.now().date(),
"zeitraum": zeitraum,
"betrag_quartal": betrag_quartal,
"betrag_jaehrlich": betrag_jaehrlich,
"zweck": zweck,
}
try:
from weasyprint import HTML
from stiftung.utils.vorlagen import render_vorlage
html_content = render_vorlage("pdf/bestaetigung.html", context)
pdf_bytes = HTML(string=html_content).write_pdf()
response = HttpResponse(pdf_bytes, content_type="application/pdf")
filename = f"bestaetigung_{destinataer.nachname}_{destinataer.vorname}.pdf"
response["Content-Disposition"] = f'inline; filename="{filename}"'
return response
except Exception as exc:
messages.error(request, f"PDF-Generierung fehlgeschlagen: {exc}")
return redirect("stiftung:destinataer_detail", pk=pk)
@login_required
def bestaetigung_versenden(request, pk):
"""
Sendet das Bestätigungsschreiben per E-Mail an den Destinatär.
POST-only (CSRF-geschützt). Startet asynchronen Celery-Task.
"""
from stiftung.tasks import send_bestaetigung
if request.method != "POST":
return redirect("stiftung:destinataer_detail", pk=pk)
destinataer = get_object_or_404(Destinataer, id=pk)
if not destinataer.email:
messages.error(
request,
f"Destinatär {destinataer.get_full_name()} hat keine E-Mail-Adresse hinterlegt.",
)
return redirect("stiftung:destinataer_detail", pk=pk)
base_url = request.build_absolute_uri("/").rstrip("/")
send_bestaetigung.delay(str(destinataer.id), base_url=base_url)
log_action(
request,
action="update",
entity_type="destinataer",
entity_id=str(destinataer.id),
entity_name=destinataer.get_full_name(),
description=f"Bestätigungsschreiben per E-Mail gesendet an {destinataer.get_full_name()} ({destinataer.email})",
)
messages.success(
request,
f"Bestätigungsschreiben wird per E-Mail an {destinataer.email} gesendet.",
)
return redirect("stiftung:destinataer_detail", pk=pk)

View File

@@ -0,0 +1,602 @@
"""
Portal-Views: Öffentlich zugängliche Seiten für Destinatäre (kein Login erforderlich).
Workflow Upload-Portal:
1. Destinatär erhält E-Mail mit Einmallink (Token)
2. GET /portal/upload/<token>/ → Formular anzeigen
3. POST /portal/upload/<token>/ → Dateien hochladen, Token einlösen
4. Redirect → /portal/upload/<token>/danke/
Workflow Onboarding-Portal (neue Destinatäre):
1. Verwaltung sendet OnboardingEinladung per E-Mail
2. GET/POST /portal/onboarding/<token>/schritt/<n>/ → je Schritt ein Formular
3. Schritte 1-5 via Session-State (kein Login)
4. Nach Schritt 5: Destinatär (unbestaetigt) anlegen + Stiftung benachrichtigen
Sicherheit:
- Token ist 64 Zeichen, kryptographisch sicher
- Einmalige Nutzung (abgeschlossen_am wird gesetzt)
- Automatische Ablaufzeit (30 Tage)
- CSRF-Schutz aktiv
"""
import hashlib
import logging
import mimetypes
import os
from django.contrib import messages
from django.http import Http404, HttpResponse
from django.shortcuts import get_object_or_404, redirect, render
from django.utils import timezone
from django.views.decorators.cache import never_cache
from django.views.decorators.http import require_http_methods
from stiftung.models import DokumentDatei, OnboardingEinladung, UploadToken, VierteljahresNachweis
logger = logging.getLogger(__name__)
# Erlaubte Dateitypen für Uploads
ERLAUBTE_MIME_TYPES = {
"application/pdf",
"image/jpeg",
"image/png",
"image/tiff",
}
MAX_DATEIGROESSE = 20 * 1024 * 1024 # 20 MB
def _get_client_ip(request):
"""Extrahiert die Client-IP-Adresse aus dem Request."""
x_forwarded_for = request.META.get("HTTP_X_FORWARDED_FOR")
if x_forwarded_for:
return x_forwarded_for.split(",")[0].strip()
return request.META.get("REMOTE_ADDR", "")
@never_cache
@require_http_methods(["GET", "POST"])
def upload_formular(request, token):
"""
Zeigt das Upload-Formular für einen Nachweis-Token an
und verarbeitet den Datei-Upload.
"""
upload_token = get_object_or_404(
UploadToken.objects.select_related("destinataer", "nachweis"),
token=token,
)
# Token-Gültigkeitsprüfung
if not upload_token.ist_gueltig():
if upload_token.eingeloest_am is not None:
return render(
request,
"portal/upload_fehler.html",
{
"fehler_typ": "bereits_verwendet",
"message": "Dieser Upload-Link wurde bereits verwendet.",
},
status=410,
)
return render(
request,
"portal/upload_fehler.html",
{
"fehler_typ": "abgelaufen",
"message": "Dieser Upload-Link ist abgelaufen. "
"Bitte wenden Sie sich an die Stiftung.",
},
status=410,
)
destinataer = upload_token.destinataer
nachweis = upload_token.nachweis
halbjahr = 1 if nachweis.quartal in [1, 2] else 2
halbjahr_label = f"{halbjahr}. Halbjahr {nachweis.jahr}"
base_context = {
"token": upload_token,
"destinataer": destinataer,
"nachweis": nachweis,
"halbjahr_label": halbjahr_label,
"gueltig_bis": upload_token.gueltig_bis,
"max_dateigroesse_mb": MAX_DATEIGROESSE // (1024 * 1024),
}
if request.method == "GET":
return render(request, "portal/upload_formular.html", base_context)
# POST: Kategorisierte Dateien und Texte verarbeiten
# Kategorien mit ihren DMS-Kontext-Werten und FK-Feldern auf VierteljahresNachweis
KATEGORIEN = [
{
"key": "studiennachweis",
"label": "Studiennachweis",
"kontext": "studiennachweis",
"dms_fk_field": "studiennachweis_dms_dokument",
"text_field": "studiennachweis_bemerkung",
"bestaetigt_field": "studiennachweis_eingereicht",
"pflicht": True,
},
{
"key": "einkommenssituation",
"label": "Einkommenssituation",
"kontext": "einkommenssituation",
"dms_fk_field": "einkommenssituation_dms_dokument",
"text_field": "einkommenssituation_text",
"bestaetigt_field": "einkommenssituation_bestaetigt",
"pflicht": True,
},
{
"key": "vermogenssituation",
"label": "Vermögenssituation",
"kontext": "vermoegenssituation",
"dms_fk_field": "vermogenssituation_dms_dokument",
"text_field": "vermogenssituation_text",
"bestaetigt_field": "vermogenssituation_bestaetigt",
"pflicht": True,
},
{
"key": "weitere_dokumente",
"label": "Weitere Dokumente",
"kontext": "sonstiges",
"dms_fk_field": None,
"text_field": "weitere_dokumente_beschreibung",
"bestaetigt_field": None,
"pflicht": False,
},
]
fehler_liste = []
gespeicherte_dokumente = []
nachweis_update_fields = []
for kat in KATEGORIEN:
datei = request.FILES.get(kat["key"])
text = request.POST.get(f"{kat['key']}_text", "").strip()
# Pflichtprüfung: mindestens Datei oder Text
if kat["pflicht"] and not datei and not text:
fehler_liste.append(
f'Bitte laden Sie für „{kat["label"]}" eine Datei hoch '
f"oder geben Sie einen Texteintrag ein."
)
continue
# Datei verarbeiten
if datei:
if datei.size > MAX_DATEIGROESSE:
fehler_liste.append(
f'{kat["label"]}": Datei „{datei.name}" ist zu groß (max. 20 MB).'
)
else:
mime_type, _ = mimetypes.guess_type(datei.name)
if mime_type not in ERLAUBTE_MIME_TYPES:
fehler_liste.append(
f'{kat["label"]}": Dateiformat von „{datei.name}" '
f"nicht erlaubt (PDF, JPG, PNG, TIFF)."
)
else:
try:
dok = DokumentDatei(
titel=f"{kat['label']} {halbjahr_label}: {os.path.splitext(datei.name)[0]}",
beschreibung=f"Hochgeladen über Upload-Portal am {timezone.now().strftime('%d.%m.%Y')}",
kontext=kat["kontext"],
datei=datei,
dateiname_original=datei.name,
dateityp=mime_type or "application/octet-stream",
dateigroesse=datei.size,
destinataer=destinataer,
)
dok.save()
nachweis.nachweis_dokumente.add(dok)
gespeicherte_dokumente.append(dok)
# Kategorie-spezifische FK setzen
if kat["dms_fk_field"]:
setattr(nachweis, kat["dms_fk_field"], dok)
nachweis_update_fields.append(kat["dms_fk_field"])
# Bestätigt-Flag setzen
if kat["bestaetigt_field"]:
setattr(nachweis, kat["bestaetigt_field"], True)
nachweis_update_fields.append(kat["bestaetigt_field"])
except Exception as exc:
logger.exception("Fehler beim Speichern von %s (%s): %s", datei.name, kat["label"], exc)
fehler_liste.append(
f'Fehler beim Speichern von „{datei.name}" ({kat["label"]}).'
)
# Text verarbeiten
if text:
if kat["text_field"]:
setattr(nachweis, kat["text_field"], text)
nachweis_update_fields.append(kat["text_field"])
# Auch bei reinem Text: bestätigt setzen
if not datei and kat["bestaetigt_field"]:
setattr(nachweis, kat["bestaetigt_field"], True)
nachweis_update_fields.append(kat["bestaetigt_field"])
# Bei Pflicht-Fehlern und keinen gespeicherten Dokumenten: Formular erneut anzeigen
if fehler_liste and not gespeicherte_dokumente:
ctx = {**base_context, "fehler": " ".join(fehler_liste)}
# Texte wieder einfüllen
for kat in KATEGORIEN:
ctx[f"{kat['key']}_text"] = request.POST.get(f"{kat['key']}_text", "")
return render(request, "portal/upload_formular.html", ctx)
# Nachweis-Felder speichern
if nachweis_update_fields:
nachweis.save(update_fields=list(set(nachweis_update_fields)))
# Token einlösen
ip = _get_client_ip(request)
upload_token.einloesen(ip_address=ip)
# Nachweis-Status aktualisieren
if nachweis.status in ("offen", "nachbesserung"):
nachweis.status = "eingereicht"
nachweis.eingereicht_am = timezone.now()
nachweis.save(update_fields=["status", "eingereicht_am"])
logger.info(
"Upload-Portal: %d Datei(en) für Destinatär %s (Nachweis %s) gespeichert.",
len(gespeicherte_dokumente),
destinataer.id,
nachweis.id,
)
return redirect("portal:upload_danke", token=token)
@never_cache
def upload_danke(request, token):
"""Bestätigungsseite nach erfolgreichem Upload."""
upload_token = get_object_or_404(
UploadToken.objects.select_related("destinataer", "nachweis"),
token=token,
)
nachweis = upload_token.nachweis
halbjahr = 1 if nachweis.quartal in [1, 2] else 2
halbjahr_label = f"{halbjahr}. Halbjahr {nachweis.jahr}"
return render(
request,
"portal/upload_danke.html",
{
"destinataer": upload_token.destinataer,
"nachweis": nachweis,
"halbjahr_label": halbjahr_label,
},
)
# =============================================================================
# Onboarding-Portal: Mehrstufiges Antragsformular für neue Destinatäre
# =============================================================================
ONBOARDING_SCHRITTE = 5
SESSION_KEY = "onboarding_data"
ERLAUBTE_MIME_TYPES_ONBOARDING = {
"application/pdf",
"image/jpeg",
"image/png",
"image/tiff",
}
MAX_DATEIGROESSE_ONBOARDING = 20 * 1024 * 1024 # 20 MB
def _get_onboarding_einladung(token):
"""Holt und validiert eine OnboardingEinladung anhand des Tokens."""
try:
einladung = OnboardingEinladung.objects.get(token=token)
except OnboardingEinladung.DoesNotExist:
return None, "nicht_gefunden"
if not einladung.ist_gueltig():
if einladung.status == "abgeschlossen":
return None, "bereits_abgeschlossen"
return None, "abgelaufen"
return einladung, None
def _onboarding_fehler(request, fehler_typ):
"""Rendert die Fehlerseite für das Onboarding-Portal."""
return render(
request,
"portal/onboarding_fehler.html",
{"fehler_typ": fehler_typ},
status=410,
)
@never_cache
def onboarding_schritt(request, token, schritt=1):
"""
Mehrstufiges Onboarding-Formular für neue Destinatäre.
Schritt 1-5, sessionbasiert, kein Login erforderlich.
"""
einladung, fehler = _get_onboarding_einladung(token)
if fehler:
return _onboarding_fehler(request, fehler)
schritt = int(schritt)
if schritt < 1 or schritt > ONBOARDING_SCHRITTE:
return redirect("portal:onboarding_schritt", token=token, schritt=1)
session_key = f"{SESSION_KEY}_{token}"
data = request.session.get(session_key, {})
# Navigationspfade: Zurück-Button
if request.method == "POST" and request.POST.get("aktion") == "zurueck" and schritt > 1:
return redirect("portal:onboarding_schritt", token=token, schritt=schritt - 1)
if request.method == "POST":
if schritt == 1:
return _onboarding_schritt1_post(request, token, einladung, data, session_key)
elif schritt == 2:
return _onboarding_schritt2_post(request, token, einladung, data, session_key)
elif schritt == 3:
return _onboarding_schritt3_post(request, token, einladung, data, session_key)
elif schritt == 4:
return _onboarding_schritt4_post(request, token, einladung, data, session_key)
elif schritt == 5:
return _onboarding_schritt5_post(request, token, einladung, data, session_key)
# GET: Formular anzeigen
context = {
"einladung": einladung,
"token": token,
"schritt": schritt,
"schritte_gesamt": ONBOARDING_SCHRITTE,
"data": data,
}
return render(request, f"portal/onboarding_schritt{schritt}.html", context)
def _onboarding_schritt1_post(request, token, einladung, data, session_key):
"""Schritt 1: Datenschutzerklärung + Erklärung des Leistungsempfängers."""
if not request.POST.get("dse_zustimmung"):
context = {
"einladung": einladung,
"token": token,
"schritt": 1,
"schritte_gesamt": ONBOARDING_SCHRITTE,
"data": data,
"fehler": "Bitte stimmen Sie der Datenschutzerklärung zu, um fortzufahren.",
}
return render(request, "portal/onboarding_schritt1.html", context)
if not request.POST.get("merkblatt_zustimmung"):
context = {
"einladung": einladung,
"token": token,
"schritt": 1,
"schritte_gesamt": ONBOARDING_SCHRITTE,
"data": data,
"fehler": "Bitte bestätigen Sie die Erklärung des Leistungsempfängers.",
}
return render(request, "portal/onboarding_schritt1.html", context)
data["schritt1"] = {
"dse_zustimmung": True,
"dse_zeitstempel": timezone.now().isoformat(),
"merkblatt_zustimmung": True,
}
request.session[session_key] = data
return redirect("portal:onboarding_schritt", token=token, schritt=2)
def _onboarding_schritt2_post(request, token, einladung, data, session_key):
"""Schritt 2: Persönliche Daten (Merkblatt 1-4)."""
pflichtfelder = ["vorname", "nachname", "geburtsdatum", "strasse", "plz", "ort",
"email", "telefon", "verwandtschaftsverhaeltnis"]
fehlende = [f for f in pflichtfelder if not request.POST.get(f, "").strip()]
if fehlende:
context = {
"einladung": einladung,
"token": token,
"schritt": 2,
"schritte_gesamt": ONBOARDING_SCHRITTE,
"data": data,
"post_data": request.POST,
"fehler": "Bitte füllen Sie alle Pflichtfelder aus.",
"fehlende_felder": fehlende,
}
return render(request, "portal/onboarding_schritt2.html", context)
data["schritt2"] = {
"vorname": request.POST["vorname"].strip(),
"nachname": request.POST["nachname"].strip(),
"geburtsdatum": request.POST["geburtsdatum"].strip(),
"strasse": request.POST["strasse"].strip(),
"plz": request.POST["plz"].strip(),
"ort": request.POST["ort"].strip(),
"email": request.POST["email"].strip(),
"telefon": request.POST["telefon"].strip(),
"handynummer": request.POST.get("handynummer", "").strip(),
"verwandtschaftsverhaeltnis": request.POST["verwandtschaftsverhaeltnis"].strip(),
"familienzweig": request.POST.get("familienzweig", "").strip(),
}
request.session[session_key] = data
return redirect("portal:onboarding_schritt", token=token, schritt=3)
def _onboarding_schritt3_post(request, token, einladung, data, session_key):
"""Schritt 3: Ausbildung/Studium (Merkblatt 5-6)."""
in_ausbildung = request.POST.get("in_ausbildung") == "ja"
data["schritt3"] = {
"in_ausbildung": in_ausbildung,
"ausbildungsart": request.POST.get("ausbildungsart", "").strip(),
"institution": request.POST.get("institution", "").strip(),
"voraussichtliche_dauer": request.POST.get("voraussichtliche_dauer", "").strip(),
}
request.session[session_key] = data
return redirect("portal:onboarding_schritt", token=token, schritt=4)
def _onboarding_schritt4_post(request, token, einladung, data, session_key):
"""Schritt 4: Finanzielle Situation (Merkblatt 7-12)."""
data["schritt4"] = {
"haushaltstyp": request.POST.get("haushaltstyp", "").strip(),
"haushaltsgroesse": request.POST.get("haushaltsgroesse", "").strip(),
"monatliche_bezuege": request.POST.get("monatliche_bezuege", "").strip(),
"bezuege_art": request.POST.get("bezuege_art", "").strip(),
"unterhalt": request.POST.get("unterhalt", "").strip(),
"miete_heizung": request.POST.get("miete_heizung", "").strip(),
"vermoegen": request.POST.get("vermoegen", "").strip(),
"lebensunterhalt_aufwendungen": request.POST.get("lebensunterhalt_aufwendungen", "").strip(),
}
request.session[session_key] = data
return redirect("portal:onboarding_schritt", token=token, schritt=5)
def _onboarding_schritt5_post(request, token, einladung, data, session_key):
"""Schritt 5: Zusammenfassung, Datei-Upload und Bestätigung."""
if not request.POST.get("finale_bestaetigung"):
context = {
"einladung": einladung,
"token": token,
"schritt": 5,
"schritte_gesamt": ONBOARDING_SCHRITTE,
"data": data,
"fehler": "Bitte bestätigen Sie die Richtigkeit Ihrer Angaben.",
}
return render(request, "portal/onboarding_schritt5.html", context)
# Dateien prüfen und im DMS speichern (werden dem neuen Destinatär zugeordnet)
from stiftung.models import Destinataer, DokumentDatei
schritt2 = data.get("schritt2", {})
schritt3 = data.get("schritt3", {})
# Neuen Destinatär anlegen (unbestätigt 4-Augen-Prinzip)
try:
import datetime
geb_str = schritt2.get("geburtsdatum", "")
geburtsdatum = None
if geb_str:
try:
geburtsdatum = datetime.date.fromisoformat(geb_str)
except ValueError:
pass
destinataer = Destinataer(
vorname=schritt2.get("vorname", ""),
nachname=schritt2.get("nachname", ""),
geburtsdatum=geburtsdatum,
email=schritt2.get("email", ""),
telefon=schritt2.get("telefon", ""),
strasse=schritt2.get("strasse", ""),
plz=schritt2.get("plz", ""),
ort=schritt2.get("ort", ""),
familienzweig=schritt2.get("familienzweig") or "anderer",
unterstuetzung_bestaetigt=False,
aktiv=False, # Erst nach Vorstandsfreigabe aktivieren
)
destinataer.save()
except Exception as exc:
logger.exception("Fehler beim Anlegen des Destinatärs aus Onboarding: %s", exc)
context = {
"einladung": einladung,
"token": token,
"schritt": 5,
"schritte_gesamt": ONBOARDING_SCHRITTE,
"data": data,
"fehler": "Technischer Fehler beim Speichern. Bitte versuchen Sie es erneut.",
}
return render(request, "portal/onboarding_schritt5.html", context)
# Hochgeladene Dokumente im DMS speichern
dms_dokumente_gespeichert = []
for datei_key, datei in request.FILES.items():
if datei.size > MAX_DATEIGROESSE_ONBOARDING:
continue
mime_type, _ = mimetypes.guess_type(datei.name)
if mime_type not in ERLAUBTE_MIME_TYPES_ONBOARDING:
continue
try:
dok = DokumentDatei(
titel=f"Onboarding-Dokument: {os.path.splitext(datei.name)[0]}",
beschreibung=f"Onboarding von {destinataer.vorname} {destinataer.nachname}",
kontext="onboarding",
datei=datei,
dateiname_original=datei.name,
dateityp=mime_type or "application/octet-stream",
dateigroesse=datei.size,
destinataer=destinataer,
)
dok.save()
dms_dokumente_gespeichert.append(dok)
except Exception as exc:
logger.exception("Fehler beim Speichern von Onboarding-Dokument %s: %s", datei.name, exc)
# Einladung als abgeschlossen markieren
einladung.abgeschlossen_am = timezone.now()
einladung.status = "abgeschlossen"
einladung.destinataer = destinataer
einladung.save(update_fields=["abgeschlossen_am", "status", "destinataer"])
# Interne Benachrichtigung: E-Mail an Stiftung
_benachrichtige_stiftung_onboarding(destinataer, einladung, data)
# Session aufräumen
if session_key in request.session:
del request.session[session_key]
logger.info(
"Onboarding abgeschlossen: Destinatär %s angelegt (Einladung %s), %d Dokumente.",
destinataer.id,
einladung.id,
len(dms_dokumente_gespeichert),
)
return redirect("portal:onboarding_danke", token=token)
def _benachrichtige_stiftung_onboarding(destinataer, einladung, data):
"""Sendet eine interne Benachrichtigungs-E-Mail nach Abschluss des Onboardings."""
from django.conf import settings
from django.core.mail import EmailMessage
from stiftung.utils.config import get_config
empfaenger = get_config("notification_email") or getattr(settings, "STIFTUNG_NOTIFICATION_EMAIL", settings.DEFAULT_FROM_EMAIL)
subject = f"Neues Onboarding abgeschlossen: {destinataer.vorname} {destinataer.nachname}"
body = (
f"Ein neues Onboarding-Verfahren wurde abgeschlossen.\n\n"
f"Name: {destinataer.vorname} {destinataer.nachname}\n"
f"E-Mail: {destinataer.email}\n"
f"Einladung: {einladung.id}\n\n"
f"Bitte prüfen und freigeben:\n"
f"{getattr(settings, 'SITE_URL', 'https://vhtv-stiftung.de')}"
f"/destinataere/{destinataer.id}/\n\n"
f"Der Destinatär ist noch NICHT aktiv (unterstuetzung_bestaetigt=False).\n"
f"Freigabe durch den Vorstand erforderlich.\n"
)
try:
from_email = get_config("smtp_from_email") or settings.DEFAULT_FROM_EMAIL
EmailMessage(subject, body, from_email, [empfaenger]).send()
except Exception as exc:
logger.warning("Onboarding-Benachrichtigung konnte nicht gesendet werden: %s", exc)
@never_cache
def onboarding_danke(request, token):
"""Abschlussseite nach erfolgreichem Onboarding."""
try:
einladung = OnboardingEinladung.objects.select_related("destinataer").get(
token=token, status="abgeschlossen"
)
except OnboardingEinladung.DoesNotExist:
return render(
request,
"portal/onboarding_fehler.html",
{"fehler_typ": "nicht_gefunden"},
status=404,
)
return render(
request,
"portal/onboarding_danke.html",
{"einladung": einladung},
)

View File

@@ -1878,7 +1878,50 @@ def email_settings(request):
},
)
imap_settings = AppConfiguration.objects.filter(category="email", is_active=True).order_by("order")
# Ensure SMTP settings exist in DB (auto-init)
smtp_defaults = [
("smtp_host", "SMTP Server", "Hostname des SMTP-Servers (z.B. smtp.ionos.de)", "smtp.ionos.de", "text", 10),
("smtp_port", "SMTP Port", "Port des SMTP-Servers (465 für SSL, 587 für STARTTLS)", "465", "number", 11),
("smtp_user", "SMTP Benutzername", "Benutzername / E-Mail-Adresse für die SMTP-Anmeldung", "", "text", 12),
("smtp_password", "SMTP Passwort", "Passwort für die SMTP-Anmeldung", "", "password", 13),
("smtp_use_ssl", "SSL/TLS verwenden (SMTP)", "Sichere Verbindung zum SMTP-Server (empfohlen für Port 465)", "True", "boolean", 14),
("smtp_from_email", "Absenderadresse", "Absenderadresse für ausgehende E-Mails", "buero@vhtv-stiftung.de", "text", 15),
]
for key, name, desc, default, stype, order in smtp_defaults:
AppConfiguration.objects.get_or_create(
key=key,
defaults={
"display_name": name,
"description": desc,
"value": default,
"default_value": default,
"setting_type": stype,
"category": "email",
"order": order,
},
)
# Ensure notification settings exist in DB (auto-init)
notification_defaults = [
("notification_email", "Benachrichtigungs-E-Mail", "Empfänger für interne Benachrichtigungen (z.B. neue Onboardings). Wenn leer, wird die Absenderadresse verwendet.", "", "text", 20),
]
for key, name, desc, default, stype, order in notification_defaults:
AppConfiguration.objects.get_or_create(
key=key,
defaults={
"display_name": name,
"description": desc,
"value": default,
"default_value": default,
"setting_type": stype,
"category": "email",
"order": order,
},
)
imap_settings = AppConfiguration.objects.filter(category="email", key__startswith="imap_", is_active=True).order_by("order")
smtp_settings = AppConfiguration.objects.filter(category="email", key__startswith="smtp_", is_active=True).order_by("order")
notification_settings = AppConfiguration.objects.filter(category="email", key="notification_email", is_active=True).order_by("order")
test_result = None
@@ -1887,7 +1930,8 @@ def email_settings(request):
if action == "save":
updated = 0
for setting in imap_settings:
all_email_settings = AppConfiguration.objects.filter(category="email", is_active=True)
for setting in all_email_settings:
field_name = f"setting_{setting.key}"
if setting.setting_type == "boolean":
new_val = "True" if field_name in request.POST else "False"
@@ -1954,13 +1998,124 @@ def email_settings(request):
"message": f"Verbindungsfehler: {e}",
}
elif action == "test_smtp":
import smtplib
import ssl as ssl_module
host = get_config("smtp_host")
port = int(get_config("smtp_port", 465))
user = get_config("smtp_user")
password = get_config("smtp_password")
use_ssl = get_config("smtp_use_ssl", True)
if not all([host, user, password]):
test_result = {
"success": False,
"message": "SMTP-Server, Benutzername und Passwort müssen konfiguriert sein.",
"section": "smtp",
}
else:
try:
if use_ssl:
context = ssl_module.create_default_context()
conn = smtplib.SMTP_SSL(host, port, context=context, timeout=15)
else:
conn = smtplib.SMTP(host, port, timeout=15)
conn.starttls()
conn.login(user, password)
conn.quit()
test_result = {
"success": True,
"message": f"SMTP-Verbindung erfolgreich! Angemeldet als {user}.",
"section": "smtp",
}
except smtplib.SMTPAuthenticationError as e:
test_result = {
"success": False,
"message": f"SMTP-Authentifizierungsfehler: {e}",
"section": "smtp",
}
except Exception as e:
test_result = {
"success": False,
"message": f"SMTP-Verbindungsfehler: {e}",
"section": "smtp",
}
elif action == "test_smtp_send":
from django.core.mail import EmailMessage, get_connection
from django.utils import timezone
test_email = request.POST.get("test_email", "").strip()
if not test_email:
test_result = {
"success": False,
"message": "Bitte geben Sie eine Empfänger-E-Mail-Adresse ein.",
"section": "smtp",
}
else:
host = get_config("smtp_host")
port = int(get_config("smtp_port", 465))
user = get_config("smtp_user")
password = get_config("smtp_password")
use_ssl = get_config("smtp_use_ssl", True)
from_email = get_config("smtp_from_email", "buero@vhtv-stiftung.de")
if not all([host, user, password]):
test_result = {
"success": False,
"message": "SMTP-Server, Benutzername und Passwort müssen konfiguriert sein.",
"section": "smtp",
}
else:
try:
connection = get_connection(
backend="django.core.mail.backends.smtp.EmailBackend",
host=host,
port=port,
username=user,
password=password,
use_ssl=bool(use_ssl),
use_tls=False,
fail_silently=False,
)
now = timezone.now().strftime("%d.%m.%Y %H:%M")
msg = EmailMessage(
subject=f"[vHTV-Stiftung] SMTP-Test ({now})",
body=(
f"Dies ist eine Test-E-Mail der Stiftungsverwaltung.\n\n"
f"Zeitpunkt: {now}\n"
f"SMTP-Server: {host}:{port}\n"
f"Absender: {from_email}\n\n"
f"Wenn Sie diese E-Mail erhalten, funktioniert der E-Mail-Versand korrekt."
),
from_email=from_email,
to=[test_email],
connection=connection,
)
msg.send()
test_result = {
"success": True,
"message": f"Test-E-Mail wurde an {test_email} gesendet! Bitte prüfen Sie den Posteingang (und Spam-Ordner).",
"section": "smtp",
}
except Exception as e:
test_result = {
"success": False,
"message": f"E-Mail-Versand fehlgeschlagen: {e}",
"section": "smtp",
}
# Refresh after save
imap_settings = AppConfiguration.objects.filter(category="email", is_active=True).order_by("order")
imap_settings = AppConfiguration.objects.filter(category="email", key__startswith="imap_", is_active=True).order_by("order")
smtp_settings = AppConfiguration.objects.filter(category="email", key__startswith="smtp_", is_active=True).order_by("order")
notification_settings = AppConfiguration.objects.filter(category="email", key="notification_email", is_active=True).order_by("order")
context = {
"imap_settings": imap_settings,
"smtp_settings": smtp_settings,
"notification_settings": notification_settings,
"test_result": test_result,
"title": "E-Mail / IMAP Konfiguration",
"title": "E-Mail-Konfiguration (IMAP & SMTP)",
}
return render(request, "stiftung/email_settings.html", context)

View File

@@ -31,6 +31,7 @@ from django_otp.util import random_hex
from rest_framework.decorators import api_view
from rest_framework.response import Response
from stiftung.audit import log_action
from stiftung.models import (AppConfiguration, AuditLog, BackupJob, BankTransaction,
BriefVorlage, CSVImport, Destinataer,
DestinataerEmailEingang, DestinataerNotiz,
@@ -1696,11 +1697,13 @@ def batch_erinnerung_senden(request):
count = 0
for nachweis in overdue:
try:
AuditLog.objects.create(
user=request.user,
action=f"ERINNERUNG: Nachweis {nachweis.jahr} Q{nachweis.quartal} {nachweis.destinataer.get_full_name()} ({nachweis.destinataer.email})",
model_name="VierteljahresNachweis",
object_id=str(nachweis.id),
log_action(
request,
action="update",
entity_type="destinataer",
entity_id=str(nachweis.id),
entity_name=nachweis.destinataer.get_full_name(),
description=f"ERINNERUNG: Nachweis {nachweis.jahr} Q{nachweis.quartal} {nachweis.destinataer.get_full_name()} ({nachweis.destinataer.email})",
)
count += 1
except Exception:
@@ -1713,6 +1716,101 @@ def batch_erinnerung_senden(request):
return redirect("stiftung:nachweis_board")
@login_required
def nachweis_aufforderung_senden(request, nachweis_pk):
"""
Sendet eine Nachweis-Aufforderungs-E-Mail für einen einzelnen Nachweis.
Erstellt einen UploadToken und versendet den Link per E-Mail an den Destinatär.
POST-only (CSRF-geschützt).
"""
from stiftung.tasks import send_nachweis_aufforderung
if request.method != "POST":
return redirect("stiftung:nachweis_board")
nachweis = get_object_or_404(
VierteljahresNachweis.objects.select_related("destinataer"),
id=nachweis_pk,
)
destinataer = nachweis.destinataer
if not destinataer.email:
messages.error(
request,
f"Destinatär {destinataer.get_full_name()} hat keine E-Mail-Adresse hinterlegt.",
)
return redirect("stiftung:destinataer_detail", pk=destinataer.id)
base_url = request.build_absolute_uri("/").rstrip("/")
send_nachweis_aufforderung.delay(
str(destinataer.id), str(nachweis.id), base_url=base_url
)
log_action(
request,
action="update",
entity_type="destinataer",
entity_id=str(nachweis.id),
entity_name=destinataer.get_full_name(),
description=f"Nachweis-Aufforderung per E-Mail gesendet an {destinataer.get_full_name()} ({destinataer.email}) Nachweis {nachweis.jahr} Q{nachweis.quartal}",
)
messages.success(
request,
f"Nachweis-Aufforderung wird per E-Mail an {destinataer.email} gesendet.",
)
return redirect("stiftung:destinataer_detail", pk=destinataer.id)
@login_required
def batch_nachweis_aufforderung_senden(request):
"""
Batch: Nachweis-Aufforderungen an alle Destinatäre mit offenen Nachweisen versenden.
POST-only. Sendet für jeden offenen Nachweis einen UploadToken per E-Mail.
"""
from stiftung.tasks import send_nachweis_aufforderung
if request.method != "POST":
return redirect("stiftung:nachweis_board")
heute = date.today()
jahr = int(request.POST.get("jahr", heute.year))
offene_nachweise = VierteljahresNachweis.objects.filter(
jahr=jahr,
status__in=["offen", "teilweise", "nachbesserung"],
destinataer__aktiv=True,
).select_related("destinataer")
base_url = request.build_absolute_uri("/").rstrip("/")
count = 0
ohne_email = 0
for nachweis in offene_nachweise:
if not nachweis.destinataer.email:
ohne_email += 1
continue
send_nachweis_aufforderung.delay(
str(nachweis.destinataer.id), str(nachweis.id), base_url=base_url
)
count += 1
log_action(
request,
action="update",
entity_type="system",
entity_id="",
entity_name="Batch-Nachweis-Aufforderung",
description=f"Batch-Nachweis-Aufforderung {jahr}: {count} E-Mails angestoßen, {ohne_email} ohne E-Mail-Adresse.",
)
meldung = f"{count} Nachweis-Aufforderung(en) werden per E-Mail versendet."
if ohne_email:
meldung += f" {ohne_email} Destinatär(e) haben keine E-Mail-Adresse."
messages.success(request, meldung)
return redirect("stiftung:nachweis_board")
@login_required
def zahlungs_pipeline(request):
"""2c: Zahlungs-Pipeline 5-Stufen-Kanban-Ansicht."""
@@ -1935,5 +2033,127 @@ def sepa_xml_export(request):
return response
# =============================================================================
# Phase 5: Onboarding Admin-seitige Verwaltung
# =============================================================================
@login_required
def onboarding_einladung_senden(request):
"""
Erstellt eine OnboardingEinladung und sendet den Einladungslink per E-Mail.
Aufruf: POST /destinataere/onboarding/einladen/
Erwartet: email, vorname (optional), nachname (optional).
"""
import secrets
from datetime import timedelta
from stiftung.models import OnboardingEinladung
from stiftung.tasks import send_onboarding_einladung
if request.method != "POST":
return redirect("stiftung:destinataer_list")
email = request.POST.get("email", "").strip()
if not email:
messages.error(request, "Bitte eine gültige E-Mail-Adresse angeben.")
return redirect("stiftung:destinataer_list")
vorname = request.POST.get("vorname", "").strip()
nachname = request.POST.get("nachname", "").strip()
# Prüfen ob bereits eine offene Einladung für diese E-Mail existiert
bestehend = OnboardingEinladung.objects.filter(
email=email,
status="offen",
gueltig_bis__gt=timezone.now(),
).first()
if bestehend:
messages.warning(
request,
f"Für {email} existiert bereits eine gültige Einladung (bis {bestehend.gueltig_bis.strftime('%d.%m.%Y')}). "
f"Keine neue Einladung erstellt.",
)
return redirect("stiftung:destinataer_list")
token_str = secrets.token_urlsafe(48)
gueltig_bis = timezone.now() + timedelta(days=30)
einladung = OnboardingEinladung.objects.create(
token=token_str,
email=email,
vorname=vorname,
nachname=nachname,
eingeladen_von=request.user,
gueltig_bis=gueltig_bis,
status="offen",
)
base_url = request.build_absolute_uri("/").rstrip("/")
send_onboarding_einladung.delay(str(einladung.id), base_url=base_url)
log_action(
request,
action="create",
entity_type="destinataer",
entity_id=str(einladung.id),
entity_name=email,
description=f"Onboarding-Einladung gesendet an {email}"
+ (f" ({vorname} {nachname})" if vorname or nachname else ""),
)
messages.success(
request,
f"Onboarding-Einladung wurde per E-Mail an {email} gesendet (gültig bis {gueltig_bis.strftime('%d.%m.%Y')}).",
)
return redirect("stiftung:onboarding_einladung_liste")
@login_required
def onboarding_einladung_liste(request):
"""Übersicht aller Onboarding-Einladungen."""
from stiftung.models import OnboardingEinladung
einladungen = OnboardingEinladung.objects.select_related(
"eingeladen_von", "destinataer"
).order_by("-erstellt_am")
return render(
request,
"stiftung/onboarding_einladung_liste.html",
{"einladungen": einladungen},
)
@login_required
def onboarding_einladung_widerrufen(request, pk):
"""Widerruft eine offene Onboarding-Einladung."""
from stiftung.models import OnboardingEinladung
einladung = get_object_or_404(OnboardingEinladung, id=pk)
if request.method == "POST":
if einladung.status == "offen":
einladung.status = "widerrufen"
einladung.save(update_fields=["status"])
log_action(
request,
action="update",
entity_type="destinataer",
entity_id=str(einladung.id),
entity_name=einladung.email,
description=f"Onboarding-Einladung für {einladung.email} widerrufen",
)
messages.success(request, f"Einladung für {einladung.email} wurde widerrufen.")
else:
messages.error(request, "Diese Einladung kann nicht mehr widerrufen werden.")
return redirect("stiftung:onboarding_einladung_liste")
return render(
request,
"stiftung/onboarding_einladung_widerrufen_bestaetigung.html",
{"einladung": einladung},
)
# Two-Factor Authentication Views

View File

@@ -84,13 +84,13 @@ def veranstaltung_detail(request, pk):
def veranstaltung_serienbrief_pdf(request, pk):
"""Generiert Serienbrief-PDF für alle Teilnehmer einer Veranstaltung"""
from weasyprint import HTML
from django.template.loader import render_to_string
from stiftung.utils.vorlagen import render_vorlage
veranstaltung = get_object_or_404(Veranstaltung, pk=pk)
teilnehmer = veranstaltung.teilnehmer.all().order_by("nachname", "vorname")
# Render HTML for all letters
html_string = render_to_string(
# Render HTML for all letters (DB-Vorlage first, file fallback)
html_string = render_vorlage(
"stiftung/veranstaltung/serienbrief_pdf.html",
{
"veranstaltung": veranstaltung,

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