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

@@ -36,8 +36,10 @@ from .destinataere import ( # noqa: F401
DestinataerNotiz,
DestinataerUnterstuetzung,
Foerderung,
OnboardingEinladung,
Person,
UnterstuetzungWiederkehrend,
UploadToken,
VierteljahresNachweis,
)
@@ -52,3 +54,7 @@ from .veranstaltungen import ( # noqa: F401
Veranstaltung,
Veranstaltungsteilnehmer,
)
from .vorlagen import ( # noqa: F401
DokumentVorlage,
)

View File

@@ -1307,3 +1307,149 @@ class EmailEingang(models.Model):
# Backward-compatible alias
DestinataerEmailEingang = EmailEingang
class UploadToken(models.Model):
"""
Einmaliger Upload-Token für tokenbasiertes Nachweis-Upload-Portal.
Ermöglicht Destinatären den Dokumenten-Upload ohne Nutzerkonto.
Der Token wird per E-Mail (mit QR-Code) versendet und ist 30 Tage gültig.
Nach einmaliger Nutzung (Upload) wird eingeloest_am gesetzt.
Die IP-Adresse wird nur als SHA-256-Hash gespeichert (DSGVO-konform).
"""
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
token = models.CharField(
max_length=128,
unique=True,
db_index=True,
verbose_name="Token",
)
destinataer = models.ForeignKey(
"Destinataer",
on_delete=models.CASCADE,
related_name="upload_tokens",
verbose_name="Destinatär",
)
nachweis = models.ForeignKey(
"VierteljahresNachweis",
on_delete=models.CASCADE,
related_name="upload_tokens",
verbose_name="Nachweis",
)
gueltig_bis = models.DateTimeField(verbose_name="Gültig bis")
erstellt_am = models.DateTimeField(auto_now_add=True, verbose_name="Erstellt am")
eingeloest_am = models.DateTimeField(
null=True, blank=True, verbose_name="Eingelöst am"
)
ist_aktiv = models.BooleanField(default=True, verbose_name="Aktiv")
ip_hash = models.CharField(
max_length=64, blank=True, null=True, verbose_name="IP-Hash (SHA-256)"
)
erinnerung_gesendet = models.BooleanField(
default=False, verbose_name="Erinnerung gesendet"
)
class Meta:
verbose_name = "Upload-Token"
verbose_name_plural = "Upload-Token"
ordering = ["-erstellt_am"]
def __str__(self):
return f"Token für {self.destinataer} ({self.nachweis})"
def ist_gueltig(self):
"""Prüft ob der Token noch gültig und aktiv ist."""
from django.utils import timezone
return (
self.ist_aktiv
and self.eingeloest_am is None
and self.gueltig_bis > timezone.now()
)
def einloesen(self, ip_address=None):
"""Markiert den Token als eingelöst. IP wird als Hash gespeichert."""
import hashlib
from django.utils import timezone
self.eingeloest_am = timezone.now()
self.ist_aktiv = False
if ip_address:
self.ip_hash = hashlib.sha256(ip_address.encode()).hexdigest()
self.save(update_fields=["eingeloest_am", "ist_aktiv", "ip_hash"])
class OnboardingEinladung(models.Model):
"""
Einladung zum Onboarding für neue Destinatäre.
Verwaltungsmitarbeiter versenden eine Einladungs-E-Mail.
Der Eingeladene füllt das mehrstufige Onboarding-Formular aus.
Nach Abschluss wird ein neuer Destinatär mit unterstuetzung_bestaetigt=False angelegt.
"""
STATUS_CHOICES = [
("offen", "Offen"),
("abgeschlossen", "Abgeschlossen"),
("abgelaufen", "Abgelaufen"),
("widerrufen", "Widerrufen"),
]
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
token = models.CharField(
max_length=128,
unique=True,
db_index=True,
verbose_name="Token",
)
email = models.EmailField(verbose_name="E-Mail-Adresse des Eingeladenen")
vorname = models.CharField(
max_length=100, blank=True, verbose_name="Vorname (optional)"
)
nachname = models.CharField(
max_length=100, blank=True, verbose_name="Nachname (optional)"
)
eingeladen_von = models.ForeignKey(
"auth.User",
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name="onboarding_einladungen",
verbose_name="Eingeladen von",
)
gueltig_bis = models.DateTimeField(verbose_name="Gültig bis")
erstellt_am = models.DateTimeField(auto_now_add=True, verbose_name="Erstellt am")
abgeschlossen_am = models.DateTimeField(
null=True, blank=True, verbose_name="Abgeschlossen am"
)
destinataer = models.ForeignKey(
"Destinataer",
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name="onboarding_einladung",
verbose_name="Resultierender Destinatär",
)
status = models.CharField(
max_length=20,
choices=STATUS_CHOICES,
default="offen",
verbose_name="Status",
)
notizen = models.TextField(blank=True, verbose_name="Interne Notizen")
class Meta:
verbose_name = "Onboarding-Einladung"
verbose_name_plural = "Onboarding-Einladungen"
ordering = ["-erstellt_am"]
def __str__(self):
return f"Einladung für {self.email} ({self.get_status_display()})"
def ist_gueltig(self):
"""Prüft ob die Einladung noch gültig ist."""
from django.utils import timezone
return (
self.status == "offen"
and self.gueltig_bis > timezone.now()
)

View File

@@ -0,0 +1,54 @@
import uuid
from django.contrib.auth.models import User
from django.db import models
class DokumentVorlage(models.Model):
"""Web-editierbare Vorlagen für generierte Dokumente (PDF, E-Mail, Berichte)."""
KATEGORIE_CHOICES = [
("pdf", "PDF-Dokument"),
("email", "E-Mail"),
("bericht", "Bericht"),
("serienbrief", "Serienbrief"),
]
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
schluessel = models.CharField(
max_length=200,
unique=True,
verbose_name="Schlüssel",
help_text="Interner Template-Pfad, z.B. pdf/bestaetigung.html",
)
bezeichnung = models.CharField(max_length=200, verbose_name="Bezeichnung")
kategorie = models.CharField(
max_length=30,
choices=KATEGORIE_CHOICES,
verbose_name="Kategorie",
)
html_inhalt = models.TextField(verbose_name="HTML-Inhalt")
verfuegbare_variablen = models.JSONField(
default=dict,
blank=True,
verbose_name="Verfügbare Variablen",
help_text="JSON-Dokumentation der verfügbaren Template-Variablen",
)
zuletzt_bearbeitet_von = models.ForeignKey(
User,
null=True,
blank=True,
on_delete=models.SET_NULL,
related_name="bearbeitete_vorlagen",
verbose_name="Zuletzt bearbeitet von",
)
zuletzt_bearbeitet_am = models.DateTimeField(auto_now=True, verbose_name="Zuletzt bearbeitet")
erstellt_am = models.DateTimeField(auto_now_add=True, verbose_name="Erstellt am")
class Meta:
verbose_name = "Dokument-Vorlage"
verbose_name_plural = "Dokument-Vorlagen"
ordering = ["kategorie", "bezeichnung"]
def __str__(self):
return f"{self.bezeichnung} ({self.schluessel})"