""" 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// → Formular anzeigen 3. POST /portal/upload// → Dateien hochladen, Token einlösen 4. Redirect → /portal/upload//danke/ Workflow Onboarding-Portal (neue Destinatäre): 1. Verwaltung sendet OnboardingEinladung per E-Mail 2. GET/POST /portal/onboarding//schritt// → 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}, )