Add Vorlagen editor, upload portal, onboarding, and participant import command
Some checks failed
CI/CD Pipeline / test (push) Has been cancelled
CI/CD Pipeline / deploy (push) Has been cancelled
Code Quality / quality (push) Has been cancelled

- Dokument-Vorlagen-Editor: create/edit/reset document templates (admin)
- Upload-Portal: public portal for Nachweis uploads via token
- Onboarding: invite Destinatäre via email with multi-step wizard
- Bestätigungsschreiben: preview and send confirmation letters
- Email settings: SMTP configuration UI
- Management command: import_veranstaltung_teilnehmer for bulk participant import

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
SysAdmin Agent
2026-03-21 09:25:18 +00:00
parent fdf078fa10
commit aed540fe4b
51 changed files with 5335 additions and 33 deletions

View File

@@ -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/"

View File

@@ -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

View File

@@ -0,0 +1,82 @@
"""
Management command to import participants into a Veranstaltung.
Usage:
python manage.py import_veranstaltung_teilnehmer <veranstaltung_id>
"""
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."))

View File

@@ -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

View File

@@ -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.")
)

View File

@@ -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'],
},
),
]

View File

@@ -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),
]

View File

@@ -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),
]

View File

@@ -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,
)

View File

@@ -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()
)

View File

@@ -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})"

View File

@@ -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/<str:token>/",
upload_formular,
name="upload_formular",
),
path(
"upload/<str:token>/danke/",
upload_danke,
name="upload_danke",
),
# Onboarding-Portal (neue Destinatäre Einladungs-Token)
path(
"onboarding/<str:token>/",
onboarding_schritt,
{"schritt": 1},
name="onboarding_start",
),
path(
"onboarding/<str:token>/schritt/<int:schritt>/",
onboarding_schritt,
name="onboarding_schritt",
),
path(
"onboarding/<str:token>/danke/",
onboarding_danke,
name="onboarding_danke",
),
]

View File

@@ -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}

View File

@@ -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/<uuid:nachweis_pk>/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/<uuid:pk>/widerrufen/",
views.onboarding_einladung_widerrufen,
name="onboarding_einladung_widerrufen",
),
# Bestätigungsschreiben
path(
"destinataere/<uuid:pk>/bestaetigung/",
views.bestaetigung_vorschau,
name="bestaetigung_vorschau",
),
path(
"destinataere/<uuid:pk>/bestaetigung/versenden/",
views.bestaetigung_versenden,
name="bestaetigung_versenden",
),
# Dokument-Vorlagen-Editor
path("administration/vorlagen/", views.vorlagen_liste, name="vorlagen_liste"),
path("administration/vorlagen/<uuid:pk>/", views.vorlage_editor, name="vorlage_editor"),
path("administration/vorlagen/<uuid:pk>/zuruecksetzen/", views.vorlage_zuruecksetzen, name="vorlage_zuruecksetzen"),
path("administration/vorlagen/<uuid:pk>/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"),

View File

@@ -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}")

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,211 @@
"""Views für den web-basierten Dokument-Vorlagen-Editor."""
from django.contrib import messages
from django.contrib.auth.decorators import login_required
from django.http import HttpResponse, JsonResponse
from django.shortcuts import get_object_or_404, redirect, render
from django.views.decorators.http import require_POST
from stiftung.models import DokumentVorlage
from stiftung.utils.vorlagen import get_vorlage_original
@login_required
def vorlagen_liste(request):
"""Übersicht aller Dokument-Vorlagen nach Kategorie."""
vorlagen = DokumentVorlage.objects.select_related("zuletzt_bearbeitet_von").all()
kategorien = {}
for v in vorlagen:
if v.kategorie not in kategorien:
kategorien[v.kategorie] = []
kategorien[v.kategorie].append(v)
# Kategorie-Labels
kategorie_labels = dict(DokumentVorlage.KATEGORIE_CHOICES)
return render(request, "stiftung/vorlagen_liste.html", {
"kategorien": kategorien,
"kategorie_labels": kategorie_labels,
"vorlagen_count": vorlagen.count(),
})
@login_required
def vorlage_editor(request, pk):
"""Editor für eine einzelne Vorlage."""
vorlage = get_object_or_404(DokumentVorlage, pk=pk)
if request.method == "POST":
html_inhalt = request.POST.get("html_inhalt", "")
vorlage.html_inhalt = html_inhalt
vorlage.zuletzt_bearbeitet_von = request.user
vorlage.save()
messages.success(request, f'Vorlage „{vorlage.bezeichnung}" wurde gespeichert.')
return redirect("stiftung:vorlage_editor", pk=pk)
try:
original = get_vorlage_original(vorlage.schluessel)
hat_original = True
except FileNotFoundError:
original = None
hat_original = False
import json
from django.utils.safestring import mark_safe
# JSON-encode and escape </script> to prevent XSS in script tag
html_json = json.dumps(vorlage.html_inhalt)
html_json = html_json.replace("<", "\\u003c").replace(">", "\\u003e")
# Serienbrief templates are full HTML documents with Django template tags
# ({% for %}, {% if %}) — Summernote WYSIWYG mangles these.
# Use a plain code editor textarea instead.
use_code_editor = vorlage.kategorie == "serienbrief"
return render(request, "stiftung/vorlage_editor.html", {
"vorlage": vorlage,
"hat_original": hat_original,
"variablen": vorlage.verfuegbare_variablen,
"html_inhalt_json": mark_safe(html_json),
"use_code_editor": use_code_editor,
})
@login_required
@require_POST
def vorlage_zuruecksetzen(request, pk):
"""Setzt eine Vorlage auf den Datei-Original-Inhalt zurück."""
vorlage = get_object_or_404(DokumentVorlage, pk=pk)
try:
original = get_vorlage_original(vorlage.schluessel)
vorlage.html_inhalt = original
vorlage.zuletzt_bearbeitet_von = request.user
vorlage.save()
messages.success(request, f'Vorlage „{vorlage.bezeichnung}" wurde auf die Original-Datei zurückgesetzt.')
except FileNotFoundError:
messages.error(request, "Original-Datei nicht gefunden. Zurücksetzen nicht möglich.")
return redirect("stiftung:vorlage_editor", pk=pk)
@login_required
@require_POST
def vorlagen_alle_zuruecksetzen(request):
"""Setzt ALLE Vorlagen auf die Original-Datei-Inhalte zurück."""
vorlagen = DokumentVorlage.objects.all()
restored = 0
for vorlage in vorlagen:
try:
original = get_vorlage_original(vorlage.schluessel)
vorlage.html_inhalt = original
vorlage.zuletzt_bearbeitet_von = request.user
vorlage.save()
restored += 1
except FileNotFoundError:
pass
messages.success(request, f"{restored} Vorlage(n) auf Original zurückgesetzt.")
return redirect("stiftung:vorlagen_liste")
@login_required
def vorlage_vorschau(request, pk):
"""Rendert eine Vorschau der Vorlage mit Beispieldaten (JSON-Response)."""
vorlage = get_object_or_404(DokumentVorlage, pk=pk)
# Rohinhalt aus POST (live-preview) oder aus DB
html_inhalt = request.POST.get("html_inhalt") if request.method == "POST" else vorlage.html_inhalt
# Einfache Beispieldaten je Kategorie
beispiel_context = _get_beispiel_context(vorlage.schluessel)
try:
from django.template import Context, Engine
engine = Engine.get_default()
t = engine.from_string(html_inhalt)
rendered = t.render(Context(beispiel_context))
return HttpResponse(rendered, content_type="text/html; charset=utf-8")
except Exception as exc:
return HttpResponse(
f"<pre style='color:red'>Template-Fehler: {exc}</pre>",
content_type="text/html; charset=utf-8",
)
def _get_beispiel_context(schluessel: str) -> dict:
"""Gibt Beispieldaten für Vorschau-Rendering zurück."""
from datetime import date, time
class FakeObj(dict):
def __getattr__(self, k):
return self.get(k, "")
destinataer = FakeObj(
vorname="Maria",
nachname="Mustermann",
anrede="Frau",
strasse="Musterstraße 1",
plz="46499",
ort="Hamminkeln",
email="m.mustermann@example.com",
)
einladung = FakeObj(
vorname="Maria",
nachname="Mustermann",
email="m.mustermann@example.com",
)
base = {
"destinataer": destinataer,
"einladung": einladung,
"datum": date.today(),
"zeitraum": "01.01.2025 31.12.2025",
"betrag_quartal": 500,
"betrag_jaehrlich": 2000,
"gesamtbetrag": 2000,
"zweck": "Studienförderung",
"unterstuetzungen": [],
"halbjahr_label": "1. Halbjahr 2025",
"upload_url": "https://vhtv-stiftung.de/portal/upload/beispiel-token/",
"gueltig_bis": date.today(),
"qr_code_base64": "",
"ist_erinnerung": False,
"onboarding_url": "https://vhtv-stiftung.de/portal/onboarding/beispiel/",
"veranstaltung": FakeObj(titel="Stiftungsessen 2025"),
"teilnehmer_list": [],
}
# Serienbrief-Vorlage: vollständige Veranstaltungs- und Teilnehmer-Beispieldaten
if "serienbrief" in schluessel:
base["veranstaltung"] = FakeObj(
titel="Stiftungsessen 2025",
datum=date.today(),
uhrzeit=time(18, 0),
ort="Gasthaus zur Linde",
adresse="Lindenstraße 12, 46499 Hamminkeln",
betreff="",
briefvorlage="",
unterschrift_1_name="Katrin Kleinpaß",
unterschrift_1_titel="Rentmeisterin",
unterschrift_2_name="Jan Remmer Siebels",
unterschrift_2_titel="Rentmeister",
)
base["teilnehmer"] = [
FakeObj(
anrede="Frau",
vorname="Maria",
nachname="Mustermann",
strasse="Musterstraße 1",
plz="46499",
ort="Hamminkeln",
),
FakeObj(
anrede="Herr",
vorname="Hans",
nachname="Beispiel",
strasse="Beispielweg 7",
plz="46499",
ort="Hamminkeln",
),
]
return base

View File

@@ -592,6 +592,10 @@
<i class="fas fa-users"></i>
<span>Destinataere</span>
</a>
<a class="sidebar-link" href="{% url 'stiftung:onboarding_einladung_liste' %}">
<i class="fas fa-user-plus"></i>
<span>Onboarding</span>
</a>
<a class="sidebar-link" href="{% url 'stiftung:foerderung_list' %}">
<i class="fas fa-gift"></i>
<span>Foerderungen</span>

View File

@@ -0,0 +1,56 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Bestätigung Ihrer Förderung van Hees-Theyssen-Vogel'sche Stiftung</title>
<style>
body { font-family: Arial, sans-serif; font-size: 15px; color: #222; background: #f5f5f5; margin: 0; padding: 0; }
.container { max-width: 600px; margin: 32px auto; background: #fff; border-radius: 8px; box-shadow: 0 2px 8px rgba(0,0,0,0.08); overflow: hidden; }
.header { background: #1a3a5c; color: #fff; padding: 28px 32px 20px; }
.header h1 { margin: 0 0 4px; font-size: 20px; }
.header p { margin: 0; font-size: 13px; opacity: 0.8; }
.body { padding: 28px 32px; }
.body p { line-height: 1.6; margin: 0 0 16px; }
.info-box { background: #f0f6ff; border: 1px solid #b0cce8; border-radius: 6px; padding: 16px 20px; margin: 20px 0; }
.info-box p { margin: 0 0 8px; }
.info-box p:last-child { margin: 0; }
.footer { background: #f0f0f0; padding: 16px 32px; font-size: 12px; color: #777; border-top: 1px solid #e0e0e0; }
</style>
</head>
<body>
<div class="container">
<div class="header">
<h1>van Hees-Theyssen-Vogel'sche Stiftung</h1>
<p>Bestätigung Ihrer Förderleistungen</p>
</div>
<div class="body">
<p>Sehr geehrte{% if destinataer.anrede == "Herr" %}r Herr{% elif destinataer.anrede == "Frau" %} Frau{% else %}{{ destinataer.anrede }}{% endif %} {{ destinataer.nachname }},</p>
<p>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 %}.</p>
<p>Das beigefügte Dokument gilt als offizieller Nachweis der erhaltenen Förderung.</p>
<div class="info-box">
<p><strong>Empfänger:</strong> {{ destinataer.vorname }} {{ destinataer.nachname }}</p>
{% if zeitraum %}<p><strong>Förderzeitraum:</strong> {{ zeitraum }}</p>{% endif %}
{% if gesamtbetrag %}<p><strong>Gesamtbetrag:</strong> {{ gesamtbetrag|floatformat:2 }} €</p>{% endif %}
<p><strong>Erstellt am:</strong> {{ datum|date:"d.m.Y" }}</p>
</div>
<p>Bei Fragen stehen wir Ihnen gerne zur Verfügung.</p>
<p>Mit freundlichen Grüßen</p>
<p><strong>Jan Remmer Siebels</strong> &amp; <strong>Katrin Kleinpaß</strong><br>
Rentmeister / Rentmeisterin<br>
van Hees-Theyssen-Vogel'sche Stiftung</p>
</div>
<div class="footer">
van Hees-Theyssen-Vogel'sche Stiftung &bull; Raesfelder Str. 3 &bull; 46499 Hamminkeln &bull; Tel. 02858/836780<br>
Diese E-Mail wurde automatisch erzeugt. Bitte antworten Sie nicht direkt auf diese E-Mail.
</div>
</div>
</body>
</html>

View File

@@ -0,0 +1,78 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{% if ist_erinnerung %}Erinnerung: {% endif %}Nachweis-Aufforderung {{ halbjahr_label }}</title>
<style>
body { font-family: Arial, sans-serif; font-size: 15px; color: #222; background: #f5f5f5; margin: 0; padding: 0; }
.container { max-width: 600px; margin: 32px auto; background: #fff; border-radius: 8px; box-shadow: 0 2px 8px rgba(0,0,0,0.08); overflow: hidden; }
.header { background: #1a3a5c; color: #fff; padding: 28px 32px 20px; }
.header h1 { margin: 0 0 4px; font-size: 20px; }
.header p { margin: 0; font-size: 13px; opacity: 0.8; }
.body { padding: 28px 32px; }
.body p { line-height: 1.6; margin: 0 0 16px; }
.cta-box { background: #f0f6ff; border: 1px solid #b0cce8; border-radius: 6px; padding: 20px; margin: 24px 0; text-align: center; }
.cta-button { display: inline-block; background: #1a3a5c; color: #fff !important; text-decoration: none; padding: 12px 28px; border-radius: 5px; font-weight: bold; font-size: 16px; margin-bottom: 12px; }
.qr-section { margin-top: 12px; }
.qr-section img { width: 140px; height: 140px; display: block; margin: 8px auto 0; }
.qr-section small { display: block; color: #666; font-size: 12px; margin-top: 4px; }
.info-list { background: #fafafa; border-left: 3px solid #1a3a5c; padding: 12px 16px; margin: 16px 0; }
.info-list li { margin-bottom: 4px; }
.footer { background: #f0f0f0; padding: 16px 32px; font-size: 12px; color: #777; border-top: 1px solid #e0e0e0; }
.reminder-banner { background: #fff3cd; border: 1px solid #ffc107; border-radius: 5px; padding: 10px 16px; margin-bottom: 16px; font-weight: bold; color: #856404; }
.url-fallback { font-size: 12px; color: #555; word-break: break-all; margin-top: 8px; }
</style>
</head>
<body>
<div class="container">
<div class="header">
<h1>van Hees-Theyssen-Vogel'sche Stiftung</h1>
<p>Nachweis-Aufforderung &bull; {{ halbjahr_label }}</p>
</div>
<div class="body">
{% if ist_erinnerung %}
<div class="reminder-banner">&#128276; Erinnerung: Ihre Unterlagen stehen noch aus</div>
{% endif %}
<p>Sehr geehrte(r) {{ destinataer.vorname }} {{ destinataer.nachname }},</p>
{% if ist_erinnerung %}
<p>wir möchten Sie daran erinnern, dass Ihre Unterlagen für das <strong>{{ halbjahr_label }}</strong> noch ausstehen.</p>
<p>Der Upload-Link läuft am <strong>{{ gueltig_bis|date:"d.m.Y" }}</strong> ab.</p>
{% else %}
<p>die van Hees-Theyssen-Vogel'sche Stiftung bittet Sie, Ihre Unterlagen für das <strong>{{ halbjahr_label }}</strong> einzureichen.</p>
<p>Bitte reichen Sie bis zum <strong>{{ gueltig_bis|date:"d.m.Y" }}</strong> folgende Unterlagen ein:</p>
<ul class="info-list">
<li>Semesterbescheinigung / Ausbildungsnachweis</li>
<li>Leistungsnachweise (Zeugnisse, Kreditpunkte etc.)</li>
<li>Nachweis über Einkommenssituation und Vermögensverhältnisse</li>
</ul>
{% endif %}
<div class="cta-box">
<a href="{{ upload_url }}" class="cta-button">Unterlagen hochladen</a>
{% if qr_code_base64 %}
<div class="qr-section">
<small>Oder QR-Code scannen:</small>
<img src="data:image/png;base64,{{ qr_code_base64 }}" alt="QR-Code für Upload-Link">
</div>
{% endif %}
<p class="url-fallback">{{ upload_url }}</p>
</div>
<p>Dieser Link ist <strong>einmalig verwendbar</strong> und gültig bis <strong>{{ gueltig_bis|date:"d.m.Y" }}</strong>.</p>
<p>Falls Sie Fragen haben, wenden Sie sich bitte direkt an die Stiftung:<br>
Tel. 02858/836780 &bull; <a href="mailto:Jan.Siebels@gmail.com">Jan.Siebels@gmail.com</a></p>
<p>Mit freundlichen Grüßen</p>
<p><strong>Jan Remmer Siebels</strong> &amp; <strong>Katrin Kleinpaß</strong><br>
Rentmeister / Rentmeisterin</p>
</div>
<div class="footer">
van Hees-Theyssen-Vogel'sche Stiftung &bull; Raesfelder Str. 3 &bull; 46499 Hamminkeln &bull; Tel. 02858/836780<br>
Diese E-Mail wurde automatisch erzeugt. Bitte antworten Sie nicht direkt auf diese E-Mail.
</div>
</div>
</body>
</html>

View File

@@ -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.

View File

@@ -0,0 +1,63 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Onboarding-Einladung vHTV-Stiftung</title>
<style>
body { font-family: Arial, sans-serif; color: #333; background: #f5f5f5; margin: 0; padding: 0; }
.container { max-width: 600px; margin: 32px auto; background: #fff; border-radius: 8px; overflow: hidden; box-shadow: 0 2px 8px rgba(0,0,0,0.08); }
.header { background: #2c5f2e; color: #fff; padding: 32px 32px 24px; }
.header h1 { margin: 0; font-size: 22px; }
.header p { margin: 8px 0 0; opacity: 0.85; font-size: 14px; }
.body { padding: 32px; }
.body p { line-height: 1.6; }
.btn { display: inline-block; margin: 24px 0 16px; padding: 14px 28px; background: #2c5f2e; color: #fff !important; text-decoration: none; border-radius: 6px; font-size: 16px; font-weight: bold; }
.info-box { background: #f0f7f0; border-left: 4px solid #2c5f2e; padding: 16px; margin: 20px 0; border-radius: 4px; }
.info-box ul { margin: 8px 0; padding-left: 20px; }
.info-box li { margin: 6px 0; }
.link-fallback { font-size: 12px; color: #666; word-break: break-all; margin-top: 8px; }
.footer { background: #f8f8f8; padding: 20px 32px; font-size: 12px; color: #888; border-top: 1px solid #eee; }
.expiry { color: #c0392b; font-weight: bold; }
</style>
</head>
<body>
<div class="container">
<div class="header">
<h1>van Hees-Theyssen-Vogel'sche Stiftung</h1>
<p>Einladung zum Onboarding-Verfahren</p>
</div>
<div class="body">
<p>Sehr geehrte Damen und Herren,{% if einladung.vorname %} liebe/r <strong>{{ einladung.vorname }}{% if einladung.nachname %} {{ einladung.nachname }}{% endif %}</strong>{% endif %},</p>
<p>Sie wurden zur Aufnahme in die <strong>van Hees-Theyssen-Vogel'sche Stiftung</strong> eingeladen. Um das Antragsverfahren zu starten, klicken Sie bitte auf den folgenden Button:</p>
<a href="{{ onboarding_url }}" class="btn">Jetzt Onboarding starten</a>
<p class="link-fallback">Oder kopieren Sie diesen Link in Ihren Browser:<br>{{ onboarding_url }}</p>
<p class="expiry">Dieser Link ist gültig bis: {{ gueltig_bis|date:"d.m.Y H:i" }} Uhr.</p>
<div class="info-box">
<strong>Was Sie erwartet:</strong>
<ul>
<li>Zustimmung zur Datenschutzerklärung</li>
<li>Persönliche Daten (gemäß Stiftungsmerkblatt)</li>
<li>Angaben zu Ausbildung / Studium</li>
<li>Angaben zur finanziellen Situation</li>
<li>Upload relevanter Dokumente</li>
</ul>
</div>
<p>Nach Abschluss des Verfahrens prüft der Vorstand Ihren Antrag und setzt sich mit Ihnen in Verbindung.</p>
<p>Bei Fragen wenden Sie sich bitte an uns:</p>
</div>
<div class="footer">
van Hees-Theyssen-Vogel'sche Stiftung<br>
Raesfelder Str. 3, 46499 Hamminkeln<br>
Tel. 02858/836780
</div>
</div>
</body>
</html>

View File

@@ -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

View File

@@ -0,0 +1,254 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<title>Bestätigung {{ destinataer.vorname }} {{ destinataer.nachname }}</title>
<style>
@page {
size: A4;
margin: 2cm 2.5cm 2cm 2.5cm;
}
body {
font-family: "Times New Roman", Times, serif;
font-size: 10pt;
line-height: 1.4;
color: #000;
}
/* Absenderzeile (klein, über Adressfeld) */
.absender-zeile {
font-size: 7.5pt;
border-bottom: 1px solid #000;
margin-bottom: 3pt;
padding-bottom: 1pt;
color: #444;
}
/* Empfängeradresse */
.empfaenger {
min-height: 35mm;
margin-bottom: 8mm;
}
.empfaenger p {
margin: 0;
line-height: 1.3;
}
/* Datum und Ort */
.datum-zeile {
text-align: right;
margin-bottom: 6mm;
}
/* Betreff */
.betreff {
font-weight: bold;
margin-bottom: 6mm;
}
/* Brieftext */
.brieftext p {
margin: 0 0 4mm 0;
text-align: justify;
}
/* Tabelle der Unterstützungen */
.unterstuetzungs-tabelle {
width: 100%;
border-collapse: collapse;
margin: 6mm 0;
font-size: 9.5pt;
}
.unterstuetzungs-tabelle th {
border-bottom: 1.5px solid #000;
padding: 2mm 3mm;
text-align: left;
font-weight: bold;
}
.unterstuetzungs-tabelle td {
border-bottom: 0.5px solid #ccc;
padding: 2mm 3mm;
}
.unterstuetzungs-tabelle tr:last-child td {
border-bottom: 1.5px solid #000;
}
.text-right { text-align: right; }
/* Gesamtsumme */
.summen-zeile td {
font-weight: bold;
padding-top: 3mm;
}
/* Unterschrift */
.unterschrift {
margin-top: 12mm;
display: table;
width: 100%;
}
.unterschrift-person {
display: inline-block;
width: 45%;
vertical-align: top;
}
.unterschrift-linie {
border-top: 1px solid #000;
margin-bottom: 2mm;
width: 80%;
}
.stiftungsname-header {
font-size: 12pt;
font-weight: bold;
margin-bottom: 1mm;
}
.stiftungsadresse {
font-size: 8.5pt;
color: #444;
margin-bottom: 6mm;
}
.footer-hinweis {
margin-top: 14mm;
font-size: 8pt;
color: #555;
border-top: 0.5px solid #ccc;
padding-top: 3mm;
}
</style>
</head>
<body>
<!-- Stiftungskopf -->
<div class="stiftungsname-header">van Hees-Theyssen-Vogel'sche Stiftung</div>
<div class="stiftungsadresse">
Raesfelder Str. 3 &nbsp;·&nbsp; 46499 Hamminkeln
</div>
<!-- Empfänger (DIN 5008) -->
<div class="empfaenger">
<div class="absender-zeile">van Hees-Theyssen-Vogel'sche Stiftung · Raesfelder Str. 3 · 46499 Hamminkeln</div>
{% if destinataer.anrede %}<p>{{ destinataer.anrede }}</p>{% endif %}
<p>{{ destinataer.vorname }} {{ destinataer.nachname }}</p>
{% if destinataer.strasse %}<p>{{ destinataer.strasse }}</p>{% endif %}
{% if destinataer.plz or destinataer.ort %}<p>{{ destinataer.plz }} {{ destinataer.ort }}</p>{% endif %}
</div>
<!-- Datum -->
<div class="datum-zeile">
Hamminkeln, den {{ datum|date:"j. F Y" }}
</div>
<!-- Betreff -->
<div class="betreff">
Bestätigung über Förderleistungen der van Hees-Theyssen-Vogel'schen Stiftung
</div>
<!-- Brieftext -->
<div class="brieftext">
<p>
Sehr geehrte{% if destinataer.anrede == "Herr" %}r Herr{% elif destinataer.anrede == "Frau" %} Frau{% else %}
{{ destinataer.anrede }}{% endif %} {{ destinataer.nachname }},
</p>
<p>
wir freuen uns, Ihnen hiermit die durch die van Hees-Theyssen-Vogel'sche
Stiftung gewährte finanzielle Unterstützung zu bestätigen.
</p>
{% if betrag_quartal %}
<p>
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.
</p>
{% endif %}
<p>
Diese Bestätigung dient Ihnen als offizieller Nachweis der erhaltenen
Förderung gegenüber Behörden, Bildungseinrichtungen oder anderen
zuständigen Stellen.
</p>
<p>
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.
</p>
{% if unterstuetzungen %}
<p>Im {% if zeitraum %}Zeitraum {{ zeitraum }}{% else %}zurückliegenden Förderzeitraum{% endif %}
wurden Ihnen folgende Leistungen gewährt:</p>
<table class="unterstuetzungs-tabelle">
<thead>
<tr>
<th>Datum</th>
<th>Beschreibung / Zweck</th>
<th class="text-right">Betrag</th>
<th>Status</th>
</tr>
</thead>
<tbody>
{% for u in unterstuetzungen %}
<tr>
<td>{{ u.faellig_am|date:"d.m.Y" }}</td>
<td>{{ u.beschreibung|default:"Förderleistung" }}</td>
<td class="text-right">{{ u.betrag|floatformat:2 }} €</td>
<td>{{ u.get_status_display }}</td>
</tr>
{% endfor %}
</tbody>
<tfoot>
<tr class="summen-zeile">
<td colspan="2"><strong>Gesamtbetrag</strong></td>
<td class="text-right"><strong>{{ gesamtbetrag|floatformat:2 }} €</strong></td>
<td></td>
</tr>
</tfoot>
</table>
{% endif %}
<p>
Wir wünschen Ihnen weiterhin viel Erfolg bei Ihrem Vorhaben und freuen uns,
Sie durch unsere Stiftung unterstützen zu dürfen.
</p>
<p>Mit freundlichen Grüßen</p>
</div>
<!-- Unterschriften -->
<div class="unterschrift">
<div class="unterschrift-person">
<div class="unterschrift-linie"></div>
Jan Remmer Siebels<br>
Rentmeister<br>
van Hees-Theyssen-Vogel'sche Stiftung
</div>
<div class="unterschrift-person">
<div class="unterschrift-linie"></div>
Katrin Kleinpaß<br>
Rentmeisterin<br>
van Hees-Theyssen-Vogel'sche Stiftung
</div>
</div>
<div class="footer-hinweis">
van Hees-Theyssen-Vogel'sche Stiftung &nbsp;·&nbsp; Raesfelder Str. 3 &nbsp;·&nbsp; 46499 Hamminkeln
&nbsp;·&nbsp; Tel. 02858/836780 &nbsp;·&nbsp; buero@vhtv-stiftung.de<br>
Gemeinnützige Stiftung des bürgerlichen Rechts
</div>
</body>
</html>

View File

@@ -0,0 +1,424 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Datenschutzerklärung van Hees-Theyssen-Vogel'sche Stiftung</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
<link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css" rel="stylesheet">
<style>
:root {
--racing-green: #004225;
--racing-green-light: #006837;
}
body {
background-color: #f8f9fa;
font-size: 0.95rem;
line-height: 1.7;
color: #333;
}
.portal-header {
background: linear-gradient(135deg, var(--racing-green) 0%, var(--racing-green-light) 100%);
color: white;
padding: 2rem 0;
}
.portal-header h1 {
font-size: 1.5rem;
font-weight: 600;
margin-bottom: 0.25rem;
}
.portal-header .subtitle {
font-size: 0.9rem;
opacity: 0.85;
}
.content-card {
background: white;
border-radius: 12px;
box-shadow: 0 2px 12px rgba(0,0,0,0.08);
padding: 2.5rem;
margin-bottom: 2rem;
}
h2 {
color: var(--racing-green);
font-size: 1.15rem;
font-weight: 600;
border-bottom: 2px solid var(--racing-green);
padding-bottom: 0.4rem;
margin-top: 2rem;
margin-bottom: 1rem;
}
h2:first-of-type {
margin-top: 0;
}
h3 {
font-size: 1rem;
font-weight: 600;
color: #333;
margin-top: 1.25rem;
}
.dsgvo-article {
background: #f0f7f4;
border-left: 4px solid var(--racing-green);
border-radius: 0 6px 6px 0;
padding: 0.75rem 1rem;
margin: 0.5rem 0 1rem 0;
font-size: 0.88rem;
color: #004225;
}
.rights-list li {
padding: 0.4rem 0;
border-bottom: 1px solid #f0f0f0;
}
.rights-list li:last-child {
border-bottom: none;
}
.footer-note {
font-size: 0.82rem;
color: #6c757d;
text-align: center;
padding: 1.5rem 0;
}
.ao-hinweis {
background: #fff8e1;
border: 1px solid #ffc107;
border-radius: 8px;
padding: 1rem 1.25rem;
margin: 1rem 0;
font-size: 0.88rem;
}
@media print {
.portal-header { background: #004225 !important; -webkit-print-color-adjust: exact; }
.content-card { box-shadow: none; border: 1px solid #ddd; }
}
</style>
</head>
<body>
<!-- Header -->
<div class="portal-header">
<div class="container">
<div class="row align-items-center">
<div class="col">
<h1><i class="fas fa-shield-alt me-2"></i>Datenschutzerklärung</h1>
<div class="subtitle">van Hees-Theyssen-Vogel'sche Stiftung &middot; Destinatär-Portal</div>
</div>
<div class="col-auto text-end">
<small class="opacity-75">Stand: März 2026</small>
</div>
</div>
</div>
</div>
<!-- Content -->
<div class="container my-4">
<div class="content-card">
<!-- 1. Verantwortliche Stelle -->
<h2><i class="fas fa-building me-2"></i>1. Verantwortliche Stelle</h2>
<p>
Verantwortliche Stelle im Sinne der Datenschutz-Grundverordnung (DSGVO) und des Bundesdatenschutzgesetzes (BDSG) ist:
</p>
<address class="ms-3">
<strong>van Hees-Theyssen-Vogel'sche Stiftung</strong><br>
Raesfelder Str. 3<br>
46499 Hamminkeln<br><br>
<i class="fas fa-envelope me-1"></i> stiftung@vhtv-stiftung.de
</address>
<p class="text-muted small">
Die Stiftung ist als gemeinnützige Familienstiftung anerkannt und verfolgt ausschließlich und unmittelbar
gemeinnützige Zwecke im Sinne des § 52 der Abgabenordnung (AO).
</p>
<!-- 2. Grundsätze -->
<h2><i class="fas fa-gavel me-2"></i>2. Grundsätze der Datenverarbeitung</h2>
<p>
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).
</p>
<p>
Wir erheben, verarbeiten und speichern Ihre personenbezogenen Daten grundsätzlich nur
</p>
<ul>
<li>mit Ihrer ausdrücklichen Einwilligung (Art. 6 Abs. 1 lit. a DSGVO), oder</li>
<li>zur Erfüllung einer vertraglichen oder vorvertraglichen Verpflichtung (Art. 6 Abs. 1 lit. b DSGVO), oder</li>
<li>aufgrund einer rechtlichen Verpflichtung (Art. 6 Abs. 1 lit. c DSGVO), oder</li>
<li>zur Wahrung berechtigter Interessen (Art. 6 Abs. 1 lit. f DSGVO).</li>
</ul>
<!-- 3. Upload-Portal -->
<h2><i class="fas fa-upload me-2"></i>3. Nachweis-Upload-Portal (bestehende Destinatäre)</h2>
<p>
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.
</p>
<h3>3.1 Verarbeitete Daten</h3>
<ul>
<li><strong>Hochgeladene Dokumente</strong> (Studienbescheinigungen, Einkommensnachweise, Vermögensnachweise)</li>
<li><strong>IP-Adresse</strong> (ausschließlich als kryptographischer Hash gespeichert; keine Rückführung möglich)</li>
<li><strong>Zeitstempel</strong> der Token-Einlösung und des Dokumenten-Uploads</li>
<li><strong>Token-Status</strong> (eingelöst / abgelaufen)</li>
</ul>
<h3>3.2 Rechtsgrundlage</h3>
<div class="dsgvo-article">
<strong>Art. 6 Abs. 1 lit. b DSGVO</strong> — 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.
</div>
<h3>3.3 Zweck der Verarbeitung</h3>
<p>
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.
</p>
<div class="ao-hinweis">
<i class="fas fa-info-circle me-2 text-warning"></i>
<strong>Hinweis gemäß § 53 AO:</strong> 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.
</div>
<h3>3.4 Speicherdauer und Löschkonzept</h3>
<table class="table table-sm table-bordered">
<thead class="table-light">
<tr>
<th>Datenkategorie</th>
<th>Speicherdauer</th>
<th>Begründung</th>
</tr>
</thead>
<tbody>
<tr>
<td>Upload-Token (abgelaufen, nicht eingelöst)</td>
<td>90 Tage nach Ablauf</td>
<td>Nachvollziehbarkeit von Zustellungsversuchen</td>
</tr>
<tr>
<td>IP-Hash</td>
<td>90 Tage</td>
<td>Sicherheit / Missbrauchsschutz</td>
</tr>
<tr>
<td>Hochgeladene Nachweisdokumente</td>
<td>10 Jahre nach letzter Unterstützungsleistung</td>
<td>Steuerrechtliche Aufbewahrungspflichten (§ 147 AO)</td>
</tr>
<tr>
<td>Zeitstempel und Protokolldaten</td>
<td>10 Jahre</td>
<td>Gemeinnützigkeitsnachweis gegenüber Finanzbehörden</td>
</tr>
</tbody>
</table>
<!-- 4. Onboarding -->
<h2><i class="fas fa-user-plus me-2"></i>4. Onboarding-Formular (neue Destinatäre)</h2>
<p>
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.
</p>
<h3>4.1 Erhobene Datenkategorien</h3>
<p><strong>Persönliche Identifikationsdaten:</strong></p>
<ul>
<li>Vor- und Nachname, Geburtsdatum, Geburtsort</li>
<li>Anschrift (Straße, PLZ, Ort)</li>
<li>Telefon- und Mobilnummer, E-Mail-Adresse</li>
<li>Kopie des Personalausweises oder Reisepasses</li>
<li>Tabellarischer Lebenslauf</li>
</ul>
<p><strong>Verwandtschaftsnachweis:</strong></p>
<ul>
<li>Nachweis des Verwandtschaftsverhältnisses zu einem Geschwisterteil des Stifters
Hendrik van Hees oder seiner Ehefrau Aletta Theyssen-Vogel</li>
</ul>
<p><strong>Ausbildungs- und Studiendaten:</strong></p>
<ul>
<li>Aktueller Ausbildungs-/Studienstatus</li>
<li>Studienbescheinigung oder Ausbildungsnachweis</li>
<li>Voraussichtliche Dauer der Ausbildung/des Studiums</li>
</ul>
<p><strong>Finanzdaten:</strong></p>
<ul>
<li>Haushaltsgröße und Zusammensetzung des Haushalts</li>
<li>Bezüge und Einkünfte (einschließlich Leistungen nach SGB)</li>
<li>Einkommensteuerbescheid, Lohn-/Gehaltsnachweis, ggf. Rentenbescheid</li>
<li>Unterhaltsleistungen und sonstige Bezüge</li>
<li>Miet- und Heizungsaufwendungen (ggf. Mietvertragskopie)</li>
<li>Vermögensübersicht (Spareinlagen, Wertpapiere, Immobilien)</li>
<li>Monatliche Aufwendungen für Lebensunterhalt und Ausbildung</li>
</ul>
<h3>4.2 Rechtsgrundlagen</h3>
<div class="dsgvo-article">
<strong>Art. 6 Abs. 1 lit. b DSGVO</strong> — Verarbeitung zur Durchführung vorvertraglicher
Maßnahmen im Rahmen der Aufnahme als Destinatär der Stiftung.<br><br>
<strong>Art. 9 Abs. 2 lit. b DSGVO</strong> — 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.<br><br>
<strong>Art. 6 Abs. 1 lit. a DSGVO</strong> — Einwilligung für die Verarbeitung freiwillig
übermittelter Daten, die über das gesetzlich Erforderliche hinausgehen.
</div>
<h3>4.3 Zweck der Verarbeitung</h3>
<p>
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).
</p>
<h3>4.4 Vier-Augen-Prinzip und Freigabeverfahren</h3>
<p>
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.
</p>
<h3>4.5 Speicherdauer</h3>
<p>
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).
</p>
<!-- 5. Technische Sicherheit -->
<h2><i class="fas fa-lock me-2"></i>5. Technische Sicherheitsmaßnahmen</h2>
<ul>
<li><strong>HTTPS-Verschlüsselung</strong> für alle Datenübertragungen</li>
<li><strong>Token-basierter Zugang</strong> (kryptographisch sicher, 64-Zeichen-Token, einmalig nutzbar)</li>
<li><strong>IP-Anonymisierung</strong> durch SHA-256-Hash; keine Klartextspeicherung</li>
<li><strong>CSRF-Schutz</strong> für alle Formularübertragungen</li>
<li><strong>Rate Limiting</strong> zum Schutz vor Missbrauch</li>
<li><strong>Automatische Tokenablaufzeit</strong> (30 Tage)</li>
<li><strong>Dateivalidierung</strong> (nur PDF, JPG, PNG; maximale Dateigröße 20 MB)</li>
</ul>
<!-- 6. Keine automatisierte Entscheidungsfindung -->
<h2><i class="fas fa-robot me-2"></i>6. Keine automatisierte Entscheidungsfindung</h2>
<p>
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.
</p>
<!-- 7. Weitergabe an Dritte -->
<h2><i class="fas fa-share-alt me-2"></i>7. Weitergabe personenbezogener Daten</h2>
<p>
Eine Weitergabe Ihrer personenbezogenen Daten an Dritte erfolgt grundsätzlich nicht.
Ausnahmen gelten nur, soweit:
</p>
<ul>
<li>eine gesetzliche Verpflichtung zur Weitergabe besteht (z. B. im Rahmen
von Steuerprüfungen oder behördlichen Anfragen), oder</li>
<li>Sie ausdrücklich in die Weitergabe eingewilligt haben.</li>
</ul>
<p>
Auftragsverarbeiter (z. B. Hosting-Dienstleister) sind vertraglich zur Einhaltung
der DSGVO verpflichtet und dürfen die Daten nur zu den vereinbarten Zwecken verwenden.
</p>
<!-- 8. Ihre Rechte -->
<h2><i class="fas fa-user-shield me-2"></i>8. Ihre Rechte als betroffene Person</h2>
<p>
Sie haben nach der Datenschutz-Grundverordnung folgende Rechte gegenüber der
verantwortlichen Stelle:
</p>
<ul class="rights-list list-unstyled">
<li>
<i class="fas fa-eye text-success me-2"></i>
<strong>Auskunftsrecht (Art. 15 DSGVO):</strong> Sie haben das Recht, Auskunft über
die zu Ihrer Person gespeicherten Daten zu erhalten.
</li>
<li>
<i class="fas fa-edit text-primary me-2"></i>
<strong>Berichtigungsrecht (Art. 16 DSGVO):</strong> Sie haben das Recht, unrichtige
oder unvollständige Daten berichtigen zu lassen.
</li>
<li>
<i class="fas fa-trash text-danger me-2"></i>
<strong>Löschungsrecht (Art. 17 DSGVO):</strong> Sie haben das Recht auf Löschung
Ihrer Daten, soweit keine gesetzlichen Aufbewahrungspflichten entgegenstehen.
</li>
<li>
<i class="fas fa-pause text-warning me-2"></i>
<strong>Einschränkungsrecht (Art. 18 DSGVO):</strong> Sie haben das Recht, die
Verarbeitung Ihrer Daten unter bestimmten Voraussetzungen einschränken zu lassen.
</li>
<li>
<i class="fas fa-hand-paper text-secondary me-2"></i>
<strong>Widerspruchsrecht (Art. 21 DSGVO):</strong> 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.
</li>
<li>
<i class="fas fa-download text-info me-2"></i>
<strong>Datenübertragbarkeit (Art. 20 DSGVO):</strong> Sie haben das Recht, die Ihnen
bereitgestellten Daten in einem strukturierten, maschinenlesbaren Format zu erhalten.
</li>
<li>
<i class="fas fa-undo text-dark me-2"></i>
<strong>Widerrufsrecht (Art. 7 Abs. 3 DSGVO):</strong> 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.
</li>
</ul>
<p>
Zur Ausübung Ihrer Rechte wenden Sie sich bitte schriftlich oder per E-Mail an die
verantwortliche Stelle (siehe Abschnitt 1).
</p>
<!-- 9. Beschwerderecht -->
<h2><i class="fas fa-balance-scale me-2"></i>9. Beschwerderecht bei der Aufsichtsbehörde</h2>
<p>
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:
</p>
<address class="ms-3">
<strong>Landesbeauftragte für Datenschutz und Informationsfreiheit Nordrhein-Westfalen</strong><br>
Postfach 20 04 44<br>
40102 Düsseldorf<br>
<i class="fas fa-globe me-1"></i> www.ldi.nrw.de
</address>
<!-- 10. Änderungen -->
<h2><i class="fas fa-history me-2"></i>10. Änderungen dieser Datenschutzerklärung</h2>
<p>
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.
</p>
</div><!-- /content-card -->
<div class="footer-note">
&copy; van Hees-Theyssen-Vogel'sche Stiftung &middot; Raesfelder Str. 3, 46499 Hamminkeln &middot;
stiftung@vhtv-stiftung.de &middot; Stand: März 2026
</div>
</div><!-- /container -->
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
</body>
</html>

View File

@@ -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 #}
<div class="dse-panel card border-0 shadow-sm mb-4">
<div class="card-header" style="background: linear-gradient(135deg, #004225 0%, #006837 100%); color: white;">
<h5 class="mb-0">
<i class="fas fa-shield-alt me-2"></i>
Datenschutz &amp; Einwilligung
</h5>
<small class="opacity-85">Bitte lesen Sie die folgenden Erklärungen sorgfältig und bestätigen Sie diese, um fortzufahren.</small>
</div>
<div class="card-body p-4">
{# ─── Datenschutzerklärung ─────────────────────────────────────────────── #}
<div class="section mb-4">
<h6 class="text-uppercase text-muted fw-bold mb-3" style="font-size: 0.78rem; letter-spacing: 0.05em;">
<i class="fas fa-file-alt me-1"></i> Datenschutzerklärung
</h6>
<div class="border rounded p-3 bg-light" style="max-height: 280px; overflow-y: auto; font-size: 0.88rem; line-height: 1.6;">
<p><strong>Verantwortliche Stelle:</strong><br>
van Hees-Theyssen-Vogel'sche Stiftung, Raesfelder Str. 3, 46499 Hamminkeln<br>
E-Mail: {{ stiftung_email|default:"stiftung@vhtv-stiftung.de" }}</p>
<p><strong>Zweck der Datenerhebung:</strong><br>
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.</p>
<p><strong>Erhobene Daten:</strong>
Persönliche Identifikationsdaten (Name, Adresse, Geburtsdatum, Kontaktdaten),
Identitätsnachweise, Verwandtschaftsnachweis, Ausbildungs-/Studiendaten sowie
Angaben zur finanziellen Situation (Einkommen, Vermögen, Haushaltskosten).</p>
<p><strong>Rechtsgrundlagen:</strong>
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.</p>
<p><strong>Speicherdauer:</strong>
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).</p>
<p><strong>Ihre Rechte:</strong>
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.</p>
<p><strong>Beschwerderecht:</strong>
Sie haben das Recht, sich bei der Landesbeauftragten für Datenschutz und
Informationsfreiheit NRW zu beschweren (www.ldi.nrw.de).</p>
<p class="mb-0">
<a href="{% url 'portal:datenschutzerklaerung' %}" target="_blank" class="text-decoration-none">
<i class="fas fa-external-link-alt me-1"></i>
Vollständige Datenschutzerklärung öffnen
</a>
</p>
</div>
<div class="form-check mt-3">
<input class="form-check-input" type="checkbox" id="zustimmung_dse" name="zustimmung_dse"
value="1" required>
<label class="form-check-label fw-semibold" for="zustimmung_dse">
Ich habe die Datenschutzerklärung gelesen und verstanden und stimme der
Verarbeitung meiner personenbezogenen Daten zum genannten Zweck zu.
</label>
<div class="invalid-feedback">
Bitte bestätigen Sie die Datenschutzerklärung, um fortzufahren.
</div>
</div>
</div>
<hr class="my-4">
{# ─── Erklärung des Leistungsempfängers ───────────────────────────────── #}
<div class="section mb-4">
<h6 class="text-uppercase text-muted fw-bold mb-3" style="font-size: 0.78rem; letter-spacing: 0.05em;">
<i class="fas fa-pen-fancy me-1"></i> Erklärung des Antragstellers (gemäß Stiftungsmerkblatt)
</h6>
<div class="border rounded p-3 bg-light" style="font-size: 0.88rem; line-height: 1.7;">
<p>
Ich erkläre, dass meine Angaben in diesem Formular sowie in allen beigefügten Unterlagen
<strong>vollständig und wahrheitsgemäß</strong> 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.
</p>
<p>
Ich verpflichte mich, <strong>Änderungen meiner Einkommens- und Vermögenssituation</strong>
sowie meines Ausbildungsstatus unverzüglich der Stiftung mitzuteilen, sobald diese
zu einer Änderung der Anspruchsvoraussetzungen führen könnten.
</p>
<p class="mb-0">
Mir ist bekannt, dass die Stiftung ihre Unterstützungsleistungen nach Maßgabe
des <strong>§ 53 Abgabenordnung (AO)</strong> erbringt und daher die Einhaltung
der dort genannten Einkommens- und Vermögensgrenzen regelmäßig überprüfen muss.
</p>
<div class="mt-3 p-2 rounded" style="background: #fff8e1; border: 1px solid #ffc107; font-size: 0.82rem;">
<i class="fas fa-info-circle me-1 text-warning"></i>
<strong>Aktuelle Grenzwerte gemäß § 53 Nr. 2 AO (Stand 01/2024):</strong>
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.
</div>
</div>
<div class="form-check mt-3">
<input class="form-check-input" type="checkbox" id="zustimmung_erklaerung" name="zustimmung_erklaerung"
value="1" required>
<label class="form-check-label fw-semibold" for="zustimmung_erklaerung">
Ich bestätige die vorstehende Erklärung und erkenne die Angabepflichten
sowie die Folgen unvollständiger oder falscher Angaben an.
</label>
<div class="invalid-feedback">
Bitte bestätigen Sie die Erklärung des Antragstellers, um fortzufahren.
</div>
</div>
</div>
<hr class="my-4">
{# ─── Optionale Einwilligung ───────────────────────────────────────────── #}
<div class="section mb-2">
<h6 class="text-uppercase text-muted fw-bold mb-3" style="font-size: 0.78rem; letter-spacing: 0.05em;">
<i class="fas fa-envelope-open-text me-1"></i> Kommunikation (freiwillig)
</h6>
<div class="form-check">
<input class="form-check-input" type="checkbox" id="zustimmung_kommunikation"
name="zustimmung_kommunikation" value="1">
<label class="form-check-label" for="zustimmung_kommunikation">
Ich bin damit einverstanden, dass die Stiftung mich per E-Mail über
Fristen, Nachweisverpflichtungen und stiftungsbezogene Informationen kontaktiert.
</label>
<small class="d-block text-muted mt-1">
Diese Einwilligung ist freiwillig und kann jederzeit widerrufen werden.
Ohne diese Einwilligung ist ggf. nur postalische Kommunikation möglich.
</small>
</div>
</div>
{# Hidden: Zeitstempel der Einwilligung #}
<input type="hidden" name="einwilligung_zeitstempel" id="einwilligung_zeitstempel">
</div><!-- /card-body -->
</div><!-- /dse-panel -->
{# JavaScript: Zeitstempel bei Seitenaufruf setzen, Pflichtfelder validieren #}
<script>
(function () {
// Zeitstempel der Anzeige setzen (nicht des Absendens, soll als Nachweis dienen)
var ts = document.getElementById('einwilligung_zeitstempel');
if (ts) {
ts.value = new Date().toISOString();
}
// Bootstrap-Validierung für Pflicht-Checkboxen
var form = document.querySelector('form');
if (form) {
form.addEventListener('submit', function (evt) {
var dse = document.getElementById('zustimmung_dse');
var erkl = document.getElementById('zustimmung_erklaerung');
var valid = true;
if (dse && !dse.checked) {
dse.classList.add('is-invalid');
valid = false;
} else if (dse) {
dse.classList.remove('is-invalid');
}
if (erkl && !erkl.checked) {
erkl.classList.add('is-invalid');
valid = false;
} else if (erkl) {
erkl.classList.remove('is-invalid');
}
if (!valid) {
evt.preventDefault();
evt.stopPropagation();
// Zum ersten Fehler scrollen
var firstInvalid = form.querySelector('.is-invalid');
if (firstInvalid) {
firstInvalid.scrollIntoView({ behavior: 'smooth', block: 'center' });
}
}
}, true);
// Live-Feedback bei Checkbox-Änderung
['zustimmung_dse', 'zustimmung_erklaerung'].forEach(function (id) {
var el = document.getElementById(id);
if (el) {
el.addEventListener('change', function () {
if (this.checked) {
this.classList.remove('is-invalid');
this.classList.add('is-valid');
} else {
this.classList.remove('is-valid');
}
});
}
});
}
})();
</script>

View File

@@ -0,0 +1,46 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{% block title %}Onboarding{% endblock %} vHTV-Stiftung</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
<style>
:root { --gruen: #004225; --gruen-hell: #006837; }
body { background: #f8f9fa; font-size: 0.95rem; line-height: 1.7; color: #333; }
.portal-header { background: linear-gradient(135deg, var(--gruen) 0%, var(--gruen-hell) 100%); color: #fff; padding: 1.5rem 0; }
.portal-header h1 { font-size: 1.4rem; font-weight: 600; margin-bottom: 0.25rem; }
.portal-header .subtitle { font-size: 0.85rem; opacity: 0.85; }
.fortschritt-bar { background: rgba(255,255,255,0.2); border-radius: 4px; height: 8px; margin-top: 12px; }
.fortschritt-fill { background: #fff; border-radius: 4px; height: 8px; transition: width 0.3s; }
.fortschritt-label { font-size: 0.8rem; opacity: 0.9; margin-top: 4px; }
.card { border: none; box-shadow: 0 2px 12px rgba(0,0,0,0.08); }
.card-header { background: #e8f4ee; border-bottom: 2px solid var(--gruen); }
.card-header h2 { font-size: 1.15rem; color: var(--gruen); margin: 0; }
.btn-weiter { background: var(--gruen); border-color: var(--gruen); }
.btn-weiter:hover { background: var(--gruen-hell); border-color: var(--gruen-hell); }
.btn-zurueck { border-color: #aaa; color: #555; }
.required-mark { color: #c00; }
.hinweis-box { background: #fff8e1; border-left: 4px solid #f0ad4e; border-radius: 4px; padding: 12px 16px; font-size: 0.9rem; }
.dse-scroll { max-height: 300px; overflow-y: auto; border: 1px solid #dee2e6; border-radius: 4px; padding: 16px; background: #fff; font-size: 0.85rem; }
.portal-footer { text-align: center; font-size: 0.8rem; color: #aaa; margin-top: 2rem; padding-bottom: 2rem; }
</style>
</head>
<body>
<div class="portal-header">
<div class="container">
<h1>van Hees-Theyssen-Vogel'sche Stiftung</h1>
<p class="subtitle mb-0">Onboarding-Antrag</p>
{% block fortschritt %}{% endblock %}
</div>
</div>
<div class="container py-4">
<div class="row justify-content-center">
<div class="col-12 col-md-8 col-lg-7">
{% block inhalt %}{% endblock %}
</div>
</div>
</div>
<div class="portal-footer">van Hees-Theyssen-Vogel'sche Stiftung · Raesfelder Str. 3, 46499 Hamminkeln · Tel. 02858/836780</div>
</body>
</html>

View File

@@ -0,0 +1,24 @@
{% extends "portal/onboarding_basis.html" %}
{% block title %}Antrag eingereicht{% endblock %}
{% block fortschritt %}{% endblock %}
{% block inhalt %}
<div class="card mb-3">
<div class="card-body text-center py-5">
<div style="font-size: 3rem; color: #2c5f2e; margin-bottom: 16px;"></div>
<h2 style="color: #2c5f2e;">Ihr Antrag wurde eingereicht!</h2>
<p class="lead mt-3">Vielen Dank für Ihre Angaben.</p>
<p>Ihr Onboarding-Antrag wurde erfolgreich übermittelt. Die Stiftung prüft Ihre Angaben und wird sich in Kürze mit Ihnen in Verbindung setzen.</p>
<div class="hinweis-box mt-4 text-start">
<strong>Nächste Schritte:</strong>
<ul class="mt-2 mb-0">
<li>Die Stiftung prüft Ihren Antrag (4-Augen-Prinzip durch den Vorstand).</li>
<li>Sie erhalten eine Rückmeldung per E-Mail an die angegebene Adresse.</li>
<li>Ggf. werden weitere Unterlagen angefordert.</li>
</ul>
</div>
<p class="mt-4 text-muted small">
Bei Fragen: van Hees-Theyssen-Vogel'sche Stiftung · Tel. 02858/836780
</p>
</div>
</div>
{% endblock %}

View File

@@ -0,0 +1,24 @@
{% extends "portal/onboarding_basis.html" %}
{% block title %}Fehler Onboarding{% endblock %}
{% block fortschritt %}{% endblock %}
{% block inhalt %}
<div class="card mb-3">
<div class="card-body text-center py-5">
<div style="font-size: 2.5rem; color: #c0392b; margin-bottom: 16px;"></div>
{% if fehler_typ == "bereits_abgeschlossen" %}
<h2 class="text-danger">Dieser Link wurde bereits verwendet</h2>
<p>Das Onboarding-Verfahren für diesen Einladungslink wurde bereits abgeschlossen.</p>
<p>Wenn Sie Fragen haben, wenden Sie sich bitte direkt an die Stiftung.</p>
{% elif fehler_typ == "abgelaufen" %}
<h2 class="text-danger">Dieser Einladungslink ist abgelaufen</h2>
<p>Der Einladungslink ist nicht mehr gültig. Bitte kontaktieren Sie die Stiftung, um einen neuen Link zu erhalten.</p>
{% else %}
<h2 class="text-danger">Ungültiger Link</h2>
<p>Dieser Einladungslink ist ungültig oder wurde nicht gefunden.</p>
{% endif %}
<p class="mt-4 text-muted small">
van Hees-Theyssen-Vogel'sche Stiftung · Raesfelder Str. 3, 46499 Hamminkeln · Tel. 02858/836780
</p>
</div>
</div>
{% endblock %}

View File

@@ -0,0 +1,59 @@
{% extends "portal/onboarding_basis.html" %}
{% block title %}Schritt 1: Datenschutz{% endblock %}
{% block fortschritt %}
<div class="fortschritt-bar"><div class="fortschritt-fill" style="width:20%"></div></div>
<p class="fortschritt-label">Schritt 1 von 5 Datenschutz &amp; Erklärung</p>
{% endblock %}
{% block inhalt %}
<div class="card mb-3">
<div class="card-header py-3">
<h2>Schritt 1: Datenschutzerklärung &amp; Erklärung des Leistungsempfängers</h2>
</div>
<div class="card-body">
{% if fehler %}
<div class="alert alert-danger">{{ fehler }}</div>
{% endif %}
<p class="text-muted mb-3">Bitte lesen Sie die nachfolgende Datenschutzerklärung sowie die Erklärung des Leistungsempfängers und stimmen Sie beiden zu.</p>
<h5>1. Datenschutzerklärung</h5>
<div class="dse-scroll mb-2">
<strong>Verantwortliche Stelle:</strong> van Hees-Theyssen-Vogel'sche Stiftung, Raesfelder Str. 3, 46499 Hamminkeln<br><br>
<strong>Verarbeitungszweck:</strong> Die von Ihnen übermittelten personenbezogenen Daten werden ausschließlich zum Zweck der Prüfung und Gewährung von Stiftungsleistungen gemäß der Stiftungssatzung verarbeitet.<br><br>
<strong>Rechtsgrundlage:</strong> 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.<br><br>
<strong>Gespeicherte Daten:</strong> Name, Adresse, Geburtsdatum, Kontaktdaten, Einkommens- und Vermögensdaten, Ausbildungsnachweise, hochgeladene Dokumente.<br><br>
<strong>Speicherdauer:</strong> 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.<br><br>
<strong>Ihre Rechte:</strong> 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.<br><br>
<strong>Weitergabe:</strong> Eine Weitergabe Ihrer Daten an Dritte erfolgt nur im gesetzlich zulässigen Rahmen (z.B. Steuerberater, Stiftungsaufsicht) oder auf Basis Ihrer ausdrücklichen Einwilligung.
</div>
<h5 class="mt-4">2. Erklärung des Leistungsempfängers</h5>
<div class="dse-scroll mb-2">
<p>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.</p>
<p>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.</p>
<p>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.</p>
<p><strong>Förderbedingungen (§ 53 AO):</strong> 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.</p>
</div>
<form method="post">
{% csrf_token %}
<div class="form-check mt-3">
<input class="form-check-input" type="checkbox" name="dse_zustimmung" id="dse_zustimmung" required>
<label class="form-check-label" for="dse_zustimmung">
Ich habe die <strong>Datenschutzerklärung</strong> gelesen und stimme der Verarbeitung meiner personenbezogenen Daten zu. <span class="required-mark">*</span>
</label>
</div>
<div class="form-check mt-2">
<input class="form-check-input" type="checkbox" name="merkblatt_zustimmung" id="merkblatt_zustimmung" required>
<label class="form-check-label" for="merkblatt_zustimmung">
Ich habe die <strong>Erklärung des Leistungsempfängers</strong> gelesen und erkenne die Angabepflichten als verbindlich an. <span class="required-mark">*</span>
</label>
</div>
<div class="d-flex justify-content-end mt-4">
<button type="submit" class="btn btn-primary btn-weiter px-4">Weiter →</button>
</div>
</form>
</div>
</div>
{% endblock %}

View File

@@ -0,0 +1,97 @@
{% extends "portal/onboarding_basis.html" %}
{% block title %}Schritt 2: Persönliche Daten{% endblock %}
{% block fortschritt %}
<div class="fortschritt-bar"><div class="fortschritt-fill" style="width:40%"></div></div>
<p class="fortschritt-label">Schritt 2 von 5 Persönliche Daten</p>
{% endblock %}
{% block inhalt %}
<div class="card mb-3">
<div class="card-header py-3">
<h2>Schritt 2: Persönliche Angaben</h2>
</div>
<div class="card-body">
{% if fehler %}
<div class="alert alert-danger">{{ fehler }}</div>
{% endif %}
<p class="text-muted small">Pflichtfelder sind mit <span class="required-mark">*</span> markiert. (Merkblatt Punkte 14)</p>
<form method="post">
{% csrf_token %}
<div class="row g-3 mb-3">
<div class="col-sm-6">
<label for="vorname" class="form-label">Vorname <span class="required-mark">*</span></label>
<input type="text" class="form-control{% if 'vorname' in fehlende_felder %} is-invalid{% endif %}" id="vorname" name="vorname" value="{{ post_data.vorname|default:data.schritt2.vorname|default:'' }}" required>
</div>
<div class="col-sm-6">
<label for="nachname" class="form-label">Nachname <span class="required-mark">*</span></label>
<input type="text" class="form-control{% if 'nachname' in fehlende_felder %} is-invalid{% endif %}" id="nachname" name="nachname" value="{{ post_data.nachname|default:data.schritt2.nachname|default:'' }}" required>
</div>
<div class="col-sm-6">
<label for="geburtsdatum" class="form-label">Geburtsdatum <span class="required-mark">*</span></label>
<input type="date" class="form-control{% if 'geburtsdatum' in fehlende_felder %} is-invalid{% endif %}" id="geburtsdatum" name="geburtsdatum" value="{{ post_data.geburtsdatum|default:data.schritt2.geburtsdatum|default:'' }}" required>
</div>
</div>
<hr class="my-3">
<h6 class="text-muted">Adresse (Punkt 1)</h6>
<div class="row g-3 mb-3">
<div class="col-12">
<label for="strasse" class="form-label">Straße und Hausnummer <span class="required-mark">*</span></label>
<input type="text" class="form-control{% if 'strasse' in fehlende_felder %} is-invalid{% endif %}" id="strasse" name="strasse" value="{{ post_data.strasse|default:data.schritt2.strasse|default:'' }}" required>
</div>
<div class="col-sm-4">
<label for="plz" class="form-label">PLZ <span class="required-mark">*</span></label>
<input type="text" class="form-control{% if 'plz' in fehlende_felder %} is-invalid{% endif %}" id="plz" name="plz" maxlength="10" value="{{ post_data.plz|default:data.schritt2.plz|default:'' }}" required>
</div>
<div class="col-sm-8">
<label for="ort" class="form-label">Ort <span class="required-mark">*</span></label>
<input type="text" class="form-control{% if 'ort' in fehlende_felder %} is-invalid{% endif %}" id="ort" name="ort" value="{{ post_data.ort|default:data.schritt2.ort|default:'' }}" required>
</div>
</div>
<hr class="my-3">
<h6 class="text-muted">Kontaktdaten (Punkt 1)</h6>
<div class="row g-3 mb-3">
<div class="col-sm-6">
<label for="telefon" class="form-label">Telefonnummer <span class="required-mark">*</span></label>
<input type="tel" class="form-control{% if 'telefon' in fehlende_felder %} is-invalid{% endif %}" id="telefon" name="telefon" value="{{ post_data.telefon|default:data.schritt2.telefon|default:'' }}" required>
</div>
<div class="col-sm-6">
<label for="handynummer" class="form-label">Handynummer</label>
<input type="tel" class="form-control" id="handynummer" name="handynummer" value="{{ post_data.handynummer|default:data.schritt2.handynummer|default:'' }}">
</div>
<div class="col-12">
<label for="email" class="form-label">E-Mail-Adresse <span class="required-mark">*</span></label>
<input type="email" class="form-control{% if 'email' in fehlende_felder %} is-invalid{% endif %}" id="email" name="email" value="{{ post_data.email|default:data.schritt2.email|default:einladung.email }}" required>
</div>
</div>
<hr class="my-3">
<h6 class="text-muted">Verwandtschaftsverhältnis (Punkt 4)</h6>
<div class="mb-3">
<label for="verwandtschaftsverhaeltnis" class="form-label">
Verwandtschaftsverhältnis zu einem Geschwisterteil des Stifters Hendrik van Hees oder seiner Ehefrau Aletta Theyssen-Vogel <span class="required-mark">*</span>
</label>
<textarea class="form-control{% if 'verwandtschaftsverhaeltnis' in fehlende_felder %} is-invalid{% endif %}" id="verwandtschaftsverhaeltnis" name="verwandtschaftsverhaeltnis" rows="2" required>{{ post_data.verwandtschaftsverhaeltnis|default:data.schritt2.verwandtschaftsverhaeltnis|default:'' }}</textarea>
<div class="form-text">z.B. „Enkelin von Margarethe van Hees, Schwester des Stifters"</div>
</div>
<div class="mb-3">
<label for="familienzweig" class="form-label">Familienzweig</label>
<select class="form-select" id="familienzweig" name="familienzweig">
<option value=""> bitte wählen </option>
<option value="hauptzweig" {% if data.schritt2.familienzweig == 'hauptzweig' or post_data.familienzweig == 'hauptzweig' %}selected{% endif %}>Hauptzweig</option>
<option value="nebenzweig" {% if data.schritt2.familienzweig == 'nebenzweig' or post_data.familienzweig == 'nebenzweig' %}selected{% endif %}>Nebenzweig</option>
<option value="verwandt" {% if data.schritt2.familienzweig == 'verwandt' or post_data.familienzweig == 'verwandt' %}selected{% endif %}>Verwandt</option>
<option value="anderer" {% if data.schritt2.familienzweig == 'anderer' or post_data.familienzweig == 'anderer' %}selected{% endif %}>Anderer</option>
</select>
</div>
<div class="d-flex justify-content-between mt-4">
<button type="submit" name="aktion" value="zurueck" class="btn btn-outline-secondary btn-zurueck">← Zurück</button>
<button type="submit" class="btn btn-primary btn-weiter px-4">Weiter →</button>
</div>
</form>
</div>
</div>
{% endblock %}

View File

@@ -0,0 +1,86 @@
{% extends "portal/onboarding_basis.html" %}
{% block title %}Schritt 3: Ausbildung/Studium{% endblock %}
{% block fortschritt %}
<div class="fortschritt-bar"><div class="fortschritt-fill" style="width:60%"></div></div>
<p class="fortschritt-label">Schritt 3 von 5 Ausbildung &amp; Studium</p>
{% endblock %}
{% block inhalt %}
<div class="card mb-3">
<div class="card-header py-3">
<h2>Schritt 3: Ausbildung &amp; Studium</h2>
</div>
<div class="card-body">
{% if fehler %}
<div class="alert alert-danger">{{ fehler }}</div>
{% endif %}
<p class="text-muted small">(Merkblatt Punkte 56)</p>
<form method="post">
{% csrf_token %}
<div class="mb-4">
<label class="form-label fw-bold">Befinden Sie sich derzeit in einer Ausbildung oder einem Studium? <span class="required-mark">*</span></label>
<div class="form-check">
<input class="form-check-input" type="radio" name="in_ausbildung" id="ausbildung_ja" value="ja"
{% if data.schritt3.in_ausbildung %}checked{% endif %}>
<label class="form-check-label" for="ausbildung_ja">Ja</label>
</div>
<div class="form-check">
<input class="form-check-input" type="radio" name="in_ausbildung" id="ausbildung_nein" value="nein"
{% if data.schritt3.in_ausbildung == False or not data.schritt3 %}checked{% endif %}>
<label class="form-check-label" for="ausbildung_nein">Nein</label>
</div>
</div>
<div id="ausbildung-felder">
<div class="mb-3">
<label for="ausbildungsart" class="form-label">Art der Ausbildung / des Studiums (Punkt 5)</label>
<select class="form-select" id="ausbildungsart" name="ausbildungsart">
<option value=""> bitte wählen </option>
<option value="studium" {% if data.schritt3.ausbildungsart == 'studium' %}selected{% endif %}>Studium (Universität/FH)</option>
<option value="berufsausbildung" {% if data.schritt3.ausbildungsart == 'berufsausbildung' %}selected{% endif %}>Berufsausbildung</option>
<option value="berufsschule" {% if data.schritt3.ausbildungsart == 'berufsschule' %}selected{% endif %}>Berufsschule</option>
<option value="promotionsstudium" {% if data.schritt3.ausbildungsart == 'promotionsstudium' %}selected{% endif %}>Promotionsstudium</option>
<option value="weiterbildung" {% if data.schritt3.ausbildungsart == 'weiterbildung' %}selected{% endif %}>Berufliche Weiterbildung</option>
<option value="sonstiges" {% if data.schritt3.ausbildungsart == 'sonstiges' %}selected{% endif %}>Sonstiges</option>
</select>
</div>
<div class="mb-3">
<label for="institution" class="form-label">Name der Hochschule / Ausbildungsstätte</label>
<input type="text" class="form-control" id="institution" name="institution"
value="{{ data.schritt3.institution|default:'' }}"
placeholder="z.B. Universität Münster, IHK Duisburg">
</div>
<div class="mb-3">
<label for="voraussichtliche_dauer" class="form-label">Voraussichtliches Ende der Ausbildung / des Studiums (Punkt 6)</label>
<input type="text" class="form-control" id="voraussichtliche_dauer" name="voraussichtliche_dauer"
value="{{ data.schritt3.voraussichtliche_dauer|default:'' }}"
placeholder="z.B. Sommersemester 2027 oder 08/2026">
<div class="form-text">Bitte Semester oder Monat/Jahr angeben.</div>
</div>
</div>
<div class="hinweis-box mt-3">
<strong>Hinweis:</strong> Studienbescheinigungen und Ausbildungsnachweise können Sie im nächsten Schritt (Dokumente-Upload) hochladen.
</div>
<div class="d-flex justify-content-between mt-4">
<button type="submit" name="aktion" value="zurueck" class="btn btn-outline-secondary btn-zurueck">← Zurück</button>
<button type="submit" class="btn btn-primary btn-weiter px-4">Weiter →</button>
</div>
</form>
</div>
</div>
<script>
function toggleAusbildungsFelder() {
const ja = document.getElementById('ausbildung_ja').checked;
document.getElementById('ausbildung-felder').style.opacity = ja ? '1' : '0.4';
}
document.getElementById('ausbildung_ja').addEventListener('change', toggleAusbildungsFelder);
document.getElementById('ausbildung_nein').addEventListener('change', toggleAusbildungsFelder);
toggleAusbildungsFelder();
</script>
{% endblock %}

View File

@@ -0,0 +1,97 @@
{% extends "portal/onboarding_basis.html" %}
{% block title %}Schritt 4: Finanzielle Situation{% endblock %}
{% block fortschritt %}
<div class="fortschritt-bar"><div class="fortschritt-fill" style="width:80%"></div></div>
<p class="fortschritt-label">Schritt 4 von 5 Finanzielle Situation</p>
{% endblock %}
{% block inhalt %}
<div class="card mb-3">
<div class="card-header py-3">
<h2>Schritt 4: Finanzielle Situation</h2>
</div>
<div class="card-body">
{% if fehler %}
<div class="alert alert-danger">{{ fehler }}</div>
{% endif %}
<p class="text-muted small">(Merkblatt Punkte 712)</p>
<div class="hinweis-box mb-4">
<strong>Förderbedingungen gem. § 53 AO:</strong> Förderung ist möglich, wenn monatliche Bezüge als Alleinstehende(r) max. <strong>2.245 €</strong> und das Vermögen max. <strong>15.500 €</strong> betragen. Die Sätze erhöhen sich bei weiteren Haushaltsangehörigen. Bitte machen Sie genaue und vollständige Angaben.
</div>
<form method="post">
{% csrf_token %}
<h6 class="text-muted mt-2">Haushaltssituation (Punkt 7)</h6>
<div class="mb-3">
<label for="haushaltstyp" class="form-label">Sind Sie alleinstehend oder Haushaltsvorstand?</label>
<select class="form-select" id="haushaltstyp" name="haushaltstyp">
<option value=""> bitte wählen </option>
<option value="alleinstehend" {% if data.schritt4.haushaltstyp == 'alleinstehend' %}selected{% endif %}>Alleinstehend</option>
<option value="haushaltsvorstand" {% if data.schritt4.haushaltstyp == 'haushaltsvorstand' %}selected{% endif %}>Haushaltsvorstand</option>
<option value="sonstiges" {% if data.schritt4.haushaltstyp == 'sonstiges' %}selected{% endif %}>Sonstiges</option>
</select>
</div>
<div class="mb-4">
<label for="haushaltsgroesse" class="form-label">Welche weiteren Personen/Angehörige leben gegebenenfalls in Ihrem Haushalt?</label>
<textarea class="form-control" id="haushaltsgroesse" name="haushaltsgroesse" rows="2"
placeholder="z.B. Ehepartner/in (45 J.), 2 Kinder (8 J., 12 J.)">{{ data.schritt4.haushaltsgroesse|default:'' }}</textarea>
</div>
<hr class="my-3">
<h6 class="text-muted">Bezüge &amp; Einkommen (Punkte 89)</h6>
<div class="mb-3">
<label for="monatliche_bezuege" class="form-label">Monatliche Bezüge im Sinne des Sozialgesetzbuches (€) (Punkt 8)</label>
<input type="text" class="form-control" id="monatliche_bezuege" name="monatliche_bezuege"
value="{{ data.schritt4.monatliche_bezuege|default:'' }}"
placeholder="z.B. 1.200,00">
<div class="form-text">Einkommensteuerbescheid, Lohn-/Gehaltsnachweis, Rentenbescheid, Pflegegrad etc. bitte im nächsten Schritt hochladen.</div>
</div>
<div class="mb-3">
<label for="bezuege_art" class="form-label">Art der Bezüge (bitte kurz beschreiben)</label>
<input type="text" class="form-control" id="bezuege_art" name="bezuege_art"
value="{{ data.schritt4.bezuege_art|default:'' }}"
placeholder="z.B. BAföG, Rente, Gehalt Teilzeit, Unterhalt">
</div>
<div class="mb-4">
<label for="unterhalt" class="form-label">Unterhaltsleistungen oder sonstige Bezüge, falls zutreffend (€) (Punkt 9)</label>
<input type="text" class="form-control" id="unterhalt" name="unterhalt"
value="{{ data.schritt4.unterhalt|default:'' }}"
placeholder="z.B. 400,00 monatlich vom Vater">
<div class="form-text">Belege bitte im nächsten Schritt hochladen.</div>
</div>
<hr class="my-3">
<h6 class="text-muted">Wohnkosten &amp; Vermögen (Punkte 1011)</h6>
<div class="mb-3">
<label for="miete_heizung" class="form-label">Miet- und Heizungsaufwendungen pro Monat (€) (Punkt 10)</label>
<input type="text" class="form-control" id="miete_heizung" name="miete_heizung"
value="{{ data.schritt4.miete_heizung|default:'' }}"
placeholder="z.B. 650,00">
<div class="form-text">Kopie des Mietvertrags bitte im nächsten Schritt hochladen.</div>
</div>
<div class="mb-4">
<label for="vermoegen" class="form-label">Gesamtvermögen (€) (Punkt 11)</label>
<input type="text" class="form-control" id="vermoegen" name="vermoegen"
value="{{ data.schritt4.vermoegen|default:'' }}"
placeholder="z.B. 3.500,00 (Spar- und Girokonto)">
<div class="form-text">Spar-/Festgeldguthaben, Aktien, Immobilien etc. bitte angeben.</div>
</div>
<hr class="my-3">
<h6 class="text-muted">Lebensunterhalt (Punkt 12)</h6>
<div class="mb-3">
<label for="lebensunterhalt_aufwendungen" class="form-label">Aufwendungen für den Lebensunterhalt und ggf. Unterricht/Studium pro Monat (€)</label>
<input type="text" class="form-control" id="lebensunterhalt_aufwendungen" name="lebensunterhalt_aufwendungen"
value="{{ data.schritt4.lebensunterhalt_aufwendungen|default:'' }}"
placeholder="z.B. 1.100,00">
</div>
<div class="d-flex justify-content-between mt-4">
<button type="submit" name="aktion" value="zurueck" class="btn btn-outline-secondary btn-zurueck">← Zurück</button>
<button type="submit" class="btn btn-primary btn-weiter px-4">Weiter →</button>
</div>
</form>
</div>
</div>
{% endblock %}

View File

@@ -0,0 +1,108 @@
{% extends "portal/onboarding_basis.html" %}
{% block title %}Schritt 5: Zusammenfassung &amp; Dokumente{% endblock %}
{% block fortschritt %}
<div class="fortschritt-bar"><div class="fortschritt-fill" style="width:100%"></div></div>
<p class="fortschritt-label">Schritt 5 von 5 Zusammenfassung, Dokumente &amp; Bestätigung</p>
{% endblock %}
{% block inhalt %}
<div class="card mb-3">
<div class="card-header py-3">
<h2>Schritt 5: Zusammenfassung, Dokumente &amp; Bestätigung</h2>
</div>
<div class="card-body">
{% if fehler %}
<div class="alert alert-danger">{{ fehler }}</div>
{% endif %}
<h5>Ihre Angaben im Überblick</h5>
{% if data.schritt2 %}
<div class="table-responsive mb-3">
<table class="table table-sm table-bordered">
<tr><th class="table-light w-40">Name</th><td>{{ data.schritt2.vorname }} {{ data.schritt2.nachname }}</td></tr>
<tr><th class="table-light">Geburtsdatum</th><td>{{ data.schritt2.geburtsdatum }}</td></tr>
<tr><th class="table-light">Adresse</th><td>{{ data.schritt2.strasse }}, {{ data.schritt2.plz }} {{ data.schritt2.ort }}</td></tr>
<tr><th class="table-light">E-Mail</th><td>{{ data.schritt2.email }}</td></tr>
<tr><th class="table-light">Telefon</th><td>{{ data.schritt2.telefon }}{% if data.schritt2.handynummer %} / {{ data.schritt2.handynummer }}{% endif %}</td></tr>
<tr><th class="table-light">Verwandtschaft</th><td>{{ data.schritt2.verwandtschaftsverhaeltnis }}</td></tr>
</table>
</div>
{% endif %}
{% if data.schritt3 %}
<div class="table-responsive mb-3">
<table class="table table-sm table-bordered">
<tr><th class="table-light w-40">In Ausbildung/Studium</th><td>{% if data.schritt3.in_ausbildung %}Ja{% else %}Nein{% endif %}</td></tr>
{% if data.schritt3.in_ausbildung %}
<tr><th class="table-light">Art</th><td>{{ data.schritt3.ausbildungsart }}</td></tr>
<tr><th class="table-light">Institution</th><td>{{ data.schritt3.institution }}</td></tr>
<tr><th class="table-light">Voraussichtl. Ende</th><td>{{ data.schritt3.voraussichtliche_dauer }}</td></tr>
{% endif %}
</table>
</div>
{% endif %}
{% if data.schritt4 %}
<div class="table-responsive mb-3">
<table class="table table-sm table-bordered">
<tr><th class="table-light w-40">Haushaltstyp</th><td>{{ data.schritt4.haushaltstyp }}</td></tr>
<tr><th class="table-light">Haushaltspersonen</th><td>{{ data.schritt4.haushaltsgroesse|default:"" }}</td></tr>
<tr><th class="table-light">Monatl. Bezüge</th><td>{{ data.schritt4.monatliche_bezuege|default:"" }} €</td></tr>
<tr><th class="table-light">Art der Bezüge</th><td>{{ data.schritt4.bezuege_art|default:"" }}</td></tr>
<tr><th class="table-light">Unterhalt</th><td>{{ data.schritt4.unterhalt|default:"" }}</td></tr>
<tr><th class="table-light">Miete &amp; Heizung</th><td>{{ data.schritt4.miete_heizung|default:"" }} €</td></tr>
<tr><th class="table-light">Vermögen</th><td>{{ data.schritt4.vermoegen|default:"" }} €</td></tr>
<tr><th class="table-light">Lebensunterhalt</th><td>{{ data.schritt4.lebensunterhalt_aufwendungen|default:"" }} €</td></tr>
</table>
</div>
{% endif %}
<hr class="my-4">
<h5>Dokumente hochladen</h5>
<p class="text-muted small">Laden Sie alle relevanten Nachweise hoch (Punkt 2, 3, 5, 810 des Merkblatts). Erlaubte Formate: PDF, JPG, PNG, TIFF max. 20 MB je Datei.</p>
<form method="post" enctype="multipart/form-data">
{% csrf_token %}
<div class="mb-3">
<label class="form-label">Personalausweis (Vorder- und Rückseite) oder Reisepass (Punkt 2)</label>
<input type="file" class="form-control" name="ausweis" accept=".pdf,.jpg,.jpeg,.png,.tiff">
</div>
<div class="mb-3">
<label class="form-label">Tabellarischer Lebenslauf (Punkt 3)</label>
<input type="file" class="form-control" name="lebenslauf" accept=".pdf,.jpg,.jpeg,.png">
</div>
<div class="mb-3">
<label class="form-label">Studienbescheinigung / Ausbildungsnachweis (Punkt 5, falls zutreffend)</label>
<input type="file" class="form-control" name="studienbescheinigung" accept=".pdf,.jpg,.jpeg,.png">
</div>
<div class="mb-3">
<label class="form-label">Einkommensnachweis (Lohnabrechnung, Rentenbescheid, BAföG-Bescheid etc.) (Punkt 8)</label>
<input type="file" class="form-control" name="einkommensnachweis" accept=".pdf,.jpg,.jpeg,.png">
</div>
<div class="mb-3">
<label class="form-label">Mietvertrag (Punkt 10, falls zutreffend)</label>
<input type="file" class="form-control" name="mietvertrag" accept=".pdf,.jpg,.jpeg,.png">
</div>
<div class="mb-4">
<label class="form-label">Weitere Belege</label>
<input type="file" class="form-control" name="weitere_belege" multiple accept=".pdf,.jpg,.jpeg,.png,.tiff">
<div class="form-text">Mehrfachauswahl möglich.</div>
</div>
<hr class="my-4">
<div class="form-check">
<input class="form-check-input" type="checkbox" name="finale_bestaetigung" id="finale_bestaetigung" required>
<label class="form-check-label" for="finale_bestaetigung">
Ich bestätige, dass alle obigen Angaben vollständig und wahrheitsgemäß sind. Ich bin mir bewusst, dass falsche Angaben zur Einstellung der Förderung und rechtlichen Konsequenzen führen können. <span class="required-mark">*</span>
</label>
</div>
<div class="d-flex justify-content-between mt-4">
<button type="submit" name="aktion" value="zurueck" class="btn btn-outline-secondary btn-zurueck">← Zurück</button>
<button type="submit" class="btn btn-success px-4">Antrag einreichen ✓</button>
</div>
</form>
</div>
</div>
{% endblock %}

View File

@@ -0,0 +1,38 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Unterlagen eingereicht vHTV-Stiftung</title>
<style>
body { font-family: Arial, sans-serif; font-size: 15px; color: #222; background: #f4f6f8; margin: 0; padding: 0; }
.container { max-width: 560px; margin: 60px auto; padding: 0 16px; text-align: center; }
.card { background: #fff; border-radius: 10px; padding: 40px 32px; box-shadow: 0 2px 12px rgba(0,0,0,0.08); }
.icon { font-size: 64px; margin-bottom: 16px; }
h1 { font-size: 24px; color: #1a3a5c; margin: 0 0 12px; }
p { line-height: 1.6; color: #444; }
.badge { display: inline-block; background: #e6f4ea; color: #2d7a3e; font-weight: bold; padding: 6px 16px; border-radius: 20px; margin: 12px 0; }
.info { font-size: 13px; color: #777; margin-top: 20px; }
.footer { margin-top: 28px; font-size: 12px; color: #aaa; }
</style>
</head>
<body>
<div class="container">
<div class="card">
<div class="icon">&#10004;&#65039;</div>
<h1>Vielen Dank!</h1>
<p>Ihre Unterlagen für das <strong>{{ halbjahr_label }}</strong> wurden erfolgreich eingereicht.</p>
<div class="badge">Einreichung bestätigt</div>
<p>Die van Hees-Theyssen-Vogel'sche Stiftung wird Ihre Unterlagen prüfen und sich bei Rückfragen bei Ihnen melden.</p>
<p class="info">
Bei Fragen wenden Sie sich an:<br>
Tel. 02858/836780 &bull;
<a href="mailto:Jan.Siebels@gmail.com">Jan.Siebels@gmail.com</a>
</p>
</div>
<div class="footer">
van Hees-Theyssen-Vogel'sche Stiftung &bull; Raesfelder Str. 3 &bull; 46499 Hamminkeln
</div>
</div>
</body>
</html>

View File

@@ -0,0 +1,35 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Link ungültig vHTV-Stiftung</title>
<style>
body { font-family: Arial, sans-serif; font-size: 15px; color: #222; background: #f4f6f8; margin: 0; padding: 0; }
.container { max-width: 520px; margin: 60px auto; padding: 0 16px; text-align: center; }
.card { background: #fff; border-radius: 10px; padding: 40px 32px; box-shadow: 0 2px 12px rgba(0,0,0,0.08); }
.icon { font-size: 56px; margin-bottom: 16px; }
h1 { font-size: 22px; color: #b30000; margin: 0 0 12px; }
p { line-height: 1.6; color: #444; }
.contact { background: #f0f6ff; border-radius: 6px; padding: 14px; margin-top: 20px; font-size: 14px; }
.footer { margin-top: 28px; font-size: 12px; color: #aaa; }
</style>
</head>
<body>
<div class="container">
<div class="card">
<div class="icon">&#128683;</div>
<h1>Link nicht mehr gültig</h1>
<p>{{ message }}</p>
<div class="contact">
<strong>Bitte wenden Sie sich direkt an die Stiftung:</strong><br>
Tel. 02858/836780<br>
<a href="mailto:Jan.Siebels@gmail.com">Jan.Siebels@gmail.com</a>
</div>
</div>
<div class="footer">
van Hees-Theyssen-Vogel'sche Stiftung &bull; Raesfelder Str. 3 &bull; 46499 Hamminkeln
</div>
</div>
</body>
</html>

View File

@@ -0,0 +1,169 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Unterlagen hochladen vHTV-Stiftung</title>
<style>
*, *::before, *::after { box-sizing: border-box; }
body { font-family: Arial, sans-serif; font-size: 15px; color: #222; background: #f4f6f8; margin: 0; padding: 0; }
.container { max-width: 720px; margin: 40px auto; padding: 0 16px 40px; }
.header { background: #1a3a5c; color: #fff; padding: 24px 28px; border-radius: 8px 8px 0 0; }
.header h1 { margin: 0 0 4px; font-size: 20px; }
.header p { margin: 0; font-size: 13px; opacity: 0.8; }
.card { background: #fff; border-radius: 0 0 8px 8px; padding: 28px; box-shadow: 0 2px 10px rgba(0,0,0,0.08); }
.badge { display: inline-block; background: #e8f0fb; color: #1a3a5c; font-weight: bold; padding: 4px 12px; border-radius: 20px; font-size: 13px; margin-bottom: 12px; }
.info-box { background: #f0f6ff; border: 1px solid #b0cce8; border-radius: 6px; padding: 14px 16px; margin-bottom: 20px; font-size: 14px; }
.error-box { background: #fff3f3; border: 1px solid #e88; border-radius: 6px; padding: 12px 16px; margin-bottom: 16px; color: #c00; }
/* Category sections */
.kategorie { border: 1px solid #dde4ed; border-radius: 8px; padding: 20px; margin-bottom: 20px; }
.kategorie h3 { margin: 0 0 4px; font-size: 16px; color: #1a3a5c; }
.kategorie .hinweis { font-size: 13px; color: #666; margin: 0 0 12px; }
.kategorie.pflicht { border-left: 3px solid #1a3a5c; }
label { display: block; font-weight: bold; margin-bottom: 6px; font-size: 14px; }
.upload-area { border: 2px dashed #b0cce8; border-radius: 6px; padding: 20px; text-align: center; cursor: pointer; background: #fafcff; transition: border-color 0.2s; position: relative; }
.upload-area:hover, .upload-area.dragover { border-color: #1a3a5c; background: #e8f0fb; }
.upload-area input[type="file"] { position: absolute; inset: 0; opacity: 0; cursor: pointer; width: 100%; height: 100%; }
.upload-area .icon { font-size: 28px; margin-bottom: 4px; }
.upload-area p { margin: 2px 0; color: #555; font-size: 13px; }
.upload-area .hint { font-size: 11px; color: #888; }
.file-list { margin: 8px 0 0; font-size: 13px; color: #444; list-style: none; padding: 0; }
.file-list li { padding: 3px 0; border-bottom: 1px solid #f0f0f0; }
.oder-text { text-align: center; color: #999; font-size: 13px; margin: 10px 0; font-style: italic; }
textarea { width: 100%; border: 1px solid #ccc; border-radius: 5px; padding: 10px; font-family: inherit; font-size: 14px; resize: vertical; min-height: 60px; }
textarea:focus { border-color: #1a3a5c; outline: none; }
.submit-btn { display: block; width: 100%; background: #1a3a5c; color: #fff; border: none; border-radius: 5px; padding: 14px; font-size: 16px; font-weight: bold; cursor: pointer; margin-top: 20px; }
.submit-btn:hover { background: #14304e; }
.deadline { font-size: 13px; color: #888; margin-top: 16px; text-align: center; }
.footer { text-align: center; margin-top: 24px; font-size: 12px; color: #aaa; }
.pflicht-hinweis { font-size: 12px; color: #888; margin-bottom: 16px; }
</style>
</head>
<body>
<div class="container">
<div class="header">
<h1>van Hees-Theyssen-Vogel'sche Stiftung</h1>
<p>Sicheres Dokumenten-Upload-Portal</p>
</div>
<div class="card">
<div class="badge">{{ halbjahr_label }}</div>
<p>Guten Tag, <strong>{{ destinataer.vorname }} {{ destinataer.nachname }}</strong>,</p>
<p>bitte laden Sie hier Ihre Unterlagen für das <strong>{{ halbjahr_label }}</strong> hoch.
Für jede Kategorie können Sie eine Datei hochladen und/oder einen Text eingeben.</p>
<p class="pflicht-hinweis">Pro Kategorie muss mindestens eine Datei <em>oder</em> ein Texteintrag eingereicht werden.</p>
{% if fehler %}
<div class="error-box">{{ fehler }}</div>
{% endif %}
<form method="post" enctype="multipart/form-data" action="">
{% csrf_token %}
<!-- 1. Studiennachweis -->
<div class="kategorie pflicht">
<h3>Studiennachweis</h3>
<p class="hinweis">Semesterbescheinigung, Ausbildungsnachweis, Leistungsnachweise (Zeugnisse, Kreditpunkte etc.)</p>
<label for="studiennachweis">Datei hochladen:</label>
<div class="upload-area" data-target="studiennachweis">
<input type="file" name="studiennachweis" id="studiennachweis" accept=".pdf,.jpg,.jpeg,.png,.tiff,.tif">
<div class="icon">&#128196;</div>
<p>Datei hierher ziehen oder klicken</p>
<p class="hint">PDF, JPG, PNG, TIFF &bull; max. {{ max_dateigroesse_mb }} MB</p>
</div>
<ul class="file-list" data-list="studiennachweis"></ul>
<p class="oder-text">— oder Texteintrag —</p>
<textarea name="studiennachweis_text" placeholder="z.B. 'Semesterbescheinigung liegt bei' oder 'Kein Studium/Ausbildung mehr seit ...'">{{ studiennachweis_text|default:"" }}</textarea>
</div>
<!-- 2. Einkommenssituation -->
<div class="kategorie pflicht">
<h3>Einkommenssituation</h3>
<p class="hinweis">Einkommensteuerbescheid, Lohn-/Gehaltsnachweis, Rentenbescheid, Bescheinigung Pflegegrad etc.</p>
<label for="einkommenssituation">Datei hochladen:</label>
<div class="upload-area" data-target="einkommenssituation">
<input type="file" name="einkommenssituation" id="einkommenssituation" accept=".pdf,.jpg,.jpeg,.png,.tiff,.tif">
<div class="icon">&#128196;</div>
<p>Datei hierher ziehen oder klicken</p>
<p class="hint">PDF, JPG, PNG, TIFF &bull; max. {{ max_dateigroesse_mb }} MB</p>
</div>
<ul class="file-list" data-list="einkommenssituation"></ul>
<p class="oder-text">— oder Texteintrag —</p>
<textarea name="einkommenssituation_text" placeholder="z.B. 'Keine Änderungen seit letzter Meldung' oder Details zu Änderungen">{{ einkommenssituation_text|default:"" }}</textarea>
</div>
<!-- 3. Vermögenssituation -->
<div class="kategorie pflicht">
<h3>Vermögenssituation</h3>
<p class="hinweis">Angaben zu Spar-/Festgeldguthaben, Aktien, Immobilien etc.</p>
<label for="vermogenssituation">Datei hochladen:</label>
<div class="upload-area" data-target="vermogenssituation">
<input type="file" name="vermogenssituation" id="vermogenssituation" accept=".pdf,.jpg,.jpeg,.png,.tiff,.tif">
<div class="icon">&#128196;</div>
<p>Datei hierher ziehen oder klicken</p>
<p class="hint">PDF, JPG, PNG, TIFF &bull; max. {{ max_dateigroesse_mb }} MB</p>
</div>
<ul class="file-list" data-list="vermogenssituation"></ul>
<p class="oder-text">— oder Texteintrag —</p>
<textarea name="vermogenssituation_text" placeholder="z.B. 'Keine Änderungen seit letzter Meldung' oder Details zu Änderungen">{{ vermogenssituation_text|default:"" }}</textarea>
</div>
<!-- 4. Weitere Dokumente (optional) -->
<div class="kategorie">
<h3>Weitere Dokumente (optional)</h3>
<p class="hinweis">Mietvertrag, Unterhaltsbelege, sonstige Nachweise</p>
<label for="weitere_dokumente">Datei hochladen:</label>
<div class="upload-area" data-target="weitere_dokumente">
<input type="file" name="weitere_dokumente" id="weitere_dokumente" accept=".pdf,.jpg,.jpeg,.png,.tiff,.tif">
<div class="icon">&#128196;</div>
<p>Datei hierher ziehen oder klicken</p>
<p class="hint">PDF, JPG, PNG, TIFF &bull; max. {{ max_dateigroesse_mb }} MB</p>
</div>
<ul class="file-list" data-list="weitere_dokumente"></ul>
<p class="oder-text">— oder Texteintrag —</p>
<textarea name="weitere_dokumente_text" placeholder="Optionale Anmerkungen oder Beschreibung">{{ weitere_dokumente_text|default:"" }}</textarea>
</div>
<button type="submit" class="submit-btn">Unterlagen jetzt einreichen</button>
</form>
<p class="deadline">&#9201; Gültig bis: {{ gueltig_bis|date:"d.m.Y" }}</p>
</div>
<div class="footer">
van Hees-Theyssen-Vogel'sche Stiftung &bull; Raesfelder Str. 3 &bull; 46499 Hamminkeln<br>
Fragen? Tel. 02858/836780
</div>
</div>
<script>
document.querySelectorAll('.upload-area').forEach(area => {
const input = area.querySelector('input[type="file"]');
const target = area.dataset.target;
const list = document.querySelector(`[data-list="${target}"]`);
function updateList(files) {
list.innerHTML = '';
Array.from(files).forEach(f => {
const li = document.createElement('li');
li.textContent = `\u2714 ${f.name} (${(f.size/1024/1024).toFixed(2)} MB)`;
list.appendChild(li);
});
}
input.addEventListener('change', () => updateList(input.files));
area.addEventListener('dragover', e => { e.preventDefault(); area.classList.add('dragover'); });
area.addEventListener('dragleave', () => area.classList.remove('dragover'));
area.addEventListener('drop', e => {
e.preventDefault();
area.classList.remove('dragover');
input.files = e.dataTransfer.files;
updateList(e.dataTransfer.files);
});
});
</script>
</body>
</html>

View File

@@ -271,6 +271,12 @@
<span>Dokumentenverwaltung</span>
</a>
</div>
<div class="col-md-3 mb-3">
<a href="{% url 'stiftung:vorlagen_liste' %}" class="btn btn-outline-primary w-100">
<i class="fas fa-file-code d-block mb-2 fa-2x"></i>
<span>Dokument-Vorlagen</span>
</a>
</div>
</div>
</div>
</div>

View File

@@ -63,6 +63,21 @@
</button>
<ul class="dropdown-menu dropdown-menu-end">
<li><a class="dropdown-item" href="{% url 'stiftung:destinataer_export' pk=destinataer.pk %}"><i class="fas fa-download me-2"></i>Export</a></li>
<li><hr class="dropdown-divider"></li>
<li>
<a class="dropdown-item" href="{% url 'stiftung:bestaetigung_vorschau' pk=destinataer.pk %}" target="_blank">
<i class="fas fa-file-pdf me-2"></i>Bestätigung (Vorschau)
</a>
</li>
<li>
<form method="post" action="{% url 'stiftung:bestaetigung_versenden' pk=destinataer.pk %}" class="d-inline">
{% csrf_token %}
<button type="submit" class="dropdown-item" onclick="return confirm('Bestätigungsschreiben per E-Mail an {{ destinataer.email|default:'(keine E-Mail)'}} senden?')">
<i class="fas fa-envelope me-2"></i>Bestätigung versenden
</button>
</form>
</li>
<li><hr class="dropdown-divider"></li>
<li>
<form method="post" action="{% url 'stiftung:destinataer_toggle_archiv' pk=destinataer.pk %}" class="d-inline">
{% csrf_token %}
@@ -493,6 +508,14 @@
<td>
<div class="btn-group btn-group-sm">
<a href="{% url 'stiftung:quarterly_confirmation_edit' nachweis.id %}" class="btn btn-outline-primary btn-sm" title="Bearbeiten"><i class="fas fa-edit"></i></a>
{% if nachweis.status == 'offen' or nachweis.status == 'teilweise' or nachweis.status == 'nachbesserung' %}
{% if destinataer.email %}
<form method="post" action="{% url 'stiftung:nachweis_aufforderung_senden' nachweis_pk=nachweis.id %}" style="display:inline;">
{% csrf_token %}
<button type="submit" class="btn btn-outline-info btn-sm" title="Upload-Aufforderung per E-Mail senden" onclick="return confirm('Upload-Link für {{ nachweis.jahr }} Q{{ nachweis.quartal }} an {{ destinataer.email }} senden?')"><i class="fas fa-paper-plane"></i></button>
</form>
{% endif %}
{% endif %}
{% if user.is_staff %}
{% if nachweis.status == 'eingereicht' %}
<button type="button" class="btn btn-outline-success btn-sm" onclick="approveQuarterly('{{ nachweis.id }}')" title="Freigeben"><i class="fas fa-check"></i></button>

View File

@@ -14,11 +14,21 @@
<div class="container-fluid">
<div class="row">
<div class="col-lg-8">
<div class="card">
{% if test_result %}
<div class="alert alert-{% if test_result.success %}success{% else %}danger{% endif %} alert-dismissible fade show">
<i class="fas fa-{% if test_result.success %}check-circle{% else %}exclamation-triangle{% endif %} me-1"></i>
{{ test_result.message }}
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
</div>
{% endif %}
<!-- IMAP Section -->
<div class="card mb-4">
<div class="card-header d-flex justify-content-between align-items-center">
<h3 class="card-title mb-0">
<i class="fas fa-envelope"></i>
{{ title }}
<i class="fas fa-inbox"></i>
E-Mail Eingang (IMAP)
</h3>
<a href="{% url 'stiftung:administration' %}" class="btn btn-sm btn-secondary">
<i class="fas fa-arrow-left"></i> Zurück
@@ -26,14 +36,6 @@
</div>
<div class="card-body">
{% if test_result %}
<div class="alert alert-{% if test_result.success %}success{% else %}danger{% endif %} alert-dismissible fade show">
<i class="fas fa-{% if test_result.success %}check-circle{% else %}exclamation-triangle{% endif %} me-1"></i>
{{ test_result.message }}
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
</div>
{% endif %}
<form method="post">
{% csrf_token %}
@@ -93,7 +95,7 @@
<i class="fas fa-save me-1"></i> Speichern
</button>
<button type="submit" name="action" value="test" class="btn btn-outline-primary">
<i class="fas fa-plug me-1"></i> Verbindung testen
<i class="fas fa-plug me-1"></i> IMAP testen
</button>
<a href="{% url 'stiftung:email_eingang_list' %}" class="btn btn-outline-secondary ms-auto">
<i class="fas fa-inbox me-1"></i> Zum Posteingang
@@ -102,13 +104,150 @@
</form>
</div>
</div>
<!-- SMTP Section -->
<div class="card mb-4">
<div class="card-header">
<h3 class="card-title mb-0">
<i class="fas fa-paper-plane"></i>
E-Mail Ausgang (SMTP)
</h3>
</div>
<div class="card-body">
<form method="post">
{% csrf_token %}
{% for setting in smtp_settings %}
<div class="mb-3">
<label for="setting_{{ setting.key }}" class="form-label">
<strong>{{ setting.display_name }}</strong>
</label>
{% if setting.description %}
<div class="form-text mb-1">{{ setting.description }}</div>
{% endif %}
{% if setting.setting_type == 'boolean' %}
<div class="form-check form-switch">
<input type="checkbox"
class="form-check-input"
id="setting_{{ setting.key }}"
name="setting_{{ setting.key }}"
value="True"
{% if setting.get_typed_value %}checked{% endif %}>
<label class="form-check-label" for="setting_{{ setting.key }}">Aktiviert</label>
</div>
{% elif setting.setting_type == 'password' %}
<div class="input-group">
<input type="password"
class="form-control"
id="setting_{{ setting.key }}"
name="setting_{{ setting.key }}"
value="{{ setting.value }}"
placeholder="{% if setting.value %}••••••••{% else %}Passwort eingeben{% endif %}">
<button type="button" class="btn btn-outline-secondary" onclick="togglePassword(this)" title="Passwort anzeigen">
<i class="fas fa-eye"></i>
</button>
</div>
{% elif setting.setting_type == 'number' %}
<input type="number"
class="form-control"
id="setting_{{ setting.key }}"
name="setting_{{ setting.key }}"
value="{{ setting.value }}">
{% else %}
<input type="text"
class="form-control"
id="setting_{{ setting.key }}"
name="setting_{{ setting.key }}"
value="{{ setting.value }}">
{% endif %}
</div>
{% endfor %}
<hr>
<div class="d-flex gap-2">
<button type="submit" name="action" value="save" class="btn btn-primary">
<i class="fas fa-save me-1"></i> Speichern
</button>
<button type="submit" name="action" value="test_smtp" class="btn btn-outline-primary">
<i class="fas fa-plug me-1"></i> Verbindung testen
</button>
</div>
<hr>
<div class="mb-3">
<label for="test_email" class="form-label">
<strong>Test-E-Mail senden</strong>
</label>
<div class="form-text mb-1">Sendet eine echte Test-E-Mail an die angegebene Adresse, um den vollständigen Versandweg zu prüfen.</div>
<div class="input-group">
<input type="email"
class="form-control"
id="test_email"
name="test_email"
placeholder="empfaenger@example.de"
value="{{ request.POST.test_email|default:'' }}">
<button type="submit" name="action" value="test_smtp_send" class="btn btn-outline-success">
<i class="fas fa-paper-plane me-1"></i> Test senden
</button>
</div>
</div>
</form>
</div>
</div>
<!-- Notification Section -->
<div class="card mb-4">
<div class="card-header">
<h3 class="card-title mb-0">
<i class="fas fa-bell"></i>
Benachrichtigungen
</h3>
</div>
<div class="card-body">
<form method="post">
{% csrf_token %}
{% for setting in notification_settings %}
<div class="mb-3">
<label for="setting_{{ setting.key }}" class="form-label">
<strong>{{ setting.display_name }}</strong>
</label>
{% if setting.description %}
<div class="form-text mb-1">{{ setting.description }}</div>
{% endif %}
<input type="text"
class="form-control"
id="setting_{{ setting.key }}"
name="setting_{{ setting.key }}"
value="{{ setting.value }}"
placeholder="z.B. vorstand@vhtv-stiftung.de">
</div>
{% endfor %}
<hr>
<div class="d-flex gap-2">
<button type="submit" name="action" value="save" class="btn btn-primary">
<i class="fas fa-save me-1"></i> Speichern
</button>
</div>
</form>
</div>
</div>
</div>
<!-- Info sidebar -->
<div class="col-lg-4">
<div class="card">
<div class="card mb-4">
<div class="card-header">
<i class="fas fa-info-circle"></i> Hinweise
<i class="fas fa-info-circle"></i> IMAP-Hinweise
</div>
<div class="card-body" style="font-size: 0.85rem;">
<p>Konfigurieren Sie hier die IMAP-Verbindung zum E-Mail-Server. Eingehende E-Mails werden automatisch alle <strong>15 Minuten</strong> abgerufen und den Destinatären zugeordnet.</p>
@@ -122,6 +261,23 @@
<p class="mb-0"><i class="fas fa-shield-alt text-success me-1"></i> Das Passwort wird in der Datenbank gespeichert. Umgebungsvariablen (<code>IMAP_HOST</code>, etc.) werden als Fallback verwendet, wenn hier keine Werte gesetzt sind.</p>
</div>
</div>
<div class="card">
<div class="card-header">
<i class="fas fa-info-circle"></i> SMTP-Hinweise
</div>
<div class="card-body" style="font-size: 0.85rem;">
<p>SMTP wird für den <strong>ausgehenden</strong> E-Mail-Versand verwendet (Nachweis-Aufforderungen, Erinnerungen, Onboarding-Einladungen).</p>
<hr>
<p class="mb-1"><strong>IONOS-Einstellungen:</strong></p>
<ul class="mb-0" style="font-size: 0.8rem;">
<li>Server: <code>smtp.ionos.de</code></li>
<li>SSL/TLS: Port <code>465</code></li>
<li>STARTTLS: Port <code>587</code></li>
</ul>
<hr>
<p class="mb-0"><i class="fas fa-envelope text-primary me-1"></i> Die Absenderadresse <code>buero@vhtv-stiftung.de</code> muss mit dem SMTP-Benutzernamen übereinstimmen.</p>
</div>
</div>
</div>
</div>
</div>

View File

@@ -13,6 +13,13 @@
Nachweis-Board {{ jahr_filter }}
</h1>
<div class="d-flex gap-2">
<form method="post" action="{% url 'stiftung:batch_nachweis_aufforderung_senden' %}">
{% csrf_token %}
<input type="hidden" name="jahr" value="{{ jahr_filter }}">
<button type="submit" class="btn btn-primary" onclick="return confirm('Nachweis-Aufforderungs-E-Mails für alle offenen Nachweise {{ jahr_filter }} versenden?')">
<i class="fas fa-paper-plane me-2"></i>Aufforderungen senden
</button>
</form>
{% if overdue_count > 0 %}
<form method="post" action="{% url 'stiftung:batch_erinnerung_senden' %}">
{% csrf_token %}
@@ -126,6 +133,14 @@
{{ nachweis.get_completion_percentage }}%
</a>
</div>
{% if nachweis.status == 'offen' or nachweis.status == 'teilweise' or nachweis.status == 'nachbesserung' %}{% if row.destinataer.email %}
<form method="post" action="{% url 'stiftung:nachweis_aufforderung_senden' nachweis_pk=nachweis.pk %}" class="mt-1">
{% csrf_token %}
<button type="submit" class="btn btn-outline-primary btn-xs" style="font-size:0.65rem;padding:1px 5px;" title="Upload-Link per E-Mail senden" onclick="return confirm('Upload-Aufforderung an {{ row.destinataer.email }} senden?')">
<i class="fas fa-paper-plane"></i>
</button>
</form>
{% endif %}{% endif %}
{% else %}
<span class="text-muted small"></span>
{% endif %}

View File

@@ -0,0 +1,114 @@
{% extends "base.html" %}
{% block title %}Onboarding-Einladungen{% endblock %}
{% block content %}
<div class="container-fluid py-4">
<div class="d-flex justify-content-between align-items-center mb-4">
<h1 class="h3">Onboarding-Einladungen</h1>
<button type="button" class="btn btn-primary" data-bs-toggle="modal" data-bs-target="#einladungModal">
+ Neue Einladung
</button>
</div>
<!-- Neue Einladung Modal -->
<div class="modal fade" id="einladungModal" tabindex="-1">
<div class="modal-dialog">
<div class="modal-content">
<form method="post" action="{% url 'stiftung:onboarding_einladung_senden' %}">
{% csrf_token %}
<div class="modal-header">
<h5 class="modal-title">Neue Onboarding-Einladung senden</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<div class="mb-3">
<label for="email" class="form-label">E-Mail-Adresse <span class="text-danger">*</span></label>
<input type="email" class="form-control" id="email" name="email" required>
</div>
<div class="row g-2">
<div class="col-6">
<label for="vorname" class="form-label">Vorname (optional)</label>
<input type="text" class="form-control" id="vorname" name="vorname">
</div>
<div class="col-6">
<label for="nachname" class="form-label">Nachname (optional)</label>
<input type="text" class="form-control" id="nachname" name="nachname">
</div>
</div>
<div class="alert alert-info mt-3 mb-0 small">
Der eingeladene Kandidat erhält einen Einmal-Link per E-Mail (gültig 30 Tage), über den er das mehrstufige Onboarding-Formular ausfüllen kann.
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Abbrechen</button>
<button type="submit" class="btn btn-primary">Einladung senden</button>
</div>
</form>
</div>
</div>
</div>
<!-- Tabelle -->
<div class="card">
<div class="card-body p-0">
<table class="table table-hover mb-0">
<thead class="table-light">
<tr>
<th>E-Mail</th>
<th>Name</th>
<th>Status</th>
<th>Gültig bis</th>
<th>Eingeladen von</th>
<th>Abgeschlossen</th>
<th>Destinatär</th>
<th></th>
</tr>
</thead>
<tbody>
{% for e in einladungen %}
<tr>
<td>{{ e.email }}</td>
<td>{{ e.vorname }} {{ e.nachname }}</td>
<td>
{% if e.status == "offen" %}
<span class="badge bg-success">Offen</span>
{% elif e.status == "abgeschlossen" %}
<span class="badge bg-primary">Abgeschlossen</span>
{% elif e.status == "abgelaufen" %}
<span class="badge bg-secondary">Abgelaufen</span>
{% elif e.status == "widerrufen" %}
<span class="badge bg-danger">Widerrufen</span>
{% endif %}
</td>
<td>{{ e.gueltig_bis|date:"d.m.Y" }}</td>
<td>{{ e.eingeladen_von.get_full_name|default:e.eingeladen_von.username|default:"" }}</td>
<td>{{ e.abgeschlossen_am|date:"d.m.Y H:i"|default:"" }}</td>
<td>
{% if e.destinataer %}
<a href="{% url 'stiftung:destinataer_detail' pk=e.destinataer.id %}">
{{ e.destinataer.vorname }} {{ e.destinataer.nachname }}
{% if not e.destinataer.unterstuetzung_bestaetigt %}
<span class="badge bg-warning text-dark ms-1">Freigabe ausstehend</span>
{% endif %}
</a>
{% else %}{% endif %}
</td>
<td>
{% if e.status == "offen" %}
<form method="post" action="{% url 'stiftung:onboarding_einladung_widerrufen' pk=e.id %}"
onsubmit="return confirm('Einladung für {{ e.email }} widerrufen?');">
{% csrf_token %}
<button type="submit" class="btn btn-sm btn-outline-danger">Widerrufen</button>
</form>
{% endif %}
</td>
</tr>
{% empty %}
<tr><td colspan="8" class="text-center text-muted py-4">Keine Onboarding-Einladungen vorhanden.</td></tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
{% endblock %}

View File

@@ -0,0 +1,21 @@
{% extends "base.html" %}
{% block title %}Einladung widerrufen{% endblock %}
{% block content %}
<div class="container py-5">
<div class="row justify-content-center">
<div class="col-md-6">
<div class="card">
<div class="card-body text-center py-4">
<h4>Einladung widerrufen?</h4>
<p class="text-muted">Die Onboarding-Einladung für <strong>{{ einladung.email }}</strong> wird widerrufen. Der Link wird ungültig.</p>
<form method="post">
{% csrf_token %}
<a href="{% url 'stiftung:onboarding_einladung_liste' %}" class="btn btn-secondary me-2">Abbrechen</a>
<button type="submit" class="btn btn-danger">Ja, widerrufen</button>
</form>
</div>
</div>
</div>
</div>
</div>
{% endblock %}

View File

@@ -0,0 +1,311 @@
{% extends 'base.html' %}
{% load static %}
{% block title %}{{ vorlage.bezeichnung }} Vorlage bearbeiten{% endblock %}
{% block extra_css %}
<!-- Summernote WYSIWYG (lokal) -->
<link rel="stylesheet" href="{% static 'stiftung/vendor/summernote/summernote-bs5.min.css' %}">
<style>
.preview-frame {
width: 100%;
height: 580px;
border: 1px solid #dee2e6;
border-radius: 4px;
background: #fff;
}
.var-list {
max-height: 400px;
overflow-y: auto;
}
.var-item {
cursor: pointer;
font-family: monospace;
font-size: 12px;
}
.var-item:hover {
background-color: #e9ecef;
}
.note-editor.note-frame {
border-radius: 4px;
}
.code-editor-textarea {
width: 100%;
height: 520px;
font-family: 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, monospace;
font-size: 13px;
line-height: 1.5;
padding: 12px;
border: 1px solid #dee2e6;
border-radius: 4px;
resize: vertical;
tab-size: 4;
white-space: pre;
overflow-wrap: normal;
overflow-x: auto;
background: #f8f9fa;
}
.code-editor-textarea:focus {
outline: none;
border-color: #86b7fe;
box-shadow: 0 0 0 .25rem rgba(13,110,253,.25);
}
</style>
{% endblock %}
{% block content %}
<div class="row">
<div class="col-12">
<div class="d-sm-flex align-items-center justify-content-between mb-3">
<div>
<h1 class="h3 mb-0 text-gray-800">
<i class="fas fa-file-code me-2"></i>{{ vorlage.bezeichnung }}
</h1>
<small class="text-muted"><code>{{ vorlage.schluessel }}</code> &nbsp;·&nbsp;
Kategorie: {{ vorlage.get_kategorie_display }}</small>
</div>
<div class="d-flex gap-2">
<a href="{% url 'stiftung:vorlagen_liste' %}" class="btn btn-sm btn-outline-secondary">
<i class="fas fa-arrow-left me-1"></i>Zurück zur Liste
</a>
{% if hat_original %}
<form method="post" action="{% url 'stiftung:vorlage_zuruecksetzen' pk=vorlage.pk %}"
onsubmit="return confirm('Alle Änderungen verwerfen und auf Original zurücksetzen?')">
{% csrf_token %}
<button type="submit" class="btn btn-sm btn-outline-warning">
<i class="fas fa-undo me-1"></i>Original wiederherstellen
</button>
</form>
{% endif %}
</div>
</div>
</div>
</div>
<div class="row">
<!-- Editor (links) -->
<div class="col-lg-8">
<form method="post" id="editor-form">
{% csrf_token %}
<div class="d-flex gap-2 mb-2 justify-content-end">
<button type="button" class="btn btn-sm btn-outline-secondary" id="btn-preview">
<i class="fas fa-eye me-1"></i>Vorschau
</button>
<button type="submit" class="btn btn-sm btn-primary">
<i class="fas fa-save me-1"></i>Speichern
</button>
</div>
<textarea name="html_inhalt" id="code-editor"{% if use_code_editor %} class="code-editor-textarea"{% endif %}>{{ vorlage.html_inhalt }}</textarea>
<script type="application/json" id="vorlage-html-inhalt">{{ html_inhalt_json }}</script>
</form>
<!-- Vorschau-Bereich (initial versteckt) -->
<div id="preview-area" class="mt-3" style="display:none">
<div class="d-flex align-items-center justify-content-between mb-2">
<strong>Vorschau <span class="badge bg-secondary">Beispieldaten</span></strong>
<button type="button" class="btn btn-sm btn-outline-secondary" id="btn-close-preview">
<i class="fas fa-times me-1"></i>Vorschau schließen
</button>
</div>
<iframe id="preview-frame" class="preview-frame" title="Vorschau"></iframe>
</div>
</div>
<!-- Seitenleiste (rechts) -->
<div class="col-lg-4">
{% if variablen %}
<div class="card shadow mb-3">
<div class="card-header py-2">
<h6 class="m-0 font-weight-bold text-primary">
<i class="fas fa-code me-1"></i>Verfügbare Variablen
</h6>
<small class="text-muted">Klick zum Einfügen</small>
</div>
<div class="card-body p-0">
<div class="var-list">
<table class="table table-sm mb-0">
<tbody>
{% for var, beschreibung in variablen.items %}
<tr class="var-item" data-var="{{ var }}">
<td class="ps-3 py-1">
<code>{% templatetag openvariable %} {{ var }} {% templatetag closevariable %}</code>
</td>
<td class="pe-3 py-1 text-muted small">{{ beschreibung }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
{% endif %}
<div class="card shadow">
<div class="card-header py-2">
<h6 class="m-0 font-weight-bold text-secondary">
<i class="fas fa-info-circle me-1"></i>Info
</h6>
</div>
<div class="card-body small">
<dl class="mb-0">
<dt>Zuletzt bearbeitet</dt>
<dd class="text-muted">{{ vorlage.zuletzt_bearbeitet_am|date:"d.m.Y H:i" }}</dd>
{% if vorlage.zuletzt_bearbeitet_von %}
<dt>Bearbeitet von</dt>
<dd class="text-muted">{{ vorlage.zuletzt_bearbeitet_von.get_full_name|default:vorlage.zuletzt_bearbeitet_von.username }}</dd>
{% endif %}
<dt>Erstellt am</dt>
<dd class="text-muted mb-0">{{ vorlage.erstellt_am|date:"d.m.Y" }}</dd>
</dl>
</div>
</div>
</div>
</div>
{% endblock %}
{% block javascript %}
<!-- jQuery (lokal) -->
<script src="{% static 'stiftung/vendor/jquery/jquery.min.js' %}"></script>
<!-- Summernote WYSIWYG (lokal) -->
<script src="{% static 'stiftung/vendor/summernote/summernote-bs5.min.js' %}"></script>
<script src="{% static 'stiftung/vendor/summernote/summernote-de-DE.min.js' %}"></script>
<script>
(function() {
var initialContent;
try {
initialContent = JSON.parse(document.getElementById('vorlage-html-inhalt').textContent);
} catch(e) {
initialContent = null;
}
var useCodeEditor = {{ use_code_editor|yesno:"true,false" }};
var editor = document.getElementById('code-editor');
// Code-Editor-Modus: Plain textarea fuer vollstaendige HTML-Dokumente
// (Serienbrief-Vorlagen mit DOCTYPE, Template-Tags usw.)
if (useCodeEditor) {
if (initialContent) {
editor.value = initialContent;
}
// Tab-Taste einfügen statt Fokus wechseln
editor.addEventListener('keydown', function(e) {
if (e.key === 'Tab') {
e.preventDefault();
var start = this.selectionStart;
var end = this.selectionEnd;
this.value = this.value.substring(0, start) + ' ' + this.value.substring(end);
this.selectionStart = this.selectionEnd = start + 4;
}
});
// Variablen einfügen bei Klick
document.querySelectorAll('.var-item').forEach(function(row) {
row.addEventListener('click', function() {
var varName = this.getAttribute('data-var');
var placeholder = String.fromCharCode(123,123) + ' ' + varName + ' ' + String.fromCharCode(125,125);
var start = editor.selectionStart;
editor.value = editor.value.substring(0, start) + placeholder + editor.value.substring(editor.selectionEnd);
editor.selectionStart = editor.selectionEnd = start + placeholder.length;
editor.focus();
});
});
// Vorschau
var previewArea = document.getElementById('preview-area');
var previewFrame = document.getElementById('preview-frame');
document.getElementById('btn-preview').addEventListener('click', function() {
var formData = new FormData();
formData.append('html_inhalt', editor.value);
formData.append('csrfmiddlewaretoken', document.querySelector('[name=csrfmiddlewaretoken]').value);
fetch('{% url "stiftung:vorlage_vorschau" pk=vorlage.pk %}', { method: 'POST', body: formData })
.then(function(r) { return r.text(); })
.then(function(html) {
previewFrame.srcdoc = html;
previewArea.style.display = 'block';
previewArea.scrollIntoView({behavior: 'smooth'});
})
.catch(function(err) { alert('Vorschau fehlgeschlagen: ' + err); });
});
document.getElementById('btn-close-preview').addEventListener('click', function() {
previewArea.style.display = 'none';
});
return; // Skip Summernote initialization
}
if (typeof $ === 'undefined' || typeof $.fn.summernote === 'undefined') {
// Fallback: Summernote nicht geladen — Textarea sichtbar lassen
if (editor) { editor.style.height = '520px'; editor.style.fontFamily = 'monospace'; editor.style.fontSize = '13px'; }
return;
}
// Summernote initialisieren (für HTML-Fragment-Vorlagen: E-Mail, PDF-Fragmente)
$('#code-editor').summernote({
lang: 'de-DE',
height: 520,
toolbar: [
['style', ['bold', 'italic', 'underline', 'strikethrough', 'clear']],
['para', ['style', 'ul', 'ol', 'paragraph']],
['table', ['table']],
['insert', ['link', 'hr']],
['view', ['fullscreen', 'codeview', 'undo', 'redo']],
],
callbacks: {
onInit: function() {
if (initialContent) {
$('#code-editor').summernote('code', initialContent);
}
}
}
});
// Variablen einfügen bei Klick
document.querySelectorAll('.var-item').forEach(function(row) {
row.addEventListener('click', function() {
const varName = this.getAttribute('data-var');
const placeholder = String.fromCharCode(123,123) + ' ' + varName + ' ' + String.fromCharCode(125,125);
$('#code-editor').summernote('focus');
$('#code-editor').summernote('insertText', placeholder);
});
});
// Vorschau
const previewArea = document.getElementById('preview-area');
const previewFrame = document.getElementById('preview-frame');
const btnPreview = document.getElementById('btn-preview');
const btnClosePreview = document.getElementById('btn-close-preview');
btnPreview.addEventListener('click', function() {
const content = $('#code-editor').summernote('code');
const formData = new FormData();
formData.append('html_inhalt', content);
formData.append('csrfmiddlewaretoken', document.querySelector('[name=csrfmiddlewaretoken]').value);
fetch('{% url "stiftung:vorlage_vorschau" pk=vorlage.pk %}', {
method: 'POST',
body: formData,
})
.then(r => r.text())
.then(html => {
previewFrame.srcdoc = html;
previewArea.style.display = 'block';
previewArea.scrollIntoView({behavior: 'smooth'});
})
.catch(err => alert('Vorschau fehlgeschlagen: ' + err));
});
btnClosePreview.addEventListener('click', function() {
previewArea.style.display = 'none';
});
// Formular-Submit: Summernote-Inhalt in Textarea schreiben
document.getElementById('editor-form').addEventListener('submit', function() {
// Summernote schreibt den Inhalt automatisch in die Textarea beim Submit
// Sicherheitshalber explizit synchronisieren:
const content = $('#code-editor').summernote('code');
document.querySelector('textarea[name=html_inhalt]').value = content;
});
})();
</script>
{% endblock %}

View File

@@ -0,0 +1,100 @@
{% extends 'base.html' %}
{% block title %}Dokument-Vorlagen Administration{% endblock %}
{% block content %}
<div class="row">
<div class="col-12">
<div class="d-sm-flex align-items-center justify-content-between mb-4">
<h1 class="h3 mb-0 text-gray-800">
<i class="fas fa-file-code me-2"></i>Dokument-Vorlagen
</h1>
<div class="d-flex gap-2">
<form method="post" action="{% url 'stiftung:vorlagen_alle_zuruecksetzen' %}"
onsubmit="return confirm('ALLE Vorlagen auf die Original-Dateien zurücksetzen? Individuelle Änderungen gehen verloren.')">
{% csrf_token %}
<button type="submit" class="btn btn-sm btn-outline-warning">
<i class="fas fa-undo me-1"></i>Alle zurücksetzen
</button>
</form>
<a href="{% url 'stiftung:administration' %}" class="btn btn-sm btn-outline-secondary">
<i class="fas fa-arrow-left me-1"></i>Administration
</a>
</div>
</div>
</div>
</div>
<div class="row mb-3">
<div class="col-12">
<div class="alert alert-info">
<i class="fas fa-info-circle me-2"></i>
Hier können Sie alle Vorlagen für generierte Dokumente (PDF-Briefe, E-Mails) direkt bearbeiten.
Änderungen werden sofort aktiv. Mit „Zurücksetzen" können Sie jederzeit auf die Original-Datei-Vorlage zurückkehren.
</div>
</div>
</div>
{% for kategorie, vlist in kategorien.items %}
<div class="card shadow mb-4">
<div class="card-header py-3">
<h6 class="m-0 font-weight-bold text-primary">
{% if kategorie == 'pdf' %}<i class="fas fa-file-pdf me-2"></i>PDF-Dokumente
{% elif kategorie == 'email' %}<i class="fas fa-envelope me-2"></i>E-Mail-Vorlagen
{% elif kategorie == 'bericht' %}<i class="fas fa-chart-bar me-2"></i>Berichte
{% elif kategorie == 'serienbrief' %}<i class="fas fa-mail-bulk me-2"></i>Serienbriefe
{% else %}{{ kategorie_labels|dictsort:kategorie }}
{% endif %}
<span class="badge bg-secondary ms-2">{{ vlist|length }}</span>
</h6>
</div>
<div class="card-body p-0">
<div class="table-responsive">
<table class="table table-hover mb-0">
<thead class="table-light">
<tr>
<th>Bezeichnung</th>
<th>Schlüssel</th>
<th>Zuletzt bearbeitet</th>
<th>Bearbeitet von</th>
<th></th>
</tr>
</thead>
<tbody>
{% for vorlage in vlist %}
<tr>
<td>
<strong>{{ vorlage.bezeichnung }}</strong>
</td>
<td>
<code class="small">{{ vorlage.schluessel }}</code>
</td>
<td class="text-muted small">
{{ vorlage.zuletzt_bearbeitet_am|date:"d.m.Y H:i" }}
</td>
<td class="text-muted small">
{% if vorlage.zuletzt_bearbeitet_von %}
{{ vorlage.zuletzt_bearbeitet_von.get_full_name|default:vorlage.zuletzt_bearbeitet_von.username }}
{% else %}
<span class="text-muted"></span>
{% endif %}
</td>
<td class="text-end">
<a href="{% url 'stiftung:vorlage_editor' pk=vorlage.pk %}"
class="btn btn-sm btn-primary">
<i class="fas fa-edit me-1"></i>Bearbeiten
</a>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
{% empty %}
<div class="alert alert-warning">
Keine Vorlagen gefunden. Bitte führen Sie die Datenbank-Migration aus.
</div>
{% endfor %}
{% endblock %}