diff --git a/app/core/settings.py b/app/core/settings.py index d55b098..a45dfaa 100644 --- a/app/core/settings.py +++ b/app/core/settings.py @@ -118,8 +118,11 @@ MEDIA_URL = "/media/" MEDIA_ROOT = BASE_DIR / "media" # Celery -CELERY_BROKER_URL = os.getenv("REDIS_URL", "redis://redis:6379/0") -CELERY_RESULT_BACKEND = os.getenv("REDIS_URL", "redis://redis:6379/0") +CELERY_BROKER_URL = os.getenv("CELERY_REDIS_URL", os.getenv("REDIS_URL", "redis://redis:6379/2")) +CELERY_RESULT_BACKEND = os.getenv("CELERY_REDIS_URL", os.getenv("REDIS_URL", "redis://redis:6379/2")) +CELERY_TASK_SERIALIZER = "json" +CELERY_RESULT_SERIALIZER = "json" +CELERY_ACCEPT_CONTENT = ["json"] # Celery Beat – periodische Tasks from celery.schedules import crontab # noqa: E402 @@ -130,6 +133,11 @@ CELERY_BEAT_SCHEDULE = { "task": "stiftung.tasks.poll_emails", "schedule": crontab(minute="*/15"), }, + # Täglich um 08:00 Uhr: Ablaufende Upload-Tokens prüfen und Erinnerungen versenden + "check-ablaufende-tokens": { + "task": "stiftung.tasks.check_ablaufende_tokens", + "schedule": crontab(hour="8", minute="0"), + }, } # IMAP-Konfiguration für E-Mail-Eingang (Destinatäre) @@ -141,6 +149,16 @@ IMAP_PASSWORD = os.getenv("IMAP_PASSWORD", "") IMAP_FOLDER = os.getenv("IMAP_FOLDER", "INBOX") IMAP_USE_SSL = os.getenv("IMAP_USE_SSL", "true").lower() == "true" +# SMTP-Konfiguration für E-Mail-Ausgang (Nachweis-Aufforderungen, Einladungen) +# Pflichtfelder: EMAIL_HOST_USER, EMAIL_HOST_PASSWORD +EMAIL_HOST = os.getenv("EMAIL_HOST", "smtp.ionos.de") +EMAIL_PORT = int(os.getenv("EMAIL_PORT") or "465") +EMAIL_USE_SSL = os.getenv("EMAIL_USE_SSL", "true").lower() == "true" +EMAIL_HOST_USER = os.getenv("EMAIL_HOST_USER", "") +EMAIL_HOST_PASSWORD = os.getenv("EMAIL_HOST_PASSWORD", "") +DEFAULT_FROM_EMAIL = os.getenv("DEFAULT_FROM_EMAIL", "stiftung@vhtv-stiftung.de") +EMAIL_SUBJECT_PREFIX = "[vHTV-Stiftung] " + # Authentication LOGIN_URL = "/login/" diff --git a/app/core/urls.py b/app/core/urls.py index 1e8c316..659a0b0 100644 --- a/app/core/urls.py +++ b/app/core/urls.py @@ -8,6 +8,8 @@ from stiftung.views import home urlpatterns = [ path("api/v1/", include("stiftung.api_urls")), + # Öffentliches Portal (kein Login erforderlich – tokenbasiert) + path("portal/", include("stiftung.portal_urls")), path("", include("stiftung.urls")), path("admin/", admin.site.urls), # Authentication URLs diff --git a/app/stiftung/management/commands/import_veranstaltung_teilnehmer.py b/app/stiftung/management/commands/import_veranstaltung_teilnehmer.py new file mode 100644 index 0000000..d722c40 --- /dev/null +++ b/app/stiftung/management/commands/import_veranstaltung_teilnehmer.py @@ -0,0 +1,82 @@ +""" +Management command to import participants into a Veranstaltung. + +Usage: + python manage.py import_veranstaltung_teilnehmer +""" +from django.core.management.base import BaseCommand + +from stiftung.models import Veranstaltung, Veranstaltungsteilnehmer + + +TEILNEHMER_DATA = [ + {"anrede": "Herr", "vorname": "Stephan", "nachname": "Bohnekamp", "strasse": "Marienthaler Strasse 44", "plz": "46569", "ort": "Hünxe-Drevenack"}, + {"anrede": "Frau", "vorname": "Maike", "nachname": "Buchmann-Bender", "strasse": "Am Wehagen 6", "plz": "46485", "ort": "Wesel"}, + {"anrede": "Herr", "vorname": "Edmund", "nachname": "Eichelberg", "strasse": "Schwarzensteiner Weg 75", "plz": "46569", "ort": "Hünxe-Drevenack"}, + {"anrede": "Herr", "vorname": "Walter", "nachname": "Buchmann-Bender", "strasse": "Büskesheide 11", "plz": "46499", "ort": "Hamminkeln"}, + {"anrede": "Herr", "vorname": "Gerold", "nachname": "Hurtienne", "strasse": "Birkenweg 14", "plz": "46569", "ort": "Hünxe-Drevenack"}, + {"anrede": "Frau", "vorname": "Katrin", "nachname": "Kleinpaß", "strasse": "Raesfelder Strasse 3", "plz": "46499", "ort": "Hamminkeln"}, + {"anrede": "Frau", "vorname": "Zoe", "nachname": "Kleinpaß", "strasse": "Raesfelder Strasse 3", "plz": "46499", "ort": "Hamminkeln"}, + {"anrede": "Frau", "vorname": "Nele", "nachname": "Schmäh", "strasse": "Raesfelder Strasse 3", "plz": "46499", "ort": "Hamminkeln"}, + {"anrede": "Frau", "vorname": "Susanne", "nachname": "Menz", "strasse": "Zum Weissenstein 7 a", "plz": "46499", "ort": "Hamminkeln"}, + {"anrede": "Herr", "vorname": "Jan Remmer", "nachname": "Siebels", "strasse": "Holthauser Feld 7", "plz": "49716", "ort": "Meppen"}, + {"anrede": "Frau", "vorname": "Annette", "nachname": "von der Höh", "strasse": "Fehmarnstrasse 53", "plz": "33729", "ort": "Bielefeld"}, + {"anrede": "Herr", "vorname": "Hartmut", "nachname": "Küppers", "strasse": "Jöhrenstr. 10", "plz": "30559", "ort": "Hannover"}, + {"anrede": "Frau", "vorname": "Ruth", "nachname": "Höhne", "strasse": "Löwenburgstr. 127", "plz": "53229", "ort": "Bonn-Niederholtorf"}, + {"anrede": "Herr", "vorname": "Aleph", "nachname": "Freese", "strasse": "Christoph Str. 50", "plz": "40225", "ort": "Düsseldorf"}, + {"anrede": "Herr", "vorname": "Patrik", "nachname": "Schüngel", "strasse": "Im Sand 11a", "plz": "47608", "ort": "Geldern- Walbeck"}, + {"anrede": "Frau", "vorname": "Christiane", "nachname": "Siebels", "strasse": "Rudolf Kinau Strasse 10", "plz": "49716", "ort": "Meppen"}, +] + + +class Command(BaseCommand): + help = "Importiert Teilnehmer in eine Veranstaltung" + + def add_arguments(self, parser): + parser.add_argument("veranstaltung_id", type=str, help="UUID der Veranstaltung") + parser.add_argument("--dry-run", action="store_true", help="Nur anzeigen, nicht importieren") + + def handle(self, *args, **options): + vid = options["veranstaltung_id"] + dry_run = options["dry_run"] + + try: + veranstaltung = Veranstaltung.objects.get(id=vid) + except Veranstaltung.DoesNotExist: + self.stderr.write(self.style.ERROR(f"Veranstaltung {vid} nicht gefunden")) + return + + self.stdout.write(f"Veranstaltung: {veranstaltung}") + self.stdout.write(f"Teilnehmer zu importieren: {len(TEILNEHMER_DATA)}") + + if dry_run: + for t in TEILNEHMER_DATA: + self.stdout.write(f" [DRY] {t['anrede']} {t['vorname']} {t['nachname']}") + return + + created = 0 + for t in TEILNEHMER_DATA: + # Check for duplicates + exists = Veranstaltungsteilnehmer.objects.filter( + veranstaltung=veranstaltung, + vorname=t["vorname"], + nachname=t["nachname"], + ).exists() + if exists: + self.stdout.write(self.style.WARNING(f" SKIP (exists): {t['vorname']} {t['nachname']}")) + continue + + Veranstaltungsteilnehmer.objects.create( + veranstaltung=veranstaltung, + anrede=t["anrede"], + vorname=t["vorname"], + nachname=t["nachname"], + strasse=t["strasse"], + plz=t["plz"], + ort=t["ort"], + rsvp_status="eingeladen", + ) + created += 1 + self.stdout.write(self.style.SUCCESS(f" OK: {t['vorname']} {t['nachname']}")) + + self.stdout.write(self.style.SUCCESS(f"\n{created} Teilnehmer importiert.")) diff --git a/app/stiftung/management/commands/init_config.py b/app/stiftung/management/commands/init_config.py index 1ec6ddc..e572c76 100644 --- a/app/stiftung/management/commands/init_config.py +++ b/app/stiftung/management/commands/init_config.py @@ -69,6 +69,67 @@ class Command(BaseCommand): "category": "email", "order": 6, }, + # SMTP Settings + { + "key": "smtp_host", + "display_name": "SMTP Server", + "description": "Hostname des SMTP-Servers (z.B. smtp.ionos.de)", + "value": "smtp.ionos.de", + "default_value": "smtp.ionos.de", + "setting_type": "text", + "category": "email", + "order": 10, + }, + { + "key": "smtp_port", + "display_name": "SMTP Port", + "description": "Port des SMTP-Servers (465 für SSL, 587 für STARTTLS)", + "value": "465", + "default_value": "465", + "setting_type": "number", + "category": "email", + "order": 11, + }, + { + "key": "smtp_user", + "display_name": "SMTP Benutzername", + "description": "Benutzername / E-Mail-Adresse für die SMTP-Anmeldung", + "value": "", + "default_value": "", + "setting_type": "text", + "category": "email", + "order": 12, + }, + { + "key": "smtp_password", + "display_name": "SMTP Passwort", + "description": "Passwort für die SMTP-Anmeldung", + "value": "", + "default_value": "", + "setting_type": "password", + "category": "email", + "order": 13, + }, + { + "key": "smtp_use_ssl", + "display_name": "SSL/TLS verwenden (SMTP)", + "description": "Sichere Verbindung zum SMTP-Server (empfohlen für Port 465)", + "value": "True", + "default_value": "True", + "setting_type": "boolean", + "category": "email", + "order": 14, + }, + { + "key": "smtp_from_email", + "display_name": "Absenderadresse (SMTP)", + "description": "Absenderadresse für ausgehende E-Mails (z.B. buero@vhtv-stiftung.de)", + "value": "buero@vhtv-stiftung.de", + "default_value": "buero@vhtv-stiftung.de", + "setting_type": "text", + "category": "email", + "order": 15, + }, ] all_settings = email_settings diff --git a/app/stiftung/management/commands/restore_vorlagen.py b/app/stiftung/management/commands/restore_vorlagen.py new file mode 100644 index 0000000..84913d6 --- /dev/null +++ b/app/stiftung/management/commands/restore_vorlagen.py @@ -0,0 +1,50 @@ +"""Management-Command: Stellt alle DokumentVorlage-Einträge aus den Originaldateien wieder her.""" + +from django.core.management.base import BaseCommand + +from stiftung.models import DokumentVorlage +from stiftung.utils.vorlagen import get_vorlage_original + + +class Command(BaseCommand): + help = "Stellt alle DokumentVorlage-Einträge aus den Original-Dateien wieder her." + + def add_arguments(self, parser): + parser.add_argument( + "--dry-run", + action="store_true", + help="Zeigt nur an, was geändert würde, ohne tatsächlich zu ändern.", + ) + + def handle(self, *args, **options): + dry_run = options["dry_run"] + vorlagen = DokumentVorlage.objects.all() + + if not vorlagen.exists(): + self.stdout.write(self.style.WARNING("Keine DokumentVorlage-Einträge gefunden.")) + return + + restored = 0 + skipped = 0 + for vorlage in vorlagen: + try: + original = get_vorlage_original(vorlage.schluessel) + except FileNotFoundError: + self.stdout.write( + self.style.WARNING(f" SKIP: {vorlage.schluessel} — Original-Datei nicht gefunden") + ) + skipped += 1 + continue + + if dry_run: + self.stdout.write(f" WÜRDE WIEDERHERSTELLEN: {vorlage.schluessel}") + else: + vorlage.html_inhalt = original + vorlage.save(update_fields=["html_inhalt", "zuletzt_bearbeitet_am"]) + self.stdout.write(self.style.SUCCESS(f" OK: {vorlage.schluessel}")) + restored += 1 + + action = "würden wiederhergestellt" if dry_run else "wiederhergestellt" + self.stdout.write( + self.style.SUCCESS(f"\n{restored} Vorlagen {action}, {skipped} übersprungen.") + ) diff --git a/app/stiftung/migrations/0060_portal_upload_token_onboarding.py b/app/stiftung/migrations/0060_portal_upload_token_onboarding.py new file mode 100644 index 0000000..4631cff --- /dev/null +++ b/app/stiftung/migrations/0060_portal_upload_token_onboarding.py @@ -0,0 +1,59 @@ +# Generated by Django 5.0.6 on 2026-03-15 23:02 + +import django.db.models.deletion +import uuid +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('stiftung', '0059_nachweis_kategorie_dms_felder'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='OnboardingEinladung', + fields=[ + ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('token', models.CharField(db_index=True, max_length=128, unique=True, verbose_name='Token')), + ('email', models.EmailField(max_length=254, verbose_name='E-Mail-Adresse des Eingeladenen')), + ('vorname', models.CharField(blank=True, max_length=100, verbose_name='Vorname (optional)')), + ('nachname', models.CharField(blank=True, max_length=100, verbose_name='Nachname (optional)')), + ('gueltig_bis', models.DateTimeField(verbose_name='Gültig bis')), + ('erstellt_am', models.DateTimeField(auto_now_add=True, verbose_name='Erstellt am')), + ('abgeschlossen_am', models.DateTimeField(blank=True, null=True, verbose_name='Abgeschlossen am')), + ('status', models.CharField(choices=[('offen', 'Offen'), ('abgeschlossen', 'Abgeschlossen'), ('abgelaufen', 'Abgelaufen'), ('widerrufen', 'Widerrufen')], default='offen', max_length=20, verbose_name='Status')), + ('notizen', models.TextField(blank=True, verbose_name='Interne Notizen')), + ('destinataer', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='onboarding_einladung', to='stiftung.destinataer', verbose_name='Resultierender Destinatär')), + ('eingeladen_von', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='onboarding_einladungen', to=settings.AUTH_USER_MODEL, verbose_name='Eingeladen von')), + ], + options={ + 'verbose_name': 'Onboarding-Einladung', + 'verbose_name_plural': 'Onboarding-Einladungen', + 'ordering': ['-erstellt_am'], + }, + ), + migrations.CreateModel( + name='UploadToken', + fields=[ + ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('token', models.CharField(db_index=True, max_length=128, unique=True, verbose_name='Token')), + ('gueltig_bis', models.DateTimeField(verbose_name='Gültig bis')), + ('erstellt_am', models.DateTimeField(auto_now_add=True, verbose_name='Erstellt am')), + ('eingeloest_am', models.DateTimeField(blank=True, null=True, verbose_name='Eingelöst am')), + ('ist_aktiv', models.BooleanField(default=True, verbose_name='Aktiv')), + ('ip_hash', models.CharField(blank=True, max_length=64, null=True, verbose_name='IP-Hash (SHA-256)')), + ('erinnerung_gesendet', models.BooleanField(default=False, verbose_name='Erinnerung gesendet')), + ('destinataer', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='upload_tokens', to='stiftung.destinataer', verbose_name='Destinatär')), + ('nachweis', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='upload_tokens', to='stiftung.vierteljahresnachweis', verbose_name='Nachweis')), + ], + options={ + 'verbose_name': 'Upload-Token', + 'verbose_name_plural': 'Upload-Token', + 'ordering': ['-erstellt_am'], + }, + ), + ] diff --git a/app/stiftung/migrations/0061_dokument_vorlage.py b/app/stiftung/migrations/0061_dokument_vorlage.py new file mode 100644 index 0000000..3d4ca2a --- /dev/null +++ b/app/stiftung/migrations/0061_dokument_vorlage.py @@ -0,0 +1,160 @@ +import uuid + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +def seed_vorlagen(apps, schema_editor): + """Seed initial DokumentVorlage records from file templates.""" + import os + + from django.template.loader import get_template + from django.template import TemplateDoesNotExist + + DokumentVorlage = apps.get_model("stiftung", "DokumentVorlage") + + # Map: (schluessel, bezeichnung, kategorie, variablen) + vorlagen_def = [ + ( + "pdf/bestaetigung.html", + "Bestätigung PDF", + "pdf", + { + "destinataer.vorname": "Vorname", + "destinataer.nachname": "Nachname", + "destinataer.anrede": "Anrede (Herr/Frau)", + "destinataer.strasse": "Straße", + "destinataer.plz": "PLZ", + "destinataer.ort": "Ort", + "betrag_quartal": "Betrag pro Quartal", + "betrag_jaehrlich": "Jährlicher Betrag", + "zeitraum": "Förderzeitraum", + "zweck": "Förderzweck", + "unterstuetzungen": "Liste der Unterstützungen", + "gesamtbetrag": "Gesamtbetrag", + "datum": "Datum der Erstellung", + }, + ), + ( + "email/bestaetigung.html", + "Bestätigung E-Mail (HTML)", + "email", + { + "destinataer.vorname": "Vorname", + "destinataer.nachname": "Nachname", + "destinataer.anrede": "Anrede", + "zeitraum": "Förderzeitraum", + "gesamtbetrag": "Gesamtbetrag", + "datum": "Datum", + }, + ), + ( + "email/nachweis_aufforderung.html", + "Nachweis-Aufforderung E-Mail (HTML)", + "email", + { + "destinataer.vorname": "Vorname", + "destinataer.nachname": "Nachname", + "halbjahr_label": "Halbjahr-Bezeichnung", + "upload_url": "Upload-URL", + "gueltig_bis": "Gültig bis", + "qr_code_base64": "QR-Code (base64)", + "ist_erinnerung": "True wenn Erinnerung", + }, + ), + ( + "email/nachweis_aufforderung.txt", + "Nachweis-Aufforderung E-Mail (Text)", + "email", + { + "destinataer.vorname": "Vorname", + "destinataer.nachname": "Nachname", + "halbjahr_label": "Halbjahr-Bezeichnung", + "upload_url": "Upload-URL", + "gueltig_bis": "Gültig bis", + "ist_erinnerung": "True wenn Erinnerung", + }, + ), + ( + "email/onboarding_einladung.html", + "Onboarding-Einladung E-Mail (HTML)", + "email", + { + "einladung.vorname": "Vorname", + "einladung.nachname": "Nachname", + "onboarding_url": "Onboarding-URL", + "gueltig_bis": "Gültig bis", + }, + ), + ( + "email/onboarding_einladung.txt", + "Onboarding-Einladung E-Mail (Text)", + "email", + { + "einladung.vorname": "Vorname", + "einladung.nachname": "Nachname", + "onboarding_url": "Onboarding-URL", + "gueltig_bis": "Gültig bis", + }, + ), + ] + + templates_dir = os.path.join(settings.BASE_DIR, "templates") + + for schluessel, bezeichnung, kategorie, variablen in vorlagen_def: + template_path = os.path.join(templates_dir, schluessel) + if os.path.exists(template_path): + with open(template_path, "r", encoding="utf-8") as f: + html_inhalt = f.read() + DokumentVorlage.objects.get_or_create( + schluessel=schluessel, + defaults={ + "bezeichnung": bezeichnung, + "kategorie": kategorie, + "html_inhalt": html_inhalt, + "verfuegbare_variablen": variablen, + }, + ) + + +class Migration(migrations.Migration): + + dependencies = [ + ("stiftung", "0060_portal_upload_token_onboarding"), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name="DokumentVorlage", + fields=[ + ("id", models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ("schluessel", models.CharField(help_text="Interner Template-Pfad, z.B. pdf/bestaetigung.html", max_length=200, unique=True, verbose_name="Schlüssel")), + ("bezeichnung", models.CharField(max_length=200, verbose_name="Bezeichnung")), + ("kategorie", models.CharField( + choices=[("pdf", "PDF-Dokument"), ("email", "E-Mail"), ("bericht", "Bericht"), ("serienbrief", "Serienbrief")], + max_length=30, + verbose_name="Kategorie", + )), + ("html_inhalt", models.TextField(verbose_name="HTML-Inhalt")), + ("verfuegbare_variablen", models.JSONField(blank=True, default=dict, help_text="JSON-Dokumentation der verfügbaren Template-Variablen", verbose_name="Verfügbare Variablen")), + ("zuletzt_bearbeitet_am", models.DateTimeField(auto_now=True, verbose_name="Zuletzt bearbeitet")), + ("erstellt_am", models.DateTimeField(auto_now_add=True, verbose_name="Erstellt am")), + ("zuletzt_bearbeitet_von", models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="bearbeitete_vorlagen", + to=settings.AUTH_USER_MODEL, + verbose_name="Zuletzt bearbeitet von", + )), + ], + options={ + "verbose_name": "Dokument-Vorlage", + "verbose_name_plural": "Dokument-Vorlagen", + "ordering": ["kategorie", "bezeichnung"], + }, + ), + migrations.RunPython(seed_vorlagen, migrations.RunPython.noop), + ] diff --git a/app/stiftung/migrations/0062_veranstaltungseinladung_vorlage.py b/app/stiftung/migrations/0062_veranstaltungseinladung_vorlage.py new file mode 100644 index 0000000..09e1157 --- /dev/null +++ b/app/stiftung/migrations/0062_veranstaltungseinladung_vorlage.py @@ -0,0 +1,57 @@ +"""Seed Veranstaltungseinladung (Serienbrief) into DokumentVorlage.""" + +import os + +from django.conf import settings +from django.db import migrations + + +def seed_veranstaltungseinladung(apps, schema_editor): + DokumentVorlage = apps.get_model("stiftung", "DokumentVorlage") + + schluessel = "stiftung/veranstaltung/serienbrief_pdf.html" + template_path = os.path.join(settings.BASE_DIR, "templates", schluessel) + + if os.path.exists(template_path): + with open(template_path, "r", encoding="utf-8") as f: + html_inhalt = f.read() + + DokumentVorlage.objects.get_or_create( + schluessel=schluessel, + defaults={ + "bezeichnung": "Veranstaltungseinladung (Serienbrief)", + "kategorie": "serienbrief", + "html_inhalt": html_inhalt, + "verfuegbare_variablen": { + "veranstaltung.titel": "Titel der Veranstaltung", + "veranstaltung.datum": "Datum der Veranstaltung", + "veranstaltung.uhrzeit": "Uhrzeit", + "veranstaltung.ort": "Ort / Gasthaus", + "veranstaltung.adresse": "Adresse des Veranstaltungsorts", + "veranstaltung.betreff": "Betreffzeile (optional)", + "veranstaltung.briefvorlage": "Freier Brieftext (HTML, optional)", + "veranstaltung.unterschrift_1_name": "Name Unterschrift 1", + "veranstaltung.unterschrift_1_titel": "Titel Unterschrift 1", + "veranstaltung.unterschrift_2_name": "Name Unterschrift 2", + "veranstaltung.unterschrift_2_titel": "Titel Unterschrift 2", + "teilnehmer": "Liste der Teilnehmer (for-Schleife)", + "t.anrede": "Anrede des Teilnehmers (in Schleife)", + "t.vorname": "Vorname des Teilnehmers", + "t.nachname": "Nachname des Teilnehmers", + "t.strasse": "Straße des Teilnehmers", + "t.plz": "PLZ des Teilnehmers", + "t.ort": "Ort des Teilnehmers", + }, + }, + ) + + +class Migration(migrations.Migration): + + dependencies = [ + ("stiftung", "0061_dokument_vorlage"), + ] + + operations = [ + migrations.RunPython(seed_veranstaltungseinladung, migrations.RunPython.noop), + ] diff --git a/app/stiftung/models/__init__.py b/app/stiftung/models/__init__.py index 5066b61..1c9659b 100644 --- a/app/stiftung/models/__init__.py +++ b/app/stiftung/models/__init__.py @@ -36,8 +36,10 @@ from .destinataere import ( # noqa: F401 DestinataerNotiz, DestinataerUnterstuetzung, Foerderung, + OnboardingEinladung, Person, UnterstuetzungWiederkehrend, + UploadToken, VierteljahresNachweis, ) @@ -52,3 +54,7 @@ from .veranstaltungen import ( # noqa: F401 Veranstaltung, Veranstaltungsteilnehmer, ) + +from .vorlagen import ( # noqa: F401 + DokumentVorlage, +) diff --git a/app/stiftung/models/destinataere.py b/app/stiftung/models/destinataere.py index 7b1de5c..6f02f9b 100644 --- a/app/stiftung/models/destinataere.py +++ b/app/stiftung/models/destinataere.py @@ -1307,3 +1307,149 @@ class EmailEingang(models.Model): # Backward-compatible alias DestinataerEmailEingang = EmailEingang + + +class UploadToken(models.Model): + """ + Einmaliger Upload-Token für tokenbasiertes Nachweis-Upload-Portal. + + Ermöglicht Destinatären den Dokumenten-Upload ohne Nutzerkonto. + Der Token wird per E-Mail (mit QR-Code) versendet und ist 30 Tage gültig. + Nach einmaliger Nutzung (Upload) wird eingeloest_am gesetzt. + Die IP-Adresse wird nur als SHA-256-Hash gespeichert (DSGVO-konform). + """ + + id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) + token = models.CharField( + max_length=128, + unique=True, + db_index=True, + verbose_name="Token", + ) + destinataer = models.ForeignKey( + "Destinataer", + on_delete=models.CASCADE, + related_name="upload_tokens", + verbose_name="Destinatär", + ) + nachweis = models.ForeignKey( + "VierteljahresNachweis", + on_delete=models.CASCADE, + related_name="upload_tokens", + verbose_name="Nachweis", + ) + gueltig_bis = models.DateTimeField(verbose_name="Gültig bis") + erstellt_am = models.DateTimeField(auto_now_add=True, verbose_name="Erstellt am") + eingeloest_am = models.DateTimeField( + null=True, blank=True, verbose_name="Eingelöst am" + ) + ist_aktiv = models.BooleanField(default=True, verbose_name="Aktiv") + ip_hash = models.CharField( + max_length=64, blank=True, null=True, verbose_name="IP-Hash (SHA-256)" + ) + erinnerung_gesendet = models.BooleanField( + default=False, verbose_name="Erinnerung gesendet" + ) + + class Meta: + verbose_name = "Upload-Token" + verbose_name_plural = "Upload-Token" + ordering = ["-erstellt_am"] + + def __str__(self): + return f"Token für {self.destinataer} ({self.nachweis})" + + def ist_gueltig(self): + """Prüft ob der Token noch gültig und aktiv ist.""" + from django.utils import timezone + return ( + self.ist_aktiv + and self.eingeloest_am is None + and self.gueltig_bis > timezone.now() + ) + + def einloesen(self, ip_address=None): + """Markiert den Token als eingelöst. IP wird als Hash gespeichert.""" + import hashlib + from django.utils import timezone + self.eingeloest_am = timezone.now() + self.ist_aktiv = False + if ip_address: + self.ip_hash = hashlib.sha256(ip_address.encode()).hexdigest() + self.save(update_fields=["eingeloest_am", "ist_aktiv", "ip_hash"]) + + +class OnboardingEinladung(models.Model): + """ + Einladung zum Onboarding für neue Destinatäre. + + Verwaltungsmitarbeiter versenden eine Einladungs-E-Mail. + Der Eingeladene füllt das mehrstufige Onboarding-Formular aus. + Nach Abschluss wird ein neuer Destinatär mit unterstuetzung_bestaetigt=False angelegt. + """ + + STATUS_CHOICES = [ + ("offen", "Offen"), + ("abgeschlossen", "Abgeschlossen"), + ("abgelaufen", "Abgelaufen"), + ("widerrufen", "Widerrufen"), + ] + + id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) + token = models.CharField( + max_length=128, + unique=True, + db_index=True, + verbose_name="Token", + ) + email = models.EmailField(verbose_name="E-Mail-Adresse des Eingeladenen") + vorname = models.CharField( + max_length=100, blank=True, verbose_name="Vorname (optional)" + ) + nachname = models.CharField( + max_length=100, blank=True, verbose_name="Nachname (optional)" + ) + eingeladen_von = models.ForeignKey( + "auth.User", + on_delete=models.SET_NULL, + null=True, + blank=True, + related_name="onboarding_einladungen", + verbose_name="Eingeladen von", + ) + gueltig_bis = models.DateTimeField(verbose_name="Gültig bis") + erstellt_am = models.DateTimeField(auto_now_add=True, verbose_name="Erstellt am") + abgeschlossen_am = models.DateTimeField( + null=True, blank=True, verbose_name="Abgeschlossen am" + ) + destinataer = models.ForeignKey( + "Destinataer", + on_delete=models.SET_NULL, + null=True, + blank=True, + related_name="onboarding_einladung", + verbose_name="Resultierender Destinatär", + ) + status = models.CharField( + max_length=20, + choices=STATUS_CHOICES, + default="offen", + verbose_name="Status", + ) + notizen = models.TextField(blank=True, verbose_name="Interne Notizen") + + class Meta: + verbose_name = "Onboarding-Einladung" + verbose_name_plural = "Onboarding-Einladungen" + ordering = ["-erstellt_am"] + + def __str__(self): + return f"Einladung für {self.email} ({self.get_status_display()})" + + def ist_gueltig(self): + """Prüft ob die Einladung noch gültig ist.""" + from django.utils import timezone + return ( + self.status == "offen" + and self.gueltig_bis > timezone.now() + ) diff --git a/app/stiftung/models/vorlagen.py b/app/stiftung/models/vorlagen.py new file mode 100644 index 0000000..b6975f2 --- /dev/null +++ b/app/stiftung/models/vorlagen.py @@ -0,0 +1,54 @@ +import uuid + +from django.contrib.auth.models import User +from django.db import models + + +class DokumentVorlage(models.Model): + """Web-editierbare Vorlagen für generierte Dokumente (PDF, E-Mail, Berichte).""" + + KATEGORIE_CHOICES = [ + ("pdf", "PDF-Dokument"), + ("email", "E-Mail"), + ("bericht", "Bericht"), + ("serienbrief", "Serienbrief"), + ] + + id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) + schluessel = models.CharField( + max_length=200, + unique=True, + verbose_name="Schlüssel", + help_text="Interner Template-Pfad, z.B. pdf/bestaetigung.html", + ) + bezeichnung = models.CharField(max_length=200, verbose_name="Bezeichnung") + kategorie = models.CharField( + max_length=30, + choices=KATEGORIE_CHOICES, + verbose_name="Kategorie", + ) + html_inhalt = models.TextField(verbose_name="HTML-Inhalt") + verfuegbare_variablen = models.JSONField( + default=dict, + blank=True, + verbose_name="Verfügbare Variablen", + help_text="JSON-Dokumentation der verfügbaren Template-Variablen", + ) + zuletzt_bearbeitet_von = models.ForeignKey( + User, + null=True, + blank=True, + on_delete=models.SET_NULL, + related_name="bearbeitete_vorlagen", + verbose_name="Zuletzt bearbeitet von", + ) + zuletzt_bearbeitet_am = models.DateTimeField(auto_now=True, verbose_name="Zuletzt bearbeitet") + erstellt_am = models.DateTimeField(auto_now_add=True, verbose_name="Erstellt am") + + class Meta: + verbose_name = "Dokument-Vorlage" + verbose_name_plural = "Dokument-Vorlagen" + ordering = ["kategorie", "bezeichnung"] + + def __str__(self): + return f"{self.bezeichnung} ({self.schluessel})" diff --git a/app/stiftung/portal_urls.py b/app/stiftung/portal_urls.py new file mode 100644 index 0000000..8635555 --- /dev/null +++ b/app/stiftung/portal_urls.py @@ -0,0 +1,46 @@ +""" +URL-Konfiguration für das öffentliche Destinatär-Portal. + +Diese URLs sind ohne Login zugänglich (tokenbasierte Authentifizierung). +""" +from django.urls import path + +from stiftung.views.portal import ( + onboarding_danke, + onboarding_schritt, + upload_danke, + upload_formular, +) + +app_name = "portal" + +urlpatterns = [ + # Upload-Portal (bestehende Destinatäre – Token-basiert) + path( + "upload//", + upload_formular, + name="upload_formular", + ), + path( + "upload//danke/", + upload_danke, + name="upload_danke", + ), + # Onboarding-Portal (neue Destinatäre – Einladungs-Token) + path( + "onboarding//", + onboarding_schritt, + {"schritt": 1}, + name="onboarding_start", + ), + path( + "onboarding//schritt//", + onboarding_schritt, + name="onboarding_schritt", + ), + path( + "onboarding//danke/", + onboarding_danke, + name="onboarding_danke", + ), +] diff --git a/app/stiftung/tasks.py b/app/stiftung/tasks.py index e3c99c4..30b812f 100644 --- a/app/stiftung/tasks.py +++ b/app/stiftung/tasks.py @@ -418,3 +418,427 @@ def poll_emails(self, search_all_recent_days=0): # Backward-compatible alias for existing Celery Beat schedules poll_destinataer_emails = poll_emails + + +# ============================================================================= +# SMTP-Ausgangs-Tasks: Nachweis-Aufforderungen und Token-Erinnerungen +# ============================================================================= + +import secrets # noqa: E402 (wird hier benötigt) +from datetime import timedelta # noqa: E402 + + +def _get_smtp_connection(): + """ + Erstellt eine Django-E-Mail-Verbindung mit SMTP-Einstellungen aus der DB. + """ + from django.core.mail import get_connection + from stiftung.utils.config import get_config + + return get_connection( + backend="django.core.mail.backends.smtp.EmailBackend", + host=get_config("smtp_host", "smtp.ionos.de"), + port=int(get_config("smtp_port", 465)), + username=get_config("smtp_user", ""), + password=get_config("smtp_password", ""), + use_ssl=bool(get_config("smtp_use_ssl", True)), + use_tls=False, + fail_silently=False, + ) + + +def _get_smtp_from_email(): + """Gibt die konfigurierte Absenderadresse zurück.""" + from stiftung.utils.config import get_config + return get_config("smtp_from_email", "buero@vhtv-stiftung.de") + + +@shared_task(bind=True, max_retries=3, default_retry_delay=300) +def send_nachweis_aufforderung(self, destinataer_id, nachweis_id, base_url=None): + """ + Erstellt einen UploadToken und sendet eine Nachweis-Aufforderungs-E-Mail + mit Einmallink und QR-Code an den Destinatär. + + Args: + destinataer_id: UUID des Destinatärs + nachweis_id: UUID des VierteljahresNachweises + base_url: Basis-URL der Anwendung (z.B. 'https://vhtv-stiftung.de') + """ + from django.conf import settings + from django.core.mail import EmailMultiAlternatives + from django.template.loader import render_to_string + from django.utils import timezone + import io + try: + import qrcode + from PIL import Image + import base64 + qr_available = True + except ImportError: + qr_available = False + + from stiftung.models import Destinataer, VierteljahresNachweis, UploadToken + + try: + destinataer = Destinataer.objects.get(id=destinataer_id) + nachweis = VierteljahresNachweis.objects.get(id=nachweis_id) + except (Destinataer.DoesNotExist, VierteljahresNachweis.DoesNotExist) as exc: + logger.error("send_nachweis_aufforderung: Objekt nicht gefunden: %s", exc) + return {"status": "error", "message": str(exc)} + + if not destinataer.email: + logger.warning( + "send_nachweis_aufforderung: Destinatär %s hat keine E-Mail-Adresse", + destinataer_id, + ) + return {"status": "skipped", "reason": "no_email"} + + # Bestehende aktive Tokens für diesen Nachweis deaktivieren + UploadToken.objects.filter( + destinataer=destinataer, + nachweis=nachweis, + ist_aktiv=True, + ).update(ist_aktiv=False) + + # Neuen Token erstellen + token_str = secrets.token_urlsafe(48) + gueltig_bis = timezone.now() + timedelta(days=30) + upload_token = UploadToken.objects.create( + token=token_str, + destinataer=destinataer, + nachweis=nachweis, + gueltig_bis=gueltig_bis, + ) + + if base_url is None: + base_url = getattr(settings, "SITE_URL", "https://vhtv-stiftung.de") + + upload_url = f"{base_url}/portal/upload/{token_str}/" + + # QR-Code generieren + qr_code_base64 = None + if qr_available: + try: + qr = qrcode.QRCode( + version=1, + error_correction=qrcode.constants.ERROR_CORRECT_M, + box_size=6, + border=4, + ) + qr.add_data(upload_url) + qr.make(fit=True) + img = qr.make_image(fill_color="black", back_color="white") + buffer = io.BytesIO() + img.save(buffer, format="PNG") + qr_code_base64 = base64.b64encode(buffer.getvalue()).decode() + except Exception as qr_exc: + logger.warning("QR-Code-Generierung fehlgeschlagen: %s", qr_exc) + + # Halbjahr bestimmen (Q1+Q2 = 1. Halbjahr, Q3+Q4 = 2. Halbjahr) + halbjahr = 1 if nachweis.quartal in [1, 2] else 2 + halbjahr_label = f"{halbjahr}. Halbjahr {nachweis.jahr}" + quartal_label = f"Q{nachweis.quartal} {nachweis.jahr}" + + context = { + "destinataer": destinataer, + "nachweis": nachweis, + "upload_url": upload_url, + "qr_code_base64": qr_code_base64, + "gueltig_bis": gueltig_bis, + "halbjahr_label": halbjahr_label, + "quartal_label": quartal_label, + } + + subject = f"Nachweis-Aufforderung: {quartal_label} ({halbjahr_label}) – vHTV-Stiftung" + from_email = _get_smtp_from_email() + to_email = destinataer.email + + from stiftung.utils.vorlagen import render_vorlage + text_body = render_vorlage("email/nachweis_aufforderung.txt", context) + html_body = render_vorlage("email/nachweis_aufforderung.html", context) + + try: + connection = _get_smtp_connection() + msg = EmailMultiAlternatives(subject, text_body, from_email, [to_email], connection=connection) + msg.attach_alternative(html_body, "text/html") + msg.send() + logger.info( + "Nachweis-Aufforderung gesendet an %s (Token %s)", + to_email, + upload_token.id, + ) + return { + "status": "sent", + "destinataer_id": str(destinataer_id), + "nachweis_id": str(nachweis_id), + "token_id": str(upload_token.id), + } + except Exception as exc: + logger.exception("E-Mail-Versand fehlgeschlagen für %s: %s", to_email, exc) + raise self.retry(exc=exc) + + +@shared_task(bind=True, max_retries=3, default_retry_delay=300) +def send_nachweis_erinnerung(self, token_id, base_url=None): + """ + Sendet eine Erinnerungs-E-Mail für einen bald ablaufenden Upload-Token. + Wird durch Celery Beat ausgelöst (7 Tage vor Ablauf). + """ + from django.conf import settings + from django.core.mail import EmailMultiAlternatives + from django.template.loader import render_to_string + + from stiftung.models import UploadToken + + try: + upload_token = UploadToken.objects.select_related( + "destinataer", "nachweis" + ).get(id=token_id, ist_aktiv=True) + except UploadToken.DoesNotExist: + return {"status": "skipped", "reason": "token_not_found_or_inactive"} + + if not upload_token.ist_gueltig(): + return {"status": "skipped", "reason": "token_invalid"} + + if not upload_token.destinataer.email: + return {"status": "skipped", "reason": "no_email"} + + if base_url is None: + base_url = getattr(settings, "SITE_URL", "https://vhtv-stiftung.de") + + upload_url = f"{base_url}/portal/upload/{upload_token.token}/" + nachweis = upload_token.nachweis + halbjahr = 1 if nachweis.quartal in [1, 2] else 2 + halbjahr_label = f"{halbjahr}. Halbjahr {nachweis.jahr}" + + context = { + "destinataer": upload_token.destinataer, + "nachweis": nachweis, + "upload_url": upload_url, + "gueltig_bis": upload_token.gueltig_bis, + "halbjahr_label": halbjahr_label, + "ist_erinnerung": True, + } + + subject = f"Erinnerung: Nachweis-Upload noch ausstehend – {halbjahr_label}" + from_email = _get_smtp_from_email() + to_email = upload_token.destinataer.email + + from stiftung.utils.vorlagen import render_vorlage + text_body = render_vorlage("email/nachweis_aufforderung.txt", context) + html_body = render_vorlage("email/nachweis_aufforderung.html", context) + + try: + connection = _get_smtp_connection() + msg = EmailMultiAlternatives(subject, text_body, from_email, [to_email], connection=connection) + msg.attach_alternative(html_body, "text/html") + msg.send() + upload_token.erinnerung_gesendet = True + upload_token.save(update_fields=["erinnerung_gesendet"]) + logger.info("Erinnerung gesendet an %s (Token %s)", to_email, token_id) + return {"status": "sent", "token_id": str(token_id)} + except Exception as exc: + logger.exception("Erinnerungs-E-Mail fehlgeschlagen für %s: %s", to_email, exc) + raise self.retry(exc=exc) + + +@shared_task(bind=True, max_retries=3, default_retry_delay=300) +def send_onboarding_einladung(self, einladung_id, base_url=None): + """ + Sendet eine Onboarding-Einladungs-E-Mail an eine neue potenzielle Destinatärin/ + einen neuen potenziellen Destinatär. + + Args: + einladung_id: UUID der OnboardingEinladung + base_url: Basis-URL der Anwendung + """ + from django.conf import settings + from django.core.mail import EmailMultiAlternatives + from django.template.loader import render_to_string + + from stiftung.models import OnboardingEinladung + + try: + einladung = OnboardingEinladung.objects.get(id=einladung_id) + except OnboardingEinladung.DoesNotExist as exc: + logger.error("send_onboarding_einladung: Einladung %s nicht gefunden", einladung_id) + return {"status": "error", "message": str(exc)} + + if not einladung.ist_gueltig(): + return {"status": "skipped", "reason": "einladung_ungueltig"} + + if base_url is None: + base_url = getattr(settings, "SITE_URL", "https://vhtv-stiftung.de") + + onboarding_url = f"{base_url}/portal/onboarding/{einladung.token}/" + + context = { + "einladung": einladung, + "onboarding_url": onboarding_url, + "gueltig_bis": einladung.gueltig_bis, + } + + subject = "Einladung zum Onboarding – van Hees-Theyssen-Vogel'sche Stiftung" + from_email = _get_smtp_from_email() + to_email = einladung.email + + from stiftung.utils.vorlagen import render_vorlage + text_body = render_vorlage("email/onboarding_einladung.txt", context) + html_body = render_vorlage("email/onboarding_einladung.html", context) + + try: + connection = _get_smtp_connection() + msg = EmailMultiAlternatives(subject, text_body, from_email, [to_email], connection=connection) + msg.attach_alternative(html_body, "text/html") + msg.send() + logger.info( + "Onboarding-Einladung gesendet an %s (Einladung %s)", + to_email, + einladung_id, + ) + return {"status": "sent", "einladung_id": str(einladung_id), "email": to_email} + except Exception as exc: + logger.exception("Onboarding-E-Mail-Versand fehlgeschlagen für %s: %s", to_email, exc) + raise self.retry(exc=exc) + + +@shared_task(bind=True, max_retries=3, default_retry_delay=300) +def send_bestaetigung(self, destinataer_id, base_url=None): + """ + Generiert ein Bestätigungsschreiben (PDF) für einen Destinatär und sendet es + per E-Mail. Das PDF wird zusätzlich im DMS unter Kontext "korrespondenz" abgelegt. + + Args: + destinataer_id: UUID des Destinatärs + base_url: Basis-URL der Anwendung (für Konsistenz mit anderen Tasks) + """ + from decimal import Decimal + from django.core.files.base import ContentFile + from django.template.loader import render_to_string + from django.core.mail import EmailMultiAlternatives + from django.utils import timezone + + from stiftung.models import Destinataer, DestinataerUnterstuetzung, DokumentDatei + + try: + destinataer = Destinataer.objects.get(id=destinataer_id) + except Destinataer.DoesNotExist as exc: + logger.error("send_bestaetigung: Destinatär %s nicht gefunden", destinataer_id) + return {"status": "error", "message": str(exc)} + + if not destinataer.email: + logger.warning("send_bestaetigung: Destinatär %s hat keine E-Mail-Adresse", destinataer_id) + return {"status": "skipped", "reason": "no_email"} + + # Alle abgeschlossenen Unterstützungen laden + unterstuetzungen = list(DestinataerUnterstuetzung.objects.filter( + destinataer=destinataer, + status__in=["ausgezahlt", "abgeschlossen"], + ).order_by("faellig_am")) + + gesamtbetrag = sum(u.betrag for u in unterstuetzungen) if unterstuetzungen else Decimal("0") + + zeitraum = None + if unterstuetzungen: + erste = unterstuetzungen[0].faellig_am + letzte = unterstuetzungen[-1].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) + + datum = timezone.now().date() + context = { + "destinataer": destinataer, + "unterstuetzungen": unterstuetzungen, + "gesamtbetrag": gesamtbetrag, + "datum": datum, + "zeitraum": zeitraum, + "betrag_quartal": betrag_quartal, + "betrag_jaehrlich": betrag_jaehrlich, + "zweck": zweck, + } + + # PDF generieren via WeasyPrint + pdf_bytes = None + 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() + except Exception as exc: + logger.error("send_bestaetigung: PDF-Generierung fehlgeschlagen: %s", exc) + raise self.retry(exc=exc) + + # PDF im DMS ablegen + filename = ( + f"bestaetigung_{destinataer.nachname}_{destinataer.vorname}" + f"_{datum.strftime('%Y%m%d')}.pdf" + ) + try: + doc = DokumentDatei( + titel=f"Bestätigungsschreiben {datum.strftime('%d.%m.%Y')} – {destinataer.get_full_name()}", + beschreibung="Automatisch generiertes Bestätigungsschreiben über Förderleistungen.", + kontext="korrespondenz", + dateiname_original=filename, + dateityp="application/pdf", + dateigroesse=len(pdf_bytes), + destinataer=destinataer, + ) + doc.datei.save(filename, ContentFile(pdf_bytes), save=False) + doc.save() + logger.info("Bestätigung im DMS gespeichert (ID: %s).", doc.pk) + except Exception as exc: + logger.error("send_bestaetigung: DMS-Speicherung fehlgeschlagen: %s", exc) + # Weiter mit E-Mail-Versand auch wenn DMS-Speicherung schlägt fehl + + # E-Mail senden + from stiftung.utils.vorlagen import render_vorlage + html_body = render_vorlage("email/bestaetigung.html", context) + subject = "Bestätigung Ihrer Stiftungsförderung – van Hees-Theyssen-Vogel'sche Stiftung" + from_email = _get_smtp_from_email() + to_email = destinataer.email + + try: + connection = _get_smtp_connection() + msg = EmailMultiAlternatives(subject, "", from_email, [to_email], connection=connection) + msg.attach_alternative(html_body, "text/html") + if pdf_bytes: + msg.attach(filename, pdf_bytes, "application/pdf") + msg.send() + logger.info("Bestätigung gesendet an %s (Destinatär %s)", to_email, destinataer_id) + return {"status": "sent", "destinataer_id": str(destinataer_id), "email": to_email} + except Exception as exc: + logger.exception("send_bestaetigung: E-Mail-Versand fehlgeschlagen für %s: %s", to_email, exc) + raise self.retry(exc=exc) + + +@shared_task +def check_ablaufende_tokens(): + """ + Prüft täglich Upload-Tokens, die in 7 Tagen ablaufen, + und sendet Erinnerungs-E-Mails (falls noch nicht gesendet). + Wird durch Celery Beat aufgerufen. + """ + from django.utils import timezone + + from stiftung.models import UploadToken + + grenze = timezone.now() + timedelta(days=7) + tokens = UploadToken.objects.filter( + ist_aktiv=True, + eingeloest_am__isnull=True, + erinnerung_gesendet=False, + gueltig_bis__lte=grenze, + gueltig_bis__gt=timezone.now(), + ) + count = 0 + for token in tokens: + send_nachweis_erinnerung.delay(str(token.id)) + count += 1 + logger.info("check_ablaufende_tokens: %d Erinnerungen angestoßen", count) + return {"triggered": count} diff --git a/app/stiftung/urls.py b/app/stiftung/urls.py index d534b03..580b13f 100644 --- a/app/stiftung/urls.py +++ b/app/stiftung/urls.py @@ -459,6 +459,53 @@ urlpatterns = [ # Phase 2: Pächter-Workflow (2d) path("paechter/workflow/", views.paechter_workflow, name="paechter_workflow"), + # Phase 4: Upload-Portal – Admin-seitige Auslöser + path( + "quarterly-confirmations//aufforderung-senden/", + views.nachweis_aufforderung_senden, + name="nachweis_aufforderung_senden", + ), + path( + "nachweis-board/batch-aufforderung-senden/", + views.batch_nachweis_aufforderung_senden, + name="batch_nachweis_aufforderung_senden", + ), + + # Phase 5: Onboarding – Admin-Seite + path( + "destinataere/onboarding/einladen/", + views.onboarding_einladung_senden, + name="onboarding_einladung_senden", + ), + path( + "destinataere/onboarding/einladungen/", + views.onboarding_einladung_liste, + name="onboarding_einladung_liste", + ), + path( + "destinataere/onboarding/einladungen//widerrufen/", + views.onboarding_einladung_widerrufen, + name="onboarding_einladung_widerrufen", + ), + # Bestätigungsschreiben + path( + "destinataere//bestaetigung/", + views.bestaetigung_vorschau, + name="bestaetigung_vorschau", + ), + path( + "destinataere//bestaetigung/versenden/", + views.bestaetigung_versenden, + name="bestaetigung_versenden", + ), + + # Dokument-Vorlagen-Editor + path("administration/vorlagen/", views.vorlagen_liste, name="vorlagen_liste"), + path("administration/vorlagen//", views.vorlage_editor, name="vorlage_editor"), + path("administration/vorlagen//zuruecksetzen/", views.vorlage_zuruecksetzen, name="vorlage_zuruecksetzen"), + path("administration/vorlagen//vorschau/", views.vorlage_vorschau, name="vorlage_vorschau"), + path("administration/vorlagen/alle-zuruecksetzen/", views.vorlagen_alle_zuruecksetzen, name="vorlagen_alle_zuruecksetzen"), + # Phase 3: DMS – Django-natives Dokumentenmanagement path("dms/", views.dms_list, name="dms_list"), path("dms/hochladen/", views.dms_upload, name="dms_upload"), diff --git a/app/stiftung/utils/vorlagen.py b/app/stiftung/utils/vorlagen.py new file mode 100644 index 0000000..e305515 --- /dev/null +++ b/app/stiftung/utils/vorlagen.py @@ -0,0 +1,59 @@ +"""Utility für das Rendering von Dokument-Vorlagen. + +Prüft zuerst die Datenbank (DokumentVorlage), fällt dann auf die Datei-Vorlage zurück. +""" + +from django.template import Context, Engine, Template +from django.template.loader import render_to_string + + +def render_vorlage(template_name: str, context: dict, request=None) -> str: + """Rendert eine Vorlage. + + Schaut zuerst in der DB nach (DokumentVorlage), fällt auf die Datei zurück. + + Args: + template_name: Template-Pfad, z.B. "pdf/bestaetigung.html" + context: Template-Kontext-Dictionary + request: Optionaler Request (für RequestContext) + + Returns: + Gerenderter HTML-String + """ + from stiftung.models import DokumentVorlage + + try: + vorlage = DokumentVorlage.objects.get(schluessel=template_name) + # Eigene Engine mit den Standard-Builtins, aber ohne Dateisystem-Loader + engine = Engine.get_default() + t = engine.from_string(vorlage.html_inhalt) + return t.render(Context(context)) + except DokumentVorlage.DoesNotExist: + pass + + # Fallback: Datei-Template + return render_to_string(template_name, context, request=request) + + +def get_vorlage_original(template_name: str) -> str: + """Liest den Original-Dateiinhalt einer Vorlage (für Reset-Funktion).""" + from django.template.loaders.filesystem import Loader + from django.template import Engine + + engine = Engine.get_default() + for loader in engine.template_loaders: + try: + source, _ = loader.get_contents_and_origin(template_name) + return source + except Exception: + # Try get_template_sources + try: + for origin in loader.get_template_sources(template_name): + try: + with open(origin.name, "r", encoding="utf-8") as f: + return f.read() + except OSError: + continue + except Exception: + continue + raise FileNotFoundError(f"Template-Datei nicht gefunden: {template_name}") diff --git a/app/stiftung/views/__init__.py b/app/stiftung/views/__init__.py index 53ce682..18d20ff 100644 --- a/app/stiftung/views/__init__.py +++ b/app/stiftung/views/__init__.py @@ -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 diff --git a/app/stiftung/views/destinataere.py b/app/stiftung/views/destinataere.py index 8be9fe0..4542955 100644 --- a/app/stiftung/views/destinataere.py +++ b/app/stiftung/views/destinataere.py @@ -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) + diff --git a/app/stiftung/views/portal.py b/app/stiftung/views/portal.py new file mode 100644 index 0000000..81924f4 --- /dev/null +++ b/app/stiftung/views/portal.py @@ -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// → 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}, + ) diff --git a/app/stiftung/views/system.py b/app/stiftung/views/system.py index cdae0b9..e28f5d1 100644 --- a/app/stiftung/views/system.py +++ b/app/stiftung/views/system.py @@ -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) diff --git a/app/stiftung/views/unterstuetzungen.py b/app/stiftung/views/unterstuetzungen.py index 97dca27..267eab7 100644 --- a/app/stiftung/views/unterstuetzungen.py +++ b/app/stiftung/views/unterstuetzungen.py @@ -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 diff --git a/app/stiftung/views/veranstaltung.py b/app/stiftung/views/veranstaltung.py index 1199854..a7bbde7 100644 --- a/app/stiftung/views/veranstaltung.py +++ b/app/stiftung/views/veranstaltung.py @@ -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, diff --git a/app/stiftung/views/vorlagen.py b/app/stiftung/views/vorlagen.py new file mode 100644 index 0000000..cdcb0ad --- /dev/null +++ b/app/stiftung/views/vorlagen.py @@ -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 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"
Template-Fehler: {exc}
", + 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 diff --git a/app/templates/base.html b/app/templates/base.html index 49199c4..d3e2fc5 100644 --- a/app/templates/base.html +++ b/app/templates/base.html @@ -592,6 +592,10 @@ Destinataere + + + Onboarding + Foerderungen diff --git a/app/templates/email/bestaetigung.html b/app/templates/email/bestaetigung.html new file mode 100644 index 0000000..2356843 --- /dev/null +++ b/app/templates/email/bestaetigung.html @@ -0,0 +1,56 @@ + + + + + + Bestätigung Ihrer Förderung – van Hees-Theyssen-Vogel'sche Stiftung + + + +
+
+

van Hees-Theyssen-Vogel'sche Stiftung

+

Bestätigung Ihrer Förderleistungen

+
+
+

Sehr geehrte{% if destinataer.anrede == "Herr" %}r Herr{% elif destinataer.anrede == "Frau" %} Frau{% else %}{{ destinataer.anrede }}{% endif %} {{ destinataer.nachname }},

+ +

anbei erhalten Sie Ihre persönliche Bestätigung über die Ihnen gewährte + Unterstützung durch die van Hees-Theyssen-Vogel'sche Stiftung{% if zeitraum %} + für den Förderzeitraum {{ zeitraum }}{% endif %}.

+ +

Das beigefügte Dokument gilt als offizieller Nachweis der erhaltenen Förderung.

+ +
+

Empfänger: {{ destinataer.vorname }} {{ destinataer.nachname }}

+ {% if zeitraum %}

Förderzeitraum: {{ zeitraum }}

{% endif %} + {% if gesamtbetrag %}

Gesamtbetrag: {{ gesamtbetrag|floatformat:2 }} €

{% endif %} +

Erstellt am: {{ datum|date:"d.m.Y" }}

+
+ +

Bei Fragen stehen wir Ihnen gerne zur Verfügung.

+ +

Mit freundlichen Grüßen

+

Jan Remmer Siebels & Katrin Kleinpaß
+ Rentmeister / Rentmeisterin
+ van Hees-Theyssen-Vogel'sche Stiftung

+
+ +
+ + diff --git a/app/templates/email/nachweis_aufforderung.html b/app/templates/email/nachweis_aufforderung.html new file mode 100644 index 0000000..561c188 --- /dev/null +++ b/app/templates/email/nachweis_aufforderung.html @@ -0,0 +1,78 @@ + + + + + + {% if ist_erinnerung %}Erinnerung: {% endif %}Nachweis-Aufforderung {{ halbjahr_label }} + + + +
+ + diff --git a/app/templates/email/nachweis_aufforderung.txt b/app/templates/email/nachweis_aufforderung.txt new file mode 100644 index 0000000..68ec341 --- /dev/null +++ b/app/templates/email/nachweis_aufforderung.txt @@ -0,0 +1,37 @@ +{% if ist_erinnerung %}ERINNERUNG: {% endif %}Nachweis-Aufforderung {{ halbjahr_label }} – van Hees-Theyssen-Vogel'sche Stiftung +================================================================================ + +Sehr geehrte(r) {{ destinataer.vorname }} {{ destinataer.nachname }}, +{% if ist_erinnerung %} +wir möchten Sie daran erinnern, dass Ihre Unterlagen für das {{ halbjahr_label }} +noch ausstehen. Der Upload-Link läuft am {{ gueltig_bis|date:"d.m.Y" }} ab. +{% else %} +die van Hees-Theyssen-Vogel'sche Stiftung bittet Sie, Ihre Unterlagen +für das {{ halbjahr_label }} einzureichen. + +Bitte reichen Sie bis zum {{ gueltig_bis|date:"d.m.Y" }} folgende Unterlagen ein: + - Semesterbescheinigung / Ausbildungsnachweis (mindestens einmal jährlich) + - Leistungsnachweise (Zeugnisse, Kreditpunkte etc.) + - Nachweis über Einkommenssituation und Vermögensverhältnisse + (falls sich Veränderungen ergeben haben) +{% endif %} +Ihre Unterlagen können Sie über folgenden Link hochladen: +{{ upload_url }} + +Dieser Link ist einmalig verwendbar und gültig bis {{ gueltig_bis|date:"d.m.Y" }}. + +Falls Sie den Link nicht verwenden können, wenden Sie sich bitte direkt +an die Stiftung (Tel. 02858/836780 oder Jan.Siebels@gmail.com). + +Mit freundlichen Grüßen + +Jan Remmer Siebels Katrin Kleinpaß +(Rentmeister) (Rentmeisterin) + +van Hees-Theyssen-Vogel'sche Stiftung +Raesfelder Str. 3 +46499 Hamminkeln +Tel. 02858/836780 + +--- +Diese E-Mail wurde automatisch erzeugt. Bitte antworten Sie nicht direkt auf diese E-Mail. diff --git a/app/templates/email/onboarding_einladung.html b/app/templates/email/onboarding_einladung.html new file mode 100644 index 0000000..64389a7 --- /dev/null +++ b/app/templates/email/onboarding_einladung.html @@ -0,0 +1,63 @@ + + + + + +Onboarding-Einladung – vHTV-Stiftung + + + +
+
+

van Hees-Theyssen-Vogel'sche Stiftung

+

Einladung zum Onboarding-Verfahren

+
+
+

Sehr geehrte Damen und Herren,{% if einladung.vorname %} liebe/r {{ einladung.vorname }}{% if einladung.nachname %} {{ einladung.nachname }}{% endif %}{% endif %},

+ +

Sie wurden zur Aufnahme in die van Hees-Theyssen-Vogel'sche Stiftung eingeladen. Um das Antragsverfahren zu starten, klicken Sie bitte auf den folgenden Button:

+ + Jetzt Onboarding starten + + + +

Dieser Link ist gültig bis: {{ gueltig_bis|date:"d.m.Y H:i" }} Uhr.

+ +
+ Was Sie erwartet: +
    +
  • Zustimmung zur Datenschutzerklärung
  • +
  • Persönliche Daten (gemäß Stiftungsmerkblatt)
  • +
  • Angaben zu Ausbildung / Studium
  • +
  • Angaben zur finanziellen Situation
  • +
  • Upload relevanter Dokumente
  • +
+
+ +

Nach Abschluss des Verfahrens prüft der Vorstand Ihren Antrag und setzt sich mit Ihnen in Verbindung.

+ +

Bei Fragen wenden Sie sich bitte an uns:

+
+ +
+ + diff --git a/app/templates/email/onboarding_einladung.txt b/app/templates/email/onboarding_einladung.txt new file mode 100644 index 0000000..6bcd962 --- /dev/null +++ b/app/templates/email/onboarding_einladung.txt @@ -0,0 +1,24 @@ +Sehr geehrte Damen und Herren,{% if einladung.vorname %} liebe/r {{ einladung.vorname }}{% if einladung.nachname %} {{ einladung.nachname }}{% endif %}{% endif %}, + +Sie wurden zur Aufnahme in die van Hees-Theyssen-Vogel'sche Stiftung eingeladen. + +Um das Antragsverfahren zu starten, folgen Sie bitte diesem Einmal-Link: + +{{ onboarding_url }} + +Der Link ist gültig bis: {{ gueltig_bis|date:"d.m.Y H:i" }} Uhr. + +Im Onboarding-Verfahren werden Sie gebeten: +- der Datenschutzerklärung zuzustimmen, +- Ihre persönlichen Daten anzugeben (gemäß Stiftungsmerkblatt), +- Ausbildungs- und Einkommensnachweise hochzuladen. + +Nach Abschluss des Verfahrens prüft der Vorstand Ihren Antrag und setzt sich mit Ihnen in Verbindung. + +Falls Sie Fragen haben, wenden Sie sich bitte an: +van Hees-Theyssen-Vogel'sche Stiftung +Raesfelder Str. 3, 46499 Hamminkeln +Tel. 02858/836780 + +Mit freundlichen Grüßen +van Hees-Theyssen-Vogel'sche Stiftung diff --git a/app/templates/pdf/bestaetigung.html b/app/templates/pdf/bestaetigung.html new file mode 100644 index 0000000..077cb7f --- /dev/null +++ b/app/templates/pdf/bestaetigung.html @@ -0,0 +1,254 @@ + + + + + Bestätigung – {{ destinataer.vorname }} {{ destinataer.nachname }} + + + + + +
van Hees-Theyssen-Vogel'sche Stiftung
+
+ Raesfelder Str. 3  ·  46499 Hamminkeln +
+ + +
+
van Hees-Theyssen-Vogel'sche Stiftung · Raesfelder Str. 3 · 46499 Hamminkeln
+ {% if destinataer.anrede %}

{{ destinataer.anrede }}

{% endif %} +

{{ destinataer.vorname }} {{ destinataer.nachname }}

+ {% if destinataer.strasse %}

{{ destinataer.strasse }}

{% endif %} + {% if destinataer.plz or destinataer.ort %}

{{ destinataer.plz }} {{ destinataer.ort }}

{% endif %} +
+ + +
+ Hamminkeln, den {{ datum|date:"j. F Y" }} +
+ + +
+ Bestätigung über Förderleistungen der van Hees-Theyssen-Vogel'schen Stiftung +
+ + +
+

+ Sehr geehrte{% if destinataer.anrede == "Herr" %}r Herr{% elif destinataer.anrede == "Frau" %} Frau{% else %} + {{ destinataer.anrede }}{% endif %} {{ destinataer.nachname }}, +

+ +

+ wir freuen uns, Ihnen hiermit die durch die van Hees-Theyssen-Vogel'sche + Stiftung gewährte finanzielle Unterstützung zu bestätigen. +

+ + {% if betrag_quartal %} +

+ Die Stiftung hat Ihnen{% if zeitraum %} im Förderzeitraum {{ zeitraum }}{% endif %} + {% if zweck %} für den Zweck „{{ zweck }}"{% endif %} + eine Förderung in Höhe von {{ betrag_quartal|floatformat:2 }} € je Quartal + (jährlich: {{ betrag_jaehrlich|floatformat:2 }} €) zuerkannt und ausgezahlt. +

+ {% endif %} + +

+ Diese Bestätigung dient Ihnen als offizieller Nachweis der erhaltenen + Förderung gegenüber Behörden, Bildungseinrichtungen oder anderen + zuständigen Stellen. +

+ +

+ Die van Hees-Theyssen-Vogel'sche Stiftung ist eine gemeinnützige Stiftung + des bürgerlichen Rechts mit Sitz in Hamminkeln und verfolgt ausschließlich + und unmittelbar gemeinnützige Zwecke im Sinne der §§ 51 ff. AO. + Die Förderung erfolgte satzungsgemäß und zweckentsprechend. +

+ + {% if unterstuetzungen %} +

Im {% if zeitraum %}Zeitraum {{ zeitraum }}{% else %}zurückliegenden Förderzeitraum{% endif %} + wurden Ihnen folgende Leistungen gewährt:

+ + + + + + + + + + + + {% for u in unterstuetzungen %} + + + + + + + {% endfor %} + + + + + + + + +
DatumBeschreibung / ZweckBetragStatus
{{ u.faellig_am|date:"d.m.Y" }}{{ u.beschreibung|default:"Förderleistung" }}{{ u.betrag|floatformat:2 }} €{{ u.get_status_display }}
Gesamtbetrag{{ gesamtbetrag|floatformat:2 }} €
+ {% endif %} + +

+ Wir wünschen Ihnen weiterhin viel Erfolg bei Ihrem Vorhaben und freuen uns, + Sie durch unsere Stiftung unterstützen zu dürfen. +

+ +

Mit freundlichen Grüßen

+
+ + +
+
+
+ Jan Remmer Siebels
+ Rentmeister
+ van Hees-Theyssen-Vogel'sche Stiftung +
+
+
+ Katrin Kleinpaß
+ Rentmeisterin
+ van Hees-Theyssen-Vogel'sche Stiftung +
+
+ + + + + diff --git a/app/templates/portal/datenschutzerklaerung.html b/app/templates/portal/datenschutzerklaerung.html new file mode 100644 index 0000000..9f13da1 --- /dev/null +++ b/app/templates/portal/datenschutzerklaerung.html @@ -0,0 +1,424 @@ + + + + + + Datenschutzerklärung – van Hees-Theyssen-Vogel'sche Stiftung + + + + + + + +
+
+
+
+

Datenschutzerklärung

+
van Hees-Theyssen-Vogel'sche Stiftung · Destinatär-Portal
+
+
+ Stand: März 2026 +
+
+
+
+ + +
+
+ + +

1. Verantwortliche Stelle

+

+ Verantwortliche Stelle im Sinne der Datenschutz-Grundverordnung (DSGVO) und des Bundesdatenschutzgesetzes (BDSG) ist: +

+
+ van Hees-Theyssen-Vogel'sche Stiftung
+ Raesfelder Str. 3
+ 46499 Hamminkeln

+ stiftung@vhtv-stiftung.de +
+

+ Die Stiftung ist als gemeinnützige Familienstiftung anerkannt und verfolgt ausschließlich und unmittelbar + gemeinnützige Zwecke im Sinne des § 52 der Abgabenordnung (AO). +

+ + +

2. Grundsätze der Datenverarbeitung

+

+ Wir verarbeiten personenbezogene Daten nur, soweit dies zur Erfüllung unserer satzungsmäßigen Aufgaben + und gesetzlichen Pflichten erforderlich ist. Die Verarbeitung erfolgt stets im Einklang mit der + Datenschutz-Grundverordnung (DSGVO) und dem Bundesdatenschutzgesetz (BDSG). +

+

+ Wir erheben, verarbeiten und speichern Ihre personenbezogenen Daten grundsätzlich nur +

+
    +
  • mit Ihrer ausdrücklichen Einwilligung (Art. 6 Abs. 1 lit. a DSGVO), oder
  • +
  • zur Erfüllung einer vertraglichen oder vorvertraglichen Verpflichtung (Art. 6 Abs. 1 lit. b DSGVO), oder
  • +
  • aufgrund einer rechtlichen Verpflichtung (Art. 6 Abs. 1 lit. c DSGVO), oder
  • +
  • zur Wahrung berechtigter Interessen (Art. 6 Abs. 1 lit. f DSGVO).
  • +
+ + +

3. Nachweis-Upload-Portal (bestehende Destinatäre)

+

+ Das Upload-Portal ermöglicht Ihnen die sichere, digitale Einreichung von Unterlagen im Rahmen + des halbjährlichen Nachweisverfahrens. Der Zugang erfolgt ausschließlich über einen persönlichen, + einmalig nutzbaren Token-Link, den Sie per E-Mail erhalten. +

+ +

3.1 Verarbeitete Daten

+
    +
  • Hochgeladene Dokumente (Studienbescheinigungen, Einkommensnachweise, Vermögensnachweise)
  • +
  • IP-Adresse (ausschließlich als kryptographischer Hash gespeichert; keine Rückführung möglich)
  • +
  • Zeitstempel der Token-Einlösung und des Dokumenten-Uploads
  • +
  • Token-Status (eingelöst / abgelaufen)
  • +
+ +

3.2 Rechtsgrundlage

+
+ Art. 6 Abs. 1 lit. b DSGVO — Verarbeitung zur Erfüllung der satzungsmäßigen Verpflichtung + der Stiftung, die Bedürftigkeit und Anspruchsberechtigung ihrer Destinatäre gemäß § 53 AO zu prüfen und + zu dokumentieren. +
+ +

3.3 Zweck der Verarbeitung

+

+ Die Verarbeitung dient ausschließlich der satzungsgemäßen Aufgabe der Stiftung: der Prüfung, ob die + Voraussetzungen für eine Unterstützungsleistung gemäß § 53 Abgabenordnung (AO) weiterhin vorliegen. + Dies umfasst insbesondere die Überprüfung der Bedürftigkeit (Einkommens- und Vermögensgrenzen) + sowie der Anspruchsvoraussetzungen. +

+ +
+ + Hinweis gemäß § 53 AO: Die van Hees-Theyssen-Vogel'sche Stiftung ist als gemeinnützige + Stiftung verpflichtet, die Bedürftigkeit der unterstützten Personen nachzuweisen. + Gemäß § 53 Nr. 2 AO dürfen nur Personen unterstützt werden, deren Bezüge + (einschließlich Leistungen nach SGB) nicht mehr als das Fünffache des Regelsatzes + nach dem Dritten Kapitel SGB XII überschreiten und deren Vermögen den + gemeinen Wert von 15.500 € nicht übersteigt. Die Einreichung der Nachweise ist + daher gesetzlich geboten und unverzichtbar. +
+ +

3.4 Speicherdauer und Löschkonzept

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
DatenkategorieSpeicherdauerBegründung
Upload-Token (abgelaufen, nicht eingelöst)90 Tage nach AblaufNachvollziehbarkeit von Zustellungsversuchen
IP-Hash90 TageSicherheit / Missbrauchsschutz
Hochgeladene Nachweisdokumente10 Jahre nach letzter UnterstützungsleistungSteuerrechtliche Aufbewahrungspflichten (§ 147 AO)
Zeitstempel und Protokolldaten10 JahreGemeinnützigkeitsnachweis gegenüber Finanzbehörden
+ + +

4. Onboarding-Formular (neue Destinatäre)

+

+ Das Onboarding-Formular dient der erstmaligen Aufnahme in den Kreis der Destinatäre der Stiftung. + Dabei werden im Rahmen eines mehrstufigen Verfahrens umfangreiche personenbezogene Daten erhoben. +

+ +

4.1 Erhobene Datenkategorien

+ +

Persönliche Identifikationsdaten:

+
    +
  • Vor- und Nachname, Geburtsdatum, Geburtsort
  • +
  • Anschrift (Straße, PLZ, Ort)
  • +
  • Telefon- und Mobilnummer, E-Mail-Adresse
  • +
  • Kopie des Personalausweises oder Reisepasses
  • +
  • Tabellarischer Lebenslauf
  • +
+ +

Verwandtschaftsnachweis:

+
    +
  • Nachweis des Verwandtschaftsverhältnisses zu einem Geschwisterteil des Stifters + Hendrik van Hees oder seiner Ehefrau Aletta Theyssen-Vogel
  • +
+ +

Ausbildungs- und Studiendaten:

+
    +
  • Aktueller Ausbildungs-/Studienstatus
  • +
  • Studienbescheinigung oder Ausbildungsnachweis
  • +
  • Voraussichtliche Dauer der Ausbildung/des Studiums
  • +
+ +

Finanzdaten:

+
    +
  • Haushaltsgröße und Zusammensetzung des Haushalts
  • +
  • Bezüge und Einkünfte (einschließlich Leistungen nach SGB)
  • +
  • Einkommensteuerbescheid, Lohn-/Gehaltsnachweis, ggf. Rentenbescheid
  • +
  • Unterhaltsleistungen und sonstige Bezüge
  • +
  • Miet- und Heizungsaufwendungen (ggf. Mietvertragskopie)
  • +
  • Vermögensübersicht (Spareinlagen, Wertpapiere, Immobilien)
  • +
  • Monatliche Aufwendungen für Lebensunterhalt und Ausbildung
  • +
+ +

4.2 Rechtsgrundlagen

+
+ Art. 6 Abs. 1 lit. b DSGVO — Verarbeitung zur Durchführung vorvertraglicher + Maßnahmen im Rahmen der Aufnahme als Destinatär der Stiftung.

+ Art. 9 Abs. 2 lit. b DSGVO — Soweit Daten besonderer Kategorien verarbeitet + werden (z. B. Pflegegrad, Gesundheitsdaten im Zusammenhang mit Einkommenssituation), + erfolgt dies zur Erfüllung von Rechten und Pflichten im Bereich des Sozialrechts + sowie zur Überprüfung der Anspruchsvoraussetzungen gemäß § 53 AO.

+ Art. 6 Abs. 1 lit. a DSGVO — Einwilligung für die Verarbeitung freiwillig + übermittelter Daten, die über das gesetzlich Erforderliche hinausgehen. +
+ +

4.3 Zweck der Verarbeitung

+

+ Die erhobenen Daten dienen ausschließlich der Prüfung der Aufnahmevoraussetzungen + als Destinatär sowie der laufenden Überprüfung der Anspruchsberechtigung. + Eine Weitergabe an Dritte erfolgt nicht, es sei denn, dies ist gesetzlich vorgeschrieben + (z. B. Finanzbehörden im Rahmen von Betriebsprüfungen). +

+ +

4.4 Vier-Augen-Prinzip und Freigabeverfahren

+

+ Alle im Onboarding-Verfahren erfassten Daten werden erst nach ausdrücklicher + Freigabe durch den Stiftungsvorstand aktiviert. Bis zur Freigabe haben nur autorisierte + Stiftungsmitarbeiter Zugriff. Das Aufnahmeverfahren ist nicht automatisiert; + jede Aufnahme wird durch den Vorstand beschlossen. +

+ +

4.5 Speicherdauer

+

+ Abgebrochene oder nicht freigegebene Onboarding-Vorgänge werden spätestens nach + 90 Tagen vollständig gelöscht. Für aufgenommene Destinatäre gilt die steuerrechtliche + Aufbewahrungsfrist gemäß § 147 AO (10 Jahre nach Ende des Förderzeitraums). +

+ + +

5. Technische Sicherheitsmaßnahmen

+
    +
  • HTTPS-Verschlüsselung für alle Datenübertragungen
  • +
  • Token-basierter Zugang (kryptographisch sicher, 64-Zeichen-Token, einmalig nutzbar)
  • +
  • IP-Anonymisierung durch SHA-256-Hash; keine Klartextspeicherung
  • +
  • CSRF-Schutz für alle Formularübertragungen
  • +
  • Rate Limiting zum Schutz vor Missbrauch
  • +
  • Automatische Tokenablaufzeit (30 Tage)
  • +
  • Dateivalidierung (nur PDF, JPG, PNG; maximale Dateigröße 20 MB)
  • +
+ + +

6. Keine automatisierte Entscheidungsfindung

+

+ Es findet keine automatisierte Entscheidungsfindung im Sinne von Art. 22 DSGVO statt. + Alle Entscheidungen über die Gewährung von Unterstützungsleistungen werden durch + den Stiftungsvorstand nach menschlicher Prüfung getroffen. +

+ + +

7. Weitergabe personenbezogener Daten

+

+ Eine Weitergabe Ihrer personenbezogenen Daten an Dritte erfolgt grundsätzlich nicht. + Ausnahmen gelten nur, soweit: +

+
    +
  • eine gesetzliche Verpflichtung zur Weitergabe besteht (z. B. im Rahmen + von Steuerprüfungen oder behördlichen Anfragen), oder
  • +
  • Sie ausdrücklich in die Weitergabe eingewilligt haben.
  • +
+

+ Auftragsverarbeiter (z. B. Hosting-Dienstleister) sind vertraglich zur Einhaltung + der DSGVO verpflichtet und dürfen die Daten nur zu den vereinbarten Zwecken verwenden. +

+ + +

8. Ihre Rechte als betroffene Person

+

+ Sie haben nach der Datenschutz-Grundverordnung folgende Rechte gegenüber der + verantwortlichen Stelle: +

+
    +
  • + + Auskunftsrecht (Art. 15 DSGVO): Sie haben das Recht, Auskunft über + die zu Ihrer Person gespeicherten Daten zu erhalten. +
  • +
  • + + Berichtigungsrecht (Art. 16 DSGVO): Sie haben das Recht, unrichtige + oder unvollständige Daten berichtigen zu lassen. +
  • +
  • + + Löschungsrecht (Art. 17 DSGVO): Sie haben das Recht auf Löschung + Ihrer Daten, soweit keine gesetzlichen Aufbewahrungspflichten entgegenstehen. +
  • +
  • + + Einschränkungsrecht (Art. 18 DSGVO): Sie haben das Recht, die + Verarbeitung Ihrer Daten unter bestimmten Voraussetzungen einschränken zu lassen. +
  • +
  • + + Widerspruchsrecht (Art. 21 DSGVO): Sie können der Verarbeitung + Ihrer Daten aus Gründen, die sich aus Ihrer besonderen Situation ergeben, widersprechen. + Bitte beachten Sie, dass ein Widerspruch bei gesetzlich vorgeschriebener Datenverarbeitung + (§ 53 AO) ggf. zur Einstellung der Unterstützungsleistungen führen kann. +
  • +
  • + + Datenübertragbarkeit (Art. 20 DSGVO): Sie haben das Recht, die Ihnen + bereitgestellten Daten in einem strukturierten, maschinenlesbaren Format zu erhalten. +
  • +
  • + + Widerrufsrecht (Art. 7 Abs. 3 DSGVO): Eine erteilte Einwilligung können + Sie jederzeit mit Wirkung für die Zukunft widerrufen. Der Widerruf berührt nicht die + Rechtmäßigkeit der bis dahin erfolgten Verarbeitung. +
  • +
+ +

+ Zur Ausübung Ihrer Rechte wenden Sie sich bitte schriftlich oder per E-Mail an die + verantwortliche Stelle (siehe Abschnitt 1). +

+ + +

9. Beschwerderecht bei der Aufsichtsbehörde

+

+ Sie haben das Recht, sich bei einer Datenschutz-Aufsichtsbehörde über die Verarbeitung + Ihrer personenbezogenen Daten zu beschweren. Zuständig ist in der Regel die Behörde + des Bundeslandes, in dem Sie Ihren gewöhnlichen Aufenthaltsort haben, oder des Bundeslandes, + in dem die verantwortliche Stelle ihren Sitz hat: +

+
+ Landesbeauftragte für Datenschutz und Informationsfreiheit Nordrhein-Westfalen
+ Postfach 20 04 44
+ 40102 Düsseldorf
+ www.ldi.nrw.de +
+ + +

10. Änderungen dieser Datenschutzerklärung

+

+ Wir behalten uns vor, diese Datenschutzerklärung bei Bedarf anzupassen, um sie + stets den aktuellen rechtlichen Anforderungen zu entsprechen oder um Änderungen + unserer Leistungen in der Erklärung umzusetzen. Für Ihren erneuten Besuch gilt + dann die neue Datenschutzerklärung. +

+ +
+ + + +
+ + + + diff --git a/app/templates/portal/einwilligung_onboarding.html b/app/templates/portal/einwilligung_onboarding.html new file mode 100644 index 0000000..cddc496 --- /dev/null +++ b/app/templates/portal/einwilligung_onboarding.html @@ -0,0 +1,216 @@ +{# Einwilligungserklärung für das Onboarding-Formular (Schritt 1) #} +{# Wird eingebettet in das mehrstufige Onboarding-Formular. #} +{# Variablen: einladung_token, stiftung_email #} + +
+
+
+ + Datenschutz & Einwilligung +
+ Bitte lesen Sie die folgenden Erklärungen sorgfältig und bestätigen Sie diese, um fortzufahren. +
+ +
+ + {# ─── Datenschutzerklärung ─────────────────────────────────────────────── #} +
+
+ Datenschutzerklärung +
+ +
+ +

Verantwortliche Stelle:
+ van Hees-Theyssen-Vogel'sche Stiftung, Raesfelder Str. 3, 46499 Hamminkeln
+ E-Mail: {{ stiftung_email|default:"stiftung@vhtv-stiftung.de" }}

+ +

Zweck der Datenerhebung:
+ Die von Ihnen in diesem Formular eingegebenen personenbezogenen Daten dienen ausschließlich + der Prüfung Ihrer Aufnahme als Destinatär (Begünstigter) der Stiftung. Dies umfasst die + Feststellung Ihrer Anspruchsberechtigung gemäß § 53 Abgabenordnung (AO) sowie der + Stiftungssatzung.

+ +

Erhobene Daten: + Persönliche Identifikationsdaten (Name, Adresse, Geburtsdatum, Kontaktdaten), + Identitätsnachweise, Verwandtschaftsnachweis, Ausbildungs-/Studiendaten sowie + Angaben zur finanziellen Situation (Einkommen, Vermögen, Haushaltskosten).

+ +

Rechtsgrundlagen: + Art. 6 Abs. 1 lit. b DSGVO (Vertragsanbahnung), Art. 9 Abs. 2 lit. b DSGVO + (besondere Datenkategorien im Bereich Sozialrecht) sowie Ihre nachstehende + Einwilligung gemäß Art. 6 Abs. 1 lit. a DSGVO.

+ +

Speicherdauer: + Nicht abgeschlossene oder nicht freigegebene Anträge werden spätestens nach 90 Tagen gelöscht. + Daten aufgenommener Destinatäre werden für die Dauer des Förderverhältnisses sowie + 10 Jahre darüber hinaus aufbewahrt (steuerrechtliche Aufbewahrungspflicht gem. § 147 AO).

+ +

Ihre Rechte: + Sie haben das Recht auf Auskunft (Art. 15 DSGVO), Berichtigung (Art. 16 DSGVO), + Löschung (Art. 17 DSGVO), Einschränkung der Verarbeitung (Art. 18 DSGVO), + Datenübertragbarkeit (Art. 20 DSGVO) und Widerspruch (Art. 21 DSGVO). + Weiterhin können Sie eine erteilte Einwilligung jederzeit widerrufen (Art. 7 Abs. 3 DSGVO). + Zur Ausübung Ihrer Rechte wenden Sie sich an: stiftung@vhtv-stiftung.de.

+ +

Beschwerderecht: + Sie haben das Recht, sich bei der Landesbeauftragten für Datenschutz und + Informationsfreiheit NRW zu beschweren (www.ldi.nrw.de).

+ +

+ + + Vollständige Datenschutzerklärung öffnen + +

+
+ +
+ + +
+ Bitte bestätigen Sie die Datenschutzerklärung, um fortzufahren. +
+
+
+ +
+ + {# ─── Erklärung des Leistungsempfängers ───────────────────────────────── #} +
+
+ Erklärung des Antragstellers (gemäß Stiftungsmerkblatt) +
+ +
+

+ Ich erkläre, dass meine Angaben in diesem Formular sowie in allen beigefügten Unterlagen + vollständig und wahrheitsgemäß sind. Ich bin mir bewusst, dass + unvollständige, fehlerhafte oder wissentlich falsche Angaben zum Ausschluss + von Leistungen der Stiftung sowie ggf. zur Rückforderung bereits gewährter + Unterstützung führen können. +

+

+ Ich verpflichte mich, Änderungen meiner Einkommens- und Vermögenssituation + sowie meines Ausbildungsstatus unverzüglich der Stiftung mitzuteilen, sobald diese + zu einer Änderung der Anspruchsvoraussetzungen führen könnten. +

+

+ Mir ist bekannt, dass die Stiftung ihre Unterstützungsleistungen nach Maßgabe + des § 53 Abgabenordnung (AO) erbringt und daher die Einhaltung + der dort genannten Einkommens- und Vermögensgrenzen regelmäßig überprüfen muss. +

+ +
+ + Aktuelle Grenzwerte gemäß § 53 Nr. 2 AO (Stand 01/2024): + Bezüge max. 2.245 € monatlich (5× Regelsatz 449 €); Vermögen max. 15.500 €. + Bei Haushaltsangehörigen erhöhen sich die Grenzen entsprechend. + Maßgeblich sind die jeweils gültigen Werte zum Zeitpunkt der Prüfung. +
+
+ +
+ + +
+ Bitte bestätigen Sie die Erklärung des Antragstellers, um fortzufahren. +
+
+
+ +
+ + {# ─── Optionale Einwilligung ───────────────────────────────────────────── #} +
+
+ Kommunikation (freiwillig) +
+ +
+ + + + Diese Einwilligung ist freiwillig und kann jederzeit widerrufen werden. + Ohne diese Einwilligung ist ggf. nur postalische Kommunikation möglich. + +
+
+ + {# Hidden: Zeitstempel der Einwilligung #} + + +
+
+ +{# JavaScript: Zeitstempel bei Seitenaufruf setzen, Pflichtfelder validieren #} + diff --git a/app/templates/portal/onboarding_basis.html b/app/templates/portal/onboarding_basis.html new file mode 100644 index 0000000..5a6f8e7 --- /dev/null +++ b/app/templates/portal/onboarding_basis.html @@ -0,0 +1,46 @@ + + + + + + {% block title %}Onboarding{% endblock %} – vHTV-Stiftung + + + + +
+
+

van Hees-Theyssen-Vogel'sche Stiftung

+

Onboarding-Antrag

+ {% block fortschritt %}{% endblock %} +
+
+
+
+
+ {% block inhalt %}{% endblock %} +
+
+
+ + + diff --git a/app/templates/portal/onboarding_danke.html b/app/templates/portal/onboarding_danke.html new file mode 100644 index 0000000..e2691eb --- /dev/null +++ b/app/templates/portal/onboarding_danke.html @@ -0,0 +1,24 @@ +{% extends "portal/onboarding_basis.html" %} +{% block title %}Antrag eingereicht{% endblock %} +{% block fortschritt %}{% endblock %} +{% block inhalt %} +
+
+
+

Ihr Antrag wurde eingereicht!

+

Vielen Dank für Ihre Angaben.

+

Ihr Onboarding-Antrag wurde erfolgreich übermittelt. Die Stiftung prüft Ihre Angaben und wird sich in Kürze mit Ihnen in Verbindung setzen.

+
+ Nächste Schritte: +
    +
  • Die Stiftung prüft Ihren Antrag (4-Augen-Prinzip durch den Vorstand).
  • +
  • Sie erhalten eine Rückmeldung per E-Mail an die angegebene Adresse.
  • +
  • Ggf. werden weitere Unterlagen angefordert.
  • +
+
+

+ Bei Fragen: van Hees-Theyssen-Vogel'sche Stiftung · Tel. 02858/836780 +

+
+
+{% endblock %} diff --git a/app/templates/portal/onboarding_fehler.html b/app/templates/portal/onboarding_fehler.html new file mode 100644 index 0000000..ca956a4 --- /dev/null +++ b/app/templates/portal/onboarding_fehler.html @@ -0,0 +1,24 @@ +{% extends "portal/onboarding_basis.html" %} +{% block title %}Fehler – Onboarding{% endblock %} +{% block fortschritt %}{% endblock %} +{% block inhalt %} +
+
+
+ {% if fehler_typ == "bereits_abgeschlossen" %} +

Dieser Link wurde bereits verwendet

+

Das Onboarding-Verfahren für diesen Einladungslink wurde bereits abgeschlossen.

+

Wenn Sie Fragen haben, wenden Sie sich bitte direkt an die Stiftung.

+ {% elif fehler_typ == "abgelaufen" %} +

Dieser Einladungslink ist abgelaufen

+

Der Einladungslink ist nicht mehr gültig. Bitte kontaktieren Sie die Stiftung, um einen neuen Link zu erhalten.

+ {% else %} +

Ungültiger Link

+

Dieser Einladungslink ist ungültig oder wurde nicht gefunden.

+ {% endif %} +

+ van Hees-Theyssen-Vogel'sche Stiftung · Raesfelder Str. 3, 46499 Hamminkeln · Tel. 02858/836780 +

+
+
+{% endblock %} diff --git a/app/templates/portal/onboarding_schritt1.html b/app/templates/portal/onboarding_schritt1.html new file mode 100644 index 0000000..5789da9 --- /dev/null +++ b/app/templates/portal/onboarding_schritt1.html @@ -0,0 +1,59 @@ +{% extends "portal/onboarding_basis.html" %} +{% block title %}Schritt 1: Datenschutz{% endblock %} +{% block fortschritt %} +
+

Schritt 1 von 5 – Datenschutz & Erklärung

+{% endblock %} +{% block inhalt %} +
+
+

Schritt 1: Datenschutzerklärung & Erklärung des Leistungsempfängers

+
+
+ {% if fehler %} +
{{ fehler }}
+ {% endif %} + +

Bitte lesen Sie die nachfolgende Datenschutzerklärung sowie die Erklärung des Leistungsempfängers und stimmen Sie beiden zu.

+ +
1. Datenschutzerklärung
+
+ Verantwortliche Stelle: van Hees-Theyssen-Vogel'sche Stiftung, Raesfelder Str. 3, 46499 Hamminkeln

+ Verarbeitungszweck: Die von Ihnen übermittelten personenbezogenen Daten werden ausschließlich zum Zweck der Prüfung und Gewährung von Stiftungsleistungen gemäß der Stiftungssatzung verarbeitet.

+ Rechtsgrundlage: Die Verarbeitung erfolgt auf Grundlage von Art. 6 Abs. 1 lit. b DSGVO (Vertragserfüllung) sowie für besondere Kategorien personenbezogener Daten (Einkommenssituation, Pflegegrad u.ä.) auf Grundlage Ihrer ausdrücklichen Einwilligung gem. Art. 9 Abs. 2 lit. a DSGVO.

+ Gespeicherte Daten: Name, Adresse, Geburtsdatum, Kontaktdaten, Einkommens- und Vermögensdaten, Ausbildungsnachweise, hochgeladene Dokumente.

+ Speicherdauer: Ihre Daten werden für die Dauer der Förderbeziehung sowie darüber hinaus für die gesetzlich vorgeschriebene Aufbewahrungszeit (i.d.R. 10 Jahre) gespeichert. Nicht angenommene Anträge werden nach 3 Jahren gelöscht.

+ Ihre Rechte: Sie haben das Recht auf Auskunft (Art. 15 DSGVO), Berichtigung (Art. 16 DSGVO), Löschung (Art. 17 DSGVO), Einschränkung der Verarbeitung (Art. 18 DSGVO) sowie das Recht auf Datenübertragbarkeit (Art. 20 DSGVO). Sie können Ihre Einwilligung jederzeit widerrufen, ohne dass die Rechtmäßigkeit der bis dahin erfolgten Verarbeitung berührt wird. Beschwerden können Sie an die Landesbeauftragte für Datenschutz und Informationsfreiheit NRW (LDI NRW) richten.

+ Weitergabe: Eine Weitergabe Ihrer Daten an Dritte erfolgt nur im gesetzlich zulässigen Rahmen (z.B. Steuerberater, Stiftungsaufsicht) oder auf Basis Ihrer ausdrücklichen Einwilligung. +
+ +
2. Erklärung des Leistungsempfängers
+
+

Das Merkblatt für die Bewilligung und Zahlung von Zuwendungen der van Hees-Theyssen-Vogel'schen Stiftung habe ich gelesen, verstanden und erkenne die dort genannten Angabepflichten als verbindlich an.

+

Hinsichtlich der Regelungen insbesondere des Sozialgesetzbuches und der Abgabenordnung habe ich mich kundig gemacht und, soweit für mein Verständnis der Regelungen erforderlich, fachlichen Rat eingeholt.

+

Ich verpflichte mich, alle erforderlichen Angaben unaufgefordert zu machen. Mir ist bekannt, dass Verstöße zur Einstellung jeglicher Förderung führen und rechtliche Folgen (z.B. Schadenersatz, strafrechtliche Folgen) nach sich ziehen, für die ich uneingeschränkt die Verantwortung übernehme.

+

Förderbedingungen (§ 53 AO): Grundsätzlich können nur Personen gefördert werden, die als Alleinstehende keine höheren monatlichen Bezüge als 2.245,00 € haben und deren Vermögen nicht zur nachhaltigen Verbesserung ihres Unterhalts ausreicht (Schonvermögen i.d.R. max. 15.500 €). Die Sätze erhöhen sich bei weiteren Haushaltsangehörigen.

+
+ +
+ {% csrf_token %} +
+ + +
+
+ + +
+ +
+ +
+
+
+
+{% endblock %} diff --git a/app/templates/portal/onboarding_schritt2.html b/app/templates/portal/onboarding_schritt2.html new file mode 100644 index 0000000..cd5c22f --- /dev/null +++ b/app/templates/portal/onboarding_schritt2.html @@ -0,0 +1,97 @@ +{% extends "portal/onboarding_basis.html" %} +{% block title %}Schritt 2: Persönliche Daten{% endblock %} +{% block fortschritt %} +
+

Schritt 2 von 5 – Persönliche Daten

+{% endblock %} +{% block inhalt %} +
+
+

Schritt 2: Persönliche Angaben

+
+
+ {% if fehler %} +
{{ fehler }}
+ {% endif %} +

Pflichtfelder sind mit * markiert. (Merkblatt Punkte 1–4)

+ +
+ {% csrf_token %} + +
+
+ + +
+
+ + +
+
+ + +
+
+ +
+
Adresse (Punkt 1)
+
+
+ + +
+
+ + +
+
+ + +
+
+ +
+
Kontaktdaten (Punkt 1)
+
+
+ + +
+
+ + +
+
+ + +
+
+ +
+
Verwandtschaftsverhältnis (Punkt 4)
+
+ + +
z.B. „Enkelin von Margarethe van Hees, Schwester des Stifters"
+
+
+ + +
+ +
+ + +
+
+
+
+{% endblock %} diff --git a/app/templates/portal/onboarding_schritt3.html b/app/templates/portal/onboarding_schritt3.html new file mode 100644 index 0000000..62cd756 --- /dev/null +++ b/app/templates/portal/onboarding_schritt3.html @@ -0,0 +1,86 @@ +{% extends "portal/onboarding_basis.html" %} +{% block title %}Schritt 3: Ausbildung/Studium{% endblock %} +{% block fortschritt %} +
+

Schritt 3 von 5 – Ausbildung & Studium

+{% endblock %} +{% block inhalt %} +
+
+

Schritt 3: Ausbildung & Studium

+
+
+ {% if fehler %} +
{{ fehler }}
+ {% endif %} +

(Merkblatt Punkte 5–6)

+ +
+ {% csrf_token %} + +
+ +
+ + +
+
+ + +
+
+ +
+
+ + +
+ +
+ + +
+ +
+ + +
Bitte Semester oder Monat/Jahr angeben.
+
+
+ +
+ Hinweis: Studienbescheinigungen und Ausbildungsnachweise können Sie im nächsten Schritt (Dokumente-Upload) hochladen. +
+ +
+ + +
+
+
+
+ + +{% endblock %} diff --git a/app/templates/portal/onboarding_schritt4.html b/app/templates/portal/onboarding_schritt4.html new file mode 100644 index 0000000..367ae6d --- /dev/null +++ b/app/templates/portal/onboarding_schritt4.html @@ -0,0 +1,97 @@ +{% extends "portal/onboarding_basis.html" %} +{% block title %}Schritt 4: Finanzielle Situation{% endblock %} +{% block fortschritt %} +
+

Schritt 4 von 5 – Finanzielle Situation

+{% endblock %} +{% block inhalt %} +
+
+

Schritt 4: Finanzielle Situation

+
+
+ {% if fehler %} +
{{ fehler }}
+ {% endif %} +

(Merkblatt Punkte 7–12)

+ +
+ Förderbedingungen gem. § 53 AO: Förderung ist möglich, wenn monatliche Bezüge als Alleinstehende(r) max. 2.245 € und das Vermögen max. 15.500 € betragen. Die Sätze erhöhen sich bei weiteren Haushaltsangehörigen. Bitte machen Sie genaue und vollständige Angaben. +
+ +
+ {% csrf_token %} + +
Haushaltssituation (Punkt 7)
+
+ + +
+
+ + +
+ +
+
Bezüge & Einkommen (Punkte 8–9)
+
+ + +
Einkommensteuerbescheid, Lohn-/Gehaltsnachweis, Rentenbescheid, Pflegegrad etc. bitte im nächsten Schritt hochladen.
+
+
+ + +
+
+ + +
Belege bitte im nächsten Schritt hochladen.
+
+ +
+
Wohnkosten & Vermögen (Punkte 10–11)
+
+ + +
Kopie des Mietvertrags bitte im nächsten Schritt hochladen.
+
+
+ + +
Spar-/Festgeldguthaben, Aktien, Immobilien etc. bitte angeben.
+
+ +
+
Lebensunterhalt (Punkt 12)
+
+ + +
+ +
+ + +
+
+
+
+{% endblock %} diff --git a/app/templates/portal/onboarding_schritt5.html b/app/templates/portal/onboarding_schritt5.html new file mode 100644 index 0000000..4999aa9 --- /dev/null +++ b/app/templates/portal/onboarding_schritt5.html @@ -0,0 +1,108 @@ +{% extends "portal/onboarding_basis.html" %} +{% block title %}Schritt 5: Zusammenfassung & Dokumente{% endblock %} +{% block fortschritt %} +
+

Schritt 5 von 5 – Zusammenfassung, Dokumente & Bestätigung

+{% endblock %} +{% block inhalt %} +
+
+

Schritt 5: Zusammenfassung, Dokumente & Bestätigung

+
+
+ {% if fehler %} +
{{ fehler }}
+ {% endif %} + +
Ihre Angaben im Überblick
+ + {% if data.schritt2 %} +
+ + + + + + + +
Name{{ data.schritt2.vorname }} {{ data.schritt2.nachname }}
Geburtsdatum{{ data.schritt2.geburtsdatum }}
Adresse{{ data.schritt2.strasse }}, {{ data.schritt2.plz }} {{ data.schritt2.ort }}
E-Mail{{ data.schritt2.email }}
Telefon{{ data.schritt2.telefon }}{% if data.schritt2.handynummer %} / {{ data.schritt2.handynummer }}{% endif %}
Verwandtschaft{{ data.schritt2.verwandtschaftsverhaeltnis }}
+
+ {% endif %} + + {% if data.schritt3 %} +
+ + + {% if data.schritt3.in_ausbildung %} + + + + {% endif %} +
In Ausbildung/Studium{% if data.schritt3.in_ausbildung %}Ja{% else %}Nein{% endif %}
Art{{ data.schritt3.ausbildungsart }}
Institution{{ data.schritt3.institution }}
Voraussichtl. Ende{{ data.schritt3.voraussichtliche_dauer }}
+
+ {% endif %} + + {% if data.schritt4 %} +
+ + + + + + + + + +
Haushaltstyp{{ data.schritt4.haushaltstyp }}
Haushaltspersonen{{ data.schritt4.haushaltsgroesse|default:"–" }}
Monatl. Bezüge{{ data.schritt4.monatliche_bezuege|default:"–" }} €
Art der Bezüge{{ data.schritt4.bezuege_art|default:"–" }}
Unterhalt{{ data.schritt4.unterhalt|default:"–" }}
Miete & Heizung{{ data.schritt4.miete_heizung|default:"–" }} €
Vermögen{{ data.schritt4.vermoegen|default:"–" }} €
Lebensunterhalt{{ data.schritt4.lebensunterhalt_aufwendungen|default:"–" }} €
+
+ {% endif %} + +
+
Dokumente hochladen
+

Laden Sie alle relevanten Nachweise hoch (Punkt 2, 3, 5, 8–10 des Merkblatts). Erlaubte Formate: PDF, JPG, PNG, TIFF – max. 20 MB je Datei.

+ +
+ {% csrf_token %} + +
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
Mehrfachauswahl möglich.
+
+ +
+
+ + +
+ +
+ + +
+
+
+
+{% endblock %} diff --git a/app/templates/portal/upload_danke.html b/app/templates/portal/upload_danke.html new file mode 100644 index 0000000..99d3bba --- /dev/null +++ b/app/templates/portal/upload_danke.html @@ -0,0 +1,38 @@ + + + + + + Unterlagen eingereicht – vHTV-Stiftung + + + +
+
+
✔️
+

Vielen Dank!

+

Ihre Unterlagen für das {{ halbjahr_label }} wurden erfolgreich eingereicht.

+
Einreichung bestätigt
+

Die van Hees-Theyssen-Vogel'sche Stiftung wird Ihre Unterlagen prüfen und sich bei Rückfragen bei Ihnen melden.

+

+ Bei Fragen wenden Sie sich an:
+ Tel. 02858/836780 • + Jan.Siebels@gmail.com +

+
+ +
+ + diff --git a/app/templates/portal/upload_fehler.html b/app/templates/portal/upload_fehler.html new file mode 100644 index 0000000..1ec9a61 --- /dev/null +++ b/app/templates/portal/upload_fehler.html @@ -0,0 +1,35 @@ + + + + + + Link ungültig – vHTV-Stiftung + + + +
+
+
🚫
+

Link nicht mehr gültig

+

{{ message }}

+
+ Bitte wenden Sie sich direkt an die Stiftung:
+ Tel. 02858/836780
+ Jan.Siebels@gmail.com +
+
+ +
+ + diff --git a/app/templates/portal/upload_formular.html b/app/templates/portal/upload_formular.html new file mode 100644 index 0000000..c56cbe4 --- /dev/null +++ b/app/templates/portal/upload_formular.html @@ -0,0 +1,169 @@ + + + + + + Unterlagen hochladen – vHTV-Stiftung + + + +
+
+

van Hees-Theyssen-Vogel'sche Stiftung

+

Sicheres Dokumenten-Upload-Portal

+
+
+
{{ halbjahr_label }}
+

Guten Tag, {{ destinataer.vorname }} {{ destinataer.nachname }},

+

bitte laden Sie hier Ihre Unterlagen für das {{ halbjahr_label }} hoch. + Für jede Kategorie können Sie eine Datei hochladen und/oder einen Text eingeben.

+ +

Pro Kategorie muss mindestens eine Datei oder ein Texteintrag eingereicht werden.

+ + {% if fehler %} +
{{ fehler }}
+ {% endif %} + +
+ {% csrf_token %} + + +
+

Studiennachweis

+

Semesterbescheinigung, Ausbildungsnachweis, Leistungsnachweise (Zeugnisse, Kreditpunkte etc.)

+ +
+ +
📄
+

Datei hierher ziehen oder klicken

+

PDF, JPG, PNG, TIFF • max. {{ max_dateigroesse_mb }} MB

+
+
    +

    — oder Texteintrag —

    + +
    + + +
    +

    Einkommenssituation

    +

    Einkommensteuerbescheid, Lohn-/Gehaltsnachweis, Rentenbescheid, Bescheinigung Pflegegrad etc.

    + +
    + +
    📄
    +

    Datei hierher ziehen oder klicken

    +

    PDF, JPG, PNG, TIFF • max. {{ max_dateigroesse_mb }} MB

    +
    +
      +

      — oder Texteintrag —

      + +
      + + +
      +

      Vermögenssituation

      +

      Angaben zu Spar-/Festgeldguthaben, Aktien, Immobilien etc.

      + +
      + +
      📄
      +

      Datei hierher ziehen oder klicken

      +

      PDF, JPG, PNG, TIFF • max. {{ max_dateigroesse_mb }} MB

      +
      +
        +

        — oder Texteintrag —

        + +
        + + +
        +

        Weitere Dokumente (optional)

        +

        Mietvertrag, Unterhaltsbelege, sonstige Nachweise

        + +
        + +
        📄
        +

        Datei hierher ziehen oder klicken

        +

        PDF, JPG, PNG, TIFF • max. {{ max_dateigroesse_mb }} MB

        +
        +
          +

          — oder Texteintrag —

          + +
          + + +
          + +

          ⏱ Gültig bis: {{ gueltig_bis|date:"d.m.Y" }}

          +
          + +
          + + + + diff --git a/app/templates/stiftung/administration.html b/app/templates/stiftung/administration.html index 2a100f5..3787f88 100644 --- a/app/templates/stiftung/administration.html +++ b/app/templates/stiftung/administration.html @@ -271,6 +271,12 @@ Dokumentenverwaltung + diff --git a/app/templates/stiftung/destinataer_detail.html b/app/templates/stiftung/destinataer_detail.html index 8253c6e..91faca8 100644 --- a/app/templates/stiftung/destinataer_detail.html +++ b/app/templates/stiftung/destinataer_detail.html @@ -63,6 +63,21 @@