From e0b377014c9a8819af95474a12bfedf3fb9868fc Mon Sep 17 00:00:00 2001 From: SysAdmin Agent Date: Sun, 15 Mar 2026 18:48:52 +0000 Subject: [PATCH] v4.1.0: DMS email documents, category-specific Nachweis linking, version system MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- VERSION | 1 + app/core/context_processors.py | 14 + app/core/settings.py | 1 + app/mcp_server/__init__.py | 1 + app/mcp_server/__main__.py | 4 + app/mcp_server/audit.py | 103 ++ app/mcp_server/auth.py | 70 ++ app/mcp_server/privacy.py | 152 +++ app/mcp_server/server.py | 151 +++ app/mcp_server/tools/__init__.py | 1 + app/mcp_server/tools/helpers.py | 59 ++ app/mcp_server/tools/lesen.py | 755 ++++++++++++++ app/mcp_server/tools/schreiben.py | 577 +++++++++++ app/requirements.txt | 2 + app/stiftung/admin/__init__.py | 1 + app/stiftung/agent/__init__.py | 0 app/stiftung/agent/admin.py | 64 ++ app/stiftung/agent/models.py | 166 ++++ app/stiftung/agent/orchestrator.py | 201 ++++ app/stiftung/agent/providers.py | 323 ++++++ app/stiftung/agent/tools.py | 363 +++++++ app/stiftung/agent/urls.py | 12 + app/stiftung/agent/views.py | 232 +++++ app/stiftung/forms/destinataere.py | 27 +- ..._import_types_for_unified_import_export.py | 18 + app/stiftung/migrations/0056_agent_models.py | 211 ++++ ..._applicationpermission_options_and_more.py | 47 + ...ms_email_kontext_und_nachweis_dokumente.py | 23 + .../0059_nachweis_kategorie_dms_felder.py | 29 + app/stiftung/models/destinataere.py | 55 +- app/stiftung/models/dokumente.py | 1 + app/stiftung/models/system.py | 6 + app/stiftung/tasks.py | 51 +- app/stiftung/urls.py | 12 +- app/stiftung/views/__init__.py | 7 + app/stiftung/views/dms.py | 6 +- app/stiftung/views/geschichte.py | 9 +- app/stiftung/views/import_export.py | 920 ++++++++++++++++++ app/stiftung/views/land.py | 25 +- app/stiftung/views/unterstuetzungen.py | 72 +- app/templates/base.html | 470 ++++++++- app/templates/stiftung/administration.html | 6 + .../stiftung/csv_import_mapping.html | 208 ++++ .../stiftung/email_eingang/detail.html | 55 +- app/templates/stiftung/import_export_hub.html | 187 ++++ .../stiftung/quarterly_confirmation_edit.html | 132 ++- compose.dev.yml | 66 ++ compose.yml | 64 ++ env-production.template | 8 + 49 files changed, 5913 insertions(+), 55 deletions(-) create mode 100644 VERSION create mode 100644 app/core/context_processors.py create mode 100644 app/mcp_server/__init__.py create mode 100644 app/mcp_server/__main__.py create mode 100644 app/mcp_server/audit.py create mode 100644 app/mcp_server/auth.py create mode 100644 app/mcp_server/privacy.py create mode 100644 app/mcp_server/server.py create mode 100644 app/mcp_server/tools/__init__.py create mode 100644 app/mcp_server/tools/helpers.py create mode 100644 app/mcp_server/tools/lesen.py create mode 100644 app/mcp_server/tools/schreiben.py create mode 100644 app/stiftung/agent/__init__.py create mode 100644 app/stiftung/agent/admin.py create mode 100644 app/stiftung/agent/models.py create mode 100644 app/stiftung/agent/orchestrator.py create mode 100644 app/stiftung/agent/providers.py create mode 100644 app/stiftung/agent/tools.py create mode 100644 app/stiftung/agent/urls.py create mode 100644 app/stiftung/agent/views.py create mode 100644 app/stiftung/migrations/0055_add_import_types_for_unified_import_export.py create mode 100644 app/stiftung/migrations/0056_agent_models.py create mode 100644 app/stiftung/migrations/0057_alter_applicationpermission_options_and_more.py create mode 100644 app/stiftung/migrations/0058_dms_email_kontext_und_nachweis_dokumente.py create mode 100644 app/stiftung/migrations/0059_nachweis_kategorie_dms_felder.py create mode 100644 app/stiftung/views/import_export.py create mode 100644 app/templates/stiftung/csv_import_mapping.html create mode 100644 app/templates/stiftung/import_export_hub.html diff --git a/VERSION b/VERSION new file mode 100644 index 0000000..ee74734 --- /dev/null +++ b/VERSION @@ -0,0 +1 @@ +4.1.0 diff --git a/app/core/context_processors.py b/app/core/context_processors.py new file mode 100644 index 0000000..189d6bd --- /dev/null +++ b/app/core/context_processors.py @@ -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} diff --git a/app/core/settings.py b/app/core/settings.py index 84f2c02..d55b098 100644 --- a/app/core/settings.py +++ b/app/core/settings.py @@ -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", ], }, }, diff --git a/app/mcp_server/__init__.py b/app/mcp_server/__init__.py new file mode 100644 index 0000000..c47e2a5 --- /dev/null +++ b/app/mcp_server/__init__.py @@ -0,0 +1 @@ +# MCP Server für die Stiftungsverwaltung diff --git a/app/mcp_server/__main__.py b/app/mcp_server/__main__.py new file mode 100644 index 0000000..8b34b99 --- /dev/null +++ b/app/mcp_server/__main__.py @@ -0,0 +1,4 @@ +"""Ermöglicht Start via: python -m mcp_server""" +from mcp_server.server import mcp + +mcp.run(transport="stdio") diff --git a/app/mcp_server/audit.py b/app/mcp_server/audit.py new file mode 100644 index 0000000..b76ed0d --- /dev/null +++ b/app/mcp_server/audit.py @@ -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/" + - 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, + ) diff --git a/app/mcp_server/auth.py b/app/mcp_server/auth.py new file mode 100644 index 0000000..f5359be --- /dev/null +++ b/app/mcp_server/auth.py @@ -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." + ) diff --git a/app/mcp_server/privacy.py b/app/mcp_server/privacy.py new file mode 100644 index 0000000..eb39dfd --- /dev/null +++ b/app/mcp_server/privacy.py @@ -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] diff --git a/app/mcp_server/server.py b/app/mcp_server/server.py new file mode 100644 index 0000000..cca05b5 --- /dev/null +++ b/app/mcp_server/server.py @@ -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") diff --git a/app/mcp_server/tools/__init__.py b/app/mcp_server/tools/__init__.py new file mode 100644 index 0000000..4ff23f5 --- /dev/null +++ b/app/mcp_server/tools/__init__.py @@ -0,0 +1 @@ +# MCP Tools diff --git a/app/mcp_server/tools/helpers.py b/app/mcp_server/tools/helpers.py new file mode 100644 index 0000000..10cd1cd --- /dev/null +++ b/app/mcp_server/tools/helpers.py @@ -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) diff --git a/app/mcp_server/tools/lesen.py b/app/mcp_server/tools/lesen.py new file mode 100644 index 0000000..b20f91d --- /dev/null +++ b/app/mcp_server/tools/lesen.py @@ -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, + }, + }) diff --git a/app/mcp_server/tools/schreiben.py b/app/mcp_server/tools/schreiben.py new file mode 100644 index 0000000..1286237 --- /dev/null +++ b/app/mcp_server/tools/schreiben.py @@ -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())}) diff --git a/app/requirements.txt b/app/requirements.txt index 9357343..122b24b 100644 --- a/app/requirements.txt +++ b/app/requirements.txt @@ -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 diff --git a/app/stiftung/admin/__init__.py b/app/stiftung/admin/__init__.py index d24f665..92081be 100644 --- a/app/stiftung/admin/__init__.py +++ b/app/stiftung/admin/__init__.py @@ -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" diff --git a/app/stiftung/agent/__init__.py b/app/stiftung/agent/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/stiftung/agent/admin.py b/app/stiftung/agent/admin.py new file mode 100644 index 0000000..ca8fd03 --- /dev/null +++ b/app/stiftung/agent/admin.py @@ -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" diff --git a/app/stiftung/agent/models.py b/app/stiftung/agent/models.py new file mode 100644 index 0000000..4f5ddda --- /dev/null +++ b/app/stiftung/agent/models.py @@ -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]}" diff --git a/app/stiftung/agent/orchestrator.py b/app/stiftung/agent/orchestrator.py new file mode 100644 index 0000000..a03a76f --- /dev/null +++ b/app/stiftung/agent/orchestrator.py @@ -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" diff --git a/app/stiftung/agent/providers.py b/app/stiftung/agent/providers.py new file mode 100644 index 0000000..cf8274c --- /dev/null +++ b/app/stiftung/agent/providers.py @@ -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}") diff --git a/app/stiftung/agent/tools.py b/app/stiftung/agent/tools.py new file mode 100644 index 0000000..fae74e5 --- /dev/null +++ b/app/stiftung/agent/tools.py @@ -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) diff --git a/app/stiftung/agent/urls.py b/app/stiftung/agent/urls.py new file mode 100644 index 0000000..73fd19e --- /dev/null +++ b/app/stiftung/agent/urls.py @@ -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//", views.agent_session_messages, name="agent_session_messages"), + path("sessions//loeschen/", views.agent_session_delete, name="agent_session_delete"), +] diff --git a/app/stiftung/agent/views.py b/app/stiftung/agent/views.py new file mode 100644 index 0000000..1e57961 --- /dev/null +++ b/app/stiftung/agent/views.py @@ -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// – 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}) diff --git a/app/stiftung/forms/destinataere.py b/app/stiftung/forms/destinataere.py index 2940a2e..ceb989b 100644 --- a/app/stiftung/forms/destinataere.py +++ b/app/stiftung/forms/destinataere.py @@ -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 diff --git a/app/stiftung/migrations/0055_add_import_types_for_unified_import_export.py b/app/stiftung/migrations/0055_add_import_types_for_unified_import_export.py new file mode 100644 index 0000000..0644eaa --- /dev/null +++ b/app/stiftung/migrations/0055_add_import_types_for_unified_import_export.py @@ -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'), + ), + ] diff --git a/app/stiftung/migrations/0056_agent_models.py b/app/stiftung/migrations/0056_agent_models.py new file mode 100644 index 0000000..932d7d4 --- /dev/null +++ b/app/stiftung/migrations/0056_agent_models.py @@ -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 + ] diff --git a/app/stiftung/migrations/0057_alter_applicationpermission_options_and_more.py b/app/stiftung/migrations/0057_alter_applicationpermission_options_and_more.py new file mode 100644 index 0000000..ae1fe10 --- /dev/null +++ b/app/stiftung/migrations/0057_alter_applicationpermission_options_and_more.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'), + ), + ] diff --git a/app/stiftung/migrations/0058_dms_email_kontext_und_nachweis_dokumente.py b/app/stiftung/migrations/0058_dms_email_kontext_und_nachweis_dokumente.py new file mode 100644 index 0000000..9c31fc2 --- /dev/null +++ b/app/stiftung/migrations/0058_dms_email_kontext_und_nachweis_dokumente.py @@ -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'), + ), + ] diff --git a/app/stiftung/migrations/0059_nachweis_kategorie_dms_felder.py b/app/stiftung/migrations/0059_nachweis_kategorie_dms_felder.py new file mode 100644 index 0000000..45256a7 --- /dev/null +++ b/app/stiftung/migrations/0059_nachweis_kategorie_dms_felder.py @@ -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)'), + ), + ] diff --git a/app/stiftung/models/destinataere.py b/app/stiftung/models/destinataere.py index fd44773..7b1de5c 100644 --- a/app/stiftung/models/destinataere.py +++ b/app/stiftung/models/destinataere.py @@ -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 diff --git a/app/stiftung/models/dokumente.py b/app/stiftung/models/dokumente.py index 8fd370b..90ec591 100644 --- a/app/stiftung/models/dokumente.py +++ b/app/stiftung/models/dokumente.py @@ -31,6 +31,7 @@ class DokumentDatei(models.Model): ("korrespondenz", "Korrespondenz / Brief"), ("bescheid", "Bescheid / Behörde"), ("stiftungsgeschichte", "Stiftungsgeschichte / Archiv"), + ("email", "E-Mail-Nachricht"), ("anderes", "Sonstiges"), ] diff --git a/app/stiftung/models/system.py b/app/stiftung/models/system.py index 1ee3f2a..001d615 100644 --- a/app/stiftung/models/system.py +++ b/app/stiftung/models/system.py @@ -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"), ] diff --git a/app/stiftung/tasks.py b/app/stiftung/tasks.py index 3353c98..e3c99c4 100644 --- a/app/stiftung/tasks.py +++ b/app/stiftung/tasks.py @@ -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") diff --git a/app/stiftung/urls.py b/app/stiftung/urls.py index 6bddf21..d534b03 100644 --- a/app/stiftung/urls.py +++ b/app/stiftung/urls.py @@ -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( diff --git a/app/stiftung/views/__init__.py b/app/stiftung/views/__init__.py index 7a40182..53ce682 100644 --- a/app/stiftung/views/__init__.py +++ b/app/stiftung/views/__init__.py @@ -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 diff --git a/app/stiftung/views/dms.py b/app/stiftung/views/dms.py index 61efd77..8cefd84 100644 --- a/app/stiftung/views/dms.py +++ b/app/stiftung/views/dms.py @@ -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() diff --git a/app/stiftung/views/geschichte.py b/app/stiftung/views/geschichte.py index 0618c8b..bfee57c 100644 --- a/app/stiftung/views/geschichte.py +++ b/app/stiftung/views/geschichte.py @@ -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, } diff --git a/app/stiftung/views/import_export.py b/app/stiftung/views/import_export.py new file mode 100644 index 0000000..e5f7ecf --- /dev/null +++ b/app/stiftung/views/import_export.py @@ -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") diff --git a/app/stiftung/views/land.py b/app/stiftung/views/land.py index b04a542..2da178e 100644 --- a/app/stiftung/views/land.py +++ b/app/stiftung/views/land.py @@ -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() ), ) diff --git a/app/stiftung/views/unterstuetzungen.py b/app/stiftung/views/unterstuetzungen.py index a7fe2af..97dca27 100644 --- a/app/stiftung/views/unterstuetzungen.py +++ b/app/stiftung/views/unterstuetzungen.py @@ -1299,16 +1299,53 @@ 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 - + # Auto-update status based on completion if quarterly_proof.is_complete(): if quarterly_proof.status in ['offen', 'teilweise']: @@ -1317,15 +1354,15 @@ def quarterly_confirmation_edit(request, pk): else: # If not complete, set to teilweise if some fields are filled has_partial_data = ( - quarterly_proof.einkommenssituation_bestaetigt or + quarterly_proof.einkommenssituation_bestaetigt or quarterly_proof.vermogenssituation_bestaetigt or quarterly_proof.studiennachweis_eingereicht ) if has_partial_data and quarterly_proof.status == 'offen': quarterly_proof.status = 'teilweise' - + quarterly_proof.save() - + # Try to create automatic support payment if complete if quarterly_proof.is_complete() and quarterly_proof.status == 'eingereicht': support_payment = create_quarterly_support_payment(quarterly_proof) @@ -1343,17 +1380,17 @@ def quarterly_confirmation_edit(request, pk): reasons.append("keine IBAN hinterlegt") if not quarterly_proof.destinataer.standard_konto and not StiftungsKonto.objects.exists(): reasons.append("kein Auszahlungskonto verfügbar") - + if reasons: messages.warning( request, f"Automatische Unterstützung konnte nicht erstellt werden: {', '.join(reasons)}" ) - + # Debug message to see what happened status_changed = old_status != quarterly_proof.status status_msg = f" (Status: {old_status} → {quarterly_proof.status})" if status_changed else f" (Status: {quarterly_proof.status})" - + messages.success( request, f"Vierteljahresnachweis für {nachweis.destinataer.get_full_name()} " @@ -1367,12 +1404,27 @@ def quarterly_confirmation_edit(request, pk): messages.error(request, f"Fehler in {field}: {error}") 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) diff --git a/app/templates/base.html b/app/templates/base.html index cb70345..49199c4 100644 --- a/app/templates/base.html +++ b/app/templates/base.html @@ -661,6 +661,15 @@ + + + +
diff --git a/app/templates/stiftung/csv_import_mapping.html b/app/templates/stiftung/csv_import_mapping.html new file mode 100644 index 0000000..23587f4 --- /dev/null +++ b/app/templates/stiftung/csv_import_mapping.html @@ -0,0 +1,208 @@ +{% extends 'base.html' %} + +{% block title %}CSV Import – Feldzuordnung{% endblock %} + +{% block content %} +
+
+ + +
+ + {{ filename }} – {{ total_rows }} Datenzeilen erkannt. + Ordnen Sie die CSV-Spalten den Datenbankfeldern zu. Nicht zugeordnete Spalten werden übersprungen. +
+ +
+ {% csrf_token %} + + +
+
+
+ Spalten zuordnen +
+
+
+
+ + + + + + + + + + + + {% for col in header_previews %} + + + + + + + + {% endfor %} + +
#CSV-SpalteZuordnungVorschau
{{ forloop.counter }}{{ col.header }} + + + {{ col.preview|truncatechars:60 }} +
+
+
+ +
+ + + {% if preview_rows %} +
+
+
+ Vorschau (erste {{ preview_rows|length }} Zeilen) +
+
+
+
+ + + + {% for col in header_previews %} + + {% endfor %} + + + + {% for row in preview_rows %} + + {% for cell in row %} + + {% endfor %} + + {% endfor %} + +
{{ col.header }}
{{ cell|truncatechars:40 }}
+
+
+
+ {% endif %} + + +
+
+
+ Import-Modus +
+
+
+
+
+
+ + +
+
+
+
+ + +
+
+
+
+ + +
+
+
+
+
+ + +
+ + Abbrechen + + +
+
+
+
+{% endblock %} + +{% block javascript %} + +{% endblock %} diff --git a/app/templates/stiftung/email_eingang/detail.html b/app/templates/stiftung/email_eingang/detail.html index 87ff290..0d4b924 100644 --- a/app/templates/stiftung/email_eingang/detail.html +++ b/app/templates/stiftung/email_eingang/detail.html @@ -101,11 +101,43 @@
+ {# E-Mail-Dokument (Cover-Email als DMS-Dokument) #} + {% if email_dokument %} +
+
+ E-Mail als Dokument +
+
+
+
+ {{ email_dokument.titel }} +
{{ email_dokument.get_human_size }} · Erstellt {{ email_dokument.erstellt_am|date:"d.m.Y H:i" }} +
+ +
+ {% if anhaenge_dokumente %} +
+ Diese E-Mail hat {{ anhaenge_dokumente|length }} Anhang/Anhaenge (siehe unten) + {% endif %} +
+
+ {% endif %} + {# Anhaenge / DMS-Dokumente #} - {% if dms_dokumente %} + {% if anhaenge_dokumente %}
- Anhaenge ({{ dms_dokumente|length }}) + Anhaenge ({{ anhaenge_dokumente|length }}) + {% if email_dokument %} + gehoeren zur obigen E-Mail + {% endif %}
@@ -118,17 +150,22 @@ - {% for dok in dms_dokumente %} + {% for dok in anhaenge_dokumente %} {% endfor %} @@ -136,7 +173,7 @@
{{ dok.dateiname_original|default:dok.titel }} {{ dok.dateityp|default:"–" }} {{ dok.get_human_size }} - {% if dok.datei %} - - Herunterladen - - {% endif %} +
+ {% if dok.datei %} + + + + {% endif %} + + + +
- {% else %} + {% elif not email_dokument %}
Keine Anhaenge in dieser E-Mail. diff --git a/app/templates/stiftung/import_export_hub.html b/app/templates/stiftung/import_export_hub.html new file mode 100644 index 0000000..cc1092a --- /dev/null +++ b/app/templates/stiftung/import_export_hub.html @@ -0,0 +1,187 @@ +{% extends 'base.html' %} + +{% block title %}Daten Import & Export - Stiftungsverwaltung{% endblock %} + +{% block content %} +
+
+
+

Daten Import & Export

+
+ + +
+
+
+ CSV Export +
+
+
+

Exportieren Sie beliebige Daten als CSV-Datei (Semikolon-getrennt, UTF-8 mit BOM für Excel-Kompatibilität).

+
+ {% for et in export_types %} +
+
+
+
{{ et.label }}
+

{{ et.count }} Datensätze

+ + Exportieren + +
+
+
+ {% endfor %} +
+
+
+ + +
+
+
+ CSV Import +
+
+
+
+
+

Importieren Sie Daten aus CSV-Dateien. Im nächsten Schritt können Sie die Spalten Ihrer CSV-Datei den Datenbankfeldern zuordnen.

+
+ {% csrf_token %} +
+
+ + +
+
+ + +
+
+ +
+
+
+
+
+
+
Hinweise
+
    +
  • Unterstützte Formate: CSV (Komma oder Semikolon)
  • +
  • Zeichenkodierung: UTF-8 oder Latin-1
  • +
  • Datumsformate: TT.MM.JJJJ oder JJJJ-MM-TT
  • +
  • Bestehende Datensätze werden anhand eindeutiger Felder aktualisiert
  • +
  • Im nächsten Schritt ordnen Sie CSV-Spalten den Feldern zu
  • +
+
+
+
+
+
+ + + {% if recent_imports %} +
+
+
+ Letzte Imports +
+
+
+
+ + + + + + + + + + + + + + {% for imp in recent_imports %} + + + + + + + + + + + {% if imp.error_log %} + + {% endif %} + {% endfor %} + +
TypDateinameStatusErgebnisBenutzerDatumDetails
{{ imp.get_import_type_display }}{{ imp.filename }} + {% if imp.status == 'completed' %} + OK + {% elif imp.status == 'partial' %} + Teilweise + {% elif imp.status == 'failed' %} + Fehler + {% elif imp.status == 'processing' %} + Läuft + {% else %} + {{ imp.get_status_display }} + {% endif %} + + {% if imp.total_rows > 0 %} + {{ imp.imported_rows }}/{{ imp.total_rows }} importiert + {% if imp.failed_rows > 0 %} + ({{ imp.failed_rows }} fehlgeschlagen) + {% endif %} + {% else %} + - + {% endif %} + {{ imp.created_by|default:"-" }}{{ imp.started_at|date:"d.m.Y H:i" }} + {% if imp.error_log %} + + {% endif %} +
+
+
+
+ {% endif %} +
+
+{% endblock %} diff --git a/app/templates/stiftung/quarterly_confirmation_edit.html b/app/templates/stiftung/quarterly_confirmation_edit.html index 984fe72..dfa660f 100644 --- a/app/templates/stiftung/quarterly_confirmation_edit.html +++ b/app/templates/stiftung/quarterly_confirmation_edit.html @@ -245,13 +245,35 @@ {% endif %}
-
+
{{ form.studiennachweis_bemerkung }} {% if form.studiennachweis_bemerkung.help_text %} {{ form.studiennachweis_bemerkung.help_text }} {% endif %}
+ + {% if alle_dms_dokumente or nachweis.studiennachweis_dms_dokument %} +
+ + {% if nachweis.studiennachweis_dms_dokument %} +
+ + {{ nachweis.studiennachweis_dms_dokument.titel }} + ({{ nachweis.studiennachweis_dms_dokument.get_human_size }}) + +
+ {% endif %} + +
+ {% endif %}
@@ -278,12 +300,12 @@ {% endif %} -
+
{{ form.einkommenssituation_datei }} {% if nachweis.einkommenssituation_datei %}
- Aktuelle Datei: + Aktuelle Datei: {{ nachweis.einkommenssituation_datei.name }} @@ -294,6 +316,28 @@ {{ form.einkommenssituation_datei.help_text }} {% endif %}
+ + {% if alle_dms_dokumente or nachweis.einkommenssituation_dms_dokument %} +
+ + {% if nachweis.einkommenssituation_dms_dokument %} +
+ + {{ nachweis.einkommenssituation_dms_dokument.titel }} + ({{ nachweis.einkommenssituation_dms_dokument.get_human_size }}) + +
+ {% endif %} + +
+ {% endif %}
@@ -336,6 +380,28 @@ {{ form.vermogenssituation_datei.help_text }} {% endif %} + + {% if alle_dms_dokumente or nachweis.vermogenssituation_dms_dokument %} +
+ + {% if nachweis.vermogenssituation_dms_dokument %} +
+ + {{ nachweis.vermogenssituation_dms_dokument.titel }} + ({{ nachweis.vermogenssituation_dms_dokument.get_human_size }}) + +
+ {% endif %} + +
+ {% endif %} @@ -374,6 +440,66 @@ + +
+
+
+ Dokumente aus dem DMS verknuepfen +
+
+
+

+ Waehlen Sie Dokumente aus dem DMS von {{ destinataer.get_full_name }}, die als Nachweise fuer dieses Quartal dienen sollen. +

+ + {% if verknuepfte_nachweis_dokumente %} +
+ +
+ {% for dok in verknuepfte_nachweis_dokumente %} +
+
+ + {{ dok.titel }} + {{ dok.get_kontext_display }} +
{{ dok.dateiname_original }} ({{ dok.get_human_size }}) +
+
+ + +
+
+ {% endfor %} +
+
+ {% endif %} + + {% if verfuegbare_dms_dokumente %} +
+ + + + Waehlen Sie ein Dokument und klicken Sie auf "Speichern", um es als Nachweis zu verknuepfen. + +
+ {% elif not verknuepfte_nachweis_dokumente %} +
+ Keine DMS-Dokumente fuer {{ destinataer.get_full_name }} vorhanden. +
Dokument hochladen +
+ {% endif %} +
+
+ {% if user.is_staff %}
diff --git a/compose.dev.yml b/compose.dev.yml index df238b4..63cb3b4 100644 --- a/compose.dev.yml +++ b/compose.dev.yml @@ -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: diff --git a/compose.yml b/compose.yml index aa8e32e..f64c8c6 100644 --- a/compose.yml +++ b/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= 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: diff --git a/env-production.template b/env-production.template index 0b754dd..bc791dc 100644 --- a/env-production.template +++ b/env-production.template @@ -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: # =============================================================================