- 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>
603 lines
23 KiB
Python
603 lines
23 KiB
Python
"""
|
||
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},
|
||
)
|