Files
stiftung-management-system/app/stiftung/views/unterstuetzungen.py
SysAdmin Agent 4ef09750d6 Remove 'Abgeschlossen' from payment pipeline, make 'Überwiesen' the final step
The 'Abgeschlossen' column was redundant after 'Überwiesen' since no further
action occurs after a payment is transferred. The pipeline is now 4 stages:
Offen → Nachweis eingereicht → Freigegeben → Überwiesen.

Existing 'abgeschlossen' records are merged into the 'Überwiesen' column.
Financial reports and queries are unaffected as they already include both statuses.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-21 20:59:42 +00:00

2158 lines
85 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# views/unterstuetzungen.py
# Phase 0: Vision 2026 Code-Refactoring
import csv
import io
import json
import os
import time
from datetime import datetime, timedelta, date
from decimal import Decimal
import qrcode
import qrcode.image.svg
import requests
from django.conf import settings
from django.contrib import messages
from django.contrib.auth.decorators import login_required
from django.core.paginator import Paginator
from django.db.models import (Avg, Count, DecimalField, F, IntegerField, Q,
Sum, Value)
from django.db.models.functions import Cast, Coalesce, NullIf, Replace
from django.http import HttpResponse, JsonResponse
from django.shortcuts import get_object_or_404, redirect, render
from django.urls import reverse
from django.utils import timezone
from django.views.decorators.csrf import csrf_exempt
from django_otp.decorators import otp_required
from django_otp.plugins.otp_totp.models import TOTPDevice
from django_otp.plugins.otp_static.models import StaticDevice, StaticToken
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,
DestinataerUnterstuetzung,
DokumentLink, Foerderung, GeschichteBild, GeschichteSeite,
Land, LandAbrechnung, LandVerpachtung, Paechter, Person,
Rentmeister, StiftungsKalenderEintrag, StiftungsKonto,
UnterstuetzungWiederkehrend, Veranstaltung,
Veranstaltungsteilnehmer, Verwaltungskosten,
VierteljahresNachweis)
from stiftung.forms import (
DestinataerForm, DestinataerUnterstuetzungForm, DestinataerNotizForm,
FoerderungForm, GeschichteBildForm, GeschichteSeiteForm,
LandForm, LandVerpachtungForm, LandAbrechnungForm,
PaechterForm, DokumentLinkForm,
RentmeisterForm, StiftungsKontoForm, VerwaltungskostenForm,
BankTransactionForm, BankImportForm,
UnterstuetzungForm, UnterstuetzungWiederkehrendForm,
UnterstuetzungMarkAsPaidForm, VierteljahresNachweisForm,
UserCreationForm, UserUpdateForm, PasswordChangeForm, UserPermissionForm,
TwoFactorSetupForm, TwoFactorVerifyForm, TwoFactorDisableForm,
BackupTokenRegenerateForm, PersonForm,
VeranstaltungForm, VeranstaltungsteilnehmerForm,
)
@login_required
def unterstuetzungen_list(request):
"""Liste der Destinatärunterstützungen (Administration)."""
status = request.GET.get("status", "")
export_format = (
request.POST.get("format")
if request.method == "POST"
else request.GET.get("format", "")
)
selected_ids_param = (
request.POST.get("selected_entries", "")
if request.method == "POST"
else request.GET.get("selected_entries", "")
)
selected_ids = (
[id for id in selected_ids_param.split(",") if id] if selected_ids_param else []
)
qs = DestinataerUnterstuetzung.objects.select_related(
"destinataer", "konto", "ausgezahlt_von", "wiederkehrend_von"
).order_by("-faellig_am", "destinataer__nachname")
if status:
qs = qs.filter(status=status)
# Enhanced CSV export with field selection
if export_format == "csv":
return export_unterstuetzungen_csv(request, qs, selected_ids)
# Enhanced PDF export with corporate identity
elif export_format == "pdf":
return export_unterstuetzungen_pdf(request, qs, selected_ids)
# Get quarterly confirmation statistics
quarterly_stats = {}
total_quarterly = VierteljahresNachweis.objects.count()
for status_code, status_name in VierteljahresNachweis.STATUS_CHOICES:
count = VierteljahresNachweis.objects.filter(status=status_code).count()
quarterly_stats[status_code] = {
'name': status_name,
'count': count
}
context = {
"unterstuetzungen": qs,
"status_filter": status,
"quarterly_stats": quarterly_stats,
"total_quarterly": total_quarterly,
}
return render(request, "stiftung/unterstuetzungen_list.html", context)
def export_unterstuetzungen_csv(request, queryset, selected_ids=None):
"""Enhanced CSV export with field selection"""
import csv
from datetime import datetime
from django.http import HttpResponse
# If specific entries are selected, filter to only those
if selected_ids:
queryset = queryset.filter(id__in=selected_ids)
# Get selected fields from request (handle both 'fields' and 'selected_fields' parameter names)
selected_fields_param = ""
if request.method == "POST":
# Try 'fields' first (new format), then 'selected_fields' (legacy)
fields_list = request.POST.getlist("fields")
if fields_list:
selected_fields_param = ",".join(fields_list)
else:
selected_fields_param = request.POST.get("selected_fields", "")
else:
# Try 'fields' first (new format), then 'selected_fields' (legacy)
fields_list = request.GET.getlist("fields")
if fields_list:
selected_fields_param = ",".join(fields_list)
else:
selected_fields_param = request.GET.get("selected_fields", "")
selected_fields = selected_fields_param.split(",") if selected_fields_param else []
if not selected_fields:
# Default field set
selected_fields = [
"destinataer_name",
"betrag",
"faellig_am",
"empfaenger_iban",
"verwendungszweck",
"status",
"empfaenger_name",
"beschreibung",
]
# Field definitions with headers and data extraction
field_definitions = {
# Core payment fields
"id": ("ID", lambda u: str(u.id)),
"betrag": ("Betrag (€)", lambda u: f"{u.betrag:.2f}"),
"faellig_am": (
"Fällig am",
lambda u: u.faellig_am.strftime("%d.%m.%Y") if u.faellig_am else "",
),
"status": ("Status", lambda u: u.get_status_display()),
"beschreibung": ("Beschreibung", lambda u: u.beschreibung or ""),
"ausgezahlt_am": (
"Ausgezahlt am",
lambda u: u.ausgezahlt_am.strftime("%d.%m.%Y") if u.ausgezahlt_am else "",
),
"erstellt_am": (
"Erstellt am",
lambda u: u.erstellt_am.strftime("%d.%m.%Y %H:%M") if u.erstellt_am else "",
),
"aktualisiert_am": (
"Aktualisiert am",
lambda u: (
u.aktualisiert_am.strftime("%d.%m.%Y %H:%M")
if u.aktualisiert_am
else ""
),
),
# Destinataer fields
"destinataer_name": (
"Destinatär Name",
lambda u: u.destinataer.get_full_name() if u.destinataer else "",
),
"destinataer_vorname": (
"Vorname",
lambda u: u.destinataer.vorname if u.destinataer else "",
),
"destinataer_nachname": (
"Nachname",
lambda u: u.destinataer.nachname if u.destinataer else "",
),
"familienzweig": (
"Familienzweig",
lambda u: u.destinataer.familienzweig if u.destinataer else "",
),
"geburtsdatum": (
"Geburtsdatum",
lambda u: (
u.destinataer.geburtsdatum.strftime("%d.%m.%Y")
if u.destinataer and u.destinataer.geburtsdatum
else ""
),
),
"email": ("E-Mail", lambda u: u.destinataer.email if u.destinataer else ""),
"telefon": (
"Telefon",
lambda u: u.destinataer.telefon if u.destinataer else "",
),
"destinataer_iban": (
"Destinatär IBAN",
lambda u: u.destinataer.iban if u.destinataer else "",
),
"strasse": ("Straße", lambda u: u.destinataer.strasse if u.destinataer else ""),
"plz": ("PLZ", lambda u: u.destinataer.plz if u.destinataer else ""),
"ort": ("Ort", lambda u: u.destinataer.ort if u.destinataer else ""),
"adresse": (
"Adresse",
lambda u: (
f"{u.destinataer.strasse}, {u.destinataer.plz} {u.destinataer.ort}".strip(
", "
)
if u.destinataer
else ""
),
),
"berufsgruppe": (
"Berufsgruppe",
lambda u: u.destinataer.berufsgruppe if u.destinataer else "",
),
"ausbildungsstand": (
"Ausbildungsstand",
lambda u: u.destinataer.ausbildungsstand if u.destinataer else "",
),
"institution": (
"Institution",
lambda u: u.destinataer.institution if u.destinataer else "",
),
"jaehrliches_einkommen": (
"Jährliches Einkommen (€)",
lambda u: (
f"{u.destinataer.jaehrliches_einkommen:.2f}"
if u.destinataer and u.destinataer.jaehrliches_einkommen
else ""
),
),
"haushaltsgroesse": (
"Haushaltsgröße",
lambda u: (
str(u.destinataer.haushaltsgroesse)
if u.destinataer and u.destinataer.haushaltsgroesse
else ""
),
),
"monatliche_bezuege": (
"Monatliche Bezüge (€)",
lambda u: (
f"{u.destinataer.monatliche_bezuege:.2f}"
if u.destinataer and u.destinataer.monatliche_bezuege
else ""
),
),
"vermoegen": (
"Vermögen (€)",
lambda u: (
f"{u.destinataer.vermoegen:.2f}"
if u.destinataer and u.destinataer.vermoegen
else ""
),
),
# Payment details
"empfaenger_iban": ("Empfänger IBAN", lambda u: u.empfaenger_iban or ""),
"empfaenger_name": ("Empfänger Name", lambda u: u.empfaenger_name or ""),
"verwendungszweck": ("Verwendungszweck", lambda u: u.verwendungszweck or ""),
# Account fields
"konto_name": ("Konto", lambda u: str(u.konto) if u.konto else ""),
"konto_bank": ("Bank", lambda u: u.konto.bank_name if u.konto else ""),
"konto_iban": ("Konto IBAN", lambda u: u.konto.iban if u.konto else ""),
# System fields
"ausgezahlt_von": (
"Ausgezahlt von",
lambda u: u.ausgezahlt_von.get_full_name() if u.ausgezahlt_von else "",
),
"ist_wiederkehrend": (
"Wiederkehrend",
lambda u: "Ja" if u.wiederkehrend_von else "Nein",
),
}
# Create CSV response
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
filename = f"unterstuetzungen_{timestamp}.csv"
response = HttpResponse(content_type="text/csv; charset=utf-8")
response["Content-Disposition"] = f'attachment; filename="{filename}"'
writer = csv.writer(response, delimiter=";", quoting=csv.QUOTE_ALL)
# Write headers
headers = [
field_definitions[field][0]
for field in selected_fields
if field in field_definitions
]
writer.writerow(headers)
# Write data rows
for u in queryset:
row = []
for field in selected_fields:
if field in field_definitions:
try:
value = field_definitions[field][1](u)
row.append(value)
except Exception:
row.append("") # Fallback for any errors
else:
row.append("") # Unknown field
writer.writerow(row)
return response
def export_unterstuetzungen_pdf(request, queryset, selected_ids=None):
"""Enhanced PDF export with corporate identity and field selection"""
# If specific entries are selected, filter to only those
if selected_ids:
queryset = queryset.filter(id__in=selected_ids)
# Get selected fields from request (handle both 'fields' and 'selected_fields' parameter names)
selected_fields_param = ""
if request.method == "POST":
# Try 'fields' first (new format), then 'selected_fields' (legacy)
fields_list = request.POST.getlist("fields")
if fields_list:
selected_fields_param = ",".join(fields_list)
else:
selected_fields_param = request.POST.get("selected_fields", "")
else:
# Try 'fields' first (new format), then 'selected_fields' (legacy)
fields_list = request.GET.getlist("fields")
if fields_list:
selected_fields_param = ",".join(fields_list)
else:
selected_fields_param = request.GET.get("selected_fields", "")
selected_fields = selected_fields_param.split(",") if selected_fields_param else []
if not selected_fields:
# Default field set for PDF (fewer fields than CSV for better readability)
selected_fields = [
"destinataer_name",
"betrag",
"faellig_am",
"empfaenger_iban",
"verwendungszweck",
"status",
"beschreibung",
"ausgezahlt_am",
]
# Field definitions with display names (reuse from CSV but select PDF-appropriate subset)
field_definitions = {
# Core payment fields
"destinataer_name": "Destinatär",
"betrag": "Betrag (€)",
"faellig_am": "Fällig am",
"status": "Status",
"beschreibung": "Beschreibung",
"ausgezahlt_am": "Ausgezahlt am",
"erstellt_am": "Erstellt am",
"empfaenger_iban": "Empfänger IBAN",
"empfaenger_name": "Empfänger",
"verwendungszweck": "Verwendungszweck",
"konto_name": "Konto",
"ist_wiederkehrend": "Wiederkehrend",
}
# Filter to only include fields that are both selected and defined
filtered_fields = {
k: v for k, v in field_definitions.items() if k in selected_fields
}
# Prepare data with field extraction logic
data_for_pdf = []
for item in queryset:
row_data = {}
for field_key in filtered_fields.keys():
try:
if field_key == "destinataer_name":
row_data[field_key] = (
item.destinataer.get_full_name() if item.destinataer else ""
)
elif field_key == "betrag":
row_data[field_key] = f"{item.betrag:.2f}" if item.betrag else ""
elif field_key == "faellig_am":
row_data[field_key] = (
item.faellig_am.strftime("%d.%m.%Y") if item.faellig_am else ""
)
elif field_key == "status":
row_data[field_key] = item.get_status_display()
elif field_key == "beschreibung":
row_data[field_key] = item.beschreibung or ""
elif field_key == "ausgezahlt_am":
row_data[field_key] = (
item.ausgezahlt_am.strftime("%d.%m.%Y")
if item.ausgezahlt_am
else ""
)
elif field_key == "erstellt_am":
row_data[field_key] = (
item.erstellt_am.strftime("%d.%m.%Y")
if item.erstellt_am
else ""
)
elif field_key == "empfaenger_iban":
row_data[field_key] = item.empfaenger_iban or ""
elif field_key == "empfaenger_name":
row_data[field_key] = item.empfaenger_name or ""
elif field_key == "verwendungszweck":
row_data[field_key] = item.verwendungszweck or ""
elif field_key == "konto_name":
row_data[field_key] = str(item.konto) if item.konto else ""
elif field_key == "ist_wiederkehrend":
row_data[field_key] = "Ja" if item.wiederkehrend_von else "Nein"
else:
# Generic field access
row_data[field_key] = getattr(item, field_key, "") or ""
except Exception:
row_data[field_key] = "" # Fallback for any errors
data_for_pdf.append(row_data)
# Use PDF generator
pdf_gen = get_pdf_generator()
return pdf_gen.export_data_list_pdf(
data=data_for_pdf,
fields_config=filtered_fields,
title="Unterstützungen Export",
filename_prefix="unterstuetzungen",
request_user=request.user,
)
def export_foerderungen_csv(request, queryset, selected_ids=None):
"""Enhanced CSV export for Förderungen with field selection"""
import csv
from datetime import datetime
from django.http import HttpResponse
# If specific entries are selected, filter to only those
if selected_ids:
queryset = queryset.filter(id__in=selected_ids)
# Get selected fields from request (default to all if none specified)
selected_fields_param = (
request.POST.get("selected_fields", "")
if request.method == "POST"
else request.GET.get("selected_fields", "")
)
selected_fields = selected_fields_param.split(",") if selected_fields_param else []
if not selected_fields:
# Default field set
selected_fields = [
"destinataer_name",
"jahr",
"betrag",
"kategorie",
"status",
"antragsdatum",
"beschreibung",
]
# Field definitions with headers and data extraction
field_definitions = {
# Core fields
"id": ("ID", lambda f: str(f.id)),
"destinataer_name": (
"Destinatär Name",
lambda f: f.destinataer.get_full_name() if f.destinataer else "",
),
"jahr": ("Jahr", lambda f: str(f.jahr)),
"betrag": ("Betrag (€)", lambda f: f"{f.betrag:.2f}"),
"kategorie": ("Kategorie", lambda f: f.get_kategorie_display()),
"status": ("Status", lambda f: f.get_status_display()),
"antragsdatum": (
"Antragsdatum",
lambda f: f.antragsdatum.strftime("%d.%m.%Y") if f.antragsdatum else "",
),
"bewilligungsdatum": (
"Bewilligungsdatum",
lambda f: (
f.bewilligungsdatum.strftime("%d.%m.%Y") if f.bewilligungsdatum else ""
),
),
"auszahlungsdatum": (
"Auszahlungsdatum",
lambda f: (
f.auszahlungsdatum.strftime("%d.%m.%Y") if f.auszahlungsdatum else ""
),
),
"beschreibung": ("Beschreibung", lambda f: f.beschreibung or ""),
"begruendung": ("Begründung", lambda f: f.begruendung or ""),
"verwendungsnachweis_datum": (
"Verwendungsnachweis Datum",
lambda f: (
f.verwendungsnachweis_datum.strftime("%d.%m.%Y")
if f.verwendungsnachweis_datum
else ""
),
),
"verwendungsnachweis_status": (
"Verwendungsnachweis Status",
lambda f: (
f.get_verwendungsnachweis_status_display()
if f.verwendungsnachweis_status
else ""
),
),
# Destinataer fields
"destinataer_vorname": (
"Vorname",
lambda f: f.destinataer.vorname if f.destinataer else "",
),
"destinataer_nachname": (
"Nachname",
lambda f: f.destinataer.nachname if f.destinataer else "",
),
"familienzweig": (
"Familienzweig",
lambda f: f.destinataer.familienzweig if f.destinataer else "",
),
"email": ("E-Mail", lambda f: f.destinataer.email if f.destinataer else ""),
"telefon": (
"Telefon",
lambda f: f.destinataer.telefon if f.destinataer else "",
),
"adresse": (
"Adresse",
lambda f: (
f"{f.destinataer.strasse}, {f.destinataer.plz} {f.destinataer.ort}".strip(
", "
)
if f.destinataer
else ""
),
),
"berufsgruppe": (
"Berufsgruppe",
lambda f: f.destinataer.berufsgruppe if f.destinataer else "",
),
"ausbildungsstand": (
"Ausbildungsstand",
lambda f: f.destinataer.ausbildungsstand if f.destinataer else "",
),
"institution": (
"Institution",
lambda f: f.destinataer.institution if f.destinataer else "",
),
# System fields
"erstellt_am": (
"Erstellt am",
lambda f: f.erstellt_am.strftime("%d.%m.%Y %H:%M") if f.erstellt_am else "",
),
"aktualisiert_am": (
"Aktualisiert am",
lambda f: (
f.aktualisiert_am.strftime("%d.%m.%Y %H:%M")
if f.aktualisiert_am
else ""
),
),
}
# Create CSV response
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
filename = f"foerderungen_{timestamp}.csv"
response = HttpResponse(content_type="text/csv; charset=utf-8")
response["Content-Disposition"] = f'attachment; filename="{filename}"'
writer = csv.writer(response, delimiter=";", quoting=csv.QUOTE_ALL)
# Write headers
headers = [
field_definitions[field][0]
for field in selected_fields
if field in field_definitions
]
writer.writerow(headers)
# Write data rows
for f in queryset:
row = []
for field in selected_fields:
if field in field_definitions:
try:
value = field_definitions[field][1](f)
row.append(value)
except Exception:
row.append("") # Fallback for any errors
else:
row.append("") # Unknown field
writer.writerow(row)
return response
def export_foerderungen_pdf(request, queryset, selected_ids=None):
"""Enhanced PDF export for Förderungen with corporate identity and field selection"""
# If specific entries are selected, filter to only those
if selected_ids:
queryset = queryset.filter(id__in=selected_ids)
# Get selected fields from request (default to key fields if none specified)
selected_fields_param = (
request.POST.get("selected_fields", "")
if request.method == "POST"
else request.GET.get("selected_fields", "")
)
selected_fields = selected_fields_param.split(",") if selected_fields_param else []
if not selected_fields:
# Default field set for PDF (fewer fields than CSV for better readability)
selected_fields = [
"destinataer_name",
"jahr",
"betrag",
"kategorie",
"status",
"antragsdatum",
]
# Field definitions with display names
field_definitions = {
"destinataer_name": "Destinatär",
"jahr": "Jahr",
"betrag": "Betrag (€)",
"kategorie": "Kategorie",
"status": "Status",
"antragsdatum": "Antragsdatum",
"bewilligungsdatum": "Bewilligungsdatum",
"auszahlungsdatum": "Auszahlungsdatum",
"beschreibung": "Beschreibung",
"begruendung": "Begründung",
"verwendungsnachweis_status": "Verwendungsnachweis",
}
# Filter to only include fields that are both selected and defined
filtered_fields = {
k: v for k, v in field_definitions.items() if k in selected_fields
}
# Prepare data with field extraction logic
data_for_pdf = []
for item in queryset:
row_data = {}
for field_key in filtered_fields.keys():
try:
if field_key == "destinataer_name":
row_data[field_key] = (
item.destinataer.get_full_name() if item.destinataer else ""
)
elif field_key == "jahr":
row_data[field_key] = str(item.jahr)
elif field_key == "betrag":
row_data[field_key] = f"{item.betrag:.2f}" if item.betrag else ""
elif field_key == "kategorie":
row_data[field_key] = item.get_kategorie_display()
elif field_key == "status":
row_data[field_key] = item.get_status_display()
elif field_key == "antragsdatum":
row_data[field_key] = (
item.antragsdatum.strftime("%d.%m.%Y")
if item.antragsdatum
else ""
)
elif field_key == "bewilligungsdatum":
row_data[field_key] = (
item.bewilligungsdatum.strftime("%d.%m.%Y")
if item.bewilligungsdatum
else ""
)
elif field_key == "auszahlungsdatum":
row_data[field_key] = (
item.auszahlungsdatum.strftime("%d.%m.%Y")
if item.auszahlungsdatum
else ""
)
elif field_key == "beschreibung":
row_data[field_key] = (item.beschreibung or "")[:100] + (
"..." if len(item.beschreibung or "") > 100 else ""
)
elif field_key == "begruendung":
row_data[field_key] = (item.begruendung or "")[:100] + (
"..." if len(item.begruendung or "") > 100 else ""
)
elif field_key == "verwendungsnachweis_status":
row_data[field_key] = (
item.get_verwendungsnachweis_status_display()
if item.verwendungsnachweis_status
else ""
)
else:
# Generic field access
row_data[field_key] = getattr(item, field_key, "") or ""
except Exception:
row_data[field_key] = "" # Fallback for any errors
data_for_pdf.append(row_data)
# Use PDF generator
pdf_gen = get_pdf_generator()
return pdf_gen.export_data_list_pdf(
data=data_for_pdf,
fields_config=filtered_fields,
title="Förderungen Export",
filename_prefix="foerderungen",
request_user=request.user,
)
@login_required
def unterstuetzung_edit(request, pk):
obj = get_object_or_404(DestinataerUnterstuetzung, pk=pk)
if request.method == "POST":
form = DestinataerUnterstuetzungForm(request.POST, instance=obj)
if form.is_valid():
form.save()
messages.success(request, "Unterstützung aktualisiert.")
return redirect("stiftung:unterstuetzungen_list")
else:
form = DestinataerUnterstuetzungForm(instance=obj)
return render(
request,
"stiftung/unterstuetzung_form.html",
{"form": form, "title": "Unterstützung bearbeiten"},
)
@login_required
def unterstuetzung_delete(request, pk):
obj = get_object_or_404(DestinataerUnterstuetzung, pk=pk)
# Check if this will also delete the recurring template
will_delete_template = False
if obj.wiederkehrend_von:
andere_zahlungen = (
DestinataerUnterstuetzung.objects.filter(
wiederkehrend_von=obj.wiederkehrend_von
)
.exclude(pk=pk)
.exists()
)
will_delete_template = not andere_zahlungen
if request.method == "POST":
# Check if this support payment is linked to a recurring payment template
wiederkehrend_template = obj.wiederkehrend_von
# Delete the support payment
obj.delete()
# If this was generated from a recurring template and there are no other
# payments from this template, delete the template too
if wiederkehrend_template:
# Check if there are other payments from this recurring template
andere_zahlungen = DestinataerUnterstuetzung.objects.filter(
wiederkehrend_von=wiederkehrend_template
).exists()
# If no other payments exist from this template, delete the template too
if not andere_zahlungen:
wiederkehrend_template.delete()
messages.success(
request,
"Unterstützung und wiederkehrende Zahlungsvorlage gelöscht.",
)
else:
messages.success(request, "Unterstützung gelöscht.")
else:
messages.success(request, "Unterstützung gelöscht.")
return redirect("stiftung:unterstuetzungen_list")
context = {
"obj": obj,
"will_delete_template": will_delete_template,
}
return render(request, "stiftung/unterstuetzung_confirm_delete.html", context)
@login_required
def unterstuetzungen_all(request):
"""List all support payments - destinataer-focused view"""
status = request.GET.get("status")
destinataer_id = request.GET.get("destinataer")
export = request.GET.get("format", "")
selected_ids = (
request.POST.getlist("selected_entries") if request.method == "POST" else []
)
unterstuetzungen = DestinataerUnterstuetzung.objects.select_related(
"destinataer", "konto", "ausgezahlt_von", "wiederkehrend_von"
).order_by("-faellig_am")
# Filtering
if status:
unterstuetzungen = unterstuetzungen.filter(status=status)
if destinataer_id:
unterstuetzungen = unterstuetzungen.filter(destinataer_id=destinataer_id)
# Enhanced CSV export with field selection
if export == "csv":
return export_unterstuetzungen_csv(request, unterstuetzungen, selected_ids)
# PDF export (simple table via WeasyPrint; graceful fallback if missing)
if export == "pdf":
try:
from django.template.loader import render_to_string
from weasyprint import HTML
html = render_to_string(
"stiftung/unterstuetzungen_pdf.html",
{"unterstuetzungen": unterstuetzungen},
)
from django.http import HttpResponse
pdf = HTML(string=html).write_pdf()
resp = HttpResponse(pdf, content_type="application/pdf")
resp["Content-Disposition"] = "inline; filename=unterstuetzungen.pdf"
return resp
except Exception:
pass
# Statistics
total_betrag = unterstuetzungen.aggregate(total=Sum("betrag"))["total"] or 0
# Get quarterly confirmation statistics
quarterly_stats = {}
total_quarterly = VierteljahresNachweis.objects.count()
for status_code, status_name in VierteljahresNachweis.STATUS_CHOICES:
count = VierteljahresNachweis.objects.filter(status=status_code).count()
quarterly_stats[status_code] = {
'name': status_name,
'count': count
}
# Available destinataer for filter
destinataer = Destinataer.objects.all().order_by("nachname", "vorname")
context = {
"page_obj": unterstuetzungen, # Use directly for now (pagination can be added later)
"unterstuetzungen": unterstuetzungen,
"title": "Alle Unterstützungen",
"status_filter": status,
"total_betrag": total_betrag,
"quarterly_stats": quarterly_stats,
"total_quarterly": total_quarterly,
"status_choices": DestinataerUnterstuetzung.STATUS_CHOICES,
"destinataer": destinataer,
}
return render(request, "stiftung/unterstuetzungen_all.html", context)
@login_required
def unterstuetzung_create(request):
"""Create a new support payment"""
# Get destinataer from URL parameter if provided
destinataer_id = request.GET.get("destinataer")
initial = {}
if destinataer_id:
initial["destinataer"] = destinataer_id
# Pre-populate IBAN and name if destinataer is specified
try:
destinataer = Destinataer.objects.get(pk=destinataer_id)
if hasattr(destinataer, "iban") and destinataer.iban:
initial["empfaenger_iban"] = destinataer.iban
initial["empfaenger_name"] = destinataer.get_full_name()
except Destinataer.DoesNotExist:
pass
if request.method == "POST":
form = UnterstuetzungForm(request.POST)
if form.is_valid():
ist_wiederkehrend = form.cleaned_data.get("ist_wiederkehrend", False)
if ist_wiederkehrend:
# Create recurring payment template
wiederkehrend = UnterstuetzungWiederkehrend.objects.create(
destinataer=form.cleaned_data["destinataer"],
konto=form.cleaned_data["konto"],
betrag=form.cleaned_data["betrag"],
intervall=form.cleaned_data["intervall"],
beschreibung=form.cleaned_data["beschreibung"],
empfaenger_iban=form.cleaned_data["empfaenger_iban"],
empfaenger_name=form.cleaned_data["empfaenger_name"],
verwendungszweck=form.cleaned_data["verwendungszweck"],
erste_zahlung_am=form.cleaned_data["faellig_am"],
letzte_zahlung_am=form.cleaned_data.get("letzte_zahlung_am"),
naechste_generierung=form.cleaned_data["faellig_am"],
erstellt_von=request.user,
)
# Create the first payment
unterstuetzung = form.save(commit=False)
unterstuetzung.wiederkehrend_von = wiederkehrend
unterstuetzung.save()
messages.success(
request,
f"Wiederkehrende Unterstützung für {unterstuetzung.destinataer} wurde erfolgreich erstellt. Die erste Zahlung ist am {unterstuetzung.faellig_am} fällig.",
)
else:
# Create single payment
unterstuetzung = form.save()
messages.success(
request,
f"Unterstützung für {unterstuetzung.destinataer} wurde erfolgreich erstellt.",
)
return redirect("stiftung:unterstuetzung_detail", pk=unterstuetzung.pk)
else:
form = UnterstuetzungForm(initial=initial)
context = {
"form": form,
"title": "Neue Unterstützung erstellen",
}
return render(request, "stiftung/unterstuetzung_form.html", context)
@login_required
def get_destinataer_info(request, destinataer_id):
"""AJAX endpoint to get Destinataer IBAN and name information"""
try:
destinataer = Destinataer.objects.get(pk=destinataer_id)
data = {
"success": True,
"name": destinataer.get_full_name(),
"iban": getattr(destinataer, "iban", "") or "",
}
except Destinataer.DoesNotExist:
data = {"success": False, "error": "Destinataer not found"}
return JsonResponse(data)
@login_required
def unterstuetzung_detail(request, pk):
"""View support payment details"""
unterstuetzung = get_object_or_404(DestinataerUnterstuetzung, pk=pk)
# Check if this payment can be marked as paid
can_mark_paid = unterstuetzung.can_be_marked_paid()
context = {
"unterstuetzung": unterstuetzung,
"title": f"Unterstützung für {unterstuetzung.destinataer.get_full_name()}",
"can_mark_paid": can_mark_paid,
}
return render(request, "stiftung/unterstuetzung_detail.html", context)
@login_required
def unterstuetzung_mark_paid(request, pk):
"""Mark a support payment as paid"""
unterstuetzung = get_object_or_404(DestinataerUnterstuetzung, pk=pk)
if not unterstuetzung.can_be_marked_paid():
messages.error(
request, "Diese Unterstützung kann nicht als bezahlt markiert werden."
)
return redirect("stiftung:unterstuetzung_detail", pk=pk)
if request.method == "POST":
form = UnterstuetzungMarkAsPaidForm(request.POST)
if form.is_valid():
unterstuetzung.status = "ausgezahlt"
unterstuetzung.ausgezahlt_am = form.cleaned_data["ausgezahlt_am"]
unterstuetzung.ausgezahlt_von = request.user
# Add optional note to description
bemerkung = form.cleaned_data.get("bemerkung")
if bemerkung:
if unterstuetzung.beschreibung:
unterstuetzung.beschreibung += f" | Zahlung: {bemerkung}"
else:
unterstuetzung.beschreibung = f"Zahlung: {bemerkung}"
unterstuetzung.save()
messages.success(request, f"Unterstützung wurde als bezahlt markiert.")
return redirect("stiftung:unterstuetzung_detail", pk=pk)
else:
form = UnterstuetzungMarkAsPaidForm()
context = {
"form": form,
"unterstuetzung": unterstuetzung,
"title": f"Zahlung markieren - {unterstuetzung.destinataer.get_full_name()}",
}
return render(request, "stiftung/unterstuetzung_mark_paid.html", context)
@login_required
def wiederkehrende_unterstuetzungen(request):
"""List all recurring support payment templates"""
from django.db.models import Count
# Check for cleanup request
if request.GET.get("cleanup") == "1":
# Find templates with no associated payments
verwaiste_templates = UnterstuetzungWiederkehrend.objects.annotate(
zahlung_count=Count("destinataerunterstuetzung")
).filter(zahlung_count=0)
if verwaiste_templates.exists():
anzahl_geloescht = verwaiste_templates.count()
template_namen = list(
verwaiste_templates.values_list("destinataer__nachname", flat=True)
)
verwaiste_templates.delete()
messages.success(
request,
f'{anzahl_geloescht} verwaiste Zahlungsvorlagen bereinigt: {", ".join(template_namen[:5])}{"..." if len(template_namen) > 5 else ""}',
)
else:
messages.info(request, "Keine verwaisten Zahlungsvorlagen gefunden.")
return redirect("stiftung:wiederkehrende_unterstuetzungen")
# Get all templates with payment counts
templates = (
UnterstuetzungWiederkehrend.objects.select_related("destinataer", "konto")
.annotate(aktive_zahlungen=Count("destinataerunterstuetzung"))
.all()
)
context = {
"templates": templates,
"title": "Wiederkehrende Unterstützungen",
}
return render(request, "stiftung/wiederkehrende_unterstuetzungen.html", context)
@login_required
def quarterly_confirmation_update(request, pk):
"""Update quarterly confirmation for destinataer"""
nachweis = get_object_or_404(VierteljahresNachweis, pk=pk)
if request.method == "POST":
form = VierteljahresNachweisForm(request.POST, request.FILES, instance=nachweis)
if form.is_valid():
quarterly_proof = form.save(commit=False)
# Calculate current status before saving
old_status = nachweis.status
# Auto-update status based on completion
if quarterly_proof.is_complete():
if quarterly_proof.status in ['offen', 'teilweise']:
quarterly_proof.status = 'eingereicht'
quarterly_proof.eingereicht_am = timezone.now()
else:
# If not complete, set to teilweise if some fields are filled
has_partial_data = (
quarterly_proof.einkommenssituation_bestaetigt or
quarterly_proof.vermogenssituation_bestaetigt or
quarterly_proof.studiennachweis_eingereicht
)
if has_partial_data and quarterly_proof.status == 'offen':
quarterly_proof.status = 'teilweise'
quarterly_proof.save()
# Try to create automatic support payment if complete
if quarterly_proof.is_complete() and quarterly_proof.status == 'eingereicht':
support_payment = create_quarterly_support_payment(quarterly_proof)
if support_payment:
messages.success(
request,
f"Automatische Unterstützung über {support_payment.betrag}€ für {support_payment.destinataer.get_full_name()} wurde erstellt (fällig am {support_payment.faellig_am.strftime('%d.%m.%Y')})."
)
else:
# Log why payment wasn't created
reasons = []
if not quarterly_proof.destinataer.vierteljaehrlicher_betrag:
reasons.append("kein vierteljährlicher Betrag hinterlegt")
if not quarterly_proof.destinataer.iban:
reasons.append("keine IBAN hinterlegt")
if not quarterly_proof.destinataer.standard_konto and not StiftungsKonto.objects.exists():
reasons.append("kein Auszahlungskonto verfügbar")
if reasons:
messages.warning(
request,
f"Automatische Unterstützung konnte nicht erstellt werden: {', '.join(reasons)}"
)
# Debug message to see what happened
status_changed = old_status != quarterly_proof.status
status_msg = f" (Status: {old_status}{quarterly_proof.status})" if status_changed else f" (Status: {quarterly_proof.status})"
messages.success(
request,
f"Vierteljahresnachweis für {nachweis.destinataer.get_full_name()} "
f"({nachweis.jahr} Q{nachweis.quartal}) wurde erfolgreich aktualisiert{status_msg}."
)
return redirect("stiftung:destinataer_detail", pk=nachweis.destinataer.pk)
else:
# Add form errors to messages
for field, errors in form.errors.items():
for error in errors:
messages.error(request, f"Fehler in {field}: {error}")
# If GET request or form errors, redirect back to destinataer detail
return redirect("stiftung:destinataer_detail", pk=nachweis.destinataer.pk)
def create_quarterly_support_payment(nachweis):
"""
Get or create a single support payment for this quarterly confirmation
Ensures only one payment exists per destinataer per quarter
"""
from datetime import date
destinataer = nachweis.destinataer
# Check if all requirements are met
if not nachweis.is_complete():
return None
# Check if destinataer has required payment info
if not destinataer.vierteljaehrlicher_betrag or destinataer.vierteljaehrlicher_betrag <= 0:
return None
if not destinataer.iban:
return None
# Search for existing payment using payment due date from quarterly confirmation
# This is more accurate than using quarter date range, especially for Q1 (Dec 15 prev year)
payment_due_date = nachweis.zahlung_faelligkeitsdatum
if not payment_due_date:
# Fallback: calculate if not set
if nachweis.quartal == 1:
payment_due_date = date(nachweis.jahr - 1, 12, 15)
elif nachweis.quartal == 2:
payment_due_date = date(nachweis.jahr, 3, 15)
elif nachweis.quartal == 3:
payment_due_date = date(nachweis.jahr, 6, 15)
else: # Q4
payment_due_date = date(nachweis.jahr, 9, 15)
# Search for existing payment - match by payment due date and description
# Use a date range around the due date (±30 days) to catch any variations
from datetime import timedelta
date_start = payment_due_date - timedelta(days=30)
date_end = payment_due_date + timedelta(days=30)
existing_payment = DestinataerUnterstuetzung.objects.filter(
destinataer=destinataer,
faellig_am__gte=date_start,
faellig_am__lte=date_end
).filter(
Q(beschreibung__contains=f"Q{nachweis.quartal}/{nachweis.jahr}") |
Q(beschreibung__contains=f"Vierteljährliche Unterstützung Q{nachweis.quartal}/{nachweis.jahr}")
).first()
if existing_payment:
# Update existing payment to ensure it matches current requirements
existing_payment.betrag = destinataer.vierteljaehrlicher_betrag
existing_payment.empfaenger_iban = destinataer.iban
existing_payment.empfaenger_name = destinataer.get_full_name()
existing_payment.verwendungszweck = f"Vierteljährliche Unterstützung Q{nachweis.quartal}/{nachweis.jahr} - {destinataer.get_full_name()}"
existing_payment.beschreibung = f"Vierteljährliche Unterstützung Q{nachweis.quartal}/{nachweis.jahr} (automatisch erstellt)"
existing_payment.save()
return existing_payment
# Get default payment account
default_konto = destinataer.standard_konto
if not default_konto:
# Try to get any StiftungsKonto
default_konto = StiftungsKonto.objects.first()
if not default_konto:
return None
# Use payment due date from quarterly confirmation (already calculated by model)
# This ensures consistency with zahlung_faelligkeitsdatum
payment_due_date = nachweis.zahlung_faelligkeitsdatum
if not payment_due_date:
# Fallback: calculate if not set (should not happen, but safety check)
if nachweis.quartal == 1: # Q1 payment due December 15 of previous year
payment_due_date = date(nachweis.jahr - 1, 12, 15)
elif nachweis.quartal == 2: # Q2 payment due March 15
payment_due_date = date(nachweis.jahr, 3, 15)
elif nachweis.quartal == 3: # Q3 payment due June 15
payment_due_date = date(nachweis.jahr, 6, 15)
else: # Q4 payment due September 15
payment_due_date = date(nachweis.jahr, 9, 15)
# Create the support payment
payment = DestinataerUnterstuetzung.objects.create(
destinataer=destinataer,
konto=default_konto,
betrag=destinataer.vierteljaehrlicher_betrag,
faellig_am=payment_due_date,
status='geplant',
beschreibung=f"Vierteljährliche Unterstützung Q{nachweis.quartal}/{nachweis.jahr} (automatisch erstellt)",
empfaenger_iban=destinataer.iban,
empfaenger_name=destinataer.get_full_name(),
verwendungszweck=f"Vierteljährliche Unterstützung Q{nachweis.quartal}/{nachweis.jahr} - {destinataer.get_full_name()}",
erstellt_am=timezone.now(),
aktualisiert_am=timezone.now()
)
return payment
@login_required
def quarterly_confirmation_create(request, destinataer_id):
"""Create a new quarterly confirmation for a destinataer"""
import logging
logger = logging.getLogger(__name__)
logger.info(f"quarterly_confirmation_create called: method={request.method}, destinataer_id={destinataer_id}")
destinataer = get_object_or_404(Destinataer, pk=destinataer_id)
if request.method == "POST":
logger.info(f"POST data: {request.POST}")
jahr = request.POST.get('jahr')
quartal = request.POST.get('quartal')
if jahr and quartal:
try:
jahr = int(jahr)
quartal = int(quartal)
# Check if this quarter already exists
existing = VierteljahresNachweis.objects.filter(
destinataer=destinataer,
jahr=jahr,
quartal=quartal
).exists()
if existing:
messages.warning(
request,
f"Quartal {jahr} Q{quartal} existiert bereits für {destinataer.get_full_name()}."
)
else:
# Create new quarterly confirmation
try:
nachweis = VierteljahresNachweis.objects.create(
destinataer=destinataer,
jahr=jahr,
quartal=quartal,
studiennachweis_erforderlich=True, # Always required now
)
# Deadlines are automatically set by the model's save() method
# studiennachweis_faelligkeitsdatum: semester-based (Q1/Q2→Mar 15, Q3/Q4→Sep 15)
# zahlung_faelligkeitsdatum: quarterly advance (Q1→Dec 15 prev year, Q2→Mar 15, Q3→Jun 15, Q4→Sep 15)
# Refresh from database to ensure deadlines are set
nachweis.refresh_from_db()
studiennachweis_str = nachweis.studiennachweis_faelligkeitsdatum.strftime('%d.%m.%Y') if nachweis.studiennachweis_faelligkeitsdatum else "Nicht gesetzt"
zahlung_str = nachweis.zahlung_faelligkeitsdatum.strftime('%d.%m.%Y') if nachweis.zahlung_faelligkeitsdatum else "Nicht gesetzt"
messages.success(
request,
f"Quartal {jahr} Q{quartal} wurde erfolgreich für {destinataer.get_full_name()} erstellt. "
f"Studiennachweis fällig: {studiennachweis_str}, "
f"Zahlung fällig: {zahlung_str}."
)
except Exception as e:
from django.db import IntegrityError
if isinstance(e, IntegrityError):
messages.error(
request,
f"Quartal {jahr} Q{quartal} existiert bereits für {destinataer.get_full_name()}."
)
else:
messages.error(
request,
f"Fehler beim Erstellen des Quartals: {str(e)}"
)
except (ValueError, TypeError):
messages.error(request, "Ungültige Jahr- oder Quartalswerte.")
else:
messages.error(request, "Jahr und Quartal müssen angegeben werden.")
return redirect("stiftung:destinataer_detail", pk=destinataer.pk)
@login_required
def quarterly_confirmation_edit(request, pk):
"""Standalone edit view for quarterly confirmation"""
from stiftung.models import DokumentDatei
nachweis = get_object_or_404(VierteljahresNachweis, pk=pk)
if request.method == "POST":
# DMS-Dokument entfernen (Verknuepfung loesen)
entferne_dok_id = request.POST.get("entferne_dms_dokument")
if entferne_dok_id:
nachweis.nachweis_dokumente.remove(entferne_dok_id)
messages.success(request, "DMS-Dokument-Verknuepfung entfernt.")
return redirect("stiftung:quarterly_confirmation_edit", pk=pk)
form = VierteljahresNachweisForm(request.POST, request.FILES, instance=nachweis)
if form.is_valid():
quarterly_proof = form.save(commit=False)
# Kategorie-spezifische DMS-Dokumente zuweisen
for field_name, dms_field in [
("studiennachweis_dms_id", "studiennachweis_dms_dokument"),
("einkommenssituation_dms_id", "einkommenssituation_dms_dokument"),
("vermogenssituation_dms_id", "vermogenssituation_dms_dokument"),
]:
dms_id = request.POST.get(field_name)
if dms_id:
try:
dok = DokumentDatei.objects.get(pk=dms_id)
setattr(quarterly_proof, dms_field, dok)
except DokumentDatei.DoesNotExist:
pass
elif dms_id == "":
# Leere Auswahl = Verknuepfung entfernen
setattr(quarterly_proof, dms_field, None)
# Generisches DMS-Dokument hinzufuegen (Abwaertskompatibilitaet)
dms_dok_id = request.POST.get("dms_dokument_hinzufuegen")
if dms_dok_id:
try:
dok = DokumentDatei.objects.get(pk=dms_dok_id)
# Save first so M2M can be set
quarterly_proof.save()
quarterly_proof.nachweis_dokumente.add(dok)
except DokumentDatei.DoesNotExist:
pass
# Calculate current status before saving
old_status = nachweis.status
# Auto-update status based on completion
if quarterly_proof.is_complete():
if quarterly_proof.status in ['offen', 'teilweise']:
quarterly_proof.status = 'eingereicht'
quarterly_proof.eingereicht_am = timezone.now()
else:
# If not complete, set to teilweise if some fields are filled
has_partial_data = (
quarterly_proof.einkommenssituation_bestaetigt or
quarterly_proof.vermogenssituation_bestaetigt or
quarterly_proof.studiennachweis_eingereicht
)
if has_partial_data and quarterly_proof.status == 'offen':
quarterly_proof.status = 'teilweise'
quarterly_proof.save()
# Try to create automatic support payment if complete
if quarterly_proof.is_complete() and quarterly_proof.status == 'eingereicht':
support_payment = create_quarterly_support_payment(quarterly_proof)
if support_payment:
messages.success(
request,
f"Automatische Unterstützung über {support_payment.betrag}€ für {support_payment.destinataer.get_full_name()} wurde erstellt (fällig am {support_payment.faellig_am.strftime('%d.%m.%Y')})."
)
else:
# Log why payment wasn't created
reasons = []
if not quarterly_proof.destinataer.vierteljaehrlicher_betrag:
reasons.append("kein vierteljährlicher Betrag hinterlegt")
if not quarterly_proof.destinataer.iban:
reasons.append("keine IBAN hinterlegt")
if not quarterly_proof.destinataer.standard_konto and not StiftungsKonto.objects.exists():
reasons.append("kein Auszahlungskonto verfügbar")
if reasons:
messages.warning(
request,
f"Automatische Unterstützung konnte nicht erstellt werden: {', '.join(reasons)}"
)
# Debug message to see what happened
status_changed = old_status != quarterly_proof.status
status_msg = f" (Status: {old_status}{quarterly_proof.status})" if status_changed else f" (Status: {quarterly_proof.status})"
messages.success(
request,
f"Vierteljahresnachweis für {nachweis.destinataer.get_full_name()} "
f"({nachweis.jahr} Q{nachweis.quartal}) wurde erfolgreich aktualisiert{status_msg}."
)
return redirect("stiftung:destinataer_detail", pk=nachweis.destinataer.pk)
else:
# Add form errors to messages
for field, errors in form.errors.items():
for error in errors:
messages.error(request, f"Fehler in {field}: {error}")
else:
form = VierteljahresNachweisForm(instance=nachweis)
# Alle DMS-Dokumente des Destinataers (fuer Kategorie-Auswahl in den Sektionen)
alle_dms_dokumente = (
DokumentDatei.objects.filter(destinataer=nachweis.destinataer)
.exclude(kontext="email")
.order_by("kontext", "titel")
)
# Generisch verknuepfte Dokumente (M2M) und noch nicht verknuepfte (fuer Bottom-Sektion)
verknuepfte_nachweis_dokumente = nachweis.nachweis_dokumente.all().order_by("kontext", "titel")
verknuepfte_ids = set(verknuepfte_nachweis_dokumente.values_list("pk", flat=True))
verfuegbare_dms_dokumente = alle_dms_dokumente.exclude(pk__in=verknuepfte_ids)
context = {
'form': form,
'nachweis': nachweis,
'destinataer': nachweis.destinataer,
'title': f'Vierteljahresnachweis bearbeiten - {nachweis.destinataer.get_full_name()} {nachweis.jahr} Q{nachweis.quartal}',
'alle_dms_dokumente': alle_dms_dokumente,
'verknuepfte_nachweis_dokumente': verknuepfte_nachweis_dokumente,
'verfuegbare_dms_dokumente': verfuegbare_dms_dokumente,
}
return render(request, 'stiftung/quarterly_confirmation_edit.html', context)
@login_required
def quarterly_confirmation_approve(request, pk):
"""Approve quarterly confirmation (staff only)"""
if not request.user.is_staff:
messages.error(request, "Sie haben keine Berechtigung für diese Aktion.")
return redirect("stiftung:destinataer_list")
nachweis = get_object_or_404(VierteljahresNachweis, pk=pk)
if request.method == "POST":
if nachweis.status in ['eingereicht', 'geprueft']:
# Check if we need to create or update support payment
related_payment = nachweis.get_related_support_payment()
if nachweis.status == 'eingereicht' or (nachweis.status == 'geprueft' and not related_payment):
# Approve the quarterly confirmation
nachweis.status = 'geprueft'
nachweis.geprueft_am = timezone.now()
nachweis.geprueft_von = request.user
nachweis.save()
# Auto-approve next quarter for semester-based tracking (Q1→Q2, Q3→Q4)
auto_approved_next = nachweis.auto_approve_next_quarter()
if auto_approved_next:
messages.info(
request,
f"Q{auto_approved_next.quartal} wurde automatisch auf Basis der Q{nachweis.quartal}-Nachweise freigegeben."
)
# Handle support payment - create if missing, update if exists
# Check if payment already exists before calling create_quarterly_support_payment()
payment_existed_before = related_payment is not None
# Use create_quarterly_support_payment() which handles both cases (find existing or create new)
related_payment = create_quarterly_support_payment(nachweis)
if related_payment:
# Update status to 'in_bearbeitung' for both new and existing payments
old_status = related_payment.status
related_payment.status = 'in_bearbeitung'
related_payment.aktualisiert_am = timezone.now()
related_payment.save()
if payment_existed_before:
messages.success(
request,
f"Vierteljahresnachweis freigegeben und bestehende Unterstützung für {nachweis.destinataer.get_full_name()} "
f"({nachweis.jahr} Q{nachweis.quartal}) wurde von '{old_status}' auf 'in Bearbeitung' aktualisiert."
)
else:
messages.success(
request,
f"Vierteljahresnachweis freigegeben und neue Unterstützung über {related_payment.betrag}€ für {nachweis.destinataer.get_full_name()} "
f"({nachweis.jahr} Q{nachweis.quartal}) wurde erstellt."
)
else:
messages.warning(
request,
f"Vierteljahresnachweis freigegeben, aber Unterstützung konnte nicht erstellt werden. "
f"Bitte prüfen Sie die Einstellungen für {nachweis.destinataer.get_full_name()}."
)
else:
messages.error(
request,
"Nur eingereichte oder bereits genehmigte Nachweise können verarbeitet werden."
)
return redirect("stiftung:destinataer_detail", pk=nachweis.destinataer.pk)
@login_required
def quarterly_confirmation_reset(request, pk):
"""Reset quarterly confirmation status (staff only)"""
if not request.user.is_staff:
messages.error(request, "Sie haben keine Berechtigung für diese Aktion.")
return redirect("stiftung:destinataer_list")
nachweis = get_object_or_404(VierteljahresNachweis, pk=pk)
if request.method == "POST":
if nachweis.status in ['geprueft', 'eingereicht']:
# Reset the quarterly confirmation status
nachweis.status = 'eingereicht' if nachweis.is_complete() else 'teilweise'
nachweis.geprueft_am = None
nachweis.geprueft_von = None
nachweis.aktualisiert_am = timezone.now()
nachweis.save()
# Reset related support payment status if it exists
related_payment = nachweis.get_related_support_payment()
if related_payment and related_payment.status == 'in_bearbeitung':
related_payment.status = 'geplant'
related_payment.aktualisiert_am = timezone.now()
related_payment.save()
messages.success(
request,
f"Vierteljahresnachweis und zugehörige Unterstützung für {nachweis.destinataer.get_full_name()} "
f"({nachweis.jahr} Q{nachweis.quartal}) wurden zurückgesetzt."
)
else:
messages.success(
request,
f"Vierteljahresnachweis für {nachweis.destinataer.get_full_name()} "
f"({nachweis.jahr} Q{nachweis.quartal}) wurde zurückgesetzt."
)
else:
messages.error(
request,
"Nur genehmigte oder eingereichte Nachweise können zurückgesetzt werden."
)
return redirect("stiftung:destinataer_detail", pk=nachweis.destinataer.pk)
# ============================================================
# Phase 2: Destinatär-Timeline, Nachweis-Board, Zahlungs-Pipeline
# ============================================================
@login_required
def destinataer_timeline(request, pk):
"""2a: Chronologische Timeline eines Destinatärs alle Events in einer Ansicht."""
destinataer = get_object_or_404(Destinataer, pk=pk)
typ_filter = request.GET.get("typ", "")
events = []
if not typ_filter or typ_filter == "zahlung":
for u in destinataer.unterstuetzungen.select_related("konto", "ausgezahlt_von", "freigegeben_von").order_by("-faellig_am"):
events.append({
"datum": u.faellig_am,
"typ": "zahlung",
"icon": "fa-money-bill-wave",
"farbe": "success" if u.status == "ausgezahlt" else ("danger" if u.is_overdue() else "primary"),
"titel": f"Zahlung €{u.betrag}",
"beschreibung": u.beschreibung or u.get_status_display(),
"status": u.get_status_display(),
"objekt": u,
})
if not typ_filter or typ_filter == "nachweis":
for n in destinataer.quartalseinreichungen.order_by("-jahr", "-quartal"):
datum = n.zahlung_faelligkeitsdatum or n.faelligkeitsdatum
if datum:
events.append({
"datum": datum,
"typ": "nachweis",
"icon": "fa-file-alt",
"farbe": "success" if n.status in ("geprueft", "auto_geprueft") else ("danger" if n.is_overdue() else "warning"),
"titel": f"Nachweis {n.jahr} Q{n.quartal}",
"beschreibung": n.get_status_display(),
"status": n.get_status_display(),
"objekt": n,
})
if not typ_filter or typ_filter == "email":
for e in destinataer.email_eingaenge.order_by("-eingangsdatum"):
events.append({
"datum": e.eingangsdatum.date() if hasattr(e.eingangsdatum, "date") else e.eingangsdatum,
"typ": "email",
"icon": "fa-envelope",
"farbe": "info",
"titel": e.betreff or "(kein Betreff)",
"beschreibung": e.absender_email,
"status": e.get_status_display(),
"objekt": e,
})
if not typ_filter or typ_filter == "notiz":
for n in destinataer.notizen_eintraege.order_by("-erstellt_am"):
events.append({
"datum": n.erstellt_am.date() if hasattr(n.erstellt_am, "date") else n.erstellt_am,
"typ": "notiz",
"icon": "fa-sticky-note",
"farbe": "secondary",
"titel": n.titel or "Notiz",
"beschreibung": (n.text[:100] + "") if n.text and len(n.text) > 100 else n.text,
"status": f"von {n.erstellt_von.get_full_name() or n.erstellt_von.username}" if n.erstellt_von else "",
"objekt": n,
})
events.sort(key=lambda e: e["datum"] if e["datum"] else date.min, reverse=True)
context = {
"destinataer": destinataer,
"events": events,
"typ_filter": typ_filter,
}
return render(request, "stiftung/destinataer_timeline.html", context)
@login_required
def nachweis_board(request):
"""2b: Nachweis-Board Quartals-Übersicht aller Destinatäre."""
heute = date.today()
jahr_filter = int(request.GET.get("jahr", heute.year))
status_filter = request.GET.get("status", "")
destinataere = Destinataer.objects.filter(aktiv=True).order_by("nachname", "vorname")
# Auto-create missing VierteljahresNachweis records for the filtered year
for d in destinataere:
for q in range(1, 5):
VierteljahresNachweis.objects.get_or_create(
destinataer=d, jahr=jahr_filter, quartal=q,
defaults={"status": "offen"},
)
board = []
for d in destinataere:
quartale = {}
for q in range(1, 5):
nachweis = VierteljahresNachweis.objects.filter(
destinataer=d, jahr=jahr_filter, quartal=q
).first()
quartale[q] = nachweis
board.append({"destinataer": d, "quartale": quartale})
if status_filter:
board = [
row for row in board
if any(
(q is not None and q.status == status_filter)
for q in row["quartale"].values()
)
]
overdue_count = VierteljahresNachweis.objects.filter(
jahr=jahr_filter,
status__in=["offen", "teilweise"],
studiennachweis_faelligkeitsdatum__lt=heute,
).count()
verfuegbare_jahre = list(range(heute.year - 2, heute.year + 2))
context = {
"board": board,
"jahr_filter": jahr_filter,
"status_filter": status_filter,
"overdue_count": overdue_count,
"verfuegbare_jahre": verfuegbare_jahre,
"status_choices": VierteljahresNachweis.STATUS_CHOICES,
"heute": heute,
}
return render(request, "stiftung/nachweis_board.html", context)
@login_required
def batch_erinnerung_senden(request):
"""2b: Batch-Erinnerungen an säumige Destinatäre Audit-Log-Einträge."""
if request.method != "POST":
return redirect("stiftung:nachweis_board")
heute = date.today()
jahr = int(request.POST.get("jahr", heute.year))
overdue = VierteljahresNachweis.objects.filter(
jahr=jahr,
status__in=["offen", "teilweise"],
studiennachweis_faelligkeitsdatum__lt=heute,
destinataer__aktiv=True,
).select_related("destinataer")
count = 0
for nachweis in overdue:
try:
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:
pass
messages.success(
request,
f"{count} Erinnerung(en) im Audit-Log vermerkt.",
)
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 4-Stufen-Kanban-Ansicht."""
heute = date.today()
destinataer_id = request.GET.get("destinataer", "")
konto_id = request.GET.get("konto", "")
qs = DestinataerUnterstuetzung.objects.select_related(
"destinataer", "konto", "ausgezahlt_von", "freigegeben_von", "erstellt_von"
).exclude(status="storniert")
if destinataer_id:
qs = qs.filter(destinataer_id=destinataer_id)
if konto_id:
qs = qs.filter(konto_id=konto_id)
pipeline = {
"offen": qs.filter(status__in=["geplant", "faellig"]).order_by("faellig_am"),
"nachweis_eingereicht": qs.filter(status="nachweis_eingereicht").order_by("faellig_am"),
"freigegeben": qs.filter(status__in=["freigegeben", "in_bearbeitung"]).order_by("faellig_am"),
"ueberwiesen": qs.filter(status__in=["ausgezahlt", "abgeschlossen"]).order_by("-ausgezahlt_am"),
}
stage_meta = {
"offen": ("Offen", "secondary", "fa-clock"),
"nachweis_eingereicht": ("Nachweis eingereicht", "info", "fa-file-alt"),
"freigegeben": ("Freigegeben (4-Augen)", "warning", "fa-shield-alt"),
"ueberwiesen": ("Überwiesen", "success", "fa-university"),
}
pipeline_stages = [
{
"key": key,
"label": stage_meta[key][0],
"farbe": stage_meta[key][1],
"icon": stage_meta[key][2],
"zahlungen": list(pipeline[key]),
"gesamt": pipeline[key].aggregate(s=Sum("betrag"))["s"] or Decimal("0"),
}
for key in ["offen", "nachweis_eingereicht", "freigegeben", "ueberwiesen"]
]
destinataere = Destinataer.objects.filter(aktiv=True).order_by("nachname", "vorname")
konten = StiftungsKonto.objects.filter(aktiv=True).order_by("kontoname")
context = {
"pipeline_stages": pipeline_stages,
"destinataere": destinataere,
"konten": konten,
"destinataer_filter": destinataer_id,
"konto_filter": konto_id,
"heute": heute,
}
return render(request, "stiftung/zahlungs_pipeline.html", context)
@login_required
def unterstuetzung_freigeben(request, pk):
"""2c: 4-Augen-Prinzip Freigabe durch zweiten Nutzer."""
unterstuetzung = get_object_or_404(DestinataerUnterstuetzung, pk=pk)
if request.method == "POST":
if not unterstuetzung.can_be_freigegeben(request.user):
messages.error(
request,
"Freigabe nicht möglich: Status nicht korrekt oder Sie sind der Ersteller (Vier-Augen-Prinzip).",
)
else:
unterstuetzung.status = "freigegeben"
unterstuetzung.freigegeben_von = request.user
unterstuetzung.freigegeben_am = date.today()
unterstuetzung.save()
messages.success(
request,
f"Zahlung €{unterstuetzung.betrag} für {unterstuetzung.destinataer.get_full_name()} freigegeben.",
)
next_url = request.POST.get("next") or request.META.get("HTTP_REFERER") or reverse("stiftung:zahlungs_pipeline")
return redirect(next_url)
@login_required
def unterstuetzung_nachweis_eingereicht(request, pk):
"""2c: Status auf 'Nachweis eingereicht' setzen."""
unterstuetzung = get_object_or_404(DestinataerUnterstuetzung, pk=pk)
if request.method == "POST":
if unterstuetzung.status in ["geplant", "faellig"]:
unterstuetzung.status = "nachweis_eingereicht"
unterstuetzung.save()
messages.success(request, "Status auf 'Nachweis eingereicht' gesetzt.")
else:
messages.error(request, "Status-Übergang nicht möglich.")
next_url = request.POST.get("next") or reverse("stiftung:zahlungs_pipeline")
return redirect(next_url)
@login_required
def unterstuetzung_abschliessen(request, pk):
"""2c: Abschließen einer überwiesenen Zahlung."""
unterstuetzung = get_object_or_404(DestinataerUnterstuetzung, pk=pk)
if request.method == "POST":
if unterstuetzung.status == "ausgezahlt":
unterstuetzung.status = "abgeschlossen"
unterstuetzung.save()
messages.success(request, "Zahlung als abgeschlossen markiert.")
else:
messages.error(request, "Nur überwiesene Zahlungen können abgeschlossen werden.")
next_url = request.POST.get("next") or reverse("stiftung:zahlungs_pipeline")
return redirect(next_url)
@login_required
def sepa_xml_export(request):
"""Phase 4: SEPA pain.001 XML-Export mit schwifty IBAN/BIC-Validierung."""
from xml.etree.ElementTree import Element, SubElement, tostring
import xml.dom.minidom
try:
from schwifty import IBAN as SchwiftyIBAN, BIC as SchwiftyBIC
schwifty_available = True
except ImportError:
schwifty_available = False
zahlungen = DestinataerUnterstuetzung.objects.filter(
status="freigegeben"
).select_related("destinataer", "konto")
if not zahlungen.exists():
messages.warning(request, "Keine freigegebenen Zahlungen für SEPA-Export vorhanden.")
return redirect("stiftung:zahlungs_pipeline")
# IBAN/BIC-Validierung mit schwifty
validierungsfehler = []
if schwifty_available:
for zahlung in zahlungen:
iban_raw = (zahlung.empfaenger_iban or zahlung.destinataer.iban or "").replace(" ", "")
if not iban_raw:
validierungsfehler.append(
f"{zahlung.destinataer.get_full_name()}: Keine IBAN hinterlegt"
)
continue
try:
SchwiftyIBAN(iban_raw)
except Exception:
validierungsfehler.append(
f"{zahlung.destinataer.get_full_name()}: Ungültige IBAN '{iban_raw}'"
)
if validierungsfehler:
for fehler in validierungsfehler:
messages.error(request, f"SEPA-Validierungsfehler: {fehler}")
return redirect("stiftung:zahlungs_pipeline")
heute = date.today()
msg_id = f"STIFTUNG-{heute.strftime('%Y%m%d%H%M%S')}"
nb_of_txs = zahlungen.count()
ctrl_sum = f"{sum(z.betrag for z in zahlungen):.2f}"
root = Element("Document", {
"xmlns": "urn:iso:std:iso:20022:tech:xsd:pain.001.001.03",
"xmlns:xsi": "http://www.w3.org/2001/XMLSchema-instance",
})
cstmr_cdt = SubElement(root, "CstmrCdtTrfInitn")
grp_hdr = SubElement(cstmr_cdt, "GrpHdr")
SubElement(grp_hdr, "MsgId").text = msg_id
SubElement(grp_hdr, "CreDtTm").text = timezone.now().strftime("%Y-%m-%dT%H:%M:%S")
SubElement(grp_hdr, "NbOfTxs").text = str(nb_of_txs)
SubElement(grp_hdr, "CtrlSum").text = ctrl_sum
initg_pty = SubElement(grp_hdr, "InitgPty")
SubElement(initg_pty, "Nm").text = "van Hees-Theyssen-Vogel'sche Stiftung"
pmt_inf = SubElement(cstmr_cdt, "PmtInf")
SubElement(pmt_inf, "PmtInfId").text = f"PMT-{msg_id}"
SubElement(pmt_inf, "PmtMtd").text = "TRF"
SubElement(pmt_inf, "NbOfTxs").text = str(nb_of_txs)
SubElement(pmt_inf, "CtrlSum").text = ctrl_sum
pmt_tp_inf = SubElement(pmt_inf, "PmtTpInf")
svc_lvl = SubElement(pmt_tp_inf, "SvcLvl")
SubElement(svc_lvl, "Cd").text = "SEPA"
SubElement(pmt_inf, "ReqdExctnDt").text = heute.strftime("%Y-%m-%d")
dbtr = SubElement(pmt_inf, "Dbtr")
SubElement(dbtr, "Nm").text = "van Hees-Theyssen-Vogel'sche Stiftung"
# Schuldner-IBAN aus aktivem Stiftungskonto
if zahlungen.first() and zahlungen.first().konto and zahlungen.first().konto.iban:
dbtr_acct = SubElement(pmt_inf, "DbtrAcct")
dbtr_acct_id = SubElement(dbtr_acct, "Id")
SubElement(dbtr_acct_id, "IBAN").text = zahlungen.first().konto.iban.replace(" ", "")
dbtr_agt = SubElement(pmt_inf, "DbtrAgt")
fin_instn_id = SubElement(dbtr_agt, "FinInstnId")
bic_val = zahlungen.first().konto.bic.strip()
if schwifty_available and bic_val:
try:
bic_val = str(SchwiftyBIC(bic_val))
except Exception:
pass
SubElement(fin_instn_id, "BIC").text = bic_val or "NOTPROVIDED"
for zahlung in zahlungen:
cdt_trf = SubElement(pmt_inf, "CdtTrfTxInf")
pmt_id_el = SubElement(cdt_trf, "PmtId")
SubElement(pmt_id_el, "EndToEndId").text = str(zahlung.id)[:35]
amt = SubElement(cdt_trf, "Amt")
instd_amt = SubElement(amt, "InstdAmt", {"Ccy": "EUR"})
instd_amt.text = f"{zahlung.betrag:.2f}"
cdtr = SubElement(cdt_trf, "Cdtr")
SubElement(cdtr, "Nm").text = (zahlung.empfaenger_name or zahlung.destinataer.get_full_name())[:70]
cdtr_acct = SubElement(cdt_trf, "CdtrAcct")
cdtr_id = SubElement(cdtr_acct, "Id")
iban_clean = (zahlung.empfaenger_iban or zahlung.destinataer.iban or "").replace(" ", "")
SubElement(cdtr_id, "IBAN").text = iban_clean
rmt_inf = SubElement(cdt_trf, "RmtInf")
SubElement(rmt_inf, "Ustrd").text = (zahlung.verwendungszweck or zahlung.beschreibung or "Stiftungsunterstützung")[:140]
xml_str = xml.dom.minidom.parseString(tostring(root, encoding="unicode")).toprettyxml(indent=" ")
response = HttpResponse(xml_str, content_type="application/xml; charset=utf-8")
response["Content-Disposition"] = f'attachment; filename="sepa_export_{heute.strftime("%Y%m%d")}.xml"'
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