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>
2158 lines
85 KiB
Python
2158 lines
85 KiB
Python
# 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
|
||
|