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

@@ -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