# 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