Add Vorlagen editor, upload portal, onboarding, and participant import command
- 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:
@@ -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."))
|
||||
@@ -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
|
||||
|
||||
50
app/stiftung/management/commands/restore_vorlagen.py
Normal file
50
app/stiftung/management/commands/restore_vorlagen.py
Normal 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.")
|
||||
)
|
||||
@@ -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'],
|
||||
},
|
||||
),
|
||||
]
|
||||
160
app/stiftung/migrations/0061_dokument_vorlage.py
Normal file
160
app/stiftung/migrations/0061_dokument_vorlage.py
Normal 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),
|
||||
]
|
||||
@@ -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),
|
||||
]
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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()
|
||||
)
|
||||
|
||||
54
app/stiftung/models/vorlagen.py
Normal file
54
app/stiftung/models/vorlagen.py
Normal 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})"
|
||||
46
app/stiftung/portal_urls.py
Normal file
46
app/stiftung/portal_urls.py
Normal 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",
|
||||
),
|
||||
]
|
||||
@@ -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}
|
||||
|
||||
@@ -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"),
|
||||
|
||||
59
app/stiftung/utils/vorlagen.py
Normal file
59
app/stiftung/utils/vorlagen.py
Normal 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}")
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
602
app/stiftung/views/portal.py
Normal file
602
app/stiftung/views/portal.py
Normal 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},
|
||||
)
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
211
app/stiftung/views/vorlagen.py
Normal file
211
app/stiftung/views/vorlagen.py
Normal 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
|
||||
Reference in New Issue
Block a user