Files
stiftung-management-system/app/stiftung/views/destinataere.py
SysAdmin Agent 3ca2706e5d Phase 0: forms.py, admin.py und views.py in Domain-Packages aufteilen
- forms.py → forms/ Package (8 Domänen: destinataere, land, finanzen,
  foerderung, dokumente, veranstaltung, system, geschichte)
- admin.py → admin/ Package (7 Domänen, alle 22 @admin.register dekoriert)
- views.py (8845 Zeilen) → views/ Package (10 Domänen: dashboard, destinataere,
  land, paechter, finanzen, foerderung, dokumente, unterstuetzungen,
  veranstaltung, geschichte, system)
- __init__.py in jedem Package re-exportiert alle Symbole für Rückwärtskompatibilität
- urls.py bleibt unverändert (funktioniert durch Re-Exports)
- Django system check: 0 Fehler, alle URL-Auflösungen funktionieren

Keine funktionalen Änderungen – reine Strukturverbesserung für Vision 2026.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-11 09:55:15 +00:00

698 lines
28 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

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

# views/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,
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", "")
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 = DokumentLink.objects.filter(
destinataer_id=destinataer.pk
).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")
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,
}
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)
# 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