Files
SysAdmin Agent 4d751d861d
Some checks failed
CI/CD Pipeline / test (push) Has been cancelled
Code Quality / quality (push) Has been cancelled
CI/CD Pipeline / deploy (push) Has been cancelled
DSGVO-Compliance: Einwilligung, Datenschutzerklärung & Consent-Logging im Upload-Portal (STI-89)
- Datenschutzerklärung unter /portal/datenschutz/ öffentlich erreichbar
- Link zur Datenschutzerklärung in Nachweis-Aufforderungs-E-Mails (HTML + TXT)
- Einwilligungs-Checkbox vor Upload mit Server-Side-Validierung
- Consent-Logging: einwilligung_erteilt_am auf UploadToken (Art. 7 Abs. 1 DSGVO)
- Regelsatz-Korrektur: 449€→563€ in Onboarding-Template (Stand 01/2024)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-21 22:43:01 +00:00

625 lines
24 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""
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
def datenschutzerklaerung(request):
"""Datenschutzerklärung für das öffentliche Portal."""
return render(request, "portal/datenschutzerklaerung.html")
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: Einwilligung prüfen
einwilligung = request.POST.get("einwilligung")
if not einwilligung:
ctx = {
**base_context,
"einwilligung_fehler": "Bitte erteilen Sie Ihre Einwilligung zur Datenverarbeitung, um fortzufahren.",
}
for kat in [
"studiennachweis", "einkommenssituation", "vermogenssituation", "weitere_dokumente"
]:
ctx[f"{kat}_text"] = request.POST.get(f"{kat}_text", "")
return render(request, "portal/upload_formular.html", ctx)
# 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)))
# DSGVO-Einwilligung protokollieren (Art. 7 Abs. 1 DSGVO)
upload_token.einwilligung_erteilt_am = timezone.now()
upload_token.save(update_fields=["einwilligung_erteilt_am"])
# 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},
)