v4.1.0: DMS email documents, category-specific Nachweis linking, version system
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

- 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>
This commit is contained in:
SysAdmin Agent
2026-03-15 18:48:52 +00:00
parent faeb7c1073
commit e0b377014c
49 changed files with 5913 additions and 55 deletions

1
VERSION Normal file
View File

@@ -0,0 +1 @@
4.1.0

View File

@@ -0,0 +1,14 @@
from pathlib import Path
_VERSION = None
def app_version(request):
global _VERSION
if _VERSION is None:
version_file = Path(__file__).resolve().parent.parent.parent / "VERSION"
try:
_VERSION = version_file.read_text().strip()
except FileNotFoundError:
_VERSION = "unknown"
return {"APP_VERSION": _VERSION}

View File

@@ -72,6 +72,7 @@ TEMPLATES = [
"django.template.context_processors.request", "django.template.context_processors.request",
"django.contrib.auth.context_processors.auth", "django.contrib.auth.context_processors.auth",
"django.contrib.messages.context_processors.messages", "django.contrib.messages.context_processors.messages",
"core.context_processors.app_version",
], ],
}, },
}, },

View File

@@ -0,0 +1 @@
# MCP Server für die Stiftungsverwaltung

View File

@@ -0,0 +1,4 @@
"""Ermöglicht Start via: python -m mcp_server"""
from mcp_server.server import mcp
mcp.run(transport="stdio")

103
app/mcp_server/audit.py Normal file
View File

@@ -0,0 +1,103 @@
"""
Audit-Integration für MCP-Aktionen.
Alle MCP-Aktionen werden im bestehenden AuditLog erfasst.
Da MCP kein HTTP-Request-Objekt hat, werden Felder direkt gesetzt:
- user_agent = "MCP/<rolle>"
- session_key = "mcp"
- ip_address = None
"""
from __future__ import annotations
def log_mcp_action(
role: str,
action: str,
entity_type: str,
entity_id: str,
entity_name: str,
description: str,
changes: dict | None = None,
) -> None:
"""
Schreibt einen Audit-Log-Eintrag für eine MCP-Aktion.
Args:
role: Aktuelle MCP-Rolle ("readonly", "editor", "admin")
action: Aktionstyp (aus AuditLog.ACTION_TYPES)
entity_type: Entitätstyp (aus AuditLog.ENTITY_TYPES oder freier Text)
entity_id: ID der Entität
entity_name: Lesbarer Name der Entität
description: Beschreibung der Aktion
changes: Optionales Dict mit Änderungen
"""
# Import hier, damit Django bereits initialisiert ist wenn diese Funktion aufgerufen wird
from stiftung.models import AuditLog
# Normalisiere entity_type: muss in den AuditLog.ENTITY_TYPES-Choices sein
# oder auf "system" fallen, da AuditLog choices-Validierung ggf. nicht hart durchgesetzt wird
valid_entity_types = {choice[0] for choice in AuditLog.ENTITY_TYPES}
if entity_type not in valid_entity_types:
entity_type = "system"
# Normalisiere action: muss in ACTION_TYPES sein
valid_actions = {choice[0] for choice in AuditLog.ACTION_TYPES}
if action not in valid_actions:
action = "export" # Generischer Fallback für MCP-Leseoperationen
AuditLog.objects.create(
user=None,
username=f"mcp:{role}",
action=action,
entity_type=entity_type,
entity_id=str(entity_id) if entity_id else "",
entity_name=entity_name,
description=description,
changes=changes,
ip_address=None,
user_agent=f"MCP/{role}",
session_key="mcp",
)
def log_mcp_read(role: str, entity_type: str, entity_name: str, description: str) -> None:
"""Loggt eine Leseoperation via MCP (als 'export'-Aktion)."""
log_mcp_action(
role=role,
action="export",
entity_type=entity_type,
entity_id="",
entity_name=entity_name,
description=description,
)
def log_mcp_create(
role: str, entity_type: str, entity_id: str, entity_name: str
) -> None:
"""Loggt eine Erstellungsoperation via MCP."""
log_mcp_action(
role=role,
action="create",
entity_type=entity_type,
entity_id=entity_id,
entity_name=entity_name,
description=f"[MCP] {entity_type} '{entity_name}' erstellt",
)
def log_mcp_update(
role: str, entity_type: str, entity_id: str, entity_name: str, changes: dict
) -> None:
"""Loggt eine Aktualisierungsoperation via MCP."""
changed_fields = ", ".join(changes.keys()) if changes else ""
log_mcp_action(
role=role,
action="update",
entity_type=entity_type,
entity_id=entity_id,
entity_name=entity_name,
description=f"[MCP] {entity_type} '{entity_name}' aktualisiert: {changed_fields}",
changes=changes,
)

70
app/mcp_server/auth.py Normal file
View File

@@ -0,0 +1,70 @@
"""
MCP-Authentifizierung Token-basierte Authentifizierung mit 3 Rollen.
Tokens werden über Umgebungsvariablen konfiguriert:
MCP_TOKEN_READONLY Nur-Lese-Zugriff (alle Daten, PII maskiert)
MCP_TOKEN_EDITOR Lesen + Schreiben (PII maskiert)
MCP_TOKEN_ADMIN Voll-Zugriff (keine PII-Maskierung, alle Schreib-Ops)
Das aktive Token wird per MCP_AUTH_TOKEN übergeben (wird vom MCP-Client gesetzt).
"""
import os
ROLE_READONLY = "readonly"
ROLE_EDITOR = "editor"
ROLE_ADMIN = "admin"
# Rollenrangfolge (höher = mehr Rechte)
ROLE_RANK = {ROLE_READONLY: 1, ROLE_EDITOR: 2, ROLE_ADMIN: 3}
def _token_map() -> dict[str, str]:
"""Erstellt Mapping token → Rolle aus Umgebungsvariablen."""
mapping: dict[str, str] = {}
for role, env_var in [
(ROLE_READONLY, "MCP_TOKEN_READONLY"),
(ROLE_EDITOR, "MCP_TOKEN_EDITOR"),
(ROLE_ADMIN, "MCP_TOKEN_ADMIN"),
]:
token = os.environ.get(env_var, "").strip()
if token:
mapping[token] = role
return mapping
def get_role_for_token(token: str) -> str | None:
"""
Gibt die Rolle für einen Token zurück oder None bei ungültigem Token.
"""
if not token:
return None
return _token_map().get(token)
def get_current_role() -> str | None:
"""
Gibt die Rolle des aktuell gesetzten MCP_AUTH_TOKEN zurück.
Wird vom Server beim Start einmalig ausgewertet.
"""
token = os.environ.get("MCP_AUTH_TOKEN", "").strip()
return get_role_for_token(token)
def can_write(role: str | None) -> bool:
"""Darf die Rolle Schreiboperationen ausführen?"""
return role in (ROLE_EDITOR, ROLE_ADMIN)
def can_read_unmasked(role: str | None) -> bool:
"""Darf die Rolle ungemaskierte PII-Daten lesen?"""
return role == ROLE_ADMIN
def require_role(role: str | None) -> None:
"""Wirft ValueError wenn keine gültige Rolle vorhanden."""
if not role:
raise ValueError(
"Ungültiger oder fehlender MCP_AUTH_TOKEN. "
"Bitte MCP_AUTH_TOKEN-Umgebungsvariable setzen."
)

152
app/mcp_server/privacy.py Normal file
View File

@@ -0,0 +1,152 @@
"""
PII-Maskierung für MCP-Ausgaben.
Bei readonly- und editor-Rollen werden folgende Felder maskiert:
- iban → "****" + letzte 4 Stellen
- email → "***@" + Domain
- telefon → "****" + letzte 4 Ziffern
- geburtsdatum → nur Jahreszahl
- jaehrliches_einkommen / monatliche_bezuege / vermoegen → Bereichsangabe
Admin-Rolle erhält ungemaskierte Daten.
"""
import re
from decimal import Decimal
def mask_iban(value: str | None) -> str | None:
if not value:
return value
clean = value.replace(" ", "")
if len(clean) > 4:
return "****" + clean[-4:]
return "****"
def mask_email(value: str | None) -> str | None:
if not value:
return value
parts = value.split("@", 1)
if len(parts) == 2:
return "***@" + parts[1]
return "***"
def mask_telefon(value: str | None) -> str | None:
if not value:
return value
digits = re.sub(r"\D", "", value)
if len(digits) > 4:
return "****" + digits[-4:]
return "****"
def mask_geburtsdatum(value) -> str | None:
"""Zeigt nur das Jahr des Geburtsdatums."""
if not value:
return None
try:
return str(value)[:4] # "YYYY-MM-DD" → "YYYY"
except Exception:
return None
def mask_einkommen(value) -> str | None:
"""Gibt Einkommensbereich statt genauen Wert zurück."""
if value is None:
return None
try:
amount = float(value)
if amount < 10000:
return "< 10.000 €"
elif amount < 20000:
return "10.00020.000 €"
elif amount < 30000:
return "20.00030.000 €"
elif amount < 50000:
return "30.00050.000 €"
elif amount < 75000:
return "50.00075.000 €"
else:
return "> 75.000 €"
except (TypeError, ValueError):
return None
def mask_monatsbezuege(value) -> str | None:
"""Gibt Monatsbezüge-Bereich statt genauen Wert zurück."""
if value is None:
return None
try:
amount = float(value)
if amount < 500:
return "< 500 €/Mon."
elif amount < 1000:
return "5001.000 €/Mon."
elif amount < 2000:
return "1.0002.000 €/Mon."
elif amount < 3000:
return "2.0003.000 €/Mon."
else:
return "> 3.000 €/Mon."
except (TypeError, ValueError):
return None
# PII-Felder nach Modell
PII_FIELDS: dict[str, dict] = {
"destinataer": {
"iban": mask_iban,
"email": mask_email,
"telefon": mask_telefon,
"geburtsdatum": mask_geburtsdatum,
"jaehrliches_einkommen": mask_einkommen,
"monatliche_bezuege": mask_monatsbezuege,
"vermoegen": mask_einkommen,
},
"paechter": {
"iban": mask_iban,
"email": mask_email,
"telefon": mask_telefon,
"geburtsdatum": mask_geburtsdatum,
},
"rentmeister": {
"iban": mask_iban,
"email": mask_email,
"telefon": mask_telefon,
},
}
def apply_privacy_filter(data: dict, model_type: str, role: str) -> dict:
"""
Maskiert PII-Felder in einem Daten-Dictionary basierend auf Rolle und Modelltyp.
Args:
data: Rohdaten-Dictionary
model_type: Modelltyp (z.B. "destinataer", "paechter")
role: Aktuelle Rolle ("readonly", "editor", "admin")
Returns:
Gefiltertes Dictionary (bei admin: unveränderter Input)
"""
from .auth import can_read_unmasked
if can_read_unmasked(role):
return data
maskers = PII_FIELDS.get(model_type, {})
if not maskers:
return data
result = dict(data)
for field, mask_fn in maskers.items():
if field in result:
result[field] = mask_fn(result[field])
return result
def apply_privacy_filter_list(items: list[dict], model_type: str, role: str) -> list[dict]:
"""Wendet apply_privacy_filter auf eine Liste von Dicts an."""
return [apply_privacy_filter(item, model_type, role) for item in items]

151
app/mcp_server/server.py Normal file
View File

@@ -0,0 +1,151 @@
"""
MCP Server für die Stiftungsverwaltung.
Startmodus:
python -m mcp_server.server
Konfiguration über Umgebungsvariablen:
MCP_AUTH_TOKEN Aktiver Zugriffstoken (vom MCP-Client gesetzt)
MCP_TOKEN_READONLY Token für readonly-Rolle
MCP_TOKEN_EDITOR Token für editor-Rolle
MCP_TOKEN_ADMIN Token für admin-Rolle
DJANGO_SETTINGS_MODULE Django-Settings (Standard: core.settings)
DB_HOST, DB_PORT, POSTGRES_DB, POSTGRES_USER, POSTGRES_PASSWORD DB-Verbindung
"""
import logging
import os
import sys
# ──────────────────────────────────────────────────────────────────────────────
# Django Standalone-Setup (ORM ohne HTTP-Server)
# ──────────────────────────────────────────────────────────────────────────────
# Pfad zum app/-Verzeichnis in sys.path aufnehmen (damit Imports funktionieren)
_app_dir = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
if _app_dir not in sys.path:
sys.path.insert(0, _app_dir)
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "core.settings")
import django # noqa: E402
django.setup()
# ──────────────────────────────────────────────────────────────────────────────
# Logging
# ──────────────────────────────────────────────────────────────────────────────
logging.basicConfig(
level=logging.WARNING,
format="%(asctime)s %(levelname)s %(name)s: %(message)s",
stream=sys.stderr,
)
logger = logging.getLogger("mcp_server")
# ──────────────────────────────────────────────────────────────────────────────
# Auth-Check vor Server-Start
# ──────────────────────────────────────────────────────────────────────────────
from mcp_server.auth import get_current_role, require_role # noqa: E402
_current_role = get_current_role()
try:
require_role(_current_role)
except ValueError as exc:
logger.error("MCP Auth-Fehler: %s", exc)
sys.exit(1)
logger.info("MCP Server startet mit Rolle: %s", _current_role)
# ──────────────────────────────────────────────────────────────────────────────
# MCP Server Initialisierung
# ──────────────────────────────────────────────────────────────────────────────
from mcp.server.fastmcp import FastMCP # noqa: E402
mcp = FastMCP(
"Stiftungsverwaltung",
instructions=(
"MCP-Server der gemeinnützigen Familienstiftung. "
f"Aktive Rolle: {_current_role}. "
"Lese-Zugriff auf alle Stiftungsdaten. "
+ ("Schreib-Zugriff aktiv. " if _current_role in ("editor", "admin") else "")
+ "PII-Felder werden bei readonly/editor maskiert."
),
)
# ──────────────────────────────────────────────────────────────────────────────
# Lese-Tools registrieren (alle Rollen)
# ──────────────────────────────────────────────────────────────────────────────
from mcp_server.tools.lesen import ( # noqa: E402
dashboard,
destinataer_details,
destinataer_suchen,
dokument_details,
dokument_suchen,
globale_suche,
konten_uebersicht,
land_details,
land_suchen,
paechter_suchen,
statistiken,
termine_anzeigen,
transaktionen_suchen,
verwaltungskosten,
)
mcp.tool()(destinataer_suchen)
mcp.tool()(destinataer_details)
mcp.tool()(land_suchen)
mcp.tool()(land_details)
mcp.tool()(paechter_suchen)
mcp.tool()(konten_uebersicht)
mcp.tool()(verwaltungskosten)
mcp.tool()(transaktionen_suchen)
mcp.tool()(dokument_suchen)
mcp.tool()(dokument_details)
mcp.tool()(termine_anzeigen)
mcp.tool()(globale_suche)
mcp.tool()(dashboard)
mcp.tool()(statistiken)
# ──────────────────────────────────────────────────────────────────────────────
# Schreib-Tools registrieren (nur editor/admin)
# ──────────────────────────────────────────────────────────────────────────────
from mcp_server.auth import can_write # noqa: E402
if can_write(_current_role):
from mcp_server.tools.schreiben import ( # noqa: E402
destinataer_aktualisieren,
destinataer_anlegen,
dokument_verknuepfen,
foerderung_anlegen,
land_anlegen,
paechter_anlegen,
termin_anlegen,
unterstuetzung_anlegen,
verpachtung_anlegen,
verwaltungskosten_erfassen,
)
mcp.tool()(destinataer_anlegen)
mcp.tool()(destinataer_aktualisieren)
mcp.tool()(foerderung_anlegen)
mcp.tool()(unterstuetzung_anlegen)
mcp.tool()(land_anlegen)
mcp.tool()(verpachtung_anlegen)
mcp.tool()(paechter_anlegen)
mcp.tool()(verwaltungskosten_erfassen)
mcp.tool()(termin_anlegen)
mcp.tool()(dokument_verknuepfen)
# ──────────────────────────────────────────────────────────────────────────────
# Server starten
# ──────────────────────────────────────────────────────────────────────────────
if __name__ == "__main__":
mcp.run(transport="stdio")

View File

@@ -0,0 +1 @@
# MCP Tools

View File

@@ -0,0 +1,59 @@
"""
Hilfsfunktionen für MCP-Tool-Implementierungen.
"""
from __future__ import annotations
import json
from datetime import date, datetime, time
from decimal import Decimal
from uuid import UUID
def serialize_value(value):
"""Konvertiert Django-Feldwerte in JSON-serialisierbare Typen."""
if isinstance(value, UUID):
return str(value)
if isinstance(value, Decimal):
return float(value)
if isinstance(value, (date, datetime)):
return value.isoformat()
if isinstance(value, time):
return value.isoformat()
return value
def model_to_dict(instance, fields=None, exclude=None) -> dict:
"""
Konvertiert eine Django-Model-Instanz in ein serialisierbares Dict.
Args:
instance: Django Model Instanz
fields: Nur diese Felder einschließen (None = alle)
exclude: Diese Felder ausschließen
"""
exclude = exclude or []
result = {}
for field in instance._meta.fields:
name = field.name
if fields and name not in fields:
continue
if name in exclude:
continue
value = getattr(instance, name)
# ForeignKey: _id-Suffix-Wert (nicht ganzes Objekt)
result[name] = serialize_value(value)
# Auch ForeignKey-IDs explizit aufnehmen (z.B. konto_id)
for field in instance._meta.fields:
if hasattr(field, "attname") and field.attname != field.name:
attname = field.attname
if attname not in result:
result[attname] = serialize_value(getattr(instance, attname))
return result
def format_result(data) -> str:
"""Gibt Daten als formatiertes JSON zurück."""
return json.dumps(data, ensure_ascii=False, indent=2, default=str)

View File

@@ -0,0 +1,755 @@
"""
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,
},
})

View File

@@ -0,0 +1,577 @@
"""
Schreib-Tools für den MCP Server der Stiftungsverwaltung.
Alle Tools:
- Prüfen die Rolle (editor oder admin erforderlich)
- Schreiben Audit-Log-Einträge
- Validieren Pflichtfelder vor dem Speichern
"""
from __future__ import annotations
from mcp_server.audit import log_mcp_create, log_mcp_update
from mcp_server.auth import can_write, require_role
from mcp_server.tools.helpers import format_result
def _require_write_role() -> str:
from mcp_server.auth import get_current_role
role = get_current_role()
require_role(role)
if not can_write(role):
raise PermissionError(
f"Rolle '{role}' hat keine Schreibrechte. "
"editor- oder admin-Rolle erforderlich."
)
return role
# ──────────────────────────────────────────────────────────────────────────────
# Destinatäre
# ──────────────────────────────────────────────────────────────────────────────
def destinataer_anlegen(
vorname: str,
nachname: str,
familienzweig: str = "",
email: str = "",
telefon: str = "",
geburtsdatum: str = "",
ort: str = "",
plz: str = "",
strasse: str = "",
berufsgruppe: str = "",
notizen: str = "",
) -> str:
"""
Legt einen neuen Destinatär an.
Args:
vorname: Vorname (Pflichtfeld)
nachname: Nachname (Pflichtfeld)
familienzweig: hauptzweig/nebenzweig/verwandt/anderer
email: E-Mail-Adresse
telefon: Telefonnummer
geburtsdatum: Geburtsdatum YYYY-MM-DD
ort: Wohnort
plz: Postleitzahl
strasse: Straße und Hausnummer
berufsgruppe: student/wissenschaftler/künstler/sozialarbeiter/umweltschützer/andere
notizen: Freitext-Notizen
"""
from stiftung.models import Destinataer
role = _require_write_role()
kwargs = {
"vorname": vorname.strip(),
"nachname": nachname.strip(),
"aktiv": True,
}
if familienzweig:
kwargs["familienzweig"] = familienzweig
if email:
kwargs["email"] = email
if telefon:
kwargs["telefon"] = telefon
if geburtsdatum:
kwargs["geburtsdatum"] = geburtsdatum
if ort:
kwargs["ort"] = ort
if plz:
kwargs["plz"] = plz
if strasse:
kwargs["strasse"] = strasse
if berufsgruppe:
kwargs["berufsgruppe"] = berufsgruppe
if notizen:
kwargs["notizen"] = notizen
obj = Destinataer.objects.create(**kwargs)
log_mcp_create(role, "destinataer", str(obj.id), f"{vorname} {nachname}")
return format_result({"erfolg": True, "id": str(obj.id), "name": f"{vorname} {nachname}"})
def destinataer_aktualisieren(
destinataer_id: str,
vorname: str = "",
nachname: str = "",
email: str = "",
telefon: str = "",
ort: str = "",
plz: str = "",
strasse: str = "",
aktiv: bool | None = None,
notizen: str = "",
familienzweig: str = "",
) -> str:
"""
Aktualisiert einen bestehenden Destinatär.
Args:
destinataer_id: UUID des Destinatärs (Pflichtfeld)
vorname: Neuer Vorname (optional)
nachname: Neuer Nachname (optional)
email: Neue E-Mail (optional)
telefon: Neue Telefonnummer (optional)
ort: Neuer Ort (optional)
plz: Neue PLZ (optional)
strasse: Neue Straße (optional)
aktiv: Aktivstatus (optional)
notizen: Neue Notizen (optional)
familienzweig: Neuer Familienzweig (optional)
"""
from stiftung.models import Destinataer
role = _require_write_role()
try:
obj = Destinataer.objects.get(id=destinataer_id)
except Destinataer.DoesNotExist:
return format_result({"fehler": f"Destinatär {destinataer_id} nicht gefunden"})
changes = {}
update_fields = []
def _set(field, value):
if value != "" and value is not None:
old = getattr(obj, field)
if str(old) != str(value):
changes[field] = {"alt": str(old), "neu": str(value)}
setattr(obj, field, value)
update_fields.append(field)
_set("vorname", vorname)
_set("nachname", nachname)
_set("email", email)
_set("telefon", telefon)
_set("ort", ort)
_set("plz", plz)
_set("strasse", strasse)
_set("notizen", notizen)
_set("familienzweig", familienzweig)
if aktiv is not None:
_set("aktiv", aktiv)
if not update_fields:
return format_result({"erfolg": True, "hinweis": "Keine Änderungen"})
obj.save(update_fields=update_fields)
name = f"{obj.vorname} {obj.nachname}"
log_mcp_update(role, "destinataer", str(obj.id), name, changes)
return format_result({"erfolg": True, "id": str(obj.id), "geaenderte_felder": list(changes.keys())})
# ──────────────────────────────────────────────────────────────────────────────
# Förderungen & Unterstützungen
# ──────────────────────────────────────────────────────────────────────────────
def foerderung_anlegen(
destinataer_id: str,
jahr: int,
betrag: float,
kategorie: str = "anderes",
bemerkungen: str = "",
) -> str:
"""
Legt eine neue Förderung für einen Destinatär an.
Args:
destinataer_id: UUID des Destinatärs (Pflichtfeld)
jahr: Förderjahr (Pflichtfeld)
betrag: Förderbetrag in EUR (Pflichtfeld)
kategorie: bildung/forschung/kultur/soziales/umwelt/anderes
bemerkungen: Freitext
"""
from datetime import date
from stiftung.models import Destinataer, Foerderung
role = _require_write_role()
try:
destinataer = Destinataer.objects.get(id=destinataer_id)
except Destinataer.DoesNotExist:
return format_result({"fehler": f"Destinatär {destinataer_id} nicht gefunden"})
foerderung = Foerderung.objects.create(
destinataer=destinataer,
jahr=jahr,
betrag=betrag,
kategorie=kategorie,
bemerkungen=bemerkungen,
status="beantragt",
antragsdatum=date.today(),
)
name = f"{destinataer.vorname} {destinataer.nachname} {jahr}"
log_mcp_create(role, "foerderung", str(foerderung.id), name)
return format_result({"erfolg": True, "id": str(foerderung.id), "foerderung": name})
def unterstuetzung_anlegen(
destinataer_id: str,
konto_id: str,
betrag: float,
faellig_am: str,
beschreibung: str = "",
verwendungszweck: str = "",
) -> str:
"""
Legt eine neue Unterstützungszahlung für einen Destinatär an.
Args:
destinataer_id: UUID des Destinatärs (Pflichtfeld)
konto_id: UUID des Zahlungskontos (Pflichtfeld)
betrag: Betrag in EUR (Pflichtfeld)
faellig_am: Fälligkeitsdatum YYYY-MM-DD (Pflichtfeld)
beschreibung: Kurzbeschreibung (optional)
verwendungszweck: Verwendungszweck für Überweisung (optional)
"""
from stiftung.models import Destinataer, DestinataerUnterstuetzung, StiftungsKonto
role = _require_write_role()
try:
destinataer = Destinataer.objects.get(id=destinataer_id)
except Destinataer.DoesNotExist:
return format_result({"fehler": f"Destinatär {destinataer_id} nicht gefunden"})
try:
konto = StiftungsKonto.objects.get(id=konto_id)
except StiftungsKonto.DoesNotExist:
return format_result({"fehler": f"Konto {konto_id} nicht gefunden"})
unterstuetzung = DestinataerUnterstuetzung.objects.create(
destinataer=destinataer,
konto=konto,
betrag=betrag,
faellig_am=faellig_am,
beschreibung=beschreibung,
verwendungszweck=verwendungszweck,
status="geplant",
)
name = f"{destinataer.vorname} {destinataer.nachname} {faellig_am}"
log_mcp_create(role, "destinataer", str(unterstuetzung.id), name)
return format_result({"erfolg": True, "id": str(unterstuetzung.id)})
# ──────────────────────────────────────────────────────────────────────────────
# Ländereien & Verpachtungen
# ──────────────────────────────────────────────────────────────────────────────
def land_anlegen(
lfd_nr: str,
amtsgericht: str,
gemeinde: str,
gemarkung: str,
flur: str,
flurstueck: str,
groesse_qm: float,
verpachtete_gesamtflaeche: float = 0.0,
adresse: str = "",
) -> str:
"""
Legt eine neue Länderei an.
Args:
lfd_nr: Laufende Nummer (Pflichtfeld, eindeutig)
amtsgericht: Zuständiges Amtsgericht (Pflichtfeld)
gemeinde: Gemeinde (Pflichtfeld)
gemarkung: Gemarkung (Pflichtfeld)
flur: Flur (Pflichtfeld)
flurstueck: Flurstück (Pflichtfeld)
groesse_qm: Gesamtgröße in Quadratmetern (Pflichtfeld)
verpachtete_gesamtflaeche: Verpachtete Fläche in qm (Standard: 0)
adresse: Adresse/Ortsangabe (optional)
"""
from stiftung.models import Land
role = _require_write_role()
if Land.objects.filter(lfd_nr=lfd_nr).exists():
return format_result({"fehler": f"Länderei mit lfd_nr '{lfd_nr}' existiert bereits"})
land = Land.objects.create(
lfd_nr=lfd_nr,
amtsgericht=amtsgericht,
gemeinde=gemeinde,
gemarkung=gemarkung,
flur=flur,
flurstueck=flurstueck,
groesse_qm=groesse_qm,
verpachtete_gesamtflaeche=verpachtete_gesamtflaeche,
adresse=adresse,
)
log_mcp_create(role, "land", str(land.id), str(land))
return format_result({"erfolg": True, "id": str(land.id), "bezeichnung": str(land)})
def verpachtung_anlegen(
land_id: str,
paechter_id: str,
vertragsnummer: str,
pachtbeginn: str,
verpachtete_flaeche: float,
pachtzins_pauschal: float,
zahlungsweise: str = "jaehrlich",
pachtende: str = "",
) -> str:
"""
Legt einen neuen Pachtvertrag für eine Länderei an.
Args:
land_id: UUID der Länderei (Pflichtfeld)
paechter_id: UUID des Pächters (Pflichtfeld)
vertragsnummer: Eindeutige Vertragsnummer (Pflichtfeld)
pachtbeginn: Datum YYYY-MM-DD (Pflichtfeld)
verpachtete_flaeche: Fläche in qm (Pflichtfeld)
pachtzins_pauschal: Jährlicher Pachtzins in EUR (Pflichtfeld)
zahlungsweise: jaehrlich/halbjaehrlich/vierteljaehrlich/monatlich
pachtende: Datum YYYY-MM-DD (optional)
"""
from stiftung.models import Land, LandVerpachtung, Paechter
role = _require_write_role()
try:
land = Land.objects.get(id=land_id)
except Land.DoesNotExist:
return format_result({"fehler": f"Länderei {land_id} nicht gefunden"})
try:
paechter = Paechter.objects.get(id=paechter_id)
except Paechter.DoesNotExist:
return format_result({"fehler": f"Pächter {paechter_id} nicht gefunden"})
if LandVerpachtung.objects.filter(vertragsnummer=vertragsnummer).exists():
return format_result({"fehler": f"Vertragsnummer '{vertragsnummer}' existiert bereits"})
kwargs = {
"land": land,
"paechter": paechter,
"vertragsnummer": vertragsnummer,
"pachtbeginn": pachtbeginn,
"verpachtete_flaeche": verpachtete_flaeche,
"pachtzins_pauschal": pachtzins_pauschal,
"zahlungsweise": zahlungsweise,
"status": "aktiv",
}
if pachtende:
kwargs["pachtende"] = pachtende
verpachtung = LandVerpachtung.objects.create(**kwargs)
name = f"{land} {paechter}"
log_mcp_create(role, "verpachtung", str(verpachtung.id), name)
return format_result({"erfolg": True, "id": str(verpachtung.id)})
def paechter_anlegen(
vorname: str,
nachname: str,
email: str = "",
telefon: str = "",
ort: str = "",
plz: str = "",
strasse: str = "",
personentyp: str = "natuerlich",
) -> str:
"""
Legt einen neuen Pächter an.
Args:
vorname: Vorname (Pflichtfeld)
nachname: Nachname (Pflichtfeld)
email: E-Mail (optional)
telefon: Telefon (optional)
ort: Ort (optional)
plz: Postleitzahl (optional)
strasse: Straße (optional)
personentyp: natuerlich/gesellschaft
"""
from stiftung.models import Paechter
role = _require_write_role()
kwargs = {
"vorname": vorname.strip(),
"nachname": nachname.strip(),
"personentyp": personentyp,
}
for field, value in [("email", email), ("telefon", telefon), ("ort", ort), ("plz", plz), ("strasse", strasse)]:
if value:
kwargs[field] = value
paechter = Paechter.objects.create(**kwargs)
name = f"{vorname} {nachname}"
log_mcp_create(role, "paechter", str(paechter.id), name)
return format_result({"erfolg": True, "id": str(paechter.id), "name": name})
# ──────────────────────────────────────────────────────────────────────────────
# Verwaltungskosten
# ──────────────────────────────────────────────────────────────────────────────
def verwaltungskosten_erfassen(
bezeichnung: str,
kategorie: str,
betrag: float,
datum: str,
lieferant_firma: str = "",
rechnungsnummer: str = "",
status: str = "geplant",
) -> str:
"""
Erfasst eine neue Verwaltungskosten-Position.
Args:
bezeichnung: Bezeichnung (Pflichtfeld)
kategorie: rechnung_intern/bueroausstattung/fahrtkosten/porto/telefon_internet/
software/beratung/versicherung/steuerberatung/bankgebuehren/sonstiges
betrag: Betrag in EUR (Pflichtfeld)
datum: Datum YYYY-MM-DD (Pflichtfeld)
lieferant_firma: Lieferant oder Firma (optional)
rechnungsnummer: Rechnungsnummer (optional)
status: geplant/bestellt/erhalten/in_bearbeitung/bezahlt/storniert
"""
from stiftung.models import Verwaltungskosten
role = _require_write_role()
vk = Verwaltungskosten.objects.create(
bezeichnung=bezeichnung,
kategorie=kategorie,
betrag=betrag,
datum=datum,
lieferant_firma=lieferant_firma,
rechnungsnummer=rechnungsnummer,
status=status,
)
log_mcp_create(role, "verwaltungskosten", str(vk.id), bezeichnung)
return format_result({"erfolg": True, "id": str(vk.id), "bezeichnung": bezeichnung})
# ──────────────────────────────────────────────────────────────────────────────
# Termine
# ──────────────────────────────────────────────────────────────────────────────
def termin_anlegen(
titel: str,
datum: str,
kategorie: str = "termin",
prioritaet: str = "normal",
beschreibung: str = "",
uhrzeit: str = "",
ganztags: bool = True,
destinataer_id: str = "",
) -> str:
"""
Legt einen neuen Kalendertermin an.
Args:
titel: Titel des Termins (Pflichtfeld)
datum: Datum YYYY-MM-DD (Pflichtfeld)
kategorie: termin/zahlung/deadline/geburtstag/vertrag/pruefung/sonstiges
prioritaet: niedrig/normal/hoch/kritisch
beschreibung: Beschreibung (optional)
uhrzeit: Uhrzeit HH:MM (optional)
ganztags: Ganztägig (Standard: True)
destinataer_id: UUID eines zugehörigen Destinatärs (optional)
"""
from stiftung.models import Destinataer, StiftungsKalenderEintrag
role = _require_write_role()
kwargs = {
"titel": titel,
"datum": datum,
"kategorie": kategorie,
"prioritaet": prioritaet,
"beschreibung": beschreibung,
"ganztags": ganztags,
}
if uhrzeit:
kwargs["uhrzeit"] = uhrzeit
kwargs["ganztags"] = False
if destinataer_id:
try:
kwargs["destinataer"] = Destinataer.objects.get(id=destinataer_id)
except Destinataer.DoesNotExist:
return format_result({"fehler": f"Destinatär {destinataer_id} nicht gefunden"})
termin = StiftungsKalenderEintrag.objects.create(**kwargs)
log_mcp_create(role, "system", str(termin.id), titel)
return format_result({"erfolg": True, "id": str(termin.id), "titel": titel, "datum": datum})
# ──────────────────────────────────────────────────────────────────────────────
# Dokument verknüpfen
# ──────────────────────────────────────────────────────────────────────────────
def dokument_verknuepfen(
dokument_id: str,
land_id: str = "",
paechter_id: str = "",
destinataer_id: str = "",
) -> str:
"""
Verknüpft ein vorhandenes Dokument mit einer Länderei, einem Pächter oder Destinatär.
Args:
dokument_id: UUID des Dokuments (Pflichtfeld)
land_id: UUID der Länderei (optional)
paechter_id: UUID des Pächters (optional)
destinataer_id: UUID des Destinatärs (optional)
"""
from stiftung.models import DokumentDatei
role = _require_write_role()
try:
dokument = DokumentDatei.objects.get(id=dokument_id)
except DokumentDatei.DoesNotExist:
return format_result({"fehler": f"Dokument {dokument_id} nicht gefunden"})
changes = {}
update_fields = []
if land_id:
from stiftung.models import Land
try:
land = Land.objects.get(id=land_id)
dokument.land = land
update_fields.append("land")
changes["land"] = {"neu": str(land)}
except Land.DoesNotExist:
return format_result({"fehler": f"Länderei {land_id} nicht gefunden"})
if paechter_id:
from stiftung.models import Paechter
try:
paechter = Paechter.objects.get(id=paechter_id)
dokument.paechter = paechter
update_fields.append("paechter")
changes["paechter"] = {"neu": str(paechter)}
except Paechter.DoesNotExist:
return format_result({"fehler": f"Pächter {paechter_id} nicht gefunden"})
if destinataer_id:
from stiftung.models import Destinataer
try:
dest = Destinataer.objects.get(id=destinataer_id)
dokument.destinataer = dest
update_fields.append("destinataer")
changes["destinataer"] = {"neu": f"{dest.vorname} {dest.nachname}"}
except Destinataer.DoesNotExist:
return format_result({"fehler": f"Destinatär {destinataer_id} nicht gefunden"})
if not update_fields:
return format_result({"fehler": "Keine Verknüpfung angegeben (land_id, paechter_id oder destinataer_id)"})
dokument.save(update_fields=update_fields)
log_mcp_update(role, "dokumentlink", str(dokument.id), dokument.titel, changes)
return format_result({"erfolg": True, "id": str(dokument.id), "verknuepft_mit": list(changes.keys())})

View File

@@ -14,3 +14,5 @@ django-otp==1.2.4
django-htmx==1.19.0 django-htmx==1.19.0
qrcode[pil]==7.4.2 qrcode[pil]==7.4.2
schwifty==2026.3.0 schwifty==2026.3.0
mcp>=1.0.0
httpx>=0.27.0

View File

@@ -7,6 +7,7 @@ from . import foerderung # noqa: F401
from . import dokumente # noqa: F401 from . import dokumente # noqa: F401
from . import veranstaltung # noqa: F401 from . import veranstaltung # noqa: F401
from . import system # noqa: F401 from . import system # noqa: F401
from stiftung.agent import admin as agent_admin # noqa: F401
# Customize admin site # Customize admin site
admin.site.site_header = "Stiftungsverwaltung Administration" admin.site.site_header = "Stiftungsverwaltung Administration"

View File

View File

@@ -0,0 +1,64 @@
"""
Django Admin für den AI Agent.
Erreichbar unter /administration/agent/
"""
from django.contrib import admin
from django.utils.html import format_html
from .models import AgentConfig, ChatSession, ChatMessage
@admin.register(AgentConfig)
class AgentConfigAdmin(admin.ModelAdmin):
fieldsets = (
("Provider", {
"fields": ("provider", "model_name", "ollama_url"),
}),
("API-Keys (externe Provider)", {
"fields": ("openai_api_key", "anthropic_api_key"),
"classes": ("collapse",),
"description": "Nur ausfüllen wenn nicht Ollama verwendet wird.",
}),
("Verhalten", {
"fields": ("system_prompt", "allow_write", "chat_retention_days"),
}),
)
def has_add_permission(self, request):
# Singleton: Hinzufügen nur wenn noch keine Config existiert
return not AgentConfig.objects.exists()
def has_delete_permission(self, request, obj=None):
return False
class ChatMessageInline(admin.TabularInline):
model = ChatMessage
fields = ("role", "content_preview", "tool_name", "created_at")
readonly_fields = ("role", "content_preview", "tool_name", "created_at")
extra = 0
can_delete = False
ordering = ["created_at"]
def content_preview(self, obj):
return obj.content[:120] + ("" if len(obj.content) > 120 else "")
content_preview.short_description = "Inhalt"
@admin.register(ChatSession)
class ChatSessionAdmin(admin.ModelAdmin):
list_display = ("title_or_id", "user", "message_count", "created_at", "updated_at")
list_filter = ("user",)
search_fields = ("title", "user__username")
readonly_fields = ("id", "user", "created_at", "updated_at")
inlines = [ChatMessageInline]
ordering = ["-updated_at"]
def title_or_id(self, obj):
return obj.title or str(obj.id)[:12]
title_or_id.short_description = "Sitzung"
def message_count(self, obj):
return obj.messages.count()
message_count.short_description = "Nachrichten"

View File

@@ -0,0 +1,166 @@
"""
AI Agent Models: AgentConfig (Singleton), ChatSession, ChatMessage.
"""
import uuid
from django.contrib.auth.models import User
from django.db import models
DEFAULT_SYSTEM_PROMPT = """Du bist RentmeisterAI, der KI-Assistent der van Hees-Theyssen-Vogel'schen Stiftung.
Du hast Zugriff auf die Stiftungsdatenbank und kannst Informationen zu Destinatären, Ländereien, \
Finanzen, Förderungen und weiteren Stiftungsdaten abrufen.
Regeln:
- Antworte stets auf Deutsch, präzise und sachlich.
- Schütze personenbezogene Daten gib keine unnötigen Details heraus.
- Du kannst keine Daten ändern, nur lesen.
- Bei rechtlichen oder steuerlichen Fragen weise auf Fachberatung hin.
- Wenn du dir unsicher bist, sage das klar.
"""
class AgentConfig(models.Model):
"""Singleton-Konfiguration für den AI Agent."""
PROVIDER_CHOICES = [
("ollama", "Ollama (lokal)"),
("openai", "OpenAI"),
("anthropic", "Anthropic"),
]
provider = models.CharField(
max_length=20,
choices=PROVIDER_CHOICES,
default="ollama",
verbose_name="LLM-Provider",
)
model_name = models.CharField(
max_length=100,
default="qwen2.5:3b",
verbose_name="Modell-Name",
)
ollama_url = models.CharField(
max_length=255,
default="http://ollama:11434",
verbose_name="Ollama-URL",
)
openai_api_key = models.CharField(
max_length=255,
blank=True,
verbose_name="OpenAI API-Key",
help_text="Nur erforderlich wenn Provider = OpenAI",
)
anthropic_api_key = models.CharField(
max_length=255,
blank=True,
verbose_name="Anthropic API-Key",
help_text="Nur erforderlich wenn Provider = Anthropic",
)
system_prompt = models.TextField(
default=DEFAULT_SYSTEM_PROMPT,
verbose_name="System-Prompt",
)
allow_write = models.BooleanField(
default=False,
verbose_name="Schreib-Tools erlaubt",
help_text="Achtung: Schreib-Zugriff auf Stiftungsdaten aktivieren",
)
chat_retention_days = models.IntegerField(
default=30,
verbose_name="Chat-Verlauf Aufbewahrung (Tage)",
)
class Meta:
verbose_name = "Agent-Konfiguration"
verbose_name_plural = "Agent-Konfiguration"
def __str__(self):
return f"Agent Config ({self.get_provider_display()} / {self.model_name})"
def save(self, *args, **kwargs):
# Singleton: always use pk=1
self.pk = 1
super().save(*args, **kwargs)
def delete(self, *args, **kwargs):
pass # Singleton cannot be deleted
@classmethod
def get_config(cls):
config, _ = cls.objects.get_or_create(pk=1)
return config
class ChatSession(models.Model):
"""Chat-Sitzung eines Benutzers mit dem AI Agent."""
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
user = models.ForeignKey(
User,
on_delete=models.CASCADE,
related_name="agent_sessions",
verbose_name="Benutzer",
)
title = models.CharField(
max_length=200,
blank=True,
verbose_name="Titel",
help_text="Automatisch aus erster Nachricht generiert",
)
created_at = models.DateTimeField(auto_now_add=True, verbose_name="Erstellt")
updated_at = models.DateTimeField(auto_now=True, verbose_name="Zuletzt aktiv")
class Meta:
verbose_name = "Chat-Sitzung"
verbose_name_plural = "Chat-Sitzungen"
ordering = ["-updated_at"]
def __str__(self):
return f"{self.user.username} {self.title or str(self.id)[:8]} ({self.created_at.strftime('%d.%m.%Y')})"
def message_count(self):
return self.messages.count()
class ChatMessage(models.Model):
"""Einzelne Nachricht in einer Chat-Sitzung."""
ROLE_CHOICES = [
("user", "Benutzer"),
("assistant", "Assistent"),
("tool", "Tool-Ergebnis"),
]
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
session = models.ForeignKey(
ChatSession,
on_delete=models.CASCADE,
related_name="messages",
verbose_name="Sitzung",
)
role = models.CharField(
max_length=20,
choices=ROLE_CHOICES,
verbose_name="Rolle",
)
content = models.TextField(verbose_name="Inhalt")
tool_name = models.CharField(
max_length=100,
blank=True,
verbose_name="Tool-Name",
)
tool_call_id = models.CharField(
max_length=100,
blank=True,
verbose_name="Tool-Call-ID",
)
created_at = models.DateTimeField(auto_now_add=True, verbose_name="Erstellt")
class Meta:
verbose_name = "Chat-Nachricht"
verbose_name_plural = "Chat-Nachrichten"
ordering = ["created_at"]
def __str__(self):
return f"[{self.role}] {self.content[:60]}"

View File

@@ -0,0 +1,201 @@
"""
ReAct-Orchestrator für den AI Agent.
Implementiert einen synchronen ReAct-Loop (Reason + Act) mit:
- max. 5 Iterationen
- Tool-Calling
- Streaming via Generator
- Audit-Logging
"""
from __future__ import annotations
import json
import logging
from typing import Generator
from .providers import get_provider, LLMError
from .tools import execute_tool, TOOL_SCHEMAS
logger = logging.getLogger(__name__)
MAX_ITERATIONS = 5
def run_agent_stream(
session,
user_message: str,
page_context: str = "",
user=None,
) -> Generator[str, None, None]:
"""
Führt den ReAct-Loop aus und streamt SSE-kompatible Daten-Strings.
Yield-Format (Server-Sent Events):
"data: {json}\n\n"
JSON-Typen:
{"type": "text", "content": "..."} Textfragment
{"type": "tool_start", "name": "..."} Tool wird aufgerufen
{"type": "tool_result", "name": "...", "result": "..."}
{"type": "done"}
{"type": "error", "message": "..."}
"""
from .models import AgentConfig, ChatMessage
config = AgentConfig.get_config()
# Systemkontext aufbauen
system_content = config.system_prompt
if page_context:
system_content += f"\n\nAktueller Seitenkontext:\n{page_context}"
# Nachrichtenhistorie laden (letzte 20 Nachrichten)
history = list(
session.messages.exclude(role="tool")
.order_by("-created_at")[:20]
)
history.reverse()
messages = [{"role": "system", "content": system_content}]
for msg in history:
if msg.role in ("user", "assistant"):
messages.append({"role": msg.role, "content": msg.content})
# Neue User-Nachricht
messages.append({"role": "user", "content": user_message})
# Neue User-Message in DB speichern
ChatMessage.objects.create(
session=session,
role="user",
content=user_message,
)
# Sesstionttitel setzen falls leer
if not session.title and user_message:
session.title = user_message[:100]
session.save(update_fields=["title", "updated_at"])
tools = TOOL_SCHEMAS if not getattr(config, "allow_write", False) else TOOL_SCHEMAS
try:
provider = get_provider(config)
except LLMError as e:
yield _sse({"type": "error", "message": str(e)})
return
full_assistant_text = ""
iteration = 0
tools_disabled = False
while iteration < MAX_ITERATIONS:
iteration += 1
text_buffer = ""
pending_tool_calls = []
current_tools = None if tools_disabled else tools
try:
for chunk in provider.chat_stream(messages=messages, tools=current_tools):
chunk_type = chunk.get("type")
if chunk_type == "text":
text = chunk["content"]
text_buffer += text
full_assistant_text += text
yield _sse({"type": "text", "content": text})
elif chunk_type == "tool_call":
pending_tool_calls.append(chunk)
elif chunk_type == "done":
break
elif chunk_type == "error":
yield _sse({"type": "error", "message": chunk.get("message", "Unbekannter Fehler")})
return
except LLMError as e:
if not tools_disabled and iteration == 1:
# Tool-Calling hat den Provider zum Absturz gebracht (z.B. OOM).
# Fallback: ohne Tools erneut versuchen.
# Warte kurz, damit Ollama nach OOM-Crash neu starten kann.
import time
logger.warning("LLM-Fehler mit Tools, Fallback auf Chat-only: %s", e)
tools_disabled = True
full_assistant_text = ""
time.sleep(15)
continue
yield _sse({"type": "error", "message": str(e)})
return
if not pending_tool_calls:
# Kein Tool-Call → Antwort fertig
break
# Tool-Calls verarbeiten
# Assistent-Nachricht mit tool_calls in History
tool_calls_for_msg = []
for tc in pending_tool_calls:
tool_calls_for_msg.append({
"id": tc["id"],
"type": "function",
"function": {
"name": tc["name"],
"arguments": json.dumps(tc["arguments"], ensure_ascii=False),
},
})
assistant_msg: dict = {"role": "assistant", "content": text_buffer or ""}
if tool_calls_for_msg:
assistant_msg["tool_calls"] = tool_calls_for_msg
messages.append(assistant_msg)
# Jeden Tool-Call ausführen
for tc in pending_tool_calls:
tool_name = tc["name"]
tool_args = tc["arguments"]
tool_call_id = tc["id"]
yield _sse({"type": "tool_start", "name": tool_name})
result = execute_tool(tool_name, tool_args, user)
# Tool-Ergebnis in DB
ChatMessage.objects.create(
session=session,
role="tool",
content=result,
tool_name=tool_name,
tool_call_id=tool_call_id,
)
yield _sse({"type": "tool_result", "name": tool_name, "result": result[:500]})
# Tool-Ergebnis in Messages für nächste LLM-Iteration
messages.append({
"role": "tool",
"tool_call_id": tool_call_id,
"content": result,
})
full_assistant_text = "" # Reset für nächste Iteration
# Abschließende Assistent-Nachricht in DB speichern
if full_assistant_text:
ChatMessage.objects.create(
session=session,
role="assistant",
content=full_assistant_text,
)
# Session updated_at aktualisieren
from django.utils import timezone
session.updated_at = timezone.now()
session.save(update_fields=["updated_at"])
yield _sse({"type": "done"})
def _sse(data: dict) -> str:
"""Formatiert ein Dict als SSE data-Zeile."""
return f"data: {json.dumps(data, ensure_ascii=False)}\n\n"

View File

@@ -0,0 +1,323 @@
"""
LLM-Provider-Abstraktion für den AI Agent.
Unterstützt:
- Ollama (Standard, OpenAI-kompatibles API via httpx)
- OpenAI
- Anthropic (über OpenAI-kompatible Schnittstelle)
Alle Provider implementieren synchrones Streaming via Generator.
"""
from __future__ import annotations
import json
import logging
from typing import Generator, Any
import httpx
logger = logging.getLogger(__name__)
class LLMError(Exception):
"""LLM-Kommunikationsfehler."""
pass
class BaseLLMProvider:
def chat_stream(
self,
messages: list[dict],
tools: list[dict] | None = None,
) -> Generator[dict, None, None]:
"""
Streamt Antwort-Chunks als Dicts.
Chunk-Typen:
{"type": "text", "content": "..."}
{"type": "tool_call", "id": "...", "name": "...", "arguments": {...}}
{"type": "done"}
{"type": "error", "message": "..."}
"""
raise NotImplementedError
class OllamaProvider(BaseLLMProvider):
"""Ollama via OpenAI-kompatibler Chat-Completion-Endpunkt."""
def __init__(self, base_url: str, model: str):
self.base_url = base_url.rstrip("/")
self.model = model
def chat_stream(
self,
messages: list[dict],
tools: list[dict] | None = None,
) -> Generator[dict, None, None]:
url = f"{self.base_url}/v1/chat/completions"
payload: dict[str, Any] = {
"model": self.model,
"messages": messages,
"stream": True,
}
if tools:
payload["tools"] = tools
payload["tool_choice"] = "auto"
try:
with httpx.Client(timeout=120.0) as client:
with client.stream("POST", url, json=payload) as response:
if response.status_code != 200:
body = response.read().decode()
raise LLMError(
f"Ollama-Fehler {response.status_code}: {body[:200]}"
)
yield from _parse_openai_stream(response)
except httpx.ConnectError:
raise LLMError(
f"Verbindung zu Ollama ({self.base_url}) fehlgeschlagen. "
"Ist der Ollama-Dienst gestartet?"
)
except httpx.RemoteProtocolError:
raise LLMError(
"Ollama-Verbindung abgebrochen. "
"Möglicherweise nicht genug RAM für dieses Modell mit Tool-Calling."
)
except httpx.TimeoutException:
raise LLMError("Ollama-Anfrage hat das Zeitlimit überschritten.")
class OpenAIProvider(BaseLLMProvider):
"""OpenAI Chat-Completion API."""
BASE_URL = "https://api.openai.com"
def __init__(self, api_key: str, model: str):
self.api_key = api_key
self.model = model
def chat_stream(
self,
messages: list[dict],
tools: list[dict] | None = None,
) -> Generator[dict, None, None]:
url = f"{self.BASE_URL}/v1/chat/completions"
headers = {
"Authorization": f"Bearer {self.api_key}",
"Content-Type": "application/json",
}
payload: dict[str, Any] = {
"model": self.model,
"messages": messages,
"stream": True,
}
if tools:
payload["tools"] = tools
payload["tool_choice"] = "auto"
try:
with httpx.Client(timeout=120.0) as client:
with client.stream("POST", url, json=payload, headers=headers) as response:
if response.status_code != 200:
body = response.read().decode()
raise LLMError(
f"OpenAI-Fehler {response.status_code}: {body[:200]}"
)
yield from _parse_openai_stream(response)
except httpx.TimeoutException:
raise LLMError("OpenAI-Anfrage hat das Zeitlimit überschritten.")
class AnthropicProvider(BaseLLMProvider):
"""Anthropic Messages API (native, not OpenAI-compatible)."""
BASE_URL = "https://api.anthropic.com"
def __init__(self, api_key: str, model: str):
self.api_key = api_key
self.model = model
def chat_stream(
self,
messages: list[dict],
tools: list[dict] | None = None,
) -> Generator[dict, None, None]:
url = f"{self.BASE_URL}/v1/messages"
headers = {
"x-api-key": self.api_key,
"anthropic-version": "2023-06-01",
"Content-Type": "application/json",
}
# Extract system message from messages list
system = ""
chat_messages = []
for msg in messages:
if msg["role"] == "system":
system = msg["content"]
else:
chat_messages.append(msg)
# Convert OpenAI tool format to Anthropic format
anthropic_tools = []
if tools:
for t in tools:
fn = t.get("function", {})
anthropic_tools.append({
"name": fn.get("name"),
"description": fn.get("description", ""),
"input_schema": fn.get("parameters", {}),
})
payload: dict[str, Any] = {
"model": self.model,
"max_tokens": 4096,
"messages": chat_messages,
"stream": True,
}
if system:
payload["system"] = system
if anthropic_tools:
payload["tools"] = anthropic_tools
try:
with httpx.Client(timeout=120.0) as client:
with client.stream("POST", url, json=payload, headers=headers) as response:
if response.status_code != 200:
body = response.read().decode()
raise LLMError(
f"Anthropic-Fehler {response.status_code}: {body[:200]}"
)
yield from _parse_anthropic_stream(response)
except httpx.TimeoutException:
raise LLMError("Anthropic-Anfrage hat das Zeitlimit überschritten.")
def _parse_openai_stream(response) -> Generator[dict, None, None]:
"""Parst OpenAI-kompatibles SSE-Streaming-Format."""
accumulated_tool_calls: dict[int, dict] = {}
for line in response.iter_lines():
if not line or line == "data: [DONE]":
continue
if line.startswith("data: "):
line = line[6:]
try:
chunk = json.loads(line)
except json.JSONDecodeError:
continue
choice = chunk.get("choices", [{}])[0]
delta = choice.get("delta", {})
finish_reason = choice.get("finish_reason")
# Text content
if delta.get("content"):
yield {"type": "text", "content": delta["content"]}
# Tool calls (streaming parts arrive incrementally)
tool_calls_delta = delta.get("tool_calls", [])
for tc_delta in tool_calls_delta:
idx = tc_delta.get("index", 0)
if idx not in accumulated_tool_calls:
accumulated_tool_calls[idx] = {
"id": "",
"name": "",
"arguments": "",
}
tc = accumulated_tool_calls[idx]
if tc_delta.get("id"):
tc["id"] += tc_delta["id"]
fn = tc_delta.get("function", {})
if fn.get("name"):
tc["name"] += fn["name"]
if fn.get("arguments"):
tc["arguments"] += fn["arguments"]
if finish_reason in ("tool_calls", "stop"):
# Emit completed tool calls
for tc in accumulated_tool_calls.values():
try:
args = json.loads(tc["arguments"]) if tc["arguments"] else {}
except json.JSONDecodeError:
args = {}
yield {
"type": "tool_call",
"id": tc["id"],
"name": tc["name"],
"arguments": args,
}
accumulated_tool_calls.clear()
if finish_reason == "stop":
yield {"type": "done"}
return
yield {"type": "done"}
def _parse_anthropic_stream(response) -> Generator[dict, None, None]:
"""Parst Anthropic SSE-Streaming-Format."""
current_tool: dict | None = None
tool_input_str = ""
for line in response.iter_lines():
if not line or line.startswith("event:"):
continue
if line.startswith("data: "):
line = line[6:]
try:
event = json.loads(line)
except json.JSONDecodeError:
continue
event_type = event.get("type", "")
if event_type == "content_block_start":
block = event.get("content_block", {})
if block.get("type") == "tool_use":
current_tool = {"id": block.get("id", ""), "name": block.get("name", "")}
tool_input_str = ""
elif event_type == "content_block_delta":
delta = event.get("delta", {})
if delta.get("type") == "text_delta":
yield {"type": "text", "content": delta.get("text", "")}
elif delta.get("type") == "input_json_delta":
tool_input_str += delta.get("partial_json", "")
elif event_type == "content_block_stop":
if current_tool is not None:
try:
args = json.loads(tool_input_str) if tool_input_str else {}
except json.JSONDecodeError:
args = {}
yield {
"type": "tool_call",
"id": current_tool["id"],
"name": current_tool["name"],
"arguments": args,
}
current_tool = None
tool_input_str = ""
elif event_type == "message_stop":
yield {"type": "done"}
return
yield {"type": "done"}
def get_provider(config) -> BaseLLMProvider:
"""Erstellt den konfigurierten LLM-Provider."""
if config.provider == "ollama":
return OllamaProvider(base_url=config.ollama_url, model=config.model_name)
elif config.provider == "openai":
if not config.openai_api_key:
raise LLMError("OpenAI API-Key ist nicht konfiguriert.")
return OpenAIProvider(api_key=config.openai_api_key, model=config.model_name)
elif config.provider == "anthropic":
if not config.anthropic_api_key:
raise LLMError("Anthropic API-Key ist nicht konfiguriert.")
return AnthropicProvider(api_key=config.anthropic_api_key, model=config.model_name)
else:
raise LLMError(f"Unbekannter Provider: {config.provider}")

363
app/stiftung/agent/tools.py Normal file
View File

@@ -0,0 +1,363 @@
"""
Tool-Registry für den AI Agent.
Wrappt bestehende Django-ORM-Abfragen (analog zu mcp_server/tools/lesen.py)
mit direktem DB-Zugriff und PII-Filterung basierend auf Django-User-Berechtigungen.
Schreib-Tools sind standardmäßig deaktiviert.
"""
from __future__ import annotations
import json
import logging
from decimal import Decimal
from typing import Any
logger = logging.getLogger(__name__)
# ──────────────────────────────────────────────────────────────────────────────
# Hilfsfunktionen
# ──────────────────────────────────────────────────────────────────────────────
def _get_role(user) -> str:
"""Leitet MCP-Rolle aus Django-User ab."""
if user.is_superuser or user.has_perm("stiftung.access_administration"):
return "admin"
return "readonly"
def _serialize(obj: Any) -> Any:
"""Serialisiert Django-Modell-Werte zu JSON-fähigen Typen."""
if obj is None:
return None
if isinstance(obj, Decimal):
return float(obj)
if hasattr(obj, "isoformat"):
return obj.isoformat()
if hasattr(obj, "__str__"):
return str(obj)
return obj
def _apply_pii(data: dict, model_type: str, role: str) -> dict:
"""Wendet PII-Filterung via mcp_server.privacy an."""
from mcp_server.privacy import apply_privacy_filter
return apply_privacy_filter(data, model_type, role)
# ──────────────────────────────────────────────────────────────────────────────
# Tool-Implementierungen (Read-Only)
# ──────────────────────────────────────────────────────────────────────────────
def tool_destinataer_suchen(user, suchbegriff: str = "", aktiv: bool | None = None, limit: int = 20) -> str:
from django.db.models import Q
from stiftung.models import Destinataer
role = _get_role(user)
limit = min(limit, 50)
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)
qs = qs.order_by("nachname", "vorname")[:limit]
results = []
for obj in qs:
item = {
"id": str(obj.id),
"vorname": obj.vorname,
"nachname": obj.nachname,
"familienzweig": obj.familienzweig,
"aktiv": obj.aktiv,
"ort": obj.ort,
"email": obj.email,
}
results.append(_apply_pii(item, "destinataer", role))
return json.dumps({"count": len(results), "destinataere": results}, default=_serialize, ensure_ascii=False)
def tool_land_suchen(user, suchbegriff: str = "", limit: int = 20) -> str:
from django.db.models import Q
from stiftung.models import Land
limit = min(limit, 50)
qs = Land.objects.all()
if suchbegriff:
qs = qs.filter(
Q(bezeichnung__icontains=suchbegriff)
| Q(gemarkung__icontains=suchbegriff)
| Q(ort__icontains=suchbegriff)
)
qs = qs.order_by("bezeichnung")[:limit]
results = []
for obj in qs:
results.append({
"id": str(obj.id),
"bezeichnung": obj.bezeichnung,
"gemarkung": getattr(obj, "gemarkung", ""),
"ort": getattr(obj, "ort", ""),
"flaeche_ha": _serialize(getattr(obj, "flaeche_ha", None)),
"aktiv": getattr(obj, "aktiv", True),
})
return json.dumps({"count": len(results), "laendereien": results}, default=_serialize, ensure_ascii=False)
def tool_konten_uebersicht(user) -> str:
from stiftung.models import StiftungsKonto
role = _get_role(user)
konten = StiftungsKonto.objects.all().order_by("bezeichnung")
results = []
for k in konten:
item = {
"id": str(k.id),
"bezeichnung": k.bezeichnung,
"bank": getattr(k, "bank", ""),
"kontonummer": getattr(k, "kontonummer", ""),
"iban": getattr(k, "iban", ""),
"aktiv": getattr(k, "aktiv", True),
}
results.append(_apply_pii(item, "konto", role))
return json.dumps({"count": len(results), "konten": results}, default=_serialize, ensure_ascii=False)
def tool_foerderungen_suchen(user, suchbegriff: str = "", status: str = "", limit: int = 20) -> str:
from django.db.models import Q
from stiftung.models import Foerderung
limit = min(limit, 50)
qs = Foerderung.objects.select_related("destinataer").all()
if suchbegriff:
qs = qs.filter(
Q(bezeichnung__icontains=suchbegriff)
| Q(destinataer__nachname__icontains=suchbegriff)
| Q(destinataer__vorname__icontains=suchbegriff)
)
if status:
qs = qs.filter(status=status)
qs = qs.order_by("-erstellt_am")[:limit]
results = []
for obj in qs:
results.append({
"id": str(obj.id),
"bezeichnung": getattr(obj, "bezeichnung", ""),
"destinataer": str(obj.destinataer) if obj.destinataer else None,
"betrag": _serialize(getattr(obj, "betrag", None)),
"status": getattr(obj, "status", ""),
"erstellt_am": _serialize(getattr(obj, "erstellt_am", None)),
})
return json.dumps({"count": len(results), "foerderungen": results}, default=_serialize, ensure_ascii=False)
def tool_verwaltungskosten(user, jahr: int | None = None, limit: int = 20) -> str:
from stiftung.models import Verwaltungskosten
limit = min(limit, 50)
qs = Verwaltungskosten.objects.all()
if jahr:
qs = qs.filter(datum__year=jahr)
qs = qs.order_by("-datum")[:limit]
results = []
for obj in qs:
results.append({
"id": str(obj.id),
"datum": _serialize(getattr(obj, "datum", None)),
"bezeichnung": getattr(obj, "bezeichnung", ""),
"betrag": _serialize(getattr(obj, "betrag", None)),
"kategorie": getattr(obj, "kategorie", ""),
})
return json.dumps({"count": len(results), "verwaltungskosten": results}, default=_serialize, ensure_ascii=False)
def tool_termine_anzeigen(user, limit: int = 10) -> str:
from django.utils import timezone
from stiftung.models import StiftungsKalenderEintrag
now = timezone.now().date()
qs = StiftungsKalenderEintrag.objects.filter(datum__gte=now).order_by("datum")[:limit]
results = []
for obj in qs:
results.append({
"id": str(obj.id),
"titel": getattr(obj, "titel", ""),
"datum": _serialize(getattr(obj, "datum", None)),
"beschreibung": getattr(obj, "beschreibung", ""),
"typ": getattr(obj, "typ", ""),
})
return json.dumps({"count": len(results), "termine": results}, default=_serialize, ensure_ascii=False)
def tool_transaktionen_suchen(user, suchbegriff: str = "", limit: int = 20) -> str:
from django.db.models import Q
from stiftung.models import BankTransaction
limit = min(limit, 50)
qs = BankTransaction.objects.all()
if suchbegriff:
qs = qs.filter(
Q(verwendungszweck__icontains=suchbegriff)
| Q(auftraggeber__icontains=suchbegriff)
)
qs = qs.order_by("-buchungsdatum")[:limit]
results = []
for obj in qs:
results.append({
"id": str(obj.id),
"datum": _serialize(getattr(obj, "buchungsdatum", None)),
"betrag": _serialize(getattr(obj, "betrag", None)),
"verwendungszweck": getattr(obj, "verwendungszweck", ""),
"auftraggeber": getattr(obj, "auftraggeber", ""),
})
return json.dumps({"count": len(results), "transaktionen": results}, default=_serialize, ensure_ascii=False)
def tool_dashboard(user) -> str:
"""Gibt eine Übersicht über Schlüsselkennzahlen zurück."""
from stiftung.models import Destinataer, Foerderung, Land, StiftungsKonto
try:
destinataere_aktiv = Destinataer.objects.filter(aktiv=True).count()
destinataere_gesamt = Destinataer.objects.count()
laendereien = Land.objects.count()
konten = StiftungsKonto.objects.count()
foerderungen_offen = Foerderung.objects.filter(status="offen").count() if hasattr(Foerderung, 'objects') else 0
return json.dumps({
"destinataere_aktiv": destinataere_aktiv,
"destinataere_gesamt": destinataere_gesamt,
"laendereien": laendereien,
"konten": konten,
"foerderungen_offen": foerderungen_offen,
}, ensure_ascii=False)
except Exception as e:
return json.dumps({"fehler": str(e)}, ensure_ascii=False)
# ──────────────────────────────────────────────────────────────────────────────
# Tool-Dispatch und Schema
# ──────────────────────────────────────────────────────────────────────────────
TOOL_FUNCTIONS = {
"destinataer_suchen": tool_destinataer_suchen,
"land_suchen": tool_land_suchen,
"konten_uebersicht": tool_konten_uebersicht,
"foerderungen_suchen": tool_foerderungen_suchen,
"verwaltungskosten": tool_verwaltungskosten,
"termine_anzeigen": tool_termine_anzeigen,
"transaktionen_suchen": tool_transaktionen_suchen,
"dashboard": tool_dashboard,
}
TOOL_SCHEMAS = [
{
"type": "function",
"function": {
"name": "destinataer_suchen",
"description": "Sucht Destinatäre (Förderungsempfänger) nach Name oder Status.",
"parameters": {
"type": "object",
"properties": {
"suchbegriff": {"type": "string", "description": "Vor-/Nachname oder Institution"},
"aktiv": {"type": "boolean", "description": "true=nur Aktive, false=nur Inaktive"},
"limit": {"type": "integer", "description": "Max. Anzahl Ergebnisse (Standard: 20)"},
},
},
},
},
{
"type": "function",
"function": {
"name": "land_suchen",
"description": "Sucht Ländereien (Grundstücke) der Stiftung nach Bezeichnung oder Ort.",
"parameters": {
"type": "object",
"properties": {
"suchbegriff": {"type": "string", "description": "Bezeichnung, Gemarkung oder Ort"},
"limit": {"type": "integer", "description": "Max. Anzahl Ergebnisse"},
},
},
},
},
{
"type": "function",
"function": {
"name": "konten_uebersicht",
"description": "Zeigt alle Stiftungskonten mit Bankverbindungen.",
"parameters": {"type": "object", "properties": {}},
},
},
{
"type": "function",
"function": {
"name": "foerderungen_suchen",
"description": "Sucht Förderungen nach Bezeichnung oder Destinatär.",
"parameters": {
"type": "object",
"properties": {
"suchbegriff": {"type": "string", "description": "Bezeichnung oder Destinatär-Name"},
"status": {"type": "string", "description": "Status-Filter (z.B. 'offen', 'genehmigt')"},
"limit": {"type": "integer"},
},
},
},
},
{
"type": "function",
"function": {
"name": "verwaltungskosten",
"description": "Listet Verwaltungskosten, optional nach Jahr gefiltert.",
"parameters": {
"type": "object",
"properties": {
"jahr": {"type": "integer", "description": "Filterjahr (z.B. 2025)"},
"limit": {"type": "integer"},
},
},
},
},
{
"type": "function",
"function": {
"name": "termine_anzeigen",
"description": "Zeigt bevorstehende Termine und Fristen der Stiftung.",
"parameters": {
"type": "object",
"properties": {
"limit": {"type": "integer", "description": "Max. Anzahl Termine"},
},
},
},
},
{
"type": "function",
"function": {
"name": "transaktionen_suchen",
"description": "Sucht Banktransaktionen nach Verwendungszweck oder Auftraggeber.",
"parameters": {
"type": "object",
"properties": {
"suchbegriff": {"type": "string"},
"limit": {"type": "integer"},
},
},
},
},
{
"type": "function",
"function": {
"name": "dashboard",
"description": "Zeigt Schlüsselkennzahlen der Stiftung (Anzahl Destinatäre, Ländereien, Konten etc.).",
"parameters": {"type": "object", "properties": {}},
},
},
]
def execute_tool(name: str, arguments: dict, user) -> str:
"""Führt ein Tool aus und gibt das Ergebnis als String zurück."""
fn = TOOL_FUNCTIONS.get(name)
if fn is None:
return json.dumps({"fehler": f"Unbekanntes Tool: {name}"}, ensure_ascii=False)
try:
return fn(user, **arguments)
except TypeError as e:
logger.warning("Tool %s Parameterfehler: %s", name, e)
return json.dumps({"fehler": f"Ungültige Parameter: {e}"}, ensure_ascii=False)
except Exception as e:
logger.error("Tool %s Fehler: %s", name, e, exc_info=True)
return json.dumps({"fehler": f"Tool-Ausführung fehlgeschlagen: {e}"}, ensure_ascii=False)

View File

@@ -0,0 +1,12 @@
from django.urls import path
from . import views
urlpatterns = [
path("", views.agent_index, name="agent_index"),
path("chat/", views.agent_chat, name="agent_chat"),
path("chat/stream/", views.agent_chat_stream, name="agent_chat_stream"),
path("sessions/", views.agent_sessions, name="agent_sessions"),
path("sessions/<uuid:session_id>/", views.agent_session_messages, name="agent_session_messages"),
path("sessions/<uuid:session_id>/loeschen/", views.agent_session_delete, name="agent_session_delete"),
]

232
app/stiftung/agent/views.py Normal file
View File

@@ -0,0 +1,232 @@
"""
Views für den AI Agent.
Endpunkte:
POST /agent/chat/ Neue Nachricht senden (startet neue oder bestehende Session)
GET /agent/chat/stream/ SSE-Stream für laufende Anfrage
GET /agent/sessions/ Liste der Chat-Sitzungen (JSON)
DELETE /agent/sessions/<id>/ Sitzung löschen
"""
from __future__ import annotations
import json
import logging
from django.contrib.auth.decorators import login_required
from django.core.cache import cache
from django.http import (
HttpResponse,
JsonResponse,
StreamingHttpResponse,
)
from django.shortcuts import get_object_or_404
from django.utils import timezone
from django.views.decorators.csrf import csrf_exempt
from django.views.decorators.http import require_http_methods
from .models import AgentConfig, ChatSession, ChatMessage
from .orchestrator import run_agent_stream
logger = logging.getLogger(__name__)
RATE_LIMIT_PER_MINUTE = 20
def _check_rate_limit(user_id: int) -> bool:
"""Einfaches Rate-Limiting via Django-Cache (Redis). True = erlaubt."""
key = f"agent_rl_{user_id}"
count = cache.get(key, 0)
if count >= RATE_LIMIT_PER_MINUTE:
return False
cache.set(key, count + 1, timeout=60)
return True
def _require_agent_permission(user) -> bool:
"""Prüft ob der Benutzer den Agent nutzen darf."""
return (
user.is_superuser
or user.has_perm("stiftung.can_use_agent")
)
@login_required
@require_http_methods(["GET"])
def agent_index(request):
"""Einstiegsseite für den Chat (wird als Modal geöffnet, nicht direkt navigiert)."""
config = AgentConfig.get_config()
return JsonResponse({
"provider": config.provider,
"model": config.model_name,
"allow_write": config.allow_write,
})
@login_required
@require_http_methods(["POST"])
def agent_chat(request):
"""
Startet oder setzt einen Chat fort.
Body (JSON):
{
"message": "Wie viele aktive Destinatäre gibt es?",
"session_id": "optional-uuid",
"page_context": "optional aktueller Seiteninhalt als Text"
}
Antwort:
{
"session_id": "...",
"stream_url": "/agent/chat/stream/?session_id=..."
}
"""
if not _require_agent_permission(request.user):
return JsonResponse({"error": "Keine Berechtigung für den AI-Assistenten."}, status=403)
if not _check_rate_limit(request.user.id):
return JsonResponse(
{"error": "Rate-Limit erreicht. Bitte warten Sie eine Minute."},
status=429,
)
try:
body = json.loads(request.body)
except (json.JSONDecodeError, ValueError):
return JsonResponse({"error": "Ungültiger JSON-Body."}, status=400)
message = (body.get("message") or "").strip()
if not message:
return JsonResponse({"error": "Nachricht darf nicht leer sein."}, status=400)
page_context = (body.get("page_context") or "")[:2000]
session_id = body.get("session_id")
# Session ermitteln oder neu erstellen
session = None
if session_id:
try:
session = ChatSession.objects.get(id=session_id, user=request.user)
except ChatSession.DoesNotExist:
pass
if session is None:
session = ChatSession.objects.create(user=request.user)
# Nachricht + Page-Context in Cache für Stream-Endpunkt speichern
cache_key = f"agent_pending_{session.id}"
cache.set(
cache_key,
{"message": message, "page_context": page_context},
timeout=300, # 5 Minuten
)
return JsonResponse({
"session_id": str(session.id),
"stream_url": f"/agent/chat/stream/?session_id={session.id}",
})
@login_required
@require_http_methods(["GET"])
def agent_chat_stream(request):
"""
SSE-Endpunkt: streamt die Antwort des Agenten.
Query-Params:
session_id: UUID der Chat-Sitzung
"""
if not _require_agent_permission(request.user):
return HttpResponse("Keine Berechtigung.", status=403)
session_id = request.GET.get("session_id")
if not session_id:
return HttpResponse("session_id fehlt.", status=400)
session = get_object_or_404(ChatSession, id=session_id, user=request.user)
cache_key = f"agent_pending_{session.id}"
pending = cache.get(cache_key)
if not pending:
return HttpResponse("Keine ausstehende Nachricht gefunden.", status=400)
cache.delete(cache_key)
message = pending["message"]
page_context = pending.get("page_context", "")
def event_stream():
try:
yield from run_agent_stream(
session=session,
user_message=message,
page_context=page_context,
user=request.user,
)
except Exception as e:
logger.error("Agent-Stream-Fehler: %s", e, exc_info=True)
import json
yield f"data: {json.dumps({'type': 'error', 'message': 'Interner Fehler.'})}\n\n"
response = StreamingHttpResponse(
event_stream(),
content_type="text/event-stream",
)
response["Cache-Control"] = "no-cache"
response["X-Accel-Buffering"] = "no"
return response
@login_required
@require_http_methods(["GET"])
def agent_sessions(request):
"""Gibt die Chat-Sitzungen des Benutzers zurück (letzte 20)."""
if not _require_agent_permission(request.user):
return JsonResponse({"error": "Keine Berechtigung."}, status=403)
sessions = ChatSession.objects.filter(user=request.user).order_by("-updated_at")[:20]
data = []
for s in sessions:
data.append({
"id": str(s.id),
"title": s.title or "Neue Unterhaltung",
"created_at": s.created_at.isoformat(),
"updated_at": s.updated_at.isoformat(),
"message_count": s.messages.count(),
})
return JsonResponse({"sessions": data})
@login_required
@require_http_methods(["GET"])
def agent_session_messages(request, session_id):
"""Gibt alle Nachrichten einer Sitzung zurück."""
if not _require_agent_permission(request.user):
return JsonResponse({"error": "Keine Berechtigung."}, status=403)
session = get_object_or_404(ChatSession, id=session_id, user=request.user)
messages = session.messages.exclude(role="tool").order_by("created_at")
data = []
for m in messages:
data.append({
"id": str(m.id),
"role": m.role,
"content": m.content,
"created_at": m.created_at.isoformat(),
})
return JsonResponse({
"session_id": str(session.id),
"title": session.title,
"messages": data,
})
@login_required
@require_http_methods(["POST"])
def agent_session_delete(request, session_id):
"""Löscht eine Chat-Sitzung."""
if not _require_agent_permission(request.user):
return JsonResponse({"error": "Keine Berechtigung."}, status=403)
session = get_object_or_404(ChatSession, id=session_id, user=request.user)
session.delete()
return JsonResponse({"ok": True})

View File

@@ -398,9 +398,14 @@ class VierteljahresNachweisForm(forms.ModelForm):
einkommenssituation_datei = cleaned_data.get('einkommenssituation_datei') einkommenssituation_datei = cleaned_data.get('einkommenssituation_datei')
einkommenssituation_bestaetigt = cleaned_data.get('einkommenssituation_bestaetigt') einkommenssituation_bestaetigt = cleaned_data.get('einkommenssituation_bestaetigt')
if einkommenssituation_bestaetigt and not einkommenssituation_text and not einkommenssituation_datei: # DMS-Dokumente aus POST-Daten beruecksichtigen (werden parallel zum Formular gesendet)
has_einkommens_dms = (
self.instance and self.instance.pk and
bool(self.instance.einkommenssituation_dms_dokument_id)
)
if einkommenssituation_bestaetigt and not einkommenssituation_text and not einkommenssituation_datei and not has_einkommens_dms:
raise ValidationError( raise ValidationError(
'Wenn die Einkommenssituation bestätigt wird, muss entweder ein Text oder eine Datei angegeben werden.' 'Wenn die Einkommenssituation bestätigt wird, muss entweder ein Text, eine Datei oder ein DMS-Dokument angegeben werden.'
) )
# Validate that at least one form of confirmation is provided for asset situation # Validate that at least one form of confirmation is provided for asset situation
@@ -408,9 +413,13 @@ class VierteljahresNachweisForm(forms.ModelForm):
vermogenssituation_datei = cleaned_data.get('vermogenssituation_datei') vermogenssituation_datei = cleaned_data.get('vermogenssituation_datei')
vermogenssituation_bestaetigt = cleaned_data.get('vermogenssituation_bestaetigt') vermogenssituation_bestaetigt = cleaned_data.get('vermogenssituation_bestaetigt')
if vermogenssituation_bestaetigt and not vermogenssituation_text and not vermogenssituation_datei: has_vermogens_dms = (
self.instance and self.instance.pk and
bool(self.instance.vermogenssituation_dms_dokument_id)
)
if vermogenssituation_bestaetigt and not vermogenssituation_text and not vermogenssituation_datei and not has_vermogens_dms:
raise ValidationError( raise ValidationError(
'Wenn die Vermögenssituation bestätigt wird, muss entweder ein Text oder eine Datei angegeben werden.' 'Wenn die Vermögenssituation bestätigt wird, muss entweder ein Text, eine Datei oder ein DMS-Dokument angegeben werden.'
) )
# Validate study proof if required and marked as submitted # Validate study proof if required and marked as submitted
@@ -420,9 +429,15 @@ class VierteljahresNachweisForm(forms.ModelForm):
studiennachweis_bemerkung = cleaned_data.get('studiennachweis_bemerkung') studiennachweis_bemerkung = cleaned_data.get('studiennachweis_bemerkung')
if studiennachweis_erforderlich and studiennachweis_eingereicht: if studiennachweis_erforderlich and studiennachweis_eingereicht:
if not studiennachweis_datei and not studiennachweis_bemerkung: has_dms_studiennachweis = (
self.instance and self.instance.pk and (
bool(self.instance.studiennachweis_dms_dokument_id)
or self.instance.nachweis_dokumente.filter(kontext="studiennachweis").exists()
)
)
if not studiennachweis_datei and not studiennachweis_bemerkung and not has_dms_studiennachweis:
raise ValidationError( raise ValidationError(
'Wenn der Studiennachweis als eingereicht markiert wird, muss entweder eine Datei oder eine Bemerkung angegeben werden.' 'Wenn der Studiennachweis als eingereicht markiert wird, muss entweder eine Datei, eine Bemerkung oder ein DMS-Dokument angegeben werden.'
) )
return cleaned_data return cleaned_data

View File

@@ -0,0 +1,18 @@
# Generated by Django 5.0.6 on 2026-03-14 21:49
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('stiftung', '0054_add_alkis_kennzeichen'),
]
operations = [
migrations.AlterField(
model_name='csvimport',
name='import_type',
field=models.CharField(choices=[('destinataere', 'Destinatäre'), ('paechter', 'Pächter'), ('laendereien', 'Ländereien'), ('verpachtungen', 'Verpachtungen'), ('foerderungen', 'Förderungen'), ('konten', 'Stiftungskonten'), ('verwaltungskosten', 'Verwaltungskosten'), ('rentmeister', 'Rentmeister'), ('personen', 'Personen (Legacy)')], max_length=20, verbose_name='Import-Typ'),
),
]

View File

@@ -0,0 +1,211 @@
"""
Migration 0056: AI Agent Models (AgentConfig, ChatSession, ChatMessage)
+ can_use_agent Permission
"""
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
import uuid
class Migration(migrations.Migration):
dependencies = [
("stiftung", "0055_add_import_types_for_unified_import_export"),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name="AgentConfig",
fields=[
(
"id",
models.AutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
(
"provider",
models.CharField(
choices=[
("ollama", "Ollama (lokal)"),
("openai", "OpenAI"),
("anthropic", "Anthropic"),
],
default="ollama",
max_length=20,
verbose_name="LLM-Provider",
),
),
(
"model_name",
models.CharField(
default="qwen2.5:3b",
max_length=100,
verbose_name="Modell-Name",
),
),
(
"ollama_url",
models.CharField(
default="http://ollama:11434",
max_length=255,
verbose_name="Ollama-URL",
),
),
(
"openai_api_key",
models.CharField(
blank=True,
max_length=255,
verbose_name="OpenAI API-Key",
),
),
(
"anthropic_api_key",
models.CharField(
blank=True,
max_length=255,
verbose_name="Anthropic API-Key",
),
),
(
"system_prompt",
models.TextField(verbose_name="System-Prompt"),
),
(
"allow_write",
models.BooleanField(
default=False,
verbose_name="Schreib-Tools erlaubt",
),
),
(
"chat_retention_days",
models.IntegerField(
default=30,
verbose_name="Chat-Verlauf Aufbewahrung (Tage)",
),
),
],
options={
"verbose_name": "Agent-Konfiguration",
"verbose_name_plural": "Agent-Konfiguration",
},
),
migrations.CreateModel(
name="ChatSession",
fields=[
(
"id",
models.UUIDField(
default=uuid.uuid4,
editable=False,
primary_key=True,
serialize=False,
),
),
(
"title",
models.CharField(
blank=True,
max_length=200,
verbose_name="Titel",
),
),
(
"created_at",
models.DateTimeField(auto_now_add=True, verbose_name="Erstellt"),
),
(
"updated_at",
models.DateTimeField(auto_now=True, verbose_name="Zuletzt aktiv"),
),
(
"user",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="agent_sessions",
to=settings.AUTH_USER_MODEL,
verbose_name="Benutzer",
),
),
],
options={
"verbose_name": "Chat-Sitzung",
"verbose_name_plural": "Chat-Sitzungen",
"ordering": ["-updated_at"],
},
),
migrations.CreateModel(
name="ChatMessage",
fields=[
(
"id",
models.UUIDField(
default=uuid.uuid4,
editable=False,
primary_key=True,
serialize=False,
),
),
(
"role",
models.CharField(
choices=[
("user", "Benutzer"),
("assistant", "Assistent"),
("tool", "Tool-Ergebnis"),
],
max_length=20,
verbose_name="Rolle",
),
),
(
"content",
models.TextField(verbose_name="Inhalt"),
),
(
"tool_name",
models.CharField(
blank=True,
max_length=100,
verbose_name="Tool-Name",
),
),
(
"tool_call_id",
models.CharField(
blank=True,
max_length=100,
verbose_name="Tool-Call-ID",
),
),
(
"created_at",
models.DateTimeField(auto_now_add=True, verbose_name="Erstellt"),
),
(
"session",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="messages",
to="stiftung.chatsession",
verbose_name="Sitzung",
),
),
],
options={
"verbose_name": "Chat-Nachricht",
"verbose_name_plural": "Chat-Nachrichten",
"ordering": ["created_at"],
},
),
# Update ApplicationPermission to add can_use_agent
# (No DB table change needed — this is a managed=False model)
# The permission is added via the Meta.permissions list in system.py
]

View File

@@ -0,0 +1,47 @@
# Generated by Django 5.0.6 on 2026-03-14 22:22
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('stiftung', '0056_agent_models'),
]
operations = [
migrations.AlterModelOptions(
name='applicationpermission',
options={'default_permissions': (), 'managed': False, 'permissions': [('manage_destinataere', 'Kann Destinatäre verwalten'), ('view_destinataere', 'Kann Destinatäre anzeigen'), ('manage_land', 'Kann Ländereien verwalten'), ('view_land', 'Kann Ländereien anzeigen'), ('manage_paechter', 'Kann Pächter verwalten'), ('view_paechter', 'Kann Pächter anzeigen'), ('manage_verpachtungen', 'Kann Verpachtungen verwalten'), ('view_verpachtungen', 'Kann Verpachtungen anzeigen'), ('manage_foerderungen', 'Kann Förderungen verwalten'), ('view_foerderungen', 'Kann Förderungen anzeigen'), ('manage_documents', 'Kann Dokumente verwalten'), ('view_documents', 'Kann Dokumente anzeigen'), ('link_documents', 'Kann Dokumente verknüpfen'), ('manage_verwaltungskosten', 'Kann Verwaltungskosten verwalten'), ('view_verwaltungskosten', 'Kann Verwaltungskosten anzeigen'), ('approve_payments', 'Kann Zahlungen genehmigen'), ('manage_konten', 'Kann Stiftungskonten verwalten'), ('view_konten', 'Kann Stiftungskonten anzeigen'), ('manage_rentmeister', 'Kann Rentmeister verwalten'), ('view_rentmeister', 'Kann Rentmeister anzeigen'), ('access_administration', 'Kann Administration aufrufen'), ('view_audit_logs', 'Kann Audit-Logs anzeigen'), ('manage_backups', 'Kann Backups erstellen und verwalten'), ('manage_users', 'Kann Benutzer verwalten'), ('manage_permissions', 'Kann Berechtigungen verwalten'), ('manage_veranstaltungen', 'Kann Veranstaltungen verwalten'), ('view_veranstaltungen', 'Kann Veranstaltungen anzeigen'), ('import_data', 'Kann Daten importieren'), ('export_data', 'Kann Daten exportieren'), ('access_django_admin', 'Kann Django Admin aufrufen'), ('view_system_stats', 'Kann Systemstatistiken anzeigen'), ('can_use_agent', 'Kann AI-Assistenten nutzen')]},
),
migrations.AlterField(
model_name='agentconfig',
name='allow_write',
field=models.BooleanField(default=False, help_text='Achtung: Schreib-Zugriff auf Stiftungsdaten aktivieren', verbose_name='Schreib-Tools erlaubt'),
),
migrations.AlterField(
model_name='agentconfig',
name='anthropic_api_key',
field=models.CharField(blank=True, help_text='Nur erforderlich wenn Provider = Anthropic', max_length=255, verbose_name='Anthropic API-Key'),
),
migrations.AlterField(
model_name='agentconfig',
name='id',
field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID'),
),
migrations.AlterField(
model_name='agentconfig',
name='openai_api_key',
field=models.CharField(blank=True, help_text='Nur erforderlich wenn Provider = OpenAI', max_length=255, verbose_name='OpenAI API-Key'),
),
migrations.AlterField(
model_name='agentconfig',
name='system_prompt',
field=models.TextField(default="Du bist RentmeisterAI, der KI-Assistent der van Hees-Theyssen-Vogel'schen Stiftung.\n\nDu hast Zugriff auf die Stiftungsdatenbank und kannst Informationen zu Destinatären, Ländereien, Finanzen, Förderungen und weiteren Stiftungsdaten abrufen.\n\nRegeln:\n- Antworte stets auf Deutsch, präzise und sachlich.\n- Schütze personenbezogene Daten gib keine unnötigen Details heraus.\n- Du kannst keine Daten ändern, nur lesen.\n- Bei rechtlichen oder steuerlichen Fragen weise auf Fachberatung hin.\n- Wenn du dir unsicher bist, sage das klar.\n", verbose_name='System-Prompt'),
),
migrations.AlterField(
model_name='chatsession',
name='title',
field=models.CharField(blank=True, help_text='Automatisch aus erster Nachricht generiert', max_length=200, verbose_name='Titel'),
),
]

View File

@@ -0,0 +1,23 @@
# Generated by Django 5.0.6 on 2026-03-15 16:49
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('stiftung', '0057_alter_applicationpermission_options_and_more'),
]
operations = [
migrations.AddField(
model_name='vierteljahresnachweis',
name='nachweis_dokumente',
field=models.ManyToManyField(blank=True, help_text='Dokumente aus dem DMS, die als Nachweise fuer dieses Quartal dienen.', related_name='quartalsnachweise', to='stiftung.dokumentdatei', verbose_name='Verknuepfte DMS-Dokumente'),
),
migrations.AlterField(
model_name='dokumentdatei',
name='kontext',
field=models.CharField(choices=[('pachtvertrag', 'Pachtvertrag'), ('antrag', 'Antrag / Förderantrag'), ('verwendungsnachweis', 'Verwendungsnachweis'), ('studiennachweis', 'Studiennachweis'), ('rechnung', 'Rechnung'), ('vertrag', 'Vertrag'), ('bericht', 'Bericht'), ('landkarte', 'Landkarte / Kataster'), ('korrespondenz', 'Korrespondenz / Brief'), ('bescheid', 'Bescheid / Behörde'), ('stiftungsgeschichte', 'Stiftungsgeschichte / Archiv'), ('email', 'E-Mail-Nachricht'), ('anderes', 'Sonstiges')], default='anderes', max_length=30, verbose_name='Dokumententyp'),
),
]

View File

@@ -0,0 +1,29 @@
# Generated by Django 5.0.6 on 2026-03-15 17:25
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('stiftung', '0058_dms_email_kontext_und_nachweis_dokumente'),
]
operations = [
migrations.AddField(
model_name='vierteljahresnachweis',
name='einkommenssituation_dms_dokument',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='als_einkommensnachweis', to='stiftung.dokumentdatei', verbose_name='Einkommenssituation (DMS-Dokument)'),
),
migrations.AddField(
model_name='vierteljahresnachweis',
name='studiennachweis_dms_dokument',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='als_studiennachweis', to='stiftung.dokumentdatei', verbose_name='Studiennachweis (DMS-Dokument)'),
),
migrations.AddField(
model_name='vierteljahresnachweis',
name='vermogenssituation_dms_dokument',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='als_vermoegensnachweis', to='stiftung.dokumentdatei', verbose_name='Vermoegenssituation (DMS-Dokument)'),
),
]

View File

@@ -755,6 +755,38 @@ class VierteljahresNachweis(models.Model):
verbose_name="Beschreibung weitere Dokumente" verbose_name="Beschreibung weitere Dokumente"
) )
# DMS-Dokumente als Nachweise verknuepfen (aus dem allgemeinen DMS)
nachweis_dokumente = models.ManyToManyField(
"DokumentDatei",
blank=True,
related_name="quartalsnachweise",
verbose_name="Verknuepfte DMS-Dokumente",
help_text="Dokumente aus dem DMS, die als Nachweise fuer dieses Quartal dienen.",
)
# Kategorie-spezifische DMS-Verknuepfungen
studiennachweis_dms_dokument = models.ForeignKey(
"DokumentDatei",
on_delete=models.SET_NULL,
null=True, blank=True,
related_name="als_studiennachweis",
verbose_name="Studiennachweis (DMS-Dokument)",
)
einkommenssituation_dms_dokument = models.ForeignKey(
"DokumentDatei",
on_delete=models.SET_NULL,
null=True, blank=True,
related_name="als_einkommensnachweis",
verbose_name="Einkommenssituation (DMS-Dokument)",
)
vermogenssituation_dms_dokument = models.ForeignKey(
"DokumentDatei",
on_delete=models.SET_NULL,
null=True, blank=True,
related_name="als_vermoegensnachweis",
verbose_name="Vermoegenssituation (DMS-Dokument)",
)
# Review and approval # Review and approval
status = models.CharField( status = models.CharField(
max_length=20, max_length=20,
@@ -840,19 +872,27 @@ class VierteljahresNachweis(models.Model):
"""Check if all required documents/confirmations are provided""" """Check if all required documents/confirmations are provided"""
complete = True complete = True
# DMS-Dokumente (kategorie-spezifisch oder generisch) zaehlen als Nachweis
has_dms_studiennachweis = (
bool(self.studiennachweis_dms_dokument_id)
or self.nachweis_dokumente.filter(kontext="studiennachweis").exists()
)
# Check study proof (always required now) # Check study proof (always required now)
complete &= self.studiennachweis_eingereicht and ( complete &= self.studiennachweis_eingereicht and (
bool(self.studiennachweis_datei) or bool(self.studiennachweis_bemerkung) bool(self.studiennachweis_datei) or bool(self.studiennachweis_bemerkung) or has_dms_studiennachweis
) )
# Check income situation (either text or file) # Check income situation (either text, file, or DMS document)
complete &= self.einkommenssituation_bestaetigt and ( complete &= self.einkommenssituation_bestaetigt and (
bool(self.einkommenssituation_text) or bool(self.einkommenssituation_datei) bool(self.einkommenssituation_text) or bool(self.einkommenssituation_datei)
or bool(self.einkommenssituation_dms_dokument_id)
) )
# Check asset situation (either text or file) # Check asset situation (either text, file, or DMS document)
complete &= self.vermogenssituation_bestaetigt and ( complete &= self.vermogenssituation_bestaetigt and (
bool(self.vermogenssituation_text) or bool(self.vermogenssituation_datei) bool(self.vermogenssituation_text) or bool(self.vermogenssituation_datei)
or bool(self.vermogenssituation_dms_dokument_id)
) )
return complete return complete
@@ -868,23 +908,30 @@ class VierteljahresNachweis(models.Model):
total_requirements = 2 # Income and assets always required total_requirements = 2 # Income and assets always required
completed_requirements = 0 completed_requirements = 0
has_dms_studiennachweis = (
bool(self.studiennachweis_dms_dokument_id)
or self.nachweis_dokumente.filter(kontext="studiennachweis").exists()
)
# Study proof (if required) # Study proof (if required)
if self.studiennachweis_erforderlich: if self.studiennachweis_erforderlich:
total_requirements += 1 total_requirements += 1
if self.studiennachweis_eingereicht and ( if self.studiennachweis_eingereicht and (
bool(self.studiennachweis_datei) or bool(self.studiennachweis_bemerkung) bool(self.studiennachweis_datei) or bool(self.studiennachweis_bemerkung) or has_dms_studiennachweis
): ):
completed_requirements += 1 completed_requirements += 1
# Income situation # Income situation
if self.einkommenssituation_bestaetigt and ( if self.einkommenssituation_bestaetigt and (
bool(self.einkommenssituation_text) or bool(self.einkommenssituation_datei) bool(self.einkommenssituation_text) or bool(self.einkommenssituation_datei)
or bool(self.einkommenssituation_dms_dokument_id)
): ):
completed_requirements += 1 completed_requirements += 1
# Asset situation # Asset situation
if self.vermogenssituation_bestaetigt and ( if self.vermogenssituation_bestaetigt and (
bool(self.vermogenssituation_text) or bool(self.vermogenssituation_datei) bool(self.vermogenssituation_text) or bool(self.vermogenssituation_datei)
or bool(self.vermogenssituation_dms_dokument_id)
): ):
completed_requirements += 1 completed_requirements += 1

View File

@@ -31,6 +31,7 @@ class DokumentDatei(models.Model):
("korrespondenz", "Korrespondenz / Brief"), ("korrespondenz", "Korrespondenz / Brief"),
("bescheid", "Bescheid / Behörde"), ("bescheid", "Bescheid / Behörde"),
("stiftungsgeschichte", "Stiftungsgeschichte / Archiv"), ("stiftungsgeschichte", "Stiftungsgeschichte / Archiv"),
("email", "E-Mail-Nachricht"),
("anderes", "Sonstiges"), ("anderes", "Sonstiges"),
] ]

View File

@@ -11,6 +11,10 @@ class CSVImport(models.Model):
("paechter", "Pächter"), ("paechter", "Pächter"),
("laendereien", "Ländereien"), ("laendereien", "Ländereien"),
("verpachtungen", "Verpachtungen"), ("verpachtungen", "Verpachtungen"),
("foerderungen", "Förderungen"),
("konten", "Stiftungskonten"),
("verwaltungskosten", "Verwaltungskosten"),
("rentmeister", "Rentmeister"),
("personen", "Personen (Legacy)"), ("personen", "Personen (Legacy)"),
] ]
@@ -111,6 +115,8 @@ class ApplicationPermission(models.Model):
# System Permissions # System Permissions
("access_django_admin", "Kann Django Admin aufrufen"), ("access_django_admin", "Kann Django Admin aufrufen"),
("view_system_stats", "Kann Systemstatistiken anzeigen"), ("view_system_stats", "Kann Systemstatistiken anzeigen"),
# AI Agent Permissions
("can_use_agent", "Kann AI-Assistenten nutzen"),
] ]

View File

@@ -332,11 +332,58 @@ def poll_emails(self, search_all_recent_days=0):
if doc: if doc:
dms_dokumente.append(doc) dms_dokumente.append(doc)
# Cover-Email als eigenes DMS-Dokument speichern
email_body_doc = None
if email_text.strip():
email_filename = f"Email_{eingangsdatum.strftime('%Y%m%d_%H%M')}_{betreff[:50]}.txt"
# Bereinige Dateinamen
email_filename = re.sub(r'[^\w\s\-._]', '', email_filename)
anhang_count = len(dms_dokumente)
anhang_hinweis = (
f"\n\n--- Anhänge: {anhang_count} ---\n"
+ "\n".join(f"{d.dateiname_original or d.titel}" for d in dms_dokumente)
if dms_dokumente else ""
)
email_body_content = (
f"Von: {absender_name} <{absender_email_addr}>\n"
f"Datum: {eingangsdatum.strftime('%d.%m.%Y %H:%M')}\n"
f"Betreff: {betreff}\n"
f"{'=' * 60}\n\n"
f"{email_text}"
f"{anhang_hinweis}"
)
email_body_doc = _save_to_dms(
content=email_body_content.encode("utf-8"),
filename=email_filename,
destinataer=destinataer,
betreff=betreff,
kontext="email",
)
if email_body_doc:
# Beschreibung mit Anhang-Verweis ergaenzen
if dms_dokumente:
email_body_doc.beschreibung = (
f"E-Mail-Nachricht mit {anhang_count} Anhang/Anhängen.\n"
f"Absender: {absender_name} <{absender_email_addr}>"
)
else:
email_body_doc.beschreibung = (
f"E-Mail-Nachricht (ohne Anhänge).\n"
f"Absender: {absender_name} <{absender_email_addr}>"
)
email_body_doc.save(update_fields=["beschreibung"])
# Alle DMS-Dokumente (Email-Body + Anhaenge) verknuepfen
alle_dms_dokumente = []
if email_body_doc:
alle_dms_dokumente.append(email_body_doc)
alle_dms_dokumente.extend(dms_dokumente)
if dms_dokumente: if dms_dokumente:
eingang.status = "verarbeitet" if destinataer else status eingang.status = "verarbeitet" if destinataer else status
eingang.save() eingang.save()
if dms_dokumente: if alle_dms_dokumente:
eingang.dokument_dateien.set(dms_dokumente) eingang.dokument_dateien.set(alle_dms_dokumente)
# Als gelesen markieren # Als gelesen markieren
mail.store(msg_id, "+FLAGS", "\\Seen") mail.store(msg_id, "+FLAGS", "\\Seen")

View File

@@ -1,15 +1,23 @@
from django.urls import path from django.urls import include, path
from . import views from . import views
app_name = "stiftung" app_name = "stiftung"
urlpatterns = [ urlpatterns = [
# AI Agent
path("agent/", include("stiftung.agent.urls")),
# Home - Main landing page after login # Home - Main landing page after login
path("", views.home, name="home"), path("", views.home, name="home"),
# CSV Import URLs # CSV Import URLs (legacy)
path("import/", views.csv_import_list, name="csv_import_list"), path("import/", views.csv_import_list, name="csv_import_list"),
path("import/neu/", views.csv_import_create, name="csv_import_create"), path("import/neu/", views.csv_import_create, name="csv_import_create"),
# Unified Import/Export Hub
path("daten/", views.import_export_hub, name="import_export_hub"),
path("daten/export/", views.csv_export, name="csv_export"),
path("daten/import/upload/", views.csv_import_upload, name="csv_import_upload"),
path("daten/import/ausfuehren/", views.csv_import_execute, name="csv_import_execute"),
# Destinatär URLs (Förderungsempfänger) # Destinatär URLs (Förderungsempfänger)
path("destinataere/", views.destinataer_list, name="destinataer_list"), path("destinataere/", views.destinataer_list, name="destinataer_list"),
path( path(

View File

@@ -206,5 +206,12 @@ from .veranstaltung import ( # noqa: F401
teilnehmer_delete, teilnehmer_delete,
) )
from .import_export import ( # noqa: F401
import_export_hub,
csv_export,
csv_import_upload,
csv_import_execute,
)
# Non-view exports (helpers used elsewhere) # Non-view exports (helpers used elsewhere)
from .system import GrampsClient, get_gramps_client, get_pdf_generator # noqa: F401 from .system import GrampsClient, get_gramps_client, get_pdf_generator # noqa: F401

View File

@@ -254,9 +254,9 @@ def dms_edit(request, pk):
paechter_id = request.POST.get("paechter_id", "").strip() paechter_id = request.POST.get("paechter_id", "").strip()
verp_id = request.POST.get("verpachtung_id", "").strip() verp_id = request.POST.get("verpachtung_id", "").strip()
dok.destinataer_id = int(dest_id) if dest_id else None dok.destinataer_id = dest_id if dest_id else None
dok.land_id = int(land_id) if land_id else None dok.land_id = land_id if land_id else None
dok.paechter_id = int(paechter_id) if paechter_id else None dok.paechter_id = paechter_id if paechter_id else None
dok.verpachtung_id = verp_id if verp_id else None dok.verpachtung_id = verp_id if verp_id else None
dok.save() dok.save()

View File

@@ -750,8 +750,10 @@ def email_eingang_detail(request, pk):
messages.success(request, "Notizen gespeichert.") messages.success(request, "Notizen gespeichert.")
return redirect("stiftung:email_eingang_detail", pk=pk) return redirect("stiftung:email_eingang_detail", pk=pk)
# DMS-Dokumente # DMS-Dokumente: E-Mail-Body und Anhaenge trennen
dms_dokumente = eingang.dokument_dateien.all().order_by("erstellt_am") alle_dms_dokumente = eingang.dokument_dateien.all().order_by("erstellt_am")
email_dokument = alle_dms_dokumente.filter(kontext="email").first()
anhaenge_dokumente = alle_dms_dokumente.exclude(kontext="email")
# Alle aktiven Destinataere fuer manuelle Zuordnung # Alle aktiven Destinataere fuer manuelle Zuordnung
alle_destinataere = Destinataer.objects.filter(aktiv=True).order_by("nachname", "vorname") alle_destinataere = Destinataer.objects.filter(aktiv=True).order_by("nachname", "vorname")
@@ -759,7 +761,8 @@ def email_eingang_detail(request, pk):
context = { context = {
"title": f"E-Mail-Eingang: {eingang}", "title": f"E-Mail-Eingang: {eingang}",
"eingang": eingang, "eingang": eingang,
"dms_dokumente": dms_dokumente, "email_dokument": email_dokument,
"anhaenge_dokumente": anhaenge_dokumente,
"alle_destinataere": alle_destinataere, "alle_destinataere": alle_destinataere,
"vk_kategorie_choices": Verwaltungskosten.KATEGORIE_CHOICES, "vk_kategorie_choices": Verwaltungskosten.KATEGORIE_CHOICES,
} }

View File

@@ -0,0 +1,920 @@
# views/import_export.py
# Unified Import/Export Workflow for all Stiftung content types
import csv
import io
import json
from datetime import datetime
from decimal import Decimal, InvalidOperation
from django.contrib import messages
from django.contrib.auth.decorators import login_required
from django.http import HttpResponse
from django.shortcuts import redirect, render
from django.utils import timezone
from stiftung.models import (
CSVImport, Destinataer, Foerderung, Land, LandAbrechnung,
LandVerpachtung, Paechter, Person, Rentmeister,
StiftungsKonto, Verwaltungskosten,
)
# ---------------------------------------------------------------------------
# Export field definitions for each entity type
# Each entry: (csv_header, model_field_or_lambda)
# ---------------------------------------------------------------------------
def _date_fmt(val):
"""Format date for CSV export."""
if val is None:
return ""
return val.strftime("%d.%m.%Y")
def _datetime_fmt(val):
if val is None:
return ""
return val.strftime("%d.%m.%Y %H:%M")
def _decimal_fmt(val):
if val is None:
return ""
return f"{val:.2f}"
def _bool_fmt(val):
if val is None:
return ""
return "ja" if val else "nein"
EXPORT_DEFINITIONS = {
"destinataere": {
"model": Destinataer,
"label": "Destinatäre",
"queryset": lambda: Destinataer.objects.all().order_by("nachname", "vorname"),
"fields": [
("Vorname", lambda o: o.vorname),
("Nachname", lambda o: o.nachname),
("Geburtsdatum", lambda o: _date_fmt(o.geburtsdatum)),
("E-Mail", lambda o: o.email or ""),
("Telefon", lambda o: o.telefon or ""),
("IBAN", lambda o: o.iban or ""),
("Straße", lambda o: o.strasse or ""),
("PLZ", lambda o: o.plz or ""),
("Ort", lambda o: o.ort or ""),
("Familienzweig", lambda o: o.familienzweig or ""),
("Berufsgruppe", lambda o: o.berufsgruppe or ""),
("Ausbildungsstand", lambda o: o.ausbildungsstand or ""),
("Institution", lambda o: o.institution or ""),
("Projektbeschreibung", lambda o: o.projekt_beschreibung or ""),
("Jährliches_Einkommen", lambda o: _decimal_fmt(o.jaehrliches_einkommen)),
("Finanzielle_Notlage", lambda o: _bool_fmt(o.finanzielle_notlage)),
("Ist_Abkömmling", lambda o: _bool_fmt(o.ist_abkoemmling)),
("Haushaltsgroesse", lambda o: str(o.haushaltsgroesse) if o.haushaltsgroesse else ""),
("Monatliche_Bezuege", lambda o: _decimal_fmt(o.monatliche_bezuege)),
("Vermoegen", lambda o: _decimal_fmt(o.vermoegen)),
("Unterstuetzung_Bestaetigt", lambda o: _bool_fmt(o.unterstuetzung_bestaetigt)),
("Vierteljaehrlicher_Betrag", lambda o: _decimal_fmt(o.vierteljaehrlicher_betrag)),
("Studiennachweis_Erforderlich", lambda o: _bool_fmt(o.studiennachweis_erforderlich)),
("Letzter_Studiennachweis", lambda o: _date_fmt(o.letzter_studiennachweis)),
("Notizen", lambda o: o.notizen or ""),
("Aktiv", lambda o: _bool_fmt(o.aktiv)),
],
},
"paechter": {
"model": Paechter,
"label": "Pächter",
"queryset": lambda: Paechter.objects.all().order_by("nachname", "vorname"),
"fields": [
("Vorname", lambda o: o.vorname),
("Nachname", lambda o: o.nachname),
("Geburtsdatum", lambda o: _date_fmt(o.geburtsdatum)),
("E-Mail", lambda o: o.email or ""),
("Telefon", lambda o: o.telefon or ""),
("IBAN", lambda o: o.iban or ""),
("Straße", lambda o: o.strasse or ""),
("PLZ", lambda o: o.plz or ""),
("Ort", lambda o: o.ort or ""),
("Personentyp", lambda o: o.personentyp or ""),
("Pachtnummer", lambda o: o.pachtnummer or ""),
("Pachtbeginn_Erste", lambda o: _date_fmt(o.pachtbeginn_erste)),
("Pachtende_Letzte", lambda o: _date_fmt(o.pachtende_letzte)),
("Pachtzins_Aktuell", lambda o: _decimal_fmt(o.pachtzins_aktuell)),
("Landwirtschaftliche_Ausbildung", lambda o: _bool_fmt(o.landwirtschaftliche_ausbildung)),
("Berufserfahrung_Jahre", lambda o: str(o.berufserfahrung_jahre) if o.berufserfahrung_jahre else ""),
("Spezialisierung", lambda o: o.spezialisierung or ""),
("Notizen", lambda o: o.notizen or ""),
("Aktiv", lambda o: _bool_fmt(o.aktiv)),
],
},
"laendereien": {
"model": Land,
"label": "Ländereien",
"queryset": lambda: Land.objects.select_related("aktueller_paechter").all().order_by("lfd_nr"),
"fields": [
("Lfd_Nr", lambda o: o.lfd_nr or ""),
("EW_Nummer", lambda o: o.ew_nummer or ""),
("Grundbuchblatt", lambda o: o.grundbuchblatt or ""),
("ALKIS_Kennzeichen", lambda o: o.alkis_kennzeichen or ""),
("Amtsgericht", lambda o: o.amtsgericht or ""),
("Gemeinde", lambda o: o.gemeinde or ""),
("Gemarkung", lambda o: o.gemarkung or ""),
("Flur", lambda o: o.flur or ""),
("Flurstück", lambda o: o.flurstueck or ""),
("Adresse", lambda o: o.adresse or ""),
("Größe_qm", lambda o: _decimal_fmt(o.groesse_qm)),
("Grünland_qm", lambda o: _decimal_fmt(o.gruenland_qm)),
("Acker_qm", lambda o: _decimal_fmt(o.acker_qm)),
("Wald_qm", lambda o: _decimal_fmt(o.wald_qm)),
("Sonstiges_qm", lambda o: _decimal_fmt(o.sonstiges_qm)),
("Verpachtete_Gesamtfläche_qm", lambda o: _decimal_fmt(o.verpachtete_gesamtflaeche)),
("Verp_Fläche_aktuell_qm", lambda o: _decimal_fmt(o.verp_flaeche_aktuell)),
("Pächter_Name", lambda o: o.paechter_name or ""),
("Pachtbeginn", lambda o: _date_fmt(o.pachtbeginn)),
("Pachtende", lambda o: _date_fmt(o.pachtende)),
("Zahlungsweise", lambda o: o.zahlungsweise or ""),
("Pachtzins_pro_ha", lambda o: _decimal_fmt(o.pachtzins_pro_ha)),
("Pachtzins_pauschal", lambda o: _decimal_fmt(o.pachtzins_pauschal)),
("USt_Option", lambda o: _bool_fmt(o.ust_option)),
("USt_Satz", lambda o: _decimal_fmt(o.ust_satz)),
("Aktiv", lambda o: _bool_fmt(o.aktiv)),
("Notizen", lambda o: o.notizen or ""),
],
},
"verpachtungen": {
"model": LandVerpachtung,
"label": "Verpachtungen",
"queryset": lambda: LandVerpachtung.objects.select_related("land", "paechter").all().order_by("vertragsnummer"),
"fields": [
("Vertragsnummer", lambda o: o.vertragsnummer or ""),
("Land_Lfd_Nr", lambda o: o.land.lfd_nr if o.land else ""),
("Land_Gemeinde", lambda o: o.land.gemeinde if o.land else ""),
("Land_Gemarkung", lambda o: o.land.gemarkung if o.land else ""),
("Pächter_Name", lambda o: f"{o.paechter.vorname} {o.paechter.nachname}" if o.paechter else ""),
("Pachtbeginn", lambda o: _date_fmt(o.pachtbeginn)),
("Pachtende", lambda o: _date_fmt(o.pachtende)),
("Verlängerung_Klausel", lambda o: o.verlaengerung_klausel or ""),
("Verpachtete_Fläche_qm", lambda o: _decimal_fmt(o.verpachtete_flaeche)),
("Pachtzins_pauschal", lambda o: _decimal_fmt(o.pachtzins_pauschal)),
("Pachtzins_pro_ha", lambda o: _decimal_fmt(o.pachtzins_pro_ha)),
("Zahlungsweise", lambda o: o.zahlungsweise or ""),
("USt_Option", lambda o: _bool_fmt(o.ust_option)),
("USt_Satz", lambda o: _decimal_fmt(o.ust_satz)),
("Status", lambda o: o.status or ""),
("Bemerkungen", lambda o: o.bemerkungen or ""),
],
},
"foerderungen": {
"model": Foerderung,
"label": "Förderungen",
"queryset": lambda: Foerderung.objects.select_related("destinataer").all().order_by("-jahr"),
"fields": [
("Destinatär_Vorname", lambda o: o.destinataer.vorname if o.destinataer else ""),
("Destinatär_Nachname", lambda o: o.destinataer.nachname if o.destinataer else ""),
("Jahr", lambda o: str(o.jahr)),
("Betrag", lambda o: _decimal_fmt(o.betrag)),
("Kategorie", lambda o: o.get_kategorie_display() if o.kategorie else ""),
("Status", lambda o: o.get_status_display() if o.status else ""),
("Antragsdatum", lambda o: _date_fmt(o.antragsdatum)),
("Entscheidungsdatum", lambda o: _date_fmt(o.entscheidungsdatum)),
("Bemerkungen", lambda o: o.bemerkungen or ""),
],
},
"konten": {
"model": StiftungsKonto,
"label": "Stiftungskonten",
"queryset": lambda: StiftungsKonto.objects.all().order_by("kontoname"),
"fields": [
("Kontoname", lambda o: o.kontoname),
("Bank", lambda o: o.bank_name or ""),
("IBAN", lambda o: o.iban or ""),
("BIC", lambda o: o.bic or ""),
("Konto_Typ", lambda o: o.get_konto_typ_display() if o.konto_typ else ""),
("Saldo", lambda o: _decimal_fmt(o.saldo)),
("Saldo_Datum", lambda o: _date_fmt(o.saldo_datum)),
("Zinssatz", lambda o: _decimal_fmt(o.zinssatz)),
("Laufzeit_Bis", lambda o: _date_fmt(o.laufzeit_bis)),
("Aktiv", lambda o: _bool_fmt(o.aktiv)),
("Notizen", lambda o: o.notizen or ""),
],
},
"verwaltungskosten": {
"model": Verwaltungskosten,
"label": "Verwaltungskosten",
"queryset": lambda: Verwaltungskosten.objects.select_related("rentmeister").all().order_by("-datum"),
"fields": [
("Bezeichnung", lambda o: o.bezeichnung),
("Kategorie", lambda o: o.get_kategorie_display() if o.kategorie else ""),
("Betrag", lambda o: _decimal_fmt(o.betrag)),
("Datum", lambda o: _date_fmt(o.datum)),
("Lieferant", lambda o: o.lieferant_firma or ""),
("Rechnungsnummer", lambda o: o.rechnungsnummer or ""),
("Status", lambda o: o.get_status_display() if o.status else ""),
("Rentmeister", lambda o: f"{o.rentmeister.vorname} {o.rentmeister.nachname}" if o.rentmeister else ""),
("Beschreibung", lambda o: o.beschreibung or ""),
("Notizen", lambda o: o.notizen or ""),
],
},
"rentmeister": {
"model": Rentmeister,
"label": "Rentmeister",
"queryset": lambda: Rentmeister.objects.all().order_by("nachname", "vorname"),
"fields": [
("Anrede", lambda o: o.get_anrede_display() if o.anrede else ""),
("Vorname", lambda o: o.vorname),
("Nachname", lambda o: o.nachname),
("Titel", lambda o: o.titel or ""),
("E-Mail", lambda o: o.email or ""),
("Telefon", lambda o: o.telefon or ""),
("Mobil", lambda o: o.mobil or ""),
("Straße", lambda o: o.strasse or ""),
("PLZ", lambda o: o.plz or ""),
("Ort", lambda o: o.ort or ""),
("IBAN", lambda o: o.iban or ""),
("BIC", lambda o: o.bic or ""),
("Bank", lambda o: o.bank_name or ""),
("Seit", lambda o: _date_fmt(o.seit_datum)),
("Bis", lambda o: _date_fmt(o.bis_datum)),
("Monatliche_Vergütung", lambda o: _decimal_fmt(o.monatliche_verguetung)),
("Km_Pauschale", lambda o: _decimal_fmt(o.km_pauschale)),
("Aktiv", lambda o: _bool_fmt(o.aktiv)),
("Notizen", lambda o: o.notizen or ""),
],
},
}
# ---------------------------------------------------------------------------
# Import field definitions for field mapping
# Each: (display_label, model_field, field_type, required)
# ---------------------------------------------------------------------------
IMPORT_FIELD_DEFINITIONS = {
"destinataere": {
"model": Destinataer,
"label": "Destinatäre",
"unique_fields": ["vorname", "nachname"],
"fields": [
("Vorname", "vorname", "text", True),
("Nachname", "nachname", "text", True),
("Geburtsdatum", "geburtsdatum", "date", False),
("E-Mail", "email", "text", False),
("Telefon", "telefon", "text", False),
("IBAN", "iban", "text", False),
("Straße", "strasse", "text", False),
("PLZ", "plz", "text", False),
("Ort", "ort", "text", False),
("Familienzweig", "familienzweig", "text", False),
("Berufsgruppe", "berufsgruppe", "text", False),
("Ausbildungsstand", "ausbildungsstand", "text", False),
("Institution", "institution", "text", False),
("Projektbeschreibung", "projekt_beschreibung", "text", False),
("Jährliches Einkommen", "jaehrliches_einkommen", "decimal", False),
("Finanzielle Notlage", "finanzielle_notlage", "bool", False),
("Ist Abkömmling", "ist_abkoemmling", "bool", False),
("Haushaltsgröße", "haushaltsgroesse", "int", False),
("Monatliche Bezüge", "monatliche_bezuege", "decimal", False),
("Vermögen", "vermoegen", "decimal", False),
("Unterstützung bestätigt", "unterstuetzung_bestaetigt", "bool", False),
("Vierteljährlicher Betrag", "vierteljaehrlicher_betrag", "decimal", False),
("Studiennachweis erforderlich", "studiennachweis_erforderlich", "bool", False),
("Letzter Studiennachweis", "letzter_studiennachweis", "date", False),
("Notizen", "notizen", "text", False),
("Aktiv", "aktiv", "bool", False),
],
},
"paechter": {
"model": Paechter,
"label": "Pächter",
"unique_fields": ["vorname", "nachname"],
"fields": [
("Vorname", "vorname", "text", True),
("Nachname", "nachname", "text", True),
("Geburtsdatum", "geburtsdatum", "date", False),
("E-Mail", "email", "text", False),
("Telefon", "telefon", "text", False),
("IBAN", "iban", "text", False),
("Straße", "strasse", "text", False),
("PLZ", "plz", "text", False),
("Ort", "ort", "text", False),
("Personentyp", "personentyp", "text", False),
("Pachtnummer", "pachtnummer", "text", False),
("Pachtbeginn Erste", "pachtbeginn_erste", "date", False),
("Pachtende Letzte", "pachtende_letzte", "date", False),
("Pachtzins Aktuell", "pachtzins_aktuell", "decimal", False),
("Landw. Ausbildung", "landwirtschaftliche_ausbildung", "bool", False),
("Berufserfahrung Jahre", "berufserfahrung_jahre", "int", False),
("Spezialisierung", "spezialisierung", "text", False),
("Notizen", "notizen", "text", False),
("Aktiv", "aktiv", "bool", False),
],
},
"laendereien": {
"model": Land,
"label": "Ländereien",
"unique_fields": ["lfd_nr"],
"unique_fields_alt": ["gemeinde", "gemarkung", "flur", "flurstueck"],
"fields": [
("Lfd Nr", "lfd_nr", "text", False),
("EW Nummer", "ew_nummer", "text", False),
("Grundbuchblatt", "grundbuchblatt", "text", False),
("ALKIS Kennzeichen", "alkis_kennzeichen", "text", False),
("Amtsgericht", "amtsgericht", "text", False),
("Gemeinde", "gemeinde", "text", False),
("Gemarkung", "gemarkung", "text", False),
("Flur", "flur", "text", False),
("Flurstück", "flurstueck", "text", False),
("Adresse", "adresse", "text", False),
("Größe qm", "groesse_qm", "decimal", False),
("Grünland qm", "gruenland_qm", "decimal", False),
("Acker qm", "acker_qm", "decimal", False),
("Wald qm", "wald_qm", "decimal", False),
("Sonstiges qm", "sonstiges_qm", "decimal", False),
("Verpachtete Gesamtfläche", "verpachtete_gesamtflaeche", "decimal", False),
("Verp Fläche aktuell", "verp_flaeche_aktuell", "decimal", False),
("Pächter Name", "paechter_name", "text", False),
("Pächter Anschrift", "paechter_anschrift", "text", False),
("Pachtbeginn", "pachtbeginn", "date", False),
("Pachtende", "pachtende", "date", False),
("Verlängerung Klausel", "verlaengerung_klausel", "text", False),
("Zahlungsweise", "zahlungsweise", "text", False),
("Pachtzins pro ha", "pachtzins_pro_ha", "decimal", False),
("Pachtzins pauschal", "pachtzins_pauschal", "decimal", False),
("USt Option", "ust_option", "bool", False),
("USt Satz", "ust_satz", "decimal", False),
("Grundsteuer Umlage", "grundsteuer_umlage", "bool", False),
("Versicherungen Umlage", "versicherungen_umlage", "bool", False),
("Verbandsbeiträge Umlage", "verbandsbeitraege_umlage", "bool", False),
("Jagdpacht Anteil Umlage", "jagdpacht_anteil_umlage", "bool", False),
("Anteil Grundsteuer", "anteil_grundsteuer", "decimal", False),
("Anteil LWK", "anteil_lwk", "decimal", False),
("Aktiv", "aktiv", "bool", False),
("Notizen", "notizen", "text", False),
],
},
"foerderungen": {
"model": Foerderung,
"label": "Förderungen",
"unique_fields": [],
"fields": [
("Destinatär Vorname", "_destinataer_vorname", "text", True),
("Destinatär Nachname", "_destinataer_nachname", "text", True),
("Jahr", "jahr", "int", True),
("Betrag", "betrag", "decimal", True),
("Kategorie", "kategorie", "text", False),
("Status", "status", "text", False),
("Antragsdatum", "antragsdatum", "date", False),
("Entscheidungsdatum", "entscheidungsdatum", "date", False),
("Bemerkungen", "bemerkungen", "text", False),
],
},
"konten": {
"model": StiftungsKonto,
"label": "Stiftungskonten",
"unique_fields": ["iban"],
"fields": [
("Kontoname", "kontoname", "text", True),
("Bank", "bank_name", "text", False),
("IBAN", "iban", "text", False),
("BIC", "bic", "text", False),
("Konto Typ", "konto_typ", "text", False),
("Saldo", "saldo", "decimal", False),
("Zinssatz", "zinssatz", "decimal", False),
("Aktiv", "aktiv", "bool", False),
("Notizen", "notizen", "text", False),
],
},
"verwaltungskosten": {
"model": Verwaltungskosten,
"label": "Verwaltungskosten",
"unique_fields": [],
"fields": [
("Bezeichnung", "bezeichnung", "text", True),
("Kategorie", "kategorie", "text", False),
("Betrag", "betrag", "decimal", True),
("Datum", "datum", "date", True),
("Lieferant", "lieferant_firma", "text", False),
("Rechnungsnummer", "rechnungsnummer", "text", False),
("Status", "status", "text", False),
("Beschreibung", "beschreibung", "text", False),
("Notizen", "notizen", "text", False),
],
},
"rentmeister": {
"model": Rentmeister,
"label": "Rentmeister",
"unique_fields": ["vorname", "nachname"],
"fields": [
("Anrede", "anrede", "text", False),
("Vorname", "vorname", "text", True),
("Nachname", "nachname", "text", True),
("Titel", "titel", "text", False),
("E-Mail", "email", "text", False),
("Telefon", "telefon", "text", False),
("Mobil", "mobil", "text", False),
("Straße", "strasse", "text", False),
("PLZ", "plz", "text", False),
("Ort", "ort", "text", False),
("IBAN", "iban", "text", False),
("BIC", "bic", "text", False),
("Bank", "bank_name", "text", False),
("Seit", "seit_datum", "date", False),
("Bis", "bis_datum", "date", False),
("Monatliche Vergütung", "monatliche_verguetung", "decimal", False),
("Km Pauschale", "km_pauschale", "decimal", False),
("Aktiv", "aktiv", "bool", False),
("Notizen", "notizen", "text", False),
],
},
}
# ---------------------------------------------------------------------------
# Value parsing helpers
# ---------------------------------------------------------------------------
def _parse_bool(value):
if not value:
return None
v = str(value).strip().lower()
if v in ("true", "ja", "yes", "1", "wahr", "x"):
return True
if v in ("false", "nein", "no", "0", "falsch", ""):
return False
return None
def _parse_date(value):
if not value or not str(value).strip():
return None
v = str(value).strip()
for fmt in ("%d.%m.%Y", "%Y-%m-%d", "%d/%m/%Y"):
try:
return datetime.strptime(v, fmt).date()
except ValueError:
continue
return None
def _parse_decimal(value):
if not value or not str(value).strip():
return None
v = str(value).strip().replace(",", ".")
try:
return Decimal(v)
except (InvalidOperation, ValueError):
return None
def _parse_int(value):
if not value or not str(value).strip():
return None
try:
return int(str(value).strip().replace(",", "").replace(".", ""))
except ValueError:
return None
def _parse_value(raw, field_type):
"""Parse a raw CSV string into the appropriate Python type."""
if field_type == "date":
return _parse_date(raw)
elif field_type == "decimal":
return _parse_decimal(raw)
elif field_type == "int":
return _parse_int(raw)
elif field_type == "bool":
return _parse_bool(raw)
else:
return str(raw).strip() if raw else None
# ---------------------------------------------------------------------------
# Views
# ---------------------------------------------------------------------------
@login_required
def import_export_hub(request):
"""Unified import/export hub page."""
# Get recent imports for display
recent_imports = CSVImport.objects.all().order_by("-started_at")[:10]
# Count records per entity type
entity_counts = {}
for key, defn in EXPORT_DEFINITIONS.items():
try:
entity_counts[key] = defn["model"].objects.count()
except Exception:
entity_counts[key] = 0
export_types = [
{"key": k, "label": v["label"], "count": entity_counts.get(k, 0)}
for k, v in EXPORT_DEFINITIONS.items()
]
import_types = [
{"key": k, "label": v["label"]}
for k, v in IMPORT_FIELD_DEFINITIONS.items()
]
context = {
"export_types": export_types,
"import_types": import_types,
"recent_imports": recent_imports,
}
return render(request, "stiftung/import_export_hub.html", context)
@login_required
def csv_export(request):
"""Export any entity type as CSV."""
export_type = request.GET.get("type")
if export_type not in EXPORT_DEFINITIONS:
messages.error(request, "Unbekannter Export-Typ.")
return redirect("stiftung:import_export_hub")
defn = EXPORT_DEFINITIONS[export_type]
queryset = defn["queryset"]()
response = HttpResponse(content_type="text/csv; charset=utf-8")
response["Content-Disposition"] = f'attachment; filename="{export_type}_{timezone.now().strftime("%Y%m%d_%H%M")}.csv"'
# BOM for Excel compatibility
response.write("\ufeff")
writer = csv.writer(response, delimiter=";")
headers = [f[0] for f in defn["fields"]]
writer.writerow(headers)
for obj in queryset:
row = []
for _header, extractor in defn["fields"]:
try:
row.append(extractor(obj))
except Exception:
row.append("")
writer.writerow(row)
return response
@login_required
def csv_import_upload(request):
"""Step 1: Upload CSV file and show field mapping UI."""
if request.method != "POST":
return redirect("stiftung:import_export_hub")
import_type = request.POST.get("import_type")
csv_file = request.FILES.get("csv_file")
if not csv_file or not import_type:
messages.error(request, "Bitte wählen Sie einen Import-Typ und eine CSV-Datei aus.")
return redirect("stiftung:import_export_hub")
if not csv_file.name.lower().endswith(".csv"):
messages.error(request, "Bitte wählen Sie eine gültige CSV-Datei aus.")
return redirect("stiftung:import_export_hub")
if import_type not in IMPORT_FIELD_DEFINITIONS:
messages.error(request, "Unbekannter Import-Typ.")
return redirect("stiftung:import_export_hub")
defn = IMPORT_FIELD_DEFINITIONS[import_type]
try:
raw_bytes = csv_file.read()
# Try UTF-8 first, fallback to latin-1
try:
decoded = raw_bytes.decode("utf-8-sig")
except UnicodeDecodeError:
decoded = raw_bytes.decode("latin-1")
# Detect delimiter
first_line = decoded.split("\n")[0]
delimiter = ";" if ";" in first_line else ","
reader = csv.reader(io.StringIO(decoded), delimiter=delimiter)
csv_headers = next(reader)
csv_headers = [h.strip() for h in csv_headers]
# Read preview rows (up to 5)
preview_rows = []
for i, row in enumerate(reader):
if i >= 5:
break
preview_rows.append(row)
# Count remaining rows
remaining = sum(1 for _ in reader)
total_rows = len(preview_rows) + remaining
# Auto-match CSV headers to model fields
# Build mapping suggestions based on fuzzy matching
model_fields = defn["fields"] # list of (label, field_name, type, required)
auto_mapping = {}
for csv_idx, csv_header in enumerate(csv_headers):
csv_h_lower = csv_header.lower().replace("_", " ").replace("-", " ").strip()
best_match = ""
best_score = 0
for label, field_name, _ftype, _req in model_fields:
label_lower = label.lower().replace("_", " ").replace("-", " ").strip()
field_lower = field_name.lower().replace("_", " ").strip()
# Exact match
if csv_h_lower == label_lower or csv_h_lower == field_lower:
best_match = field_name
best_score = 100
break
# Partial match
if csv_h_lower in label_lower or label_lower in csv_h_lower:
score = 80
if score > best_score:
best_match = field_name
best_score = score
elif csv_h_lower in field_lower or field_lower in csv_h_lower:
score = 70
if score > best_score:
best_match = field_name
best_score = score
if best_score >= 70:
auto_mapping[str(csv_idx)] = best_match
# Build header_previews: list of dicts with header + first-row preview
header_previews = []
first_row = preview_rows[0] if preview_rows else []
for idx, header in enumerate(csv_headers):
preview_val = first_row[idx] if idx < len(first_row) else ""
header_previews.append({"header": header, "preview": preview_val})
# Store CSV data in session for step 2
request.session["csv_import_data"] = decoded
request.session["csv_import_delimiter"] = delimiter
request.session["csv_import_type"] = import_type
request.session["csv_import_filename"] = csv_file.name
request.session["csv_import_filesize"] = len(raw_bytes)
context = {
"import_type": import_type,
"import_label": defn["label"],
"header_previews": header_previews,
"model_fields": model_fields,
"preview_rows": preview_rows,
"total_rows": total_rows,
"filename": csv_file.name,
"auto_mapping_json": json.dumps(auto_mapping),
}
return render(request, "stiftung/csv_import_mapping.html", context)
except Exception as e:
messages.error(request, f"Fehler beim Lesen der CSV-Datei: {str(e)}")
return redirect("stiftung:import_export_hub")
@login_required
def csv_import_execute(request):
"""Step 2: Execute the import with user-defined field mapping."""
if request.method != "POST":
return redirect("stiftung:import_export_hub")
import_type = request.session.get("csv_import_type")
csv_data = request.session.get("csv_import_data")
delimiter = request.session.get("csv_import_delimiter", ",")
filename = request.session.get("csv_import_filename", "unknown.csv")
filesize = request.session.get("csv_import_filesize", 0)
if not import_type or not csv_data:
messages.error(request, "Keine Import-Daten gefunden. Bitte starten Sie den Import erneut.")
return redirect("stiftung:import_export_hub")
if import_type not in IMPORT_FIELD_DEFINITIONS:
messages.error(request, "Unbekannter Import-Typ.")
return redirect("stiftung:import_export_hub")
defn = IMPORT_FIELD_DEFINITIONS[import_type]
# Import mode: merge (update existing), skip (skip existing), create (always new)
import_mode = request.POST.get("import_mode", "merge")
if import_mode not in ("merge", "skip", "create"):
import_mode = "merge"
# Parse field mapping from POST data
# Format: mapping_0=field_name, mapping_1=field_name, ...
reader = csv.reader(io.StringIO(csv_data), delimiter=delimiter)
csv_headers = next(reader)
csv_headers = [h.strip() for h in csv_headers]
field_mapping = {} # csv_index -> (model_field, field_type)
field_types = {f[1]: f[2] for f in defn["fields"]}
for i in range(len(csv_headers)):
mapped_field = request.POST.get(f"mapping_{i}", "")
if mapped_field and mapped_field != "__skip__":
ftype = field_types.get(mapped_field, "text")
field_mapping[i] = (mapped_field, ftype)
if not field_mapping:
messages.error(request, "Keine Felder zugeordnet. Bitte ordnen Sie mindestens ein Feld zu.")
return redirect("stiftung:import_export_hub")
# Check required fields - warn but don't block (per-row validation will handle it)
required_fields = {f[1] for f in defn["fields"] if f[3]}
mapped_fields = {v[0] for v in field_mapping.values()}
missing_required = required_fields - mapped_fields
missing_required = {f for f in missing_required if not f.startswith("_")}
if missing_required:
field_labels = {f[1]: f[0] for f in defn["fields"]}
missing_labels = [field_labels.get(f, f) for f in missing_required]
messages.warning(
request,
f"Hinweis: Pflichtfelder nicht zugeordnet: {', '.join(missing_labels)}. "
f"Zeilen ohne diese Daten werden übersprungen."
)
# Create import record
csv_import = CSVImport.objects.create(
import_type=import_type,
filename=filename,
file_size=filesize,
created_by=request.user.username if request.user.is_authenticated else "Unknown",
status="processing",
)
# Process the import
model = defn["model"]
unique_fields = defn["unique_fields"]
unique_fields_alt = defn.get("unique_fields_alt", [])
total_rows = 0
imported_rows = 0
failed_rows = 0
skipped_rows = 0
error_log = []
reader = csv.reader(io.StringIO(csv_data), delimiter=delimiter)
next(reader) # Skip header
for row_num, row in enumerate(reader, start=2):
total_rows += 1
try:
# Build data dict from mapping
data = {}
for csv_idx, (model_field, field_type) in field_mapping.items():
if csv_idx < len(row):
raw_value = row[csv_idx]
parsed = _parse_value(raw_value, field_type)
data[model_field] = parsed
# Special handling for Förderungen (link to Destinatär)
if import_type == "foerderungen":
vorname = data.pop("_destinataer_vorname", None)
nachname = data.pop("_destinataer_nachname", None)
if vorname and nachname:
dest = Destinataer.objects.filter(
vorname__iexact=vorname, nachname__iexact=nachname
).first()
if dest:
data["destinataer"] = dest
else:
error_log.append(
f"Zeile {row_num}: Destinatär '{vorname} {nachname}' nicht gefunden"
)
failed_rows += 1
continue
else:
error_log.append(f"Zeile {row_num}: Destinatär Vor-/Nachname erforderlich")
failed_rows += 1
continue
# Validate required fields
required_missing = []
for label, field_name, _ftype, required in defn["fields"]:
if required and not field_name.startswith("_"):
val = data.get(field_name)
if val is None or (isinstance(val, str) and not val.strip()):
required_missing.append(label)
if required_missing:
error_log.append(
f"Zeile {row_num}: Pflichtfelder leer: {', '.join(required_missing)}"
)
failed_rows += 1
continue
# Clean None values from data - don't set fields that weren't mapped
clean_data = {k: v for k, v in data.items() if v is not None and not k.startswith("_")}
# For fields not in clean_data, check if the DB column requires
# a value (NOT NULL without a model default). If so, provide a
# sensible zero-value so the INSERT doesn't fail.
for fname, ftype in ((f[1], f[2]) for f in defn["fields"]):
if fname.startswith("_") or fname in clean_data:
continue
try:
mf = model._meta.get_field(fname)
if not mf.null and not mf.has_default():
if ftype == "decimal":
clean_data[fname] = Decimal("0")
elif ftype == "int":
clean_data[fname] = 0
elif ftype == "bool":
clean_data[fname] = False
elif ftype == "text":
# For unique text fields, generate a value
# instead of empty string to avoid unique violations
if mf.unique:
import uuid as _uuid
clean_data[fname] = f"AUTO-{_uuid.uuid4().hex[:8]}"
else:
clean_data[fname] = ""
except Exception:
pass
# Try to find existing record using user-mapped data (not auto-generated defaults)
existing = None
if import_mode != "create":
# Use original user data (before defaults) for dedup lookup
for uf_set in ([unique_fields] if unique_fields else []) + ([unique_fields_alt] if unique_fields_alt else []):
if existing:
break
lookup = {}
for uf in uf_set:
val = data.get(uf)
if val and (not isinstance(val, str) or val.strip()):
lookup[f"{uf}__iexact"] = val if isinstance(val, str) else val
else:
lookup = None
break
if lookup:
existing = model.objects.filter(**lookup).first()
if existing:
if import_mode == "skip":
skipped_rows += 1
continue
else:
# Merge mode: update existing record with mapped values
for field, value in clean_data.items():
setattr(existing, field, value)
existing.save()
else:
model.objects.create(**clean_data)
imported_rows += 1
except Exception as e:
error_log.append(f"Zeile {row_num}: {str(e)}")
failed_rows += 1
# Determine status
if failed_rows == 0 and (imported_rows > 0 or skipped_rows > 0):
status = "completed"
elif imported_rows > 0 or skipped_rows > 0:
status = "partial"
elif total_rows == 0:
status = "completed"
else:
status = "failed"
# Build skip info for error log
if skipped_rows > 0:
error_log.insert(0, f"Übersprungen: {skipped_rows} bereits vorhandene Einträge")
# Update import record
csv_import.total_rows = total_rows
csv_import.imported_rows = imported_rows
csv_import.failed_rows = failed_rows
csv_import.error_log = "\n".join(error_log) if error_log else None
csv_import.status = status
csv_import.completed_at = timezone.now()
csv_import.save()
# Clean session
for key in ["csv_import_data", "csv_import_delimiter", "csv_import_type",
"csv_import_filename", "csv_import_filesize"]:
request.session.pop(key, None)
skip_info = f", {skipped_rows} übersprungen" if skipped_rows > 0 else ""
if status == "completed":
messages.success(
request,
f"Import erfolgreich! {imported_rows} Datensätze importiert{skip_info}.",
)
elif status == "partial":
messages.warning(
request,
f"Import teilweise erfolgreich. {imported_rows} importiert, {failed_rows} fehlgeschlagen{skip_info}.",
)
else:
messages.error(
request,
f"Import fehlgeschlagen. {failed_rows} Zeilen konnten nicht importiert werden{skip_info}.",
)
return redirect("stiftung:import_export_hub")

View File

@@ -14,8 +14,8 @@ import qrcode.image.svg
from django.contrib import messages from django.contrib import messages
from django.contrib.auth.decorators import login_required from django.contrib.auth.decorators import login_required
from django.core.paginator import Paginator from django.core.paginator import Paginator
from django.db.models import (Avg, Count, DecimalField, F, IntegerField, Q, from django.db.models import (Avg, BigIntegerField, Count, DecimalField, F,
Sum, Value) IntegerField, Q, Sum, Value)
from django.db.models.functions import Cast, Coalesce, NullIf, Replace from django.db.models.functions import Cast, Coalesce, NullIf, Replace
from django.http import HttpResponse, JsonResponse from django.http import HttpResponse, JsonResponse
from django.shortcuts import get_object_or_404, redirect, render from django.shortcuts import get_object_or_404, redirect, render
@@ -274,24 +274,25 @@ def land_list(request):
lands = lands.filter(aktiv=False) lands = lands.filter(aktiv=False)
# Annotate with verpachtungsgrad and numeric casts for natural sorting # Annotate with verpachtungsgrad and numeric casts for natural sorting
# Prepare numeric versions of textual fields by stripping common non-digits # Use regexp_replace to strip ALL non-digit characters for safe integer casting
from django.db.models import Func
class RegexpReplace(Func):
function = "REGEXP_REPLACE"
template = "%(function)s(%(expressions)s, '[^0-9]', '', 'g')"
def digits_only(field_expr): def digits_only(field_expr):
expr = Replace(field_expr, Value(" "), Value("")) return RegexpReplace(field_expr)
expr = Replace(expr, Value("-"), Value(""))
expr = Replace(expr, Value("."), Value(""))
expr = Replace(expr, Value("/"), Value(""))
expr = Replace(expr, Value("L"), Value(""))
return expr
lands = lands.extra( lands = lands.extra(
select={ select={
"verpachtungsgrad": "CASE WHEN groesse_qm > 0 THEN (verp_flaeche_aktuell / groesse_qm) * 100 ELSE 0 END" "verpachtungsgrad": "CASE WHEN groesse_qm > 0 THEN (verp_flaeche_aktuell / groesse_qm) * 100 ELSE 0 END"
} }
).annotate( ).annotate(
lfd_nr_num=Cast(NullIf(digits_only(F("lfd_nr")), Value("")), IntegerField()), lfd_nr_num=Cast(NullIf(digits_only(F("lfd_nr")), Value("")), BigIntegerField()),
flur_num=Cast(NullIf(digits_only(F("flur")), Value("")), IntegerField()), flur_num=Cast(NullIf(digits_only(F("flur")), Value("")), BigIntegerField()),
flurstueck_num=Cast( flurstueck_num=Cast(
NullIf(digits_only(F("flurstueck")), Value("")), IntegerField() NullIf(digits_only(F("flurstueck")), Value("")), BigIntegerField()
), ),
) )

View File

@@ -1299,13 +1299,50 @@ def quarterly_confirmation_create(request, destinataer_id):
@login_required @login_required
def quarterly_confirmation_edit(request, pk): def quarterly_confirmation_edit(request, pk):
"""Standalone edit view for quarterly confirmation""" """Standalone edit view for quarterly confirmation"""
from stiftung.models import DokumentDatei
nachweis = get_object_or_404(VierteljahresNachweis, pk=pk) nachweis = get_object_or_404(VierteljahresNachweis, pk=pk)
if request.method == "POST": if request.method == "POST":
# DMS-Dokument entfernen (Verknuepfung loesen)
entferne_dok_id = request.POST.get("entferne_dms_dokument")
if entferne_dok_id:
nachweis.nachweis_dokumente.remove(entferne_dok_id)
messages.success(request, "DMS-Dokument-Verknuepfung entfernt.")
return redirect("stiftung:quarterly_confirmation_edit", pk=pk)
form = VierteljahresNachweisForm(request.POST, request.FILES, instance=nachweis) form = VierteljahresNachweisForm(request.POST, request.FILES, instance=nachweis)
if form.is_valid(): if form.is_valid():
quarterly_proof = form.save(commit=False) quarterly_proof = form.save(commit=False)
# Kategorie-spezifische DMS-Dokumente zuweisen
for field_name, dms_field in [
("studiennachweis_dms_id", "studiennachweis_dms_dokument"),
("einkommenssituation_dms_id", "einkommenssituation_dms_dokument"),
("vermogenssituation_dms_id", "vermogenssituation_dms_dokument"),
]:
dms_id = request.POST.get(field_name)
if dms_id:
try:
dok = DokumentDatei.objects.get(pk=dms_id)
setattr(quarterly_proof, dms_field, dok)
except DokumentDatei.DoesNotExist:
pass
elif dms_id == "":
# Leere Auswahl = Verknuepfung entfernen
setattr(quarterly_proof, dms_field, None)
# Generisches DMS-Dokument hinzufuegen (Abwaertskompatibilitaet)
dms_dok_id = request.POST.get("dms_dokument_hinzufuegen")
if dms_dok_id:
try:
dok = DokumentDatei.objects.get(pk=dms_dok_id)
# Save first so M2M can be set
quarterly_proof.save()
quarterly_proof.nachweis_dokumente.add(dok)
except DokumentDatei.DoesNotExist:
pass
# Calculate current status before saving # Calculate current status before saving
old_status = nachweis.status old_status = nachweis.status
@@ -1368,11 +1405,26 @@ def quarterly_confirmation_edit(request, pk):
else: else:
form = VierteljahresNachweisForm(instance=nachweis) form = VierteljahresNachweisForm(instance=nachweis)
# Alle DMS-Dokumente des Destinataers (fuer Kategorie-Auswahl in den Sektionen)
alle_dms_dokumente = (
DokumentDatei.objects.filter(destinataer=nachweis.destinataer)
.exclude(kontext="email")
.order_by("kontext", "titel")
)
# Generisch verknuepfte Dokumente (M2M) und noch nicht verknuepfte (fuer Bottom-Sektion)
verknuepfte_nachweis_dokumente = nachweis.nachweis_dokumente.all().order_by("kontext", "titel")
verknuepfte_ids = set(verknuepfte_nachweis_dokumente.values_list("pk", flat=True))
verfuegbare_dms_dokumente = alle_dms_dokumente.exclude(pk__in=verknuepfte_ids)
context = { context = {
'form': form, 'form': form,
'nachweis': nachweis, 'nachweis': nachweis,
'destinataer': nachweis.destinataer, 'destinataer': nachweis.destinataer,
'title': f'Vierteljahresnachweis bearbeiten - {nachweis.destinataer.get_full_name()} {nachweis.jahr} Q{nachweis.quartal}', 'title': f'Vierteljahresnachweis bearbeiten - {nachweis.destinataer.get_full_name()} {nachweis.jahr} Q{nachweis.quartal}',
'alle_dms_dokumente': alle_dms_dokumente,
'verknuepfte_nachweis_dokumente': verknuepfte_nachweis_dokumente,
'verfuegbare_dms_dokumente': verfuegbare_dms_dokumente,
} }
return render(request, 'stiftung/quarterly_confirmation_edit.html', context) return render(request, 'stiftung/quarterly_confirmation_edit.html', context)

View File

@@ -661,6 +661,15 @@
</a> </a>
</div> </div>
<!-- Daten -->
<div class="sidebar-section">
<div class="sidebar-heading">Daten</div>
<a class="sidebar-link" href="{% url 'stiftung:import_export_hub' %}">
<i class="fas fa-exchange-alt"></i>
<span>Import & Export</span>
</a>
</div>
<!-- System --> <!-- System -->
<div class="sidebar-section"> <div class="sidebar-section">
<div class="sidebar-heading">System</div> <div class="sidebar-heading">System</div>
@@ -741,7 +750,7 @@
<!-- Footer --> <!-- Footer -->
<footer class="main-footer"> <footer class="main-footer">
&copy; 2026 van Hees-Theyssen-Vogel'sche Stiftung &middot; &copy; 2026 van Hees-Theyssen-Vogel'sche Stiftung &middot;
<small>Vision 2026 &middot; v4.0.0</small> <small>Vision 2026 &middot; v{{ APP_VERSION }}</small>
</footer> </footer>
</main> </main>
@@ -995,5 +1004,464 @@
}); });
})(); })();
</script> </script>
{% if user.is_authenticated and perms.stiftung.can_use_agent %}
<!-- ══════════════════════════════════════════════════════════════
AI Agent Chat-Widget
══════════════════════════════════════════════════════════════ -->
<style>
#agent-fab {
position: fixed;
bottom: 1.5rem;
right: 1.5rem;
z-index: 9000;
width: 52px;
height: 52px;
border-radius: 50%;
background: var(--racing-green);
color: #fff;
border: none;
box-shadow: 0 4px 16px rgba(0,0,0,0.25);
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
font-size: 1.25rem;
transition: background 0.2s, transform 0.15s;
}
#agent-fab:hover { background: var(--racing-green-light); transform: scale(1.07); }
#agent-panel {
position: fixed;
bottom: 5.5rem;
right: 1.5rem;
z-index: 9001;
width: min(420px, calc(100vw - 2rem));
max-height: calc(100vh - 7rem);
background: #fff;
border-radius: 1rem;
box-shadow: 0 8px 40px rgba(0,0,0,0.2);
display: flex;
flex-direction: column;
overflow: hidden;
transform: translateY(10px) scale(0.97);
opacity: 0;
pointer-events: none;
transition: opacity 0.2s, transform 0.2s;
}
#agent-panel.open {
opacity: 1;
transform: translateY(0) scale(1);
pointer-events: all;
}
#agent-header {
background: var(--racing-green);
color: #fff;
padding: 0.75rem 1rem;
display: flex;
align-items: center;
gap: 0.5rem;
flex-shrink: 0;
}
#agent-header h6 { margin: 0; font-size: 0.9rem; font-weight: 600; flex: 1; }
#agent-header button { background: none; border: none; color: rgba(255,255,255,0.7); cursor: pointer; padding: 0 4px; font-size: 1rem; }
#agent-header button:hover { color: #fff; }
#agent-session-bar {
background: #f8f9fa;
border-bottom: 1px solid #e9ecef;
padding: 0.4rem 0.75rem;
display: flex;
align-items: center;
gap: 0.5rem;
flex-shrink: 0;
}
#agent-session-bar select {
flex: 1;
font-size: 0.8rem;
border: 1px solid #dee2e6;
border-radius: 4px;
padding: 2px 6px;
background: #fff;
}
#agent-session-bar button { font-size: 0.75rem; white-space: nowrap; }
#agent-messages {
flex: 1;
overflow-y: auto;
padding: 0.75rem;
display: flex;
flex-direction: column;
gap: 0.5rem;
min-height: 200px;
}
.agent-msg {
max-width: 85%;
padding: 0.5rem 0.75rem;
border-radius: 0.75rem;
font-size: 0.85rem;
line-height: 1.5;
word-break: break-word;
}
.agent-msg.user {
background: var(--racing-green);
color: #fff;
align-self: flex-end;
border-bottom-right-radius: 4px;
}
.agent-msg.assistant {
background: #f0f2f5;
color: #212529;
align-self: flex-start;
border-bottom-left-radius: 4px;
}
.agent-msg.tool-indicator {
background: #fff3cd;
color: #856404;
align-self: flex-start;
font-size: 0.78rem;
padding: 0.3rem 0.6rem;
}
.agent-msg pre { margin: 0; white-space: pre-wrap; font-family: inherit; }
.agent-msg code { background: rgba(0,0,0,0.08); border-radius: 3px; padding: 1px 4px; font-size: 0.82em; }
#agent-input-area {
border-top: 1px solid #e9ecef;
padding: 0.6rem 0.75rem;
display: flex;
gap: 0.5rem;
flex-shrink: 0;
}
#agent-input {
flex: 1;
border: 1px solid #dee2e6;
border-radius: 0.5rem;
padding: 0.4rem 0.65rem;
font-size: 0.85rem;
resize: none;
min-height: 36px;
max-height: 120px;
outline: none;
font-family: inherit;
}
#agent-input:focus { border-color: var(--racing-green); }
#agent-send-btn {
background: var(--racing-green);
color: #fff;
border: none;
border-radius: 0.5rem;
padding: 0 0.75rem;
cursor: pointer;
font-size: 1rem;
transition: background 0.15s;
align-self: flex-end;
height: 36px;
}
#agent-send-btn:disabled { background: #adb5bd; cursor: not-allowed; }
#agent-send-btn:not(:disabled):hover { background: var(--racing-green-light); }
.agent-typing { display: flex; gap: 4px; align-items: center; padding: 0.5rem 0.75rem; }
.agent-typing span {
width: 7px; height: 7px; background: #adb5bd;
border-radius: 50%; animation: agent-bounce 1s infinite;
}
.agent-typing span:nth-child(2) { animation-delay: 0.15s; }
.agent-typing span:nth-child(3) { animation-delay: 0.3s; }
@keyframes agent-bounce {
0%,80%,100% { transform: translateY(0); }
40% { transform: translateY(-6px); }
}
</style>
<!-- FAB Button -->
<button id="agent-fab" title="AI-Assistent öffnen" onclick="agentToggle()">
<i class="fas fa-robot"></i>
</button>
<!-- Chat Panel -->
<div id="agent-panel">
<div id="agent-header">
<i class="fas fa-robot"></i>
<h6>RentmeisterAI</h6>
<button onclick="agentNewSession()" title="Neue Unterhaltung"><i class="fas fa-plus"></i></button>
<button onclick="agentToggle()" title="Schließen"><i class="fas fa-times"></i></button>
</div>
<div id="agent-session-bar">
<select id="agent-session-select" onchange="agentLoadSession(this.value)" title="Sitzung wechseln">
<option value="">— Neue Unterhaltung —</option>
</select>
<button class="btn btn-sm btn-outline-danger" onclick="agentDeleteSession()" title="Sitzung löschen">
<i class="fas fa-trash-alt"></i>
</button>
</div>
<div id="agent-messages">
<div class="agent-msg assistant">
Guten Tag! Ich bin RentmeisterAI. Wie kann ich Ihnen helfen?
</div>
</div>
<div id="agent-input-area">
<textarea id="agent-input" placeholder="Nachricht eingeben… (Enter = senden)" rows="1"
onkeydown="agentKeydown(event)"></textarea>
<button id="agent-send-btn" onclick="agentSend()" title="Senden">
<i class="fas fa-paper-plane"></i>
</button>
</div>
</div>
<script>
(function() {
const CSRF = '{{ csrf_token }}';
let currentSessionId = null;
let isStreaming = false;
window.agentToggle = function() {
const panel = document.getElementById('agent-panel');
const isOpen = panel.classList.contains('open');
panel.classList.toggle('open');
if (!isOpen) {
agentLoadSessions();
document.getElementById('agent-input').focus();
}
};
window.agentNewSession = function() {
currentSessionId = null;
document.getElementById('agent-session-select').value = '';
document.getElementById('agent-messages').innerHTML =
'<div class="agent-msg assistant">Neue Unterhaltung gestartet. Wie kann ich helfen?</div>';
};
window.agentKeydown = function(e) {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
agentSend();
}
};
window.agentSend = async function() {
if (isStreaming) return;
const input = document.getElementById('agent-input');
const msg = input.value.trim();
if (!msg) return;
input.value = '';
autoResizeTextarea(input);
appendMessage('user', msg);
const typingEl = appendTyping();
setSending(true);
// Seitenkontext: URL + Titel
const pageContext = `Seite: ${document.title}\nURL: ${window.location.href}`;
try {
const res = await fetch('/agent/chat/', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': CSRF,
},
body: JSON.stringify({
message: msg,
session_id: currentSessionId,
page_context: pageContext,
}),
});
if (!res.ok) {
const err = await res.json().catch(() => ({}));
throw new Error(err.error || `HTTP ${res.status}`);
}
const data = await res.json();
currentSessionId = data.session_id;
// Session-Select aktualisieren
updateSessionSelect(data.session_id);
// SSE-Stream starten
typingEl.remove();
await agentStream(data.stream_url);
} catch (e) {
typingEl.remove();
appendMessage('assistant', `Fehler: ${e.message}`);
} finally {
setSending(false);
}
};
async function agentStream(url) {
const msgEl = appendMessage('assistant', '');
const contentEl = msgEl.querySelector('.agent-text');
return new Promise((resolve) => {
const es = new EventSource(url);
es.onmessage = function(e) {
try {
const chunk = JSON.parse(e.data);
if (chunk.type === 'text') {
contentEl.textContent += chunk.content;
scrollMessages();
} else if (chunk.type === 'tool_start') {
appendToolIndicator(chunk.name);
} else if (chunk.type === 'done') {
es.close();
renderMarkdown(contentEl);
agentLoadSessions();
resolve();
} else if (chunk.type === 'error') {
contentEl.textContent = `Fehler: ${chunk.message}`;
es.close();
resolve();
}
} catch(err) { /* ignore parse errors */ }
};
es.onerror = function() {
es.close();
if (!contentEl.textContent) {
contentEl.textContent = 'Verbindungsfehler.';
}
resolve();
};
});
}
function appendMessage(role, text) {
const messages = document.getElementById('agent-messages');
const div = document.createElement('div');
div.className = `agent-msg ${role}`;
const span = document.createElement('span');
span.className = 'agent-text';
span.textContent = text;
div.appendChild(span);
messages.appendChild(div);
scrollMessages();
return div;
}
function appendTyping() {
const messages = document.getElementById('agent-messages');
const div = document.createElement('div');
div.className = 'agent-msg assistant agent-typing-wrap';
div.innerHTML = '<div class="agent-typing"><span></span><span></span><span></span></div>';
messages.appendChild(div);
scrollMessages();
return div;
}
function appendToolIndicator(toolName) {
const messages = document.getElementById('agent-messages');
const div = document.createElement('div');
div.className = 'agent-msg tool-indicator';
div.innerHTML = `<i class="fas fa-cog fa-spin me-1"></i> Werkzeug: <em>${escHtml(toolName)}</em>`;
messages.appendChild(div);
scrollMessages();
}
function renderMarkdown(el) {
// Simple markdown: **bold**, `code`, newlines
let html = escHtml(el.textContent)
.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>')
.replace(/`([^`]+)`/g, '<code>$1</code>')
.replace(/\n/g, '<br>');
el.innerHTML = html;
}
function scrollMessages() {
const m = document.getElementById('agent-messages');
m.scrollTop = m.scrollHeight;
}
function setSending(v) {
isStreaming = v;
document.getElementById('agent-send-btn').disabled = v;
document.getElementById('agent-input').disabled = v;
}
function autoResizeTextarea(el) {
el.style.height = 'auto';
el.style.height = Math.min(el.scrollHeight, 120) + 'px';
}
document.addEventListener('DOMContentLoaded', function() {
const input = document.getElementById('agent-input');
if (input) input.addEventListener('input', () => autoResizeTextarea(input));
});
window.agentLoadSessions = async function() {
try {
const res = await fetch('/agent/sessions/');
if (!res.ok) return;
const data = await res.json();
const sel = document.getElementById('agent-session-select');
// Keep current value
const current = sel.value;
sel.innerHTML = '<option value="">— Neue Unterhaltung —</option>';
data.sessions.forEach(s => {
const opt = document.createElement('option');
opt.value = s.id;
opt.textContent = (s.title || 'Unterhaltung').substring(0, 40);
sel.appendChild(opt);
});
if (currentSessionId) sel.value = currentSessionId;
else if (current) sel.value = current;
} catch(e) { /* ignore */ }
};
window.agentLoadSession = async function(sessionId) {
if (!sessionId) { agentNewSession(); return; }
currentSessionId = sessionId;
try {
const res = await fetch(`/agent/sessions/${sessionId}/`);
if (!res.ok) return;
const data = await res.json();
const messages = document.getElementById('agent-messages');
messages.innerHTML = '';
data.messages.forEach(m => {
if (m.role === 'tool') return;
const div = appendMessage(m.role, m.content);
renderMarkdown(div.querySelector('.agent-text'));
});
if (data.messages.length === 0) {
appendMessage('assistant', 'Wie kann ich Ihnen helfen?');
}
} catch(e) { /* ignore */ }
};
window.agentDeleteSession = async function() {
if (!currentSessionId) return;
if (!confirm('Diese Unterhaltung wirklich löschen?')) return;
try {
await fetch(`/agent/sessions/${currentSessionId}/loeschen/`, {
method: 'POST',
headers: { 'X-CSRFToken': CSRF },
});
agentNewSession();
agentLoadSessions();
} catch(e) { /* ignore */ }
};
function updateSessionSelect(id) {
const sel = document.getElementById('agent-session-select');
let found = false;
for (const opt of sel.options) { if (opt.value === id) { found = true; break; } }
if (!found) {
const opt = document.createElement('option');
opt.value = id;
opt.textContent = '(aktuelle Unterhaltung)';
sel.appendChild(opt);
}
sel.value = id;
}
function escHtml(s) {
return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
}
})();
</script>
{% endif %}
</body> </body>
</html> </html>

View File

@@ -247,6 +247,12 @@
<span>Destinatärunterstützungen</span> <span>Destinatärunterstützungen</span>
</a> </a>
</div> </div>
<div class="col-md-3 mb-3">
<a href="/admin/stiftung/agentconfig/" class="btn btn-outline-warning w-100" target="_blank">
<i class="fas fa-robot d-block mb-2 fa-2x"></i>
<span>AI Agent Konfiguration</span>
</a>
</div>
<div class="col-md-3 mb-3"> <div class="col-md-3 mb-3">
<a href="/admin/" class="btn btn-outline-warning w-100" target="_blank"> <a href="/admin/" class="btn btn-outline-warning w-100" target="_blank">
<i class="fas fa-user-shield d-block mb-2 fa-2x"></i> <i class="fas fa-user-shield d-block mb-2 fa-2x"></i>

View File

@@ -0,0 +1,208 @@
{% extends 'base.html' %}
{% block title %}CSV Import Feldzuordnung{% endblock %}
{% block content %}
<div class="row">
<div class="col-12">
<div class="d-flex justify-content-between align-items-center mb-4">
<h1><i class="fas fa-columns text-primary"></i> Feldzuordnung: {{ import_label }}</h1>
<a href="{% url 'stiftung:import_export_hub' %}" class="btn btn-outline-secondary">
<i class="fas fa-arrow-left"></i> Zurück
</a>
</div>
<div class="alert alert-info">
<i class="fas fa-file-csv"></i>
<strong>{{ filename }}</strong> {{ total_rows }} Datenzeilen erkannt.
Ordnen Sie die CSV-Spalten den Datenbankfeldern zu. Nicht zugeordnete Spalten werden übersprungen.
</div>
<form method="post" action="{% url 'stiftung:csv_import_execute' %}">
{% csrf_token %}
<!-- Mapping Table -->
<div class="card shadow-sm mb-4">
<div class="card-header bg-primary text-white">
<h5 class="card-title mb-0">
<i class="fas fa-project-diagram"></i> Spalten zuordnen
</h5>
</div>
<div class="card-body p-0">
<div class="table-responsive">
<table class="table table-hover mb-0">
<thead class="table-light">
<tr>
<th style="width: 5%;">#</th>
<th style="width: 25%;">CSV-Spalte</th>
<th style="width: 5%;"></th>
<th style="width: 30%;">Zuordnung</th>
<th style="width: 35%;">Vorschau</th>
</tr>
</thead>
<tbody>
{% for col in header_previews %}
<tr>
<td class="text-muted">{{ forloop.counter }}</td>
<td><code>{{ col.header }}</code></td>
<td class="text-center text-muted"><i class="fas fa-arrow-right"></i></td>
<td>
<select name="mapping_{{ forloop.counter0 }}" class="form-select form-select-sm mapping-select"
data-col-index="{{ forloop.counter0 }}">
<option value="__skip__"> Überspringen </option>
{% for field in model_fields %}
<option value="{{ field.1 }}">
{{ field.0 }}{% if field.3 %} *{% endif %}
({{ field.2 }})
</option>
{% endfor %}
</select>
</td>
<td>
<small class="text-muted">{{ col.preview|truncatechars:60 }}</small>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
<div class="card-footer">
<small class="text-muted">* = Pflichtfeld. Zuordnungen werden automatisch vorgeschlagen und können manuell geändert werden.</small>
</div>
</div>
<!-- Preview Table -->
{% if preview_rows %}
<div class="card shadow-sm mb-4">
<div class="card-header bg-light">
<h5 class="card-title mb-0">
<i class="fas fa-table"></i> Vorschau (erste {{ preview_rows|length }} Zeilen)
</h5>
</div>
<div class="card-body p-0">
<div class="table-responsive">
<table class="table table-sm table-striped mb-0">
<thead class="table-light">
<tr>
{% for col in header_previews %}
<th><small>{{ col.header }}</small></th>
{% endfor %}
</tr>
</thead>
<tbody>
{% for row in preview_rows %}
<tr>
{% for cell in row %}
<td><small>{{ cell|truncatechars:40 }}</small></td>
{% endfor %}
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
{% endif %}
<!-- Import Mode -->
<div class="card shadow-sm mb-4">
<div class="card-header bg-light">
<h5 class="card-title mb-0">
<i class="fas fa-cog"></i> Import-Modus
</h5>
</div>
<div class="card-body">
<div class="row">
<div class="col-md-4">
<div class="form-check">
<input class="form-check-input" type="radio" name="import_mode" id="mode_skip" value="skip" checked>
<label class="form-check-label" for="mode_skip">
<strong>Nur neue importieren</strong>
<br><small class="text-muted">Bereits vorhandene Einträge werden übersprungen</small>
</label>
</div>
</div>
<div class="col-md-4">
<div class="form-check">
<input class="form-check-input" type="radio" name="import_mode" id="mode_merge" value="merge">
<label class="form-check-label" for="mode_merge">
<strong>Zusammenführen</strong>
<br><small class="text-muted">Vorhandene Einträge werden mit neuen Daten aktualisiert</small>
</label>
</div>
</div>
<div class="col-md-4">
<div class="form-check">
<input class="form-check-input" type="radio" name="import_mode" id="mode_create" value="create">
<label class="form-check-label" for="mode_create">
<strong>Alle neu anlegen</strong>
<br><small class="text-muted">Keine Duplikatprüfung, alle Zeilen als neue Einträge</small>
</label>
</div>
</div>
</div>
</div>
</div>
<!-- Actions -->
<div class="d-flex justify-content-between">
<a href="{% url 'stiftung:import_export_hub' %}" class="btn btn-outline-secondary">
<i class="fas fa-times"></i> Abbrechen
</a>
<button type="submit" class="btn btn-primary btn-lg">
<i class="fas fa-upload"></i> {{ total_rows }} Zeilen importieren
</button>
</div>
</form>
</div>
</div>
{% endblock %}
{% block javascript %}
<script>
// Auto-apply mapping from server
const autoMapping = {{ auto_mapping_json|safe }};
document.addEventListener('DOMContentLoaded', function() {
for (const [colIdx, fieldName] of Object.entries(autoMapping)) {
const select = document.querySelector(`select[name="mapping_${colIdx}"]`);
if (select) {
for (const opt of select.options) {
if (opt.value === fieldName) {
opt.selected = true;
break;
}
}
}
}
// Highlight duplicate mappings
const selects = document.querySelectorAll('.mapping-select');
selects.forEach(sel => {
sel.addEventListener('change', highlightDuplicates);
});
highlightDuplicates();
});
function highlightDuplicates() {
const selects = document.querySelectorAll('.mapping-select');
const valueCount = {};
selects.forEach(sel => {
const val = sel.value;
if (val && val !== '__skip__') {
valueCount[val] = (valueCount[val] || 0) + 1;
}
});
selects.forEach(sel => {
const val = sel.value;
if (val && val !== '__skip__' && valueCount[val] > 1) {
sel.classList.add('is-invalid');
} else {
sel.classList.remove('is-invalid');
}
});
}
</script>
{% endblock %}

View File

@@ -101,11 +101,43 @@
</div> </div>
</div> </div>
{# E-Mail-Dokument (Cover-Email als DMS-Dokument) #}
{% if email_dokument %}
<div class="card mb-4 border-primary">
<div class="card-header bg-primary text-white">
<i class="fas fa-envelope me-2"></i>E-Mail als Dokument
</div>
<div class="card-body">
<div class="d-flex justify-content-between align-items-center">
<div>
<strong>{{ email_dokument.titel }}</strong>
<br><small class="text-muted">{{ email_dokument.get_human_size }} &middot; Erstellt {{ email_dokument.erstellt_am|date:"d.m.Y H:i" }}</small>
</div>
<div class="btn-group btn-group-sm">
<a href="{% url 'stiftung:dms_download' email_dokument.pk %}" class="btn btn-outline-primary" title="Herunterladen">
<i class="fas fa-download me-1"></i>Herunterladen
</a>
<a href="{% url 'stiftung:dms_detail' email_dokument.pk %}" class="btn btn-outline-secondary" title="Im DMS anzeigen">
<i class="fas fa-external-link-alt me-1"></i>Im DMS
</a>
</div>
</div>
{% if anhaenge_dokumente %}
<hr class="my-2">
<small class="text-muted"><i class="fas fa-paperclip me-1"></i>Diese E-Mail hat {{ anhaenge_dokumente|length }} Anhang/Anhaenge (siehe unten)</small>
{% endif %}
</div>
</div>
{% endif %}
{# Anhaenge / DMS-Dokumente #} {# Anhaenge / DMS-Dokumente #}
{% if dms_dokumente %} {% if anhaenge_dokumente %}
<div class="card mb-4"> <div class="card mb-4">
<div class="card-header"> <div class="card-header">
<i class="fas fa-paperclip me-2"></i>Anhaenge ({{ dms_dokumente|length }}) <i class="fas fa-paperclip me-2"></i>Anhaenge ({{ anhaenge_dokumente|length }})
{% if email_dokument %}
<small class="text-muted ms-2">gehoeren zur obigen E-Mail</small>
{% endif %}
</div> </div>
<div class="card-body p-0"> <div class="card-body p-0">
<table class="table mb-0"> <table class="table mb-0">
@@ -118,17 +150,22 @@
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{% for dok in dms_dokumente %} {% for dok in anhaenge_dokumente %}
<tr> <tr>
<td>{{ dok.dateiname_original|default:dok.titel }}</td> <td>{{ dok.dateiname_original|default:dok.titel }}</td>
<td><span class="text-muted small">{{ dok.dateityp|default:"" }}</span></td> <td><span class="text-muted small">{{ dok.dateityp|default:"" }}</span></td>
<td><span class="text-muted small">{{ dok.get_human_size }}</span></td> <td><span class="text-muted small">{{ dok.get_human_size }}</span></td>
<td> <td>
{% if dok.datei %} <div class="btn-group btn-group-sm">
<a href="{% url 'stiftung:dms_download' dok.pk %}" class="btn btn-sm btn-outline-primary"> {% if dok.datei %}
<i class="fas fa-download me-1"></i>Herunterladen <a href="{% url 'stiftung:dms_download' dok.pk %}" class="btn btn-outline-primary" title="Herunterladen">
</a> <i class="fas fa-download"></i>
{% endif %} </a>
{% endif %}
<a href="{% url 'stiftung:dms_detail' dok.pk %}" class="btn btn-outline-secondary" title="Im DMS anzeigen">
<i class="fas fa-external-link-alt"></i>
</a>
</div>
</td> </td>
</tr> </tr>
{% endfor %} {% endfor %}
@@ -136,7 +173,7 @@
</table> </table>
</div> </div>
</div> </div>
{% else %} {% elif not email_dokument %}
<div class="card mb-4"> <div class="card mb-4">
<div class="card-body text-muted text-center py-3"> <div class="card-body text-muted text-center py-3">
<i class="fas fa-paperclip me-1"></i>Keine Anhaenge in dieser E-Mail. <i class="fas fa-paperclip me-1"></i>Keine Anhaenge in dieser E-Mail.

View File

@@ -0,0 +1,187 @@
{% extends 'base.html' %}
{% block title %}Daten Import & Export - Stiftungsverwaltung{% endblock %}
{% block content %}
<div class="row">
<div class="col-12">
<div class="d-flex justify-content-between align-items-center mb-4">
<h1><i class="fas fa-exchange-alt text-primary"></i> Daten Import & Export</h1>
</div>
<!-- Export Section -->
<div class="card shadow-sm mb-4">
<div class="card-header bg-success text-white">
<h5 class="card-title mb-0">
<i class="fas fa-download"></i> CSV Export
</h5>
</div>
<div class="card-body">
<p class="text-muted mb-3">Exportieren Sie beliebige Daten als CSV-Datei (Semikolon-getrennt, UTF-8 mit BOM für Excel-Kompatibilität).</p>
<div class="row">
{% for et in export_types %}
<div class="col-md-4 col-lg-3 mb-3">
<div class="card h-100 border">
<div class="card-body text-center">
<h6 class="card-title">{{ et.label }}</h6>
<p class="text-muted small mb-2">{{ et.count }} Datensätze</p>
<a href="{% url 'stiftung:csv_export' %}?type={{ et.key }}"
class="btn btn-outline-success btn-sm {% if et.count == 0 %}disabled{% endif %}">
<i class="fas fa-download"></i> Exportieren
</a>
</div>
</div>
</div>
{% endfor %}
</div>
</div>
</div>
<!-- Import Section -->
<div class="card shadow-sm mb-4">
<div class="card-header bg-primary text-white">
<h5 class="card-title mb-0">
<i class="fas fa-upload"></i> CSV Import
</h5>
</div>
<div class="card-body">
<div class="row">
<div class="col-lg-8">
<p class="text-muted mb-3">Importieren Sie Daten aus CSV-Dateien. Im nächsten Schritt können Sie die Spalten Ihrer CSV-Datei den Datenbankfeldern zuordnen.</p>
<form method="post" action="{% url 'stiftung:csv_import_upload' %}" enctype="multipart/form-data">
{% csrf_token %}
<div class="row">
<div class="col-md-5 mb-3">
<label for="import_type" class="form-label"><strong>Import-Typ</strong></label>
<select name="import_type" id="import_type" class="form-select" required>
<option value="">Bitte wählen...</option>
{% for it in import_types %}
<option value="{{ it.key }}">{{ it.label }}</option>
{% endfor %}
</select>
</div>
<div class="col-md-5 mb-3">
<label for="csv_file" class="form-label"><strong>CSV-Datei</strong></label>
<input type="file" name="csv_file" id="csv_file" class="form-control" accept=".csv" required>
</div>
<div class="col-md-2 mb-3 d-flex align-items-end">
<button type="submit" class="btn btn-primary w-100">
<i class="fas fa-arrow-right"></i> Weiter
</button>
</div>
</div>
</form>
</div>
<div class="col-lg-4">
<div class="alert alert-info mb-0">
<h6><i class="fas fa-info-circle"></i> Hinweise</h6>
<ul class="small mb-0">
<li>Unterstützte Formate: CSV (Komma oder Semikolon)</li>
<li>Zeichenkodierung: UTF-8 oder Latin-1</li>
<li>Datumsformate: TT.MM.JJJJ oder JJJJ-MM-TT</li>
<li>Bestehende Datensätze werden anhand eindeutiger Felder aktualisiert</li>
<li>Im nächsten Schritt ordnen Sie CSV-Spalten den Feldern zu</li>
</ul>
</div>
</div>
</div>
</div>
</div>
<!-- Recent Imports -->
{% if recent_imports %}
<div class="card shadow-sm">
<div class="card-header bg-light">
<h5 class="card-title mb-0">
<i class="fas fa-history"></i> Letzte Imports
</h5>
</div>
<div class="card-body">
<div class="table-responsive">
<table class="table table-hover table-sm">
<thead class="table-light">
<tr>
<th>Typ</th>
<th>Dateiname</th>
<th>Status</th>
<th>Ergebnis</th>
<th>Benutzer</th>
<th>Datum</th>
<th>Details</th>
</tr>
</thead>
<tbody>
{% for imp in recent_imports %}
<tr>
<td><span class="badge bg-primary">{{ imp.get_import_type_display }}</span></td>
<td>{{ imp.filename }}</td>
<td>
{% if imp.status == 'completed' %}
<span class="badge bg-success"><i class="fas fa-check"></i> OK</span>
{% elif imp.status == 'partial' %}
<span class="badge bg-warning"><i class="fas fa-exclamation-triangle"></i> Teilweise</span>
{% elif imp.status == 'failed' %}
<span class="badge bg-danger"><i class="fas fa-times"></i> Fehler</span>
{% elif imp.status == 'processing' %}
<span class="badge bg-info"><i class="fas fa-spinner fa-spin"></i> Läuft</span>
{% else %}
<span class="badge bg-secondary">{{ imp.get_status_display }}</span>
{% endif %}
</td>
<td>
{% if imp.total_rows > 0 %}
<small>{{ imp.imported_rows }}/{{ imp.total_rows }} importiert</small>
{% if imp.failed_rows > 0 %}
<small class="text-danger">({{ imp.failed_rows }} fehlgeschlagen)</small>
{% endif %}
{% else %}
<small class="text-muted">-</small>
{% endif %}
</td>
<td><small>{{ imp.created_by|default:"-" }}</small></td>
<td><small>{{ imp.started_at|date:"d.m.Y H:i" }}</small></td>
<td>
{% if imp.error_log %}
<button type="button" class="btn btn-outline-warning btn-sm"
data-bs-toggle="modal" data-bs-target="#errorModal{{ imp.id }}">
<i class="fas fa-eye"></i>
</button>
{% endif %}
</td>
</tr>
{% if imp.error_log %}
<div class="modal fade" id="errorModal{{ imp.id }}" tabindex="-1">
<div class="modal-dialog modal-lg">
<div class="modal-content">
<div class="modal-header bg-warning text-dark">
<h5 class="modal-title">
<i class="fas fa-exclamation-triangle"></i> Fehlerprotokoll {{ imp.filename }}
</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<div class="alert alert-info">
<strong>{{ imp.imported_rows }}</strong> importiert,
<strong>{{ imp.failed_rows }}</strong> fehlgeschlagen
von <strong>{{ imp.total_rows }}</strong> Zeilen.
</div>
<pre class="bg-light p-3 rounded" style="max-height: 400px; overflow-y: auto;"><code>{{ imp.error_log }}</code></pre>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Schließen</button>
</div>
</div>
</div>
</div>
{% endif %}
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
{% endif %}
</div>
</div>
{% endblock %}

View File

@@ -245,13 +245,35 @@
{% endif %} {% endif %}
</div> </div>
<div class="mb-0"> <div class="mb-3">
<label for="{{ form.studiennachweis_bemerkung.id_for_label }}" class="form-label">{{ form.studiennachweis_bemerkung.label }}</label> <label for="{{ form.studiennachweis_bemerkung.id_for_label }}" class="form-label">{{ form.studiennachweis_bemerkung.label }}</label>
{{ form.studiennachweis_bemerkung }} {{ form.studiennachweis_bemerkung }}
{% if form.studiennachweis_bemerkung.help_text %} {% if form.studiennachweis_bemerkung.help_text %}
<small class="form-text text-muted">{{ form.studiennachweis_bemerkung.help_text }}</small> <small class="form-text text-muted">{{ form.studiennachweis_bemerkung.help_text }}</small>
{% endif %} {% endif %}
</div> </div>
{% if alle_dms_dokumente or nachweis.studiennachweis_dms_dokument %}
<div class="mb-0">
<label class="form-label"><i class="fas fa-folder-open me-1"></i>Oder: DMS-Dokument zuweisen</label>
{% if nachweis.studiennachweis_dms_dokument %}
<div class="alert alert-success py-2 mb-2">
<i class="fas fa-check-circle me-1"></i>
<strong>{{ nachweis.studiennachweis_dms_dokument.titel }}</strong>
<small class="text-muted">({{ nachweis.studiennachweis_dms_dokument.get_human_size }})</small>
<a href="{% url 'stiftung:dms_download' nachweis.studiennachweis_dms_dokument.pk %}" class="ms-2"><i class="fas fa-download"></i></a>
</div>
{% endif %}
<select class="form-select form-select-sm" name="studiennachweis_dms_id">
<option value="">-- DMS-Dokument waehlen --</option>
{% for dok in alle_dms_dokumente %}
<option value="{{ dok.pk }}" {% if nachweis.studiennachweis_dms_dokument_id == dok.pk %}selected{% endif %}>
{{ dok.titel }} ({{ dok.get_kontext_display }}, {{ dok.get_human_size }})
</option>
{% endfor %}
</select>
</div>
{% endif %}
</div> </div>
</div> </div>
@@ -278,7 +300,7 @@
{% endif %} {% endif %}
</div> </div>
<div class="mb-0"> <div class="mb-3">
<label for="{{ form.einkommenssituation_datei.id_for_label }}" class="form-label">{{ form.einkommenssituation_datei.label }}</label> <label for="{{ form.einkommenssituation_datei.id_for_label }}" class="form-label">{{ form.einkommenssituation_datei.label }}</label>
{{ form.einkommenssituation_datei }} {{ form.einkommenssituation_datei }}
{% if nachweis.einkommenssituation_datei %} {% if nachweis.einkommenssituation_datei %}
@@ -294,6 +316,28 @@
<small class="form-text text-muted">{{ form.einkommenssituation_datei.help_text }}</small> <small class="form-text text-muted">{{ form.einkommenssituation_datei.help_text }}</small>
{% endif %} {% endif %}
</div> </div>
{% if alle_dms_dokumente or nachweis.einkommenssituation_dms_dokument %}
<div class="mb-0">
<label class="form-label"><i class="fas fa-folder-open me-1"></i>Oder: DMS-Dokument zuweisen</label>
{% if nachweis.einkommenssituation_dms_dokument %}
<div class="alert alert-success py-2 mb-2">
<i class="fas fa-check-circle me-1"></i>
<strong>{{ nachweis.einkommenssituation_dms_dokument.titel }}</strong>
<small class="text-muted">({{ nachweis.einkommenssituation_dms_dokument.get_human_size }})</small>
<a href="{% url 'stiftung:dms_download' nachweis.einkommenssituation_dms_dokument.pk %}" class="ms-2"><i class="fas fa-download"></i></a>
</div>
{% endif %}
<select class="form-select form-select-sm" name="einkommenssituation_dms_id">
<option value="">-- DMS-Dokument waehlen --</option>
{% for dok in alle_dms_dokumente %}
<option value="{{ dok.pk }}" {% if nachweis.einkommenssituation_dms_dokument_id == dok.pk %}selected{% endif %}>
{{ dok.titel }} ({{ dok.get_kontext_display }}, {{ dok.get_human_size }})
</option>
{% endfor %}
</select>
</div>
{% endif %}
</div> </div>
</div> </div>
@@ -336,6 +380,28 @@
<small class="form-text text-muted">{{ form.vermogenssituation_datei.help_text }}</small> <small class="form-text text-muted">{{ form.vermogenssituation_datei.help_text }}</small>
{% endif %} {% endif %}
</div> </div>
{% if alle_dms_dokumente or nachweis.vermogenssituation_dms_dokument %}
<div class="mb-0">
<label class="form-label"><i class="fas fa-folder-open me-1"></i>Oder: DMS-Dokument zuweisen</label>
{% if nachweis.vermogenssituation_dms_dokument %}
<div class="alert alert-success py-2 mb-2">
<i class="fas fa-check-circle me-1"></i>
<strong>{{ nachweis.vermogenssituation_dms_dokument.titel }}</strong>
<small class="text-muted">({{ nachweis.vermogenssituation_dms_dokument.get_human_size }})</small>
<a href="{% url 'stiftung:dms_download' nachweis.vermogenssituation_dms_dokument.pk %}" class="ms-2"><i class="fas fa-download"></i></a>
</div>
{% endif %}
<select class="form-select form-select-sm" name="vermogenssituation_dms_id">
<option value="">-- DMS-Dokument waehlen --</option>
{% for dok in alle_dms_dokumente %}
<option value="{{ dok.pk }}" {% if nachweis.vermogenssituation_dms_dokument_id == dok.pk %}selected{% endif %}>
{{ dok.titel }} ({{ dok.get_kontext_display }}, {{ dok.get_human_size }})
</option>
{% endfor %}
</select>
</div>
{% endif %}
</div> </div>
</div> </div>
@@ -374,6 +440,66 @@
</div> </div>
</div> </div>
<!-- DMS-Dokumente als Nachweise verknuepfen -->
<div class="card shadow mb-4">
<div class="card-header bg-dark text-white">
<h5 class="card-title mb-0">
<i class="fas fa-folder-open me-2"></i>Dokumente aus dem DMS verknuepfen
</h5>
</div>
<div class="card-body">
<p class="small text-muted mb-3">
Waehlen Sie Dokumente aus dem DMS von {{ destinataer.get_full_name }}, die als Nachweise fuer dieses Quartal dienen sollen.
</p>
{% if verknuepfte_nachweis_dokumente %}
<div class="mb-3">
<label class="form-label"><strong>Bereits verknuepfte Dokumente:</strong></label>
<div class="list-group list-group-flush">
{% for dok in verknuepfte_nachweis_dokumente %}
<div class="list-group-item d-flex justify-content-between align-items-center px-0">
<div>
<i class="fas fa-file me-1 text-muted"></i>
<strong>{{ dok.titel }}</strong>
<span class="badge bg-secondary ms-1">{{ dok.get_kontext_display }}</span>
<br><small class="text-muted">{{ dok.dateiname_original }} ({{ dok.get_human_size }})</small>
</div>
<div class="btn-group btn-group-sm">
<a href="{% url 'stiftung:dms_download' dok.pk %}" class="btn btn-outline-primary" title="Herunterladen"><i class="fas fa-download"></i></a>
<button type="submit" name="entferne_dms_dokument" value="{{ dok.pk }}" class="btn btn-outline-danger" title="Verknuepfung entfernen">
<i class="fas fa-unlink"></i>
</button>
</div>
</div>
{% endfor %}
</div>
</div>
{% endif %}
{% if verfuegbare_dms_dokumente %}
<div class="mb-0">
<label class="form-label"><strong>Verfuegbare Dokumente hinzufuegen:</strong></label>
<select class="form-select form-select-sm" name="dms_dokument_hinzufuegen" id="dms_dokument_hinzufuegen">
<option value="">-- Dokument aus dem DMS waehlen --</option>
{% for dok in verfuegbare_dms_dokumente %}
<option value="{{ dok.pk }}">
{{ dok.titel }} ({{ dok.get_kontext_display }}, {{ dok.get_human_size }})
</option>
{% endfor %}
</select>
<small class="form-text text-muted mt-1 d-block">
Waehlen Sie ein Dokument und klicken Sie auf "Speichern", um es als Nachweis zu verknuepfen.
</small>
</div>
{% elif not verknuepfte_nachweis_dokumente %}
<div class="text-center text-muted py-2">
<i class="fas fa-info-circle me-1"></i>Keine DMS-Dokumente fuer {{ destinataer.get_full_name }} vorhanden.
<br><small><a href="{% url 'stiftung:dms_upload' %}?destinataer={{ destinataer.pk }}" class="text-decoration-none">Dokument hochladen</a></small>
</div>
{% endif %}
</div>
</div>
<!-- Internal Notes (Staff Only) --> <!-- Internal Notes (Staff Only) -->
{% if user.is_staff %} {% if user.is_staff %}
<div class="card shadow mb-4"> <div class="card shadow mb-4">

View File

@@ -84,6 +84,71 @@ services:
- db - db
- redis - redis
mcp:
build: ./app
depends_on:
db:
condition: service_healthy
environment:
- POSTGRES_DB=stiftung_dev
- POSTGRES_USER=postgres
- POSTGRES_PASSWORD=postgres_dev
- DB_HOST=db
- DB_PORT=5432
- DJANGO_SECRET_KEY=dev-secret-key-not-for-production
- DJANGO_DEBUG=1
- DJANGO_ALLOWED_HOSTS=localhost,127.0.0.1
- LANGUAGE_CODE=de
- TIME_ZONE=Europe/Berlin
- MCP_TOKEN_READONLY=${MCP_TOKEN_READONLY:-dev-readonly-token}
- MCP_TOKEN_EDITOR=${MCP_TOKEN_EDITOR:-dev-editor-token}
- MCP_TOKEN_ADMIN=${MCP_TOKEN_ADMIN:-dev-admin-token}
# Kein Port-Mapping nur internes Netz
# Start via: docker compose -f compose.dev.yml run --rm -e MCP_AUTH_TOKEN=dev-readonly-token mcp
stdin_open: true
volumes:
- ./app:/app
command: ["python", "-m", "mcp_server"]
ollama:
image: ollama/ollama:latest
# Kein externes Port-Mapping — nur über internes Docker-Netzwerk erreichbar
# Django-App: http://ollama:11434
environment:
- OLLAMA_MAX_LOADED_MODELS=1
- OLLAMA_NUM_PARALLEL=1
- OLLAMA_DEFAULT_MODEL=${OLLAMA_DEFAULT_MODEL:-qwen2.5:3b}
volumes:
- ollama_data_dev:/root/.ollama
restart: unless-stopped
healthcheck:
test: ["CMD-SHELL", "curl -sf http://localhost:11434/api/tags || exit 1"]
interval: 30s
timeout: 10s
retries: 5
start_period: 60s
# Beim ersten Start: Ollama starten, dann Modell laden (falls nicht vorhanden)
entrypoint: >
sh -c "
ollama serve &
OLLAMA_PID=$$!
echo '[ollama] Warte auf API...'
RETRIES=0
until curl -sf http://localhost:11434/api/tags > /dev/null 2>&1; do
RETRIES=$$((RETRIES + 1))
[ $$RETRIES -ge 60 ] && echo '[ollama] FEHLER: API nicht bereit.' && exit 1
sleep 1
done
MODEL=$${OLLAMA_DEFAULT_MODEL:-qwen2.5:3b}
if ollama list | grep -q \"$$MODEL\"; then
echo \"[ollama] Modell '$$MODEL' bereits vorhanden.\"
else
echo \"[ollama] Lade Modell '$$MODEL'...\"
ollama pull \"$$MODEL\"
fi
wait $$OLLAMA_PID
"
grampsweb: grampsweb:
image: ghcr.io/gramps-project/grampsweb:latest image: ghcr.io/gramps-project/grampsweb:latest
ports: ports:
@@ -112,3 +177,4 @@ volumes:
paperless_export_dev: paperless_export_dev:
paperless_consume_dev: paperless_consume_dev:
gramps_data_dev: gramps_data_dev:
ollama_data_dev:

View File

@@ -113,6 +113,69 @@ services:
- db - db
command: ["celery", "-A", "core", "beat", "-l", "info"] command: ["celery", "-A", "core", "beat", "-l", "info"]
mcp:
build: ./app
depends_on:
db:
condition: service_healthy
environment:
- POSTGRES_DB=${POSTGRES_DB}
- POSTGRES_USER=${POSTGRES_USER}
- POSTGRES_PASSWORD=${POSTGRES_PASSWORD}
- DB_HOST=${DB_HOST}
- DB_PORT=${DB_PORT}
- DJANGO_SECRET_KEY=${DJANGO_SECRET_KEY}
- DJANGO_DEBUG=0
- DJANGO_ALLOWED_HOSTS=localhost
- LANGUAGE_CODE=${LANGUAGE_CODE}
- TIME_ZONE=${TIME_ZONE}
- MCP_TOKEN_READONLY=${MCP_TOKEN_READONLY}
- MCP_TOKEN_EDITOR=${MCP_TOKEN_EDITOR}
- MCP_TOKEN_ADMIN=${MCP_TOKEN_ADMIN}
# Kein Port-Mapping nur internes Netz
# Start via: docker compose run --rm -e MCP_AUTH_TOKEN=<token> mcp
stdin_open: true
command: ["python", "-m", "mcp_server"]
ollama:
image: ollama/ollama:latest
# Kein externes Port-Mapping — nur über internes Docker-Netzwerk erreichbar
# Django-App: http://ollama:11434
environment:
- OLLAMA_MAX_LOADED_MODELS=1
- OLLAMA_NUM_PARALLEL=1
- OLLAMA_DEFAULT_MODEL=${OLLAMA_DEFAULT_MODEL:-qwen2.5:3b}
volumes:
- ollama_data:/root/.ollama
restart: unless-stopped
healthcheck:
test: ["CMD-SHELL", "curl -sf http://localhost:11434/api/tags || exit 1"]
interval: 30s
timeout: 10s
retries: 5
start_period: 60s
# Beim ersten Start: Ollama starten, dann Modell laden (falls nicht vorhanden)
entrypoint: >
sh -c "
ollama serve &
OLLAMA_PID=$$!
echo '[ollama] Warte auf API...'
RETRIES=0
until curl -sf http://localhost:11434/api/tags > /dev/null 2>&1; do
RETRIES=$$((RETRIES + 1))
[ $$RETRIES -ge 60 ] && echo '[ollama] FEHLER: API nicht bereit.' && exit 1
sleep 1
done
MODEL=$${OLLAMA_DEFAULT_MODEL:-qwen2.5:3b}
if ollama list | grep -q \"$$MODEL\"; then
echo \"[ollama] Modell '$$MODEL' bereits vorhanden.\"
else
echo \"[ollama] Lade Modell '$$MODEL'...\"
ollama pull \"$$MODEL\"
fi
wait $$OLLAMA_PID
"
grampsweb: grampsweb:
image: ghcr.io/gramps-project/grampsweb:latest image: ghcr.io/gramps-project/grampsweb:latest
ports: ports:
@@ -138,3 +201,4 @@ services:
volumes: volumes:
dbdata: dbdata:
gramps_data: gramps_data:
ollama_data:

View File

@@ -68,6 +68,14 @@ GRAMPS_USERNAME=admin@vhtv-stiftung.de
GRAMPS_PASSWORD=your_grampsweb_admin_password_here GRAMPS_PASSWORD=your_grampsweb_admin_password_here
GRAMPS_API_TOKEN=your_gramps_api_token_if_needed GRAMPS_API_TOKEN=your_gramps_api_token_if_needed
# OLLAMA KONFIGURATION (AI Agent)
# Standard-Modell für 8 GB RAM Server (ohne GPU):
# qwen2.5:3b (~2 GB) — Empfohlen (bester Kompromiss)
# phi3:mini (~2.3 GB) — Alternative
# gemma2:2b (~1.5 GB) — Schnellste Option
# llama3.2:3b (~2 GB) — Solide Basis
OLLAMA_DEFAULT_MODEL=qwen2.5:3b
# ============================================================================= # =============================================================================
# GENERATE SECRET KEYS: # GENERATE SECRET KEYS:
# ============================================================================= # =============================================================================