Files
stiftung-management-system/app/mcp_server/tools/lesen.py
SysAdmin Agent e0b377014c
Some checks failed
CI/CD Pipeline / test (push) Has been cancelled
CI/CD Pipeline / deploy (push) Has been cancelled
Code Quality / quality (push) Has been cancelled
v4.1.0: DMS email documents, category-specific Nachweis linking, version system
- Save cover email body as DMS document with new 'email' context type
- Show email body separately from attachments in email detail view
- Add per-category DMS document assignment in quarterly confirmation
  (Studiennachweis, Einkommenssituation, Vermögenssituation)
- Add VERSION file and context processor for automatic version display
- Add MCP server, agent system, import/export, and new migrations
- Update compose files and production environment template

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-15 18:48:52 +00:00

756 lines
27 KiB
Python

"""
Lese-Tools für den MCP Server der Stiftungsverwaltung.
Alle Tools:
- Prüfen die Rolle (readonly/editor/admin erforderlich)
- Wenden PII-Maskierung an (außer bei admin)
- Schreiben Audit-Log-Einträge
"""
from __future__ import annotations
from django.db.models import Q, Sum
from mcp_server.audit import log_mcp_read
from mcp_server.auth import require_role
from mcp_server.privacy import apply_privacy_filter, apply_privacy_filter_list
from mcp_server.tools.helpers import format_result, model_to_dict
def _get_role() -> str:
from mcp_server.auth import get_current_role, require_role as _require
role = get_current_role()
_require(role)
return role
# ──────────────────────────────────────────────────────────────────────────────
# Destinatäre
# ──────────────────────────────────────────────────────────────────────────────
def destinataer_suchen(
suchbegriff: str = "",
aktiv: bool | None = None,
familienzweig: str = "",
limit: int = 20,
) -> str:
"""
Sucht Destinatäre nach Name, Familienzweig oder Aktivstatus.
Args:
suchbegriff: Freitext (Vor-/Nachname, Institution)
aktiv: True=nur Aktive, False=nur Inaktive, None=alle
familienzweig: Filtert nach Familienzweig (hauptzweig/nebenzweig/verwandt/anderer)
limit: Maximale Anzahl Ergebnisse (max. 100)
"""
from stiftung.models import Destinataer
role = _get_role()
limit = min(limit, 100)
qs = Destinataer.objects.all()
if suchbegriff:
qs = qs.filter(
Q(vorname__icontains=suchbegriff)
| Q(nachname__icontains=suchbegriff)
| Q(institution__icontains=suchbegriff)
)
if aktiv is not None:
qs = qs.filter(aktiv=aktiv)
if familienzweig:
qs = qs.filter(familienzweig=familienzweig)
qs = qs.order_by("nachname", "vorname")[:limit]
# Reduzierte Felder für Listen-Ausgabe
results = []
for obj in qs:
item = {
"id": str(obj.id),
"vorname": obj.vorname,
"nachname": obj.nachname,
"familienzweig": obj.familienzweig,
"aktiv": obj.aktiv,
"berufsgruppe": obj.berufsgruppe,
"ort": obj.ort,
"email": obj.email,
}
results.append(apply_privacy_filter(item, "destinataer", role))
log_mcp_read(role, "destinataer", "Destinatär-Suche", f"Suche: '{suchbegriff}', {len(results)} Ergebnisse")
return format_result({"anzahl": len(results), "destinataere": results})
def destinataer_details(destinataer_id: str) -> str:
"""
Gibt vollständige Details eines Destinatärs zurück.
Args:
destinataer_id: UUID des Destinatärs
"""
from stiftung.models import Destinataer, DestinataerUnterstuetzung, Foerderung
role = _get_role()
try:
obj = Destinataer.objects.get(id=destinataer_id)
except Destinataer.DoesNotExist:
return format_result({"fehler": f"Destinatär {destinataer_id} nicht gefunden"})
data = model_to_dict(obj)
data = apply_privacy_filter(data, "destinataer", role)
# Aktuelle Unterstützungen
unterstuetzungen = list(
DestinataerUnterstuetzung.objects.filter(destinataer=obj)
.exclude(status="storniert")
.order_by("-faellig_am")[:10]
.values("id", "betrag", "faellig_am", "status", "beschreibung")
)
for u in unterstuetzungen:
for k, v in u.items():
from mcp_server.tools.helpers import serialize_value
u[k] = serialize_value(v)
# Förderungen
foerderungen = list(
Foerderung.objects.filter(destinataer=obj)
.order_by("-jahr")[:10]
.values("id", "jahr", "betrag", "kategorie", "status")
)
for f in foerderungen:
for k, v in f.items():
from mcp_server.tools.helpers import serialize_value
f[k] = serialize_value(v)
data["aktuelle_unterstuetzungen"] = unterstuetzungen
data["foerderungen"] = foerderungen
name = f"{obj.vorname} {obj.nachname}"
log_mcp_read(role, "destinataer", name, f"Details abgerufen für {name}")
return format_result(data)
# ──────────────────────────────────────────────────────────────────────────────
# Ländereien
# ──────────────────────────────────────────────────────────────────────────────
def land_suchen(
suchbegriff: str = "",
gemeinde: str = "",
limit: int = 20,
) -> str:
"""
Sucht Ländereien nach Bezeichnung, Gemarkung oder Gemeinde.
Args:
suchbegriff: Freitext (Bezeichnung, Gemarkung)
gemeinde: Filtert nach Gemeinde
limit: Maximale Anzahl Ergebnisse (max. 100)
"""
from stiftung.models import Land
role = _get_role()
limit = min(limit, 100)
qs = Land.objects.all()
if suchbegriff:
qs = qs.filter(
Q(gemeinde__icontains=suchbegriff)
| Q(gemarkung__icontains=suchbegriff)
| Q(flur__icontains=suchbegriff)
| Q(lfd_nr__icontains=suchbegriff)
| Q(adresse__icontains=suchbegriff)
)
if gemeinde:
qs = qs.filter(gemeinde__icontains=gemeinde)
qs = qs.order_by("gemeinde", "gemarkung")[:limit]
results = []
for obj in qs:
results.append({
"id": str(obj.id),
"bezeichnung": str(obj),
"lfd_nr": obj.lfd_nr,
"gemeinde": obj.gemeinde,
"gemarkung": obj.gemarkung,
"flur": obj.flur,
"flurstueck": obj.flurstueck,
"groesse_qm": float(obj.groesse_qm) if obj.groesse_qm else None,
"aktiv_verpachtet": obj.neue_verpachtungen.filter(status="aktiv").exists(),
})
log_mcp_read(role, "land", "Länderei-Suche", f"Suche: '{suchbegriff}', {len(results)} Ergebnisse")
return format_result({"anzahl": len(results), "laendereien": results})
def land_details(land_id: str) -> str:
"""
Gibt vollständige Details einer Länderei zurück.
Args:
land_id: UUID der Länderei
"""
from stiftung.models import Land, LandVerpachtung
role = _get_role()
try:
obj = Land.objects.get(id=land_id)
except Land.DoesNotExist:
return format_result({"fehler": f"Länderei {land_id} nicht gefunden"})
data = model_to_dict(obj)
# Aktive Verpachtungen
verpachtungen = []
for v in obj.neue_verpachtungen.all().order_by("-pachtbeginn")[:5]:
verpachtungen.append({
"id": str(v.id),
"paechter": str(v.paechter) if v.paechter else None,
"pachtbeginn": v.pachtbeginn.isoformat() if v.pachtbeginn else None,
"pachtende": v.pachtende.isoformat() if v.pachtende else None,
"pachtzins_pauschal": float(v.pachtzins_pauschal) if v.pachtzins_pauschal else None,
"status": v.status,
})
data["verpachtungen"] = verpachtungen
log_mcp_read(role, "land", str(obj), f"Land-Details abgerufen: {obj}")
return format_result(data)
# ──────────────────────────────────────────────────────────────────────────────
# Pächter
# ──────────────────────────────────────────────────────────────────────────────
def paechter_suchen(
suchbegriff: str = "",
limit: int = 20,
) -> str:
"""
Sucht Pächter nach Name.
Args:
suchbegriff: Freitext (Vor-/Nachname)
limit: Maximale Anzahl Ergebnisse (max. 100)
"""
from stiftung.models import Paechter
role = _get_role()
limit = min(limit, 100)
qs = Paechter.objects.all()
if suchbegriff:
qs = qs.filter(
Q(vorname__icontains=suchbegriff) | Q(nachname__icontains=suchbegriff)
)
qs = qs.order_by("nachname", "vorname")[:limit]
results = []
for obj in qs:
item = {
"id": str(obj.id),
"vorname": obj.vorname,
"nachname": obj.nachname,
"personentyp": obj.personentyp,
"ort": obj.ort,
"email": obj.email,
"telefon": obj.telefon,
"aktive_verpachtungen": obj.neue_verpachtungen.filter(status="aktiv").count() if hasattr(obj, "neue_verpachtungen") else 0,
}
results.append(apply_privacy_filter(item, "paechter", role))
log_mcp_read(role, "paechter", "Pächter-Suche", f"Suche: '{suchbegriff}', {len(results)} Ergebnisse")
return format_result({"anzahl": len(results), "paechter": results})
# ──────────────────────────────────────────────────────────────────────────────
# Konten
# ──────────────────────────────────────────────────────────────────────────────
def konten_uebersicht() -> str:
"""
Gibt eine Übersicht aller Stiftungskonten mit aktuellem Saldo zurück.
"""
from stiftung.models import StiftungsKonto
role = _get_role()
konten = []
gesamt_saldo = 0.0
for konto in StiftungsKonto.objects.filter(aktiv=True).order_by("bank_name"):
saldo = float(konto.saldo) if konto.saldo else 0.0
gesamt_saldo += saldo
konten.append({
"id": str(konto.id),
"kontoname": konto.kontoname,
"bank_name": konto.bank_name,
"konto_typ": konto.konto_typ,
"saldo": saldo,
"saldo_datum": konto.saldo_datum.isoformat() if konto.saldo_datum else None,
"zinssatz": float(konto.zinssatz) if konto.zinssatz else None,
# IBAN nur für Admin
"iban": konto.iban if role == "admin" else "****" + konto.iban[-4:] if konto.iban and len(konto.iban) > 4 else "****",
})
log_mcp_read(role, "stiftungskonto", "Kontenübersicht", f"{len(konten)} Konten abgerufen")
return format_result({
"konten": konten,
"gesamt_saldo": round(gesamt_saldo, 2),
"anzahl_konten": len(konten),
})
# ──────────────────────────────────────────────────────────────────────────────
# Verwaltungskosten
# ──────────────────────────────────────────────────────────────────────────────
def verwaltungskosten(
jahr: int | None = None,
kategorie: str = "",
status: str = "",
limit: int = 50,
) -> str:
"""
Listet Verwaltungskosten auf.
Args:
jahr: Filtert nach Jahr (z.B. 2024)
kategorie: Filtert nach Kategorie (rechnung_intern, bueroausstattung, ...)
status: Filtert nach Status (geplant, bezahlt, ...)
limit: Maximale Anzahl (max. 200)
"""
from stiftung.models import Verwaltungskosten
role = _get_role()
limit = min(limit, 200)
qs = Verwaltungskosten.objects.all()
if jahr:
qs = qs.filter(datum__year=jahr)
if kategorie:
qs = qs.filter(kategorie=kategorie)
if status:
qs = qs.filter(status=status)
qs = qs.order_by("-datum")[:limit]
results = []
gesamt = 0.0
for obj in qs:
betrag = float(obj.betrag) if obj.betrag else 0.0
gesamt += betrag
results.append({
"id": str(obj.id),
"bezeichnung": obj.bezeichnung,
"kategorie": obj.kategorie,
"betrag": betrag,
"datum": obj.datum.isoformat(),
"lieferant_firma": obj.lieferant_firma,
"status": obj.status,
"rechnungsnummer": obj.rechnungsnummer,
})
log_mcp_read(role, "verwaltungskosten", "Verwaltungskosten", f"{len(results)} Einträge")
return format_result({
"anzahl": len(results),
"gesamt_betrag": round(gesamt, 2),
"verwaltungskosten": results,
})
# ──────────────────────────────────────────────────────────────────────────────
# Transaktionen
# ──────────────────────────────────────────────────────────────────────────────
def transaktionen_suchen(
suchbegriff: str = "",
konto_id: str = "",
von_datum: str = "",
bis_datum: str = "",
transaction_type: str = "",
limit: int = 50,
) -> str:
"""
Sucht Banktransaktionen.
Args:
suchbegriff: Freitext in Verwendungszweck oder Empfänger
konto_id: UUID des Kontos (optional)
von_datum: Startdatum YYYY-MM-DD (optional)
bis_datum: Enddatum YYYY-MM-DD (optional)
transaction_type: eingang/ausgang/dauerauftrag/... (optional)
limit: Maximale Anzahl (max. 200)
"""
from stiftung.models import BankTransaction
role = _get_role()
limit = min(limit, 200)
qs = BankTransaction.objects.select_related("konto").all()
if suchbegriff:
qs = qs.filter(
Q(verwendungszweck__icontains=suchbegriff)
| Q(empfaenger_zahlungspflichtiger__icontains=suchbegriff)
)
if konto_id:
qs = qs.filter(konto_id=konto_id)
if von_datum:
qs = qs.filter(datum__gte=von_datum)
if bis_datum:
qs = qs.filter(datum__lte=bis_datum)
if transaction_type:
qs = qs.filter(transaction_type=transaction_type)
qs = qs.order_by("-datum")[:limit]
results = []
for obj in qs:
results.append({
"id": str(obj.id),
"datum": obj.datum.isoformat(),
"betrag": float(obj.betrag),
"waehrung": obj.waehrung,
"verwendungszweck": obj.verwendungszweck[:200],
"empfaenger_zahlungspflichtiger": obj.empfaenger_zahlungspflichtiger,
"transaction_type": obj.transaction_type,
"status": obj.status,
"konto": obj.konto.kontoname if obj.konto else None,
})
log_mcp_read(role, "banktransaction", "Transaktionssuche", f"Suche: '{suchbegriff}', {len(results)} Ergebnisse")
return format_result({"anzahl": len(results), "transaktionen": results})
# ──────────────────────────────────────────────────────────────────────────────
# Dokumente
# ──────────────────────────────────────────────────────────────────────────────
def dokument_suchen(
suchbegriff: str = "",
kontext: str = "",
limit: int = 30,
) -> str:
"""
Sucht Dokumente im DMS nach Titel, Beschreibung oder Kontext.
Args:
suchbegriff: Freitext (Titel, Beschreibung, Volltext)
kontext: Dokumententyp (pachtvertrag, antrag, rechnung, ...)
limit: Maximale Anzahl (max. 100)
"""
from stiftung.models import DokumentDatei
role = _get_role()
limit = min(limit, 100)
qs = DokumentDatei.objects.all()
if suchbegriff:
qs = qs.filter(
Q(titel__icontains=suchbegriff)
| Q(beschreibung__icontains=suchbegriff)
| Q(inhaltstext__icontains=suchbegriff)
)
if kontext:
qs = qs.filter(kontext=kontext)
qs = qs.order_by("-erstellt_am")[:limit]
results = []
for obj in qs:
results.append({
"id": str(obj.id),
"titel": obj.titel,
"kontext": obj.kontext,
"beschreibung": obj.beschreibung[:200] if obj.beschreibung else "",
"dateityp": obj.dateityp,
"dateigroesse": obj.dateigroesse,
"dateiname_original": obj.dateiname_original,
})
log_mcp_read(role, "dokumentlink", "Dokumentsuche", f"Suche: '{suchbegriff}', {len(results)} Ergebnisse")
return format_result({"anzahl": len(results), "dokumente": results})
def dokument_details(dokument_id: str) -> str:
"""
Gibt Details eines Dokuments zurück (ohne Dateiinhalt).
Args:
dokument_id: UUID des Dokuments
"""
from stiftung.models import DokumentDatei
role = _get_role()
try:
obj = DokumentDatei.objects.get(id=dokument_id)
except DokumentDatei.DoesNotExist:
return format_result({"fehler": f"Dokument {dokument_id} nicht gefunden"})
data = {
"id": str(obj.id),
"titel": obj.titel,
"kontext": obj.kontext,
"beschreibung": obj.beschreibung,
"dateityp": obj.dateityp,
"dateigroesse": obj.dateigroesse,
"dateiname_original": obj.dateiname_original,
# Verknüpfungen
"land_id": str(obj.land_id) if obj.land_id else None,
"paechter_id": str(obj.paechter_id) if obj.paechter_id else None,
}
# Inhaltstext nur für Nicht-Binary-Dokumente und wenn vorhanden
if obj.inhaltstext:
data["inhaltsvorschau"] = obj.inhaltstext[:500]
log_mcp_read(role, "dokumentlink", obj.titel, f"Dokumentdetails abgerufen: {obj.titel}")
return format_result(data)
# ──────────────────────────────────────────────────────────────────────────────
# Termine
# ──────────────────────────────────────────────────────────────────────────────
def termine_anzeigen(
von_datum: str = "",
bis_datum: str = "",
kategorie: str = "",
prioritaet: str = "",
limit: int = 50,
) -> str:
"""
Zeigt Kalendereinträge und Termine an.
Args:
von_datum: Startdatum YYYY-MM-DD (optional, Standard: heute)
bis_datum: Enddatum YYYY-MM-DD (optional)
kategorie: termin/zahlung/deadline/geburtstag/vertrag/pruefung/sonstiges
prioritaet: niedrig/normal/hoch/kritisch
limit: Maximale Anzahl (max. 200)
"""
from datetime import date as date_type
from stiftung.models import StiftungsKalenderEintrag
role = _get_role()
limit = min(limit, 200)
qs = StiftungsKalenderEintrag.objects.all()
if von_datum:
qs = qs.filter(datum__gte=von_datum)
else:
qs = qs.filter(datum__gte=date_type.today())
if bis_datum:
qs = qs.filter(datum__lte=bis_datum)
if kategorie:
qs = qs.filter(kategorie=kategorie)
if prioritaet:
qs = qs.filter(prioritaet=prioritaet)
qs = qs.order_by("datum", "uhrzeit")[:limit]
results = []
for obj in qs:
results.append({
"id": str(obj.id),
"titel": obj.titel,
"datum": obj.datum.isoformat(),
"uhrzeit": obj.uhrzeit.isoformat() if obj.uhrzeit else None,
"ganztags": obj.ganztags,
"kategorie": obj.kategorie,
"prioritaet": obj.prioritaet,
"beschreibung": obj.beschreibung[:300] if obj.beschreibung else "",
"destinataer_id": str(obj.destinataer_id) if obj.destinataer_id else None,
})
log_mcp_read(role, "system", "Terminübersicht", f"{len(results)} Termine abgerufen")
return format_result({"anzahl": len(results), "termine": results})
# ──────────────────────────────────────────────────────────────────────────────
# Globale Suche & Dashboard
# ──────────────────────────────────────────────────────────────────────────────
def globale_suche(suchbegriff: str, limit_pro_typ: int = 5) -> str:
"""
Sucht über alle Entitätstypen gleichzeitig.
Args:
suchbegriff: Suchbegriff (mindestens 2 Zeichen)
limit_pro_typ: Ergebnisse pro Entitätstyp (max. 20)
"""
from stiftung.models import (
BankTransaction, Destinataer, Land, Paechter,
StiftungsKalenderEintrag, Verwaltungskosten,
)
role = _get_role()
if len(suchbegriff) < 2:
return format_result({"fehler": "Suchbegriff muss mindestens 2 Zeichen lang sein"})
limit_pro_typ = min(limit_pro_typ, 20)
ergebnisse = {}
# Destinatäre
dest = Destinataer.objects.filter(
Q(vorname__icontains=suchbegriff) | Q(nachname__icontains=suchbegriff)
)[:limit_pro_typ]
ergebnisse["destinataere"] = [
{"id": str(d.id), "name": f"{d.vorname} {d.nachname}", "typ": "destinataer"}
for d in dest
]
# Ländereien
laender = Land.objects.filter(
Q(bezeichnung__icontains=suchbegriff) | Q(gemarkung__icontains=suchbegriff)
)[:limit_pro_typ]
ergebnisse["laendereien"] = [
{"id": str(l.id), "name": str(l), "typ": "land"}
for l in laender
]
# Pächter
paechter = Paechter.objects.filter(
Q(vorname__icontains=suchbegriff) | Q(nachname__icontains=suchbegriff)
)[:limit_pro_typ]
ergebnisse["paechter"] = [
{"id": str(p.id), "name": f"{p.vorname} {p.nachname}", "typ": "paechter"}
for p in paechter
]
# Transaktionen
transaktionen = BankTransaction.objects.filter(
Q(verwendungszweck__icontains=suchbegriff)
| Q(empfaenger_zahlungspflichtiger__icontains=suchbegriff)
)[:limit_pro_typ]
ergebnisse["transaktionen"] = [
{"id": str(t.id), "verwendungszweck": t.verwendungszweck[:100], "betrag": float(t.betrag), "datum": t.datum.isoformat(), "typ": "transaktion"}
for t in transaktionen
]
log_mcp_read(role, "system", "Globale Suche", f"Suche: '{suchbegriff}'")
return format_result(ergebnisse)
def dashboard() -> str:
"""
Gibt eine Übersicht der wichtigsten Stiftungsdaten zurück.
"""
from datetime import date as date_type
from stiftung.models import (
BankTransaction, Destinataer, DestinataerUnterstuetzung,
Land, LandVerpachtung, StiftungsKalenderEintrag, StiftungsKonto,
)
role = _get_role()
heute = date_type.today()
# Konten-Gesamtsaldo
konten = StiftungsKonto.objects.filter(aktiv=True)
gesamt_saldo = sum(float(k.saldo or 0) for k in konten)
# Destinatäre
aktive_dest = Destinataer.objects.filter(aktiv=True).count()
# Offene Zahlungen
offene_zahlungen = DestinataerUnterstuetzung.objects.filter(
status__in=["geplant", "faellig", "nachweis_eingereicht", "freigegeben"]
).aggregate(anzahl=Sum("betrag"))
offene_zahlungen_betrag = float(offene_zahlungen["anzahl"] or 0)
offene_zahlungen_anzahl = DestinataerUnterstuetzung.objects.filter(
status__in=["geplant", "faellig", "nachweis_eingereicht", "freigegeben"]
).count()
# Fällige Termine (nächste 30 Tage)
from datetime import timedelta
naechste_termine = StiftungsKalenderEintrag.objects.filter(
datum__gte=heute,
datum__lte=heute + timedelta(days=30),
).order_by("datum")[:5]
termine_liste = [
{"titel": t.titel, "datum": t.datum.isoformat(), "prioritaet": t.prioritaet, "kategorie": t.kategorie}
for t in naechste_termine
]
# Aktive Verpachtungen
aktive_verpachtungen = LandVerpachtung.objects.filter(status="aktiv").count()
log_mcp_read(role, "system", "Dashboard", "Dashboard abgerufen")
return format_result({
"stand": heute.isoformat(),
"finanzen": {
"gesamt_saldo_eur": round(gesamt_saldo, 2),
"anzahl_konten": konten.count(),
},
"destinataere": {
"aktiv": aktive_dest,
},
"zahlungen": {
"offen_anzahl": offene_zahlungen_anzahl,
"offen_betrag_eur": round(offene_zahlungen_betrag, 2),
},
"verpachtungen": {
"aktiv": aktive_verpachtungen,
},
"naechste_termine": termine_liste,
})
def statistiken() -> str:
"""
Gibt detaillierte Statistiken der Stiftungsverwaltung zurück.
"""
from datetime import date as date_type
from stiftung.models import (
BankTransaction, Destinataer, Foerderung,
Land, LandVerpachtung, Verwaltungskosten,
)
role = _get_role()
aktuelles_jahr = date_type.today().year
# Förderungen dieses Jahr
foerderungen_jahr = Foerderung.objects.filter(jahr=aktuelles_jahr)
foerderungen_gesamt = foerderungen_jahr.aggregate(summe=Sum("betrag"))
# Destinatäre nach Familienzweig
from django.db.models import Count
dest_zweige = list(
Destinataer.objects.filter(aktiv=True)
.values("familienzweig")
.annotate(anzahl=Count("id"))
.order_by("-anzahl")
)
# Verwaltungskosten dieses Jahr
vk_jahr = Verwaltungskosten.objects.filter(datum__year=aktuelles_jahr)
vk_gesamt = vk_jahr.aggregate(summe=Sum("betrag"))
# Ländereien
laender_ges = Land.objects.count()
laender_verpachtet = Land.objects.filter(
neue_verpachtungen__status="aktiv"
).distinct().count()
log_mcp_read(role, "system", "Statistiken", f"Statistiken für {aktuelles_jahr} abgerufen")
return format_result({
"jahr": aktuelles_jahr,
"foerderungen": {
"anzahl": foerderungen_jahr.count(),
"gesamt_betrag_eur": float(foerderungen_gesamt["summe"] or 0),
},
"verwaltungskosten": {
"anzahl": vk_jahr.count(),
"gesamt_betrag_eur": float(vk_gesamt["summe"] or 0),
},
"destinataere_nach_zweig": dest_zweige,
"laendereien": {
"gesamt": laender_ges,
"aktiv_verpachtet": laender_verpachtet,
},
})