# views/destinataere.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.models import (AppConfiguration, AuditLog, BackupJob, BankTransaction, BriefVorlage, CSVImport, Destinataer, DestinataerEmailEingang, DestinataerNotiz, DestinataerUnterstuetzung, DokumentDatei, 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 person_list(request): search_query = request.GET.get("search", "") familienzweig_filter = request.GET.get("familienzweig", "") aktiv_filter = request.GET.get("aktiv", "") persons = Person.objects.all() if search_query: persons = persons.filter( Q(nachname__icontains=search_query) | Q(vorname__icontains=search_query) | Q(email__icontains=search_query) | Q(familienzweig__icontains=search_query) ) if familienzweig_filter: persons = persons.filter(familienzweig=familienzweig_filter) if aktiv_filter == "true": persons = persons.filter(aktiv=True) elif aktiv_filter == "false": persons = persons.filter(aktiv=False) # Annotate with total funding persons = persons.annotate(total_foerderungen=Sum("foerderung__betrag")) paginator = Paginator(persons, 20) page_number = request.GET.get("page") page_obj = paginator.get_page(page_number) context = { "page_obj": page_obj, "search_query": search_query, "familienzweig_filter": familienzweig_filter, "aktiv_filter": aktiv_filter, "familienzweig_choices": Person.FAMILIENZWIG_CHOICES, } return render(request, "stiftung/person_list.html", context) @login_required def person_detail(request, pk): person = get_object_or_404(Person, pk=pk) foerderungen = person.foerderung_set.all().order_by("-jahr", "-betrag") # Get new LandVerpachtungen for this person's Paechter instances verpachtungen = LandVerpachtung.objects.filter(paechter__person=person).order_by( "-pachtbeginn" ) context = { "person": person, "foerderungen": foerderungen, "verpachtungen": verpachtungen, } return render(request, "stiftung/person_detail.html", context) @login_required def person_create(request): if request.method == "POST": form = PersonForm(request.POST) if form.is_valid(): person = form.save() messages.success( request, f'Person "{person.get_full_name()}" wurde erfolgreich erstellt.', ) return redirect("stiftung:person_detail", pk=person.pk) else: form = PersonForm() context = {"form": form, "title": "Neue Person erstellen"} return render(request, "stiftung/person_form.html", context) @login_required def person_update(request, pk): person = get_object_or_404(Person, pk=pk) if request.method == "POST": form = PersonForm(request.POST, instance=person) if form.is_valid(): person = form.save() messages.success( request, f'Person "{person.get_full_name()}" wurde erfolgreich aktualisiert.', ) return redirect("stiftung:person_detail", pk=person.pk) else: form = PersonForm(instance=person) context = { "form": form, "person": person, "title": f"Person bearbeiten: {person.get_full_name()}", } return render(request, "stiftung/person_form.html", context) @login_required def person_delete(request, pk): person = get_object_or_404(Person, pk=pk) if request.method == "POST": person.delete() messages.success( request, f'Person "{person.get_full_name()}" wurde erfolgreich gelöscht.' ) return redirect("stiftung:person_list") context = {"person": person} return render(request, "stiftung/person_confirm_delete.html", context) # Destinatär Views (Förderungsempfänger) @login_required def destinataer_list(request): search_query = request.GET.get("search", "") familienzweig_filter = request.GET.get("familienzweig", "") berufsgruppe_filter = request.GET.get("berufsgruppe", "") aktiv_filter = request.GET.get("aktiv", "true") sort = request.GET.get("sort", "") direction = request.GET.get("dir", "asc") destinataere = Destinataer.objects.all() if search_query: destinataere = destinataere.filter( Q(nachname__icontains=search_query) | Q(vorname__icontains=search_query) | Q(email__icontains=search_query) | Q(institution__icontains=search_query) | Q(familienzweig__icontains=search_query) ) if familienzweig_filter: destinataere = destinataere.filter(familienzweig=familienzweig_filter) if berufsgruppe_filter: destinataere = destinataere.filter(berufsgruppe=berufsgruppe_filter) if aktiv_filter == "true": destinataere = destinataere.filter(aktiv=True) elif aktiv_filter == "false": destinataere = destinataere.filter(aktiv=False) # Annotate with total funding (coalesce nulls to Decimal for stable sorting) destinataere = destinataere.annotate( total_foerderungen=Coalesce( Sum("foerderung__betrag"), Value( Decimal("0.00"), output_field=DecimalField(max_digits=12, decimal_places=2), ), output_field=DecimalField(max_digits=12, decimal_places=2), ) ) # Sorting sort_map = { "vorname": ["vorname"], "nachname": ["nachname"], "email": ["email"], "vierteljaehrlicher_betrag": ["vierteljaehrlicher_betrag"], "letzter_studiennachweis": ["letzter_studiennachweis"], "unterstuetzung_bestaetigt": ["unterstuetzung_bestaetigt"], # Keep old mappings for backward compatibility "name": ["nachname", "vorname"], "familienzweig": ["familienzweig"], "berufsgruppe": ["berufsgruppe"], "institution": ["institution"], "foerderungen": ["total_foerderungen"], "status": ["aktiv"], } if sort in sort_map: fields = sort_map[sort] if direction == "desc": order_fields = [f"-{f}" for f in fields] else: order_fields = fields destinataere = destinataere.order_by(*order_fields) else: # Default sorting by last name (nachname) ascending destinataere = destinataere.order_by("nachname", "vorname") paginator = Paginator(destinataere, 50) # Increased from 20 to 50 entries per page page_number = request.GET.get("page") page_obj = paginator.get_page(page_number) # Set default sort to nachname if no sort is specified effective_sort = sort if sort else "nachname" effective_direction = direction if sort else "asc" context = { "page_obj": page_obj, "search_query": search_query, "familienzweig_filter": familienzweig_filter, "berufsgruppe_filter": berufsgruppe_filter, "aktiv_filter": aktiv_filter, "familienzweig_choices": Destinataer.FAMILIENZWIG_CHOICES, "berufsgruppe_choices": Destinataer.BERUFSGRUPPE_CHOICES, "sort": effective_sort, "dir": effective_direction, } return render(request, "stiftung/destinataer_list.html", context) @login_required def destinataer_detail(request, pk): destinataer = get_object_or_404(Destinataer, pk=pk) # Alle mit diesem Destinatär verknüpften Dokumente laden verknuepfte_dokumente = DokumentDatei.objects.filter( destinataer=destinataer ).order_by("kontext", "titel") # Förderungen für diesen Destinatär laden foerderungen = Foerderung.objects.filter(destinataer=destinataer).order_by( "-jahr", "-betrag" ) # Unterstützungen für diesen Destinatär laden unterstuetzungen = DestinataerUnterstuetzung.objects.filter( destinataer=destinataer ).order_by("-faellig_am") # Notizen laden notizen_eintraege = DestinataerNotiz.objects.filter( destinataer=destinataer ).order_by("-erstellt_am") # Quarterly confirmations - load for current and next year from datetime import date current_year = date.today().year quarterly_confirmations = VierteljahresNachweis.objects.filter( destinataer=destinataer, jahr__in=[current_year, current_year + 1] ).order_by('-jahr', '-quartal') # Create missing quarterly confirmations for current year # Quarterly tracking is now always available regardless of study proof requirements for quartal in range(1, 5): # Q1-Q4 nachweis, created = VierteljahresNachweis.get_or_create_for_period( destinataer, current_year, quartal ) # Reload to get any newly created confirmations quarterly_confirmations = VierteljahresNachweis.objects.filter( destinataer=destinataer, jahr__in=[current_year, current_year + 1] ).order_by('-jahr', '-quartal') # Modal forms removed - only using full-screen editor now # Generate available years for the add quarter dropdown (current year + next 5 years) available_years = list(range(current_year, current_year + 6)) # Alle verfügbaren StiftungsKonten für das Select-Feld laden stiftungskonten = StiftungsKonto.objects.all().order_by("kontoname") # Timeline events (merged from destinataer_timeline view) timeline_events = [] for u in destinataer.unterstuetzungen.select_related("konto", "ausgezahlt_von", "freigegeben_von").order_by("-faellig_am"): timeline_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 \u20ac{u.betrag}", "beschreibung": u.beschreibung or u.get_status_display(), "status": u.get_status_display(), }) for n in destinataer.quartalseinreichungen.order_by("-jahr", "-quartal"): datum = n.zahlung_faelligkeitsdatum or n.faelligkeitsdatum if datum: timeline_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(), }) for e in destinataer.email_eingaenge.order_by("-eingangsdatum"): timeline_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(), }) for n in destinataer.notizen_eintraege.order_by("-erstellt_am"): timeline_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] + "\u2026") 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 "", }) timeline_events.sort(key=lambda e: e["datum"] if e["datum"] else date.min, reverse=True) context = { "destinataer": destinataer, "verknuepfte_dokumente": verknuepfte_dokumente, "foerderungen": foerderungen, "unterstuetzungen": unterstuetzungen, "notizen_eintraege": notizen_eintraege, "stiftungskonten": stiftungskonten, "quarterly_confirmations": quarterly_confirmations, "available_years": available_years, "current_year": current_year, "timeline_events": timeline_events, } return render(request, "stiftung/destinataer_detail.html", context) @login_required def destinataer_create(request): if request.method == "POST": form = DestinataerForm(request.POST) if form.is_valid(): destinataer = form.save() messages.success( request, f'Destinatär "{destinataer.get_full_name()}" wurde erfolgreich erstellt.', ) return redirect("stiftung:destinataer_detail", pk=destinataer.pk) else: form = DestinataerForm() context = {"form": form, "title": "Neuen Destinatär erstellen"} return render(request, "stiftung/destinataer_form.html", context) @login_required def destinataer_update(request, pk): destinataer = get_object_or_404(Destinataer, pk=pk) if request.method == "POST": form = DestinataerForm(request.POST, instance=destinataer) # Handle AJAX requests if request.headers.get('X-Requested-With') == 'XMLHttpRequest': if form.is_valid(): try: destinataer = form.save() # Note: Support payments are now only created through quarterly confirmations # No automatic creation when unterstuetzung_bestaetigt is checked return JsonResponse({ 'success': True, 'message': f'Destinatär "{destinataer.get_full_name()}" wurde erfolgreich aktualisiert.' }) except Exception as e: return JsonResponse({ 'success': False, 'error': f'Fehler beim Speichern: {str(e)}' }) else: # Return form errors for AJAX requests errors = [] for field, field_errors in form.errors.items(): for error in field_errors: errors.append(f'{form[field].label}: {error}') return JsonResponse({ 'success': False, 'error': 'Formular enthält Fehler: ' + '; '.join(errors) }) # Handle regular form submission if form.is_valid(): destinataer = form.save() # Note: Support payments are now only created through quarterly confirmations # No automatic creation when unterstuetzung_bestaetigt is checked messages.success( request, f'Destinatär "{destinataer.get_full_name()}" wurde erfolgreich aktualisiert.', ) return redirect("stiftung:destinataer_detail", pk=destinataer.pk) else: form = DestinataerForm(instance=destinataer) context = { "form": form, "destinataer": destinataer, "title": f"Destinatär bearbeiten: {destinataer.get_full_name()}", } return render(request, "stiftung/destinataer_form.html", context) @login_required def destinataer_delete(request, pk): destinataer = get_object_or_404(Destinataer, pk=pk) if request.method == "POST": destinataer.delete() messages.success( request, f'Destinatär "{destinataer.get_full_name()}" wurde erfolgreich gelöscht.', ) return redirect("stiftung:destinataer_list") context = {"destinataer": destinataer} return render(request, "stiftung/destinataer_confirm_delete.html", context) @login_required def destinataer_toggle_archiv(request, pk): """Destinatär aktivieren/deaktivieren (archivieren).""" destinataer = get_object_or_404(Destinataer, pk=pk) if request.method == "POST": destinataer.aktiv = not destinataer.aktiv destinataer.save(update_fields=["aktiv"]) status_text = "aktiviert" if destinataer.aktiv else "archiviert (inaktiv gesetzt)" AuditLog.objects.create( user=request.user, action=f"Destinatär {destinataer.get_full_name()} wurde {status_text}.", model_name="Destinataer", object_id=str(destinataer.pk), ) messages.success(request, f'Destinatär "{destinataer.get_full_name()}" wurde {status_text}.') return redirect("stiftung:destinataer_detail", pk=destinataer.pk) # Paechter Views (Landpächter) @login_required def destinataer_notiz_create(request, pk): destinataer = get_object_or_404(Destinataer, pk=pk) if request.method == "POST": form = DestinataerNotizForm(request.POST, request.FILES) if form.is_valid(): note = form.save(commit=False) note.destinataer = destinataer note.erstellt_von = request.user note.save() messages.success(request, "Notiz wurde gespeichert.") return redirect("stiftung:destinataer_detail", pk=destinataer.pk) else: # Debug: show what validation failed for field, errors in form.errors.items(): messages.error(request, f'Fehler in {field}: {", ".join(errors)}') else: form = DestinataerNotizForm() return render( request, "stiftung/destinataer_notiz_form.html", {"form": form, "destinataer": destinataer, "title": "Notiz hinzufügen"}, ) @login_required def destinataer_export(request, pk): """Export complete Destinatär data as ZIP with documents""" import json import os import tempfile import zipfile from django.http import HttpResponse destinataer = get_object_or_404(Destinataer, pk=pk) # Create a temporary file for the ZIP temp_file = tempfile.NamedTemporaryFile(delete=False, suffix=".zip") try: with zipfile.ZipFile(temp_file.name, "w", zipfile.ZIP_DEFLATED) as zipf: # 1. Entity data as JSON entity_data = { "id": str(destinataer.id), "anrede": destinataer.get_anrede_display() if hasattr(destinataer, 'anrede') and destinataer.anrede else None, "titel": destinataer.titel if hasattr(destinataer, 'titel') else None, "vorname": destinataer.vorname, "nachname": destinataer.nachname, "geburtsdatum": ( destinataer.geburtsdatum.isoformat() if destinataer.geburtsdatum else None ), "email": destinataer.email, "telefon": destinataer.telefon, "mobil": destinataer.mobil if hasattr(destinataer, 'mobil') else None, "iban": destinataer.iban, "strasse": destinataer.strasse, "plz": destinataer.plz, "ort": destinataer.ort, "familienzweig": destinataer.get_familienzweig_display(), "berufsgruppe": destinataer.get_berufsgruppe_display(), "ausbildungsstand": destinataer.ausbildungsstand, "institution": destinataer.institution, "projekt_beschreibung": destinataer.projekt_beschreibung, "jaehrliches_einkommen": ( str(destinataer.jaehrliches_einkommen) if destinataer.jaehrliches_einkommen else None ), "finanzielle_notlage": destinataer.finanzielle_notlage, "ist_abkoemmling": destinataer.ist_abkoemmling, "haushaltsgroesse": destinataer.haushaltsgroesse, "monatliche_bezuege": ( str(destinataer.monatliche_bezuege) if destinataer.monatliche_bezuege else None ), "vermoegen": ( str(destinataer.vermoegen) if destinataer.vermoegen else None ), "unterstuetzung_bestaetigt": destinataer.unterstuetzung_bestaetigt, "vierteljaehrlicher_betrag": ( str(destinataer.vierteljaehrlicher_betrag) if destinataer.vierteljaehrlicher_betrag else None ), "standard_konto": ( str(destinataer.standard_konto) if destinataer.standard_konto else None ), "studiennachweis_erforderlich": destinataer.studiennachweis_erforderlich, "letzter_studiennachweis": ( destinataer.letzter_studiennachweis.isoformat() if destinataer.letzter_studiennachweis else None ), "notizen": destinataer.notizen, "aktiv": destinataer.aktiv, "export_datum": timezone.now().isoformat(), "export_user": request.user.username, } zipf.writestr( "destinataer_data.json", json.dumps(entity_data, indent=2, ensure_ascii=False), ) # 2. Notes with attachments notizen = DestinataerNotiz.objects.filter(destinataer=destinataer).order_by( "-erstellt_am" ) notes_data = [] for note in notizen: note_data = { "titel": note.titel, "text": note.text, "erstellt_am": note.erstellt_am.isoformat(), "erstellt_von": ( note.erstellt_von.username if note.erstellt_von else None ), "datei_name": note.datei.name if note.datei else None, } notes_data.append(note_data) # Add attachment file if exists if note.datei and os.path.exists(note.datei.path): zipf.write( note.datei.path, f"notizen_anhaenge/{os.path.basename(note.datei.name)}", ) if notes_data: zipf.writestr( "notizen.json", json.dumps(notes_data, indent=2, ensure_ascii=False) ) # 3. Linked documents from Paperless dokumente = DokumentLink.objects.filter(destinataer_id=destinataer.pk) docs_data = [] for doc in dokumente: doc_data = { "paperless_id": doc.paperless_document_id, "titel": doc.titel, "kontext": doc.get_kontext_display(), "beschreibung": doc.beschreibung, } docs_data.append(doc_data) # Try to download document from Paperless try: if ( hasattr(settings, "PAPERLESS_API_URL") and settings.PAPERLESS_API_URL ): doc_url = f"{settings.PAPERLESS_API_URL}/api/documents/{doc.paperless_document_id}/download/" headers = {} if ( hasattr(settings, "PAPERLESS_API_TOKEN") and settings.PAPERLESS_API_TOKEN ): headers["Authorization"] = ( f"Token {settings.PAPERLESS_API_TOKEN}" ) response = requests.get(doc_url, headers=headers, timeout=30) if response.status_code == 200: # Determine file extension from Content-Type or use .pdf as fallback content_type = response.headers.get("content-type", "") if "pdf" in content_type: ext = ".pdf" elif "jpeg" in content_type or "jpg" in content_type: ext = ".jpg" elif "png" in content_type: ext = ".png" else: ext = ".pdf" # fallback safe_filename = f"dokument_{doc.paperless_document_id}_{doc.titel.replace('/', '_')[:50]}{ext}" zipf.writestr( f"dokumente/{safe_filename}", response.content ) doc_data["downloaded"] = True else: doc_data["download_error"] = f"HTTP {response.status_code}" except Exception as e: doc_data["download_error"] = str(e) if docs_data: zipf.writestr( "dokumente.json", json.dumps(docs_data, indent=2, ensure_ascii=False), ) # 4. Quarterly Confirmations with documents quarterly_confirmations = destinataer.quartalseinreichungen.all().order_by("-jahr", "-quartal") quarterly_data = [] for confirmation in quarterly_confirmations: confirmation_data = { "id": str(confirmation.id), "jahr": confirmation.jahr, "quartal": confirmation.quartal, "quartal_display": confirmation.get_quartal_display(), "status": confirmation.status, "status_display": confirmation.get_status_display(), "studiennachweis_erforderlich": confirmation.studiennachweis_erforderlich, "studiennachweis_eingereicht": confirmation.studiennachweis_eingereicht, "studiennachweis_bemerkung": confirmation.studiennachweis_bemerkung, "einkommenssituation_bestaetigt": confirmation.einkommenssituation_bestaetigt, "einkommenssituation_text": confirmation.einkommenssituation_text, "vermogenssituation_bestaetigt": confirmation.vermogenssituation_bestaetigt, "vermogenssituation_text": confirmation.vermogenssituation_text, "weitere_dokumente_beschreibung": confirmation.weitere_dokumente_beschreibung, "interne_notizen": confirmation.interne_notizen, "erstellt_am": confirmation.erstellt_am.isoformat(), "aktualisiert_am": confirmation.aktualisiert_am.isoformat(), "eingereicht_am": confirmation.eingereicht_am.isoformat() if confirmation.eingereicht_am else None, "geprueft_am": confirmation.geprueft_am.isoformat() if confirmation.geprueft_am else None, "geprueft_von": confirmation.geprueft_von.username if confirmation.geprueft_von else None, "faelligkeitsdatum": confirmation.faelligkeitsdatum.isoformat() if confirmation.faelligkeitsdatum else None, "completion_percentage": confirmation.get_completion_percentage(), "uploaded_files": [] } # Add uploaded files from quarterly confirmation quarterly_files = [ ("studiennachweis", confirmation.studiennachweis_datei), ("einkommenssituation", confirmation.einkommenssituation_datei), ("vermogenssituation", confirmation.vermogenssituation_datei), ("weitere_dokumente", confirmation.weitere_dokumente), ] for file_type, file_field in quarterly_files: if file_field and os.path.exists(file_field.path): file_info = { "type": file_type, "name": os.path.basename(file_field.name), "path": file_field.name } confirmation_data["uploaded_files"].append(file_info) # Add file to ZIP safe_filename = f"quarterly_{confirmation.jahr}_Q{confirmation.quartal}_{file_type}_{os.path.basename(file_field.name)}" zipf.write( file_field.path, f"vierteljahresnachweis/{safe_filename}" ) quarterly_data.append(confirmation_data) if quarterly_data: zipf.writestr( "vierteljahresnachweis.json", json.dumps(quarterly_data, indent=2, ensure_ascii=False), ) # Prepare response with open(temp_file.name, "rb") as f: response = HttpResponse(f.read(), content_type="application/zip") filename = f"destinataer_{destinataer.nachname}_{destinataer.vorname}_{timezone.now().strftime('%Y%m%d_%H%M%S')}.zip" response["Content-Disposition"] = f'attachment; filename="{filename}"' return response finally: # Clean up temp file try: os.unlink(temp_file.name) except: pass