v4.1.0: DMS email documents, category-specific Nachweis linking, version system
- Save cover email body as DMS document with new 'email' context type - Show email body separately from attachments in email detail view - Add per-category DMS document assignment in quarterly confirmation (Studiennachweis, Einkommenssituation, Vermögenssituation) - Add VERSION file and context processor for automatic version display - Add MCP server, agent system, import/export, and new migrations - Update compose files and production environment template Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
14
app/core/context_processors.py
Normal file
14
app/core/context_processors.py
Normal 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}
|
||||
@@ -72,6 +72,7 @@ TEMPLATES = [
|
||||
"django.template.context_processors.request",
|
||||
"django.contrib.auth.context_processors.auth",
|
||||
"django.contrib.messages.context_processors.messages",
|
||||
"core.context_processors.app_version",
|
||||
],
|
||||
},
|
||||
},
|
||||
|
||||
1
app/mcp_server/__init__.py
Normal file
1
app/mcp_server/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
# MCP Server für die Stiftungsverwaltung
|
||||
4
app/mcp_server/__main__.py
Normal file
4
app/mcp_server/__main__.py
Normal 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
103
app/mcp_server/audit.py
Normal 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
70
app/mcp_server/auth.py
Normal 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
152
app/mcp_server/privacy.py
Normal 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.000–20.000 €"
|
||||
elif amount < 30000:
|
||||
return "20.000–30.000 €"
|
||||
elif amount < 50000:
|
||||
return "30.000–50.000 €"
|
||||
elif amount < 75000:
|
||||
return "50.000–75.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 "500–1.000 €/Mon."
|
||||
elif amount < 2000:
|
||||
return "1.000–2.000 €/Mon."
|
||||
elif amount < 3000:
|
||||
return "2.000–3.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
151
app/mcp_server/server.py
Normal 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")
|
||||
1
app/mcp_server/tools/__init__.py
Normal file
1
app/mcp_server/tools/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
# MCP Tools
|
||||
59
app/mcp_server/tools/helpers.py
Normal file
59
app/mcp_server/tools/helpers.py
Normal 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)
|
||||
755
app/mcp_server/tools/lesen.py
Normal file
755
app/mcp_server/tools/lesen.py
Normal 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,
|
||||
},
|
||||
})
|
||||
577
app/mcp_server/tools/schreiben.py
Normal file
577
app/mcp_server/tools/schreiben.py
Normal 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())})
|
||||
@@ -14,3 +14,5 @@ django-otp==1.2.4
|
||||
django-htmx==1.19.0
|
||||
qrcode[pil]==7.4.2
|
||||
schwifty==2026.3.0
|
||||
mcp>=1.0.0
|
||||
httpx>=0.27.0
|
||||
|
||||
@@ -7,6 +7,7 @@ from . import foerderung # noqa: F401
|
||||
from . import dokumente # noqa: F401
|
||||
from . import veranstaltung # noqa: F401
|
||||
from . import system # noqa: F401
|
||||
from stiftung.agent import admin as agent_admin # noqa: F401
|
||||
|
||||
# Customize admin site
|
||||
admin.site.site_header = "Stiftungsverwaltung Administration"
|
||||
|
||||
0
app/stiftung/agent/__init__.py
Normal file
0
app/stiftung/agent/__init__.py
Normal file
64
app/stiftung/agent/admin.py
Normal file
64
app/stiftung/agent/admin.py
Normal 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"
|
||||
166
app/stiftung/agent/models.py
Normal file
166
app/stiftung/agent/models.py
Normal 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]}"
|
||||
201
app/stiftung/agent/orchestrator.py
Normal file
201
app/stiftung/agent/orchestrator.py
Normal 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"
|
||||
323
app/stiftung/agent/providers.py
Normal file
323
app/stiftung/agent/providers.py
Normal 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
363
app/stiftung/agent/tools.py
Normal 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)
|
||||
12
app/stiftung/agent/urls.py
Normal file
12
app/stiftung/agent/urls.py
Normal 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
232
app/stiftung/agent/views.py
Normal 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})
|
||||
@@ -398,9 +398,14 @@ class VierteljahresNachweisForm(forms.ModelForm):
|
||||
einkommenssituation_datei = cleaned_data.get('einkommenssituation_datei')
|
||||
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(
|
||||
'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
|
||||
@@ -408,9 +413,13 @@ class VierteljahresNachweisForm(forms.ModelForm):
|
||||
vermogenssituation_datei = cleaned_data.get('vermogenssituation_datei')
|
||||
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(
|
||||
'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
|
||||
@@ -420,9 +429,15 @@ class VierteljahresNachweisForm(forms.ModelForm):
|
||||
studiennachweis_bemerkung = cleaned_data.get('studiennachweis_bemerkung')
|
||||
|
||||
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(
|
||||
'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
|
||||
|
||||
@@ -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'),
|
||||
),
|
||||
]
|
||||
211
app/stiftung/migrations/0056_agent_models.py
Normal file
211
app/stiftung/migrations/0056_agent_models.py
Normal 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
|
||||
]
|
||||
@@ -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'),
|
||||
),
|
||||
]
|
||||
@@ -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'),
|
||||
),
|
||||
]
|
||||
@@ -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)'),
|
||||
),
|
||||
]
|
||||
@@ -755,6 +755,38 @@ class VierteljahresNachweis(models.Model):
|
||||
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
|
||||
status = models.CharField(
|
||||
max_length=20,
|
||||
@@ -840,19 +872,27 @@ class VierteljahresNachweis(models.Model):
|
||||
"""Check if all required documents/confirmations are provided"""
|
||||
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)
|
||||
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 (
|
||||
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 (
|
||||
bool(self.vermogenssituation_text) or bool(self.vermogenssituation_datei)
|
||||
or bool(self.vermogenssituation_dms_dokument_id)
|
||||
)
|
||||
|
||||
return complete
|
||||
@@ -868,23 +908,30 @@ class VierteljahresNachweis(models.Model):
|
||||
total_requirements = 2 # Income and assets always required
|
||||
completed_requirements = 0
|
||||
|
||||
has_dms_studiennachweis = (
|
||||
bool(self.studiennachweis_dms_dokument_id)
|
||||
or self.nachweis_dokumente.filter(kontext="studiennachweis").exists()
|
||||
)
|
||||
|
||||
# Study proof (if required)
|
||||
if self.studiennachweis_erforderlich:
|
||||
total_requirements += 1
|
||||
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
|
||||
|
||||
# Income situation
|
||||
if self.einkommenssituation_bestaetigt and (
|
||||
bool(self.einkommenssituation_text) or bool(self.einkommenssituation_datei)
|
||||
or bool(self.einkommenssituation_dms_dokument_id)
|
||||
):
|
||||
completed_requirements += 1
|
||||
|
||||
# Asset situation
|
||||
if self.vermogenssituation_bestaetigt and (
|
||||
bool(self.vermogenssituation_text) or bool(self.vermogenssituation_datei)
|
||||
or bool(self.vermogenssituation_dms_dokument_id)
|
||||
):
|
||||
completed_requirements += 1
|
||||
|
||||
|
||||
@@ -31,6 +31,7 @@ class DokumentDatei(models.Model):
|
||||
("korrespondenz", "Korrespondenz / Brief"),
|
||||
("bescheid", "Bescheid / Behörde"),
|
||||
("stiftungsgeschichte", "Stiftungsgeschichte / Archiv"),
|
||||
("email", "E-Mail-Nachricht"),
|
||||
("anderes", "Sonstiges"),
|
||||
]
|
||||
|
||||
|
||||
@@ -11,6 +11,10 @@ class CSVImport(models.Model):
|
||||
("paechter", "Pächter"),
|
||||
("laendereien", "Ländereien"),
|
||||
("verpachtungen", "Verpachtungen"),
|
||||
("foerderungen", "Förderungen"),
|
||||
("konten", "Stiftungskonten"),
|
||||
("verwaltungskosten", "Verwaltungskosten"),
|
||||
("rentmeister", "Rentmeister"),
|
||||
("personen", "Personen (Legacy)"),
|
||||
]
|
||||
|
||||
@@ -111,6 +115,8 @@ class ApplicationPermission(models.Model):
|
||||
# System Permissions
|
||||
("access_django_admin", "Kann Django Admin aufrufen"),
|
||||
("view_system_stats", "Kann Systemstatistiken anzeigen"),
|
||||
# AI Agent Permissions
|
||||
("can_use_agent", "Kann AI-Assistenten nutzen"),
|
||||
]
|
||||
|
||||
|
||||
|
||||
@@ -332,11 +332,58 @@ def poll_emails(self, search_all_recent_days=0):
|
||||
if 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:
|
||||
eingang.status = "verarbeitet" if destinataer else status
|
||||
eingang.save()
|
||||
if dms_dokumente:
|
||||
eingang.dokument_dateien.set(dms_dokumente)
|
||||
if alle_dms_dokumente:
|
||||
eingang.dokument_dateien.set(alle_dms_dokumente)
|
||||
|
||||
# Als gelesen markieren
|
||||
mail.store(msg_id, "+FLAGS", "\\Seen")
|
||||
|
||||
@@ -1,15 +1,23 @@
|
||||
from django.urls import path
|
||||
from django.urls import include, path
|
||||
|
||||
from . import views
|
||||
|
||||
app_name = "stiftung"
|
||||
|
||||
urlpatterns = [
|
||||
# AI Agent
|
||||
path("agent/", include("stiftung.agent.urls")),
|
||||
|
||||
# Home - Main landing page after login
|
||||
path("", views.home, name="home"),
|
||||
# CSV Import URLs
|
||||
# CSV Import URLs (legacy)
|
||||
path("import/", views.csv_import_list, name="csv_import_list"),
|
||||
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)
|
||||
path("destinataere/", views.destinataer_list, name="destinataer_list"),
|
||||
path(
|
||||
|
||||
@@ -206,5 +206,12 @@ from .veranstaltung import ( # noqa: F401
|
||||
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)
|
||||
from .system import GrampsClient, get_gramps_client, get_pdf_generator # noqa: F401
|
||||
|
||||
@@ -254,9 +254,9 @@ def dms_edit(request, pk):
|
||||
paechter_id = request.POST.get("paechter_id", "").strip()
|
||||
verp_id = request.POST.get("verpachtung_id", "").strip()
|
||||
|
||||
dok.destinataer_id = int(dest_id) if dest_id else None
|
||||
dok.land_id = int(land_id) if land_id else None
|
||||
dok.paechter_id = int(paechter_id) if paechter_id else None
|
||||
dok.destinataer_id = dest_id if dest_id else None
|
||||
dok.land_id = land_id if land_id else None
|
||||
dok.paechter_id = paechter_id if paechter_id else None
|
||||
dok.verpachtung_id = verp_id if verp_id else None
|
||||
|
||||
dok.save()
|
||||
|
||||
@@ -750,8 +750,10 @@ def email_eingang_detail(request, pk):
|
||||
messages.success(request, "Notizen gespeichert.")
|
||||
return redirect("stiftung:email_eingang_detail", pk=pk)
|
||||
|
||||
# DMS-Dokumente
|
||||
dms_dokumente = eingang.dokument_dateien.all().order_by("erstellt_am")
|
||||
# DMS-Dokumente: E-Mail-Body und Anhaenge trennen
|
||||
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_destinataere = Destinataer.objects.filter(aktiv=True).order_by("nachname", "vorname")
|
||||
@@ -759,7 +761,8 @@ def email_eingang_detail(request, pk):
|
||||
context = {
|
||||
"title": f"E-Mail-Eingang: {eingang}",
|
||||
"eingang": eingang,
|
||||
"dms_dokumente": dms_dokumente,
|
||||
"email_dokument": email_dokument,
|
||||
"anhaenge_dokumente": anhaenge_dokumente,
|
||||
"alle_destinataere": alle_destinataere,
|
||||
"vk_kategorie_choices": Verwaltungskosten.KATEGORIE_CHOICES,
|
||||
}
|
||||
|
||||
920
app/stiftung/views/import_export.py
Normal file
920
app/stiftung/views/import_export.py
Normal 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")
|
||||
@@ -14,8 +14,8 @@ import qrcode.image.svg
|
||||
from django.contrib import messages
|
||||
from django.contrib.auth.decorators import login_required
|
||||
from django.core.paginator import Paginator
|
||||
from django.db.models import (Avg, Count, DecimalField, F, IntegerField, Q,
|
||||
Sum, Value)
|
||||
from django.db.models import (Avg, BigIntegerField, Count, DecimalField, F,
|
||||
IntegerField, Q, Sum, Value)
|
||||
from django.db.models.functions import Cast, Coalesce, NullIf, Replace
|
||||
from django.http import HttpResponse, JsonResponse
|
||||
from django.shortcuts import get_object_or_404, redirect, render
|
||||
@@ -274,24 +274,25 @@ def land_list(request):
|
||||
lands = lands.filter(aktiv=False)
|
||||
|
||||
# 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):
|
||||
expr = Replace(field_expr, Value(" "), Value(""))
|
||||
expr = Replace(expr, Value("-"), Value(""))
|
||||
expr = Replace(expr, Value("."), Value(""))
|
||||
expr = Replace(expr, Value("/"), Value(""))
|
||||
expr = Replace(expr, Value("L"), Value(""))
|
||||
return expr
|
||||
return RegexpReplace(field_expr)
|
||||
|
||||
lands = lands.extra(
|
||||
select={
|
||||
"verpachtungsgrad": "CASE WHEN groesse_qm > 0 THEN (verp_flaeche_aktuell / groesse_qm) * 100 ELSE 0 END"
|
||||
}
|
||||
).annotate(
|
||||
lfd_nr_num=Cast(NullIf(digits_only(F("lfd_nr")), Value("")), IntegerField()),
|
||||
flur_num=Cast(NullIf(digits_only(F("flur")), Value("")), IntegerField()),
|
||||
lfd_nr_num=Cast(NullIf(digits_only(F("lfd_nr")), Value("")), BigIntegerField()),
|
||||
flur_num=Cast(NullIf(digits_only(F("flur")), Value("")), BigIntegerField()),
|
||||
flurstueck_num=Cast(
|
||||
NullIf(digits_only(F("flurstueck")), Value("")), IntegerField()
|
||||
NullIf(digits_only(F("flurstueck")), Value("")), BigIntegerField()
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@@ -1299,13 +1299,50 @@ def quarterly_confirmation_create(request, destinataer_id):
|
||||
@login_required
|
||||
def quarterly_confirmation_edit(request, pk):
|
||||
"""Standalone edit view for quarterly confirmation"""
|
||||
from stiftung.models import DokumentDatei
|
||||
|
||||
nachweis = get_object_or_404(VierteljahresNachweis, pk=pk)
|
||||
|
||||
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)
|
||||
if form.is_valid():
|
||||
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
|
||||
old_status = nachweis.status
|
||||
|
||||
@@ -1368,11 +1405,26 @@ def quarterly_confirmation_edit(request, pk):
|
||||
else:
|
||||
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 = {
|
||||
'form': form,
|
||||
'nachweis': nachweis,
|
||||
'destinataer': nachweis.destinataer,
|
||||
'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)
|
||||
|
||||
|
||||
@@ -661,6 +661,15 @@
|
||||
</a>
|
||||
</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 -->
|
||||
<div class="sidebar-section">
|
||||
<div class="sidebar-heading">System</div>
|
||||
@@ -741,7 +750,7 @@
|
||||
<!-- Footer -->
|
||||
<footer class="main-footer">
|
||||
© 2026 van Hees-Theyssen-Vogel'sche Stiftung ·
|
||||
<small>Vision 2026 · v4.0.0</small>
|
||||
<small>Vision 2026 · v{{ APP_VERSION }}</small>
|
||||
</footer>
|
||||
</main>
|
||||
|
||||
@@ -995,5 +1004,464 @@
|
||||
});
|
||||
})();
|
||||
</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,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
{% endif %}
|
||||
</body>
|
||||
</html>
|
||||
@@ -247,6 +247,12 @@
|
||||
<span>Destinatärunterstützungen</span>
|
||||
</a>
|
||||
</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">
|
||||
<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>
|
||||
|
||||
208
app/templates/stiftung/csv_import_mapping.html
Normal file
208
app/templates/stiftung/csv_import_mapping.html
Normal 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 %}
|
||||
@@ -101,11 +101,43 @@
|
||||
</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 }} · 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 #}
|
||||
{% if dms_dokumente %}
|
||||
{% if anhaenge_dokumente %}
|
||||
<div class="card mb-4">
|
||||
<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 class="card-body p-0">
|
||||
<table class="table mb-0">
|
||||
@@ -118,17 +150,22 @@
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for dok in dms_dokumente %}
|
||||
{% for dok in anhaenge_dokumente %}
|
||||
<tr>
|
||||
<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.get_human_size }}</span></td>
|
||||
<td>
|
||||
<div class="btn-group btn-group-sm">
|
||||
{% if dok.datei %}
|
||||
<a href="{% url 'stiftung:dms_download' dok.pk %}" class="btn btn-sm btn-outline-primary">
|
||||
<i class="fas fa-download me-1"></i>Herunterladen
|
||||
<a href="{% url 'stiftung:dms_download' dok.pk %}" class="btn btn-outline-primary" title="Herunterladen">
|
||||
<i class="fas fa-download"></i>
|
||||
</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>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
@@ -136,7 +173,7 @@
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
{% else %}
|
||||
{% elif not email_dokument %}
|
||||
<div class="card mb-4">
|
||||
<div class="card-body text-muted text-center py-3">
|
||||
<i class="fas fa-paperclip me-1"></i>Keine Anhaenge in dieser E-Mail.
|
||||
|
||||
187
app/templates/stiftung/import_export_hub.html
Normal file
187
app/templates/stiftung/import_export_hub.html
Normal 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 %}
|
||||
@@ -245,13 +245,35 @@
|
||||
{% endif %}
|
||||
</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>
|
||||
{{ form.studiennachweis_bemerkung }}
|
||||
{% if form.studiennachweis_bemerkung.help_text %}
|
||||
<small class="form-text text-muted">{{ form.studiennachweis_bemerkung.help_text }}</small>
|
||||
{% endif %}
|
||||
</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>
|
||||
|
||||
@@ -278,7 +300,7 @@
|
||||
{% endif %}
|
||||
</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>
|
||||
{{ form.einkommenssituation_datei }}
|
||||
{% if nachweis.einkommenssituation_datei %}
|
||||
@@ -294,6 +316,28 @@
|
||||
<small class="form-text text-muted">{{ form.einkommenssituation_datei.help_text }}</small>
|
||||
{% endif %}
|
||||
</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>
|
||||
|
||||
@@ -336,6 +380,28 @@
|
||||
<small class="form-text text-muted">{{ form.vermogenssituation_datei.help_text }}</small>
|
||||
{% endif %}
|
||||
</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>
|
||||
|
||||
@@ -374,6 +440,66 @@
|
||||
</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) -->
|
||||
{% if user.is_staff %}
|
||||
<div class="card shadow mb-4">
|
||||
|
||||
@@ -84,6 +84,71 @@ services:
|
||||
- db
|
||||
- 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:
|
||||
image: ghcr.io/gramps-project/grampsweb:latest
|
||||
ports:
|
||||
@@ -112,3 +177,4 @@ volumes:
|
||||
paperless_export_dev:
|
||||
paperless_consume_dev:
|
||||
gramps_data_dev:
|
||||
ollama_data_dev:
|
||||
|
||||
64
compose.yml
64
compose.yml
@@ -113,6 +113,69 @@ services:
|
||||
- db
|
||||
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:
|
||||
image: ghcr.io/gramps-project/grampsweb:latest
|
||||
ports:
|
||||
@@ -138,3 +201,4 @@ services:
|
||||
volumes:
|
||||
dbdata:
|
||||
gramps_data:
|
||||
ollama_data:
|
||||
|
||||
@@ -68,6 +68,14 @@ GRAMPS_USERNAME=admin@vhtv-stiftung.de
|
||||
GRAMPS_PASSWORD=your_grampsweb_admin_password_here
|
||||
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:
|
||||
# =============================================================================
|
||||
|
||||
Reference in New Issue
Block a user