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

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

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