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

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

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

View File

@@ -0,0 +1,82 @@
"""
Management command to import participants into a Veranstaltung.
Usage:
python manage.py import_veranstaltung_teilnehmer <veranstaltung_id>
"""
from django.core.management.base import BaseCommand
from stiftung.models import Veranstaltung, Veranstaltungsteilnehmer
TEILNEHMER_DATA = [
{"anrede": "Herr", "vorname": "Stephan", "nachname": "Bohnekamp", "strasse": "Marienthaler Strasse 44", "plz": "46569", "ort": "Hünxe-Drevenack"},
{"anrede": "Frau", "vorname": "Maike", "nachname": "Buchmann-Bender", "strasse": "Am Wehagen 6", "plz": "46485", "ort": "Wesel"},
{"anrede": "Herr", "vorname": "Edmund", "nachname": "Eichelberg", "strasse": "Schwarzensteiner Weg 75", "plz": "46569", "ort": "Hünxe-Drevenack"},
{"anrede": "Herr", "vorname": "Walter", "nachname": "Buchmann-Bender", "strasse": "Büskesheide 11", "plz": "46499", "ort": "Hamminkeln"},
{"anrede": "Herr", "vorname": "Gerold", "nachname": "Hurtienne", "strasse": "Birkenweg 14", "plz": "46569", "ort": "Hünxe-Drevenack"},
{"anrede": "Frau", "vorname": "Katrin", "nachname": "Kleinpaß", "strasse": "Raesfelder Strasse 3", "plz": "46499", "ort": "Hamminkeln"},
{"anrede": "Frau", "vorname": "Zoe", "nachname": "Kleinpaß", "strasse": "Raesfelder Strasse 3", "plz": "46499", "ort": "Hamminkeln"},
{"anrede": "Frau", "vorname": "Nele", "nachname": "Schmäh", "strasse": "Raesfelder Strasse 3", "plz": "46499", "ort": "Hamminkeln"},
{"anrede": "Frau", "vorname": "Susanne", "nachname": "Menz", "strasse": "Zum Weissenstein 7 a", "plz": "46499", "ort": "Hamminkeln"},
{"anrede": "Herr", "vorname": "Jan Remmer", "nachname": "Siebels", "strasse": "Holthauser Feld 7", "plz": "49716", "ort": "Meppen"},
{"anrede": "Frau", "vorname": "Annette", "nachname": "von der Höh", "strasse": "Fehmarnstrasse 53", "plz": "33729", "ort": "Bielefeld"},
{"anrede": "Herr", "vorname": "Hartmut", "nachname": "Küppers", "strasse": "Jöhrenstr. 10", "plz": "30559", "ort": "Hannover"},
{"anrede": "Frau", "vorname": "Ruth", "nachname": "Höhne", "strasse": "Löwenburgstr. 127", "plz": "53229", "ort": "Bonn-Niederholtorf"},
{"anrede": "Herr", "vorname": "Aleph", "nachname": "Freese", "strasse": "Christoph Str. 50", "plz": "40225", "ort": "Düsseldorf"},
{"anrede": "Herr", "vorname": "Patrik", "nachname": "Schüngel", "strasse": "Im Sand 11a", "plz": "47608", "ort": "Geldern- Walbeck"},
{"anrede": "Frau", "vorname": "Christiane", "nachname": "Siebels", "strasse": "Rudolf Kinau Strasse 10", "plz": "49716", "ort": "Meppen"},
]
class Command(BaseCommand):
help = "Importiert Teilnehmer in eine Veranstaltung"
def add_arguments(self, parser):
parser.add_argument("veranstaltung_id", type=str, help="UUID der Veranstaltung")
parser.add_argument("--dry-run", action="store_true", help="Nur anzeigen, nicht importieren")
def handle(self, *args, **options):
vid = options["veranstaltung_id"]
dry_run = options["dry_run"]
try:
veranstaltung = Veranstaltung.objects.get(id=vid)
except Veranstaltung.DoesNotExist:
self.stderr.write(self.style.ERROR(f"Veranstaltung {vid} nicht gefunden"))
return
self.stdout.write(f"Veranstaltung: {veranstaltung}")
self.stdout.write(f"Teilnehmer zu importieren: {len(TEILNEHMER_DATA)}")
if dry_run:
for t in TEILNEHMER_DATA:
self.stdout.write(f" [DRY] {t['anrede']} {t['vorname']} {t['nachname']}")
return
created = 0
for t in TEILNEHMER_DATA:
# Check for duplicates
exists = Veranstaltungsteilnehmer.objects.filter(
veranstaltung=veranstaltung,
vorname=t["vorname"],
nachname=t["nachname"],
).exists()
if exists:
self.stdout.write(self.style.WARNING(f" SKIP (exists): {t['vorname']} {t['nachname']}"))
continue
Veranstaltungsteilnehmer.objects.create(
veranstaltung=veranstaltung,
anrede=t["anrede"],
vorname=t["vorname"],
nachname=t["nachname"],
strasse=t["strasse"],
plz=t["plz"],
ort=t["ort"],
rsvp_status="eingeladen",
)
created += 1
self.stdout.write(self.style.SUCCESS(f" OK: {t['vorname']} {t['nachname']}"))
self.stdout.write(self.style.SUCCESS(f"\n{created} Teilnehmer importiert."))

View File

@@ -69,6 +69,67 @@ class Command(BaseCommand):
"category": "email",
"order": 6,
},
# SMTP Settings
{
"key": "smtp_host",
"display_name": "SMTP Server",
"description": "Hostname des SMTP-Servers (z.B. smtp.ionos.de)",
"value": "smtp.ionos.de",
"default_value": "smtp.ionos.de",
"setting_type": "text",
"category": "email",
"order": 10,
},
{
"key": "smtp_port",
"display_name": "SMTP Port",
"description": "Port des SMTP-Servers (465 für SSL, 587 für STARTTLS)",
"value": "465",
"default_value": "465",
"setting_type": "number",
"category": "email",
"order": 11,
},
{
"key": "smtp_user",
"display_name": "SMTP Benutzername",
"description": "Benutzername / E-Mail-Adresse für die SMTP-Anmeldung",
"value": "",
"default_value": "",
"setting_type": "text",
"category": "email",
"order": 12,
},
{
"key": "smtp_password",
"display_name": "SMTP Passwort",
"description": "Passwort für die SMTP-Anmeldung",
"value": "",
"default_value": "",
"setting_type": "password",
"category": "email",
"order": 13,
},
{
"key": "smtp_use_ssl",
"display_name": "SSL/TLS verwenden (SMTP)",
"description": "Sichere Verbindung zum SMTP-Server (empfohlen für Port 465)",
"value": "True",
"default_value": "True",
"setting_type": "boolean",
"category": "email",
"order": 14,
},
{
"key": "smtp_from_email",
"display_name": "Absenderadresse (SMTP)",
"description": "Absenderadresse für ausgehende E-Mails (z.B. buero@vhtv-stiftung.de)",
"value": "buero@vhtv-stiftung.de",
"default_value": "buero@vhtv-stiftung.de",
"setting_type": "text",
"category": "email",
"order": 15,
},
]
all_settings = email_settings

View File

@@ -0,0 +1,50 @@
"""Management-Command: Stellt alle DokumentVorlage-Einträge aus den Originaldateien wieder her."""
from django.core.management.base import BaseCommand
from stiftung.models import DokumentVorlage
from stiftung.utils.vorlagen import get_vorlage_original
class Command(BaseCommand):
help = "Stellt alle DokumentVorlage-Einträge aus den Original-Dateien wieder her."
def add_arguments(self, parser):
parser.add_argument(
"--dry-run",
action="store_true",
help="Zeigt nur an, was geändert würde, ohne tatsächlich zu ändern.",
)
def handle(self, *args, **options):
dry_run = options["dry_run"]
vorlagen = DokumentVorlage.objects.all()
if not vorlagen.exists():
self.stdout.write(self.style.WARNING("Keine DokumentVorlage-Einträge gefunden."))
return
restored = 0
skipped = 0
for vorlage in vorlagen:
try:
original = get_vorlage_original(vorlage.schluessel)
except FileNotFoundError:
self.stdout.write(
self.style.WARNING(f" SKIP: {vorlage.schluessel} — Original-Datei nicht gefunden")
)
skipped += 1
continue
if dry_run:
self.stdout.write(f" WÜRDE WIEDERHERSTELLEN: {vorlage.schluessel}")
else:
vorlage.html_inhalt = original
vorlage.save(update_fields=["html_inhalt", "zuletzt_bearbeitet_am"])
self.stdout.write(self.style.SUCCESS(f" OK: {vorlage.schluessel}"))
restored += 1
action = "würden wiederhergestellt" if dry_run else "wiederhergestellt"
self.stdout.write(
self.style.SUCCESS(f"\n{restored} Vorlagen {action}, {skipped} übersprungen.")
)