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,
@@ -476,11 +477,13 @@ def destinataer_toggle_archiv(request, pk):
destinataer.aktiv = not destinataer.aktiv
destinataer.save(update_fields=["aktiv"])
status_text = "aktiviert" if destinataer.aktiv else "archiviert (inaktiv gesetzt)"
AuditLog.objects.create(
user=request.user,
action=f"Destinatär {destinataer.get_full_name()} wurde {status_text}.",
model_name="Destinataer",
object_id=str(destinataer.pk),
log_action(
request,
action="update",
entity_type="destinataer",
entity_id=str(destinataer.pk),
entity_name=destinataer.get_full_name(),
description=f"Destinatär {destinataer.get_full_name()} wurde {status_text}.",
)
messages.success(request, f'Destinatär "{destinataer.get_full_name()}" wurde {status_text}.')
return redirect("stiftung:destinataer_detail", pk=destinataer.pk)
@@ -760,3 +763,101 @@ def destinataer_export(request, pk):
pass
# =============================================================================
# Bestätigungsschreiben
# =============================================================================
@login_required
def bestaetigung_vorschau(request, pk):
"""
PDF-Vorschau eines Bestätigungsschreibens für einen Destinatär im Browser.
Generiert das PDF on-the-fly via WeasyPrint.
"""
from decimal import Decimal
from django.template.loader import render_to_string
destinataer = get_object_or_404(Destinataer, id=pk)
unterstuetzungen = DestinataerUnterstuetzung.objects.filter(
destinataer=destinataer,
status__in=["ausgezahlt", "abgeschlossen"],
).order_by("faellig_am")
gesamtbetrag = unterstuetzungen.aggregate(total=Sum("betrag"))["total"] or Decimal("0")
zeitraum = None
if unterstuetzungen.exists():
erste = unterstuetzungen.first().faellig_am
letzte = unterstuetzungen.last().faellig_am
if erste == letzte:
zeitraum = erste.strftime("%d.%m.%Y")
else:
zeitraum = f"{erste.strftime('%d.%m.%Y')} {letzte.strftime('%d.%m.%Y')}"
betrag_quartal = destinataer.vierteljaehrlicher_betrag
betrag_jaehrlich = (betrag_quartal * 4) if betrag_quartal else None
zweck = destinataer.ausbildungsstand or (destinataer.get_berufsgruppe_display() if destinataer.berufsgruppe else None)
context = {
"destinataer": destinataer,
"unterstuetzungen": unterstuetzungen,
"gesamtbetrag": gesamtbetrag,
"datum": timezone.now().date(),
"zeitraum": zeitraum,
"betrag_quartal": betrag_quartal,
"betrag_jaehrlich": betrag_jaehrlich,
"zweck": zweck,
}
try:
from weasyprint import HTML
from stiftung.utils.vorlagen import render_vorlage
html_content = render_vorlage("pdf/bestaetigung.html", context)
pdf_bytes = HTML(string=html_content).write_pdf()
response = HttpResponse(pdf_bytes, content_type="application/pdf")
filename = f"bestaetigung_{destinataer.nachname}_{destinataer.vorname}.pdf"
response["Content-Disposition"] = f'inline; filename="{filename}"'
return response
except Exception as exc:
messages.error(request, f"PDF-Generierung fehlgeschlagen: {exc}")
return redirect("stiftung:destinataer_detail", pk=pk)
@login_required
def bestaetigung_versenden(request, pk):
"""
Sendet das Bestätigungsschreiben per E-Mail an den Destinatär.
POST-only (CSRF-geschützt). Startet asynchronen Celery-Task.
"""
from stiftung.tasks import send_bestaetigung
if request.method != "POST":
return redirect("stiftung:destinataer_detail", pk=pk)
destinataer = get_object_or_404(Destinataer, id=pk)
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=pk)
base_url = request.build_absolute_uri("/").rstrip("/")
send_bestaetigung.delay(str(destinataer.id), base_url=base_url)
log_action(
request,
action="update",
entity_type="destinataer",
entity_id=str(destinataer.id),
entity_name=destinataer.get_full_name(),
description=f"Bestätigungsschreiben per E-Mail gesendet an {destinataer.get_full_name()} ({destinataer.email})",
)
messages.success(
request,
f"Bestätigungsschreiben wird per E-Mail an {destinataer.email} gesendet.",
)
return redirect("stiftung:destinataer_detail", pk=pk)