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,59 @@
# Generated by Django 5.0.6 on 2026-03-15 23:02
import django.db.models.deletion
import uuid
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('stiftung', '0059_nachweis_kategorie_dms_felder'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name='OnboardingEinladung',
fields=[
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
('token', models.CharField(db_index=True, max_length=128, unique=True, verbose_name='Token')),
('email', models.EmailField(max_length=254, verbose_name='E-Mail-Adresse des Eingeladenen')),
('vorname', models.CharField(blank=True, max_length=100, verbose_name='Vorname (optional)')),
('nachname', models.CharField(blank=True, max_length=100, verbose_name='Nachname (optional)')),
('gueltig_bis', models.DateTimeField(verbose_name='Gültig bis')),
('erstellt_am', models.DateTimeField(auto_now_add=True, verbose_name='Erstellt am')),
('abgeschlossen_am', models.DateTimeField(blank=True, null=True, verbose_name='Abgeschlossen am')),
('status', models.CharField(choices=[('offen', 'Offen'), ('abgeschlossen', 'Abgeschlossen'), ('abgelaufen', 'Abgelaufen'), ('widerrufen', 'Widerrufen')], default='offen', max_length=20, verbose_name='Status')),
('notizen', models.TextField(blank=True, verbose_name='Interne Notizen')),
('destinataer', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='onboarding_einladung', to='stiftung.destinataer', verbose_name='Resultierender Destinatär')),
('eingeladen_von', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='onboarding_einladungen', to=settings.AUTH_USER_MODEL, verbose_name='Eingeladen von')),
],
options={
'verbose_name': 'Onboarding-Einladung',
'verbose_name_plural': 'Onboarding-Einladungen',
'ordering': ['-erstellt_am'],
},
),
migrations.CreateModel(
name='UploadToken',
fields=[
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
('token', models.CharField(db_index=True, max_length=128, unique=True, verbose_name='Token')),
('gueltig_bis', models.DateTimeField(verbose_name='Gültig bis')),
('erstellt_am', models.DateTimeField(auto_now_add=True, verbose_name='Erstellt am')),
('eingeloest_am', models.DateTimeField(blank=True, null=True, verbose_name='Eingelöst am')),
('ist_aktiv', models.BooleanField(default=True, verbose_name='Aktiv')),
('ip_hash', models.CharField(blank=True, max_length=64, null=True, verbose_name='IP-Hash (SHA-256)')),
('erinnerung_gesendet', models.BooleanField(default=False, verbose_name='Erinnerung gesendet')),
('destinataer', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='upload_tokens', to='stiftung.destinataer', verbose_name='Destinatär')),
('nachweis', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='upload_tokens', to='stiftung.vierteljahresnachweis', verbose_name='Nachweis')),
],
options={
'verbose_name': 'Upload-Token',
'verbose_name_plural': 'Upload-Token',
'ordering': ['-erstellt_am'],
},
),
]

View File

@@ -0,0 +1,160 @@
import uuid
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
def seed_vorlagen(apps, schema_editor):
"""Seed initial DokumentVorlage records from file templates."""
import os
from django.template.loader import get_template
from django.template import TemplateDoesNotExist
DokumentVorlage = apps.get_model("stiftung", "DokumentVorlage")
# Map: (schluessel, bezeichnung, kategorie, variablen)
vorlagen_def = [
(
"pdf/bestaetigung.html",
"Bestätigung PDF",
"pdf",
{
"destinataer.vorname": "Vorname",
"destinataer.nachname": "Nachname",
"destinataer.anrede": "Anrede (Herr/Frau)",
"destinataer.strasse": "Straße",
"destinataer.plz": "PLZ",
"destinataer.ort": "Ort",
"betrag_quartal": "Betrag pro Quartal",
"betrag_jaehrlich": "Jährlicher Betrag",
"zeitraum": "Förderzeitraum",
"zweck": "Förderzweck",
"unterstuetzungen": "Liste der Unterstützungen",
"gesamtbetrag": "Gesamtbetrag",
"datum": "Datum der Erstellung",
},
),
(
"email/bestaetigung.html",
"Bestätigung E-Mail (HTML)",
"email",
{
"destinataer.vorname": "Vorname",
"destinataer.nachname": "Nachname",
"destinataer.anrede": "Anrede",
"zeitraum": "Förderzeitraum",
"gesamtbetrag": "Gesamtbetrag",
"datum": "Datum",
},
),
(
"email/nachweis_aufforderung.html",
"Nachweis-Aufforderung E-Mail (HTML)",
"email",
{
"destinataer.vorname": "Vorname",
"destinataer.nachname": "Nachname",
"halbjahr_label": "Halbjahr-Bezeichnung",
"upload_url": "Upload-URL",
"gueltig_bis": "Gültig bis",
"qr_code_base64": "QR-Code (base64)",
"ist_erinnerung": "True wenn Erinnerung",
},
),
(
"email/nachweis_aufforderung.txt",
"Nachweis-Aufforderung E-Mail (Text)",
"email",
{
"destinataer.vorname": "Vorname",
"destinataer.nachname": "Nachname",
"halbjahr_label": "Halbjahr-Bezeichnung",
"upload_url": "Upload-URL",
"gueltig_bis": "Gültig bis",
"ist_erinnerung": "True wenn Erinnerung",
},
),
(
"email/onboarding_einladung.html",
"Onboarding-Einladung E-Mail (HTML)",
"email",
{
"einladung.vorname": "Vorname",
"einladung.nachname": "Nachname",
"onboarding_url": "Onboarding-URL",
"gueltig_bis": "Gültig bis",
},
),
(
"email/onboarding_einladung.txt",
"Onboarding-Einladung E-Mail (Text)",
"email",
{
"einladung.vorname": "Vorname",
"einladung.nachname": "Nachname",
"onboarding_url": "Onboarding-URL",
"gueltig_bis": "Gültig bis",
},
),
]
templates_dir = os.path.join(settings.BASE_DIR, "templates")
for schluessel, bezeichnung, kategorie, variablen in vorlagen_def:
template_path = os.path.join(templates_dir, schluessel)
if os.path.exists(template_path):
with open(template_path, "r", encoding="utf-8") as f:
html_inhalt = f.read()
DokumentVorlage.objects.get_or_create(
schluessel=schluessel,
defaults={
"bezeichnung": bezeichnung,
"kategorie": kategorie,
"html_inhalt": html_inhalt,
"verfuegbare_variablen": variablen,
},
)
class Migration(migrations.Migration):
dependencies = [
("stiftung", "0060_portal_upload_token_onboarding"),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name="DokumentVorlage",
fields=[
("id", models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
("schluessel", models.CharField(help_text="Interner Template-Pfad, z.B. pdf/bestaetigung.html", max_length=200, unique=True, verbose_name="Schlüssel")),
("bezeichnung", models.CharField(max_length=200, verbose_name="Bezeichnung")),
("kategorie", models.CharField(
choices=[("pdf", "PDF-Dokument"), ("email", "E-Mail"), ("bericht", "Bericht"), ("serienbrief", "Serienbrief")],
max_length=30,
verbose_name="Kategorie",
)),
("html_inhalt", models.TextField(verbose_name="HTML-Inhalt")),
("verfuegbare_variablen", models.JSONField(blank=True, default=dict, help_text="JSON-Dokumentation der verfügbaren Template-Variablen", verbose_name="Verfügbare Variablen")),
("zuletzt_bearbeitet_am", models.DateTimeField(auto_now=True, verbose_name="Zuletzt bearbeitet")),
("erstellt_am", models.DateTimeField(auto_now_add=True, verbose_name="Erstellt am")),
("zuletzt_bearbeitet_von", models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="bearbeitete_vorlagen",
to=settings.AUTH_USER_MODEL,
verbose_name="Zuletzt bearbeitet von",
)),
],
options={
"verbose_name": "Dokument-Vorlage",
"verbose_name_plural": "Dokument-Vorlagen",
"ordering": ["kategorie", "bezeichnung"],
},
),
migrations.RunPython(seed_vorlagen, migrations.RunPython.noop),
]

View File

@@ -0,0 +1,57 @@
"""Seed Veranstaltungseinladung (Serienbrief) into DokumentVorlage."""
import os
from django.conf import settings
from django.db import migrations
def seed_veranstaltungseinladung(apps, schema_editor):
DokumentVorlage = apps.get_model("stiftung", "DokumentVorlage")
schluessel = "stiftung/veranstaltung/serienbrief_pdf.html"
template_path = os.path.join(settings.BASE_DIR, "templates", schluessel)
if os.path.exists(template_path):
with open(template_path, "r", encoding="utf-8") as f:
html_inhalt = f.read()
DokumentVorlage.objects.get_or_create(
schluessel=schluessel,
defaults={
"bezeichnung": "Veranstaltungseinladung (Serienbrief)",
"kategorie": "serienbrief",
"html_inhalt": html_inhalt,
"verfuegbare_variablen": {
"veranstaltung.titel": "Titel der Veranstaltung",
"veranstaltung.datum": "Datum der Veranstaltung",
"veranstaltung.uhrzeit": "Uhrzeit",
"veranstaltung.ort": "Ort / Gasthaus",
"veranstaltung.adresse": "Adresse des Veranstaltungsorts",
"veranstaltung.betreff": "Betreffzeile (optional)",
"veranstaltung.briefvorlage": "Freier Brieftext (HTML, optional)",
"veranstaltung.unterschrift_1_name": "Name Unterschrift 1",
"veranstaltung.unterschrift_1_titel": "Titel Unterschrift 1",
"veranstaltung.unterschrift_2_name": "Name Unterschrift 2",
"veranstaltung.unterschrift_2_titel": "Titel Unterschrift 2",
"teilnehmer": "Liste der Teilnehmer (for-Schleife)",
"t.anrede": "Anrede des Teilnehmers (in Schleife)",
"t.vorname": "Vorname des Teilnehmers",
"t.nachname": "Nachname des Teilnehmers",
"t.strasse": "Straße des Teilnehmers",
"t.plz": "PLZ des Teilnehmers",
"t.ort": "Ort des Teilnehmers",
},
},
)
class Migration(migrations.Migration):
dependencies = [
("stiftung", "0061_dokument_vorlage"),
]
operations = [
migrations.RunPython(seed_veranstaltungseinladung, migrations.RunPython.noop),
]