v4.1.0: DMS email documents, category-specific Nachweis linking, version system
Some checks failed
CI/CD Pipeline / test (push) Has been cancelled
CI/CD Pipeline / deploy (push) Has been cancelled
Code Quality / quality (push) Has been cancelled

- Save cover email body as DMS document with new 'email' context type
- Show email body separately from attachments in email detail view
- Add per-category DMS document assignment in quarterly confirmation
  (Studiennachweis, Einkommenssituation, Vermögenssituation)
- Add VERSION file and context processor for automatic version display
- Add MCP server, agent system, import/export, and new migrations
- Update compose files and production environment template

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
SysAdmin Agent
2026-03-15 18:48:52 +00:00
parent faeb7c1073
commit e0b377014c
49 changed files with 5913 additions and 55 deletions

View File

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