From fdf078fa1008cc0092f66da0608acea94262bd71 Mon Sep 17 00:00:00 2001 From: SysAdmin Agent Date: Sat, 21 Mar 2026 09:15:35 +0000 Subject: [PATCH] Add MCP tools for Veranstaltung participant management - veranstaltungen_anzeigen: list events with participant counts - veranstaltung_teilnehmer_anzeigen: list participants by event - veranstaltung_teilnehmer_anlegen: add single participant - veranstaltung_teilnehmer_importieren: bulk import via JSON array Co-Authored-By: Claude Opus 4.6 --- app/mcp_server/__init__.py | 2 + app/mcp_server/server.py | 9 ++ app/mcp_server/tools/lesen.py | 94 ++++++++++++++++++ app/mcp_server/tools/schreiben.py | 155 ++++++++++++++++++++++++++++++ 4 files changed, 260 insertions(+) diff --git a/app/mcp_server/__init__.py b/app/mcp_server/__init__.py index c47e2a5..5da8cd5 100644 --- a/app/mcp_server/__init__.py +++ b/app/mcp_server/__init__.py @@ -1 +1,3 @@ # MCP Server für die Stiftungsverwaltung +# Portabler MCP Server – kompatibel mit Claude Desktop, Cursor, Windsurf und +# allen MCP-kompatiblen AI-Tools. Siehe README.md für Einrichtung. diff --git a/app/mcp_server/server.py b/app/mcp_server/server.py index cca05b5..7efd2c6 100644 --- a/app/mcp_server/server.py +++ b/app/mcp_server/server.py @@ -28,6 +28,7 @@ if _app_dir not in sys.path: sys.path.insert(0, _app_dir) os.environ.setdefault("DJANGO_SETTINGS_MODULE", "core.settings") +os.environ.setdefault("DJANGO_ALLOW_ASYNC_UNSAFE", "true") import django # noqa: E402 @@ -94,6 +95,8 @@ from mcp_server.tools.lesen import ( # noqa: E402 statistiken, termine_anzeigen, transaktionen_suchen, + veranstaltung_teilnehmer_anzeigen, + veranstaltungen_anzeigen, verwaltungskosten, ) @@ -108,6 +111,8 @@ mcp.tool()(transaktionen_suchen) mcp.tool()(dokument_suchen) mcp.tool()(dokument_details) mcp.tool()(termine_anzeigen) +mcp.tool()(veranstaltungen_anzeigen) +mcp.tool()(veranstaltung_teilnehmer_anzeigen) mcp.tool()(globale_suche) mcp.tool()(dashboard) mcp.tool()(statistiken) @@ -128,6 +133,8 @@ if can_write(_current_role): paechter_anlegen, termin_anlegen, unterstuetzung_anlegen, + veranstaltung_teilnehmer_anlegen, + veranstaltung_teilnehmer_importieren, verpachtung_anlegen, verwaltungskosten_erfassen, ) @@ -142,6 +149,8 @@ if can_write(_current_role): mcp.tool()(verwaltungskosten_erfassen) mcp.tool()(termin_anlegen) mcp.tool()(dokument_verknuepfen) + mcp.tool()(veranstaltung_teilnehmer_anlegen) + mcp.tool()(veranstaltung_teilnehmer_importieren) # ────────────────────────────────────────────────────────────────────────────── # Server starten diff --git a/app/mcp_server/tools/lesen.py b/app/mcp_server/tools/lesen.py index b20f91d..1b1e5f7 100644 --- a/app/mcp_server/tools/lesen.py +++ b/app/mcp_server/tools/lesen.py @@ -569,6 +569,100 @@ def termine_anzeigen( return format_result({"anzahl": len(results), "termine": results}) +# ────────────────────────────────────────────────────────────────────────────── +# Veranstaltungen +# ────────────────────────────────────────────────────────────────────────────── + +def veranstaltungen_anzeigen( + status: str = "", + limit: int = 20, +) -> str: + """ + Zeigt Veranstaltungen der Stiftung an. + + Args: + status: geplant/einladungen_versendet/abgeschlossen/abgesagt (optional, leer = alle) + limit: Maximale Anzahl (max. 50) + """ + from stiftung.models.veranstaltungen import Veranstaltung + + role = _get_role() + limit = min(limit, 50) + + qs = Veranstaltung.objects.all() + if status: + qs = qs.filter(status=status) + + qs = qs.order_by("-datum")[:limit] + + results = [] + for v in qs: + results.append({ + "id": str(v.id), + "titel": v.titel, + "datum": v.datum.isoformat(), + "uhrzeit": v.uhrzeit.isoformat() if v.uhrzeit else None, + "ort": v.ort, + "status": v.status, + "teilnehmer_gesamt": v.get_teilnehmer_count(), + "zugesagt": v.get_zugesagte_count(), + "abgesagt": v.get_abgesagte_count(), + }) + + log_mcp_read(role, "veranstaltung", "Veranstaltungsübersicht", f"{len(results)} Veranstaltungen") + return format_result({"anzahl": len(results), "veranstaltungen": results}) + + +def veranstaltung_teilnehmer_anzeigen( + veranstaltung_id: str, + rsvp_status: str = "", +) -> str: + """ + Zeigt die Teilnehmer einer Veranstaltung an. + + Args: + veranstaltung_id: UUID der Veranstaltung (Pflichtfeld) + rsvp_status: eingeladen/zugesagt/abgesagt/keine_rueckmeldung (optional, leer = alle) + """ + from stiftung.models.veranstaltungen import Veranstaltung + + role = _get_role() + + try: + veranstaltung = Veranstaltung.objects.get(id=veranstaltung_id) + except Veranstaltung.DoesNotExist: + return format_result({"fehler": f"Veranstaltung {veranstaltung_id} nicht gefunden"}) + + qs = veranstaltung.teilnehmer.all() + if rsvp_status: + qs = qs.filter(rsvp_status=rsvp_status) + + results = [] + for t in qs: + results.append({ + "id": str(t.id), + "anrede": t.anrede, + "vorname": t.vorname, + "nachname": t.nachname, + "strasse": t.strasse, + "plz": t.plz, + "ort": t.ort, + "email": t.email, + "rsvp_status": t.rsvp_status, + "destinataer_id": str(t.destinataer_id) if t.destinataer_id else None, + }) + + log_mcp_read( + role, "veranstaltung", str(veranstaltung.id), + f"{len(results)} Teilnehmer von '{veranstaltung.titel}'", + ) + return format_result({ + "veranstaltung": str(veranstaltung), + "anzahl": len(results), + "teilnehmer": results, + }) + + # ────────────────────────────────────────────────────────────────────────────── # Globale Suche & Dashboard # ────────────────────────────────────────────────────────────────────────────── diff --git a/app/mcp_server/tools/schreiben.py b/app/mcp_server/tools/schreiben.py index 1286237..ff9970d 100644 --- a/app/mcp_server/tools/schreiben.py +++ b/app/mcp_server/tools/schreiben.py @@ -575,3 +575,158 @@ def dokument_verknuepfen( 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())}) + + +# ────────────────────────────────────────────────────────────────────────────── +# Veranstaltungen – Teilnehmer +# ────────────────────────────────────────────────────────────────────────────── + +def veranstaltung_teilnehmer_anlegen( + veranstaltung_id: str, + vorname: str, + nachname: str, + anrede: str = "", + strasse: str = "", + plz: str = "", + ort: str = "", + email: str = "", + rsvp_status: str = "eingeladen", + bemerkungen: str = "", + destinataer_id: str = "", +) -> str: + """ + Fügt einen Teilnehmer zu einer Veranstaltung hinzu. + + Args: + veranstaltung_id: UUID der Veranstaltung (Pflichtfeld) + vorname: Vorname (Pflichtfeld) + nachname: Nachname (Pflichtfeld) + anrede: Herr/Frau (optional). Akzeptiert auch 'Herrn' → wird zu 'Herr' normalisiert. + strasse: Straße und Hausnummer (optional) + plz: Postleitzahl (optional) + ort: Ort (optional) + email: E-Mail-Adresse (optional) + rsvp_status: eingeladen/zugesagt/abgesagt/keine_rueckmeldung (Standard: eingeladen) + bemerkungen: Freitext-Bemerkungen (optional) + destinataer_id: UUID eines bestehenden Destinatärs zum Verknüpfen (optional) + """ + from stiftung.models import Destinataer + from stiftung.models.veranstaltungen import Veranstaltung, Veranstaltungsteilnehmer + + role = _require_write_role() + + try: + veranstaltung = Veranstaltung.objects.get(id=veranstaltung_id) + except Veranstaltung.DoesNotExist: + return format_result({"fehler": f"Veranstaltung {veranstaltung_id} nicht gefunden"}) + + # Normalize anrede: 'Herrn' → 'Herr' + anrede_norm = anrede.strip() + if anrede_norm.lower() == "herrn": + anrede_norm = "Herr" + + kwargs = { + "veranstaltung": veranstaltung, + "vorname": vorname.strip(), + "nachname": nachname.strip(), + "anrede": anrede_norm, + "strasse": strasse.strip(), + "plz": plz.strip(), + "ort": ort.strip(), + "email": email.strip(), + "rsvp_status": rsvp_status, + "bemerkungen": bemerkungen, + } + + 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"}) + + teilnehmer = Veranstaltungsteilnehmer.objects.create(**kwargs) + name = f"{vorname} {nachname}" + log_mcp_create(role, "veranstaltung", str(teilnehmer.id), f"Teilnehmer: {name}") + return format_result({ + "erfolg": True, + "id": str(teilnehmer.id), + "name": name, + "veranstaltung": str(veranstaltung), + }) + + +def veranstaltung_teilnehmer_importieren( + veranstaltung_id: str, + teilnehmer_liste: str, +) -> str: + """ + Importiert mehrere Teilnehmer auf einmal in eine Veranstaltung. + + Args: + veranstaltung_id: UUID der Veranstaltung (Pflichtfeld) + teilnehmer_liste: JSON-Array mit Teilnehmerdaten. Jedes Objekt kann enthalten: + vorname (Pflicht), nachname (Pflicht), anrede, strasse, plz, ort, email, + rsvp_status, bemerkungen. + Beispiel: [{"vorname": "Max", "nachname": "Muster", "anrede": "Herr", + "strasse": "Musterstr. 1", "plz": "12345", "ort": "Berlin"}] + """ + import json as _json + + from stiftung.models.veranstaltungen import Veranstaltung, Veranstaltungsteilnehmer + + role = _require_write_role() + + try: + veranstaltung = Veranstaltung.objects.get(id=veranstaltung_id) + except Veranstaltung.DoesNotExist: + return format_result({"fehler": f"Veranstaltung {veranstaltung_id} nicht gefunden"}) + + try: + teilnehmer_data = _json.loads(teilnehmer_liste) + except _json.JSONDecodeError as e: + return format_result({"fehler": f"Ungültiges JSON: {e}"}) + + if not isinstance(teilnehmer_data, list): + return format_result({"fehler": "teilnehmer_liste muss ein JSON-Array sein"}) + + erstellt = [] + fehler = [] + + for idx, entry in enumerate(teilnehmer_data): + vorname = (entry.get("vorname") or "").strip() + nachname = (entry.get("nachname") or "").strip() + + if not vorname or not nachname: + fehler.append({"index": idx, "grund": "vorname und nachname sind Pflichtfelder"}) + continue + + anrede = (entry.get("anrede") or "").strip() + if anrede.lower() == "herrn": + anrede = "Herr" + + teilnehmer = Veranstaltungsteilnehmer.objects.create( + veranstaltung=veranstaltung, + vorname=vorname, + nachname=nachname, + anrede=anrede, + strasse=(entry.get("strasse") or "").strip(), + plz=(entry.get("plz") or "").strip(), + ort=(entry.get("ort") or "").strip(), + email=(entry.get("email") or "").strip(), + rsvp_status=entry.get("rsvp_status", "eingeladen"), + bemerkungen=entry.get("bemerkungen", ""), + ) + erstellt.append({"id": str(teilnehmer.id), "name": f"{vorname} {nachname}"}) + + log_mcp_create( + role, "veranstaltung", str(veranstaltung.id), + f"{len(erstellt)} Teilnehmer importiert", + ) + return format_result({ + "erfolg": True, + "veranstaltung": str(veranstaltung), + "erstellt": len(erstellt), + "fehler": len(fehler), + "teilnehmer": erstellt, + "fehler_details": fehler if fehler else None, + })