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:
@@ -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()
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user