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