The Bestätigung email was sent via Celery task (fire-and-forget), so the UI always showed "wird gesendet" even when the task failed silently in the worker. Now sends synchronously from the web process (matching the working test email pattern) with proper error display to the user. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
880 lines
36 KiB
Python
880 lines
36 KiB
Python
# 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.audit import log_action
|
||
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)"
|
||
log_action(
|
||
request,
|
||
action="update",
|
||
entity_type="destinataer",
|
||
entity_id=str(destinataer.pk),
|
||
entity_name=destinataer.get_full_name(),
|
||
description=f"Destinatär {destinataer.get_full_name()} wurde {status_text}.",
|
||
)
|
||
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
|
||
|
||
|
||
# =============================================================================
|
||
# Bestätigungsschreiben
|
||
# =============================================================================
|
||
|
||
@login_required
|
||
def bestaetigung_vorschau(request, pk):
|
||
"""
|
||
PDF-Vorschau eines Bestätigungsschreibens für einen Destinatär im Browser.
|
||
Generiert das PDF on-the-fly via WeasyPrint.
|
||
"""
|
||
from decimal import Decimal
|
||
from django.template.loader import render_to_string
|
||
|
||
destinataer = get_object_or_404(Destinataer, id=pk)
|
||
|
||
unterstuetzungen = DestinataerUnterstuetzung.objects.filter(
|
||
destinataer=destinataer,
|
||
status__in=["ausgezahlt", "abgeschlossen"],
|
||
).order_by("faellig_am")
|
||
|
||
gesamtbetrag = unterstuetzungen.aggregate(total=Sum("betrag"))["total"] or Decimal("0")
|
||
|
||
zeitraum = None
|
||
if unterstuetzungen.exists():
|
||
erste = unterstuetzungen.first().faellig_am
|
||
letzte = unterstuetzungen.last().faellig_am
|
||
if erste == letzte:
|
||
zeitraum = erste.strftime("%d.%m.%Y")
|
||
else:
|
||
zeitraum = f"{erste.strftime('%d.%m.%Y')} – {letzte.strftime('%d.%m.%Y')}"
|
||
|
||
betrag_quartal = destinataer.vierteljaehrlicher_betrag
|
||
betrag_jaehrlich = (betrag_quartal * 4) if betrag_quartal else None
|
||
zweck = destinataer.ausbildungsstand or (destinataer.get_berufsgruppe_display() if destinataer.berufsgruppe else None)
|
||
|
||
context = {
|
||
"destinataer": destinataer,
|
||
"unterstuetzungen": unterstuetzungen,
|
||
"gesamtbetrag": gesamtbetrag,
|
||
"datum": timezone.now().date(),
|
||
"zeitraum": zeitraum,
|
||
"betrag_quartal": betrag_quartal,
|
||
"betrag_jaehrlich": betrag_jaehrlich,
|
||
"zweck": zweck,
|
||
}
|
||
|
||
try:
|
||
from weasyprint import HTML
|
||
from stiftung.utils.vorlagen import render_vorlage
|
||
html_content = render_vorlage("pdf/bestaetigung.html", context)
|
||
pdf_bytes = HTML(string=html_content).write_pdf()
|
||
response = HttpResponse(pdf_bytes, content_type="application/pdf")
|
||
filename = f"bestaetigung_{destinataer.nachname}_{destinataer.vorname}.pdf"
|
||
response["Content-Disposition"] = f'inline; filename="{filename}"'
|
||
return response
|
||
except Exception as exc:
|
||
messages.error(request, f"PDF-Generierung fehlgeschlagen: {exc}")
|
||
return redirect("stiftung:destinataer_detail", pk=pk)
|
||
|
||
|
||
@login_required
|
||
def bestaetigung_versenden(request, pk):
|
||
"""
|
||
Sendet das Bestätigungsschreiben per E-Mail an den Destinatär.
|
||
POST-only (CSRF-geschützt). Sendet synchron für direktes Feedback.
|
||
"""
|
||
from stiftung.tasks import _send_bestaetigung_sync
|
||
|
||
if request.method != "POST":
|
||
return redirect("stiftung:destinataer_detail", pk=pk)
|
||
|
||
destinataer = get_object_or_404(Destinataer, id=pk)
|
||
|
||
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=pk)
|
||
|
||
try:
|
||
result = _send_bestaetigung_sync(str(destinataer.id))
|
||
except Exception as exc:
|
||
import logging
|
||
logging.getLogger(__name__).exception("Bestätigung versenden fehlgeschlagen: %s", exc)
|
||
messages.error(
|
||
request,
|
||
f"Bestätigungsschreiben konnte nicht gesendet werden: {exc}",
|
||
)
|
||
return redirect("stiftung:destinataer_detail", pk=pk)
|
||
|
||
if result and result.get("status") == "skipped":
|
||
messages.warning(request, "Versand übersprungen: Keine E-Mail-Adresse hinterlegt.")
|
||
return redirect("stiftung:destinataer_detail", pk=pk)
|
||
|
||
if result and result.get("status") == "error":
|
||
messages.error(request, f"Fehler: {result.get('message', 'Unbekannter Fehler')}")
|
||
return redirect("stiftung:destinataer_detail", pk=pk)
|
||
|
||
log_action(
|
||
request,
|
||
action="update",
|
||
entity_type="destinataer",
|
||
entity_id=str(destinataer.id),
|
||
entity_name=destinataer.get_full_name(),
|
||
description=f"Bestätigungsschreiben per E-Mail gesendet an {destinataer.get_full_name()} ({destinataer.email})",
|
||
)
|
||
|
||
messages.success(
|
||
request,
|
||
f"Bestätigungsschreiben wurde erfolgreich an {destinataer.email} gesendet.",
|
||
)
|
||
return redirect("stiftung:destinataer_detail", pk=pk)
|
||
|