Compare commits
68 Commits
28621d2774
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2a3577baff | ||
|
|
d5eb072a46 | ||
|
|
700a6472b7 | ||
|
|
905e5a7d6c | ||
|
|
3cdf49419e | ||
|
|
5d27f9235e | ||
|
|
c305417bb9 | ||
|
|
2a579c83c0 | ||
|
|
55da366014 | ||
|
|
66ccdc793c | ||
|
|
cee51ccec2 | ||
|
|
951c434ef2 | ||
|
|
b257fc090f | ||
|
|
5afa6e0ce1 | ||
|
|
7c7dd6ed1c | ||
|
|
fd626a9c66 | ||
|
|
5807bf85f1 | ||
|
|
f893172a2b | ||
|
|
4d751d861d | ||
|
|
f7c122515f | ||
|
|
5f1a3fd27d | ||
|
|
33ca6c0a1c | ||
|
|
3200ff7563 | ||
|
|
fe2c657586 | ||
|
|
b8fb35db7a | ||
|
|
d7992558ee | ||
|
|
31bf348136 | ||
|
|
4e9fe816d5 | ||
|
|
59e05856b4 | ||
|
|
0e129ae56a | ||
|
|
4ef09750d6 | ||
|
|
7c7bd73404 | ||
|
|
aed540fe4b | ||
|
|
fdf078fa10 | ||
|
|
e0b377014c | ||
|
|
faeb7c1073 | ||
|
|
042114b1e7 | ||
|
|
cb3a75a5a8 | ||
|
|
dccd5e974f | ||
|
|
f1358d0131 | ||
|
|
7e42b50d5b | ||
|
|
7a9dc533c3 | ||
|
|
781d410f88 | ||
|
|
d84421ea38 | ||
|
|
5c9db56158 | ||
|
|
e6f4c5ba1b | ||
|
|
f4fc512ad3 | ||
|
|
8c528308bd | ||
|
|
8ae7bff38c | ||
|
|
65e025d8c4 | ||
|
|
83cf2798b1 | ||
|
|
2a7c9d8529 | ||
|
|
96204c04dd | ||
|
|
c3c6755027 | ||
|
|
8e1db11f8d | ||
|
|
b47ffd4a3c | ||
|
|
cf127b043d | ||
|
|
113bd53a3a | ||
|
|
502fab31fc | ||
|
|
905aa879ee | ||
|
|
2be72c3990 | ||
|
|
a79a0989d6 | ||
|
|
ee2c827d85 | ||
|
|
bf47ba11c9 | ||
|
|
3ca2706e5d | ||
|
|
7e9e4fddf1 | ||
|
|
b4bad7bc83 | ||
|
|
709903e627 |
1
.github/workflows/ci-cd.yml
vendored
1
.github/workflows/ci-cd.yml
vendored
@@ -183,6 +183,7 @@ jobs:
|
||||
|
||||
# Build and start containers from source code
|
||||
echo "🔨 Building and starting containers from source code..."
|
||||
export APP_VERSION=$(cat VERSION 2>/dev/null || echo "unknown")
|
||||
docker-compose -f compose.yml up -d --build
|
||||
|
||||
# Wait for containers to be ready
|
||||
|
||||
10
.mcp.json
Normal file
10
.mcp.json
Normal file
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"mcpServers": {
|
||||
"stiftung": {
|
||||
"command": "bash",
|
||||
"args": [
|
||||
"/home/remmer/stiftung/app/mcp_server/connect.sh"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,8 @@
|
||||
FROM python:3.12-slim
|
||||
ARG APP_VERSION=unknown
|
||||
ENV PYTHONDONTWRITEBYTECODE=1 \
|
||||
PYTHONUNBUFFERED=1
|
||||
PYTHONUNBUFFERED=1 \
|
||||
APP_VERSION=$APP_VERSION
|
||||
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
build-essential libpq-dev postgresql-client \
|
||||
|
||||
26
app/core/context_processors.py
Normal file
26
app/core/context_processors.py
Normal file
@@ -0,0 +1,26 @@
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
_VERSION = None
|
||||
|
||||
|
||||
def app_version(request):
|
||||
global _VERSION
|
||||
if _VERSION is None:
|
||||
# 1. Environment variable (set in Docker/deployment)
|
||||
_VERSION = os.environ.get("APP_VERSION", "").strip()
|
||||
if not _VERSION:
|
||||
# 2. Try VERSION file at common locations
|
||||
base = Path(__file__).resolve().parent.parent # app/
|
||||
for candidate in [
|
||||
base.parent / "VERSION", # repo root (local dev)
|
||||
base / "VERSION", # app/ dir (Docker)
|
||||
]:
|
||||
try:
|
||||
_VERSION = candidate.read_text().strip()
|
||||
break
|
||||
except FileNotFoundError:
|
||||
continue
|
||||
else:
|
||||
_VERSION = "unknown"
|
||||
return {"APP_VERSION": _VERSION}
|
||||
@@ -35,10 +35,12 @@ INSTALLED_APPS = [
|
||||
"django.contrib.humanize",
|
||||
"rest_framework",
|
||||
"rest_framework.authtoken",
|
||||
"django_htmx",
|
||||
"django_otp",
|
||||
"django_otp.plugins.otp_totp",
|
||||
"django_otp.plugins.otp_static",
|
||||
"stiftung",
|
||||
"django.contrib.postgres",
|
||||
]
|
||||
# Add this to app/core/settings.py
|
||||
SESSION_COOKIE_NAME = 'stiftung_sessionid' # Different from default 'sessionid'
|
||||
@@ -48,6 +50,7 @@ MIDDLEWARE = [
|
||||
"django.middleware.security.SecurityMiddleware",
|
||||
"django.contrib.sessions.middleware.SessionMiddleware",
|
||||
"django.middleware.common.CommonMiddleware",
|
||||
"django_htmx.middleware.HtmxMiddleware",
|
||||
"django.middleware.csrf.CsrfViewMiddleware",
|
||||
"django.contrib.auth.middleware.AuthenticationMiddleware",
|
||||
"django_otp.middleware.OTPMiddleware",
|
||||
@@ -69,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",
|
||||
],
|
||||
},
|
||||
},
|
||||
@@ -114,38 +118,47 @@ MEDIA_URL = "/media/"
|
||||
MEDIA_ROOT = BASE_DIR / "media"
|
||||
|
||||
# Celery
|
||||
CELERY_BROKER_URL = os.getenv("REDIS_URL", "redis://redis:6379/0")
|
||||
CELERY_RESULT_BACKEND = os.getenv("REDIS_URL", "redis://redis:6379/0")
|
||||
CELERY_BROKER_URL = os.getenv("CELERY_REDIS_URL", os.getenv("REDIS_URL", "redis://redis:6379/2"))
|
||||
CELERY_RESULT_BACKEND = os.getenv("CELERY_REDIS_URL", os.getenv("REDIS_URL", "redis://redis:6379/2"))
|
||||
CELERY_TASK_SERIALIZER = "json"
|
||||
CELERY_RESULT_SERIALIZER = "json"
|
||||
CELERY_ACCEPT_CONTENT = ["json"]
|
||||
|
||||
# Celery Beat – periodische Tasks
|
||||
from celery.schedules import crontab # noqa: E402
|
||||
|
||||
CELERY_BEAT_SCHEDULE = {
|
||||
# E-Mail-Postfach alle 15 Minuten auf neue Destinatär-Nachrichten prüfen
|
||||
"poll-destinataer-emails": {
|
||||
"task": "stiftung.tasks.poll_destinataer_emails",
|
||||
# E-Mail-Postfach alle 15 Minuten auf neue Nachrichten pruefen
|
||||
"poll-emails": {
|
||||
"task": "stiftung.tasks.poll_emails",
|
||||
"schedule": crontab(minute="*/15"),
|
||||
},
|
||||
# Täglich um 08:00 Uhr: Ablaufende Upload-Tokens prüfen und Erinnerungen versenden
|
||||
"check-ablaufende-tokens": {
|
||||
"task": "stiftung.tasks.check_ablaufende_tokens",
|
||||
"schedule": crontab(hour="8", minute="0"),
|
||||
},
|
||||
}
|
||||
|
||||
# IMAP-Konfiguration für E-Mail-Eingang (Destinatäre)
|
||||
# Pflichtfelder: IMAP_HOST, IMAP_USER, IMAP_PASSWORD
|
||||
IMAP_HOST = os.getenv("IMAP_HOST", "")
|
||||
IMAP_PORT = int(os.getenv("IMAP_PORT", "993"))
|
||||
IMAP_PORT = int(os.getenv("IMAP_PORT") or "993")
|
||||
IMAP_USER = os.getenv("IMAP_USER", "paperless@vhtv-stiftung.de")
|
||||
IMAP_PASSWORD = os.getenv("IMAP_PASSWORD", "")
|
||||
IMAP_FOLDER = os.getenv("IMAP_FOLDER", "INBOX")
|
||||
IMAP_USE_SSL = os.getenv("IMAP_USE_SSL", "true").lower() == "true"
|
||||
|
||||
# Paperless
|
||||
PAPERLESS_API_URL = os.getenv("PAPERLESS_API_URL", "https://vhtv-stiftung.de/paperless")
|
||||
PAPERLESS_API_TOKEN = os.getenv("PAPERLESS_API_TOKEN")
|
||||
PAPERLESS_REQUIRED_TAG = os.getenv("PAPERLESS_REQUIRED_TAG", "Stiftung_Destinatäre")
|
||||
PAPERLESS_LAND_TAG = os.getenv("PAPERLESS_LAND_TAG", "Stiftung_Land_und_Pächter")
|
||||
PAPERLESS_ADMIN_TAG = os.getenv("PAPERLESS_ADMIN_TAG", "Stiftung_Administration")
|
||||
PAPERLESS_DESTINATAERE_TAG_ID = os.getenv("PAPERLESS_DESTINATAERE_TAG_ID")
|
||||
PAPERLESS_LAND_TAG_ID = os.getenv("PAPERLESS_LAND_TAG_ID")
|
||||
PAPERLESS_ADMIN_TAG_ID = os.getenv("PAPERLESS_ADMIN_TAG_ID")
|
||||
# SMTP-Konfiguration für E-Mail-Ausgang (Nachweis-Aufforderungen, Einladungen)
|
||||
# Pflichtfelder: EMAIL_HOST_USER, EMAIL_HOST_PASSWORD
|
||||
EMAIL_HOST = os.getenv("EMAIL_HOST", "smtp.ionos.de")
|
||||
EMAIL_PORT = int(os.getenv("EMAIL_PORT") or "465")
|
||||
EMAIL_USE_SSL = os.getenv("EMAIL_USE_SSL", "true").lower() == "true"
|
||||
EMAIL_HOST_USER = os.getenv("EMAIL_HOST_USER", "")
|
||||
EMAIL_HOST_PASSWORD = os.getenv("EMAIL_HOST_PASSWORD", "")
|
||||
DEFAULT_FROM_EMAIL = os.getenv("DEFAULT_FROM_EMAIL", "stiftung@vhtv-stiftung.de")
|
||||
EMAIL_SUBJECT_PREFIX = "[vHTV-Stiftung] "
|
||||
|
||||
|
||||
# Authentication
|
||||
LOGIN_URL = "/login/"
|
||||
@@ -153,7 +166,7 @@ LOGIN_REDIRECT_URL = "/"
|
||||
LOGOUT_REDIRECT_URL = "/login/"
|
||||
|
||||
# Gramps integration
|
||||
GRAMPS_URL = os.environ.get("GRAMPS_URL", "http://grampsweb:80")
|
||||
GRAMPS_URL = os.environ.get("GRAMPS_URL", "http://grampsweb:5000")
|
||||
GRAMPS_API_TOKEN = os.environ.get("GRAMPS_API_TOKEN", "")
|
||||
GRAMPS_STIFTER_IDS = os.environ.get("GRAMPS_STIFTER_IDS", "") # comma-separated
|
||||
GRAMPS_USERNAME = os.environ.get("GRAMPS_USERNAME", "")
|
||||
@@ -177,7 +190,7 @@ if not DEBUG:
|
||||
|
||||
# django-otp settings
|
||||
OTP_TOTP_ISSUER = 'Stiftung Management System'
|
||||
OTP_LOGIN_URL = '/two-factor/login/'
|
||||
OTP_LOGIN_URL = '/auth/2fa/verify/'
|
||||
|
||||
# Optional: Hide sensitive data in admin when not verified
|
||||
OTP_ADMIN_HIDE_SENSITIVE_DATA = True
|
||||
|
||||
@@ -8,6 +8,8 @@ from stiftung.views import home
|
||||
|
||||
urlpatterns = [
|
||||
path("api/v1/", include("stiftung.api_urls")),
|
||||
# Öffentliches Portal (kein Login erforderlich – tokenbasiert)
|
||||
path("portal/", include("stiftung.portal_urls")),
|
||||
path("", include("stiftung.urls")),
|
||||
path("admin/", admin.site.urls),
|
||||
# Authentication URLs
|
||||
|
||||
21
app/mcp_server/.env.example
Normal file
21
app/mcp_server/.env.example
Normal file
@@ -0,0 +1,21 @@
|
||||
# Stiftung MCP Server – Umgebungsvariablen
|
||||
# Kopiere diese Datei nach .env und passe die Werte an.
|
||||
|
||||
# ── Token-Konfiguration ─────────────────────────────────────────────────
|
||||
# Generiere sichere Token: openssl rand -hex 32
|
||||
MCP_TOKEN_READONLY=
|
||||
MCP_TOKEN_EDITOR=
|
||||
MCP_TOKEN_ADMIN=
|
||||
|
||||
# Aktives Token für die aktuelle Sitzung (eines der obigen)
|
||||
MCP_AUTH_TOKEN=
|
||||
|
||||
# ── Datenbank ────────────────────────────────────────────────────────────
|
||||
DB_HOST=localhost
|
||||
DB_PORT=5432
|
||||
POSTGRES_DB=stiftung
|
||||
POSTGRES_USER=stiftung
|
||||
POSTGRES_PASSWORD=
|
||||
|
||||
# ── Django ───────────────────────────────────────────────────────────────
|
||||
DJANGO_SETTINGS_MODULE=core.settings
|
||||
302
app/mcp_server/README.md
Normal file
302
app/mcp_server/README.md
Normal file
@@ -0,0 +1,302 @@
|
||||
# Stiftung MCP Server
|
||||
|
||||
MCP (Model Context Protocol) Server für die Stiftungsverwaltung. Ermöglicht AI-Assistenten den strukturierten Zugriff auf alle Stiftungsdaten.
|
||||
|
||||
## Funktionsumfang
|
||||
|
||||
### Lese-Tools (alle Rollen)
|
||||
| Tool | Beschreibung |
|
||||
|------|-------------|
|
||||
| `destinataer_suchen` | Suche nach Destinatären (Name, Status, Familienzweig) |
|
||||
| `destinataer_details` | Vollständige Details eines Destinatärs |
|
||||
| `land_suchen` | Suche nach Ländereien (Gemarkung, Gemeinde) |
|
||||
| `land_details` | Details einer Länderei inkl. Verpachtungen |
|
||||
| `paechter_suchen` | Suche nach Pächtern |
|
||||
| `konten_uebersicht` | Alle Stiftungskonten mit Salden |
|
||||
| `verwaltungskosten` | Verwaltungskosten filtern (Jahr, Kategorie, Status) |
|
||||
| `transaktionen_suchen` | Banktransaktionen durchsuchen |
|
||||
| `dokument_suchen` | Volltextsuche im DMS |
|
||||
| `dokument_details` | Metadaten eines Dokuments |
|
||||
| `termine_anzeigen` | Kalendereinträge und Termine |
|
||||
| `globale_suche` | Suche über alle Entitätstypen |
|
||||
| `dashboard` | Kennzahlen-Übersicht |
|
||||
| `statistiken` | Detaillierte Auswertungen |
|
||||
|
||||
### Schreib-Tools (editor/admin)
|
||||
| Tool | Beschreibung |
|
||||
|------|-------------|
|
||||
| `destinataer_anlegen` | Neuen Destinatär erfassen |
|
||||
| `destinataer_aktualisieren` | Bestehenden Destinatär aktualisieren |
|
||||
| `foerderung_anlegen` | Neue Förderung zuweisen |
|
||||
| `unterstuetzung_anlegen` | Unterstützungszahlung erfassen |
|
||||
| `land_anlegen` | Neue Länderei erfassen |
|
||||
| `verpachtung_anlegen` | Pachtvertrag erstellen |
|
||||
| `paechter_anlegen` | Neuen Pächter erfassen |
|
||||
| `verwaltungskosten_erfassen` | Verwaltungskosten buchen |
|
||||
| `termin_anlegen` | Neuen Kalendereintrag erstellen |
|
||||
| `dokument_verknuepfen` | Dokument mit Entität verknüpfen |
|
||||
|
||||
## Voraussetzungen
|
||||
|
||||
- Python 3.11+
|
||||
- Zugriff auf die PostgreSQL-Datenbank der Stiftung
|
||||
- Django-App Abhängigkeiten installiert (`app/requirements.txt`)
|
||||
- MCP SDK: `pip install mcp`
|
||||
|
||||
## Authentifizierung & Rollen
|
||||
|
||||
Der Server verwendet Token-basierte Authentifizierung mit drei Rollen:
|
||||
|
||||
| Rolle | Lesen | Schreiben | PII-Daten |
|
||||
|-------|-------|-----------|-----------|
|
||||
| `readonly` | Ja | Nein | Maskiert |
|
||||
| `editor` | Ja | Ja | Maskiert |
|
||||
| `admin` | Ja | Ja | Vollzugriff |
|
||||
|
||||
### PII-Maskierung (readonly/editor)
|
||||
- IBAN: `****4567`
|
||||
- E-Mail: `***@example.de`
|
||||
- Telefon: `****1234`
|
||||
- Geburtsdatum: nur Jahrgang
|
||||
- Einkommen/Vermögen: Bereichsangabe
|
||||
|
||||
## Umgebungsvariablen
|
||||
|
||||
```bash
|
||||
# Pflicht: Eines der drei Token setzen
|
||||
MCP_TOKEN_READONLY=<geheimes-token-readonly>
|
||||
MCP_TOKEN_EDITOR=<geheimes-token-editor>
|
||||
MCP_TOKEN_ADMIN=<geheimes-token-admin>
|
||||
|
||||
# Pflicht: Das aktive Token für diese Sitzung
|
||||
MCP_AUTH_TOKEN=<das-token-das-gerade-verwendet-wird>
|
||||
|
||||
# Django (automatisch wenn im Docker-Netzwerk)
|
||||
DJANGO_SETTINGS_MODULE=core.settings
|
||||
DB_HOST=db
|
||||
DB_PORT=5432
|
||||
POSTGRES_DB=stiftung
|
||||
POSTGRES_USER=stiftung
|
||||
POSTGRES_PASSWORD=<db-passwort>
|
||||
```
|
||||
|
||||
## Einrichtung
|
||||
|
||||
### 1. Token generieren
|
||||
|
||||
Generiere sichere, zufällige Token für jede Rolle:
|
||||
|
||||
```bash
|
||||
# Beispiel mit openssl
|
||||
export MCP_TOKEN_READONLY=$(openssl rand -hex 32)
|
||||
export MCP_TOKEN_EDITOR=$(openssl rand -hex 32)
|
||||
export MCP_TOKEN_ADMIN=$(openssl rand -hex 32)
|
||||
echo "READONLY: $MCP_TOKEN_READONLY"
|
||||
echo "EDITOR: $MCP_TOKEN_EDITOR"
|
||||
echo "ADMIN: $MCP_TOKEN_ADMIN"
|
||||
```
|
||||
|
||||
Speichere die Token sicher (z.B. in `.env` oder einem Passwort-Manager).
|
||||
|
||||
### 2. Starten
|
||||
|
||||
```bash
|
||||
# Aus dem app/-Verzeichnis:
|
||||
cd /pfad/zum/projekt/app
|
||||
MCP_AUTH_TOKEN=<dein-token> python -m mcp_server
|
||||
```
|
||||
|
||||
Oder mit dem Start-Skript:
|
||||
```bash
|
||||
MCP_AUTH_TOKEN=<dein-token> ./app/mcp_server/start.sh
|
||||
```
|
||||
|
||||
## Client-Konfigurationen
|
||||
|
||||
### Claude Desktop / Claude Code
|
||||
|
||||
Datei: `~/.claude/claude_desktop_config.json` (macOS/Linux) oder `%APPDATA%\Claude\claude_desktop_config.json` (Windows)
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"stiftung": {
|
||||
"command": "python",
|
||||
"args": ["-m", "mcp_server"],
|
||||
"cwd": "/pfad/zum/projekt/app",
|
||||
"env": {
|
||||
"DJANGO_SETTINGS_MODULE": "core.settings",
|
||||
"MCP_AUTH_TOKEN": "<dein-token>",
|
||||
"MCP_TOKEN_READONLY": "<readonly-token>",
|
||||
"MCP_TOKEN_EDITOR": "<editor-token>",
|
||||
"MCP_TOKEN_ADMIN": "<admin-token>",
|
||||
"DB_HOST": "localhost",
|
||||
"DB_PORT": "5432",
|
||||
"POSTGRES_DB": "stiftung",
|
||||
"POSTGRES_USER": "stiftung",
|
||||
"POSTGRES_PASSWORD": "<db-passwort>"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Claude Code (Projekt-spezifisch)
|
||||
|
||||
Datei: `.mcp.json` im Projekt-Root:
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"stiftung": {
|
||||
"command": "python",
|
||||
"args": ["-m", "mcp_server"],
|
||||
"cwd": "./app",
|
||||
"env": {
|
||||
"DJANGO_SETTINGS_MODULE": "core.settings",
|
||||
"MCP_AUTH_TOKEN": "<dein-token>",
|
||||
"MCP_TOKEN_READONLY": "<readonly-token>",
|
||||
"MCP_TOKEN_EDITOR": "<editor-token>",
|
||||
"MCP_TOKEN_ADMIN": "<admin-token>",
|
||||
"DB_HOST": "localhost",
|
||||
"DB_PORT": "5432",
|
||||
"POSTGRES_DB": "stiftung",
|
||||
"POSTGRES_USER": "stiftung",
|
||||
"POSTGRES_PASSWORD": "<db-passwort>"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Cursor
|
||||
|
||||
Datei: `.cursor/mcp.json` im Projekt-Root:
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"stiftung": {
|
||||
"command": "python",
|
||||
"args": ["-m", "mcp_server"],
|
||||
"cwd": "/pfad/zum/projekt/app",
|
||||
"env": {
|
||||
"DJANGO_SETTINGS_MODULE": "core.settings",
|
||||
"MCP_AUTH_TOKEN": "<dein-token>",
|
||||
"MCP_TOKEN_READONLY": "<readonly-token>",
|
||||
"MCP_TOKEN_EDITOR": "<editor-token>",
|
||||
"MCP_TOKEN_ADMIN": "<admin-token>",
|
||||
"DB_HOST": "localhost",
|
||||
"DB_PORT": "5432",
|
||||
"POSTGRES_DB": "stiftung",
|
||||
"POSTGRES_USER": "stiftung",
|
||||
"POSTGRES_PASSWORD": "<db-passwort>"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Windsurf
|
||||
|
||||
Datei: `~/.codeium/windsurf/mcp_config.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"stiftung": {
|
||||
"command": "python",
|
||||
"args": ["-m", "mcp_server"],
|
||||
"cwd": "/pfad/zum/projekt/app",
|
||||
"env": {
|
||||
"DJANGO_SETTINGS_MODULE": "core.settings",
|
||||
"MCP_AUTH_TOKEN": "<dein-token>",
|
||||
"MCP_TOKEN_READONLY": "<readonly-token>",
|
||||
"MCP_TOKEN_EDITOR": "<editor-token>",
|
||||
"MCP_TOKEN_ADMIN": "<admin-token>",
|
||||
"DB_HOST": "localhost",
|
||||
"DB_PORT": "5432",
|
||||
"POSTGRES_DB": "stiftung",
|
||||
"POSTGRES_USER": "stiftung",
|
||||
"POSTGRES_PASSWORD": "<db-passwort>"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Docker (empfohlen für Produktion)
|
||||
|
||||
```bash
|
||||
docker compose exec mcp python -m mcp_server
|
||||
```
|
||||
|
||||
Oder als MCP-Client-Konfiguration:
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"stiftung": {
|
||||
"command": "docker",
|
||||
"args": ["compose", "-f", "/pfad/zum/projekt/compose.yml", "exec", "-T", "mcp", "python", "-m", "mcp_server"],
|
||||
"env": {
|
||||
"MCP_AUTH_TOKEN": "<dein-token>"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Generisch (jeder MCP-kompatible Client)
|
||||
|
||||
Transport: **stdio** (Standard)
|
||||
|
||||
```bash
|
||||
# Direkt starten
|
||||
cd /pfad/zum/projekt/app
|
||||
MCP_AUTH_TOKEN=<token> \
|
||||
MCP_TOKEN_READONLY=<readonly> \
|
||||
MCP_TOKEN_EDITOR=<editor> \
|
||||
MCP_TOKEN_ADMIN=<admin> \
|
||||
DB_HOST=localhost \
|
||||
POSTGRES_DB=stiftung \
|
||||
POSTGRES_USER=stiftung \
|
||||
POSTGRES_PASSWORD=<pw> \
|
||||
python -m mcp_server
|
||||
```
|
||||
|
||||
## Datenschutz
|
||||
|
||||
- Alle Aktionen werden im AuditLog erfasst (Quelle: `mcp:<rolle>`)
|
||||
- PII-Felder werden bei readonly/editor automatisch maskiert
|
||||
- Kein Bulk-Export möglich (Ergebnis-Limits pro Abfrage)
|
||||
- Listen-Abfragen liefern reduzierte Felder
|
||||
- Der Server läuft im Docker-internen Netzwerk ohne externen Port
|
||||
|
||||
## Dateistruktur
|
||||
|
||||
```
|
||||
app/mcp_server/
|
||||
├── __init__.py # Paket-Marker
|
||||
├── __main__.py # python -m mcp_server Einstiegspunkt
|
||||
├── server.py # MCP Server Hauptmodul (Tool-Registrierung)
|
||||
├── auth.py # Token-Authentifizierung, Rollen-System
|
||||
├── privacy.py # PII-Maskierung
|
||||
├── audit.py # AuditLog-Integration
|
||||
├── start.sh # Shell-Startskript
|
||||
├── requirements.txt # MCP-spezifische Abhängigkeiten
|
||||
├── README.md # Diese Datei
|
||||
└── tools/
|
||||
├── __init__.py
|
||||
├── helpers.py # Serialisierung, Model→Dict Konvertierung
|
||||
├── lesen.py # 14 Lese-Tools
|
||||
└── schreiben.py # 10 Schreib-Tools
|
||||
```
|
||||
|
||||
## Sicherheitshinweise
|
||||
|
||||
- Token niemals im Code oder in Git committen
|
||||
- Für Produktion: Token in `.env`-Datei oder Secret-Manager speichern
|
||||
- Empfohlene Token-Rotation: alle 90 Tage
|
||||
- Bei Verdacht auf Token-Kompromittierung: sofort rotieren
|
||||
- Der MCP Server sollte nur im lokalen Netzwerk oder via VPN erreichbar sein
|
||||
3
app/mcp_server/__init__.py
Normal file
3
app/mcp_server/__init__.py
Normal file
@@ -0,0 +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.
|
||||
4
app/mcp_server/__main__.py
Normal file
4
app/mcp_server/__main__.py
Normal file
@@ -0,0 +1,4 @@
|
||||
"""Ermöglicht Start via: python -m mcp_server"""
|
||||
from mcp_server.server import mcp
|
||||
|
||||
mcp.run(transport="stdio")
|
||||
103
app/mcp_server/audit.py
Normal file
103
app/mcp_server/audit.py
Normal file
@@ -0,0 +1,103 @@
|
||||
"""
|
||||
Audit-Integration für MCP-Aktionen.
|
||||
|
||||
Alle MCP-Aktionen werden im bestehenden AuditLog erfasst.
|
||||
Da MCP kein HTTP-Request-Objekt hat, werden Felder direkt gesetzt:
|
||||
- user_agent = "MCP/<rolle>"
|
||||
- session_key = "mcp"
|
||||
- ip_address = None
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
|
||||
def log_mcp_action(
|
||||
role: str,
|
||||
action: str,
|
||||
entity_type: str,
|
||||
entity_id: str,
|
||||
entity_name: str,
|
||||
description: str,
|
||||
changes: dict | None = None,
|
||||
) -> None:
|
||||
"""
|
||||
Schreibt einen Audit-Log-Eintrag für eine MCP-Aktion.
|
||||
|
||||
Args:
|
||||
role: Aktuelle MCP-Rolle ("readonly", "editor", "admin")
|
||||
action: Aktionstyp (aus AuditLog.ACTION_TYPES)
|
||||
entity_type: Entitätstyp (aus AuditLog.ENTITY_TYPES oder freier Text)
|
||||
entity_id: ID der Entität
|
||||
entity_name: Lesbarer Name der Entität
|
||||
description: Beschreibung der Aktion
|
||||
changes: Optionales Dict mit Änderungen
|
||||
"""
|
||||
# Import hier, damit Django bereits initialisiert ist wenn diese Funktion aufgerufen wird
|
||||
from stiftung.models import AuditLog
|
||||
|
||||
# Normalisiere entity_type: muss in den AuditLog.ENTITY_TYPES-Choices sein
|
||||
# oder auf "system" fallen, da AuditLog choices-Validierung ggf. nicht hart durchgesetzt wird
|
||||
valid_entity_types = {choice[0] for choice in AuditLog.ENTITY_TYPES}
|
||||
if entity_type not in valid_entity_types:
|
||||
entity_type = "system"
|
||||
|
||||
# Normalisiere action: muss in ACTION_TYPES sein
|
||||
valid_actions = {choice[0] for choice in AuditLog.ACTION_TYPES}
|
||||
if action not in valid_actions:
|
||||
action = "export" # Generischer Fallback für MCP-Leseoperationen
|
||||
|
||||
AuditLog.objects.create(
|
||||
user=None,
|
||||
username=f"mcp:{role}",
|
||||
action=action,
|
||||
entity_type=entity_type,
|
||||
entity_id=str(entity_id) if entity_id else "",
|
||||
entity_name=entity_name,
|
||||
description=description,
|
||||
changes=changes,
|
||||
ip_address=None,
|
||||
user_agent=f"MCP/{role}",
|
||||
session_key="mcp",
|
||||
)
|
||||
|
||||
|
||||
def log_mcp_read(role: str, entity_type: str, entity_name: str, description: str) -> None:
|
||||
"""Loggt eine Leseoperation via MCP (als 'export'-Aktion)."""
|
||||
log_mcp_action(
|
||||
role=role,
|
||||
action="export",
|
||||
entity_type=entity_type,
|
||||
entity_id="",
|
||||
entity_name=entity_name,
|
||||
description=description,
|
||||
)
|
||||
|
||||
|
||||
def log_mcp_create(
|
||||
role: str, entity_type: str, entity_id: str, entity_name: str
|
||||
) -> None:
|
||||
"""Loggt eine Erstellungsoperation via MCP."""
|
||||
log_mcp_action(
|
||||
role=role,
|
||||
action="create",
|
||||
entity_type=entity_type,
|
||||
entity_id=entity_id,
|
||||
entity_name=entity_name,
|
||||
description=f"[MCP] {entity_type} '{entity_name}' erstellt",
|
||||
)
|
||||
|
||||
|
||||
def log_mcp_update(
|
||||
role: str, entity_type: str, entity_id: str, entity_name: str, changes: dict
|
||||
) -> None:
|
||||
"""Loggt eine Aktualisierungsoperation via MCP."""
|
||||
changed_fields = ", ".join(changes.keys()) if changes else ""
|
||||
log_mcp_action(
|
||||
role=role,
|
||||
action="update",
|
||||
entity_type=entity_type,
|
||||
entity_id=entity_id,
|
||||
entity_name=entity_name,
|
||||
description=f"[MCP] {entity_type} '{entity_name}' aktualisiert: {changed_fields}",
|
||||
changes=changes,
|
||||
)
|
||||
70
app/mcp_server/auth.py
Normal file
70
app/mcp_server/auth.py
Normal file
@@ -0,0 +1,70 @@
|
||||
"""
|
||||
MCP-Authentifizierung – Token-basierte Authentifizierung mit 3 Rollen.
|
||||
|
||||
Tokens werden über Umgebungsvariablen konfiguriert:
|
||||
MCP_TOKEN_READONLY – Nur-Lese-Zugriff (alle Daten, PII maskiert)
|
||||
MCP_TOKEN_EDITOR – Lesen + Schreiben (PII maskiert)
|
||||
MCP_TOKEN_ADMIN – Voll-Zugriff (keine PII-Maskierung, alle Schreib-Ops)
|
||||
|
||||
Das aktive Token wird per MCP_AUTH_TOKEN übergeben (wird vom MCP-Client gesetzt).
|
||||
"""
|
||||
|
||||
import os
|
||||
|
||||
ROLE_READONLY = "readonly"
|
||||
ROLE_EDITOR = "editor"
|
||||
ROLE_ADMIN = "admin"
|
||||
|
||||
# Rollenrangfolge (höher = mehr Rechte)
|
||||
ROLE_RANK = {ROLE_READONLY: 1, ROLE_EDITOR: 2, ROLE_ADMIN: 3}
|
||||
|
||||
|
||||
def _token_map() -> dict[str, str]:
|
||||
"""Erstellt Mapping token → Rolle aus Umgebungsvariablen."""
|
||||
mapping: dict[str, str] = {}
|
||||
for role, env_var in [
|
||||
(ROLE_READONLY, "MCP_TOKEN_READONLY"),
|
||||
(ROLE_EDITOR, "MCP_TOKEN_EDITOR"),
|
||||
(ROLE_ADMIN, "MCP_TOKEN_ADMIN"),
|
||||
]:
|
||||
token = os.environ.get(env_var, "").strip()
|
||||
if token:
|
||||
mapping[token] = role
|
||||
return mapping
|
||||
|
||||
|
||||
def get_role_for_token(token: str) -> str | None:
|
||||
"""
|
||||
Gibt die Rolle für einen Token zurück oder None bei ungültigem Token.
|
||||
"""
|
||||
if not token:
|
||||
return None
|
||||
return _token_map().get(token)
|
||||
|
||||
|
||||
def get_current_role() -> str | None:
|
||||
"""
|
||||
Gibt die Rolle des aktuell gesetzten MCP_AUTH_TOKEN zurück.
|
||||
Wird vom Server beim Start einmalig ausgewertet.
|
||||
"""
|
||||
token = os.environ.get("MCP_AUTH_TOKEN", "").strip()
|
||||
return get_role_for_token(token)
|
||||
|
||||
|
||||
def can_write(role: str | None) -> bool:
|
||||
"""Darf die Rolle Schreiboperationen ausführen?"""
|
||||
return role in (ROLE_EDITOR, ROLE_ADMIN)
|
||||
|
||||
|
||||
def can_read_unmasked(role: str | None) -> bool:
|
||||
"""Darf die Rolle ungemaskierte PII-Daten lesen?"""
|
||||
return role == ROLE_ADMIN
|
||||
|
||||
|
||||
def require_role(role: str | None) -> None:
|
||||
"""Wirft ValueError wenn keine gültige Rolle vorhanden."""
|
||||
if not role:
|
||||
raise ValueError(
|
||||
"Ungültiger oder fehlender MCP_AUTH_TOKEN. "
|
||||
"Bitte MCP_AUTH_TOKEN-Umgebungsvariable setzen."
|
||||
)
|
||||
16
app/mcp_server/connect.sh
Normal file
16
app/mcp_server/connect.sh
Normal file
@@ -0,0 +1,16 @@
|
||||
#!/usr/bin/env bash
|
||||
# MCP-Verbindungsskript zum Remote-Server
|
||||
# Token wird aus der Umgebungsvariable MCP_AUTH_TOKEN gelesen – nie hardcoden.
|
||||
# Einrichten: export MCP_AUTH_TOKEN=<token> in ~/.bashrc oder per Secrets-Manager.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
: "${MCP_AUTH_TOKEN:?MCP_AUTH_TOKEN nicht gesetzt. Bitte in ~/.bashrc oder ~/.profile exportieren.}"
|
||||
|
||||
exec ssh \
|
||||
-o StrictHostKeyChecking=no \
|
||||
deployment@217.154.84.225 \
|
||||
"cd /opt/stiftung && docker compose run --rm -T \
|
||||
-e MCP_AUTH_TOKEN=${MCP_AUTH_TOKEN} \
|
||||
-e DJANGO_ALLOW_ASYNC_UNSAFE=true \
|
||||
mcp"
|
||||
152
app/mcp_server/privacy.py
Normal file
152
app/mcp_server/privacy.py
Normal file
@@ -0,0 +1,152 @@
|
||||
"""
|
||||
PII-Maskierung für MCP-Ausgaben.
|
||||
|
||||
Bei readonly- und editor-Rollen werden folgende Felder maskiert:
|
||||
- iban → "****" + letzte 4 Stellen
|
||||
- email → "***@" + Domain
|
||||
- telefon → "****" + letzte 4 Ziffern
|
||||
- geburtsdatum → nur Jahreszahl
|
||||
- jaehrliches_einkommen / monatliche_bezuege / vermoegen → Bereichsangabe
|
||||
|
||||
Admin-Rolle erhält ungemaskierte Daten.
|
||||
"""
|
||||
|
||||
import re
|
||||
from decimal import Decimal
|
||||
|
||||
|
||||
def mask_iban(value: str | None) -> str | None:
|
||||
if not value:
|
||||
return value
|
||||
clean = value.replace(" ", "")
|
||||
if len(clean) > 4:
|
||||
return "****" + clean[-4:]
|
||||
return "****"
|
||||
|
||||
|
||||
def mask_email(value: str | None) -> str | None:
|
||||
if not value:
|
||||
return value
|
||||
parts = value.split("@", 1)
|
||||
if len(parts) == 2:
|
||||
return "***@" + parts[1]
|
||||
return "***"
|
||||
|
||||
|
||||
def mask_telefon(value: str | None) -> str | None:
|
||||
if not value:
|
||||
return value
|
||||
digits = re.sub(r"\D", "", value)
|
||||
if len(digits) > 4:
|
||||
return "****" + digits[-4:]
|
||||
return "****"
|
||||
|
||||
|
||||
def mask_geburtsdatum(value) -> str | None:
|
||||
"""Zeigt nur das Jahr des Geburtsdatums."""
|
||||
if not value:
|
||||
return None
|
||||
try:
|
||||
return str(value)[:4] # "YYYY-MM-DD" → "YYYY"
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def mask_einkommen(value) -> str | None:
|
||||
"""Gibt Einkommensbereich statt genauen Wert zurück."""
|
||||
if value is None:
|
||||
return None
|
||||
try:
|
||||
amount = float(value)
|
||||
if amount < 10000:
|
||||
return "< 10.000 €"
|
||||
elif amount < 20000:
|
||||
return "10.000–20.000 €"
|
||||
elif amount < 30000:
|
||||
return "20.000–30.000 €"
|
||||
elif amount < 50000:
|
||||
return "30.000–50.000 €"
|
||||
elif amount < 75000:
|
||||
return "50.000–75.000 €"
|
||||
else:
|
||||
return "> 75.000 €"
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
def mask_monatsbezuege(value) -> str | None:
|
||||
"""Gibt Monatsbezüge-Bereich statt genauen Wert zurück."""
|
||||
if value is None:
|
||||
return None
|
||||
try:
|
||||
amount = float(value)
|
||||
if amount < 500:
|
||||
return "< 500 €/Mon."
|
||||
elif amount < 1000:
|
||||
return "500–1.000 €/Mon."
|
||||
elif amount < 2000:
|
||||
return "1.000–2.000 €/Mon."
|
||||
elif amount < 3000:
|
||||
return "2.000–3.000 €/Mon."
|
||||
else:
|
||||
return "> 3.000 €/Mon."
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
# PII-Felder nach Modell
|
||||
PII_FIELDS: dict[str, dict] = {
|
||||
"destinataer": {
|
||||
"iban": mask_iban,
|
||||
"email": mask_email,
|
||||
"telefon": mask_telefon,
|
||||
"geburtsdatum": mask_geburtsdatum,
|
||||
"jaehrliches_einkommen": mask_einkommen,
|
||||
"monatliche_bezuege": mask_monatsbezuege,
|
||||
"vermoegen": mask_einkommen,
|
||||
},
|
||||
"paechter": {
|
||||
"iban": mask_iban,
|
||||
"email": mask_email,
|
||||
"telefon": mask_telefon,
|
||||
"geburtsdatum": mask_geburtsdatum,
|
||||
},
|
||||
"rentmeister": {
|
||||
"iban": mask_iban,
|
||||
"email": mask_email,
|
||||
"telefon": mask_telefon,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def apply_privacy_filter(data: dict, model_type: str, role: str) -> dict:
|
||||
"""
|
||||
Maskiert PII-Felder in einem Daten-Dictionary basierend auf Rolle und Modelltyp.
|
||||
|
||||
Args:
|
||||
data: Rohdaten-Dictionary
|
||||
model_type: Modelltyp (z.B. "destinataer", "paechter")
|
||||
role: Aktuelle Rolle ("readonly", "editor", "admin")
|
||||
|
||||
Returns:
|
||||
Gefiltertes Dictionary (bei admin: unveränderter Input)
|
||||
"""
|
||||
from .auth import can_read_unmasked
|
||||
|
||||
if can_read_unmasked(role):
|
||||
return data
|
||||
|
||||
maskers = PII_FIELDS.get(model_type, {})
|
||||
if not maskers:
|
||||
return data
|
||||
|
||||
result = dict(data)
|
||||
for field, mask_fn in maskers.items():
|
||||
if field in result:
|
||||
result[field] = mask_fn(result[field])
|
||||
return result
|
||||
|
||||
|
||||
def apply_privacy_filter_list(items: list[dict], model_type: str, role: str) -> list[dict]:
|
||||
"""Wendet apply_privacy_filter auf eine Liste von Dicts an."""
|
||||
return [apply_privacy_filter(item, model_type, role) for item in items]
|
||||
3
app/mcp_server/requirements.txt
Normal file
3
app/mcp_server/requirements.txt
Normal file
@@ -0,0 +1,3 @@
|
||||
# MCP Server Dependencies
|
||||
# Install alongside the main Django app requirements
|
||||
mcp>=1.0.0
|
||||
160
app/mcp_server/server.py
Normal file
160
app/mcp_server/server.py
Normal file
@@ -0,0 +1,160 @@
|
||||
"""
|
||||
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")
|
||||
os.environ.setdefault("DJANGO_ALLOW_ASYNC_UNSAFE", "true")
|
||||
|
||||
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,
|
||||
veranstaltung_teilnehmer_anzeigen,
|
||||
veranstaltungen_anzeigen,
|
||||
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()(veranstaltungen_anzeigen)
|
||||
mcp.tool()(veranstaltung_teilnehmer_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,
|
||||
veranstaltung_teilnehmer_anlegen,
|
||||
veranstaltung_teilnehmer_importieren,
|
||||
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)
|
||||
mcp.tool()(veranstaltung_teilnehmer_anlegen)
|
||||
mcp.tool()(veranstaltung_teilnehmer_importieren)
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
# Server starten
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
if __name__ == "__main__":
|
||||
mcp.run(transport="stdio")
|
||||
18
app/mcp_server/start.sh
Normal file
18
app/mcp_server/start.sh
Normal file
@@ -0,0 +1,18 @@
|
||||
#!/bin/sh
|
||||
# MCP Server Startskript (direkter Aufruf ohne Docker)
|
||||
#
|
||||
# Voraussetzung: Python-Umgebung mit allen requirements.txt Paketen
|
||||
# Nutzung: MCP_AUTH_TOKEN=<token> ./app/mcp_server/start.sh
|
||||
#
|
||||
# Dieses Skript wird von MCP-Clients (z.B. Claude Desktop) aufgerufen.
|
||||
# Das Arbeitsverzeichnis muss das app/-Verzeichnis sein.
|
||||
|
||||
set -e
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
APP_DIR="$(dirname "$SCRIPT_DIR")"
|
||||
|
||||
export DJANGO_SETTINGS_MODULE="${DJANGO_SETTINGS_MODULE:-core.settings}"
|
||||
export PYTHONPATH="$APP_DIR:${PYTHONPATH:-}"
|
||||
|
||||
exec python -m mcp_server
|
||||
1
app/mcp_server/tools/__init__.py
Normal file
1
app/mcp_server/tools/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
# MCP Tools
|
||||
59
app/mcp_server/tools/helpers.py
Normal file
59
app/mcp_server/tools/helpers.py
Normal file
@@ -0,0 +1,59 @@
|
||||
"""
|
||||
Hilfsfunktionen für MCP-Tool-Implementierungen.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from datetime import date, datetime, time
|
||||
from decimal import Decimal
|
||||
from uuid import UUID
|
||||
|
||||
|
||||
def serialize_value(value):
|
||||
"""Konvertiert Django-Feldwerte in JSON-serialisierbare Typen."""
|
||||
if isinstance(value, UUID):
|
||||
return str(value)
|
||||
if isinstance(value, Decimal):
|
||||
return float(value)
|
||||
if isinstance(value, (date, datetime)):
|
||||
return value.isoformat()
|
||||
if isinstance(value, time):
|
||||
return value.isoformat()
|
||||
return value
|
||||
|
||||
|
||||
def model_to_dict(instance, fields=None, exclude=None) -> dict:
|
||||
"""
|
||||
Konvertiert eine Django-Model-Instanz in ein serialisierbares Dict.
|
||||
|
||||
Args:
|
||||
instance: Django Model Instanz
|
||||
fields: Nur diese Felder einschließen (None = alle)
|
||||
exclude: Diese Felder ausschließen
|
||||
"""
|
||||
exclude = exclude or []
|
||||
result = {}
|
||||
for field in instance._meta.fields:
|
||||
name = field.name
|
||||
if fields and name not in fields:
|
||||
continue
|
||||
if name in exclude:
|
||||
continue
|
||||
value = getattr(instance, name)
|
||||
# ForeignKey: _id-Suffix-Wert (nicht ganzes Objekt)
|
||||
result[name] = serialize_value(value)
|
||||
|
||||
# Auch ForeignKey-IDs explizit aufnehmen (z.B. konto_id)
|
||||
for field in instance._meta.fields:
|
||||
if hasattr(field, "attname") and field.attname != field.name:
|
||||
attname = field.attname
|
||||
if attname not in result:
|
||||
result[attname] = serialize_value(getattr(instance, attname))
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def format_result(data) -> str:
|
||||
"""Gibt Daten als formatiertes JSON zurück."""
|
||||
return json.dumps(data, ensure_ascii=False, indent=2, default=str)
|
||||
849
app/mcp_server/tools/lesen.py
Normal file
849
app/mcp_server/tools/lesen.py
Normal file
@@ -0,0 +1,849 @@
|
||||
"""
|
||||
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})
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
# 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
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
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,
|
||||
},
|
||||
})
|
||||
732
app/mcp_server/tools/schreiben.py
Normal file
732
app/mcp_server/tools/schreiben.py
Normal file
@@ -0,0 +1,732 @@
|
||||
"""
|
||||
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())})
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
# 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,
|
||||
})
|
||||
@@ -11,4 +11,8 @@ gunicorn==22.0.0
|
||||
python-dateutil==2.9.0
|
||||
markdown==3.6
|
||||
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
|
||||
|
||||
250
app/static/stiftung/js/briefvorlage_editor.js
Normal file
250
app/static/stiftung/js/briefvorlage_editor.js
Normal file
@@ -0,0 +1,250 @@
|
||||
/**
|
||||
* Briefvorlage-Editor: Minimal-WYSIWYG + Vorschau-Panel + Vorlagen-Loader
|
||||
* für Django Admin – keine externen Abhängigkeiten.
|
||||
*/
|
||||
(function () {
|
||||
"use strict";
|
||||
|
||||
// Warte auf DOM
|
||||
document.addEventListener("DOMContentLoaded", function () {
|
||||
var textareas = document.querySelectorAll("textarea.briefvorlage-textarea");
|
||||
textareas.forEach(function (textarea) {
|
||||
initEditor(textarea);
|
||||
});
|
||||
});
|
||||
|
||||
function initEditor(textarea) {
|
||||
var wrapper = document.createElement("div");
|
||||
wrapper.style.cssText = "border:1px solid #ccc;border-radius:4px;overflow:hidden;margin-top:4px;";
|
||||
|
||||
// ---- Toolbar ----
|
||||
var toolbar = document.createElement("div");
|
||||
toolbar.style.cssText = "background:#f5f5f5;border-bottom:1px solid #ccc;padding:5px 8px;display:flex;flex-wrap:wrap;gap:4px;align-items:center;";
|
||||
|
||||
var buttons = [
|
||||
{ label: "B", cmd: "bold", title: "Fett (Strg+B)", style: "font-weight:bold;" },
|
||||
{ label: "I", cmd: "italic", title: "Kursiv (Strg+I)", style: "font-style:italic;" },
|
||||
{ label: "U", cmd: "underline", title: "Unterstrichen (Strg+U)", style: "text-decoration:underline;" },
|
||||
{ label: "¶", cmd: "insertParagraph", title: "Absatz einfügen" },
|
||||
{ label: "• Liste", cmd: "insertUnorderedList", title: "Aufzählung" },
|
||||
{ label: "1. Liste", cmd: "insertOrderedList", title: "Nummerierte Liste" },
|
||||
];
|
||||
|
||||
buttons.forEach(function (b) {
|
||||
var btn = document.createElement("button");
|
||||
btn.type = "button";
|
||||
btn.title = b.title;
|
||||
btn.innerHTML = b.label;
|
||||
btn.style.cssText = "padding:3px 8px;cursor:pointer;border:1px solid #ccc;border-radius:3px;background:#fff;font-size:13px;" + (b.style || "");
|
||||
btn.addEventListener("click", function (e) {
|
||||
e.preventDefault();
|
||||
editor.focus();
|
||||
document.execCommand(b.cmd, false, null);
|
||||
syncToTextarea();
|
||||
});
|
||||
toolbar.appendChild(btn);
|
||||
});
|
||||
|
||||
// Trennlinie
|
||||
var sep = document.createElement("span");
|
||||
sep.style.cssText = "border-left:1px solid #ccc;height:20px;margin:0 4px;";
|
||||
toolbar.appendChild(sep);
|
||||
|
||||
// Tab-Buttons: Editor / HTML / Vorschau
|
||||
var tabEditor = createTabBtn("Editor", true);
|
||||
var tabHtml = createTabBtn("HTML", false);
|
||||
var tabVorschau = createTabBtn("Vorschau", false);
|
||||
toolbar.appendChild(tabEditor);
|
||||
toolbar.appendChild(tabHtml);
|
||||
toolbar.appendChild(tabVorschau);
|
||||
|
||||
// Vorlage-Loader (nur wenn BriefVorlage-API verfügbar)
|
||||
var sep2 = document.createElement("span");
|
||||
sep2.style.cssText = "border-left:1px solid #ccc;height:20px;margin:0 4px;";
|
||||
toolbar.appendChild(sep2);
|
||||
|
||||
var vorlagenSelect = document.createElement("select");
|
||||
vorlagenSelect.style.cssText = "font-size:12px;padding:2px 6px;border:1px solid #ccc;border-radius:3px;max-width:200px;";
|
||||
var defaultOption = document.createElement("option");
|
||||
defaultOption.value = "";
|
||||
defaultOption.textContent = "– Vorlage laden –";
|
||||
vorlagenSelect.appendChild(defaultOption);
|
||||
toolbar.appendChild(vorlagenSelect);
|
||||
|
||||
// Vorlagen asynchron laden
|
||||
loadVorlagen(vorlagenSelect);
|
||||
|
||||
var ladeBtn = document.createElement("button");
|
||||
ladeBtn.type = "button";
|
||||
ladeBtn.textContent = "Laden";
|
||||
ladeBtn.title = "Ausgewählte Vorlage in den Editor laden";
|
||||
ladeBtn.style.cssText = "padding:3px 8px;cursor:pointer;border:1px solid #0d6efd;border-radius:3px;background:#0d6efd;color:#fff;font-size:12px;";
|
||||
ladeBtn.addEventListener("click", function (e) {
|
||||
e.preventDefault();
|
||||
var val = vorlagenSelect.value;
|
||||
if (!val) return;
|
||||
var opt = vorlagenSelect.querySelector("option[value='" + val + "']");
|
||||
if (!opt) return;
|
||||
var html = opt.dataset.briefvorlage || "";
|
||||
var betreff = opt.dataset.betreff || "";
|
||||
if (confirm("Vorlage \"" + opt.textContent + "\" laden?\nDer aktuelle Brieftext wird überschrieben.")) {
|
||||
editor.innerHTML = html;
|
||||
textarea.value = html;
|
||||
// Betreff-Feld befüllen falls vorhanden und nicht leer
|
||||
if (betreff) {
|
||||
var betreffField = document.getElementById("id_betreff");
|
||||
if (betreffField && !betreffField.value) {
|
||||
betreffField.value = betreff;
|
||||
}
|
||||
}
|
||||
updatePreview();
|
||||
}
|
||||
});
|
||||
toolbar.appendChild(ladeBtn);
|
||||
|
||||
// ---- Editor-Div (WYSIWYG) ----
|
||||
var editor = document.createElement("div");
|
||||
editor.contentEditable = "true";
|
||||
editor.style.cssText = "min-height:300px;padding:12px;font-family:Times New Roman,serif;font-size:11pt;line-height:1.4;outline:none;background:#fff;";
|
||||
editor.innerHTML = textarea.value;
|
||||
editor.addEventListener("input", syncToTextarea);
|
||||
editor.addEventListener("keyup", syncToTextarea);
|
||||
|
||||
// ---- HTML-Textarea (Quelltext) ----
|
||||
textarea.style.cssText += "display:none;width:100%;box-sizing:border-box;border:none;padding:12px;font-family:monospace;font-size:13px;";
|
||||
textarea.addEventListener("input", function () {
|
||||
editor.innerHTML = textarea.value;
|
||||
updatePreview();
|
||||
});
|
||||
|
||||
// ---- Vorschau-Panel ----
|
||||
var preview = document.createElement("div");
|
||||
preview.style.cssText = "display:none;min-height:300px;padding:12px;background:#fff;font-family:'Times New Roman',serif;font-size:11pt;line-height:1.4;";
|
||||
|
||||
// Tab-Logik
|
||||
function showTab(which) {
|
||||
editor.style.display = "none";
|
||||
textarea.style.display = "none";
|
||||
preview.style.display = "none";
|
||||
tabEditor.style.background = "#f5f5f5";
|
||||
tabHtml.style.background = "#f5f5f5";
|
||||
tabVorschau.style.background = "#f5f5f5";
|
||||
if (which === "editor") {
|
||||
editor.style.display = "block";
|
||||
tabEditor.style.background = "#fff";
|
||||
tabEditor.style.fontWeight = "bold";
|
||||
} else if (which === "html") {
|
||||
textarea.style.display = "block";
|
||||
tabHtml.style.background = "#fff";
|
||||
tabHtml.style.fontWeight = "bold";
|
||||
} else {
|
||||
preview.style.display = "block";
|
||||
tabVorschau.style.background = "#fff";
|
||||
tabVorschau.style.fontWeight = "bold";
|
||||
updatePreview();
|
||||
}
|
||||
}
|
||||
|
||||
tabEditor.addEventListener("click", function (e) { e.preventDefault(); showTab("editor"); });
|
||||
tabHtml.addEventListener("click", function (e) {
|
||||
e.preventDefault();
|
||||
syncToTextarea();
|
||||
showTab("html");
|
||||
});
|
||||
tabVorschau.addEventListener("click", function (e) { e.preventDefault(); showTab("vorschau"); });
|
||||
|
||||
// Zusammenbauen
|
||||
wrapper.appendChild(toolbar);
|
||||
wrapper.appendChild(editor);
|
||||
wrapper.appendChild(preview);
|
||||
|
||||
// Textarea hinter Editor platzieren
|
||||
textarea.parentNode.insertBefore(wrapper, textarea);
|
||||
wrapper.appendChild(textarea);
|
||||
|
||||
// Initial: Editor-Tab aktiv
|
||||
showTab("editor");
|
||||
|
||||
// ---- Hilfsfunktionen ----
|
||||
function syncToTextarea() {
|
||||
textarea.value = editor.innerHTML;
|
||||
}
|
||||
|
||||
function updatePreview() {
|
||||
// Platzhalter durch Beispielwerte ersetzen für Vorschau
|
||||
var html = textarea.value;
|
||||
var replacements = {
|
||||
"{{ anrede }}": "Frau",
|
||||
"{{ vorname }}": "Maria",
|
||||
"{{ nachname }}": "Mustermann",
|
||||
"{{ strasse }}": "Musterstraße 12",
|
||||
"{{ plz }}": "46499",
|
||||
"{{ ort }}": "Hamminkeln",
|
||||
"{{ datum }}": "Freitag, 17. April 2026",
|
||||
"{{ uhrzeit }}": "19:00 Uhr",
|
||||
"{{ veranstaltungsort }}": "Marienthaler Gasthof",
|
||||
"{{ gasthaus_adresse }}": "Pastor-Winkelmann-Str. 2, 46499 Hamminkeln",
|
||||
};
|
||||
for (var key in replacements) {
|
||||
html = html.split(key).join(replacements[key]);
|
||||
}
|
||||
preview.innerHTML = html || "<em style='color:#999;'>Kein Brieftext eingegeben.</em>";
|
||||
}
|
||||
|
||||
function createTabBtn(label, active) {
|
||||
var btn = document.createElement("button");
|
||||
btn.type = "button";
|
||||
btn.textContent = label;
|
||||
btn.style.cssText = "padding:3px 10px;cursor:pointer;border:1px solid #ccc;border-radius:3px;font-size:12px;background:" + (active ? "#fff" : "#f5f5f5") + ";";
|
||||
if (active) btn.style.fontWeight = "bold";
|
||||
return btn;
|
||||
}
|
||||
}
|
||||
|
||||
function loadVorlagen(selectEl) {
|
||||
// Lese Vorlagen über einfachen Admin-API-Aufruf
|
||||
fetch("/admin/stiftung/briefvorlage/?format=json", {
|
||||
headers: { "X-Requested-With": "XMLHttpRequest" }
|
||||
})
|
||||
.then(function (r) { return r.ok ? r.json() : null; })
|
||||
.then(function (data) {
|
||||
if (!data || !data.results) return;
|
||||
data.results.forEach(function (v) {
|
||||
var opt = document.createElement("option");
|
||||
opt.value = v.id || v.pk;
|
||||
opt.textContent = v.name || v.fields && v.fields.name;
|
||||
opt.dataset.briefvorlage = v.briefvorlage || v.fields && v.fields.briefvorlage || "";
|
||||
opt.dataset.betreff = v.betreff || v.fields && v.fields.betreff || "";
|
||||
selectEl.appendChild(opt);
|
||||
});
|
||||
})
|
||||
.catch(function () {
|
||||
// Kein API-Endpunkt – Vorlage-Loader deaktivieren
|
||||
});
|
||||
|
||||
// Alternativ: REST-API
|
||||
fetch("/api/v1/briefvorlagen/", {
|
||||
headers: { "X-Requested-With": "XMLHttpRequest" }
|
||||
})
|
||||
.then(function (r) { return r.ok ? r.json() : null; })
|
||||
.then(function (data) {
|
||||
if (!data) return;
|
||||
var results = Array.isArray(data) ? data : data.results;
|
||||
if (!results) return;
|
||||
// Bereits vorhandene Optionen nicht doppeln
|
||||
var existing = Array.from(selectEl.options).map(function (o) { return o.value; });
|
||||
results.forEach(function (v) {
|
||||
var id = String(v.id || v.pk || "");
|
||||
if (existing.includes(id)) return;
|
||||
var opt = document.createElement("option");
|
||||
opt.value = id;
|
||||
opt.textContent = v.name;
|
||||
opt.dataset.briefvorlage = v.briefvorlage || "";
|
||||
opt.dataset.betreff = v.betreff || "";
|
||||
selectEl.appendChild(opt);
|
||||
});
|
||||
})
|
||||
.catch(function () { /* kein REST-Endpunkt */ });
|
||||
}
|
||||
|
||||
})();
|
||||
2
app/static/stiftung/vendor/jquery/jquery.min.js
vendored
Normal file
2
app/static/stiftung/vendor/jquery/jquery.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
BIN
app/static/stiftung/vendor/summernote/font/summernote.eot
vendored
Normal file
BIN
app/static/stiftung/vendor/summernote/font/summernote.eot
vendored
Normal file
Binary file not shown.
BIN
app/static/stiftung/vendor/summernote/font/summernote.ttf
vendored
Normal file
BIN
app/static/stiftung/vendor/summernote/font/summernote.ttf
vendored
Normal file
Binary file not shown.
BIN
app/static/stiftung/vendor/summernote/font/summernote.woff
vendored
Normal file
BIN
app/static/stiftung/vendor/summernote/font/summernote.woff
vendored
Normal file
Binary file not shown.
BIN
app/static/stiftung/vendor/summernote/font/summernote.woff2
vendored
Normal file
BIN
app/static/stiftung/vendor/summernote/font/summernote.woff2
vendored
Normal file
Binary file not shown.
1
app/static/stiftung/vendor/summernote/summernote-bs5.min.css
vendored
Normal file
1
app/static/stiftung/vendor/summernote/summernote-bs5.min.css
vendored
Normal file
File diff suppressed because one or more lines are too long
2
app/static/stiftung/vendor/summernote/summernote-bs5.min.js
vendored
Normal file
2
app/static/stiftung/vendor/summernote/summernote-bs5.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
2
app/static/stiftung/vendor/summernote/summernote-de-DE.min.js
vendored
Normal file
2
app/static/stiftung/vendor/summernote/summernote-de-DE.min.js
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
/*! Summernote v0.8.20 | (c) 2013- Alan Hong and contributors | MIT license */
|
||||
!function(e,t){if("object"==typeof exports&&"object"==typeof module)module.exports=t();else if("function"==typeof define&&define.amd)define([],t);else{var r=t();for(var i in r)("object"==typeof exports?exports:e)[i]=r[i]}}(self,(function(){return(e=jQuery).extend(e.summernote.lang,{"de-DE":{font:{bold:"Fett",italic:"Kursiv",underline:"Unterstrichen",clear:"Zurücksetzen",height:"Zeilenhöhe",name:"Schriftart",strikethrough:"Durchgestrichen",subscript:"Tiefgestellt",superscript:"Hochgestellt",size:"Schriftgröße"},image:{image:"Bild",insert:"Bild einfügen",resizeFull:"Originalgröße",resizeHalf:"1/2 Größe",resizeQuarter:"1/4 Größe",floatLeft:"Linksbündig",floatRight:"Rechtsbündig",floatNone:"Kein Textfluss",shapeRounded:"Abgerundete Ecken",shapeCircle:"Kreisförmig",shapeThumbnail:'"Vorschaubild"',shapeNone:"Kein Rahmen",dragImageHere:"Bild hierher ziehen",dropImage:"Bild oder Text nehmen",selectFromFiles:"Datei auswählen",maximumFileSize:"Maximale Dateigröße",maximumFileSizeError:"Maximale Dateigröße überschritten",url:"Bild URL",remove:"Bild entfernen",original:"Original"},video:{video:"Video",videoLink:"Videolink",insert:"Video einfügen",url:"Video URL",providers:"(YouTube, Vimeo, Vine, Instagram, DailyMotion oder Youku)"},link:{link:"Link",insert:"Link einfügen",unlink:"Link entfernen",edit:"Bearbeiten",textToDisplay:"Anzeigetext",url:"Link URL",openInNewWindow:"In neuem Fenster öffnen",useProtocol:"Standardprotokoll verwenden"},table:{table:"Tabelle",addRowAbove:"+ Zeile oberhalb",addRowBelow:"+ Zeile unterhalb",addColLeft:"+ Spalte links",addColRight:"+ Spalte rechts",delRow:"Zeile löschen",delCol:"Spalte löschen",delTable:"Tabelle löschen"},hr:{insert:"Horizontale Linie einfügen"},style:{style:"Stil",normal:"Normal",p:"Normal",blockquote:"Zitat",pre:"Quellcode",h1:"Überschrift 1",h2:"Überschrift 2",h3:"Überschrift 3",h4:"Überschrift 4",h5:"Überschrift 5",h6:"Überschrift 6"},lists:{unordered:"Aufzählung",ordered:"Nummerierung"},options:{help:"Hilfe",fullscreen:"Vollbild",codeview:"Quellcode anzeigen"},paragraph:{paragraph:"Absatz",outdent:"Einzug verkleinern",indent:"Einzug vergrößern",left:"Links ausrichten",center:"Zentriert ausrichten",right:"Rechts ausrichten",justify:"Blocksatz"},color:{recent:"Letzte Farbe",more:"Weitere Farben",background:"Hintergrundfarbe",foreground:"Schriftfarbe",transparent:"Transparenz",setTransparent:"Transparenz setzen",reset:"Zurücksetzen",resetToDefault:"Auf Standard zurücksetzen"},shortcut:{shortcuts:"Tastenkürzel",close:"Schließen",textFormatting:"Textformatierung",action:"Aktion",paragraphFormatting:"Absatzformatierung",documentStyle:"Dokumentenstil",extraKeys:"Weitere Tasten"},help:{insertParagraph:"Absatz einfügen",undo:"Letzte Anweisung rückgängig",redo:"Letzte Anweisung wiederholen",tab:"Einzug hinzufügen",untab:"Einzug entfernen",bold:"Schrift Fett",italic:"Schrift Kursiv",underline:"Unterstreichen",strikethrough:"Durchstreichen",removeFormat:"Entfernt Format",justifyLeft:"Linksbündig",justifyCenter:"Mittig",justifyRight:"Rechtsbündig",justifyFull:"Blocksatz",insertUnorderedList:"Unnummerierte Liste",insertOrderedList:"Nummerierte Liste",outdent:"Aktuellen Absatz ausrücken",indent:"Aktuellen Absatz einrücken",formatPara:"Formatiert aktuellen Block als Absatz (P-Tag)",formatH1:"Formatiert aktuellen Block als H1",formatH2:"Formatiert aktuellen Block als H2",formatH3:"Formatiert aktuellen Block als H3",formatH4:"Formatiert aktuellen Block als H4",formatH5:"Formatiert aktuellen Block als H5",formatH6:"Formatiert aktuellen Block als H6",insertHorizontalRule:"Fügt eine horizontale Linie ein","linkDialog.show":"Zeigt den Linkdialog"},history:{undo:"Rückgängig",redo:"Wiederholen"},specialChar:{specialChar:"Sonderzeichen",select:"Zeichen auswählen"}}}),{};var e}));
|
||||
File diff suppressed because it is too large
Load Diff
15
app/stiftung/admin/__init__.py
Normal file
15
app/stiftung/admin/__init__.py
Normal file
@@ -0,0 +1,15 @@
|
||||
from django.contrib import admin
|
||||
|
||||
from . import destinataere # noqa: F401
|
||||
from . import land # noqa: F401
|
||||
from . import finanzen # noqa: F401
|
||||
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"
|
||||
admin.site.site_title = "Stiftungsverwaltung Admin"
|
||||
admin.site.index_title = "Willkommen zur Stiftungsverwaltung"
|
||||
178
app/stiftung/admin/destinataere.py
Normal file
178
app/stiftung/admin/destinataere.py
Normal file
@@ -0,0 +1,178 @@
|
||||
from django.contrib import admin
|
||||
from django.urls import reverse
|
||||
from django.utils.html import format_html
|
||||
|
||||
from ..models import Destinataer, DestinataerEmailEingang, DestinataerUnterstuetzung
|
||||
|
||||
|
||||
@admin.register(Destinataer)
|
||||
class DestinataerAdmin(admin.ModelAdmin):
|
||||
list_display = [
|
||||
"nachname",
|
||||
"vorname",
|
||||
"familienzweig",
|
||||
"berufsgruppe",
|
||||
"institution",
|
||||
"finanzielle_notlage",
|
||||
"aktiv",
|
||||
]
|
||||
list_filter = ["familienzweig", "berufsgruppe", "finanzielle_notlage", "aktiv"]
|
||||
search_fields = ["nachname", "vorname", "email", "institution", "familienzweig"]
|
||||
ordering = ["nachname", "vorname"]
|
||||
readonly_fields = ["id"]
|
||||
|
||||
fieldsets = (
|
||||
(
|
||||
"Persönliche Daten",
|
||||
{"fields": ("anrede", "vorname", "nachname", "geburtsdatum", "email", "telefon")},
|
||||
),
|
||||
(
|
||||
"Berufliche Informationen",
|
||||
{"fields": ("berufsgruppe", "ausbildungsstand", "institution")},
|
||||
),
|
||||
(
|
||||
"Projekt & Finanzen",
|
||||
{
|
||||
"fields": (
|
||||
"projekt_beschreibung",
|
||||
"jaehrliches_einkommen",
|
||||
"finanzielle_notlage",
|
||||
)
|
||||
},
|
||||
),
|
||||
(
|
||||
"Stiftungsdaten",
|
||||
{"fields": ("familienzweig", "iban", "strasse", "plz", "ort")},
|
||||
),
|
||||
("Zusätzlich", {"fields": ("notizen", "aktiv")}),
|
||||
("System", {"fields": ("id",), "classes": ("collapse",)}),
|
||||
)
|
||||
|
||||
def iban_display(self, obj):
|
||||
if obj.iban:
|
||||
return format_html(
|
||||
'<span style="font-family: monospace;">{}</span>', obj.iban
|
||||
)
|
||||
return "-"
|
||||
|
||||
iban_display.short_description = "IBAN"
|
||||
|
||||
|
||||
@admin.register(DestinataerUnterstuetzung)
|
||||
class DestinataerUnterstuetzungAdmin(admin.ModelAdmin):
|
||||
list_display = [
|
||||
"__str__",
|
||||
"destinataer",
|
||||
"betrag",
|
||||
"faellig_am",
|
||||
"status",
|
||||
"wiederkehrend_von",
|
||||
"ausgezahlt_am",
|
||||
]
|
||||
list_filter = ["status", "faellig_am", "erstellt_am", "konto"]
|
||||
search_fields = [
|
||||
"destinataer__vorname",
|
||||
"destinataer__nachname",
|
||||
"beschreibung",
|
||||
"empfaenger_name",
|
||||
]
|
||||
readonly_fields = ["id", "erstellt_am", "aktualisiert_am"]
|
||||
|
||||
fieldsets = (
|
||||
(
|
||||
"Grundinformationen",
|
||||
{
|
||||
"fields": (
|
||||
"destinataer",
|
||||
"konto",
|
||||
"betrag",
|
||||
"faellig_am",
|
||||
"status",
|
||||
"beschreibung",
|
||||
)
|
||||
},
|
||||
),
|
||||
(
|
||||
"Überweisungsdaten",
|
||||
{"fields": ("empfaenger_iban", "empfaenger_name", "verwendungszweck")},
|
||||
),
|
||||
("Zahlungsinformationen", {"fields": ("ausgezahlt_am", "ausgezahlt_von")}),
|
||||
("Wiederkehrend", {"fields": ("wiederkehrend_von",)}),
|
||||
(
|
||||
"Metadaten",
|
||||
{
|
||||
"fields": ("id", "erstellt_am", "aktualisiert_am"),
|
||||
"classes": ("collapse",),
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@admin.register(DestinataerEmailEingang)
|
||||
class DestinataerEmailEingangAdmin(admin.ModelAdmin):
|
||||
list_display = [
|
||||
"eingangsdatum",
|
||||
"absender_email",
|
||||
"absender_name",
|
||||
"destinataer_link",
|
||||
"betreff_kurz",
|
||||
"anzahl_anhaenge",
|
||||
"status",
|
||||
"created_at",
|
||||
]
|
||||
list_filter = ["status", "eingangsdatum"]
|
||||
search_fields = [
|
||||
"absender_email",
|
||||
"absender_name",
|
||||
"betreff",
|
||||
"destinataer__vorname",
|
||||
"destinataer__nachname",
|
||||
]
|
||||
readonly_fields = ["created_at", "absender_email", "absender_name", "eingangsdatum",
|
||||
"email_text", "paperless_dokument_ids", "fehler_details"]
|
||||
raw_id_fields = ["destinataer", "quartalsnachweis"]
|
||||
date_hierarchy = "eingangsdatum"
|
||||
ordering = ["-eingangsdatum"]
|
||||
|
||||
fieldsets = [
|
||||
("E-Mail-Metadaten", {
|
||||
"fields": ["eingangsdatum", "absender_name", "absender_email", "betreff"],
|
||||
}),
|
||||
("Zuordnung", {
|
||||
"fields": ["destinataer", "status", "quartalsnachweis"],
|
||||
}),
|
||||
("Inhalt & Anhänge", {
|
||||
"fields": ["email_text", "paperless_dokument_ids"],
|
||||
}),
|
||||
("Notizen & Fehler", {
|
||||
"fields": ["notizen", "fehler_details"],
|
||||
"classes": ["collapse"],
|
||||
}),
|
||||
("System", {
|
||||
"fields": ["created_at"],
|
||||
"classes": ["collapse"],
|
||||
}),
|
||||
]
|
||||
|
||||
def destinataer_link(self, obj):
|
||||
if obj.destinataer:
|
||||
url = reverse("admin:stiftung_destinataer_change", args=[obj.destinataer.pk])
|
||||
return format_html('<a href="{}">{}</a>', url, obj.destinataer)
|
||||
return format_html('<span style="color:red;">–</span>')
|
||||
destinataer_link.short_description = "Destinatär"
|
||||
|
||||
def betreff_kurz(self, obj):
|
||||
return (obj.betreff or "")[:60]
|
||||
betreff_kurz.short_description = "Betreff"
|
||||
|
||||
def anzahl_anhaenge(self, obj):
|
||||
n = len(obj.paperless_dokument_ids or [])
|
||||
return n if n else "–"
|
||||
anzahl_anhaenge.short_description = "Anhänge"
|
||||
|
||||
actions = ["mark_verarbeitet"]
|
||||
|
||||
def mark_verarbeitet(self, request, queryset):
|
||||
updated = queryset.filter(status__in=["neu", "zugewiesen"]).update(status="verarbeitet")
|
||||
self.message_user(request, f"{updated} E-Mail(s) als verarbeitet markiert.")
|
||||
mark_verarbeitet.short_description = "Als verarbeitet markieren"
|
||||
20
app/stiftung/admin/dokumente.py
Normal file
20
app/stiftung/admin/dokumente.py
Normal file
@@ -0,0 +1,20 @@
|
||||
from django.contrib import admin
|
||||
|
||||
from ..models import DokumentLink
|
||||
|
||||
|
||||
@admin.register(DokumentLink)
|
||||
class DokumentLinkAdmin(admin.ModelAdmin):
|
||||
list_display = ["titel", "kontext", "paperless_document_id"]
|
||||
list_filter = ["kontext"]
|
||||
search_fields = ["titel", "kontext"]
|
||||
ordering = ["titel"]
|
||||
readonly_fields = ["id"]
|
||||
|
||||
fieldsets = (
|
||||
(
|
||||
"Dokument",
|
||||
{"fields": ("titel", "kontext", "paperless_document_id", "beschreibung")},
|
||||
),
|
||||
("System", {"fields": ("id",), "classes": ("collapse",)}),
|
||||
)
|
||||
191
app/stiftung/admin/finanzen.py
Normal file
191
app/stiftung/admin/finanzen.py
Normal file
@@ -0,0 +1,191 @@
|
||||
from django.contrib import admin
|
||||
|
||||
from ..models import BankTransaction, Rentmeister, StiftungsKonto, Verwaltungskosten
|
||||
|
||||
|
||||
@admin.register(Rentmeister)
|
||||
class RentmeisterAdmin(admin.ModelAdmin):
|
||||
list_display = [
|
||||
"__str__",
|
||||
"email",
|
||||
"telefon",
|
||||
"seit_datum",
|
||||
"bis_datum",
|
||||
"aktiv",
|
||||
"monatliche_verguetung",
|
||||
]
|
||||
list_filter = ["aktiv", "seit_datum", "anrede"]
|
||||
search_fields = ["vorname", "nachname", "email", "telefon", "ort"]
|
||||
ordering = ["nachname", "vorname"]
|
||||
readonly_fields = ["id", "erstellt_am", "aktualisiert_am"]
|
||||
|
||||
fieldsets = (
|
||||
("Persönliche Daten", {"fields": ("anrede", "vorname", "nachname", "titel")}),
|
||||
(
|
||||
"Kontaktdaten",
|
||||
{"fields": ("email", "telefon", "mobil", "strasse", "plz", "ort")},
|
||||
),
|
||||
(
|
||||
"Bankdaten",
|
||||
{"fields": ("iban", "bic", "bank_name"), "classes": ["collapse"]},
|
||||
),
|
||||
(
|
||||
"Stiftungsdaten",
|
||||
{
|
||||
"fields": (
|
||||
"seit_datum",
|
||||
"bis_datum",
|
||||
"aktiv",
|
||||
"monatliche_verguetung",
|
||||
"km_pauschale",
|
||||
)
|
||||
},
|
||||
),
|
||||
(
|
||||
"Zusätzliche Informationen",
|
||||
{"fields": ("notizen",), "classes": ["collapse"]},
|
||||
),
|
||||
(
|
||||
"System",
|
||||
{
|
||||
"fields": ("id", "erstellt_am", "aktualisiert_am"),
|
||||
"classes": ["collapse"],
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@admin.register(StiftungsKonto)
|
||||
class StiftungsKontoAdmin(admin.ModelAdmin):
|
||||
list_display = [
|
||||
"kontoname",
|
||||
"bank_name",
|
||||
"konto_typ",
|
||||
"saldo",
|
||||
"saldo_datum",
|
||||
"aktiv",
|
||||
]
|
||||
list_filter = ["konto_typ", "aktiv", "bank_name"]
|
||||
search_fields = ["kontoname", "bank_name", "iban"]
|
||||
ordering = ["bank_name", "kontoname"]
|
||||
readonly_fields = ["id", "erstellt_am", "aktualisiert_am"]
|
||||
|
||||
fieldsets = (
|
||||
(
|
||||
"Kontodaten",
|
||||
{"fields": ("kontoname", "bank_name", "iban", "bic", "konto_typ")},
|
||||
),
|
||||
(
|
||||
"Finanzdaten",
|
||||
{"fields": ("saldo", "saldo_datum", "zinssatz", "laufzeit_bis")},
|
||||
),
|
||||
("Status", {"fields": ("aktiv", "notizen")}),
|
||||
(
|
||||
"System",
|
||||
{
|
||||
"fields": ("id", "erstellt_am", "aktualisiert_am"),
|
||||
"classes": ["collapse"],
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@admin.register(Verwaltungskosten)
|
||||
class VerwaltungskostenAdmin(admin.ModelAdmin):
|
||||
list_display = [
|
||||
"bezeichnung",
|
||||
"kategorie",
|
||||
"betrag",
|
||||
"datum",
|
||||
"status",
|
||||
"rentmeister",
|
||||
"konto",
|
||||
]
|
||||
list_filter = ["kategorie", "status", "datum", "rentmeister", "konto"]
|
||||
search_fields = [
|
||||
"bezeichnung",
|
||||
"lieferant_firma",
|
||||
"rechnungsnummer",
|
||||
"beschreibung",
|
||||
]
|
||||
ordering = ["-datum", "-erstellt_am"]
|
||||
readonly_fields = ["id", "erstellt_am", "aktualisiert_am"]
|
||||
date_hierarchy = "datum"
|
||||
|
||||
fieldsets = (
|
||||
(
|
||||
"Grunddaten",
|
||||
{"fields": ("bezeichnung", "kategorie", "betrag", "datum", "status")},
|
||||
),
|
||||
("Zuordnung", {"fields": ("rentmeister", "konto")}),
|
||||
(
|
||||
"Lieferant/Rechnung",
|
||||
{"fields": ("lieferant_firma", "rechnungsnummer"), "classes": ["collapse"]},
|
||||
),
|
||||
(
|
||||
"Fahrtkosten",
|
||||
{
|
||||
"fields": ("km_anzahl", "km_satz", "von_ort", "nach_ort", "zweck"),
|
||||
"classes": ["collapse"],
|
||||
"description": 'Nur für Kategorie "Fahrtkosten" relevant',
|
||||
},
|
||||
),
|
||||
(
|
||||
"Zusätzliche Informationen",
|
||||
{"fields": ("beschreibung", "notizen"), "classes": ["collapse"]},
|
||||
),
|
||||
(
|
||||
"System",
|
||||
{
|
||||
"fields": ("id", "erstellt_am", "aktualisiert_am"),
|
||||
"classes": ["collapse"],
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@admin.register(BankTransaction)
|
||||
class BankTransactionAdmin(admin.ModelAdmin):
|
||||
list_display = [
|
||||
"datum",
|
||||
"konto",
|
||||
"betrag",
|
||||
"empfaenger_zahlungspflichtiger",
|
||||
"transaction_type",
|
||||
"status",
|
||||
"verwaltungskosten",
|
||||
]
|
||||
list_filter = ["konto", "transaction_type", "status", "datum", "importiert_am"]
|
||||
search_fields = ["verwendungszweck", "empfaenger_zahlungspflichtiger", "referenz"]
|
||||
readonly_fields = ["importiert_am", "import_datei"]
|
||||
ordering = ["-datum", "-importiert_am"]
|
||||
|
||||
fieldsets = (
|
||||
("Basisdaten", {"fields": ("konto", "datum", "valuta", "betrag", "waehrung")}),
|
||||
(
|
||||
"Transaktionsdetails",
|
||||
{
|
||||
"fields": (
|
||||
"verwendungszweck",
|
||||
"empfaenger_zahlungspflichtiger",
|
||||
"iban_gegenpartei",
|
||||
"bic_gegenpartei",
|
||||
"referenz",
|
||||
"transaction_type",
|
||||
)
|
||||
},
|
||||
),
|
||||
("Verwaltung", {"fields": ("status", "kommentare", "verwaltungskosten")}),
|
||||
(
|
||||
"Import-Information",
|
||||
{
|
||||
"fields": ("import_datei", "importiert_am", "saldo_nach_buchung"),
|
||||
"classes": ("collapse",),
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
def get_queryset(self, request):
|
||||
return (
|
||||
super().get_queryset(request).select_related("konto", "verwaltungskosten")
|
||||
)
|
||||
69
app/stiftung/admin/foerderung.py
Normal file
69
app/stiftung/admin/foerderung.py
Normal file
@@ -0,0 +1,69 @@
|
||||
from django.contrib import admin
|
||||
from django.db.models import Sum
|
||||
from django.urls import reverse
|
||||
from django.utils.html import format_html
|
||||
|
||||
from ..models import Foerderung
|
||||
|
||||
|
||||
@admin.register(Foerderung)
|
||||
class FoerderungAdmin(admin.ModelAdmin):
|
||||
list_display = [
|
||||
"destinataer",
|
||||
"jahr",
|
||||
"betrag",
|
||||
"verwendungsnachweis_link",
|
||||
"total_for_destinataer",
|
||||
]
|
||||
list_filter = ["jahr", "destinataer__familienzweig"]
|
||||
search_fields = [
|
||||
"destinataer__nachname",
|
||||
"destinataer__vorname",
|
||||
"destinataer__familienzweig",
|
||||
]
|
||||
ordering = ["-jahr", "-betrag"]
|
||||
readonly_fields = ["id"]
|
||||
|
||||
fieldsets = (
|
||||
(
|
||||
"Förderung",
|
||||
{
|
||||
"fields": (
|
||||
"destinataer",
|
||||
"person",
|
||||
"jahr",
|
||||
"betrag",
|
||||
"kategorie",
|
||||
"status",
|
||||
)
|
||||
},
|
||||
),
|
||||
("Dokumentation", {"fields": ("verwendungsnachweis", "bemerkungen")}),
|
||||
("Daten", {"fields": ("antragsdatum", "entscheidungsdatum")}),
|
||||
("System", {"fields": ("id",), "classes": ("collapse",)}),
|
||||
)
|
||||
|
||||
def verwendungsnachweis_link(self, obj):
|
||||
if obj.verwendungsnachweis:
|
||||
return format_html(
|
||||
'<a href="{}">{}</a>',
|
||||
reverse(
|
||||
"admin:stiftung_dokumentlink_change",
|
||||
args=[obj.verwendungsnachweis.id],
|
||||
),
|
||||
obj.verwendungsnachweis.titel,
|
||||
)
|
||||
return "-"
|
||||
|
||||
verwendungsnachweis_link.short_description = "Verwendungsnachweis"
|
||||
|
||||
def total_for_destinataer(self, obj):
|
||||
total = (
|
||||
Foerderung.objects.filter(destinataer=obj.destinataer).aggregate(
|
||||
Sum("betrag")
|
||||
)["betrag__sum"]
|
||||
or 0
|
||||
)
|
||||
return f"€{total:,.2f}"
|
||||
|
||||
total_for_destinataer.short_description = "Gesamt für Destinatär"
|
||||
206
app/stiftung/admin/land.py
Normal file
206
app/stiftung/admin/land.py
Normal file
@@ -0,0 +1,206 @@
|
||||
from django.contrib import admin
|
||||
from django.utils.html import format_html
|
||||
|
||||
from ..models import Land, LandVerpachtung, Paechter
|
||||
|
||||
|
||||
@admin.register(Paechter)
|
||||
class PaechterAdmin(admin.ModelAdmin):
|
||||
list_display = [
|
||||
"nachname",
|
||||
"vorname",
|
||||
"pachtnummer",
|
||||
"pachtzins_aktuell",
|
||||
"landwirtschaftliche_ausbildung",
|
||||
"aktiv",
|
||||
]
|
||||
list_filter = ["landwirtschaftliche_ausbildung", "aktiv"]
|
||||
search_fields = ["nachname", "vorname", "email", "pachtnummer"]
|
||||
ordering = ["nachname", "vorname"]
|
||||
readonly_fields = ["id"]
|
||||
|
||||
fieldsets = (
|
||||
(
|
||||
"Persönliche Daten",
|
||||
{"fields": ("vorname", "nachname", "geburtsdatum", "email", "telefon")},
|
||||
),
|
||||
(
|
||||
"Pacht-Informationen",
|
||||
{
|
||||
"fields": (
|
||||
"pachtnummer",
|
||||
"pachtbeginn_erste",
|
||||
"pachtende_letzte",
|
||||
"pachtzins_aktuell",
|
||||
)
|
||||
},
|
||||
),
|
||||
(
|
||||
"Landwirtschaftliche Qualifikation",
|
||||
{
|
||||
"fields": (
|
||||
"landwirtschaftliche_ausbildung",
|
||||
"berufserfahrung_jahre",
|
||||
"spezialisierung",
|
||||
)
|
||||
},
|
||||
),
|
||||
("Kontaktdaten", {"fields": ("iban", "strasse", "plz", "ort")}),
|
||||
("Pächter-Typ", {"fields": ("personentyp",)}),
|
||||
("Zusätzlich", {"fields": ("notizen", "aktiv")}),
|
||||
("System", {"fields": ("id",), "classes": ("collapse",)}),
|
||||
)
|
||||
|
||||
def iban_display(self, obj):
|
||||
if obj.iban:
|
||||
return format_html(
|
||||
'<span style="font-family: monospace;">{}</span>', obj.iban
|
||||
)
|
||||
return "-"
|
||||
|
||||
iban_display.short_description = "IBAN"
|
||||
|
||||
|
||||
@admin.register(Land)
|
||||
class LandAdmin(admin.ModelAdmin):
|
||||
list_display = [
|
||||
"lfd_nr",
|
||||
"gemeinde",
|
||||
"gemarkung",
|
||||
"flur",
|
||||
"flurstueck",
|
||||
"groesse_qm",
|
||||
"verp_flaeche_aktuell",
|
||||
"verpachtungsgrad_display",
|
||||
"aktiv",
|
||||
]
|
||||
list_filter = ["gemeinde", "gemarkung", "aktiv"]
|
||||
search_fields = ["lfd_nr", "gemeinde", "gemarkung", "flur", "flurstueck"]
|
||||
ordering = ["gemeinde", "gemarkung", "flur", "flurstueck"]
|
||||
readonly_fields = ["id", "gesamtflaeche_berechnet", "verpachtungsgrad_berechnet"]
|
||||
|
||||
fieldsets = (
|
||||
("Identifikation", {"fields": ("lfd_nr", "ew_nummer")}),
|
||||
("Gerichtliche Zuständigkeit", {"fields": ("amtsgericht",)}),
|
||||
(
|
||||
"Verwaltungsstruktur",
|
||||
{"fields": ("gemeinde", "gemarkung", "flur", "flurstueck")},
|
||||
),
|
||||
(
|
||||
"Flächenangaben",
|
||||
{
|
||||
"fields": (
|
||||
"groesse_qm",
|
||||
"gruenland_qm",
|
||||
"acker_qm",
|
||||
"wald_qm",
|
||||
"sonstiges_qm",
|
||||
)
|
||||
},
|
||||
),
|
||||
(
|
||||
"Verpachtung",
|
||||
{
|
||||
"fields": (
|
||||
"verpachtete_gesamtflaeche",
|
||||
"flaeche_alte_liste",
|
||||
"verp_flaeche_aktuell",
|
||||
)
|
||||
},
|
||||
),
|
||||
("Steuern und Abgaben", {"fields": ("anteil_grundsteuer", "anteil_lwk")}),
|
||||
("Status", {"fields": ("aktiv", "notizen")}),
|
||||
(
|
||||
"System",
|
||||
{
|
||||
"fields": ("id", "erstellt_am", "aktualisiert_am"),
|
||||
"classes": ("collapse",),
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
def verpachtungsgrad_display(self, obj):
|
||||
grad = obj.get_verpachtungsgrad()
|
||||
if grad > 90:
|
||||
color = "green"
|
||||
elif grad > 70:
|
||||
color = "orange"
|
||||
else:
|
||||
color = "red"
|
||||
return format_html('<span style="color: {};">{:.1f}%</span>', color, grad)
|
||||
|
||||
verpachtungsgrad_display.short_description = "Verpachtungsgrad"
|
||||
|
||||
def gesamtflaeche_berechnet(self, obj):
|
||||
return f"{obj.get_gesamtflaeche():.2f} qm"
|
||||
|
||||
gesamtflaeche_berechnet.short_description = "Berechnete Gesamtfläche"
|
||||
|
||||
def verpachtungsgrad_berechnet(self, obj):
|
||||
return f"{obj.get_verpachtungsgrad():.1f}%"
|
||||
|
||||
verpachtungsgrad_berechnet.short_description = "Verpachtungsgrad"
|
||||
|
||||
|
||||
@admin.register(LandVerpachtung)
|
||||
class LandVerpachtungAdmin(admin.ModelAdmin):
|
||||
list_display = [
|
||||
"land",
|
||||
"paechter",
|
||||
"pachtzins_pauschal",
|
||||
"pachtbeginn",
|
||||
"pachtende",
|
||||
"status_display",
|
||||
"erstellt_am",
|
||||
]
|
||||
list_filter = ["status", "pachtbeginn", "pachtende", "erstellt_am"]
|
||||
search_fields = ["land__lfd_nr", "land__gemeinde", "paechter__vorname", "paechter__nachname", "vertragsnummer"]
|
||||
ordering = ["-erstellt_am"]
|
||||
readonly_fields = ["id", "erstellt_am", "aktualisiert_am"]
|
||||
|
||||
fieldsets = (
|
||||
("Verpachtungsdetails", {
|
||||
"fields": ("land", "paechter", "vertragsnummer", "status")
|
||||
}),
|
||||
("Laufzeit", {
|
||||
"fields": ("pachtbeginn", "pachtende", "verlaengerung_klausel")
|
||||
}),
|
||||
("Fläche", {
|
||||
"fields": ("verpachtete_flaeche",)
|
||||
}),
|
||||
("Pachtzins", {
|
||||
"fields": ("pachtzins_pauschal", "pachtzins_pro_ha", "zahlungsweise")
|
||||
}),
|
||||
("Umsatzsteuer", {
|
||||
"fields": ("ust_option", "ust_satz"),
|
||||
"classes": ("collapse",)
|
||||
}),
|
||||
("Umlagen", {
|
||||
"fields": ("grundsteuer_umlage", "versicherungen_umlage", "verbandsbeitraege_umlage", "jagdpacht_anteil_umlage"),
|
||||
"classes": ("collapse",)
|
||||
}),
|
||||
("Zusatzinformationen", {
|
||||
"fields": ("bemerkungen",),
|
||||
"classes": ("collapse",)
|
||||
}),
|
||||
("System", {
|
||||
"fields": ("id", "erstellt_am", "aktualisiert_am"),
|
||||
"classes": ("collapse",)
|
||||
}),
|
||||
)
|
||||
|
||||
def status_display(self, obj):
|
||||
colors = {
|
||||
'aktiv': 'green',
|
||||
'beendet': 'red',
|
||||
'geplant': 'orange',
|
||||
'gekündigt': 'red'
|
||||
}
|
||||
color = colors.get(obj.status, 'black')
|
||||
return format_html(
|
||||
'<span style="color: {}; font-weight: bold;">{}</span>',
|
||||
color,
|
||||
obj.get_status_display()
|
||||
)
|
||||
|
||||
status_display.short_description = "Status"
|
||||
579
app/stiftung/admin/system.py
Normal file
579
app/stiftung/admin/system.py
Normal file
@@ -0,0 +1,579 @@
|
||||
from django.contrib import admin
|
||||
from django.db.models import Sum
|
||||
from django.utils import timezone
|
||||
from django.utils.html import format_html
|
||||
|
||||
from .. import models
|
||||
from ..models import (AppConfiguration, AuditLog, BackupJob, CSVImport, Person,
|
||||
UnterstuetzungWiederkehrend, VierteljahresNachweis)
|
||||
|
||||
|
||||
@admin.register(CSVImport)
|
||||
class CSVImportAdmin(admin.ModelAdmin):
|
||||
list_display = [
|
||||
"import_type",
|
||||
"filename",
|
||||
"status",
|
||||
"total_rows",
|
||||
"imported_rows",
|
||||
"failed_rows",
|
||||
"created_by",
|
||||
"started_at",
|
||||
"duration_display",
|
||||
]
|
||||
list_filter = ["import_type", "status", "started_at"]
|
||||
search_fields = ["filename", "created_by"]
|
||||
readonly_fields = ["id", "started_at", "completed_at", "get_success_rate"]
|
||||
ordering = ["-started_at"]
|
||||
|
||||
fieldsets = (
|
||||
(
|
||||
"Grundinformationen",
|
||||
{"fields": ("import_type", "filename", "file_size", "status")},
|
||||
),
|
||||
(
|
||||
"Ergebnisse",
|
||||
{
|
||||
"fields": (
|
||||
"total_rows",
|
||||
"imported_rows",
|
||||
"failed_rows",
|
||||
"get_success_rate",
|
||||
"error_log",
|
||||
)
|
||||
},
|
||||
),
|
||||
("Metadaten", {"fields": ("created_by", "started_at", "completed_at")}),
|
||||
)
|
||||
|
||||
def duration_display(self, obj):
|
||||
duration = obj.get_duration()
|
||||
if duration:
|
||||
return f"{duration.total_seconds():.1f}s"
|
||||
return "-"
|
||||
|
||||
duration_display.short_description = "Dauer"
|
||||
|
||||
def get_success_rate(self, obj):
|
||||
rate = obj.get_success_rate()
|
||||
if rate >= 90:
|
||||
color = "success"
|
||||
elif rate >= 70:
|
||||
color = "warning"
|
||||
else:
|
||||
color = "danger"
|
||||
return format_html('<span class="badge bg-{}">{:.1f}%</span>', color, rate)
|
||||
|
||||
get_success_rate.short_description = "Erfolgsrate"
|
||||
|
||||
|
||||
@admin.register(Person)
|
||||
class PersonAdmin(admin.ModelAdmin):
|
||||
list_display = [
|
||||
"nachname",
|
||||
"vorname",
|
||||
"familienzweig",
|
||||
"geburtsdatum",
|
||||
"email",
|
||||
"iban_display",
|
||||
]
|
||||
list_filter = ["familienzweig", "geburtsdatum"]
|
||||
search_fields = ["nachname", "vorname", "email", "familienzweig"]
|
||||
ordering = ["nachname", "vorname"]
|
||||
readonly_fields = ["id"]
|
||||
|
||||
fieldsets = (
|
||||
(
|
||||
"Persönliche Daten",
|
||||
{"fields": ("vorname", "nachname", "geburtsdatum", "email", "telefon")},
|
||||
),
|
||||
("Stiftungsdaten", {"fields": ("familienzweig", "iban", "adresse")}),
|
||||
("Zusätzlich", {"fields": ("notizen", "aktiv")}),
|
||||
("System", {"fields": ("id",), "classes": ("collapse",)}),
|
||||
)
|
||||
|
||||
def iban_display(self, obj):
|
||||
if obj.iban:
|
||||
return format_html(
|
||||
'<span style="font-family: monospace;">{}</span>', obj.iban
|
||||
)
|
||||
return "-"
|
||||
|
||||
iban_display.short_description = "IBAN"
|
||||
|
||||
def get_queryset(self, request):
|
||||
return (
|
||||
super()
|
||||
.get_queryset(request)
|
||||
.annotate(total_foerderungen=Sum("foerderung__betrag"))
|
||||
)
|
||||
|
||||
|
||||
@admin.register(AuditLog)
|
||||
class AuditLogAdmin(admin.ModelAdmin):
|
||||
list_display = [
|
||||
"timestamp",
|
||||
"username",
|
||||
"action",
|
||||
"entity_type",
|
||||
"entity_name",
|
||||
"ip_address",
|
||||
]
|
||||
list_filter = ["action", "entity_type", "timestamp", "username"]
|
||||
search_fields = ["username", "entity_name", "description", "ip_address"]
|
||||
readonly_fields = [
|
||||
"id",
|
||||
"timestamp",
|
||||
"user",
|
||||
"username",
|
||||
"action",
|
||||
"entity_type",
|
||||
"entity_id",
|
||||
"entity_name",
|
||||
"description",
|
||||
"changes",
|
||||
"ip_address",
|
||||
"user_agent",
|
||||
"session_key",
|
||||
]
|
||||
ordering = ["-timestamp"]
|
||||
date_hierarchy = "timestamp"
|
||||
|
||||
fieldsets = (
|
||||
(
|
||||
"Benutzer und Zeit",
|
||||
{"fields": ("timestamp", "user", "username", "session_key")},
|
||||
),
|
||||
(
|
||||
"Aktion",
|
||||
{
|
||||
"fields": (
|
||||
"action",
|
||||
"entity_type",
|
||||
"entity_id",
|
||||
"entity_name",
|
||||
"description",
|
||||
)
|
||||
},
|
||||
),
|
||||
("Änderungsdetails", {"fields": ("changes",), "classes": ["collapse"]}),
|
||||
(
|
||||
"Request-Informationen",
|
||||
{"fields": ("ip_address", "user_agent"), "classes": ["collapse"]},
|
||||
),
|
||||
("System", {"fields": ("id",), "classes": ["collapse"]}),
|
||||
)
|
||||
|
||||
def has_add_permission(self, request):
|
||||
return False # Don't allow manual creation
|
||||
|
||||
def has_change_permission(self, request, obj=None):
|
||||
return False # Don't allow editing
|
||||
|
||||
|
||||
@admin.register(BackupJob)
|
||||
class BackupJobAdmin(admin.ModelAdmin):
|
||||
list_display = [
|
||||
"created_at",
|
||||
"backup_type",
|
||||
"status",
|
||||
"backup_size_display",
|
||||
"duration_display",
|
||||
"created_by",
|
||||
]
|
||||
list_filter = ["backup_type", "status", "created_at"]
|
||||
search_fields = ["backup_filename", "created_by__username"]
|
||||
readonly_fields = [
|
||||
"id",
|
||||
"created_at",
|
||||
"started_at",
|
||||
"completed_at",
|
||||
"backup_size",
|
||||
"get_duration",
|
||||
]
|
||||
ordering = ["-created_at"]
|
||||
|
||||
fieldsets = (
|
||||
("Job-Details", {"fields": ("backup_type", "status", "created_by")}),
|
||||
(
|
||||
"Zeitpunkte",
|
||||
{"fields": ("created_at", "started_at", "completed_at", "get_duration")},
|
||||
),
|
||||
(
|
||||
"Ergebnis",
|
||||
{
|
||||
"fields": (
|
||||
"backup_filename",
|
||||
"backup_size",
|
||||
"database_size",
|
||||
"files_count",
|
||||
)
|
||||
},
|
||||
),
|
||||
("Fehlerbehandlung", {"fields": ("error_message",), "classes": ["collapse"]}),
|
||||
("System", {"fields": ("id",), "classes": ["collapse"]}),
|
||||
)
|
||||
|
||||
def backup_size_display(self, obj):
|
||||
return obj.get_size_display()
|
||||
|
||||
backup_size_display.short_description = "Backup-Größe"
|
||||
|
||||
def duration_display(self, obj):
|
||||
duration = obj.get_duration()
|
||||
if duration:
|
||||
return f"{duration.total_seconds():.1f}s"
|
||||
return "-"
|
||||
|
||||
duration_display.short_description = "Dauer"
|
||||
|
||||
def has_add_permission(self, request):
|
||||
return False # Use the web interface for creating backups
|
||||
|
||||
|
||||
@admin.register(AppConfiguration)
|
||||
class AppConfigurationAdmin(admin.ModelAdmin):
|
||||
list_display = [
|
||||
"display_name",
|
||||
"key",
|
||||
"value_display",
|
||||
"category",
|
||||
"setting_type",
|
||||
"is_active",
|
||||
"updated_at",
|
||||
]
|
||||
list_filter = ["category", "setting_type", "is_active"]
|
||||
search_fields = ["key", "display_name", "description"]
|
||||
readonly_fields = ["id", "created_at", "updated_at"]
|
||||
ordering = ["category", "order", "display_name"]
|
||||
|
||||
fieldsets = (
|
||||
(
|
||||
"Basic Information",
|
||||
{
|
||||
"fields": (
|
||||
"key",
|
||||
"display_name",
|
||||
"description",
|
||||
"category",
|
||||
"setting_type",
|
||||
)
|
||||
},
|
||||
),
|
||||
("Value Configuration", {"fields": ("value", "default_value")}),
|
||||
("Options", {"fields": ("is_active", "is_system", "order")}),
|
||||
(
|
||||
"Metadata",
|
||||
{"fields": ("created_at", "updated_at"), "classes": ("collapse",)},
|
||||
),
|
||||
)
|
||||
|
||||
def value_display(self, obj):
|
||||
"""Display value with type formatting"""
|
||||
value = obj.value
|
||||
if obj.setting_type == "boolean":
|
||||
icon = "✅" if obj.get_typed_value() else "❌"
|
||||
return format_html("{} {}", icon, value)
|
||||
elif obj.setting_type == "url":
|
||||
return format_html(
|
||||
'<a href="{}" target="_blank">{}</a>',
|
||||
value,
|
||||
value[:50] + "..." if len(value) > 50 else value,
|
||||
)
|
||||
elif len(value) > 100:
|
||||
return value[:100] + "..."
|
||||
return value
|
||||
|
||||
value_display.short_description = "Current Value"
|
||||
|
||||
def get_readonly_fields(self, request, obj=None):
|
||||
readonly = list(self.readonly_fields)
|
||||
if obj and obj.is_system:
|
||||
readonly.extend(["key", "setting_type", "is_system"])
|
||||
return readonly
|
||||
|
||||
|
||||
@admin.register(models.HelpBox)
|
||||
class HelpBoxAdmin(admin.ModelAdmin):
|
||||
list_display = [
|
||||
"get_page_display",
|
||||
"title",
|
||||
"is_active",
|
||||
"updated_at",
|
||||
"updated_by",
|
||||
]
|
||||
list_filter = ["page_key", "is_active", "updated_at"]
|
||||
search_fields = ["title", "content"]
|
||||
|
||||
fieldsets = (
|
||||
("Grundinformationen", {"fields": ("page_key", "title", "is_active")}),
|
||||
(
|
||||
"Inhalt",
|
||||
{
|
||||
"fields": ("content",),
|
||||
"description": "Markdown wird unterstützt: **fett**, *kursiv*, `code`, [Link](url), Listen mit - oder 1.",
|
||||
},
|
||||
),
|
||||
(
|
||||
"Metadaten",
|
||||
{
|
||||
"fields": ("created_at", "updated_at", "created_by", "updated_by"),
|
||||
"classes": ("collapse",),
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
readonly_fields = ["created_at", "updated_at"]
|
||||
|
||||
def get_page_display(self, obj):
|
||||
return obj.get_page_key_display()
|
||||
|
||||
get_page_display.short_description = "Seite"
|
||||
|
||||
def save_model(self, request, obj, form, change):
|
||||
if not change: # Neues Objekt
|
||||
obj.created_by = request.user.username
|
||||
obj.updated_by = request.user.username
|
||||
super().save_model(request, obj, form, change)
|
||||
|
||||
|
||||
@admin.register(UnterstuetzungWiederkehrend)
|
||||
class UnterstuetzungWiederkehrendAdmin(admin.ModelAdmin):
|
||||
list_display = [
|
||||
"__str__",
|
||||
"destinataer",
|
||||
"betrag",
|
||||
"intervall",
|
||||
"aktiv",
|
||||
"naechste_generierung",
|
||||
]
|
||||
list_filter = ["intervall", "aktiv", "erstellt_am"]
|
||||
search_fields = [
|
||||
"destinataer__vorname",
|
||||
"destinataer__nachname",
|
||||
"beschreibung",
|
||||
"empfaenger_name",
|
||||
]
|
||||
readonly_fields = ["id", "erstellt_am"]
|
||||
|
||||
fieldsets = (
|
||||
(
|
||||
"Grundinformationen",
|
||||
{
|
||||
"fields": (
|
||||
"destinataer",
|
||||
"konto",
|
||||
"betrag",
|
||||
"intervall",
|
||||
"beschreibung",
|
||||
"aktiv",
|
||||
)
|
||||
},
|
||||
),
|
||||
(
|
||||
"Überweisungsdaten",
|
||||
{"fields": ("empfaenger_iban", "empfaenger_name", "verwendungszweck")},
|
||||
),
|
||||
(
|
||||
"Zeitplanung",
|
||||
{
|
||||
"fields": (
|
||||
"erste_zahlung_am",
|
||||
"letzte_zahlung_am",
|
||||
"naechste_generierung",
|
||||
)
|
||||
},
|
||||
),
|
||||
(
|
||||
"Metadaten",
|
||||
{"fields": ("id", "erstellt_von", "erstellt_am"), "classes": ("collapse",)},
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@admin.register(VierteljahresNachweis)
|
||||
class VierteljahresNachweisAdmin(admin.ModelAdmin):
|
||||
list_display = [
|
||||
"destinataer",
|
||||
"jahr",
|
||||
"quartal",
|
||||
"status",
|
||||
"completion_percentage",
|
||||
"faelligkeitsdatum",
|
||||
"is_overdue_display",
|
||||
"eingereicht_am",
|
||||
"geprueft_von",
|
||||
]
|
||||
list_filter = [
|
||||
"jahr",
|
||||
"quartal",
|
||||
"status",
|
||||
"studiennachweis_erforderlich",
|
||||
"studiennachweis_eingereicht",
|
||||
"einkommenssituation_bestaetigt",
|
||||
"vermogenssituation_bestaetigt",
|
||||
"faelligkeitsdatum",
|
||||
]
|
||||
search_fields = [
|
||||
"destinataer__vorname",
|
||||
"destinataer__nachname",
|
||||
"destinataer__email",
|
||||
]
|
||||
readonly_fields = [
|
||||
"id",
|
||||
"erstellt_am",
|
||||
"aktualisiert_am",
|
||||
"completion_percentage",
|
||||
"is_overdue_display",
|
||||
]
|
||||
ordering = ["-jahr", "-quartal", "destinataer__nachname"]
|
||||
|
||||
fieldsets = (
|
||||
(
|
||||
"Grundinformationen",
|
||||
{
|
||||
"fields": (
|
||||
"destinataer",
|
||||
"jahr",
|
||||
"quartal",
|
||||
"status",
|
||||
"faelligkeitsdatum",
|
||||
)
|
||||
},
|
||||
),
|
||||
(
|
||||
"Studiennachweis",
|
||||
{
|
||||
"fields": (
|
||||
"studiennachweis_erforderlich",
|
||||
"studiennachweis_eingereicht",
|
||||
"studiennachweis_datei",
|
||||
"studiennachweis_bemerkung",
|
||||
),
|
||||
"classes": ("collapse",),
|
||||
},
|
||||
),
|
||||
(
|
||||
"Einkommenssituation",
|
||||
{
|
||||
"fields": (
|
||||
"einkommenssituation_bestaetigt",
|
||||
"einkommenssituation_text",
|
||||
"einkommenssituation_datei",
|
||||
),
|
||||
"classes": ("collapse",),
|
||||
},
|
||||
),
|
||||
(
|
||||
"Vermögenssituation",
|
||||
{
|
||||
"fields": (
|
||||
"vermogenssituation_bestaetigt",
|
||||
"vermogenssituation_text",
|
||||
"vermogenssituation_datei",
|
||||
),
|
||||
"classes": ("collapse",),
|
||||
},
|
||||
),
|
||||
(
|
||||
"Weitere Dokumente",
|
||||
{
|
||||
"fields": (
|
||||
"weitere_dokumente",
|
||||
"weitere_dokumente_beschreibung",
|
||||
),
|
||||
"classes": ("collapse",),
|
||||
},
|
||||
),
|
||||
(
|
||||
"Verwaltung & Prüfung",
|
||||
{
|
||||
"fields": (
|
||||
"interne_notizen",
|
||||
"eingereicht_am",
|
||||
"geprueft_am",
|
||||
"geprueft_von",
|
||||
),
|
||||
"classes": ("collapse",),
|
||||
},
|
||||
),
|
||||
(
|
||||
"Metadaten",
|
||||
{
|
||||
"fields": (
|
||||
"id",
|
||||
"erstellt_am",
|
||||
"aktualisiert_am",
|
||||
"completion_percentage",
|
||||
"is_overdue_display",
|
||||
)
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
def completion_percentage(self, obj):
|
||||
"""Show completion percentage as colored badge"""
|
||||
percentage = obj.get_completion_percentage()
|
||||
if percentage == 100:
|
||||
color = "success"
|
||||
elif percentage >= 70:
|
||||
color = "info"
|
||||
elif percentage >= 30:
|
||||
color = "warning"
|
||||
else:
|
||||
color = "danger"
|
||||
|
||||
return format_html(
|
||||
'<span class="badge bg-{}">{} %</span>',
|
||||
color,
|
||||
percentage
|
||||
)
|
||||
completion_percentage.short_description = "Fortschritt"
|
||||
|
||||
def is_overdue_display(self, obj):
|
||||
"""Display overdue status with icon"""
|
||||
if obj.is_overdue():
|
||||
return format_html(
|
||||
'<span class="text-danger"><i class="fas fa-exclamation-triangle"></i> Ja</span>'
|
||||
)
|
||||
return format_html(
|
||||
'<span class="text-success"><i class="fas fa-check"></i> Nein</span>'
|
||||
)
|
||||
is_overdue_display.short_description = "Überfällig"
|
||||
|
||||
actions = ["mark_as_approved", "mark_as_needs_revision"]
|
||||
|
||||
def mark_as_approved(self, request, queryset):
|
||||
"""Bulk action to approve submitted confirmations"""
|
||||
count = 0
|
||||
for nachweis in queryset.filter(status="eingereicht"):
|
||||
nachweis.status = "geprueft"
|
||||
nachweis.geprueft_am = timezone.now()
|
||||
nachweis.geprueft_von = request.user
|
||||
nachweis.save()
|
||||
count += 1
|
||||
|
||||
if count:
|
||||
self.message_user(
|
||||
request,
|
||||
f"{count} Nachweise wurden als geprüft und freigegeben markiert."
|
||||
)
|
||||
else:
|
||||
self.message_user(
|
||||
request,
|
||||
"Keine eingereichten Nachweise gefunden.",
|
||||
level="warning"
|
||||
)
|
||||
mark_as_approved.short_description = "Ausgewählte Nachweise freigeben"
|
||||
|
||||
def mark_as_needs_revision(self, request, queryset):
|
||||
"""Bulk action to mark confirmations as needing revision"""
|
||||
count = queryset.exclude(status__in=["offen", "nachbesserung"]).update(
|
||||
status="nachbesserung"
|
||||
)
|
||||
if count:
|
||||
self.message_user(
|
||||
request,
|
||||
f"{count} Nachweise wurden als nachbesserungsbedürftig markiert."
|
||||
)
|
||||
mark_as_needs_revision.short_description = "Nachbesserung erforderlich markieren"
|
||||
190
app/stiftung/admin/veranstaltung.py
Normal file
190
app/stiftung/admin/veranstaltung.py
Normal file
@@ -0,0 +1,190 @@
|
||||
from django import forms
|
||||
from django.contrib import admin
|
||||
from django.utils.html import format_html
|
||||
|
||||
from ..models import BriefVorlage, Veranstaltung, Veranstaltungsteilnehmer
|
||||
|
||||
|
||||
class VeranstaltungsteilnehmerInline(admin.TabularInline):
|
||||
model = Veranstaltungsteilnehmer
|
||||
extra = 1
|
||||
fields = [
|
||||
"anrede", "vorname", "nachname", "strasse", "plz", "ort",
|
||||
"email", "rsvp_status", "bemerkungen",
|
||||
]
|
||||
|
||||
|
||||
class BriefVorlageWidget(forms.Textarea):
|
||||
"""Erweitertes Textarea-Widget für HTML-Briefvorlagen mit Editor-Panel und Platzhalter-Hilfe."""
|
||||
|
||||
class Media:
|
||||
js = ["stiftung/js/briefvorlage_editor.js"]
|
||||
|
||||
def __init__(self, attrs=None):
|
||||
default_attrs = {"rows": 18, "cols": 80, "class": "briefvorlage-textarea", "style": "font-family: monospace; font-size: 13px;"}
|
||||
if attrs:
|
||||
default_attrs.update(attrs)
|
||||
super().__init__(attrs=default_attrs)
|
||||
|
||||
|
||||
class VeranstaltungAdminForm(forms.ModelForm):
|
||||
class Meta:
|
||||
model = Veranstaltung
|
||||
fields = "__all__"
|
||||
widgets = {
|
||||
"briefvorlage": BriefVorlageWidget(),
|
||||
}
|
||||
|
||||
|
||||
@admin.register(Veranstaltung)
|
||||
class VeranstaltungAdmin(admin.ModelAdmin):
|
||||
form = VeranstaltungAdminForm
|
||||
list_display = [
|
||||
"titel", "datum", "uhrzeit", "ort", "status",
|
||||
"get_teilnehmer_count", "get_zugesagte_count", "budget_pro_person",
|
||||
]
|
||||
list_filter = ["status", "datum"]
|
||||
search_fields = ["titel", "ort", "beschreibung"]
|
||||
ordering = ["-datum"]
|
||||
readonly_fields = ["id", "erstellt_am", "aktualisiert_am", "serienbrief_aktionen", "platzhalter_dokumentation"]
|
||||
inlines = [VeranstaltungsteilnehmerInline]
|
||||
|
||||
fieldsets = (
|
||||
("Grunddaten", {"fields": ("titel", "datum", "uhrzeit", "status")}),
|
||||
("Veranstaltungsort", {"fields": ("ort", "adresse")}),
|
||||
("Details", {"fields": ("beschreibung", "budget_pro_person")}),
|
||||
(
|
||||
"Serienbrief – Vorlage",
|
||||
{
|
||||
"fields": (
|
||||
"platzhalter_dokumentation",
|
||||
"betreff",
|
||||
"briefvorlage",
|
||||
),
|
||||
},
|
||||
),
|
||||
(
|
||||
"Serienbrief – Unterschriften & Aktionen",
|
||||
{
|
||||
"fields": (
|
||||
"unterschrift_1_name", "unterschrift_1_titel",
|
||||
"unterschrift_2_name", "unterschrift_2_titel",
|
||||
"serienbrief_aktionen",
|
||||
),
|
||||
},
|
||||
),
|
||||
("System", {"fields": ("id", "erstellt_am", "aktualisiert_am"), "classes": ("collapse",)}),
|
||||
)
|
||||
|
||||
def get_teilnehmer_count(self, obj):
|
||||
return obj.get_teilnehmer_count()
|
||||
get_teilnehmer_count.short_description = "Teilnehmer gesamt"
|
||||
|
||||
def get_zugesagte_count(self, obj):
|
||||
return obj.get_zugesagte_count()
|
||||
get_zugesagte_count.short_description = "Zugesagt"
|
||||
|
||||
def platzhalter_dokumentation(self, obj):
|
||||
return format_html(
|
||||
"""<div class="help" style="background:#f8f9fa;border:1px solid #dee2e6;border-radius:4px;padding:10px 14px;margin-bottom:4px;">
|
||||
<strong>Verfügbare Platzhalter im Brieftext:</strong><br>
|
||||
<table style="margin-top:6px;border-collapse:collapse;font-size:13px;">
|
||||
<tr><td style="padding:2px 12px 2px 0;font-family:monospace;color:#d63384;">{{{{ anrede }}}}</td><td>Anredetitel (Herr / Frau)</td></tr>
|
||||
<tr><td style="padding:2px 12px 2px 0;font-family:monospace;color:#d63384;">{{{{ vorname }}}}</td><td>Vorname des Empfängers</td></tr>
|
||||
<tr><td style="padding:2px 12px 2px 0;font-family:monospace;color:#d63384;">{{{{ nachname }}}}</td><td>Nachname des Empfängers</td></tr>
|
||||
<tr><td style="padding:2px 12px 2px 0;font-family:monospace;color:#d63384;">{{{{ strasse }}}}</td><td>Straße und Hausnummer</td></tr>
|
||||
<tr><td style="padding:2px 12px 2px 0;font-family:monospace;color:#d63384;">{{{{ plz }}}}</td><td>Postleitzahl</td></tr>
|
||||
<tr><td style="padding:2px 12px 2px 0;font-family:monospace;color:#d63384;">{{{{ ort }}}}</td><td>Wohnort des Empfängers</td></tr>
|
||||
<tr><td style="padding:2px 12px 2px 0;font-family:monospace;color:#d63384;">{{{{ datum }}}}</td><td>Datum der Veranstaltung (z.B. Freitag, 17. April 2026)</td></tr>
|
||||
<tr><td style="padding:2px 12px 2px 0;font-family:monospace;color:#d63384;">{{{{ uhrzeit }}}}</td><td>Uhrzeit der Veranstaltung (z.B. 19:00 Uhr)</td></tr>
|
||||
<tr><td style="padding:2px 12px 2px 0;font-family:monospace;color:#d63384;">{{{{ veranstaltungsort }}}}</td><td>Name des Veranstaltungsorts / Gasthaus</td></tr>
|
||||
<tr><td style="padding:2px 12px 2px 0;font-family:monospace;color:#d63384;">{{{{ gasthaus_adresse }}}}</td><td>Adresse des Gasthauses</td></tr>
|
||||
</table>
|
||||
<div style="margin-top:8px;font-size:12px;color:#6c757d;">
|
||||
Platzhalter werden beim PDF-Export automatisch mit den Empfänger- und Veranstaltungsdaten befüllt.
|
||||
Tipp: Vorlagen unter <a href="/admin/stiftung/briefvorlage/" target="_blank">Verwaltung → Briefvorlagen</a> speichern und wiederverwenden.
|
||||
</div>
|
||||
</div>"""
|
||||
)
|
||||
platzhalter_dokumentation.short_description = "Platzhalter-Dokumentation"
|
||||
platzhalter_dokumentation.allow_tags = True
|
||||
|
||||
def serienbrief_aktionen(self, obj):
|
||||
if obj.pk:
|
||||
from django.urls import reverse as url_reverse
|
||||
pdf_url = url_reverse("stiftung:veranstaltung_serienbrief_pdf", args=[obj.pk])
|
||||
vorschau_url = url_reverse("stiftung:veranstaltung_serienbrief_vorschau", args=[obj.pk])
|
||||
return format_html(
|
||||
'<a href="{}" target="_blank" class="button" style="margin-right:8px;">Serienbrief-PDF generieren</a>'
|
||||
'<a href="{}" target="_blank" class="button default">Vorschau im Browser</a>',
|
||||
pdf_url, vorschau_url,
|
||||
)
|
||||
return "–"
|
||||
serienbrief_aktionen.short_description = "Aktionen"
|
||||
|
||||
actions = ["generate_serienbrief"]
|
||||
|
||||
def generate_serienbrief(self, request, queryset):
|
||||
if queryset.count() != 1:
|
||||
self.message_user(
|
||||
request,
|
||||
"Bitte genau eine Veranstaltung auswählen.",
|
||||
level="error",
|
||||
)
|
||||
return
|
||||
from django.urls import reverse as url_reverse
|
||||
from django.shortcuts import redirect
|
||||
veranstaltung = queryset.first()
|
||||
url = url_reverse("stiftung:veranstaltung_serienbrief_pdf", args=[veranstaltung.pk])
|
||||
return redirect(url)
|
||||
generate_serienbrief.short_description = "Serienbrief-PDF generieren"
|
||||
|
||||
|
||||
@admin.register(BriefVorlage)
|
||||
class BriefVorlageAdmin(admin.ModelAdmin):
|
||||
list_display = ["name", "beschreibung_kurz", "erstellt_am", "aktualisiert_am"]
|
||||
search_fields = ["name", "beschreibung"]
|
||||
ordering = ["name"]
|
||||
readonly_fields = ["erstellt_am", "aktualisiert_am"]
|
||||
|
||||
fieldsets = (
|
||||
(None, {"fields": ("name", "beschreibung")}),
|
||||
(
|
||||
"Briefinhalt",
|
||||
{
|
||||
"fields": ("betreff", "briefvorlage"),
|
||||
"description": (
|
||||
"Verfügbare Platzhalter: {{ anrede }}, {{ vorname }}, {{ nachname }}, "
|
||||
"{{ strasse }}, {{ plz }}, {{ ort }}, {{ datum }}, {{ uhrzeit }}, "
|
||||
"{{ veranstaltungsort }}, {{ gasthaus_adresse }}"
|
||||
),
|
||||
},
|
||||
),
|
||||
("System", {"fields": ("erstellt_am", "aktualisiert_am"), "classes": ("collapse",)}),
|
||||
)
|
||||
|
||||
def beschreibung_kurz(self, obj):
|
||||
return obj.beschreibung[:80] + "…" if len(obj.beschreibung) > 80 else obj.beschreibung
|
||||
beschreibung_kurz.short_description = "Beschreibung"
|
||||
|
||||
|
||||
@admin.register(Veranstaltungsteilnehmer)
|
||||
class VeranstaltungsteilnehmerAdmin(admin.ModelAdmin):
|
||||
list_display = [
|
||||
"nachname", "vorname", "anrede", "veranstaltung", "rsvp_status", "ort",
|
||||
]
|
||||
list_filter = ["rsvp_status", "veranstaltung", "anrede"]
|
||||
search_fields = ["vorname", "nachname", "ort", "email"]
|
||||
ordering = ["veranstaltung", "nachname", "vorname"]
|
||||
readonly_fields = ["id", "erstellt_am"]
|
||||
|
||||
fieldsets = (
|
||||
("Zuordnung", {"fields": ("veranstaltung", "paechter", "destinataer")}),
|
||||
(
|
||||
"Persönliche Daten",
|
||||
{"fields": ("anrede", "vorname", "nachname", "email")},
|
||||
),
|
||||
("Adresse", {"fields": ("strasse", "plz", "ort")}),
|
||||
("RSVP", {"fields": ("rsvp_status", "bemerkungen")}),
|
||||
("System", {"fields": ("id", "erstellt_am"), "classes": ("collapse",)}),
|
||||
)
|
||||
0
app/stiftung/agent/__init__.py
Normal file
0
app/stiftung/agent/__init__.py
Normal file
64
app/stiftung/agent/admin.py
Normal file
64
app/stiftung/agent/admin.py
Normal file
@@ -0,0 +1,64 @@
|
||||
"""
|
||||
Django Admin für den AI Agent.
|
||||
|
||||
Erreichbar unter /administration/agent/
|
||||
"""
|
||||
from django.contrib import admin
|
||||
from django.utils.html import format_html
|
||||
|
||||
from .models import AgentConfig, ChatSession, ChatMessage
|
||||
|
||||
|
||||
@admin.register(AgentConfig)
|
||||
class AgentConfigAdmin(admin.ModelAdmin):
|
||||
fieldsets = (
|
||||
("Provider", {
|
||||
"fields": ("provider", "model_name", "ollama_url"),
|
||||
}),
|
||||
("API-Keys (externe Provider)", {
|
||||
"fields": ("openai_api_key", "anthropic_api_key"),
|
||||
"classes": ("collapse",),
|
||||
"description": "Nur ausfüllen wenn nicht Ollama verwendet wird.",
|
||||
}),
|
||||
("Verhalten", {
|
||||
"fields": ("system_prompt", "allow_write", "chat_retention_days"),
|
||||
}),
|
||||
)
|
||||
|
||||
def has_add_permission(self, request):
|
||||
# Singleton: Hinzufügen nur wenn noch keine Config existiert
|
||||
return not AgentConfig.objects.exists()
|
||||
|
||||
def has_delete_permission(self, request, obj=None):
|
||||
return False
|
||||
|
||||
|
||||
class ChatMessageInline(admin.TabularInline):
|
||||
model = ChatMessage
|
||||
fields = ("role", "content_preview", "tool_name", "created_at")
|
||||
readonly_fields = ("role", "content_preview", "tool_name", "created_at")
|
||||
extra = 0
|
||||
can_delete = False
|
||||
ordering = ["created_at"]
|
||||
|
||||
def content_preview(self, obj):
|
||||
return obj.content[:120] + ("…" if len(obj.content) > 120 else "")
|
||||
content_preview.short_description = "Inhalt"
|
||||
|
||||
|
||||
@admin.register(ChatSession)
|
||||
class ChatSessionAdmin(admin.ModelAdmin):
|
||||
list_display = ("title_or_id", "user", "message_count", "created_at", "updated_at")
|
||||
list_filter = ("user",)
|
||||
search_fields = ("title", "user__username")
|
||||
readonly_fields = ("id", "user", "created_at", "updated_at")
|
||||
inlines = [ChatMessageInline]
|
||||
ordering = ["-updated_at"]
|
||||
|
||||
def title_or_id(self, obj):
|
||||
return obj.title or str(obj.id)[:12]
|
||||
title_or_id.short_description = "Sitzung"
|
||||
|
||||
def message_count(self, obj):
|
||||
return obj.messages.count()
|
||||
message_count.short_description = "Nachrichten"
|
||||
166
app/stiftung/agent/models.py
Normal file
166
app/stiftung/agent/models.py
Normal file
@@ -0,0 +1,166 @@
|
||||
"""
|
||||
AI Agent Models: AgentConfig (Singleton), ChatSession, ChatMessage.
|
||||
"""
|
||||
import uuid
|
||||
|
||||
from django.contrib.auth.models import User
|
||||
from django.db import models
|
||||
|
||||
DEFAULT_SYSTEM_PROMPT = """Du bist RentmeisterAI, der KI-Assistent der van Hees-Theyssen-Vogel'schen Stiftung.
|
||||
|
||||
Du hast Zugriff auf die Stiftungsdatenbank und kannst Informationen zu Destinatären, Ländereien, \
|
||||
Finanzen, Förderungen und weiteren Stiftungsdaten abrufen.
|
||||
|
||||
Regeln:
|
||||
- Antworte stets auf Deutsch, präzise und sachlich.
|
||||
- Schütze personenbezogene Daten – gib keine unnötigen Details heraus.
|
||||
- Du kannst keine Daten ändern, nur lesen.
|
||||
- Bei rechtlichen oder steuerlichen Fragen weise auf Fachberatung hin.
|
||||
- Wenn du dir unsicher bist, sage das klar.
|
||||
"""
|
||||
|
||||
|
||||
class AgentConfig(models.Model):
|
||||
"""Singleton-Konfiguration für den AI Agent."""
|
||||
|
||||
PROVIDER_CHOICES = [
|
||||
("ollama", "Ollama (lokal)"),
|
||||
("openai", "OpenAI"),
|
||||
("anthropic", "Anthropic"),
|
||||
]
|
||||
|
||||
provider = models.CharField(
|
||||
max_length=20,
|
||||
choices=PROVIDER_CHOICES,
|
||||
default="ollama",
|
||||
verbose_name="LLM-Provider",
|
||||
)
|
||||
model_name = models.CharField(
|
||||
max_length=100,
|
||||
default="qwen2.5:3b",
|
||||
verbose_name="Modell-Name",
|
||||
)
|
||||
ollama_url = models.CharField(
|
||||
max_length=255,
|
||||
default="http://ollama:11434",
|
||||
verbose_name="Ollama-URL",
|
||||
)
|
||||
openai_api_key = models.CharField(
|
||||
max_length=255,
|
||||
blank=True,
|
||||
verbose_name="OpenAI API-Key",
|
||||
help_text="Nur erforderlich wenn Provider = OpenAI",
|
||||
)
|
||||
anthropic_api_key = models.CharField(
|
||||
max_length=255,
|
||||
blank=True,
|
||||
verbose_name="Anthropic API-Key",
|
||||
help_text="Nur erforderlich wenn Provider = Anthropic",
|
||||
)
|
||||
system_prompt = models.TextField(
|
||||
default=DEFAULT_SYSTEM_PROMPT,
|
||||
verbose_name="System-Prompt",
|
||||
)
|
||||
allow_write = models.BooleanField(
|
||||
default=False,
|
||||
verbose_name="Schreib-Tools erlaubt",
|
||||
help_text="Achtung: Schreib-Zugriff auf Stiftungsdaten aktivieren",
|
||||
)
|
||||
chat_retention_days = models.IntegerField(
|
||||
default=30,
|
||||
verbose_name="Chat-Verlauf Aufbewahrung (Tage)",
|
||||
)
|
||||
|
||||
class Meta:
|
||||
verbose_name = "Agent-Konfiguration"
|
||||
verbose_name_plural = "Agent-Konfiguration"
|
||||
|
||||
def __str__(self):
|
||||
return f"Agent Config ({self.get_provider_display()} / {self.model_name})"
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
# Singleton: always use pk=1
|
||||
self.pk = 1
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
def delete(self, *args, **kwargs):
|
||||
pass # Singleton cannot be deleted
|
||||
|
||||
@classmethod
|
||||
def get_config(cls):
|
||||
config, _ = cls.objects.get_or_create(pk=1)
|
||||
return config
|
||||
|
||||
|
||||
class ChatSession(models.Model):
|
||||
"""Chat-Sitzung eines Benutzers mit dem AI Agent."""
|
||||
|
||||
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
|
||||
user = models.ForeignKey(
|
||||
User,
|
||||
on_delete=models.CASCADE,
|
||||
related_name="agent_sessions",
|
||||
verbose_name="Benutzer",
|
||||
)
|
||||
title = models.CharField(
|
||||
max_length=200,
|
||||
blank=True,
|
||||
verbose_name="Titel",
|
||||
help_text="Automatisch aus erster Nachricht generiert",
|
||||
)
|
||||
created_at = models.DateTimeField(auto_now_add=True, verbose_name="Erstellt")
|
||||
updated_at = models.DateTimeField(auto_now=True, verbose_name="Zuletzt aktiv")
|
||||
|
||||
class Meta:
|
||||
verbose_name = "Chat-Sitzung"
|
||||
verbose_name_plural = "Chat-Sitzungen"
|
||||
ordering = ["-updated_at"]
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.user.username} – {self.title or str(self.id)[:8]} ({self.created_at.strftime('%d.%m.%Y')})"
|
||||
|
||||
def message_count(self):
|
||||
return self.messages.count()
|
||||
|
||||
|
||||
class ChatMessage(models.Model):
|
||||
"""Einzelne Nachricht in einer Chat-Sitzung."""
|
||||
|
||||
ROLE_CHOICES = [
|
||||
("user", "Benutzer"),
|
||||
("assistant", "Assistent"),
|
||||
("tool", "Tool-Ergebnis"),
|
||||
]
|
||||
|
||||
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
|
||||
session = models.ForeignKey(
|
||||
ChatSession,
|
||||
on_delete=models.CASCADE,
|
||||
related_name="messages",
|
||||
verbose_name="Sitzung",
|
||||
)
|
||||
role = models.CharField(
|
||||
max_length=20,
|
||||
choices=ROLE_CHOICES,
|
||||
verbose_name="Rolle",
|
||||
)
|
||||
content = models.TextField(verbose_name="Inhalt")
|
||||
tool_name = models.CharField(
|
||||
max_length=100,
|
||||
blank=True,
|
||||
verbose_name="Tool-Name",
|
||||
)
|
||||
tool_call_id = models.CharField(
|
||||
max_length=100,
|
||||
blank=True,
|
||||
verbose_name="Tool-Call-ID",
|
||||
)
|
||||
created_at = models.DateTimeField(auto_now_add=True, verbose_name="Erstellt")
|
||||
|
||||
class Meta:
|
||||
verbose_name = "Chat-Nachricht"
|
||||
verbose_name_plural = "Chat-Nachrichten"
|
||||
ordering = ["created_at"]
|
||||
|
||||
def __str__(self):
|
||||
return f"[{self.role}] {self.content[:60]}"
|
||||
201
app/stiftung/agent/orchestrator.py
Normal file
201
app/stiftung/agent/orchestrator.py
Normal file
@@ -0,0 +1,201 @@
|
||||
"""
|
||||
ReAct-Orchestrator für den AI Agent.
|
||||
|
||||
Implementiert einen synchronen ReAct-Loop (Reason + Act) mit:
|
||||
- max. 5 Iterationen
|
||||
- Tool-Calling
|
||||
- Streaming via Generator
|
||||
- Audit-Logging
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
from typing import Generator
|
||||
|
||||
from .providers import get_provider, LLMError
|
||||
from .tools import execute_tool, TOOL_SCHEMAS
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
MAX_ITERATIONS = 5
|
||||
|
||||
|
||||
def run_agent_stream(
|
||||
session,
|
||||
user_message: str,
|
||||
page_context: str = "",
|
||||
user=None,
|
||||
) -> Generator[str, None, None]:
|
||||
"""
|
||||
Führt den ReAct-Loop aus und streamt SSE-kompatible Daten-Strings.
|
||||
|
||||
Yield-Format (Server-Sent Events):
|
||||
"data: {json}\n\n"
|
||||
|
||||
JSON-Typen:
|
||||
{"type": "text", "content": "..."} – Textfragment
|
||||
{"type": "tool_start", "name": "..."} – Tool wird aufgerufen
|
||||
{"type": "tool_result", "name": "...", "result": "..."}
|
||||
{"type": "done"}
|
||||
{"type": "error", "message": "..."}
|
||||
"""
|
||||
from .models import AgentConfig, ChatMessage
|
||||
|
||||
config = AgentConfig.get_config()
|
||||
|
||||
# Systemkontext aufbauen
|
||||
system_content = config.system_prompt
|
||||
if page_context:
|
||||
system_content += f"\n\nAktueller Seitenkontext:\n{page_context}"
|
||||
|
||||
# Nachrichtenhistorie laden (letzte 20 Nachrichten)
|
||||
history = list(
|
||||
session.messages.exclude(role="tool")
|
||||
.order_by("-created_at")[:20]
|
||||
)
|
||||
history.reverse()
|
||||
|
||||
messages = [{"role": "system", "content": system_content}]
|
||||
for msg in history:
|
||||
if msg.role in ("user", "assistant"):
|
||||
messages.append({"role": msg.role, "content": msg.content})
|
||||
|
||||
# Neue User-Nachricht
|
||||
messages.append({"role": "user", "content": user_message})
|
||||
|
||||
# Neue User-Message in DB speichern
|
||||
ChatMessage.objects.create(
|
||||
session=session,
|
||||
role="user",
|
||||
content=user_message,
|
||||
)
|
||||
|
||||
# Sesstionttitel setzen falls leer
|
||||
if not session.title and user_message:
|
||||
session.title = user_message[:100]
|
||||
session.save(update_fields=["title", "updated_at"])
|
||||
|
||||
tools = TOOL_SCHEMAS if not getattr(config, "allow_write", False) else TOOL_SCHEMAS
|
||||
|
||||
try:
|
||||
provider = get_provider(config)
|
||||
except LLMError as e:
|
||||
yield _sse({"type": "error", "message": str(e)})
|
||||
return
|
||||
|
||||
full_assistant_text = ""
|
||||
iteration = 0
|
||||
tools_disabled = False
|
||||
|
||||
while iteration < MAX_ITERATIONS:
|
||||
iteration += 1
|
||||
text_buffer = ""
|
||||
pending_tool_calls = []
|
||||
current_tools = None if tools_disabled else tools
|
||||
|
||||
try:
|
||||
for chunk in provider.chat_stream(messages=messages, tools=current_tools):
|
||||
chunk_type = chunk.get("type")
|
||||
|
||||
if chunk_type == "text":
|
||||
text = chunk["content"]
|
||||
text_buffer += text
|
||||
full_assistant_text += text
|
||||
yield _sse({"type": "text", "content": text})
|
||||
|
||||
elif chunk_type == "tool_call":
|
||||
pending_tool_calls.append(chunk)
|
||||
|
||||
elif chunk_type == "done":
|
||||
break
|
||||
|
||||
elif chunk_type == "error":
|
||||
yield _sse({"type": "error", "message": chunk.get("message", "Unbekannter Fehler")})
|
||||
return
|
||||
|
||||
except LLMError as e:
|
||||
if not tools_disabled and iteration == 1:
|
||||
# Tool-Calling hat den Provider zum Absturz gebracht (z.B. OOM).
|
||||
# Fallback: ohne Tools erneut versuchen.
|
||||
# Warte kurz, damit Ollama nach OOM-Crash neu starten kann.
|
||||
import time
|
||||
logger.warning("LLM-Fehler mit Tools, Fallback auf Chat-only: %s", e)
|
||||
tools_disabled = True
|
||||
full_assistant_text = ""
|
||||
time.sleep(15)
|
||||
continue
|
||||
yield _sse({"type": "error", "message": str(e)})
|
||||
return
|
||||
|
||||
if not pending_tool_calls:
|
||||
# Kein Tool-Call → Antwort fertig
|
||||
break
|
||||
|
||||
# Tool-Calls verarbeiten
|
||||
# Assistent-Nachricht mit tool_calls in History
|
||||
tool_calls_for_msg = []
|
||||
for tc in pending_tool_calls:
|
||||
tool_calls_for_msg.append({
|
||||
"id": tc["id"],
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": tc["name"],
|
||||
"arguments": json.dumps(tc["arguments"], ensure_ascii=False),
|
||||
},
|
||||
})
|
||||
|
||||
assistant_msg: dict = {"role": "assistant", "content": text_buffer or ""}
|
||||
if tool_calls_for_msg:
|
||||
assistant_msg["tool_calls"] = tool_calls_for_msg
|
||||
messages.append(assistant_msg)
|
||||
|
||||
# Jeden Tool-Call ausführen
|
||||
for tc in pending_tool_calls:
|
||||
tool_name = tc["name"]
|
||||
tool_args = tc["arguments"]
|
||||
tool_call_id = tc["id"]
|
||||
|
||||
yield _sse({"type": "tool_start", "name": tool_name})
|
||||
|
||||
result = execute_tool(tool_name, tool_args, user)
|
||||
|
||||
# Tool-Ergebnis in DB
|
||||
ChatMessage.objects.create(
|
||||
session=session,
|
||||
role="tool",
|
||||
content=result,
|
||||
tool_name=tool_name,
|
||||
tool_call_id=tool_call_id,
|
||||
)
|
||||
|
||||
yield _sse({"type": "tool_result", "name": tool_name, "result": result[:500]})
|
||||
|
||||
# Tool-Ergebnis in Messages für nächste LLM-Iteration
|
||||
messages.append({
|
||||
"role": "tool",
|
||||
"tool_call_id": tool_call_id,
|
||||
"content": result,
|
||||
})
|
||||
|
||||
full_assistant_text = "" # Reset für nächste Iteration
|
||||
|
||||
# Abschließende Assistent-Nachricht in DB speichern
|
||||
if full_assistant_text:
|
||||
ChatMessage.objects.create(
|
||||
session=session,
|
||||
role="assistant",
|
||||
content=full_assistant_text,
|
||||
)
|
||||
|
||||
# Session updated_at aktualisieren
|
||||
from django.utils import timezone
|
||||
session.updated_at = timezone.now()
|
||||
session.save(update_fields=["updated_at"])
|
||||
|
||||
yield _sse({"type": "done"})
|
||||
|
||||
|
||||
def _sse(data: dict) -> str:
|
||||
"""Formatiert ein Dict als SSE data-Zeile."""
|
||||
return f"data: {json.dumps(data, ensure_ascii=False)}\n\n"
|
||||
323
app/stiftung/agent/providers.py
Normal file
323
app/stiftung/agent/providers.py
Normal file
@@ -0,0 +1,323 @@
|
||||
"""
|
||||
LLM-Provider-Abstraktion für den AI Agent.
|
||||
|
||||
Unterstützt:
|
||||
- Ollama (Standard, OpenAI-kompatibles API via httpx)
|
||||
- OpenAI
|
||||
- Anthropic (über OpenAI-kompatible Schnittstelle)
|
||||
|
||||
Alle Provider implementieren synchrones Streaming via Generator.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
from typing import Generator, Any
|
||||
|
||||
import httpx
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class LLMError(Exception):
|
||||
"""LLM-Kommunikationsfehler."""
|
||||
pass
|
||||
|
||||
|
||||
class BaseLLMProvider:
|
||||
def chat_stream(
|
||||
self,
|
||||
messages: list[dict],
|
||||
tools: list[dict] | None = None,
|
||||
) -> Generator[dict, None, None]:
|
||||
"""
|
||||
Streamt Antwort-Chunks als Dicts.
|
||||
Chunk-Typen:
|
||||
{"type": "text", "content": "..."}
|
||||
{"type": "tool_call", "id": "...", "name": "...", "arguments": {...}}
|
||||
{"type": "done"}
|
||||
{"type": "error", "message": "..."}
|
||||
"""
|
||||
raise NotImplementedError
|
||||
|
||||
|
||||
class OllamaProvider(BaseLLMProvider):
|
||||
"""Ollama via OpenAI-kompatibler Chat-Completion-Endpunkt."""
|
||||
|
||||
def __init__(self, base_url: str, model: str):
|
||||
self.base_url = base_url.rstrip("/")
|
||||
self.model = model
|
||||
|
||||
def chat_stream(
|
||||
self,
|
||||
messages: list[dict],
|
||||
tools: list[dict] | None = None,
|
||||
) -> Generator[dict, None, None]:
|
||||
url = f"{self.base_url}/v1/chat/completions"
|
||||
payload: dict[str, Any] = {
|
||||
"model": self.model,
|
||||
"messages": messages,
|
||||
"stream": True,
|
||||
}
|
||||
if tools:
|
||||
payload["tools"] = tools
|
||||
payload["tool_choice"] = "auto"
|
||||
|
||||
try:
|
||||
with httpx.Client(timeout=120.0) as client:
|
||||
with client.stream("POST", url, json=payload) as response:
|
||||
if response.status_code != 200:
|
||||
body = response.read().decode()
|
||||
raise LLMError(
|
||||
f"Ollama-Fehler {response.status_code}: {body[:200]}"
|
||||
)
|
||||
yield from _parse_openai_stream(response)
|
||||
except httpx.ConnectError:
|
||||
raise LLMError(
|
||||
f"Verbindung zu Ollama ({self.base_url}) fehlgeschlagen. "
|
||||
"Ist der Ollama-Dienst gestartet?"
|
||||
)
|
||||
except httpx.RemoteProtocolError:
|
||||
raise LLMError(
|
||||
"Ollama-Verbindung abgebrochen. "
|
||||
"Möglicherweise nicht genug RAM für dieses Modell mit Tool-Calling."
|
||||
)
|
||||
except httpx.TimeoutException:
|
||||
raise LLMError("Ollama-Anfrage hat das Zeitlimit überschritten.")
|
||||
|
||||
|
||||
class OpenAIProvider(BaseLLMProvider):
|
||||
"""OpenAI Chat-Completion API."""
|
||||
|
||||
BASE_URL = "https://api.openai.com"
|
||||
|
||||
def __init__(self, api_key: str, model: str):
|
||||
self.api_key = api_key
|
||||
self.model = model
|
||||
|
||||
def chat_stream(
|
||||
self,
|
||||
messages: list[dict],
|
||||
tools: list[dict] | None = None,
|
||||
) -> Generator[dict, None, None]:
|
||||
url = f"{self.BASE_URL}/v1/chat/completions"
|
||||
headers = {
|
||||
"Authorization": f"Bearer {self.api_key}",
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
payload: dict[str, Any] = {
|
||||
"model": self.model,
|
||||
"messages": messages,
|
||||
"stream": True,
|
||||
}
|
||||
if tools:
|
||||
payload["tools"] = tools
|
||||
payload["tool_choice"] = "auto"
|
||||
|
||||
try:
|
||||
with httpx.Client(timeout=120.0) as client:
|
||||
with client.stream("POST", url, json=payload, headers=headers) as response:
|
||||
if response.status_code != 200:
|
||||
body = response.read().decode()
|
||||
raise LLMError(
|
||||
f"OpenAI-Fehler {response.status_code}: {body[:200]}"
|
||||
)
|
||||
yield from _parse_openai_stream(response)
|
||||
except httpx.TimeoutException:
|
||||
raise LLMError("OpenAI-Anfrage hat das Zeitlimit überschritten.")
|
||||
|
||||
|
||||
class AnthropicProvider(BaseLLMProvider):
|
||||
"""Anthropic Messages API (native, not OpenAI-compatible)."""
|
||||
|
||||
BASE_URL = "https://api.anthropic.com"
|
||||
|
||||
def __init__(self, api_key: str, model: str):
|
||||
self.api_key = api_key
|
||||
self.model = model
|
||||
|
||||
def chat_stream(
|
||||
self,
|
||||
messages: list[dict],
|
||||
tools: list[dict] | None = None,
|
||||
) -> Generator[dict, None, None]:
|
||||
url = f"{self.BASE_URL}/v1/messages"
|
||||
headers = {
|
||||
"x-api-key": self.api_key,
|
||||
"anthropic-version": "2023-06-01",
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
|
||||
# Extract system message from messages list
|
||||
system = ""
|
||||
chat_messages = []
|
||||
for msg in messages:
|
||||
if msg["role"] == "system":
|
||||
system = msg["content"]
|
||||
else:
|
||||
chat_messages.append(msg)
|
||||
|
||||
# Convert OpenAI tool format to Anthropic format
|
||||
anthropic_tools = []
|
||||
if tools:
|
||||
for t in tools:
|
||||
fn = t.get("function", {})
|
||||
anthropic_tools.append({
|
||||
"name": fn.get("name"),
|
||||
"description": fn.get("description", ""),
|
||||
"input_schema": fn.get("parameters", {}),
|
||||
})
|
||||
|
||||
payload: dict[str, Any] = {
|
||||
"model": self.model,
|
||||
"max_tokens": 4096,
|
||||
"messages": chat_messages,
|
||||
"stream": True,
|
||||
}
|
||||
if system:
|
||||
payload["system"] = system
|
||||
if anthropic_tools:
|
||||
payload["tools"] = anthropic_tools
|
||||
|
||||
try:
|
||||
with httpx.Client(timeout=120.0) as client:
|
||||
with client.stream("POST", url, json=payload, headers=headers) as response:
|
||||
if response.status_code != 200:
|
||||
body = response.read().decode()
|
||||
raise LLMError(
|
||||
f"Anthropic-Fehler {response.status_code}: {body[:200]}"
|
||||
)
|
||||
yield from _parse_anthropic_stream(response)
|
||||
except httpx.TimeoutException:
|
||||
raise LLMError("Anthropic-Anfrage hat das Zeitlimit überschritten.")
|
||||
|
||||
|
||||
def _parse_openai_stream(response) -> Generator[dict, None, None]:
|
||||
"""Parst OpenAI-kompatibles SSE-Streaming-Format."""
|
||||
accumulated_tool_calls: dict[int, dict] = {}
|
||||
|
||||
for line in response.iter_lines():
|
||||
if not line or line == "data: [DONE]":
|
||||
continue
|
||||
if line.startswith("data: "):
|
||||
line = line[6:]
|
||||
try:
|
||||
chunk = json.loads(line)
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
|
||||
choice = chunk.get("choices", [{}])[0]
|
||||
delta = choice.get("delta", {})
|
||||
finish_reason = choice.get("finish_reason")
|
||||
|
||||
# Text content
|
||||
if delta.get("content"):
|
||||
yield {"type": "text", "content": delta["content"]}
|
||||
|
||||
# Tool calls (streaming – parts arrive incrementally)
|
||||
tool_calls_delta = delta.get("tool_calls", [])
|
||||
for tc_delta in tool_calls_delta:
|
||||
idx = tc_delta.get("index", 0)
|
||||
if idx not in accumulated_tool_calls:
|
||||
accumulated_tool_calls[idx] = {
|
||||
"id": "",
|
||||
"name": "",
|
||||
"arguments": "",
|
||||
}
|
||||
tc = accumulated_tool_calls[idx]
|
||||
if tc_delta.get("id"):
|
||||
tc["id"] += tc_delta["id"]
|
||||
fn = tc_delta.get("function", {})
|
||||
if fn.get("name"):
|
||||
tc["name"] += fn["name"]
|
||||
if fn.get("arguments"):
|
||||
tc["arguments"] += fn["arguments"]
|
||||
|
||||
if finish_reason in ("tool_calls", "stop"):
|
||||
# Emit completed tool calls
|
||||
for tc in accumulated_tool_calls.values():
|
||||
try:
|
||||
args = json.loads(tc["arguments"]) if tc["arguments"] else {}
|
||||
except json.JSONDecodeError:
|
||||
args = {}
|
||||
yield {
|
||||
"type": "tool_call",
|
||||
"id": tc["id"],
|
||||
"name": tc["name"],
|
||||
"arguments": args,
|
||||
}
|
||||
accumulated_tool_calls.clear()
|
||||
|
||||
if finish_reason == "stop":
|
||||
yield {"type": "done"}
|
||||
return
|
||||
|
||||
yield {"type": "done"}
|
||||
|
||||
|
||||
def _parse_anthropic_stream(response) -> Generator[dict, None, None]:
|
||||
"""Parst Anthropic SSE-Streaming-Format."""
|
||||
current_tool: dict | None = None
|
||||
tool_input_str = ""
|
||||
|
||||
for line in response.iter_lines():
|
||||
if not line or line.startswith("event:"):
|
||||
continue
|
||||
if line.startswith("data: "):
|
||||
line = line[6:]
|
||||
try:
|
||||
event = json.loads(line)
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
|
||||
event_type = event.get("type", "")
|
||||
|
||||
if event_type == "content_block_start":
|
||||
block = event.get("content_block", {})
|
||||
if block.get("type") == "tool_use":
|
||||
current_tool = {"id": block.get("id", ""), "name": block.get("name", "")}
|
||||
tool_input_str = ""
|
||||
|
||||
elif event_type == "content_block_delta":
|
||||
delta = event.get("delta", {})
|
||||
if delta.get("type") == "text_delta":
|
||||
yield {"type": "text", "content": delta.get("text", "")}
|
||||
elif delta.get("type") == "input_json_delta":
|
||||
tool_input_str += delta.get("partial_json", "")
|
||||
|
||||
elif event_type == "content_block_stop":
|
||||
if current_tool is not None:
|
||||
try:
|
||||
args = json.loads(tool_input_str) if tool_input_str else {}
|
||||
except json.JSONDecodeError:
|
||||
args = {}
|
||||
yield {
|
||||
"type": "tool_call",
|
||||
"id": current_tool["id"],
|
||||
"name": current_tool["name"],
|
||||
"arguments": args,
|
||||
}
|
||||
current_tool = None
|
||||
tool_input_str = ""
|
||||
|
||||
elif event_type == "message_stop":
|
||||
yield {"type": "done"}
|
||||
return
|
||||
|
||||
yield {"type": "done"}
|
||||
|
||||
|
||||
def get_provider(config) -> BaseLLMProvider:
|
||||
"""Erstellt den konfigurierten LLM-Provider."""
|
||||
if config.provider == "ollama":
|
||||
return OllamaProvider(base_url=config.ollama_url, model=config.model_name)
|
||||
elif config.provider == "openai":
|
||||
if not config.openai_api_key:
|
||||
raise LLMError("OpenAI API-Key ist nicht konfiguriert.")
|
||||
return OpenAIProvider(api_key=config.openai_api_key, model=config.model_name)
|
||||
elif config.provider == "anthropic":
|
||||
if not config.anthropic_api_key:
|
||||
raise LLMError("Anthropic API-Key ist nicht konfiguriert.")
|
||||
return AnthropicProvider(api_key=config.anthropic_api_key, model=config.model_name)
|
||||
else:
|
||||
raise LLMError(f"Unbekannter Provider: {config.provider}")
|
||||
363
app/stiftung/agent/tools.py
Normal file
363
app/stiftung/agent/tools.py
Normal file
@@ -0,0 +1,363 @@
|
||||
"""
|
||||
Tool-Registry für den AI Agent.
|
||||
|
||||
Wrappt bestehende Django-ORM-Abfragen (analog zu mcp_server/tools/lesen.py)
|
||||
mit direktem DB-Zugriff und PII-Filterung basierend auf Django-User-Berechtigungen.
|
||||
Schreib-Tools sind standardmäßig deaktiviert.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
from decimal import Decimal
|
||||
from typing import Any
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
# Hilfsfunktionen
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
def _get_role(user) -> str:
|
||||
"""Leitet MCP-Rolle aus Django-User ab."""
|
||||
if user.is_superuser or user.has_perm("stiftung.access_administration"):
|
||||
return "admin"
|
||||
return "readonly"
|
||||
|
||||
|
||||
def _serialize(obj: Any) -> Any:
|
||||
"""Serialisiert Django-Modell-Werte zu JSON-fähigen Typen."""
|
||||
if obj is None:
|
||||
return None
|
||||
if isinstance(obj, Decimal):
|
||||
return float(obj)
|
||||
if hasattr(obj, "isoformat"):
|
||||
return obj.isoformat()
|
||||
if hasattr(obj, "__str__"):
|
||||
return str(obj)
|
||||
return obj
|
||||
|
||||
|
||||
def _apply_pii(data: dict, model_type: str, role: str) -> dict:
|
||||
"""Wendet PII-Filterung via mcp_server.privacy an."""
|
||||
from mcp_server.privacy import apply_privacy_filter
|
||||
return apply_privacy_filter(data, model_type, role)
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
# Tool-Implementierungen (Read-Only)
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
def tool_destinataer_suchen(user, suchbegriff: str = "", aktiv: bool | None = None, limit: int = 20) -> str:
|
||||
from django.db.models import Q
|
||||
from stiftung.models import Destinataer
|
||||
role = _get_role(user)
|
||||
limit = min(limit, 50)
|
||||
qs = Destinataer.objects.all()
|
||||
if suchbegriff:
|
||||
qs = qs.filter(
|
||||
Q(vorname__icontains=suchbegriff)
|
||||
| Q(nachname__icontains=suchbegriff)
|
||||
| Q(institution__icontains=suchbegriff)
|
||||
)
|
||||
if aktiv is not None:
|
||||
qs = qs.filter(aktiv=aktiv)
|
||||
qs = qs.order_by("nachname", "vorname")[:limit]
|
||||
results = []
|
||||
for obj in qs:
|
||||
item = {
|
||||
"id": str(obj.id),
|
||||
"vorname": obj.vorname,
|
||||
"nachname": obj.nachname,
|
||||
"familienzweig": obj.familienzweig,
|
||||
"aktiv": obj.aktiv,
|
||||
"ort": obj.ort,
|
||||
"email": obj.email,
|
||||
}
|
||||
results.append(_apply_pii(item, "destinataer", role))
|
||||
return json.dumps({"count": len(results), "destinataere": results}, default=_serialize, ensure_ascii=False)
|
||||
|
||||
|
||||
def tool_land_suchen(user, suchbegriff: str = "", limit: int = 20) -> str:
|
||||
from django.db.models import Q
|
||||
from stiftung.models import Land
|
||||
limit = min(limit, 50)
|
||||
qs = Land.objects.all()
|
||||
if suchbegriff:
|
||||
qs = qs.filter(
|
||||
Q(bezeichnung__icontains=suchbegriff)
|
||||
| Q(gemarkung__icontains=suchbegriff)
|
||||
| Q(ort__icontains=suchbegriff)
|
||||
)
|
||||
qs = qs.order_by("bezeichnung")[:limit]
|
||||
results = []
|
||||
for obj in qs:
|
||||
results.append({
|
||||
"id": str(obj.id),
|
||||
"bezeichnung": obj.bezeichnung,
|
||||
"gemarkung": getattr(obj, "gemarkung", ""),
|
||||
"ort": getattr(obj, "ort", ""),
|
||||
"flaeche_ha": _serialize(getattr(obj, "flaeche_ha", None)),
|
||||
"aktiv": getattr(obj, "aktiv", True),
|
||||
})
|
||||
return json.dumps({"count": len(results), "laendereien": results}, default=_serialize, ensure_ascii=False)
|
||||
|
||||
|
||||
def tool_konten_uebersicht(user) -> str:
|
||||
from stiftung.models import StiftungsKonto
|
||||
role = _get_role(user)
|
||||
konten = StiftungsKonto.objects.all().order_by("bezeichnung")
|
||||
results = []
|
||||
for k in konten:
|
||||
item = {
|
||||
"id": str(k.id),
|
||||
"bezeichnung": k.bezeichnung,
|
||||
"bank": getattr(k, "bank", ""),
|
||||
"kontonummer": getattr(k, "kontonummer", ""),
|
||||
"iban": getattr(k, "iban", ""),
|
||||
"aktiv": getattr(k, "aktiv", True),
|
||||
}
|
||||
results.append(_apply_pii(item, "konto", role))
|
||||
return json.dumps({"count": len(results), "konten": results}, default=_serialize, ensure_ascii=False)
|
||||
|
||||
|
||||
def tool_foerderungen_suchen(user, suchbegriff: str = "", status: str = "", limit: int = 20) -> str:
|
||||
from django.db.models import Q
|
||||
from stiftung.models import Foerderung
|
||||
limit = min(limit, 50)
|
||||
qs = Foerderung.objects.select_related("destinataer").all()
|
||||
if suchbegriff:
|
||||
qs = qs.filter(
|
||||
Q(bezeichnung__icontains=suchbegriff)
|
||||
| Q(destinataer__nachname__icontains=suchbegriff)
|
||||
| Q(destinataer__vorname__icontains=suchbegriff)
|
||||
)
|
||||
if status:
|
||||
qs = qs.filter(status=status)
|
||||
qs = qs.order_by("-erstellt_am")[:limit]
|
||||
results = []
|
||||
for obj in qs:
|
||||
results.append({
|
||||
"id": str(obj.id),
|
||||
"bezeichnung": getattr(obj, "bezeichnung", ""),
|
||||
"destinataer": str(obj.destinataer) if obj.destinataer else None,
|
||||
"betrag": _serialize(getattr(obj, "betrag", None)),
|
||||
"status": getattr(obj, "status", ""),
|
||||
"erstellt_am": _serialize(getattr(obj, "erstellt_am", None)),
|
||||
})
|
||||
return json.dumps({"count": len(results), "foerderungen": results}, default=_serialize, ensure_ascii=False)
|
||||
|
||||
|
||||
def tool_verwaltungskosten(user, jahr: int | None = None, limit: int = 20) -> str:
|
||||
from stiftung.models import Verwaltungskosten
|
||||
limit = min(limit, 50)
|
||||
qs = Verwaltungskosten.objects.all()
|
||||
if jahr:
|
||||
qs = qs.filter(datum__year=jahr)
|
||||
qs = qs.order_by("-datum")[:limit]
|
||||
results = []
|
||||
for obj in qs:
|
||||
results.append({
|
||||
"id": str(obj.id),
|
||||
"datum": _serialize(getattr(obj, "datum", None)),
|
||||
"bezeichnung": getattr(obj, "bezeichnung", ""),
|
||||
"betrag": _serialize(getattr(obj, "betrag", None)),
|
||||
"kategorie": getattr(obj, "kategorie", ""),
|
||||
})
|
||||
return json.dumps({"count": len(results), "verwaltungskosten": results}, default=_serialize, ensure_ascii=False)
|
||||
|
||||
|
||||
def tool_termine_anzeigen(user, limit: int = 10) -> str:
|
||||
from django.utils import timezone
|
||||
from stiftung.models import StiftungsKalenderEintrag
|
||||
now = timezone.now().date()
|
||||
qs = StiftungsKalenderEintrag.objects.filter(datum__gte=now).order_by("datum")[:limit]
|
||||
results = []
|
||||
for obj in qs:
|
||||
results.append({
|
||||
"id": str(obj.id),
|
||||
"titel": getattr(obj, "titel", ""),
|
||||
"datum": _serialize(getattr(obj, "datum", None)),
|
||||
"beschreibung": getattr(obj, "beschreibung", ""),
|
||||
"typ": getattr(obj, "typ", ""),
|
||||
})
|
||||
return json.dumps({"count": len(results), "termine": results}, default=_serialize, ensure_ascii=False)
|
||||
|
||||
|
||||
def tool_transaktionen_suchen(user, suchbegriff: str = "", limit: int = 20) -> str:
|
||||
from django.db.models import Q
|
||||
from stiftung.models import BankTransaction
|
||||
limit = min(limit, 50)
|
||||
qs = BankTransaction.objects.all()
|
||||
if suchbegriff:
|
||||
qs = qs.filter(
|
||||
Q(verwendungszweck__icontains=suchbegriff)
|
||||
| Q(auftraggeber__icontains=suchbegriff)
|
||||
)
|
||||
qs = qs.order_by("-buchungsdatum")[:limit]
|
||||
results = []
|
||||
for obj in qs:
|
||||
results.append({
|
||||
"id": str(obj.id),
|
||||
"datum": _serialize(getattr(obj, "buchungsdatum", None)),
|
||||
"betrag": _serialize(getattr(obj, "betrag", None)),
|
||||
"verwendungszweck": getattr(obj, "verwendungszweck", ""),
|
||||
"auftraggeber": getattr(obj, "auftraggeber", ""),
|
||||
})
|
||||
return json.dumps({"count": len(results), "transaktionen": results}, default=_serialize, ensure_ascii=False)
|
||||
|
||||
|
||||
def tool_dashboard(user) -> str:
|
||||
"""Gibt eine Übersicht über Schlüsselkennzahlen zurück."""
|
||||
from stiftung.models import Destinataer, Foerderung, Land, StiftungsKonto
|
||||
try:
|
||||
destinataere_aktiv = Destinataer.objects.filter(aktiv=True).count()
|
||||
destinataere_gesamt = Destinataer.objects.count()
|
||||
laendereien = Land.objects.count()
|
||||
konten = StiftungsKonto.objects.count()
|
||||
foerderungen_offen = Foerderung.objects.filter(status="offen").count() if hasattr(Foerderung, 'objects') else 0
|
||||
return json.dumps({
|
||||
"destinataere_aktiv": destinataere_aktiv,
|
||||
"destinataere_gesamt": destinataere_gesamt,
|
||||
"laendereien": laendereien,
|
||||
"konten": konten,
|
||||
"foerderungen_offen": foerderungen_offen,
|
||||
}, ensure_ascii=False)
|
||||
except Exception as e:
|
||||
return json.dumps({"fehler": str(e)}, ensure_ascii=False)
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
# Tool-Dispatch und Schema
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
TOOL_FUNCTIONS = {
|
||||
"destinataer_suchen": tool_destinataer_suchen,
|
||||
"land_suchen": tool_land_suchen,
|
||||
"konten_uebersicht": tool_konten_uebersicht,
|
||||
"foerderungen_suchen": tool_foerderungen_suchen,
|
||||
"verwaltungskosten": tool_verwaltungskosten,
|
||||
"termine_anzeigen": tool_termine_anzeigen,
|
||||
"transaktionen_suchen": tool_transaktionen_suchen,
|
||||
"dashboard": tool_dashboard,
|
||||
}
|
||||
|
||||
TOOL_SCHEMAS = [
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "destinataer_suchen",
|
||||
"description": "Sucht Destinatäre (Förderungsempfänger) nach Name oder Status.",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"suchbegriff": {"type": "string", "description": "Vor-/Nachname oder Institution"},
|
||||
"aktiv": {"type": "boolean", "description": "true=nur Aktive, false=nur Inaktive"},
|
||||
"limit": {"type": "integer", "description": "Max. Anzahl Ergebnisse (Standard: 20)"},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "land_suchen",
|
||||
"description": "Sucht Ländereien (Grundstücke) der Stiftung nach Bezeichnung oder Ort.",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"suchbegriff": {"type": "string", "description": "Bezeichnung, Gemarkung oder Ort"},
|
||||
"limit": {"type": "integer", "description": "Max. Anzahl Ergebnisse"},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "konten_uebersicht",
|
||||
"description": "Zeigt alle Stiftungskonten mit Bankverbindungen.",
|
||||
"parameters": {"type": "object", "properties": {}},
|
||||
},
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "foerderungen_suchen",
|
||||
"description": "Sucht Förderungen nach Bezeichnung oder Destinatär.",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"suchbegriff": {"type": "string", "description": "Bezeichnung oder Destinatär-Name"},
|
||||
"status": {"type": "string", "description": "Status-Filter (z.B. 'offen', 'genehmigt')"},
|
||||
"limit": {"type": "integer"},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "verwaltungskosten",
|
||||
"description": "Listet Verwaltungskosten, optional nach Jahr gefiltert.",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"jahr": {"type": "integer", "description": "Filterjahr (z.B. 2025)"},
|
||||
"limit": {"type": "integer"},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "termine_anzeigen",
|
||||
"description": "Zeigt bevorstehende Termine und Fristen der Stiftung.",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"limit": {"type": "integer", "description": "Max. Anzahl Termine"},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "transaktionen_suchen",
|
||||
"description": "Sucht Banktransaktionen nach Verwendungszweck oder Auftraggeber.",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"suchbegriff": {"type": "string"},
|
||||
"limit": {"type": "integer"},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "dashboard",
|
||||
"description": "Zeigt Schlüsselkennzahlen der Stiftung (Anzahl Destinatäre, Ländereien, Konten etc.).",
|
||||
"parameters": {"type": "object", "properties": {}},
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
def execute_tool(name: str, arguments: dict, user) -> str:
|
||||
"""Führt ein Tool aus und gibt das Ergebnis als String zurück."""
|
||||
fn = TOOL_FUNCTIONS.get(name)
|
||||
if fn is None:
|
||||
return json.dumps({"fehler": f"Unbekanntes Tool: {name}"}, ensure_ascii=False)
|
||||
try:
|
||||
return fn(user, **arguments)
|
||||
except TypeError as e:
|
||||
logger.warning("Tool %s Parameterfehler: %s", name, e)
|
||||
return json.dumps({"fehler": f"Ungültige Parameter: {e}"}, ensure_ascii=False)
|
||||
except Exception as e:
|
||||
logger.error("Tool %s Fehler: %s", name, e, exc_info=True)
|
||||
return json.dumps({"fehler": f"Tool-Ausführung fehlgeschlagen: {e}"}, ensure_ascii=False)
|
||||
12
app/stiftung/agent/urls.py
Normal file
12
app/stiftung/agent/urls.py
Normal file
@@ -0,0 +1,12 @@
|
||||
from django.urls import path
|
||||
|
||||
from . import views
|
||||
|
||||
urlpatterns = [
|
||||
path("", views.agent_index, name="agent_index"),
|
||||
path("chat/", views.agent_chat, name="agent_chat"),
|
||||
path("chat/stream/", views.agent_chat_stream, name="agent_chat_stream"),
|
||||
path("sessions/", views.agent_sessions, name="agent_sessions"),
|
||||
path("sessions/<uuid:session_id>/", views.agent_session_messages, name="agent_session_messages"),
|
||||
path("sessions/<uuid:session_id>/loeschen/", views.agent_session_delete, name="agent_session_delete"),
|
||||
]
|
||||
232
app/stiftung/agent/views.py
Normal file
232
app/stiftung/agent/views.py
Normal file
@@ -0,0 +1,232 @@
|
||||
"""
|
||||
Views für den AI Agent.
|
||||
|
||||
Endpunkte:
|
||||
POST /agent/chat/ – Neue Nachricht senden (startet neue oder bestehende Session)
|
||||
GET /agent/chat/stream/ – SSE-Stream für laufende Anfrage
|
||||
GET /agent/sessions/ – Liste der Chat-Sitzungen (JSON)
|
||||
DELETE /agent/sessions/<id>/ – Sitzung löschen
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
|
||||
from django.contrib.auth.decorators import login_required
|
||||
from django.core.cache import cache
|
||||
from django.http import (
|
||||
HttpResponse,
|
||||
JsonResponse,
|
||||
StreamingHttpResponse,
|
||||
)
|
||||
from django.shortcuts import get_object_or_404
|
||||
from django.utils import timezone
|
||||
from django.views.decorators.csrf import csrf_exempt
|
||||
from django.views.decorators.http import require_http_methods
|
||||
|
||||
from .models import AgentConfig, ChatSession, ChatMessage
|
||||
from .orchestrator import run_agent_stream
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
RATE_LIMIT_PER_MINUTE = 20
|
||||
|
||||
|
||||
def _check_rate_limit(user_id: int) -> bool:
|
||||
"""Einfaches Rate-Limiting via Django-Cache (Redis). True = erlaubt."""
|
||||
key = f"agent_rl_{user_id}"
|
||||
count = cache.get(key, 0)
|
||||
if count >= RATE_LIMIT_PER_MINUTE:
|
||||
return False
|
||||
cache.set(key, count + 1, timeout=60)
|
||||
return True
|
||||
|
||||
|
||||
def _require_agent_permission(user) -> bool:
|
||||
"""Prüft ob der Benutzer den Agent nutzen darf."""
|
||||
return (
|
||||
user.is_superuser
|
||||
or user.has_perm("stiftung.can_use_agent")
|
||||
)
|
||||
|
||||
|
||||
@login_required
|
||||
@require_http_methods(["GET"])
|
||||
def agent_index(request):
|
||||
"""Einstiegsseite für den Chat (wird als Modal geöffnet, nicht direkt navigiert)."""
|
||||
config = AgentConfig.get_config()
|
||||
return JsonResponse({
|
||||
"provider": config.provider,
|
||||
"model": config.model_name,
|
||||
"allow_write": config.allow_write,
|
||||
})
|
||||
|
||||
|
||||
@login_required
|
||||
@require_http_methods(["POST"])
|
||||
def agent_chat(request):
|
||||
"""
|
||||
Startet oder setzt einen Chat fort.
|
||||
|
||||
Body (JSON):
|
||||
{
|
||||
"message": "Wie viele aktive Destinatäre gibt es?",
|
||||
"session_id": "optional-uuid",
|
||||
"page_context": "optional – aktueller Seiteninhalt als Text"
|
||||
}
|
||||
|
||||
Antwort:
|
||||
{
|
||||
"session_id": "...",
|
||||
"stream_url": "/agent/chat/stream/?session_id=..."
|
||||
}
|
||||
"""
|
||||
if not _require_agent_permission(request.user):
|
||||
return JsonResponse({"error": "Keine Berechtigung für den AI-Assistenten."}, status=403)
|
||||
|
||||
if not _check_rate_limit(request.user.id):
|
||||
return JsonResponse(
|
||||
{"error": "Rate-Limit erreicht. Bitte warten Sie eine Minute."},
|
||||
status=429,
|
||||
)
|
||||
|
||||
try:
|
||||
body = json.loads(request.body)
|
||||
except (json.JSONDecodeError, ValueError):
|
||||
return JsonResponse({"error": "Ungültiger JSON-Body."}, status=400)
|
||||
|
||||
message = (body.get("message") or "").strip()
|
||||
if not message:
|
||||
return JsonResponse({"error": "Nachricht darf nicht leer sein."}, status=400)
|
||||
|
||||
page_context = (body.get("page_context") or "")[:2000]
|
||||
session_id = body.get("session_id")
|
||||
|
||||
# Session ermitteln oder neu erstellen
|
||||
session = None
|
||||
if session_id:
|
||||
try:
|
||||
session = ChatSession.objects.get(id=session_id, user=request.user)
|
||||
except ChatSession.DoesNotExist:
|
||||
pass
|
||||
|
||||
if session is None:
|
||||
session = ChatSession.objects.create(user=request.user)
|
||||
|
||||
# Nachricht + Page-Context in Cache für Stream-Endpunkt speichern
|
||||
cache_key = f"agent_pending_{session.id}"
|
||||
cache.set(
|
||||
cache_key,
|
||||
{"message": message, "page_context": page_context},
|
||||
timeout=300, # 5 Minuten
|
||||
)
|
||||
|
||||
return JsonResponse({
|
||||
"session_id": str(session.id),
|
||||
"stream_url": f"/agent/chat/stream/?session_id={session.id}",
|
||||
})
|
||||
|
||||
|
||||
@login_required
|
||||
@require_http_methods(["GET"])
|
||||
def agent_chat_stream(request):
|
||||
"""
|
||||
SSE-Endpunkt: streamt die Antwort des Agenten.
|
||||
|
||||
Query-Params:
|
||||
session_id: UUID der Chat-Sitzung
|
||||
"""
|
||||
if not _require_agent_permission(request.user):
|
||||
return HttpResponse("Keine Berechtigung.", status=403)
|
||||
|
||||
session_id = request.GET.get("session_id")
|
||||
if not session_id:
|
||||
return HttpResponse("session_id fehlt.", status=400)
|
||||
|
||||
session = get_object_or_404(ChatSession, id=session_id, user=request.user)
|
||||
|
||||
cache_key = f"agent_pending_{session.id}"
|
||||
pending = cache.get(cache_key)
|
||||
if not pending:
|
||||
return HttpResponse("Keine ausstehende Nachricht gefunden.", status=400)
|
||||
|
||||
cache.delete(cache_key)
|
||||
message = pending["message"]
|
||||
page_context = pending.get("page_context", "")
|
||||
|
||||
def event_stream():
|
||||
try:
|
||||
yield from run_agent_stream(
|
||||
session=session,
|
||||
user_message=message,
|
||||
page_context=page_context,
|
||||
user=request.user,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error("Agent-Stream-Fehler: %s", e, exc_info=True)
|
||||
import json
|
||||
yield f"data: {json.dumps({'type': 'error', 'message': 'Interner Fehler.'})}\n\n"
|
||||
|
||||
response = StreamingHttpResponse(
|
||||
event_stream(),
|
||||
content_type="text/event-stream",
|
||||
)
|
||||
response["Cache-Control"] = "no-cache"
|
||||
response["X-Accel-Buffering"] = "no"
|
||||
return response
|
||||
|
||||
|
||||
@login_required
|
||||
@require_http_methods(["GET"])
|
||||
def agent_sessions(request):
|
||||
"""Gibt die Chat-Sitzungen des Benutzers zurück (letzte 20)."""
|
||||
if not _require_agent_permission(request.user):
|
||||
return JsonResponse({"error": "Keine Berechtigung."}, status=403)
|
||||
|
||||
sessions = ChatSession.objects.filter(user=request.user).order_by("-updated_at")[:20]
|
||||
data = []
|
||||
for s in sessions:
|
||||
data.append({
|
||||
"id": str(s.id),
|
||||
"title": s.title or "Neue Unterhaltung",
|
||||
"created_at": s.created_at.isoformat(),
|
||||
"updated_at": s.updated_at.isoformat(),
|
||||
"message_count": s.messages.count(),
|
||||
})
|
||||
return JsonResponse({"sessions": data})
|
||||
|
||||
|
||||
@login_required
|
||||
@require_http_methods(["GET"])
|
||||
def agent_session_messages(request, session_id):
|
||||
"""Gibt alle Nachrichten einer Sitzung zurück."""
|
||||
if not _require_agent_permission(request.user):
|
||||
return JsonResponse({"error": "Keine Berechtigung."}, status=403)
|
||||
|
||||
session = get_object_or_404(ChatSession, id=session_id, user=request.user)
|
||||
messages = session.messages.exclude(role="tool").order_by("created_at")
|
||||
data = []
|
||||
for m in messages:
|
||||
data.append({
|
||||
"id": str(m.id),
|
||||
"role": m.role,
|
||||
"content": m.content,
|
||||
"created_at": m.created_at.isoformat(),
|
||||
})
|
||||
return JsonResponse({
|
||||
"session_id": str(session.id),
|
||||
"title": session.title,
|
||||
"messages": data,
|
||||
})
|
||||
|
||||
|
||||
@login_required
|
||||
@require_http_methods(["POST"])
|
||||
def agent_session_delete(request, session_id):
|
||||
"""Löscht eine Chat-Sitzung."""
|
||||
if not _require_agent_permission(request.user):
|
||||
return JsonResponse({"error": "Keine Berechtigung."}, status=403)
|
||||
|
||||
session = get_object_or_404(ChatSession, id=session_id, user=request.user)
|
||||
session.delete()
|
||||
return JsonResponse({"ok": True})
|
||||
@@ -389,7 +389,11 @@ def restore_database(db_backup_file):
|
||||
from django.db import connection
|
||||
with connection.cursor() as cursor:
|
||||
# Check some key tables
|
||||
test_tables = ['stiftung_person', 'stiftung_land', 'stiftung_destinataer']
|
||||
test_tables = [
|
||||
'stiftung_person', 'stiftung_land', 'stiftung_destinataer',
|
||||
'stiftung_dokumentdatei', 'stiftung_emaileingang',
|
||||
'stiftung_verwaltungskosten', 'stiftung_geschichteseite',
|
||||
]
|
||||
for table in test_tables:
|
||||
try:
|
||||
cursor.execute(f"SELECT COUNT(*) FROM {table}")
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
58
app/stiftung/forms/__init__.py
Normal file
58
app/stiftung/forms/__init__.py
Normal file
@@ -0,0 +1,58 @@
|
||||
from .destinataere import (DestinataerForm, DestinataerNotizForm,
|
||||
DestinataerUnterstuetzungForm,
|
||||
UnterstuetzungForm, UnterstuetzungMarkAsPaidForm,
|
||||
UnterstuetzungWiederkehrendForm,
|
||||
VierteljahresNachweisForm)
|
||||
from .dokumente import DokumentLinkForm
|
||||
from .finanzen import (BankImportForm, BankTransactionForm, RentmeisterForm,
|
||||
StiftungsKontoForm, VerwaltungskostenForm)
|
||||
from .foerderung import FoerderungForm
|
||||
from .geschichte import GeschichteBildForm, GeschichteSeiteForm
|
||||
from .land import LandAbrechnungForm, LandForm, LandVerpachtungForm, PaechterForm
|
||||
from .system import (BackupTokenRegenerateForm, PasswordChangeForm, PersonForm,
|
||||
TwoFactorDisableForm, TwoFactorSetupForm,
|
||||
TwoFactorVerifyForm, UserCreationForm, UserPermissionForm,
|
||||
UserUpdateForm)
|
||||
from .veranstaltung import VeranstaltungForm, VeranstaltungsteilnehmerForm
|
||||
|
||||
__all__ = [
|
||||
# destinataere
|
||||
"DestinataerForm",
|
||||
"DestinataerNotizForm",
|
||||
"DestinataerUnterstuetzungForm",
|
||||
"UnterstuetzungForm",
|
||||
"UnterstuetzungMarkAsPaidForm",
|
||||
"UnterstuetzungWiederkehrendForm",
|
||||
"VierteljahresNachweisForm",
|
||||
# dokumente
|
||||
"DokumentLinkForm",
|
||||
# finanzen
|
||||
"BankImportForm",
|
||||
"BankTransactionForm",
|
||||
"RentmeisterForm",
|
||||
"StiftungsKontoForm",
|
||||
"VerwaltungskostenForm",
|
||||
# foerderung
|
||||
"FoerderungForm",
|
||||
# geschichte
|
||||
"GeschichteBildForm",
|
||||
"GeschichteSeiteForm",
|
||||
# land
|
||||
"LandAbrechnungForm",
|
||||
"LandForm",
|
||||
"LandVerpachtungForm",
|
||||
"PaechterForm",
|
||||
# system
|
||||
"BackupTokenRegenerateForm",
|
||||
"PasswordChangeForm",
|
||||
"PersonForm",
|
||||
"TwoFactorDisableForm",
|
||||
"TwoFactorSetupForm",
|
||||
"TwoFactorVerifyForm",
|
||||
"UserCreationForm",
|
||||
"UserPermissionForm",
|
||||
"UserUpdateForm",
|
||||
# veranstaltung
|
||||
"VeranstaltungForm",
|
||||
"VeranstaltungsteilnehmerForm",
|
||||
]
|
||||
439
app/stiftung/forms/destinataere.py
Normal file
439
app/stiftung/forms/destinataere.py
Normal file
@@ -0,0 +1,439 @@
|
||||
from django import forms
|
||||
from django.utils import timezone
|
||||
|
||||
from ..models import (Destinataer, DestinataerNotiz, DestinataerUnterstuetzung,
|
||||
UnterstuetzungWiederkehrend, VierteljahresNachweis)
|
||||
from django.core.exceptions import ValidationError
|
||||
|
||||
|
||||
class DestinataerForm(forms.ModelForm):
|
||||
"""Form für das Erstellen und Bearbeiten von Destinatären"""
|
||||
|
||||
class Meta:
|
||||
model = Destinataer
|
||||
fields = "__all__"
|
||||
widgets = {
|
||||
"anrede": forms.Select(attrs={"class": "form-select"}),
|
||||
"vorname": forms.TextInput(attrs={"class": "form-control"}),
|
||||
"nachname": forms.TextInput(attrs={"class": "form-control"}),
|
||||
"titel": forms.TextInput(attrs={"class": "form-control"}),
|
||||
"strasse": forms.TextInput(attrs={"class": "form-control"}),
|
||||
"plz": forms.TextInput(attrs={"class": "form-control"}),
|
||||
"ort": forms.TextInput(attrs={"class": "form-control"}),
|
||||
"telefon": forms.TextInput(attrs={"class": "form-control"}),
|
||||
"mobil": forms.TextInput(attrs={"class": "form-control"}),
|
||||
"email": forms.EmailInput(attrs={"class": "form-control"}),
|
||||
"aktiv": forms.CheckboxInput(attrs={"class": "form-check-input"}),
|
||||
"notizen": forms.Textarea(attrs={"class": "form-control", "rows": 3}),
|
||||
"ist_abkoemmling": forms.CheckboxInput(attrs={"class": "form-check-input"}),
|
||||
"haushaltsgroesse": forms.NumberInput(
|
||||
attrs={"class": "form-control", "min": 1}
|
||||
),
|
||||
"vermoegen": forms.NumberInput(
|
||||
attrs={"class": "form-control", "step": "0.01"}
|
||||
),
|
||||
"unterstuetzung_bestaetigt": forms.CheckboxInput(
|
||||
attrs={"class": "form-check-input"}
|
||||
),
|
||||
"standard_konto": forms.Select(attrs={"class": "form-select"}, choices=[(None, "---")] + [(c.pk, str(c)) for c in getattr(Destinataer, 'konten_queryset', lambda: [])()]),
|
||||
"vierteljaehrlicher_betrag": forms.NumberInput(
|
||||
attrs={"class": "form-control", "step": "0.01"}
|
||||
),
|
||||
"studiennachweis_erforderlich": forms.CheckboxInput(
|
||||
attrs={"class": "form-check-input"}
|
||||
),
|
||||
"letzter_studiennachweis": forms.DateInput(
|
||||
attrs={"class": "form-control", "type": "date"}
|
||||
),
|
||||
"familienzweig": forms.Select(attrs={"class": "form-select"}),
|
||||
"berufsgruppe": forms.Select(attrs={"class": "form-select"}),
|
||||
}
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
for field_name, field in self.fields.items():
|
||||
if field_name not in ["vorname", "nachname"]:
|
||||
field.required = False
|
||||
# Set choices for familienzweig, berufsgruppe and anrede to match model
|
||||
self.fields["familienzweig"].choices = [("", "Bitte wählen...")] + list(Destinataer.FAMILIENZWIG_CHOICES)
|
||||
self.fields["berufsgruppe"].choices = [("", "Bitte wählen...")] + list(Destinataer.BERUFSGRUPPE_CHOICES)
|
||||
if "anrede" in self.fields:
|
||||
self.fields["anrede"].choices = [("", "Bitte wählen...")] + list(Destinataer.ANREDE_CHOICES)
|
||||
# Set choices for standard_konto to allow blank
|
||||
self.fields["standard_konto"].empty_label = "---"
|
||||
|
||||
|
||||
class DestinataerUnterstuetzungForm(forms.ModelForm):
|
||||
"""Form für geplante/ausgeführte Destinatärunterstützungen"""
|
||||
|
||||
class Meta:
|
||||
model = DestinataerUnterstuetzung
|
||||
fields = [
|
||||
"destinataer",
|
||||
"konto",
|
||||
"betrag",
|
||||
"faellig_am",
|
||||
"status",
|
||||
"beschreibung",
|
||||
"empfaenger_iban",
|
||||
"empfaenger_name",
|
||||
"verwendungszweck",
|
||||
]
|
||||
widgets = {
|
||||
"destinataer": forms.Select(attrs={"class": "form-select"}),
|
||||
"konto": forms.Select(attrs={"class": "form-select"}),
|
||||
"betrag": forms.NumberInput(
|
||||
attrs={"class": "form-control", "step": "0.01"}
|
||||
),
|
||||
"faellig_am": forms.DateInput(
|
||||
attrs={"class": "form-control", "type": "date"}
|
||||
),
|
||||
"status": forms.Select(attrs={"class": "form-select"}),
|
||||
"beschreibung": forms.TextInput(attrs={"class": "form-control"}),
|
||||
"empfaenger_iban": forms.TextInput(
|
||||
attrs={"class": "form-control", "placeholder": "DE89 3704 0044 0532 0130 00"}
|
||||
),
|
||||
"empfaenger_name": forms.TextInput(
|
||||
attrs={"class": "form-control", "placeholder": "Max Mustermann"}
|
||||
),
|
||||
"verwendungszweck": forms.TextInput(
|
||||
attrs={"class": "form-control", "placeholder": "Vierteljährliche Unterstützung Q1/2025"}
|
||||
),
|
||||
}
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
# Make faellig_am read-only for automatically generated quarterly payments
|
||||
self.is_auto_generated = False
|
||||
if self.instance and self.instance.pk and self.instance.beschreibung:
|
||||
if "Vierteljährliche Unterstützung" in self.instance.beschreibung and "(automatisch erstellt)" in self.instance.beschreibung:
|
||||
self.is_auto_generated = True
|
||||
|
||||
# Use a TextInput widget with readonly attribute to display the date
|
||||
from django import forms
|
||||
current_date = self.instance.faellig_am
|
||||
if current_date:
|
||||
self.fields['faellig_am'].widget = forms.TextInput(
|
||||
attrs={
|
||||
"class": "form-control",
|
||||
"readonly": True,
|
||||
"value": current_date.strftime('%d.%m.%Y'), # German date format
|
||||
"style": "background-color: #f8f9fa; cursor: not-allowed;"
|
||||
}
|
||||
)
|
||||
self.fields['faellig_am'].initial = current_date
|
||||
|
||||
self.fields['faellig_am'].help_text = "Fälligkeitsdatum wird automatisch basierend auf Quartal berechnet"
|
||||
|
||||
def clean(self):
|
||||
cleaned_data = super().clean()
|
||||
|
||||
# For auto-generated payments, preserve the original due date
|
||||
if self.is_auto_generated and self.instance and self.instance.pk:
|
||||
cleaned_data['faellig_am'] = self.instance.faellig_am
|
||||
|
||||
return cleaned_data
|
||||
|
||||
|
||||
class DestinataerNotizForm(forms.ModelForm):
|
||||
class Meta:
|
||||
model = DestinataerNotiz
|
||||
fields = ["titel", "text", "datei"]
|
||||
widgets = {
|
||||
"titel": forms.TextInput(
|
||||
attrs={
|
||||
"class": "form-control",
|
||||
"placeholder": "z.B. Telefonat vom 29.08.2025",
|
||||
}
|
||||
),
|
||||
"text": forms.Textarea(
|
||||
attrs={
|
||||
"class": "form-control",
|
||||
"rows": 5,
|
||||
"placeholder": "Notiztext...",
|
||||
}
|
||||
),
|
||||
"datei": forms.ClearableFileInput(attrs={"class": "form-control"}),
|
||||
}
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
# Make all fields optional
|
||||
self.fields["datei"].required = False
|
||||
self.fields["titel"].required = False
|
||||
self.fields["text"].required = False
|
||||
|
||||
def clean(self):
|
||||
cleaned = super().clean()
|
||||
titel = cleaned.get("titel", "").strip()
|
||||
text = cleaned.get("text", "").strip()
|
||||
if not (titel or text):
|
||||
raise forms.ValidationError(
|
||||
"Bitte geben Sie einen Titel oder einen Text ein."
|
||||
)
|
||||
return cleaned
|
||||
|
||||
|
||||
class UnterstuetzungForm(forms.ModelForm):
|
||||
"""Form für das Erstellen und Bearbeiten von Unterstützungen"""
|
||||
|
||||
# Special field for creating recurring payments
|
||||
ist_wiederkehrend = forms.BooleanField(
|
||||
required=False,
|
||||
label="Wiederkehrende Zahlung",
|
||||
help_text="Aktivieren Sie diese Option um automatisch wiederkehrende Zahlungen zu erstellen",
|
||||
)
|
||||
intervall = forms.ChoiceField(
|
||||
choices=[("", "--- Wählen Sie ein Intervall ---")]
|
||||
+ UnterstuetzungWiederkehrend.INTERVALL_CHOICES,
|
||||
required=False,
|
||||
widget=forms.Select(attrs={"class": "form-select"}),
|
||||
label="Zahlungsintervall",
|
||||
)
|
||||
letzte_zahlung_am = forms.DateField(
|
||||
required=False,
|
||||
widget=forms.DateInput(attrs={"class": "form-control", "type": "date"}),
|
||||
label="Letzte Zahlung am (optional)",
|
||||
help_text="Leer lassen für unbegrenzte Wiederholung",
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = DestinataerUnterstuetzung
|
||||
fields = [
|
||||
"destinataer",
|
||||
"konto",
|
||||
"faellig_am",
|
||||
"betrag",
|
||||
"status",
|
||||
"beschreibung",
|
||||
"empfaenger_iban",
|
||||
"empfaenger_name",
|
||||
"verwendungszweck",
|
||||
]
|
||||
|
||||
widgets = {
|
||||
"destinataer": forms.Select(attrs={"class": "form-select"}),
|
||||
"konto": forms.Select(attrs={"class": "form-select"}),
|
||||
"faellig_am": forms.DateInput(
|
||||
attrs={"class": "form-control", "type": "date"}
|
||||
),
|
||||
"betrag": forms.NumberInput(
|
||||
attrs={"class": "form-control", "step": "0.01"}
|
||||
),
|
||||
"status": forms.Select(attrs={"class": "form-select"}),
|
||||
"beschreibung": forms.TextInput(attrs={"class": "form-control"}),
|
||||
"empfaenger_iban": forms.TextInput(
|
||||
attrs={
|
||||
"class": "form-control",
|
||||
"placeholder": "DE89 3704 0044 0532 0130 00",
|
||||
}
|
||||
),
|
||||
"empfaenger_name": forms.TextInput(attrs={"class": "form-control"}),
|
||||
"verwendungszweck": forms.TextInput(
|
||||
attrs={"class": "form-control", "maxlength": "140"}
|
||||
),
|
||||
}
|
||||
|
||||
labels = {
|
||||
"destinataer": "Destinatär",
|
||||
"konto": "Zahlungskonto",
|
||||
"faellig_am": "Fällig am",
|
||||
"betrag": "Betrag (€)",
|
||||
"status": "Status",
|
||||
"beschreibung": "Beschreibung",
|
||||
"empfaenger_iban": "Empfänger IBAN",
|
||||
"empfaenger_name": "Empfänger Name",
|
||||
"verwendungszweck": "Verwendungszweck",
|
||||
}
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
# Add onchange event to destinataer field for AJAX IBAN fetching
|
||||
self.fields["destinataer"].widget.attrs["onchange"] = "updateDestinataerInfo()"
|
||||
|
||||
def clean(self):
|
||||
cleaned_data = super().clean()
|
||||
ist_wiederkehrend = cleaned_data.get("ist_wiederkehrend")
|
||||
intervall = cleaned_data.get("intervall")
|
||||
|
||||
if ist_wiederkehrend and not intervall:
|
||||
raise forms.ValidationError(
|
||||
"Bitte wählen Sie ein Zahlungsintervall für wiederkehrende Zahlungen."
|
||||
)
|
||||
|
||||
return cleaned_data
|
||||
|
||||
|
||||
class UnterstuetzungWiederkehrendForm(forms.ModelForm):
|
||||
"""Form für das Bearbeiten von wiederkehrenden Unterstützungsvorlagen"""
|
||||
|
||||
class Meta:
|
||||
model = UnterstuetzungWiederkehrend
|
||||
fields = [
|
||||
"destinataer",
|
||||
"konto",
|
||||
"betrag",
|
||||
"intervall",
|
||||
"beschreibung",
|
||||
"empfaenger_iban",
|
||||
"empfaenger_name",
|
||||
"verwendungszweck",
|
||||
"erste_zahlung_am",
|
||||
"letzte_zahlung_am",
|
||||
"aktiv",
|
||||
]
|
||||
|
||||
widgets = {
|
||||
"destinataer": forms.Select(attrs={"class": "form-select"}),
|
||||
"konto": forms.Select(attrs={"class": "form-select"}),
|
||||
"betrag": forms.NumberInput(
|
||||
attrs={"class": "form-control", "step": "0.01"}
|
||||
),
|
||||
"intervall": forms.Select(attrs={"class": "form-select"}),
|
||||
"beschreibung": forms.TextInput(attrs={"class": "form-control"}),
|
||||
"empfaenger_iban": forms.TextInput(
|
||||
attrs={
|
||||
"class": "form-control",
|
||||
"placeholder": "DE89 3704 0044 0532 0130 00",
|
||||
}
|
||||
),
|
||||
"empfaenger_name": forms.TextInput(attrs={"class": "form-control"}),
|
||||
"verwendungszweck": forms.TextInput(
|
||||
attrs={"class": "form-control", "maxlength": "140"}
|
||||
),
|
||||
"erste_zahlung_am": forms.DateInput(
|
||||
attrs={"class": "form-control", "type": "date"}
|
||||
),
|
||||
"letzte_zahlung_am": forms.DateInput(
|
||||
attrs={"class": "form-control", "type": "date"}
|
||||
),
|
||||
"aktiv": forms.CheckboxInput(attrs={"class": "form-check-input"}),
|
||||
}
|
||||
|
||||
|
||||
class UnterstuetzungMarkAsPaidForm(forms.Form):
|
||||
"""Simple form to mark an Unterstützung as paid"""
|
||||
|
||||
ausgezahlt_am = forms.DateField(
|
||||
widget=forms.DateInput(attrs={"class": "form-control", "type": "date"}),
|
||||
label="Ausgezahlt am",
|
||||
initial=timezone.now().date(),
|
||||
)
|
||||
|
||||
bemerkung = forms.CharField(
|
||||
widget=forms.Textarea(attrs={"class": "form-control", "rows": 3}),
|
||||
label="Bemerkung (optional)",
|
||||
required=False,
|
||||
help_text="Optionale Notiz zur Zahlung",
|
||||
)
|
||||
|
||||
|
||||
class VierteljahresNachweisForm(forms.ModelForm):
|
||||
"""Form for quarterly confirmations (Vierteljahresnachweise)"""
|
||||
|
||||
class Meta:
|
||||
model = VierteljahresNachweis
|
||||
fields = [
|
||||
'studiennachweis_eingereicht',
|
||||
'studiennachweis_datei',
|
||||
'studiennachweis_bemerkung',
|
||||
'einkommenssituation_bestaetigt',
|
||||
'einkommenssituation_text',
|
||||
'einkommenssituation_datei',
|
||||
'vermogenssituation_bestaetigt',
|
||||
'vermogenssituation_text',
|
||||
'vermogenssituation_datei',
|
||||
'weitere_dokumente',
|
||||
'weitere_dokumente_beschreibung',
|
||||
'interne_notizen',
|
||||
]
|
||||
|
||||
widgets = {
|
||||
'studiennachweis_eingereicht': forms.CheckboxInput(attrs={'class': 'form-check-input'}),
|
||||
'studiennachweis_datei': forms.FileInput(attrs={'class': 'form-control', 'accept': '.pdf,.jpg,.jpeg,.png,.doc,.docx'}),
|
||||
'studiennachweis_bemerkung': forms.Textarea(attrs={'class': 'form-control', 'rows': 3}),
|
||||
'einkommenssituation_bestaetigt': forms.CheckboxInput(attrs={'class': 'form-check-input'}),
|
||||
'einkommenssituation_text': forms.Textarea(attrs={'class': 'form-control', 'rows': 3, 'placeholder': 'Z.B. "Keine Änderungen seit letzter Meldung"'}),
|
||||
'einkommenssituation_datei': forms.FileInput(attrs={'class': 'form-control', 'accept': '.pdf,.jpg,.jpeg,.png,.doc,.docx'}),
|
||||
'vermogenssituation_bestaetigt': forms.CheckboxInput(attrs={'class': 'form-check-input'}),
|
||||
'vermogenssituation_text': forms.Textarea(attrs={'class': 'form-control', 'rows': 3, 'placeholder': 'Z.B. "Keine Änderungen seit letzter Meldung"'}),
|
||||
'vermogenssituation_datei': forms.FileInput(attrs={'class': 'form-control', 'accept': '.pdf,.jpg,.jpeg,.png,.doc,.docx'}),
|
||||
'weitere_dokumente': forms.FileInput(attrs={'class': 'form-control', 'accept': '.pdf,.jpg,.jpeg,.png,.doc,.docx'}),
|
||||
'weitere_dokumente_beschreibung': forms.Textarea(attrs={'class': 'form-control', 'rows': 2}),
|
||||
'interne_notizen': forms.Textarea(attrs={'class': 'form-control', 'rows': 3}),
|
||||
}
|
||||
|
||||
labels = {
|
||||
'studiennachweis_erforderlich': 'Studiennachweis erforderlich',
|
||||
'studiennachweis_eingereicht': 'Studiennachweis eingereicht',
|
||||
'studiennachweis_datei': 'Studiennachweis (Datei)',
|
||||
'studiennachweis_bemerkung': 'Bemerkung zum Studiennachweis',
|
||||
'einkommenssituation_bestaetigt': 'Einkommenssituation bestätigt',
|
||||
'einkommenssituation_text': 'Einkommenssituation (Text)',
|
||||
'einkommenssituation_datei': 'Einkommenssituation (Datei)',
|
||||
'vermogenssituation_bestaetigt': 'Vermögenssituation bestätigt',
|
||||
'vermogenssituation_text': 'Vermögenssituation (Text)',
|
||||
'vermogenssituation_datei': 'Vermögenssituation (Datei)',
|
||||
'weitere_dokumente': 'Weitere Dokumente',
|
||||
'weitere_dokumente_beschreibung': 'Beschreibung weitere Dokumente',
|
||||
'interne_notizen': 'Interne Notizen (nur für Verwaltung)',
|
||||
}
|
||||
|
||||
help_texts = {
|
||||
'einkommenssituation_text': 'Z.B. "Keine Änderungen seit letzter Meldung" oder Details zu Änderungen',
|
||||
'vermogenssituation_text': 'Z.B. "Keine Änderungen seit letzter Meldung" oder Details zu Änderungen',
|
||||
'interne_notizen': 'Diese Notizen sind nur für die interne Verwaltung sichtbar',
|
||||
}
|
||||
|
||||
def clean(self):
|
||||
cleaned_data = super().clean()
|
||||
|
||||
# Validate that at least one form of confirmation is provided for income situation
|
||||
einkommenssituation_text = cleaned_data.get('einkommenssituation_text')
|
||||
einkommenssituation_datei = cleaned_data.get('einkommenssituation_datei')
|
||||
einkommenssituation_bestaetigt = cleaned_data.get('einkommenssituation_bestaetigt')
|
||||
|
||||
# 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, eine Datei oder ein DMS-Dokument angegeben werden.'
|
||||
)
|
||||
|
||||
# Validate that at least one form of confirmation is provided for asset situation
|
||||
vermogenssituation_text = cleaned_data.get('vermogenssituation_text')
|
||||
vermogenssituation_datei = cleaned_data.get('vermogenssituation_datei')
|
||||
vermogenssituation_bestaetigt = cleaned_data.get('vermogenssituation_bestaetigt')
|
||||
|
||||
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, eine Datei oder ein DMS-Dokument angegeben werden.'
|
||||
)
|
||||
|
||||
# Validate study proof if required and marked as submitted
|
||||
studiennachweis_erforderlich = cleaned_data.get('studiennachweis_erforderlich')
|
||||
studiennachweis_eingereicht = cleaned_data.get('studiennachweis_eingereicht')
|
||||
studiennachweis_datei = cleaned_data.get('studiennachweis_datei')
|
||||
studiennachweis_bemerkung = cleaned_data.get('studiennachweis_bemerkung')
|
||||
|
||||
if studiennachweis_erforderlich and studiennachweis_eingereicht:
|
||||
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, eine Bemerkung oder ein DMS-Dokument angegeben werden.'
|
||||
)
|
||||
|
||||
return cleaned_data
|
||||
19
app/stiftung/forms/dokumente.py
Normal file
19
app/stiftung/forms/dokumente.py
Normal file
@@ -0,0 +1,19 @@
|
||||
from django import forms
|
||||
|
||||
from ..models import DokumentLink
|
||||
|
||||
|
||||
class DokumentLinkForm(forms.ModelForm):
|
||||
"""Form für das Erstellen und Bearbeiten von Dokumentverknüpfungen"""
|
||||
|
||||
class Meta:
|
||||
model = DokumentLink
|
||||
fields = "__all__"
|
||||
widgets = {
|
||||
"paperless_id": forms.NumberInput(attrs={"class": "form-control"}),
|
||||
"content_type": forms.Select(attrs={"class": "form-select"}),
|
||||
"object_id": forms.TextInput(attrs={"class": "form-control"}),
|
||||
"verknuepft_am": forms.DateTimeInput(
|
||||
attrs={"class": "form-control", "type": "datetime-local"}
|
||||
),
|
||||
}
|
||||
351
app/stiftung/forms/finanzen.py
Normal file
351
app/stiftung/forms/finanzen.py
Normal file
@@ -0,0 +1,351 @@
|
||||
import re
|
||||
|
||||
from django import forms
|
||||
from django.core.exceptions import ValidationError
|
||||
|
||||
from ..models import BankTransaction, Rentmeister, StiftungsKonto, Verwaltungskosten
|
||||
|
||||
|
||||
class RentmeisterForm(forms.ModelForm):
|
||||
"""Form für das Erstellen und Bearbeiten von Rentmeistern"""
|
||||
|
||||
class Meta:
|
||||
model = Rentmeister
|
||||
fields = [
|
||||
"anrede",
|
||||
"vorname",
|
||||
"nachname",
|
||||
"titel",
|
||||
"email",
|
||||
"telefon",
|
||||
"mobil",
|
||||
"strasse",
|
||||
"plz",
|
||||
"ort",
|
||||
"iban",
|
||||
"bic",
|
||||
"bank_name",
|
||||
"seit_datum",
|
||||
"bis_datum",
|
||||
"aktiv",
|
||||
"monatliche_verguetung",
|
||||
"km_pauschale",
|
||||
"notizen",
|
||||
]
|
||||
|
||||
widgets = {
|
||||
"anrede": forms.Select(attrs={"class": "form-select"}),
|
||||
"vorname": forms.TextInput(attrs={"class": "form-control"}),
|
||||
"nachname": forms.TextInput(attrs={"class": "form-control"}),
|
||||
"titel": forms.TextInput(attrs={"class": "form-control"}),
|
||||
"email": forms.EmailInput(attrs={"class": "form-control"}),
|
||||
"telefon": forms.TextInput(attrs={"class": "form-control"}),
|
||||
"mobil": forms.TextInput(attrs={"class": "form-control"}),
|
||||
"strasse": forms.TextInput(attrs={"class": "form-control"}),
|
||||
"plz": forms.TextInput(attrs={"class": "form-control"}),
|
||||
"ort": forms.TextInput(attrs={"class": "form-control"}),
|
||||
"iban": forms.TextInput(
|
||||
attrs={"class": "form-control", "placeholder": "DE89370400440532013000"}
|
||||
),
|
||||
"bic": forms.TextInput(
|
||||
attrs={"class": "form-control", "placeholder": "COBADEFFXXX"}
|
||||
),
|
||||
"bank_name": forms.TextInput(attrs={"class": "form-control"}),
|
||||
"seit_datum": forms.DateInput(
|
||||
attrs={"class": "form-control", "type": "date"}
|
||||
),
|
||||
"bis_datum": forms.DateInput(
|
||||
attrs={"class": "form-control", "type": "date"}
|
||||
),
|
||||
"aktiv": forms.CheckboxInput(attrs={"class": "form-check-input"}),
|
||||
"monatliche_verguetung": forms.NumberInput(
|
||||
attrs={"class": "form-control", "step": "0.01"}
|
||||
),
|
||||
"km_pauschale": forms.NumberInput(
|
||||
attrs={"class": "form-control", "step": "0.01", "value": "0.30"}
|
||||
),
|
||||
"notizen": forms.Textarea(attrs={"class": "form-control", "rows": 4}),
|
||||
}
|
||||
|
||||
labels = {
|
||||
"anrede": "Anrede",
|
||||
"vorname": "Vorname *",
|
||||
"nachname": "Nachname *",
|
||||
"titel": "Titel",
|
||||
"email": "E-Mail",
|
||||
"telefon": "Telefon",
|
||||
"mobil": "Mobil",
|
||||
"strasse": "Straße",
|
||||
"plz": "PLZ",
|
||||
"ort": "Ort",
|
||||
"iban": "IBAN",
|
||||
"bic": "BIC",
|
||||
"bank_name": "Bank",
|
||||
"seit_datum": "Rentmeister seit *",
|
||||
"bis_datum": "Rentmeister bis",
|
||||
"aktiv": "Aktiv",
|
||||
"monatliche_verguetung": "Monatliche Vergütung (€)",
|
||||
"km_pauschale": "Kilometerpauschale (€/km)",
|
||||
"notizen": "Notizen",
|
||||
}
|
||||
|
||||
help_texts = {
|
||||
"iban": "Internationale Bankkontonummer für Abrechnungen",
|
||||
"km_pauschale": "Standard: 0,30 € pro Kilometer",
|
||||
"seit_datum": "Datum des Amtsantritts als Rentmeister",
|
||||
"bis_datum": "Leer lassen für aktive Rentmeister",
|
||||
}
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
# Markiere Pflichtfelder
|
||||
self.fields["vorname"].required = True
|
||||
self.fields["nachname"].required = True
|
||||
self.fields["seit_datum"].required = True
|
||||
|
||||
def clean_iban(self):
|
||||
"""Validierung der IBAN"""
|
||||
iban = self.cleaned_data.get("iban")
|
||||
if iban:
|
||||
# Entferne Leerzeichen und konvertiere zu Großbuchstaben
|
||||
iban = re.sub(r"\s+", "", iban.upper())
|
||||
|
||||
# Einfache IBAN-Längenvalidierung für deutsche IBANs
|
||||
if iban.startswith("DE") and len(iban) != 22:
|
||||
raise ValidationError("Deutsche IBANs müssen 22 Zeichen lang sein.")
|
||||
|
||||
# Speichere die bereinigte IBAN
|
||||
return iban
|
||||
return iban
|
||||
|
||||
def clean_plz(self):
|
||||
"""Validierung der PLZ"""
|
||||
plz = self.cleaned_data.get("plz")
|
||||
if plz and not re.match(r"^\d{5}$", plz):
|
||||
raise ValidationError("PLZ muss aus 5 Ziffern bestehen.")
|
||||
return plz
|
||||
|
||||
def clean(self):
|
||||
"""Übergreifende Validierung"""
|
||||
from django.utils.dateparse import parse_date
|
||||
|
||||
cleaned_data = super().clean()
|
||||
seit_datum = cleaned_data.get("seit_datum")
|
||||
bis_datum = cleaned_data.get("bis_datum")
|
||||
|
||||
# Helper function to ensure we have date objects
|
||||
def ensure_date(date_value):
|
||||
if not date_value:
|
||||
return None
|
||||
if isinstance(date_value, str):
|
||||
return parse_date(date_value)
|
||||
return date_value
|
||||
|
||||
# Convert to date objects if they're strings
|
||||
seit_datum = ensure_date(seit_datum)
|
||||
bis_datum = ensure_date(bis_datum)
|
||||
|
||||
# Prüfe Datum-Logik
|
||||
if seit_datum and bis_datum and bis_datum <= seit_datum:
|
||||
raise ValidationError("Das End-Datum muss nach dem Start-Datum liegen.")
|
||||
|
||||
return cleaned_data
|
||||
|
||||
|
||||
class StiftungsKontoForm(forms.ModelForm):
|
||||
"""Form für das Erstellen und Bearbeiten von Stiftungskonten"""
|
||||
|
||||
class Meta:
|
||||
model = StiftungsKonto
|
||||
fields = [
|
||||
"kontoname",
|
||||
"bank_name",
|
||||
"iban",
|
||||
"bic",
|
||||
"konto_typ",
|
||||
"saldo",
|
||||
"saldo_datum",
|
||||
"zinssatz",
|
||||
"laufzeit_bis",
|
||||
"aktiv",
|
||||
"notizen",
|
||||
]
|
||||
|
||||
widgets = {
|
||||
"kontoname": forms.TextInput(attrs={"class": "form-control"}),
|
||||
"bank_name": forms.TextInput(attrs={"class": "form-control"}),
|
||||
"iban": forms.TextInput(
|
||||
attrs={"class": "form-control", "placeholder": "DE89370400440532013000"}
|
||||
),
|
||||
"bic": forms.TextInput(
|
||||
attrs={"class": "form-control", "placeholder": "COBADEFFXXX"}
|
||||
),
|
||||
"konto_typ": forms.Select(attrs={"class": "form-select"}),
|
||||
"saldo": forms.NumberInput(attrs={"class": "form-control", "step": "0.01"}),
|
||||
"saldo_datum": forms.DateInput(
|
||||
attrs={"class": "form-control", "type": "date"}
|
||||
),
|
||||
"zinssatz": forms.NumberInput(
|
||||
attrs={"class": "form-control", "step": "0.01"}
|
||||
),
|
||||
"laufzeit_bis": forms.DateInput(
|
||||
attrs={"class": "form-control", "type": "date"}
|
||||
),
|
||||
"aktiv": forms.CheckboxInput(attrs={"class": "form-check-input"}),
|
||||
"notizen": forms.Textarea(attrs={"class": "form-control", "rows": 3}),
|
||||
}
|
||||
|
||||
|
||||
class VerwaltungskostenForm(forms.ModelForm):
|
||||
"""Form für das Erstellen und Bearbeiten von Verwaltungskosten"""
|
||||
|
||||
class Meta:
|
||||
model = Verwaltungskosten
|
||||
fields = [
|
||||
"bezeichnung",
|
||||
"kategorie",
|
||||
"betrag",
|
||||
"datum",
|
||||
"status",
|
||||
"rentmeister",
|
||||
"zahlungskonto",
|
||||
"quellkonto",
|
||||
"lieferant_firma",
|
||||
"rechnungsnummer",
|
||||
"km_anzahl",
|
||||
"km_satz",
|
||||
"von_ort",
|
||||
"nach_ort",
|
||||
"zweck",
|
||||
"beschreibung",
|
||||
"notizen",
|
||||
]
|
||||
|
||||
widgets = {
|
||||
"bezeichnung": forms.TextInput(attrs={"class": "form-control"}),
|
||||
"kategorie": forms.Select(attrs={"class": "form-select"}),
|
||||
"betrag": forms.NumberInput(
|
||||
attrs={"class": "form-control", "step": "0.01"}
|
||||
),
|
||||
"datum": forms.DateInput(attrs={"class": "form-control", "type": "date"}),
|
||||
"status": forms.Select(attrs={"class": "form-select"}),
|
||||
"rentmeister": forms.Select(attrs={"class": "form-select"}),
|
||||
"zahlungskonto": forms.Select(attrs={"class": "form-select"}),
|
||||
"quellkonto": forms.Select(attrs={"class": "form-select"}),
|
||||
"lieferant_firma": forms.TextInput(attrs={"class": "form-control"}),
|
||||
"rechnungsnummer": forms.TextInput(attrs={"class": "form-control"}),
|
||||
"km_anzahl": forms.NumberInput(
|
||||
attrs={"class": "form-control", "step": "0.1"}
|
||||
),
|
||||
"km_satz": forms.NumberInput(
|
||||
attrs={"class": "form-control", "step": "0.01"}
|
||||
),
|
||||
"von_ort": forms.TextInput(attrs={"class": "form-control"}),
|
||||
"nach_ort": forms.TextInput(attrs={"class": "form-control"}),
|
||||
"zweck": forms.TextInput(attrs={"class": "form-control"}),
|
||||
"beschreibung": forms.Textarea(attrs={"class": "form-control", "rows": 3}),
|
||||
"notizen": forms.Textarea(attrs={"class": "form-control", "rows": 2}),
|
||||
}
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
# Filtere nur aktive Rentmeister und Konten
|
||||
self.fields["rentmeister"].queryset = Rentmeister.objects.filter(aktiv=True)
|
||||
self.fields["zahlungskonto"].queryset = StiftungsKonto.objects.filter(
|
||||
aktiv=True
|
||||
)
|
||||
self.fields["quellkonto"].queryset = StiftungsKonto.objects.filter(aktiv=True)
|
||||
|
||||
# Standardwerte setzen
|
||||
if not self.instance.pk: # Nur bei neuen Objekten
|
||||
# Standard km_satz auf 0.30 Euro setzen
|
||||
self.fields["km_satz"].initial = 0.30
|
||||
|
||||
|
||||
class BankTransactionForm(forms.ModelForm):
|
||||
"""Form für das Bearbeiten von Banktransaktionen"""
|
||||
|
||||
class Meta:
|
||||
model = BankTransaction
|
||||
fields = [
|
||||
"konto",
|
||||
"datum",
|
||||
"valuta",
|
||||
"betrag",
|
||||
"waehrung",
|
||||
"verwendungszweck",
|
||||
"empfaenger_zahlungspflichtiger",
|
||||
"iban_gegenpartei",
|
||||
"bic_gegenpartei",
|
||||
"transaction_type",
|
||||
"status",
|
||||
"kommentare",
|
||||
"verwaltungskosten",
|
||||
]
|
||||
|
||||
widgets = {
|
||||
"konto": forms.Select(attrs={"class": "form-select"}),
|
||||
"datum": forms.DateInput(attrs={"class": "form-control", "type": "date"}),
|
||||
"valuta": forms.DateInput(attrs={"class": "form-control", "type": "date"}),
|
||||
"betrag": forms.NumberInput(
|
||||
attrs={"class": "form-control", "step": "0.01"}
|
||||
),
|
||||
"waehrung": forms.TextInput(attrs={"class": "form-control"}),
|
||||
"verwendungszweck": forms.Textarea(
|
||||
attrs={"class": "form-control", "rows": 3}
|
||||
),
|
||||
"empfaenger_zahlungspflichtiger": forms.TextInput(
|
||||
attrs={"class": "form-control"}
|
||||
),
|
||||
"iban_gegenpartei": forms.TextInput(attrs={"class": "form-control"}),
|
||||
"bic_gegenpartei": forms.TextInput(attrs={"class": "form-control"}),
|
||||
"transaction_type": forms.Select(attrs={"class": "form-select"}),
|
||||
"status": forms.Select(attrs={"class": "form-select"}),
|
||||
"kommentare": forms.Textarea(attrs={"class": "form-control", "rows": 4}),
|
||||
"verwaltungskosten": forms.Select(attrs={"class": "form-select"}),
|
||||
}
|
||||
|
||||
|
||||
class BankImportForm(forms.Form):
|
||||
"""Form für den Import von Bankdaten"""
|
||||
|
||||
konto = forms.ModelChoiceField(
|
||||
queryset=StiftungsKonto.objects.filter(aktiv=True),
|
||||
widget=forms.Select(attrs={"class": "form-select"}),
|
||||
label="Zielkonto",
|
||||
)
|
||||
|
||||
datei = forms.FileField(
|
||||
widget=forms.FileInput(attrs={"class": "form-control", "accept": ".csv,.txt"}),
|
||||
label="Bankdatei",
|
||||
help_text="Unterstützte Formate: CSV, TXT (Sparkasse, Volksbank, etc.)",
|
||||
)
|
||||
|
||||
encoding = forms.ChoiceField(
|
||||
choices=[
|
||||
("utf-8", "UTF-8"),
|
||||
("latin1", "Latin-1 / ISO-8859-1"),
|
||||
("cp1252", "Windows-1252"),
|
||||
],
|
||||
initial="utf-8",
|
||||
widget=forms.Select(attrs={"class": "form-select"}),
|
||||
label="Zeichenkodierung",
|
||||
)
|
||||
|
||||
delimiter = forms.ChoiceField(
|
||||
choices=[
|
||||
(";", "Semikolon (;)"),
|
||||
(",", "Komma (,)"),
|
||||
("\t", "Tab"),
|
||||
],
|
||||
initial=";",
|
||||
widget=forms.Select(attrs={"class": "form-select"}),
|
||||
label="Trennzeichen",
|
||||
)
|
||||
|
||||
skip_header = forms.BooleanField(
|
||||
initial=True,
|
||||
required=False,
|
||||
widget=forms.CheckboxInput(attrs={"class": "form-check-input"}),
|
||||
label="Erste Zeile überspringen (Spaltenüberschriften)",
|
||||
)
|
||||
73
app/stiftung/forms/foerderung.py
Normal file
73
app/stiftung/forms/foerderung.py
Normal file
@@ -0,0 +1,73 @@
|
||||
from django import forms
|
||||
|
||||
from ..models import Destinataer, DokumentLink, Foerderung
|
||||
|
||||
|
||||
class FoerderungForm(forms.ModelForm):
|
||||
"""Form für das Erstellen und Bearbeiten von Förderungen"""
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
# Add empty option for optional fields
|
||||
self.fields["verwendungsnachweis"].empty_label = (
|
||||
"--- Kein Dokument verknüpfen ---"
|
||||
)
|
||||
# Ensure destinataer has proper choices
|
||||
from django.utils import timezone
|
||||
|
||||
from ..models import Destinataer, DokumentLink
|
||||
|
||||
self.fields["destinataer"].queryset = Destinataer.objects.all().order_by(
|
||||
"nachname", "vorname"
|
||||
)
|
||||
self.fields["verwendungsnachweis"].queryset = (
|
||||
DokumentLink.objects.all().order_by("titel")
|
||||
)
|
||||
# Set current year as default for new forms
|
||||
if not self.instance.pk:
|
||||
self.fields["jahr"].initial = timezone.now().year
|
||||
|
||||
class Meta:
|
||||
model = Foerderung
|
||||
fields = [
|
||||
"destinataer",
|
||||
"jahr",
|
||||
"betrag",
|
||||
"kategorie",
|
||||
"status",
|
||||
"antragsdatum",
|
||||
"entscheidungsdatum",
|
||||
"verwendungsnachweis",
|
||||
"bemerkungen",
|
||||
]
|
||||
widgets = {
|
||||
"destinataer": forms.Select(attrs={"class": "form-select"}),
|
||||
"jahr": forms.NumberInput(attrs={"class": "form-control"}),
|
||||
"betrag": forms.NumberInput(
|
||||
attrs={"class": "form-control", "step": "0.01"}
|
||||
),
|
||||
"kategorie": forms.Select(attrs={"class": "form-select"}),
|
||||
"status": forms.Select(attrs={"class": "form-select"}),
|
||||
"antragsdatum": forms.DateInput(
|
||||
attrs={"class": "form-control", "type": "date"}
|
||||
),
|
||||
"entscheidungsdatum": forms.DateInput(
|
||||
attrs={"class": "form-control", "type": "date"}
|
||||
),
|
||||
"verwendungsnachweis": forms.Select(attrs={"class": "form-select"}),
|
||||
"bemerkungen": forms.Textarea(attrs={"class": "form-control", "rows": 3}),
|
||||
}
|
||||
|
||||
labels = {
|
||||
"destinataer": "Destinatär",
|
||||
"verwendungsnachweis": "Verknüpftes Dokument",
|
||||
"bemerkungen": "Bemerkungen/Beschreibung",
|
||||
"antragsdatum": "Antragsdatum",
|
||||
"entscheidungsdatum": "Entscheidungsdatum",
|
||||
}
|
||||
|
||||
help_texts = {
|
||||
"verwendungsnachweis": "Optionale Verknüpfung zu einem Dokument aus dem Paperless-System",
|
||||
"entscheidungsdatum": "Datum der Bewilligung/Ablehnung (optional)",
|
||||
"bemerkungen": "Zusätzliche Informationen zur Förderung",
|
||||
}
|
||||
107
app/stiftung/forms/geschichte.py
Normal file
107
app/stiftung/forms/geschichte.py
Normal file
@@ -0,0 +1,107 @@
|
||||
from django import forms
|
||||
|
||||
from ..models import GeschichteBild, GeschichteSeite
|
||||
|
||||
|
||||
class GeschichteSeiteForm(forms.ModelForm):
|
||||
"""Form for creating and editing history pages"""
|
||||
|
||||
class Meta:
|
||||
from ..models import GeschichteSeite
|
||||
model = GeschichteSeite
|
||||
fields = ['titel', 'slug', 'inhalt', 'ist_veroeffentlicht', 'sortierung']
|
||||
widgets = {
|
||||
'titel': forms.TextInput(attrs={
|
||||
'class': 'form-control',
|
||||
'placeholder': 'z.B. Gründung der Stiftung'
|
||||
}),
|
||||
'slug': forms.TextInput(attrs={
|
||||
'class': 'form-control',
|
||||
'placeholder': 'z.B. gruendung-der-stiftung'
|
||||
}),
|
||||
'inhalt': forms.Textarea(attrs={
|
||||
'class': 'form-control rich-text-editor',
|
||||
'rows': 20,
|
||||
'placeholder': 'Schreiben Sie hier den Inhalt der Geschichtsseite...'
|
||||
}),
|
||||
'ist_veroeffentlicht': forms.CheckboxInput(attrs={
|
||||
'class': 'form-check-input'
|
||||
}),
|
||||
'sortierung': forms.NumberInput(attrs={
|
||||
'class': 'form-control',
|
||||
'min': 0
|
||||
})
|
||||
}
|
||||
help_texts = {
|
||||
'slug': 'URL-freundliche Version des Titels (nur Buchstaben, Zahlen und Bindestriche)',
|
||||
'inhalt': 'Unterstützt Rich-Text-Formatierung, Bilder und Videos',
|
||||
'sortierung': 'Niedrigere Zahlen erscheinen zuerst in der Navigation'
|
||||
}
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
# Auto-generate slug from title if not provided
|
||||
if not self.instance.pk:
|
||||
self.fields['slug'].required = False
|
||||
|
||||
def clean_slug(self):
|
||||
slug = self.cleaned_data.get('slug')
|
||||
titel = self.cleaned_data.get('titel', '')
|
||||
|
||||
if not slug and titel:
|
||||
# Auto-generate slug from title
|
||||
from django.utils.text import slugify
|
||||
slug = slugify(titel)
|
||||
|
||||
if not slug:
|
||||
raise forms.ValidationError('Slug ist erforderlich. Bitte geben Sie einen Titel ein.')
|
||||
|
||||
return slug
|
||||
|
||||
def clean(self):
|
||||
cleaned_data = super().clean()
|
||||
titel = cleaned_data.get('titel', '')
|
||||
slug = cleaned_data.get('slug', '')
|
||||
|
||||
# Auto-generate slug if empty
|
||||
if titel and not slug:
|
||||
from django.utils.text import slugify
|
||||
cleaned_data['slug'] = slugify(titel)
|
||||
|
||||
return cleaned_data
|
||||
|
||||
|
||||
class GeschichteBildForm(forms.ModelForm):
|
||||
"""Form for uploading images to history pages"""
|
||||
|
||||
class Meta:
|
||||
from ..models import GeschichteBild
|
||||
model = GeschichteBild
|
||||
fields = ['titel', 'bild', 'beschreibung', 'alt_text', 'sortierung']
|
||||
widgets = {
|
||||
'titel': forms.TextInput(attrs={
|
||||
'class': 'form-control',
|
||||
'placeholder': 'z.B. Gründungsurkunde 1895'
|
||||
}),
|
||||
'bild': forms.ClearableFileInput(attrs={
|
||||
'class': 'form-control'
|
||||
}),
|
||||
'beschreibung': forms.Textarea(attrs={
|
||||
'class': 'form-control',
|
||||
'rows': 3,
|
||||
'placeholder': 'Beschreibung des Bildes...'
|
||||
}),
|
||||
'alt_text': forms.TextInput(attrs={
|
||||
'class': 'form-control',
|
||||
'placeholder': 'Alternativtext für Bildschirmleser'
|
||||
}),
|
||||
'sortierung': forms.NumberInput(attrs={
|
||||
'class': 'form-control',
|
||||
'min': 0
|
||||
})
|
||||
}
|
||||
help_texts = {
|
||||
'bild': 'Unterstützte Formate: JPG, PNG, GIF (max. 10MB)',
|
||||
'alt_text': 'Wichtig für Barrierefreiheit',
|
||||
'sortierung': 'Reihenfolge in der Bildergalerie'
|
||||
}
|
||||
294
app/stiftung/forms/land.py
Normal file
294
app/stiftung/forms/land.py
Normal file
@@ -0,0 +1,294 @@
|
||||
from django import forms
|
||||
|
||||
from ..models import Land, LandAbrechnung, LandVerpachtung, Paechter
|
||||
|
||||
|
||||
class LandForm(forms.ModelForm):
|
||||
"""Form für das Erstellen und Bearbeiten von Ländern"""
|
||||
|
||||
class Meta:
|
||||
model = Land
|
||||
fields = [
|
||||
# Grundlegende Identifikation
|
||||
"lfd_nr",
|
||||
"ew_nummer",
|
||||
"grundbuchblatt",
|
||||
# Gerichtliche Zuständigkeit
|
||||
"amtsgericht",
|
||||
# Verwaltungsstruktur
|
||||
"gemeinde",
|
||||
"gemarkung",
|
||||
"flur",
|
||||
"flurstueck",
|
||||
"adresse",
|
||||
"alkis_kennzeichen",
|
||||
# Flächenangaben
|
||||
"groesse_qm",
|
||||
"gruenland_qm",
|
||||
"acker_qm",
|
||||
"wald_qm",
|
||||
"sonstiges_qm",
|
||||
# Legacy Verpachtung (für Kompatibilität)
|
||||
"verpachtete_gesamtflaeche",
|
||||
"flaeche_alte_liste",
|
||||
"verp_flaeche_aktuell",
|
||||
# Aktuelle Verpachtung
|
||||
"aktueller_paechter",
|
||||
"paechter_name",
|
||||
"paechter_anschrift",
|
||||
"pachtbeginn",
|
||||
"pachtende",
|
||||
"verlaengerung_klausel",
|
||||
"zahlungsweise",
|
||||
"pachtzins_pro_ha",
|
||||
"pachtzins_pauschal",
|
||||
# Umsatzsteuer
|
||||
"ust_option",
|
||||
"ust_satz",
|
||||
# Umlagen
|
||||
"grundsteuer_umlage",
|
||||
"versicherungen_umlage",
|
||||
"verbandsbeitraege_umlage",
|
||||
"jagdpacht_anteil_umlage",
|
||||
# Legacy Steuern
|
||||
"anteil_grundsteuer",
|
||||
"anteil_lwk",
|
||||
# Status
|
||||
"aktiv",
|
||||
"notizen",
|
||||
]
|
||||
widgets = {
|
||||
# Grundlegende Identifikation
|
||||
"lfd_nr": forms.TextInput(attrs={"class": "form-control"}),
|
||||
"ew_nummer": forms.TextInput(attrs={"class": "form-control"}),
|
||||
"grundbuchblatt": forms.TextInput(attrs={"class": "form-control"}),
|
||||
# Gerichtliche Zuständigkeit
|
||||
"amtsgericht": forms.TextInput(attrs={"class": "form-control"}),
|
||||
# Verwaltungsstruktur
|
||||
"gemeinde": forms.TextInput(attrs={"class": "form-control"}),
|
||||
"gemarkung": forms.TextInput(attrs={"class": "form-control"}),
|
||||
"flur": forms.TextInput(attrs={"class": "form-control"}),
|
||||
"flurstueck": forms.TextInput(attrs={"class": "form-control"}),
|
||||
"adresse": forms.TextInput(attrs={"class": "form-control"}),
|
||||
# Flächenangaben
|
||||
"groesse_qm": forms.NumberInput(
|
||||
attrs={"class": "form-control", "step": "0.01"}
|
||||
),
|
||||
"gruenland_qm": forms.NumberInput(
|
||||
attrs={"class": "form-control", "step": "0.01"}
|
||||
),
|
||||
"acker_qm": forms.NumberInput(
|
||||
attrs={"class": "form-control", "step": "0.01"}
|
||||
),
|
||||
"wald_qm": forms.NumberInput(
|
||||
attrs={"class": "form-control", "step": "0.01"}
|
||||
),
|
||||
"sonstiges_qm": forms.NumberInput(
|
||||
attrs={"class": "form-control", "step": "0.01"}
|
||||
),
|
||||
# Legacy Verpachtung
|
||||
"verpachtete_gesamtflaeche": forms.NumberInput(
|
||||
attrs={"class": "form-control", "step": "0.01"}
|
||||
),
|
||||
"flaeche_alte_liste": forms.NumberInput(
|
||||
attrs={"class": "form-control", "step": "0.01"}
|
||||
),
|
||||
"verp_flaeche_aktuell": forms.NumberInput(
|
||||
attrs={"class": "form-control", "step": "0.01"}
|
||||
),
|
||||
# Aktuelle Verpachtung
|
||||
"aktueller_paechter": forms.Select(attrs={"class": "form-select"}),
|
||||
"paechter_name": forms.TextInput(attrs={"class": "form-control"}),
|
||||
"paechter_anschrift": forms.Textarea(
|
||||
attrs={"class": "form-control", "rows": 3}
|
||||
),
|
||||
"pachtbeginn": forms.DateInput(
|
||||
attrs={"class": "form-control", "type": "date"}
|
||||
),
|
||||
"pachtende": forms.DateInput(
|
||||
attrs={"class": "form-control", "type": "date"}
|
||||
),
|
||||
"verlaengerung_klausel": forms.CheckboxInput(
|
||||
attrs={"class": "form-check-input"}
|
||||
),
|
||||
"zahlungsweise": forms.Select(attrs={"class": "form-select"}),
|
||||
"pachtzins_pro_ha": forms.NumberInput(
|
||||
attrs={"class": "form-control", "step": "0.01"}
|
||||
),
|
||||
"pachtzins_pauschal": forms.NumberInput(
|
||||
attrs={"class": "form-control", "step": "0.01"}
|
||||
),
|
||||
# Umsatzsteuer
|
||||
"ust_option": forms.CheckboxInput(attrs={"class": "form-check-input"}),
|
||||
"ust_satz": forms.NumberInput(
|
||||
attrs={"class": "form-control", "step": "0.01"}
|
||||
),
|
||||
# Umlagen
|
||||
"grundsteuer_umlage": forms.CheckboxInput(
|
||||
attrs={"class": "form-check-input"}
|
||||
),
|
||||
"versicherungen_umlage": forms.CheckboxInput(
|
||||
attrs={"class": "form-check-input"}
|
||||
),
|
||||
"verbandsbeitraege_umlage": forms.CheckboxInput(
|
||||
attrs={"class": "form-check-input"}
|
||||
),
|
||||
"jagdpacht_anteil_umlage": forms.CheckboxInput(
|
||||
attrs={"class": "form-check-input"}
|
||||
),
|
||||
# Legacy
|
||||
"anteil_grundsteuer": forms.NumberInput(
|
||||
attrs={"class": "form-control", "step": "0.01"}
|
||||
),
|
||||
"anteil_lwk": forms.NumberInput(
|
||||
attrs={"class": "form-control", "step": "0.01"}
|
||||
),
|
||||
# Status
|
||||
"aktiv": forms.CheckboxInput(attrs={"class": "form-check-input"}),
|
||||
"notizen": forms.Textarea(attrs={"class": "form-control", "rows": 3}),
|
||||
}
|
||||
|
||||
|
||||
class LandVerpachtungForm(forms.ModelForm):
|
||||
"""Form für das Erstellen und Bearbeiten von Verpachtungen"""
|
||||
|
||||
class Meta:
|
||||
model = LandVerpachtung
|
||||
fields = [
|
||||
'land',
|
||||
'paechter',
|
||||
'vertragsnummer',
|
||||
'pachtbeginn',
|
||||
'pachtende',
|
||||
'verlaengerung_klausel',
|
||||
'verpachtete_flaeche',
|
||||
'pachtzins_pauschal',
|
||||
'pachtzins_pro_ha',
|
||||
'zahlungsweise',
|
||||
'ust_option',
|
||||
'ust_satz',
|
||||
'grundsteuer_umlage',
|
||||
'versicherungen_umlage',
|
||||
'verbandsbeitraege_umlage',
|
||||
'jagdpacht_anteil_umlage',
|
||||
'status',
|
||||
'bemerkungen'
|
||||
]
|
||||
widgets = {
|
||||
'land': forms.Select(attrs={'class': 'form-select'}),
|
||||
'paechter': forms.Select(attrs={'class': 'form-select'}),
|
||||
'vertragsnummer': forms.TextInput(attrs={'class': 'form-control'}),
|
||||
'pachtbeginn': forms.DateInput(attrs={'class': 'form-control', 'type': 'date'}),
|
||||
'pachtende': forms.DateInput(attrs={'class': 'form-control', 'type': 'date'}),
|
||||
'verlaengerung_klausel': forms.CheckboxInput(attrs={'class': 'form-check-input'}),
|
||||
'verpachtete_flaeche': forms.NumberInput(attrs={'class': 'form-control', 'step': '0.01'}),
|
||||
'pachtzins_pauschal': forms.NumberInput(attrs={'class': 'form-control', 'step': '0.01'}),
|
||||
'pachtzins_pro_ha': forms.NumberInput(attrs={'class': 'form-control', 'step': '0.01'}),
|
||||
'zahlungsweise': forms.Select(attrs={'class': 'form-select'}),
|
||||
'ust_option': forms.CheckboxInput(attrs={'class': 'form-check-input'}),
|
||||
'ust_satz': forms.NumberInput(attrs={'class': 'form-control', 'step': '0.01'}),
|
||||
'grundsteuer_umlage': forms.CheckboxInput(attrs={'class': 'form-check-input'}),
|
||||
'versicherungen_umlage': forms.CheckboxInput(attrs={'class': 'form-check-input'}),
|
||||
'verbandsbeitraege_umlage': forms.CheckboxInput(attrs={'class': 'form-check-input'}),
|
||||
'jagdpacht_anteil_umlage': forms.CheckboxInput(attrs={'class': 'form-check-input'}),
|
||||
'status': forms.Select(attrs={'class': 'form-select'}),
|
||||
'bemerkungen': forms.Textarea(attrs={'class': 'form-control', 'rows': 3}),
|
||||
}
|
||||
|
||||
|
||||
class LandAbrechnungForm(forms.ModelForm):
|
||||
"""Form für das Erstellen und Bearbeiten von Landabrechnungen"""
|
||||
|
||||
class Meta:
|
||||
model = LandAbrechnung
|
||||
fields = [
|
||||
"land",
|
||||
"abrechnungsjahr",
|
||||
# Einnahmen
|
||||
"pacht_vereinnahmt",
|
||||
"umlagen_vereinnahmt",
|
||||
"sonstige_einnahmen",
|
||||
# Ausgaben
|
||||
"grundsteuer_bescheid_nr",
|
||||
"grundsteuer_betrag",
|
||||
"versicherungen_betrag",
|
||||
"verbandsbeitraege_betrag",
|
||||
"sonstige_abgaben_betrag",
|
||||
"instandhaltung_betrag",
|
||||
"verwaltung_recht_betrag",
|
||||
# Umsatzsteuer
|
||||
"vorsteuer_aus_umlagen",
|
||||
# Sonstiges
|
||||
"offene_posten",
|
||||
"bemerkungen",
|
||||
# Dokumente werden über Paperless verknüpft, nicht hochgeladen
|
||||
]
|
||||
widgets = {
|
||||
"land": forms.Select(attrs={"class": "form-select"}),
|
||||
"abrechnungsjahr": forms.NumberInput(
|
||||
attrs={"class": "form-control", "min": "2000", "max": "2050"}
|
||||
),
|
||||
# Einnahmen
|
||||
"pacht_vereinnahmt": forms.NumberInput(
|
||||
attrs={"class": "form-control", "step": "0.01"}
|
||||
),
|
||||
"umlagen_vereinnahmt": forms.NumberInput(
|
||||
attrs={"class": "form-control", "step": "0.01"}
|
||||
),
|
||||
"sonstige_einnahmen": forms.NumberInput(
|
||||
attrs={"class": "form-control", "step": "0.01"}
|
||||
),
|
||||
# Ausgaben
|
||||
"grundsteuer_bescheid_nr": forms.TextInput(attrs={"class": "form-control"}),
|
||||
"grundsteuer_betrag": forms.NumberInput(
|
||||
attrs={"class": "form-control", "step": "0.01"}
|
||||
),
|
||||
"versicherungen_betrag": forms.NumberInput(
|
||||
attrs={"class": "form-control", "step": "0.01"}
|
||||
),
|
||||
"verbandsbeitraege_betrag": forms.NumberInput(
|
||||
attrs={"class": "form-control", "step": "0.01"}
|
||||
),
|
||||
"sonstige_abgaben_betrag": forms.NumberInput(
|
||||
attrs={"class": "form-control", "step": "0.01"}
|
||||
),
|
||||
"instandhaltung_betrag": forms.NumberInput(
|
||||
attrs={"class": "form-control", "step": "0.01"}
|
||||
),
|
||||
"verwaltung_recht_betrag": forms.NumberInput(
|
||||
attrs={"class": "form-control", "step": "0.01"}
|
||||
),
|
||||
# Umsatzsteuer
|
||||
"vorsteuer_aus_umlagen": forms.NumberInput(
|
||||
attrs={"class": "form-control", "step": "0.01"}
|
||||
),
|
||||
# Sonstiges
|
||||
"offene_posten": forms.NumberInput(
|
||||
attrs={"class": "form-control", "step": "0.01"}
|
||||
),
|
||||
"bemerkungen": forms.Textarea(attrs={"class": "form-control", "rows": 4}),
|
||||
}
|
||||
|
||||
|
||||
class PaechterForm(forms.ModelForm):
|
||||
"""Form für das Erstellen und Bearbeiten von Pächtern"""
|
||||
|
||||
class Meta:
|
||||
model = Paechter
|
||||
fields = "__all__"
|
||||
widgets = {
|
||||
"anrede": forms.Select(attrs={"class": "form-select"}),
|
||||
"vorname": forms.TextInput(attrs={"class": "form-control"}),
|
||||
"nachname": forms.TextInput(attrs={"class": "form-control"}),
|
||||
"email": forms.EmailInput(attrs={"class": "form-control"}),
|
||||
"telefon": forms.TextInput(attrs={"class": "form-control"}),
|
||||
"mobil": forms.TextInput(attrs={"class": "form-control"}),
|
||||
"geburtsdatum": forms.DateInput(
|
||||
attrs={"class": "form-control", "type": "date"}
|
||||
),
|
||||
"strasse": forms.TextInput(attrs={"class": "form-control"}),
|
||||
"plz": forms.TextInput(attrs={"class": "form-control"}),
|
||||
"ort": forms.TextInput(attrs={"class": "form-control"}),
|
||||
"aktiv": forms.CheckboxInput(attrs={"class": "form-check-input"}),
|
||||
"notizen": forms.Textarea(attrs={"class": "form-control", "rows": 3}),
|
||||
}
|
||||
460
app/stiftung/forms/system.py
Normal file
460
app/stiftung/forms/system.py
Normal file
@@ -0,0 +1,460 @@
|
||||
import re
|
||||
|
||||
from django import forms
|
||||
from django.core.exceptions import ValidationError
|
||||
|
||||
from ..models import Person
|
||||
|
||||
|
||||
class UserCreationForm(forms.Form):
|
||||
"""Form für die Erstellung neuer Benutzer"""
|
||||
|
||||
username = forms.CharField(
|
||||
label="Benutzername",
|
||||
max_length=150,
|
||||
help_text="Eindeutiger Benutzername für die Anmeldung",
|
||||
widget=forms.TextInput(attrs={"class": "form-control"}),
|
||||
)
|
||||
|
||||
email = forms.EmailField(
|
||||
label="E-Mail-Adresse",
|
||||
help_text="E-Mail-Adresse des Benutzers",
|
||||
widget=forms.EmailInput(attrs={"class": "form-control"}),
|
||||
)
|
||||
|
||||
first_name = forms.CharField(
|
||||
label="Vorname",
|
||||
max_length=30,
|
||||
required=False,
|
||||
widget=forms.TextInput(attrs={"class": "form-control"}),
|
||||
)
|
||||
|
||||
last_name = forms.CharField(
|
||||
label="Nachname",
|
||||
max_length=150,
|
||||
required=False,
|
||||
widget=forms.TextInput(attrs={"class": "form-control"}),
|
||||
)
|
||||
|
||||
password1 = forms.CharField(
|
||||
label="Passwort",
|
||||
widget=forms.PasswordInput(attrs={"class": "form-control"}),
|
||||
help_text="Mindestens 8 Zeichen",
|
||||
)
|
||||
|
||||
password2 = forms.CharField(
|
||||
label="Passwort bestätigen",
|
||||
widget=forms.PasswordInput(attrs={"class": "form-control"}),
|
||||
help_text="Geben Sie das Passwort zur Bestätigung erneut ein",
|
||||
)
|
||||
|
||||
is_active = forms.BooleanField(
|
||||
label="Aktiv",
|
||||
required=False,
|
||||
initial=True,
|
||||
help_text="Benutzer kann sich anmelden",
|
||||
widget=forms.CheckboxInput(attrs={"class": "form-check-input"}),
|
||||
)
|
||||
|
||||
is_staff = forms.BooleanField(
|
||||
label="Staff-Status",
|
||||
required=False,
|
||||
help_text="Benutzer kann auf Django Admin zugreifen",
|
||||
widget=forms.CheckboxInput(attrs={"class": "form-check-input"}),
|
||||
)
|
||||
|
||||
def clean_username(self):
|
||||
username = self.cleaned_data["username"]
|
||||
from django.contrib.auth.models import User
|
||||
|
||||
if User.objects.filter(username=username).exists():
|
||||
raise forms.ValidationError(
|
||||
"Ein Benutzer mit diesem Namen existiert bereits."
|
||||
)
|
||||
return username
|
||||
|
||||
def clean_email(self):
|
||||
email = self.cleaned_data["email"]
|
||||
from django.contrib.auth.models import User
|
||||
|
||||
if User.objects.filter(email=email).exists():
|
||||
raise forms.ValidationError(
|
||||
"Ein Benutzer mit dieser E-Mail-Adresse existiert bereits."
|
||||
)
|
||||
return email
|
||||
|
||||
def clean(self):
|
||||
cleaned_data = super().clean()
|
||||
password1 = cleaned_data.get("password1")
|
||||
password2 = cleaned_data.get("password2")
|
||||
|
||||
if password1 and password2:
|
||||
if password1 != password2:
|
||||
raise forms.ValidationError("Die Passwörter stimmen nicht überein.")
|
||||
if len(password1) < 8:
|
||||
raise forms.ValidationError(
|
||||
"Das Passwort muss mindestens 8 Zeichen lang sein."
|
||||
)
|
||||
|
||||
return cleaned_data
|
||||
|
||||
|
||||
class UserUpdateForm(forms.ModelForm):
|
||||
"""Form für die Bearbeitung bestehender Benutzer"""
|
||||
|
||||
class Meta:
|
||||
from django.contrib.auth.models import User
|
||||
|
||||
model = User
|
||||
fields = [
|
||||
"username",
|
||||
"email",
|
||||
"first_name",
|
||||
"last_name",
|
||||
"is_active",
|
||||
"is_staff",
|
||||
]
|
||||
widgets = {
|
||||
"username": forms.TextInput(attrs={"class": "form-control"}),
|
||||
"email": forms.EmailInput(attrs={"class": "form-control"}),
|
||||
"first_name": forms.TextInput(attrs={"class": "form-control"}),
|
||||
"last_name": forms.TextInput(attrs={"class": "form-control"}),
|
||||
"is_active": forms.CheckboxInput(attrs={"class": "form-check-input"}),
|
||||
"is_staff": forms.CheckboxInput(attrs={"class": "form-check-input"}),
|
||||
}
|
||||
labels = {
|
||||
"username": "Benutzername",
|
||||
"email": "E-Mail-Adresse",
|
||||
"first_name": "Vorname",
|
||||
"last_name": "Nachname",
|
||||
"is_active": "Aktiv",
|
||||
"is_staff": "Staff-Status",
|
||||
}
|
||||
help_texts = {
|
||||
"username": "Eindeutiger Benutzername für die Anmeldung",
|
||||
"email": "E-Mail-Adresse des Benutzers",
|
||||
"is_active": "Benutzer kann sich anmelden",
|
||||
"is_staff": "Benutzer kann auf Django Admin zugreifen",
|
||||
}
|
||||
|
||||
|
||||
class PasswordChangeForm(forms.Form):
|
||||
"""Form für Passwort-Änderungen"""
|
||||
|
||||
new_password1 = forms.CharField(
|
||||
label="Neues Passwort",
|
||||
widget=forms.PasswordInput(attrs={"class": "form-control"}),
|
||||
help_text="Mindestens 8 Zeichen",
|
||||
)
|
||||
|
||||
new_password2 = forms.CharField(
|
||||
label="Neues Passwort bestätigen",
|
||||
widget=forms.PasswordInput(attrs={"class": "form-control"}),
|
||||
help_text="Geben Sie das neue Passwort zur Bestätigung erneut ein",
|
||||
)
|
||||
|
||||
def clean(self):
|
||||
cleaned_data = super().clean()
|
||||
password1 = cleaned_data.get("new_password1")
|
||||
password2 = cleaned_data.get("new_password2")
|
||||
|
||||
if password1 and password2:
|
||||
if password1 != password2:
|
||||
raise forms.ValidationError("Die Passwörter stimmen nicht überein.")
|
||||
if len(password1) < 8:
|
||||
raise forms.ValidationError(
|
||||
"Das Passwort muss mindestens 8 Zeichen lang sein."
|
||||
)
|
||||
|
||||
return cleaned_data
|
||||
|
||||
|
||||
class UserPermissionForm(forms.Form):
|
||||
"""Form für die Zuweisung von Berechtigungen"""
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
user = kwargs.pop("user", None)
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
from django.contrib.auth.models import Permission
|
||||
|
||||
# Get all custom permissions for stiftung app
|
||||
app_permissions = Permission.objects.filter(
|
||||
content_type__app_label="stiftung"
|
||||
).order_by("name")
|
||||
|
||||
# Create checkbox fields for each permission
|
||||
for perm in app_permissions:
|
||||
field_name = f"perm_{perm.id}"
|
||||
self.fields[field_name] = forms.BooleanField(
|
||||
label=perm.name,
|
||||
required=False,
|
||||
widget=forms.CheckboxInput(attrs={"class": "form-check-input"}),
|
||||
)
|
||||
|
||||
# Set initial values if user is provided
|
||||
if user:
|
||||
self.fields[field_name].initial = user.has_perm(
|
||||
f"stiftung.{perm.codename}"
|
||||
)
|
||||
|
||||
def get_permission_groups(self):
|
||||
"""Group permissions by functionality for template rendering"""
|
||||
from django.contrib.auth.models import Permission
|
||||
|
||||
groups = {
|
||||
"entities": {
|
||||
"name": "Entitäten verwalten",
|
||||
"permissions": [],
|
||||
"icon": "fas fa-users",
|
||||
},
|
||||
"documents": {
|
||||
"name": "Dokumentenverwaltung",
|
||||
"permissions": [],
|
||||
"icon": "fas fa-folder-open",
|
||||
},
|
||||
"financial": {
|
||||
"name": "Finanzverwaltung",
|
||||
"permissions": [],
|
||||
"icon": "fas fa-euro-sign",
|
||||
},
|
||||
"administration": {
|
||||
"name": "Administration",
|
||||
"permissions": [],
|
||||
"icon": "fas fa-cogs",
|
||||
},
|
||||
"system": {"name": "System", "permissions": [], "icon": "fas fa-server"},
|
||||
}
|
||||
|
||||
# Get all permissions to properly categorize them
|
||||
for field_name, field in self.fields.items():
|
||||
if field_name.startswith("perm_"):
|
||||
# Extract permission ID from field name
|
||||
perm_id = field_name.replace("perm_", "")
|
||||
try:
|
||||
permission = Permission.objects.get(id=perm_id)
|
||||
label = permission.name.lower()
|
||||
codename = permission.codename.lower()
|
||||
|
||||
# Get bound field for proper template rendering
|
||||
bound_field = self[field_name]
|
||||
|
||||
# More precise categorization based on both name and codename
|
||||
if (
|
||||
any(
|
||||
word in codename
|
||||
for word in [
|
||||
"destinataer",
|
||||
"land",
|
||||
"paechter",
|
||||
"verpachtung",
|
||||
"foerderung",
|
||||
]
|
||||
)
|
||||
and "manage_" in codename
|
||||
or "view_" in codename
|
||||
):
|
||||
groups["entities"]["permissions"].append(
|
||||
(field_name, bound_field, permission)
|
||||
)
|
||||
elif (
|
||||
any(
|
||||
word in codename for word in ["documents", "link_documents"]
|
||||
)
|
||||
or "dokument" in label
|
||||
):
|
||||
groups["documents"]["permissions"].append(
|
||||
(field_name, bound_field, permission)
|
||||
)
|
||||
elif any(
|
||||
word in codename
|
||||
for word in [
|
||||
"verwaltungskosten",
|
||||
"konten",
|
||||
"rentmeister",
|
||||
"approve_payments",
|
||||
]
|
||||
) or any(
|
||||
word in label
|
||||
for word in [
|
||||
"verwaltungskosten",
|
||||
"konto",
|
||||
"rentmeister",
|
||||
"zahlung",
|
||||
]
|
||||
):
|
||||
groups["financial"]["permissions"].append(
|
||||
(field_name, bound_field, permission)
|
||||
)
|
||||
elif any(
|
||||
word in codename
|
||||
for word in [
|
||||
"administration",
|
||||
"audit",
|
||||
"backup",
|
||||
"manage_users",
|
||||
"manage_permissions",
|
||||
]
|
||||
) or any(
|
||||
word in label
|
||||
for word in [
|
||||
"administration",
|
||||
"audit",
|
||||
"backup",
|
||||
"benutzer",
|
||||
"berechtigung",
|
||||
]
|
||||
):
|
||||
groups["administration"]["permissions"].append(
|
||||
(field_name, bound_field, permission)
|
||||
)
|
||||
else:
|
||||
groups["system"]["permissions"].append(
|
||||
(field_name, bound_field, permission)
|
||||
)
|
||||
except Permission.DoesNotExist:
|
||||
# Create a fallback permission-like object with proper display
|
||||
class FallbackPermission:
|
||||
def __init__(self, field_name):
|
||||
self.name = field_name.replace('_', ' ').title()
|
||||
self.codename = field_name
|
||||
|
||||
fallback_perm = FallbackPermission(field_name)
|
||||
bound_field = self[field_name] # Get bound field for exception case too
|
||||
groups["system"]["permissions"].append((field_name, bound_field, fallback_perm))
|
||||
|
||||
return groups
|
||||
|
||||
|
||||
class TwoFactorSetupForm(forms.Form):
|
||||
"""Form for setting up 2FA with TOTP verification"""
|
||||
token = forms.CharField(
|
||||
max_length=6,
|
||||
min_length=6,
|
||||
widget=forms.TextInput(attrs={
|
||||
'class': 'form-control text-center',
|
||||
'placeholder': '000000',
|
||||
'autocomplete': 'off',
|
||||
'pattern': '[0-9]{6}',
|
||||
'inputmode': 'numeric'
|
||||
}),
|
||||
label='Bestätigungscode',
|
||||
help_text='6-stelliger Code aus Ihrer Authenticator-App'
|
||||
)
|
||||
|
||||
def clean_token(self):
|
||||
token = self.cleaned_data.get('token')
|
||||
if token and not token.isdigit():
|
||||
raise ValidationError('Der Code darf nur Zahlen enthalten.')
|
||||
return token
|
||||
|
||||
|
||||
class TwoFactorVerifyForm(forms.Form):
|
||||
"""Form for verifying 2FA during login"""
|
||||
otp_token = forms.CharField(
|
||||
max_length=8,
|
||||
min_length=6,
|
||||
widget=forms.TextInput(attrs={
|
||||
'class': 'form-control form-control-lg text-center',
|
||||
'placeholder': '000000',
|
||||
'autocomplete': 'off',
|
||||
'autofocus': True
|
||||
}),
|
||||
label='Authentifizierungscode',
|
||||
help_text='6-stelliger Code aus der App oder 8-stelliger Backup-Code'
|
||||
)
|
||||
|
||||
def clean_otp_token(self):
|
||||
token = self.cleaned_data.get('otp_token')
|
||||
if token:
|
||||
token = token.strip().lower()
|
||||
# Allow 6-digit TOTP codes or 8-character backup codes
|
||||
if len(token) == 6 and token.isdigit():
|
||||
return token
|
||||
elif len(token) == 8 and re.match(r'^[a-f0-9]{8}$', token):
|
||||
return token
|
||||
else:
|
||||
raise ValidationError(
|
||||
'Bitte geben Sie einen 6-stelligen Authenticator-Code oder 8-stelligen Backup-Code ein.'
|
||||
)
|
||||
return token
|
||||
|
||||
|
||||
class TwoFactorDisableForm(forms.Form):
|
||||
"""Form for disabling 2FA with password confirmation"""
|
||||
password = forms.CharField(
|
||||
widget=forms.PasswordInput(attrs={
|
||||
'class': 'form-control',
|
||||
'autocomplete': 'current-password',
|
||||
'autofocus': True
|
||||
}),
|
||||
label='Passwort',
|
||||
help_text='Geben Sie Ihr aktuelles Passwort zur Bestätigung ein'
|
||||
)
|
||||
|
||||
|
||||
class BackupTokenRegenerateForm(forms.Form):
|
||||
"""Form for regenerating backup tokens"""
|
||||
password = forms.CharField(
|
||||
widget=forms.PasswordInput(attrs={
|
||||
'class': 'form-control',
|
||||
'autocomplete': 'current-password'
|
||||
}),
|
||||
label='Passwort',
|
||||
help_text='Geben Sie Ihr Passwort ein, um neue Backup-Codes zu generieren'
|
||||
)
|
||||
|
||||
|
||||
class PersonForm(forms.ModelForm):
|
||||
"""Form für das Erstellen und Bearbeiten von Personen (Legacy)"""
|
||||
|
||||
class Meta:
|
||||
model = Person
|
||||
fields = [
|
||||
"familienzweig",
|
||||
"vorname",
|
||||
"nachname",
|
||||
"geburtsdatum",
|
||||
"email",
|
||||
"telefon",
|
||||
"iban",
|
||||
"adresse",
|
||||
"notizen",
|
||||
"aktiv",
|
||||
]
|
||||
|
||||
widgets = {
|
||||
"familienzweig": forms.Select(attrs={"class": "form-select"}),
|
||||
"vorname": forms.TextInput(attrs={"class": "form-control"}),
|
||||
"nachname": forms.TextInput(attrs={"class": "form-control"}),
|
||||
"geburtsdatum": forms.DateInput(
|
||||
attrs={"class": "form-control", "type": "date"}
|
||||
),
|
||||
"email": forms.EmailInput(attrs={"class": "form-control"}),
|
||||
"telefon": forms.TextInput(attrs={"class": "form-control"}),
|
||||
"iban": forms.TextInput(
|
||||
attrs={"class": "form-control", "placeholder": "DE89370400440532013000"}
|
||||
),
|
||||
"adresse": forms.Textarea(attrs={"class": "form-control", "rows": 3}),
|
||||
"notizen": forms.Textarea(attrs={"class": "form-control", "rows": 3}),
|
||||
"aktiv": forms.CheckboxInput(attrs={"class": "form-check-input"}),
|
||||
}
|
||||
|
||||
labels = {
|
||||
"familienzweig": "Familienzweig",
|
||||
"vorname": "Vorname *",
|
||||
"nachname": "Nachname *",
|
||||
"geburtsdatum": "Geburtsdatum",
|
||||
"email": "E-Mail",
|
||||
"telefon": "Telefon",
|
||||
"iban": "IBAN",
|
||||
"adresse": "Adresse",
|
||||
"notizen": "Notizen",
|
||||
"aktiv": "Aktiv",
|
||||
}
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
# Markiere Pflichtfelder
|
||||
self.fields["vorname"].required = True
|
||||
self.fields["nachname"].required = True
|
||||
59
app/stiftung/forms/veranstaltung.py
Normal file
59
app/stiftung/forms/veranstaltung.py
Normal file
@@ -0,0 +1,59 @@
|
||||
from django import forms
|
||||
|
||||
from ..models import Veranstaltung, Veranstaltungsteilnehmer
|
||||
|
||||
|
||||
class VeranstaltungForm(forms.ModelForm):
|
||||
"""Form für das Erstellen und Bearbeiten von Veranstaltungen inkl. Serienbrief-Felder"""
|
||||
|
||||
class Meta:
|
||||
model = Veranstaltung
|
||||
fields = [
|
||||
"titel", "datum", "uhrzeit", "ort", "adresse",
|
||||
"beschreibung", "status", "budget_pro_person",
|
||||
"betreff", "briefvorlage",
|
||||
"unterschrift_1_name", "unterschrift_1_titel",
|
||||
"unterschrift_2_name", "unterschrift_2_titel",
|
||||
]
|
||||
widgets = {
|
||||
"titel": forms.TextInput(attrs={"class": "form-control"}),
|
||||
"datum": forms.DateInput(attrs={"class": "form-control", "type": "date"}),
|
||||
"uhrzeit": forms.TimeInput(attrs={"class": "form-control", "type": "time"}),
|
||||
"ort": forms.TextInput(attrs={"class": "form-control"}),
|
||||
"adresse": forms.Textarea(attrs={"class": "form-control", "rows": 2}),
|
||||
"beschreibung": forms.Textarea(attrs={"class": "form-control", "rows": 3}),
|
||||
"status": forms.Select(attrs={"class": "form-select"}),
|
||||
"budget_pro_person": forms.NumberInput(attrs={"class": "form-control", "step": "0.01"}),
|
||||
"betreff": forms.TextInput(attrs={"class": "form-control"}),
|
||||
"briefvorlage": forms.Textarea(attrs={"class": "form-control", "rows": 12}),
|
||||
"unterschrift_1_name": forms.TextInput(attrs={"class": "form-control"}),
|
||||
"unterschrift_1_titel": forms.TextInput(attrs={"class": "form-control"}),
|
||||
"unterschrift_2_name": forms.TextInput(attrs={"class": "form-control"}),
|
||||
"unterschrift_2_titel": forms.TextInput(attrs={"class": "form-control"}),
|
||||
}
|
||||
|
||||
|
||||
class VeranstaltungsteilnehmerForm(forms.ModelForm):
|
||||
"""Form für das Erstellen und Bearbeiten von Veranstaltungsteilnehmern"""
|
||||
|
||||
class Meta:
|
||||
model = Veranstaltungsteilnehmer
|
||||
fields = [
|
||||
"anrede", "vorname", "nachname",
|
||||
"strasse", "plz", "ort", "email",
|
||||
"rsvp_status", "bemerkungen",
|
||||
"paechter", "destinataer",
|
||||
]
|
||||
widgets = {
|
||||
"anrede": forms.Select(attrs={"class": "form-select"}),
|
||||
"vorname": forms.TextInput(attrs={"class": "form-control"}),
|
||||
"nachname": forms.TextInput(attrs={"class": "form-control"}),
|
||||
"strasse": forms.TextInput(attrs={"class": "form-control"}),
|
||||
"plz": forms.TextInput(attrs={"class": "form-control"}),
|
||||
"ort": forms.TextInput(attrs={"class": "form-control"}),
|
||||
"email": forms.EmailInput(attrs={"class": "form-control"}),
|
||||
"rsvp_status": forms.Select(attrs={"class": "form-select"}),
|
||||
"bemerkungen": forms.Textarea(attrs={"class": "form-control", "rows": 2}),
|
||||
"paechter": forms.Select(attrs={"class": "form-select"}),
|
||||
"destinataer": forms.Select(attrs={"class": "form-select"}),
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
"""
|
||||
Management command to import participants into a Veranstaltung.
|
||||
|
||||
Usage:
|
||||
python manage.py import_veranstaltung_teilnehmer <veranstaltung_id>
|
||||
"""
|
||||
from django.core.management.base import BaseCommand
|
||||
|
||||
from stiftung.models import Veranstaltung, Veranstaltungsteilnehmer
|
||||
|
||||
|
||||
TEILNEHMER_DATA = [
|
||||
{"anrede": "Herr", "vorname": "Stephan", "nachname": "Bohnekamp", "strasse": "Marienthaler Strasse 44", "plz": "46569", "ort": "Hünxe-Drevenack"},
|
||||
{"anrede": "Frau", "vorname": "Maike", "nachname": "Buchmann-Bender", "strasse": "Am Wehagen 6", "plz": "46485", "ort": "Wesel"},
|
||||
{"anrede": "Herr", "vorname": "Edmund", "nachname": "Eichelberg", "strasse": "Schwarzensteiner Weg 75", "plz": "46569", "ort": "Hünxe-Drevenack"},
|
||||
{"anrede": "Herr", "vorname": "Walter", "nachname": "Buchmann-Bender", "strasse": "Büskesheide 11", "plz": "46499", "ort": "Hamminkeln"},
|
||||
{"anrede": "Herr", "vorname": "Gerold", "nachname": "Hurtienne", "strasse": "Birkenweg 14", "plz": "46569", "ort": "Hünxe-Drevenack"},
|
||||
{"anrede": "Frau", "vorname": "Katrin", "nachname": "Kleinpaß", "strasse": "Raesfelder Strasse 3", "plz": "46499", "ort": "Hamminkeln"},
|
||||
{"anrede": "Frau", "vorname": "Zoe", "nachname": "Kleinpaß", "strasse": "Raesfelder Strasse 3", "plz": "46499", "ort": "Hamminkeln"},
|
||||
{"anrede": "Frau", "vorname": "Nele", "nachname": "Schmäh", "strasse": "Raesfelder Strasse 3", "plz": "46499", "ort": "Hamminkeln"},
|
||||
{"anrede": "Frau", "vorname": "Susanne", "nachname": "Menz", "strasse": "Zum Weissenstein 7 a", "plz": "46499", "ort": "Hamminkeln"},
|
||||
{"anrede": "Herr", "vorname": "Jan Remmer", "nachname": "Siebels", "strasse": "Holthauser Feld 7", "plz": "49716", "ort": "Meppen"},
|
||||
{"anrede": "Frau", "vorname": "Annette", "nachname": "von der Höh", "strasse": "Fehmarnstrasse 53", "plz": "33729", "ort": "Bielefeld"},
|
||||
{"anrede": "Herr", "vorname": "Hartmut", "nachname": "Küppers", "strasse": "Jöhrenstr. 10", "plz": "30559", "ort": "Hannover"},
|
||||
{"anrede": "Frau", "vorname": "Ruth", "nachname": "Höhne", "strasse": "Löwenburgstr. 127", "plz": "53229", "ort": "Bonn-Niederholtorf"},
|
||||
{"anrede": "Herr", "vorname": "Aleph", "nachname": "Freese", "strasse": "Christoph Str. 50", "plz": "40225", "ort": "Düsseldorf"},
|
||||
{"anrede": "Herr", "vorname": "Patrik", "nachname": "Schüngel", "strasse": "Im Sand 11a", "plz": "47608", "ort": "Geldern- Walbeck"},
|
||||
{"anrede": "Frau", "vorname": "Christiane", "nachname": "Siebels", "strasse": "Rudolf Kinau Strasse 10", "plz": "49716", "ort": "Meppen"},
|
||||
]
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = "Importiert Teilnehmer in eine Veranstaltung"
|
||||
|
||||
def add_arguments(self, parser):
|
||||
parser.add_argument("veranstaltung_id", type=str, help="UUID der Veranstaltung")
|
||||
parser.add_argument("--dry-run", action="store_true", help="Nur anzeigen, nicht importieren")
|
||||
|
||||
def handle(self, *args, **options):
|
||||
vid = options["veranstaltung_id"]
|
||||
dry_run = options["dry_run"]
|
||||
|
||||
try:
|
||||
veranstaltung = Veranstaltung.objects.get(id=vid)
|
||||
except Veranstaltung.DoesNotExist:
|
||||
self.stderr.write(self.style.ERROR(f"Veranstaltung {vid} nicht gefunden"))
|
||||
return
|
||||
|
||||
self.stdout.write(f"Veranstaltung: {veranstaltung}")
|
||||
self.stdout.write(f"Teilnehmer zu importieren: {len(TEILNEHMER_DATA)}")
|
||||
|
||||
if dry_run:
|
||||
for t in TEILNEHMER_DATA:
|
||||
self.stdout.write(f" [DRY] {t['anrede']} {t['vorname']} {t['nachname']}")
|
||||
return
|
||||
|
||||
created = 0
|
||||
for t in TEILNEHMER_DATA:
|
||||
# Check for duplicates
|
||||
exists = Veranstaltungsteilnehmer.objects.filter(
|
||||
veranstaltung=veranstaltung,
|
||||
vorname=t["vorname"],
|
||||
nachname=t["nachname"],
|
||||
).exists()
|
||||
if exists:
|
||||
self.stdout.write(self.style.WARNING(f" SKIP (exists): {t['vorname']} {t['nachname']}"))
|
||||
continue
|
||||
|
||||
Veranstaltungsteilnehmer.objects.create(
|
||||
veranstaltung=veranstaltung,
|
||||
anrede=t["anrede"],
|
||||
vorname=t["vorname"],
|
||||
nachname=t["nachname"],
|
||||
strasse=t["strasse"],
|
||||
plz=t["plz"],
|
||||
ort=t["ort"],
|
||||
rsvp_status="eingeladen",
|
||||
)
|
||||
created += 1
|
||||
self.stdout.write(self.style.SUCCESS(f" OK: {t['vorname']} {t['nachname']}"))
|
||||
|
||||
self.stdout.write(self.style.SUCCESS(f"\n{created} Teilnehmer importiert."))
|
||||
@@ -7,94 +7,137 @@ class Command(BaseCommand):
|
||||
help = "Initialize default app configuration settings"
|
||||
|
||||
def handle(self, *args, **options):
|
||||
# Paperless Integration Settings
|
||||
paperless_settings = [
|
||||
# E-Mail / IMAP Settings
|
||||
email_settings = [
|
||||
{
|
||||
"key": "paperless_api_url",
|
||||
"display_name": "Paperless API URL",
|
||||
"description": "The base URL for your Paperless-NGX API (e.g., http://paperless.example.com:8000)",
|
||||
"value": "http://192.168.178.167:30070",
|
||||
"default_value": "http://192.168.178.167:30070",
|
||||
"setting_type": "url",
|
||||
"category": "paperless",
|
||||
"order": 1,
|
||||
},
|
||||
{
|
||||
"key": "paperless_api_token",
|
||||
"display_name": "Paperless API Token",
|
||||
"description": "The authentication token for Paperless API access",
|
||||
"key": "imap_host",
|
||||
"display_name": "IMAP Server",
|
||||
"description": "Hostname oder IP-Adresse des IMAP-Servers (z.B. mail.example.com)",
|
||||
"value": "",
|
||||
"default_value": "",
|
||||
"setting_type": "text",
|
||||
"category": "paperless",
|
||||
"category": "email",
|
||||
"order": 1,
|
||||
},
|
||||
{
|
||||
"key": "imap_port",
|
||||
"display_name": "IMAP Port",
|
||||
"description": "Port des IMAP-Servers (Standard: 993 für SSL, 143 für unverschlüsselt)",
|
||||
"value": "993",
|
||||
"default_value": "993",
|
||||
"setting_type": "number",
|
||||
"category": "email",
|
||||
"order": 2,
|
||||
},
|
||||
{
|
||||
"key": "paperless_destinataere_tag",
|
||||
"display_name": "Destinatäre Tag Name",
|
||||
"description": "The tag name used to identify Destinatäre documents in Paperless",
|
||||
"value": "Stiftung_Destinatäre",
|
||||
"default_value": "Stiftung_Destinatäre",
|
||||
"setting_type": "tag",
|
||||
"category": "paperless",
|
||||
"key": "imap_user",
|
||||
"display_name": "IMAP Benutzername",
|
||||
"description": "Benutzername / E-Mail-Adresse für die IMAP-Anmeldung",
|
||||
"value": "",
|
||||
"default_value": "",
|
||||
"setting_type": "text",
|
||||
"category": "email",
|
||||
"order": 3,
|
||||
},
|
||||
{
|
||||
"key": "paperless_destinataere_tag_id",
|
||||
"display_name": "Destinatäre Tag ID",
|
||||
"description": "The numeric ID of the Destinatäre tag in Paperless",
|
||||
"value": "210",
|
||||
"default_value": "210",
|
||||
"setting_type": "tag_id",
|
||||
"category": "paperless",
|
||||
"key": "imap_password",
|
||||
"display_name": "IMAP Passwort",
|
||||
"description": "Passwort für die IMAP-Anmeldung",
|
||||
"value": "",
|
||||
"default_value": "",
|
||||
"setting_type": "password",
|
||||
"category": "email",
|
||||
"order": 4,
|
||||
},
|
||||
{
|
||||
"key": "paperless_land_tag",
|
||||
"display_name": "Land & Pächter Tag Name",
|
||||
"description": "The tag name used to identify Land and Pächter documents in Paperless",
|
||||
"value": "Stiftung_Land_und_Pächter",
|
||||
"default_value": "Stiftung_Land_und_Pächter",
|
||||
"setting_type": "tag",
|
||||
"category": "paperless",
|
||||
"key": "imap_folder",
|
||||
"display_name": "IMAP Ordner",
|
||||
"description": "Name des zu überwachenden Postfach-Ordners (Standard: INBOX)",
|
||||
"value": "INBOX",
|
||||
"default_value": "INBOX",
|
||||
"setting_type": "text",
|
||||
"category": "email",
|
||||
"order": 5,
|
||||
},
|
||||
{
|
||||
"key": "paperless_land_tag_id",
|
||||
"display_name": "Land & Pächter Tag ID",
|
||||
"description": "The numeric ID of the Land & Pächter tag in Paperless",
|
||||
"value": "204",
|
||||
"default_value": "204",
|
||||
"setting_type": "tag_id",
|
||||
"category": "paperless",
|
||||
"key": "imap_use_ssl",
|
||||
"display_name": "SSL/TLS verwenden",
|
||||
"description": "Sichere Verbindung zum IMAP-Server (empfohlen)",
|
||||
"value": "True",
|
||||
"default_value": "True",
|
||||
"setting_type": "boolean",
|
||||
"category": "email",
|
||||
"order": 6,
|
||||
},
|
||||
# SMTP Settings
|
||||
{
|
||||
"key": "paperless_admin_tag",
|
||||
"display_name": "Administration Tag Name",
|
||||
"description": "The tag name used to identify Administration documents in Paperless",
|
||||
"value": "Stiftung_Administration",
|
||||
"default_value": "Stiftung_Administration",
|
||||
"setting_type": "tag",
|
||||
"category": "paperless",
|
||||
"order": 7,
|
||||
"key": "smtp_host",
|
||||
"display_name": "SMTP Server",
|
||||
"description": "Hostname des SMTP-Servers (z.B. smtp.ionos.de)",
|
||||
"value": "smtp.ionos.de",
|
||||
"default_value": "smtp.ionos.de",
|
||||
"setting_type": "text",
|
||||
"category": "email",
|
||||
"order": 10,
|
||||
},
|
||||
{
|
||||
"key": "paperless_admin_tag_id",
|
||||
"display_name": "Administration Tag ID",
|
||||
"description": "The numeric ID of the Administration tag in Paperless",
|
||||
"value": "216",
|
||||
"default_value": "216",
|
||||
"setting_type": "tag_id",
|
||||
"category": "paperless",
|
||||
"order": 8,
|
||||
"key": "smtp_port",
|
||||
"display_name": "SMTP Port",
|
||||
"description": "Port des SMTP-Servers (465 für SSL, 587 für STARTTLS)",
|
||||
"value": "465",
|
||||
"default_value": "465",
|
||||
"setting_type": "number",
|
||||
"category": "email",
|
||||
"order": 11,
|
||||
},
|
||||
{
|
||||
"key": "smtp_user",
|
||||
"display_name": "SMTP Benutzername",
|
||||
"description": "Benutzername / E-Mail-Adresse für die SMTP-Anmeldung",
|
||||
"value": "",
|
||||
"default_value": "",
|
||||
"setting_type": "text",
|
||||
"category": "email",
|
||||
"order": 12,
|
||||
},
|
||||
{
|
||||
"key": "smtp_password",
|
||||
"display_name": "SMTP Passwort",
|
||||
"description": "Passwort für die SMTP-Anmeldung",
|
||||
"value": "",
|
||||
"default_value": "",
|
||||
"setting_type": "password",
|
||||
"category": "email",
|
||||
"order": 13,
|
||||
},
|
||||
{
|
||||
"key": "smtp_use_ssl",
|
||||
"display_name": "SSL/TLS verwenden (SMTP)",
|
||||
"description": "Sichere Verbindung zum SMTP-Server (empfohlen für Port 465)",
|
||||
"value": "True",
|
||||
"default_value": "True",
|
||||
"setting_type": "boolean",
|
||||
"category": "email",
|
||||
"order": 14,
|
||||
},
|
||||
{
|
||||
"key": "smtp_from_email",
|
||||
"display_name": "Absenderadresse (SMTP)",
|
||||
"description": "Absenderadresse für ausgehende E-Mails (z.B. buero@vhtv-stiftung.de)",
|
||||
"value": "buero@vhtv-stiftung.de",
|
||||
"default_value": "buero@vhtv-stiftung.de",
|
||||
"setting_type": "text",
|
||||
"category": "email",
|
||||
"order": 15,
|
||||
},
|
||||
]
|
||||
|
||||
all_settings = email_settings
|
||||
|
||||
created_count = 0
|
||||
updated_count = 0
|
||||
|
||||
for setting_data in paperless_settings:
|
||||
for setting_data in all_settings:
|
||||
setting, created = AppConfiguration.objects.get_or_create(
|
||||
key=setting_data["key"], defaults=setting_data
|
||||
)
|
||||
|
||||
124
app/stiftung/management/commands/migrate_paperless_dokumente.py
Normal file
124
app/stiftung/management/commands/migrate_paperless_dokumente.py
Normal file
@@ -0,0 +1,124 @@
|
||||
# management/commands/migrate_paperless_dokumente.py
|
||||
# Phase 3: Migriert DokumentLink-Einträge zu DokumentDatei (falls Paperless-Dateien lokal verfügbar)
|
||||
#
|
||||
# Verwendung:
|
||||
# python manage.py migrate_paperless_dokumente [--dry-run] [--limit N]
|
||||
#
|
||||
# Was dieser Befehl tut:
|
||||
# 1. Alle DokumentLink-Objekte abrufen (Paperless-Verweise)
|
||||
# 2. Für jeden Link: DokumentDatei erstellen, falls noch keine existiert (paperless_dokument_id)
|
||||
# 3. Suchvektor aktualisieren
|
||||
# 4. paperless_dokument_id setzen, damit künftige Läufe Duplikate überspringen
|
||||
|
||||
import os
|
||||
|
||||
from django.core.management.base import BaseCommand, CommandError
|
||||
from django.db import transaction
|
||||
|
||||
from stiftung.models import DokumentDatei, DokumentLink
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = "Migriert Paperless-DokumentLink-Einträge zu DokumentDatei (Metadaten only)"
|
||||
|
||||
def add_arguments(self, parser):
|
||||
parser.add_argument(
|
||||
"--dry-run",
|
||||
action="store_true",
|
||||
help="Zeigt an, was migriert würde, ohne Änderungen vorzunehmen.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--limit",
|
||||
type=int,
|
||||
default=0,
|
||||
help="Maximale Anzahl Einträge (0 = alle).",
|
||||
)
|
||||
|
||||
def handle(self, *args, **options):
|
||||
dry_run = options["dry_run"]
|
||||
limit = options["limit"]
|
||||
|
||||
links = DokumentLink.objects.select_related(
|
||||
"destinataer", "land", "paechter", "verpachtung"
|
||||
).order_by("pk")
|
||||
|
||||
if limit > 0:
|
||||
links = links[:limit]
|
||||
|
||||
total = links.count()
|
||||
self.stdout.write(f"Gefundene DokumentLinks: {total}")
|
||||
|
||||
if dry_run:
|
||||
self.stdout.write(self.style.WARNING("DRY-RUN – keine Datenbankänderungen."))
|
||||
|
||||
created = 0
|
||||
skipped = 0
|
||||
|
||||
for link in links:
|
||||
# Bereits migriert?
|
||||
if DokumentDatei.objects.filter(
|
||||
paperless_dokument_id=link.paperless_document_id
|
||||
).exists():
|
||||
skipped += 1
|
||||
continue
|
||||
|
||||
titel = link.titel or f"Paperless #{link.paperless_document_id}"
|
||||
kontext = link.kontext or _guess_kontext(titel)
|
||||
|
||||
if dry_run:
|
||||
self.stdout.write(
|
||||
f" [DRY] Würde anlegen: {titel!r} (kontext={kontext}, "
|
||||
f"paperless_id={link.paperless_document_id})"
|
||||
)
|
||||
created += 1
|
||||
continue
|
||||
|
||||
with transaction.atomic():
|
||||
dok = DokumentDatei(
|
||||
titel=titel,
|
||||
beschreibung=link.beschreibung or "",
|
||||
kontext=kontext,
|
||||
paperless_dokument_id=link.paperless_document_id,
|
||||
)
|
||||
# Assign FKs by ID (DokumentLink stores raw UUIDs, not FK relations)
|
||||
if link.destinataer_id:
|
||||
dok.destinataer_id = link.destinataer_id
|
||||
if link.land_id:
|
||||
dok.land_id = link.land_id
|
||||
if link.paechter_id:
|
||||
dok.paechter_id = link.paechter_id
|
||||
if link.land_verpachtung_id:
|
||||
dok.verpachtung_id = link.land_verpachtung_id
|
||||
dok.save()
|
||||
dok.update_suchvektor()
|
||||
created += 1
|
||||
|
||||
self.stdout.write(
|
||||
self.style.SUCCESS(
|
||||
f"Fertig: {created} angelegt, {skipped} übersprungen (bereits migriert)."
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def _guess_kontext(title_lower: str) -> str:
|
||||
"""Leitet den Kontext-Code aus dem Titel ab."""
|
||||
t = title_lower.lower()
|
||||
if any(kw in t for kw in ["pachtvertrag", "pachtvertr"]):
|
||||
return "pachtvertrag"
|
||||
if any(kw in t for kw in ["antrag", "förderantrag"]):
|
||||
return "antrag"
|
||||
if any(kw in t for kw in ["nachweis", "verwendungsnachweis"]):
|
||||
return "verwendungsnachweis"
|
||||
if any(kw in t for kw in ["rechnung"]):
|
||||
return "rechnung"
|
||||
if any(kw in t for kw in ["bericht", "jahresbericht"]):
|
||||
return "bericht"
|
||||
if any(kw in t for kw in ["karte", "landkarte", "flurkarte"]):
|
||||
return "landkarte"
|
||||
if any(kw in t for kw in ["bescheid"]):
|
||||
return "bescheid"
|
||||
if any(kw in t for kw in ["korrespondenz", "brief"]):
|
||||
return "korrespondenz"
|
||||
if any(kw in t for kw in ["studium", "immatrikulation", "zeugnis"]):
|
||||
return "studiennachweis"
|
||||
return "anderes"
|
||||
@@ -0,0 +1,90 @@
|
||||
"""
|
||||
Migriert Legacy-Pachtdaten von Land-Feldern zu LandVerpachtung-Einträgen.
|
||||
|
||||
Die alte Struktur speichert Pachtdaten direkt auf dem Land-Model
|
||||
(aktueller_paechter, pachtbeginn, pachtende, etc.).
|
||||
Die neue Struktur nutzt das LandVerpachtung-Model (1:n).
|
||||
"""
|
||||
|
||||
from decimal import Decimal
|
||||
|
||||
from django.core.management.base import BaseCommand
|
||||
from django.db import transaction
|
||||
|
||||
from stiftung.models import Land, LandVerpachtung
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = "Migriert Land-Pachtfelder zu LandVerpachtung-Einträgen"
|
||||
|
||||
def add_arguments(self, parser):
|
||||
parser.add_argument(
|
||||
"--dry-run",
|
||||
action="store_true",
|
||||
help="Zeigt nur an, was gemacht würde",
|
||||
)
|
||||
|
||||
def handle(self, *args, **options):
|
||||
dry_run = options["dry_run"]
|
||||
|
||||
lands = Land.objects.filter(
|
||||
aktueller_paechter__isnull=False,
|
||||
).select_related("aktueller_paechter")
|
||||
|
||||
self.stdout.write(f"Land-Einträge mit aktueller_paechter: {lands.count()}")
|
||||
|
||||
created = 0
|
||||
skipped = 0
|
||||
|
||||
with transaction.atomic():
|
||||
for land in lands:
|
||||
# Skip if LandVerpachtung already exists for this land+paechter
|
||||
existing = LandVerpachtung.objects.filter(
|
||||
land=land, paechter=land.aktueller_paechter
|
||||
).exists()
|
||||
if existing:
|
||||
self.stdout.write(
|
||||
self.style.WARNING(f" Übersprungen: {land} (bereits migriert)")
|
||||
)
|
||||
skipped += 1
|
||||
continue
|
||||
|
||||
vertragsnummer = f"LEGACY-{land.lfd_nr}"
|
||||
verpachtete_flaeche = land.verp_flaeche_aktuell or land.groesse_qm or Decimal("1.00")
|
||||
pachtzins = land.pachtzins_pauschal or Decimal("0.00")
|
||||
|
||||
self.stdout.write(
|
||||
f" Migriere: {land} -> {land.aktueller_paechter} "
|
||||
f"(Beginn={land.pachtbeginn}, Ende={land.pachtende}, "
|
||||
f"Fläche={verpachtete_flaeche}qm, Pachtzins={pachtzins}€)"
|
||||
)
|
||||
|
||||
if not dry_run:
|
||||
LandVerpachtung.objects.create(
|
||||
land=land,
|
||||
paechter=land.aktueller_paechter,
|
||||
vertragsnummer=vertragsnummer,
|
||||
pachtbeginn=land.pachtbeginn or land.erstellt_am.date(),
|
||||
pachtende=land.pachtende,
|
||||
verlaengerung_klausel=land.verlaengerung_klausel,
|
||||
verpachtete_flaeche=verpachtete_flaeche,
|
||||
pachtzins_pauschal=pachtzins,
|
||||
pachtzins_pro_ha=land.pachtzins_pro_ha,
|
||||
zahlungsweise=land.zahlungsweise or "jaehrlich",
|
||||
ust_option=land.ust_option,
|
||||
ust_satz=land.ust_satz or Decimal("19.00"),
|
||||
grundsteuer_umlage=land.grundsteuer_umlage,
|
||||
versicherungen_umlage=land.versicherungen_umlage,
|
||||
verbandsbeitraege_umlage=land.verbandsbeitraege_umlage,
|
||||
jagdpacht_anteil_umlage=land.jagdpacht_anteil_umlage,
|
||||
status="aktiv",
|
||||
bemerkungen=f"Automatisch migriert aus Land-Feldern (Lfd.Nr. {land.lfd_nr})",
|
||||
)
|
||||
created += 1
|
||||
|
||||
action = "würden erstellt" if dry_run else "erstellt"
|
||||
self.stdout.write(
|
||||
self.style.SUCCESS(
|
||||
f"\n{created} LandVerpachtung-Einträge {action}, {skipped} übersprungen."
|
||||
)
|
||||
)
|
||||
50
app/stiftung/management/commands/restore_vorlagen.py
Normal file
50
app/stiftung/management/commands/restore_vorlagen.py
Normal file
@@ -0,0 +1,50 @@
|
||||
"""Management-Command: Stellt alle DokumentVorlage-Einträge aus den Originaldateien wieder her."""
|
||||
|
||||
from django.core.management.base import BaseCommand
|
||||
|
||||
from stiftung.models import DokumentVorlage
|
||||
from stiftung.utils.vorlagen import get_vorlage_original
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = "Stellt alle DokumentVorlage-Einträge aus den Original-Dateien wieder her."
|
||||
|
||||
def add_arguments(self, parser):
|
||||
parser.add_argument(
|
||||
"--dry-run",
|
||||
action="store_true",
|
||||
help="Zeigt nur an, was geändert würde, ohne tatsächlich zu ändern.",
|
||||
)
|
||||
|
||||
def handle(self, *args, **options):
|
||||
dry_run = options["dry_run"]
|
||||
vorlagen = DokumentVorlage.objects.all()
|
||||
|
||||
if not vorlagen.exists():
|
||||
self.stdout.write(self.style.WARNING("Keine DokumentVorlage-Einträge gefunden."))
|
||||
return
|
||||
|
||||
restored = 0
|
||||
skipped = 0
|
||||
for vorlage in vorlagen:
|
||||
try:
|
||||
original = get_vorlage_original(vorlage.schluessel)
|
||||
except FileNotFoundError:
|
||||
self.stdout.write(
|
||||
self.style.WARNING(f" SKIP: {vorlage.schluessel} — Original-Datei nicht gefunden")
|
||||
)
|
||||
skipped += 1
|
||||
continue
|
||||
|
||||
if dry_run:
|
||||
self.stdout.write(f" WÜRDE WIEDERHERSTELLEN: {vorlage.schluessel}")
|
||||
else:
|
||||
vorlage.html_inhalt = original
|
||||
vorlage.save(update_fields=["html_inhalt", "zuletzt_bearbeitet_am"])
|
||||
self.stdout.write(self.style.SUCCESS(f" OK: {vorlage.schluessel}"))
|
||||
restored += 1
|
||||
|
||||
action = "würden wiederhergestellt" if dry_run else "wiederhergestellt"
|
||||
self.stdout.write(
|
||||
self.style.SUCCESS(f"\n{restored} Vorlagen {action}, {skipped} übersprungen.")
|
||||
)
|
||||
30
app/stiftung/migrations/0046_briefvorlage_model.py
Normal file
30
app/stiftung/migrations/0046_briefvorlage_model.py
Normal file
@@ -0,0 +1,30 @@
|
||||
# Generated by Django 5.0.6 on 2026-03-10 22:37
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('stiftung', '0045_add_serienbrief_editable_fields'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='BriefVorlage',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('name', models.CharField(max_length=100, verbose_name='Vorlagenname')),
|
||||
('beschreibung', models.TextField(blank=True, help_text='Kurze Beschreibung des Verwendungszwecks dieser Vorlage.', verbose_name='Beschreibung')),
|
||||
('briefvorlage', models.TextField(help_text='HTML-Text des Briefs. Verfügbare Platzhalter: {{ anrede }}, {{ vorname }}, {{ nachname }}, {{ strasse }}, {{ plz }}, {{ ort }}, {{ datum }}, {{ uhrzeit }}, {{ veranstaltungsort }}, {{ gasthaus_adresse }}', verbose_name='Brieftext (HTML)')),
|
||||
('betreff', models.CharField(blank=True, help_text='Wird beim Laden der Vorlage in die Veranstaltung übernommen. Leer = unveränderter Betreff.', max_length=300, verbose_name='Standard-Betreff')),
|
||||
('erstellt_am', models.DateTimeField(auto_now_add=True)),
|
||||
('aktualisiert_am', models.DateTimeField(auto_now=True)),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Briefvorlage',
|
||||
'verbose_name_plural': 'Briefvorlagen',
|
||||
'ordering': ['name'],
|
||||
},
|
||||
),
|
||||
]
|
||||
45
app/stiftung/migrations/0047_phase2_zahlungs_pipeline.py
Normal file
45
app/stiftung/migrations/0047_phase2_zahlungs_pipeline.py
Normal file
@@ -0,0 +1,45 @@
|
||||
# Generated by Django 5.0.6 on 2026-03-11 10:39
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('stiftung', '0046_briefvorlage_model'),
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
]
|
||||
|
||||
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')]},
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='destinataerunterstuetzung',
|
||||
name='erstellt_von',
|
||||
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='erstellte_unterstuetzungen', to=settings.AUTH_USER_MODEL, verbose_name='Erstellt von'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='destinataerunterstuetzung',
|
||||
name='freigegeben_am',
|
||||
field=models.DateField(blank=True, null=True, verbose_name='Freigegeben am'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='destinataerunterstuetzung',
|
||||
name='freigegeben_von',
|
||||
field=models.ForeignKey(blank=True, help_text='Muss ein anderer Nutzer als der Ersteller sein (Vier-Augen-Prinzip)', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='freigegebene_unterstuetzungen', to=settings.AUTH_USER_MODEL, verbose_name='Freigegeben von (4-Augen)'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='destinataerunterstuetzung',
|
||||
name='ausgezahlt_von',
|
||||
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='ausgezahlte_unterstuetzungen', to=settings.AUTH_USER_MODEL, verbose_name='Ausgezahlt von'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='destinataerunterstuetzung',
|
||||
name='status',
|
||||
field=models.CharField(choices=[('geplant', 'Offen'), ('faellig', 'Fällig'), ('nachweis_eingereicht', 'Nachweis eingereicht'), ('freigegeben', 'Freigegeben (4-Augen)'), ('in_bearbeitung', 'In Bearbeitung'), ('ausgezahlt', 'Überwiesen'), ('abgeschlossen', 'Abgeschlossen'), ('storniert', 'Storniert')], default='geplant', max_length=20, verbose_name='Status'),
|
||||
),
|
||||
]
|
||||
51
app/stiftung/migrations/0048_phase3_dms_dokument_datei.py
Normal file
51
app/stiftung/migrations/0048_phase3_dms_dokument_datei.py
Normal file
@@ -0,0 +1,51 @@
|
||||
# Generated by Django 5.0.6 on 2026-03-11 11:09
|
||||
|
||||
import django.contrib.postgres.indexes
|
||||
import django.contrib.postgres.search
|
||||
import django.db.models.deletion
|
||||
import stiftung.models.dokumente
|
||||
import uuid
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('stiftung', '0047_phase2_zahlungs_pipeline'),
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='DokumentDatei',
|
||||
fields=[
|
||||
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
|
||||
('titel', models.CharField(max_length=255, verbose_name='Titel')),
|
||||
('beschreibung', models.TextField(blank=True, verbose_name='Beschreibung')),
|
||||
('kontext', 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'), ('anderes', 'Sonstiges')], default='anderes', max_length=30, verbose_name='Dokumententyp')),
|
||||
('datei', models.FileField(upload_to=stiftung.models.dokumente.dokument_upload_path, verbose_name='Datei')),
|
||||
('dateiname_original', models.CharField(blank=True, max_length=255, verbose_name='Originaldateiname')),
|
||||
('dateityp', models.CharField(blank=True, max_length=100, verbose_name='MIME-Typ')),
|
||||
('dateigroesse', models.PositiveIntegerField(default=0, verbose_name='Dateigröße (Bytes)')),
|
||||
('inhaltstext', models.TextField(blank=True, help_text='Automatisch befüllt für PDF/TXT-Dateien. Basis für Volltextsuche.', verbose_name='Extrahierter Textinhalt')),
|
||||
('suchvektor', django.contrib.postgres.search.SearchVectorField(blank=True, null=True, verbose_name='Such-Vektor (FTS)')),
|
||||
('paperless_dokument_id', models.IntegerField(blank=True, help_text='Wird nach vollständiger Migration entfernt.', null=True, verbose_name='Paperless-ID (Migration)')),
|
||||
('erstellt_am', models.DateTimeField(auto_now_add=True, verbose_name='Erstellt am')),
|
||||
('aktualisiert_am', models.DateTimeField(auto_now=True, verbose_name='Aktualisiert am')),
|
||||
('destinataer', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='dms_dokumente', to='stiftung.destinataer', verbose_name='Destinatär')),
|
||||
('erstellt_von', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='hochgeladene_dokumente', to=settings.AUTH_USER_MODEL, verbose_name='Erstellt von')),
|
||||
('foerderung', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='dms_dokumente', to='stiftung.foerderung', verbose_name='Förderung')),
|
||||
('land', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='dms_dokumente', to='stiftung.land', verbose_name='Länderei')),
|
||||
('paechter', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='dms_dokumente', to='stiftung.paechter', verbose_name='Pächter')),
|
||||
('rentmeister', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='dms_dokumente', to='stiftung.rentmeister', verbose_name='Rentmeister')),
|
||||
('verpachtung', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='dms_dokumente', to='stiftung.landverpachtung', verbose_name='Verpachtung')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Dokument',
|
||||
'verbose_name_plural': 'Dokumente (DMS)',
|
||||
'ordering': ['-erstellt_am'],
|
||||
'indexes': [django.contrib.postgres.indexes.GinIndex(fields=['suchvektor'], name='dms_suchvektor_gin_idx'), models.Index(fields=['kontext'], name='stiftung_do_kontext_c6a21e_idx'), models.Index(fields=['destinataer', 'kontext'], name='stiftung_do_destina_1189f2_idx'), models.Index(fields=['land', 'kontext'], name='stiftung_do_land_id_6668ac_idx'), models.Index(fields=['paechter', 'kontext'], name='stiftung_do_paechte_05586e_idx')],
|
||||
},
|
||||
),
|
||||
]
|
||||
33
app/stiftung/migrations/0049_phase3_email_dms_m2m.py
Normal file
33
app/stiftung/migrations/0049_phase3_email_dms_m2m.py
Normal file
@@ -0,0 +1,33 @@
|
||||
# Generated by Django 5.0.6 on 2026-03-12 08:11
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('stiftung', '0048_phase3_dms_dokument_datei'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='destinataeremaileingang',
|
||||
name='dokument_dateien',
|
||||
field=models.ManyToManyField(blank=True, help_text='Automatisch befüllte Anhänge als Django-DMS-Dateien.', related_name='email_eingaenge', to='stiftung.dokumentdatei', verbose_name='DMS-Dokumente (Anhänge)'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='appconfiguration',
|
||||
name='category',
|
||||
field=models.CharField(choices=[('paperless', 'Paperless Integration'), ('email', 'E-Mail / IMAP'), ('general', 'General Settings'), ('corporate', 'Corporate Identity'), ('notifications', 'Notifications'), ('system', 'System Settings')], default='general', max_length=50, verbose_name='Category'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='appconfiguration',
|
||||
name='setting_type',
|
||||
field=models.CharField(choices=[('text', 'Text'), ('password', 'Password'), ('number', 'Number'), ('boolean', 'Boolean'), ('url', 'URL'), ('tag', 'Tag Name'), ('tag_id', 'Tag ID')], default='text', max_length=20, verbose_name='Type'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='destinataeremaileingang',
|
||||
name='paperless_dokument_ids',
|
||||
field=models.JSONField(blank=True, default=list, help_text='Veraltet – wird nach vollständiger Migration entfernt. Neue Anhänge in dokument_dateien.', verbose_name='Paperless Dokument-IDs (Anhänge, veraltet)'),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,132 @@
|
||||
# Phase 4: Generalize EmailEingang + Rechnungsworkflow
|
||||
# - Rename DestinataerEmailEingang → EmailEingang
|
||||
# - Add kategorie, verwaltungskosten FK, land FK, verpachtung FK
|
||||
# - Expand status choices (rechnung_erfasst, zahlung_gebucht)
|
||||
# - Add verwaltungskosten FK to DokumentDatei
|
||||
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('stiftung', '0049_phase3_email_dms_m2m'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
# 1. Rename model (preserves DB table, updates Django state)
|
||||
migrations.RenameModel(
|
||||
old_name='DestinataerEmailEingang',
|
||||
new_name='EmailEingang',
|
||||
),
|
||||
|
||||
# 2. Add kategorie field to EmailEingang
|
||||
migrations.AddField(
|
||||
model_name='emaileingang',
|
||||
name='kategorie',
|
||||
field=models.CharField(
|
||||
choices=[
|
||||
('destinataer', 'Destinataer'),
|
||||
('rechnung', 'Rechnung'),
|
||||
('land_pacht', 'Grundstueck / Pacht'),
|
||||
('allgemein', 'Allgemein'),
|
||||
],
|
||||
default='allgemein',
|
||||
max_length=20,
|
||||
verbose_name='Kategorie',
|
||||
),
|
||||
),
|
||||
|
||||
# 3. Add verwaltungskosten FK to EmailEingang
|
||||
migrations.AddField(
|
||||
model_name='emaileingang',
|
||||
name='verwaltungskosten',
|
||||
field=models.ForeignKey(
|
||||
blank=True,
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.SET_NULL,
|
||||
related_name='email_eingaenge',
|
||||
to='stiftung.verwaltungskosten',
|
||||
verbose_name='Verwaltungskosten / Rechnung',
|
||||
),
|
||||
),
|
||||
|
||||
# 4. Add land FK to EmailEingang
|
||||
migrations.AddField(
|
||||
model_name='emaileingang',
|
||||
name='land',
|
||||
field=models.ForeignKey(
|
||||
blank=True,
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.SET_NULL,
|
||||
related_name='email_eingaenge',
|
||||
to='stiftung.land',
|
||||
verbose_name='Laenderei',
|
||||
),
|
||||
),
|
||||
|
||||
# 5. Add verpachtung FK to EmailEingang
|
||||
migrations.AddField(
|
||||
model_name='emaileingang',
|
||||
name='verpachtung',
|
||||
field=models.ForeignKey(
|
||||
blank=True,
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.SET_NULL,
|
||||
related_name='email_eingaenge',
|
||||
to='stiftung.landverpachtung',
|
||||
verbose_name='Verpachtung',
|
||||
),
|
||||
),
|
||||
|
||||
# 6. Update status choices on EmailEingang
|
||||
migrations.AlterField(
|
||||
model_name='emaileingang',
|
||||
name='status',
|
||||
field=models.CharField(
|
||||
choices=[
|
||||
('neu', 'Neu / Unbearbeitet'),
|
||||
('zugewiesen', 'Destinataer zugewiesen'),
|
||||
('verarbeitet', 'Verarbeitet'),
|
||||
('rechnung_erfasst', 'Rechnung erfasst'),
|
||||
('zahlung_gebucht', 'Zahlung gebucht'),
|
||||
('unbekannt', 'Unbekannter Absender'),
|
||||
('fehler', 'Fehler bei Verarbeitung'),
|
||||
],
|
||||
default='neu',
|
||||
max_length=20,
|
||||
verbose_name='Status',
|
||||
),
|
||||
),
|
||||
|
||||
# 7. Update Meta on EmailEingang
|
||||
migrations.AlterModelOptions(
|
||||
name='emaileingang',
|
||||
options={
|
||||
'ordering': ['-eingangsdatum'],
|
||||
'verbose_name': 'E-Mail-Eingang',
|
||||
'verbose_name_plural': 'E-Mail-Eingaenge',
|
||||
},
|
||||
),
|
||||
|
||||
# 8. Set kategorie='destinataer' for existing emails that have a destinataer FK
|
||||
migrations.RunSQL(
|
||||
sql="UPDATE stiftung_emaileingang SET kategorie = 'destinataer' WHERE destinataer_id IS NOT NULL;",
|
||||
reverse_sql=migrations.RunSQL.noop,
|
||||
),
|
||||
|
||||
# 9. Add verwaltungskosten FK to DokumentDatei
|
||||
migrations.AddField(
|
||||
model_name='dokumentdatei',
|
||||
name='verwaltungskosten',
|
||||
field=models.ForeignKey(
|
||||
blank=True,
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.SET_NULL,
|
||||
related_name='dms_dokumente',
|
||||
to='stiftung.verwaltungskosten',
|
||||
verbose_name='Verwaltungskosten / Rechnung',
|
||||
),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,29 @@
|
||||
# Generated by Django 5.0.6 on 2026-03-12 09:45
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('stiftung', '0050_generalize_email_rechnungsworkflow'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='emaileingang',
|
||||
name='destinataer',
|
||||
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='email_eingaenge', to='stiftung.destinataer', verbose_name='Destinataer'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='emaileingang',
|
||||
name='dokument_dateien',
|
||||
field=models.ManyToManyField(blank=True, help_text='Automatisch befuellte Anhaenge als Django-DMS-Dateien.', related_name='email_eingaenge', to='stiftung.dokumentdatei', verbose_name='DMS-Dokumente (Anhaenge)'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='emaileingang',
|
||||
name='paperless_dokument_ids',
|
||||
field=models.JSONField(blank=True, default=list, help_text='Veraltet – wird nach vollstaendiger Migration entfernt. Neue Anhaenge in dokument_dateien.', verbose_name='Paperless Dokument-IDs (Anhaenge, veraltet)'),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,23 @@
|
||||
# Generated by Django 5.0.6 on 2026-03-12 10:07
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('stiftung', '0051_alter_emaileingang_destinataer_and_more'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
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'), ('anderes', 'Sonstiges')], default='anderes', max_length=30, verbose_name='Dokumententyp'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='emaileingang',
|
||||
name='kategorie',
|
||||
field=models.CharField(choices=[('destinataer', 'Destinataer'), ('rechnung', 'Rechnung'), ('land_pacht', 'Grundstueck / Pacht'), ('stiftungsgeschichte', 'Stiftungsgeschichte'), ('allgemein', 'Allgemein')], default='allgemein', max_length=20, verbose_name='Kategorie'),
|
||||
),
|
||||
]
|
||||
18
app/stiftung/migrations/0053_geschichte_dokumente_m2m.py
Normal file
18
app/stiftung/migrations/0053_geschichte_dokumente_m2m.py
Normal file
@@ -0,0 +1,18 @@
|
||||
# Generated by Django 5.0.6 on 2026-03-12 10:40
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('stiftung', '0052_alter_dokumentdatei_kontext_and_more'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='geschichteseite',
|
||||
name='dokumente',
|
||||
field=models.ManyToManyField(blank=True, related_name='geschichte_seiten', to='stiftung.dokumentdatei', verbose_name='Verknüpfte Dokumente'),
|
||||
),
|
||||
]
|
||||
18
app/stiftung/migrations/0054_add_alkis_kennzeichen.py
Normal file
18
app/stiftung/migrations/0054_add_alkis_kennzeichen.py
Normal file
@@ -0,0 +1,18 @@
|
||||
# Generated by Django 5.0.6 on 2026-03-13 21:57
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('stiftung', '0053_geschichte_dokumente_m2m'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='land',
|
||||
name='alkis_kennzeichen',
|
||||
field=models.CharField(blank=True, help_text='z.B. 05300800400030______ — für direkte Verlinkung zum Katasteramt', max_length=30, null=True, verbose_name='ALKIS Flurstückskennzeichen'),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,18 @@
|
||||
# Generated by Django 5.0.6 on 2026-03-14 21:49
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('stiftung', '0054_add_alkis_kennzeichen'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='csvimport',
|
||||
name='import_type',
|
||||
field=models.CharField(choices=[('destinataere', 'Destinatäre'), ('paechter', 'Pächter'), ('laendereien', 'Ländereien'), ('verpachtungen', 'Verpachtungen'), ('foerderungen', 'Förderungen'), ('konten', 'Stiftungskonten'), ('verwaltungskosten', 'Verwaltungskosten'), ('rentmeister', 'Rentmeister'), ('personen', 'Personen (Legacy)')], max_length=20, verbose_name='Import-Typ'),
|
||||
),
|
||||
]
|
||||
211
app/stiftung/migrations/0056_agent_models.py
Normal file
211
app/stiftung/migrations/0056_agent_models.py
Normal file
@@ -0,0 +1,211 @@
|
||||
"""
|
||||
Migration 0056: AI Agent Models (AgentConfig, ChatSession, ChatMessage)
|
||||
+ can_use_agent Permission
|
||||
"""
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
import uuid
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("stiftung", "0055_add_import_types_for_unified_import_export"),
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name="AgentConfig",
|
||||
fields=[
|
||||
(
|
||||
"id",
|
||||
models.AutoField(
|
||||
auto_created=True,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
verbose_name="ID",
|
||||
),
|
||||
),
|
||||
(
|
||||
"provider",
|
||||
models.CharField(
|
||||
choices=[
|
||||
("ollama", "Ollama (lokal)"),
|
||||
("openai", "OpenAI"),
|
||||
("anthropic", "Anthropic"),
|
||||
],
|
||||
default="ollama",
|
||||
max_length=20,
|
||||
verbose_name="LLM-Provider",
|
||||
),
|
||||
),
|
||||
(
|
||||
"model_name",
|
||||
models.CharField(
|
||||
default="qwen2.5:3b",
|
||||
max_length=100,
|
||||
verbose_name="Modell-Name",
|
||||
),
|
||||
),
|
||||
(
|
||||
"ollama_url",
|
||||
models.CharField(
|
||||
default="http://ollama:11434",
|
||||
max_length=255,
|
||||
verbose_name="Ollama-URL",
|
||||
),
|
||||
),
|
||||
(
|
||||
"openai_api_key",
|
||||
models.CharField(
|
||||
blank=True,
|
||||
max_length=255,
|
||||
verbose_name="OpenAI API-Key",
|
||||
),
|
||||
),
|
||||
(
|
||||
"anthropic_api_key",
|
||||
models.CharField(
|
||||
blank=True,
|
||||
max_length=255,
|
||||
verbose_name="Anthropic API-Key",
|
||||
),
|
||||
),
|
||||
(
|
||||
"system_prompt",
|
||||
models.TextField(verbose_name="System-Prompt"),
|
||||
),
|
||||
(
|
||||
"allow_write",
|
||||
models.BooleanField(
|
||||
default=False,
|
||||
verbose_name="Schreib-Tools erlaubt",
|
||||
),
|
||||
),
|
||||
(
|
||||
"chat_retention_days",
|
||||
models.IntegerField(
|
||||
default=30,
|
||||
verbose_name="Chat-Verlauf Aufbewahrung (Tage)",
|
||||
),
|
||||
),
|
||||
],
|
||||
options={
|
||||
"verbose_name": "Agent-Konfiguration",
|
||||
"verbose_name_plural": "Agent-Konfiguration",
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name="ChatSession",
|
||||
fields=[
|
||||
(
|
||||
"id",
|
||||
models.UUIDField(
|
||||
default=uuid.uuid4,
|
||||
editable=False,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
),
|
||||
),
|
||||
(
|
||||
"title",
|
||||
models.CharField(
|
||||
blank=True,
|
||||
max_length=200,
|
||||
verbose_name="Titel",
|
||||
),
|
||||
),
|
||||
(
|
||||
"created_at",
|
||||
models.DateTimeField(auto_now_add=True, verbose_name="Erstellt"),
|
||||
),
|
||||
(
|
||||
"updated_at",
|
||||
models.DateTimeField(auto_now=True, verbose_name="Zuletzt aktiv"),
|
||||
),
|
||||
(
|
||||
"user",
|
||||
models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name="agent_sessions",
|
||||
to=settings.AUTH_USER_MODEL,
|
||||
verbose_name="Benutzer",
|
||||
),
|
||||
),
|
||||
],
|
||||
options={
|
||||
"verbose_name": "Chat-Sitzung",
|
||||
"verbose_name_plural": "Chat-Sitzungen",
|
||||
"ordering": ["-updated_at"],
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name="ChatMessage",
|
||||
fields=[
|
||||
(
|
||||
"id",
|
||||
models.UUIDField(
|
||||
default=uuid.uuid4,
|
||||
editable=False,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
),
|
||||
),
|
||||
(
|
||||
"role",
|
||||
models.CharField(
|
||||
choices=[
|
||||
("user", "Benutzer"),
|
||||
("assistant", "Assistent"),
|
||||
("tool", "Tool-Ergebnis"),
|
||||
],
|
||||
max_length=20,
|
||||
verbose_name="Rolle",
|
||||
),
|
||||
),
|
||||
(
|
||||
"content",
|
||||
models.TextField(verbose_name="Inhalt"),
|
||||
),
|
||||
(
|
||||
"tool_name",
|
||||
models.CharField(
|
||||
blank=True,
|
||||
max_length=100,
|
||||
verbose_name="Tool-Name",
|
||||
),
|
||||
),
|
||||
(
|
||||
"tool_call_id",
|
||||
models.CharField(
|
||||
blank=True,
|
||||
max_length=100,
|
||||
verbose_name="Tool-Call-ID",
|
||||
),
|
||||
),
|
||||
(
|
||||
"created_at",
|
||||
models.DateTimeField(auto_now_add=True, verbose_name="Erstellt"),
|
||||
),
|
||||
(
|
||||
"session",
|
||||
models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name="messages",
|
||||
to="stiftung.chatsession",
|
||||
verbose_name="Sitzung",
|
||||
),
|
||||
),
|
||||
],
|
||||
options={
|
||||
"verbose_name": "Chat-Nachricht",
|
||||
"verbose_name_plural": "Chat-Nachrichten",
|
||||
"ordering": ["created_at"],
|
||||
},
|
||||
),
|
||||
# Update ApplicationPermission to add can_use_agent
|
||||
# (No DB table change needed — this is a managed=False model)
|
||||
# The permission is added via the Meta.permissions list in system.py
|
||||
]
|
||||
@@ -0,0 +1,47 @@
|
||||
# Generated by Django 5.0.6 on 2026-03-14 22:22
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('stiftung', '0056_agent_models'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterModelOptions(
|
||||
name='applicationpermission',
|
||||
options={'default_permissions': (), 'managed': False, 'permissions': [('manage_destinataere', 'Kann Destinatäre verwalten'), ('view_destinataere', 'Kann Destinatäre anzeigen'), ('manage_land', 'Kann Ländereien verwalten'), ('view_land', 'Kann Ländereien anzeigen'), ('manage_paechter', 'Kann Pächter verwalten'), ('view_paechter', 'Kann Pächter anzeigen'), ('manage_verpachtungen', 'Kann Verpachtungen verwalten'), ('view_verpachtungen', 'Kann Verpachtungen anzeigen'), ('manage_foerderungen', 'Kann Förderungen verwalten'), ('view_foerderungen', 'Kann Förderungen anzeigen'), ('manage_documents', 'Kann Dokumente verwalten'), ('view_documents', 'Kann Dokumente anzeigen'), ('link_documents', 'Kann Dokumente verknüpfen'), ('manage_verwaltungskosten', 'Kann Verwaltungskosten verwalten'), ('view_verwaltungskosten', 'Kann Verwaltungskosten anzeigen'), ('approve_payments', 'Kann Zahlungen genehmigen'), ('manage_konten', 'Kann Stiftungskonten verwalten'), ('view_konten', 'Kann Stiftungskonten anzeigen'), ('manage_rentmeister', 'Kann Rentmeister verwalten'), ('view_rentmeister', 'Kann Rentmeister anzeigen'), ('access_administration', 'Kann Administration aufrufen'), ('view_audit_logs', 'Kann Audit-Logs anzeigen'), ('manage_backups', 'Kann Backups erstellen und verwalten'), ('manage_users', 'Kann Benutzer verwalten'), ('manage_permissions', 'Kann Berechtigungen verwalten'), ('manage_veranstaltungen', 'Kann Veranstaltungen verwalten'), ('view_veranstaltungen', 'Kann Veranstaltungen anzeigen'), ('import_data', 'Kann Daten importieren'), ('export_data', 'Kann Daten exportieren'), ('access_django_admin', 'Kann Django Admin aufrufen'), ('view_system_stats', 'Kann Systemstatistiken anzeigen'), ('can_use_agent', 'Kann AI-Assistenten nutzen')]},
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='agentconfig',
|
||||
name='allow_write',
|
||||
field=models.BooleanField(default=False, help_text='Achtung: Schreib-Zugriff auf Stiftungsdaten aktivieren', verbose_name='Schreib-Tools erlaubt'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='agentconfig',
|
||||
name='anthropic_api_key',
|
||||
field=models.CharField(blank=True, help_text='Nur erforderlich wenn Provider = Anthropic', max_length=255, verbose_name='Anthropic API-Key'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='agentconfig',
|
||||
name='id',
|
||||
field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='agentconfig',
|
||||
name='openai_api_key',
|
||||
field=models.CharField(blank=True, help_text='Nur erforderlich wenn Provider = OpenAI', max_length=255, verbose_name='OpenAI API-Key'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='agentconfig',
|
||||
name='system_prompt',
|
||||
field=models.TextField(default="Du bist RentmeisterAI, der KI-Assistent der van Hees-Theyssen-Vogel'schen Stiftung.\n\nDu hast Zugriff auf die Stiftungsdatenbank und kannst Informationen zu Destinatären, Ländereien, Finanzen, Förderungen und weiteren Stiftungsdaten abrufen.\n\nRegeln:\n- Antworte stets auf Deutsch, präzise und sachlich.\n- Schütze personenbezogene Daten – gib keine unnötigen Details heraus.\n- Du kannst keine Daten ändern, nur lesen.\n- Bei rechtlichen oder steuerlichen Fragen weise auf Fachberatung hin.\n- Wenn du dir unsicher bist, sage das klar.\n", verbose_name='System-Prompt'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='chatsession',
|
||||
name='title',
|
||||
field=models.CharField(blank=True, help_text='Automatisch aus erster Nachricht generiert', max_length=200, verbose_name='Titel'),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,23 @@
|
||||
# Generated by Django 5.0.6 on 2026-03-15 16:49
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('stiftung', '0057_alter_applicationpermission_options_and_more'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='vierteljahresnachweis',
|
||||
name='nachweis_dokumente',
|
||||
field=models.ManyToManyField(blank=True, help_text='Dokumente aus dem DMS, die als Nachweise fuer dieses Quartal dienen.', related_name='quartalsnachweise', to='stiftung.dokumentdatei', verbose_name='Verknuepfte DMS-Dokumente'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='dokumentdatei',
|
||||
name='kontext',
|
||||
field=models.CharField(choices=[('pachtvertrag', 'Pachtvertrag'), ('antrag', 'Antrag / Förderantrag'), ('verwendungsnachweis', 'Verwendungsnachweis'), ('studiennachweis', 'Studiennachweis'), ('rechnung', 'Rechnung'), ('vertrag', 'Vertrag'), ('bericht', 'Bericht'), ('landkarte', 'Landkarte / Kataster'), ('korrespondenz', 'Korrespondenz / Brief'), ('bescheid', 'Bescheid / Behörde'), ('stiftungsgeschichte', 'Stiftungsgeschichte / Archiv'), ('email', 'E-Mail-Nachricht'), ('anderes', 'Sonstiges')], default='anderes', max_length=30, verbose_name='Dokumententyp'),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,29 @@
|
||||
# Generated by Django 5.0.6 on 2026-03-15 17:25
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('stiftung', '0058_dms_email_kontext_und_nachweis_dokumente'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='vierteljahresnachweis',
|
||||
name='einkommenssituation_dms_dokument',
|
||||
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='als_einkommensnachweis', to='stiftung.dokumentdatei', verbose_name='Einkommenssituation (DMS-Dokument)'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='vierteljahresnachweis',
|
||||
name='studiennachweis_dms_dokument',
|
||||
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='als_studiennachweis', to='stiftung.dokumentdatei', verbose_name='Studiennachweis (DMS-Dokument)'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='vierteljahresnachweis',
|
||||
name='vermogenssituation_dms_dokument',
|
||||
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='als_vermoegensnachweis', to='stiftung.dokumentdatei', verbose_name='Vermoegenssituation (DMS-Dokument)'),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,59 @@
|
||||
# Generated by Django 5.0.6 on 2026-03-15 23:02
|
||||
|
||||
import django.db.models.deletion
|
||||
import uuid
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('stiftung', '0059_nachweis_kategorie_dms_felder'),
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='OnboardingEinladung',
|
||||
fields=[
|
||||
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
|
||||
('token', models.CharField(db_index=True, max_length=128, unique=True, verbose_name='Token')),
|
||||
('email', models.EmailField(max_length=254, verbose_name='E-Mail-Adresse des Eingeladenen')),
|
||||
('vorname', models.CharField(blank=True, max_length=100, verbose_name='Vorname (optional)')),
|
||||
('nachname', models.CharField(blank=True, max_length=100, verbose_name='Nachname (optional)')),
|
||||
('gueltig_bis', models.DateTimeField(verbose_name='Gültig bis')),
|
||||
('erstellt_am', models.DateTimeField(auto_now_add=True, verbose_name='Erstellt am')),
|
||||
('abgeschlossen_am', models.DateTimeField(blank=True, null=True, verbose_name='Abgeschlossen am')),
|
||||
('status', models.CharField(choices=[('offen', 'Offen'), ('abgeschlossen', 'Abgeschlossen'), ('abgelaufen', 'Abgelaufen'), ('widerrufen', 'Widerrufen')], default='offen', max_length=20, verbose_name='Status')),
|
||||
('notizen', models.TextField(blank=True, verbose_name='Interne Notizen')),
|
||||
('destinataer', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='onboarding_einladung', to='stiftung.destinataer', verbose_name='Resultierender Destinatär')),
|
||||
('eingeladen_von', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='onboarding_einladungen', to=settings.AUTH_USER_MODEL, verbose_name='Eingeladen von')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Onboarding-Einladung',
|
||||
'verbose_name_plural': 'Onboarding-Einladungen',
|
||||
'ordering': ['-erstellt_am'],
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='UploadToken',
|
||||
fields=[
|
||||
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
|
||||
('token', models.CharField(db_index=True, max_length=128, unique=True, verbose_name='Token')),
|
||||
('gueltig_bis', models.DateTimeField(verbose_name='Gültig bis')),
|
||||
('erstellt_am', models.DateTimeField(auto_now_add=True, verbose_name='Erstellt am')),
|
||||
('eingeloest_am', models.DateTimeField(blank=True, null=True, verbose_name='Eingelöst am')),
|
||||
('ist_aktiv', models.BooleanField(default=True, verbose_name='Aktiv')),
|
||||
('ip_hash', models.CharField(blank=True, max_length=64, null=True, verbose_name='IP-Hash (SHA-256)')),
|
||||
('erinnerung_gesendet', models.BooleanField(default=False, verbose_name='Erinnerung gesendet')),
|
||||
('destinataer', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='upload_tokens', to='stiftung.destinataer', verbose_name='Destinatär')),
|
||||
('nachweis', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='upload_tokens', to='stiftung.vierteljahresnachweis', verbose_name='Nachweis')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Upload-Token',
|
||||
'verbose_name_plural': 'Upload-Token',
|
||||
'ordering': ['-erstellt_am'],
|
||||
},
|
||||
),
|
||||
]
|
||||
160
app/stiftung/migrations/0061_dokument_vorlage.py
Normal file
160
app/stiftung/migrations/0061_dokument_vorlage.py
Normal file
@@ -0,0 +1,160 @@
|
||||
import uuid
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
def seed_vorlagen(apps, schema_editor):
|
||||
"""Seed initial DokumentVorlage records from file templates."""
|
||||
import os
|
||||
|
||||
from django.template.loader import get_template
|
||||
from django.template import TemplateDoesNotExist
|
||||
|
||||
DokumentVorlage = apps.get_model("stiftung", "DokumentVorlage")
|
||||
|
||||
# Map: (schluessel, bezeichnung, kategorie, variablen)
|
||||
vorlagen_def = [
|
||||
(
|
||||
"pdf/bestaetigung.html",
|
||||
"Bestätigung PDF",
|
||||
"pdf",
|
||||
{
|
||||
"destinataer.vorname": "Vorname",
|
||||
"destinataer.nachname": "Nachname",
|
||||
"destinataer.anrede": "Anrede (Herr/Frau)",
|
||||
"destinataer.strasse": "Straße",
|
||||
"destinataer.plz": "PLZ",
|
||||
"destinataer.ort": "Ort",
|
||||
"betrag_quartal": "Betrag pro Quartal",
|
||||
"betrag_jaehrlich": "Jährlicher Betrag",
|
||||
"zeitraum": "Förderzeitraum",
|
||||
"zweck": "Förderzweck",
|
||||
"unterstuetzungen": "Liste der Unterstützungen",
|
||||
"gesamtbetrag": "Gesamtbetrag",
|
||||
"datum": "Datum der Erstellung",
|
||||
},
|
||||
),
|
||||
(
|
||||
"email/bestaetigung.html",
|
||||
"Bestätigung E-Mail (HTML)",
|
||||
"email",
|
||||
{
|
||||
"destinataer.vorname": "Vorname",
|
||||
"destinataer.nachname": "Nachname",
|
||||
"destinataer.anrede": "Anrede",
|
||||
"zeitraum": "Förderzeitraum",
|
||||
"gesamtbetrag": "Gesamtbetrag",
|
||||
"datum": "Datum",
|
||||
},
|
||||
),
|
||||
(
|
||||
"email/nachweis_aufforderung.html",
|
||||
"Nachweis-Aufforderung E-Mail (HTML)",
|
||||
"email",
|
||||
{
|
||||
"destinataer.vorname": "Vorname",
|
||||
"destinataer.nachname": "Nachname",
|
||||
"halbjahr_label": "Halbjahr-Bezeichnung",
|
||||
"upload_url": "Upload-URL",
|
||||
"gueltig_bis": "Gültig bis",
|
||||
"qr_code_base64": "QR-Code (base64)",
|
||||
"ist_erinnerung": "True wenn Erinnerung",
|
||||
},
|
||||
),
|
||||
(
|
||||
"email/nachweis_aufforderung.txt",
|
||||
"Nachweis-Aufforderung E-Mail (Text)",
|
||||
"email",
|
||||
{
|
||||
"destinataer.vorname": "Vorname",
|
||||
"destinataer.nachname": "Nachname",
|
||||
"halbjahr_label": "Halbjahr-Bezeichnung",
|
||||
"upload_url": "Upload-URL",
|
||||
"gueltig_bis": "Gültig bis",
|
||||
"ist_erinnerung": "True wenn Erinnerung",
|
||||
},
|
||||
),
|
||||
(
|
||||
"email/onboarding_einladung.html",
|
||||
"Onboarding-Einladung E-Mail (HTML)",
|
||||
"email",
|
||||
{
|
||||
"einladung.vorname": "Vorname",
|
||||
"einladung.nachname": "Nachname",
|
||||
"onboarding_url": "Onboarding-URL",
|
||||
"gueltig_bis": "Gültig bis",
|
||||
},
|
||||
),
|
||||
(
|
||||
"email/onboarding_einladung.txt",
|
||||
"Onboarding-Einladung E-Mail (Text)",
|
||||
"email",
|
||||
{
|
||||
"einladung.vorname": "Vorname",
|
||||
"einladung.nachname": "Nachname",
|
||||
"onboarding_url": "Onboarding-URL",
|
||||
"gueltig_bis": "Gültig bis",
|
||||
},
|
||||
),
|
||||
]
|
||||
|
||||
templates_dir = os.path.join(settings.BASE_DIR, "templates")
|
||||
|
||||
for schluessel, bezeichnung, kategorie, variablen in vorlagen_def:
|
||||
template_path = os.path.join(templates_dir, schluessel)
|
||||
if os.path.exists(template_path):
|
||||
with open(template_path, "r", encoding="utf-8") as f:
|
||||
html_inhalt = f.read()
|
||||
DokumentVorlage.objects.get_or_create(
|
||||
schluessel=schluessel,
|
||||
defaults={
|
||||
"bezeichnung": bezeichnung,
|
||||
"kategorie": kategorie,
|
||||
"html_inhalt": html_inhalt,
|
||||
"verfuegbare_variablen": variablen,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("stiftung", "0060_portal_upload_token_onboarding"),
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name="DokumentVorlage",
|
||||
fields=[
|
||||
("id", models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
|
||||
("schluessel", models.CharField(help_text="Interner Template-Pfad, z.B. pdf/bestaetigung.html", max_length=200, unique=True, verbose_name="Schlüssel")),
|
||||
("bezeichnung", models.CharField(max_length=200, verbose_name="Bezeichnung")),
|
||||
("kategorie", models.CharField(
|
||||
choices=[("pdf", "PDF-Dokument"), ("email", "E-Mail"), ("bericht", "Bericht"), ("serienbrief", "Serienbrief")],
|
||||
max_length=30,
|
||||
verbose_name="Kategorie",
|
||||
)),
|
||||
("html_inhalt", models.TextField(verbose_name="HTML-Inhalt")),
|
||||
("verfuegbare_variablen", models.JSONField(blank=True, default=dict, help_text="JSON-Dokumentation der verfügbaren Template-Variablen", verbose_name="Verfügbare Variablen")),
|
||||
("zuletzt_bearbeitet_am", models.DateTimeField(auto_now=True, verbose_name="Zuletzt bearbeitet")),
|
||||
("erstellt_am", models.DateTimeField(auto_now_add=True, verbose_name="Erstellt am")),
|
||||
("zuletzt_bearbeitet_von", models.ForeignKey(
|
||||
blank=True,
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.SET_NULL,
|
||||
related_name="bearbeitete_vorlagen",
|
||||
to=settings.AUTH_USER_MODEL,
|
||||
verbose_name="Zuletzt bearbeitet von",
|
||||
)),
|
||||
],
|
||||
options={
|
||||
"verbose_name": "Dokument-Vorlage",
|
||||
"verbose_name_plural": "Dokument-Vorlagen",
|
||||
"ordering": ["kategorie", "bezeichnung"],
|
||||
},
|
||||
),
|
||||
migrations.RunPython(seed_vorlagen, migrations.RunPython.noop),
|
||||
]
|
||||
@@ -0,0 +1,57 @@
|
||||
"""Seed Veranstaltungseinladung (Serienbrief) into DokumentVorlage."""
|
||||
|
||||
import os
|
||||
|
||||
from django.conf import settings
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
def seed_veranstaltungseinladung(apps, schema_editor):
|
||||
DokumentVorlage = apps.get_model("stiftung", "DokumentVorlage")
|
||||
|
||||
schluessel = "stiftung/veranstaltung/serienbrief_pdf.html"
|
||||
template_path = os.path.join(settings.BASE_DIR, "templates", schluessel)
|
||||
|
||||
if os.path.exists(template_path):
|
||||
with open(template_path, "r", encoding="utf-8") as f:
|
||||
html_inhalt = f.read()
|
||||
|
||||
DokumentVorlage.objects.get_or_create(
|
||||
schluessel=schluessel,
|
||||
defaults={
|
||||
"bezeichnung": "Veranstaltungseinladung (Serienbrief)",
|
||||
"kategorie": "serienbrief",
|
||||
"html_inhalt": html_inhalt,
|
||||
"verfuegbare_variablen": {
|
||||
"veranstaltung.titel": "Titel der Veranstaltung",
|
||||
"veranstaltung.datum": "Datum der Veranstaltung",
|
||||
"veranstaltung.uhrzeit": "Uhrzeit",
|
||||
"veranstaltung.ort": "Ort / Gasthaus",
|
||||
"veranstaltung.adresse": "Adresse des Veranstaltungsorts",
|
||||
"veranstaltung.betreff": "Betreffzeile (optional)",
|
||||
"veranstaltung.briefvorlage": "Freier Brieftext (HTML, optional)",
|
||||
"veranstaltung.unterschrift_1_name": "Name Unterschrift 1",
|
||||
"veranstaltung.unterschrift_1_titel": "Titel Unterschrift 1",
|
||||
"veranstaltung.unterschrift_2_name": "Name Unterschrift 2",
|
||||
"veranstaltung.unterschrift_2_titel": "Titel Unterschrift 2",
|
||||
"teilnehmer": "Liste der Teilnehmer (for-Schleife)",
|
||||
"t.anrede": "Anrede des Teilnehmers (in Schleife)",
|
||||
"t.vorname": "Vorname des Teilnehmers",
|
||||
"t.nachname": "Nachname des Teilnehmers",
|
||||
"t.strasse": "Straße des Teilnehmers",
|
||||
"t.plz": "PLZ des Teilnehmers",
|
||||
"t.ort": "Ort des Teilnehmers",
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("stiftung", "0061_dokument_vorlage"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RunPython(seed_veranstaltungseinladung, migrations.RunPython.noop),
|
||||
]
|
||||
18
app/stiftung/migrations/0063_add_anrede_to_destinataer.py
Normal file
18
app/stiftung/migrations/0063_add_anrede_to_destinataer.py
Normal file
@@ -0,0 +1,18 @@
|
||||
# Generated by Django 5.0.6 on 2026-03-21 21:45
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('stiftung', '0062_veranstaltungseinladung_vorlage'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='destinataer',
|
||||
name='anrede',
|
||||
field=models.CharField(blank=True, choices=[('Herr', 'Herr'), ('Frau', 'Frau'), ('Divers', 'Divers')], max_length=20, null=True, verbose_name='Anrede'),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,18 @@
|
||||
# Generated by Django 5.0.6 on 2026-03-21 22:42
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('stiftung', '0063_add_anrede_to_destinataer'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='uploadtoken',
|
||||
name='einwilligung_erteilt_am',
|
||||
field=models.DateTimeField(blank=True, help_text='Zeitpunkt der DSGVO-Einwilligung beim Upload (Art. 7 Abs. 1 DSGVO)', null=True, verbose_name='Einwilligung erteilt am'),
|
||||
),
|
||||
]
|
||||
File diff suppressed because it is too large
Load Diff
60
app/stiftung/models/__init__.py
Normal file
60
app/stiftung/models/__init__.py
Normal file
@@ -0,0 +1,60 @@
|
||||
# models/ package – re-exports all models for backward compatibility
|
||||
# Phase 0: Vision 2026 – Code-Refactoring
|
||||
|
||||
from .system import ( # noqa: F401
|
||||
AppConfiguration,
|
||||
ApplicationPermission,
|
||||
AuditLog,
|
||||
BackupJob,
|
||||
CSVImport,
|
||||
HelpBox,
|
||||
)
|
||||
|
||||
from .dokumente import ( # noqa: F401
|
||||
DokumentDatei,
|
||||
)
|
||||
|
||||
from .land import ( # noqa: F401
|
||||
DokumentLink,
|
||||
Land,
|
||||
LandAbrechnung,
|
||||
LandVerpachtung,
|
||||
Paechter,
|
||||
)
|
||||
|
||||
from .finanzen import ( # noqa: F401
|
||||
BankTransaction,
|
||||
Rentmeister,
|
||||
StiftungsKonto,
|
||||
Verwaltungskosten,
|
||||
)
|
||||
|
||||
from .destinataere import ( # noqa: F401
|
||||
Destinataer,
|
||||
DestinataerEmailEingang,
|
||||
EmailEingang,
|
||||
DestinataerNotiz,
|
||||
DestinataerUnterstuetzung,
|
||||
Foerderung,
|
||||
OnboardingEinladung,
|
||||
Person,
|
||||
UnterstuetzungWiederkehrend,
|
||||
UploadToken,
|
||||
VierteljahresNachweis,
|
||||
)
|
||||
|
||||
from .geschichte import ( # noqa: F401
|
||||
GeschichteBild,
|
||||
GeschichteSeite,
|
||||
StiftungsKalenderEintrag,
|
||||
)
|
||||
|
||||
from .veranstaltungen import ( # noqa: F401
|
||||
BriefVorlage,
|
||||
Veranstaltung,
|
||||
Veranstaltungsteilnehmer,
|
||||
)
|
||||
|
||||
from .vorlagen import ( # noqa: F401
|
||||
DokumentVorlage,
|
||||
)
|
||||
1471
app/stiftung/models/destinataere.py
Normal file
1471
app/stiftung/models/destinataere.py
Normal file
File diff suppressed because it is too large
Load Diff
189
app/stiftung/models/dokumente.py
Normal file
189
app/stiftung/models/dokumente.py
Normal file
@@ -0,0 +1,189 @@
|
||||
# models/dokumente.py
|
||||
# Phase 3: Django-natives DMS – ersetzt Paperless-NGX-Integration
|
||||
import uuid
|
||||
import os
|
||||
|
||||
from django.contrib.postgres.indexes import GinIndex
|
||||
from django.contrib.postgres.search import SearchVector, SearchVectorField
|
||||
from django.db import models
|
||||
from django.utils import timezone
|
||||
|
||||
|
||||
def dokument_upload_path(instance, filename):
|
||||
"""Speichert Dateien in MEDIA_ROOT/dokumente/YYYY/MM/<uuid>/<original_filename>"""
|
||||
ext = os.path.splitext(filename)[1].lower()
|
||||
safe_name = os.path.basename(filename)[:100]
|
||||
return f"dokumente/{timezone.now().strftime('%Y/%m')}/{instance.id}/{safe_name}"
|
||||
|
||||
|
||||
class DokumentDatei(models.Model):
|
||||
"""Nativ gespeicherte Datei im Django-DMS – ersetzt Paperless-Referenzen."""
|
||||
|
||||
KONTEXT_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"),
|
||||
]
|
||||
|
||||
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
|
||||
titel = models.CharField(max_length=255, verbose_name="Titel")
|
||||
beschreibung = models.TextField(blank=True, verbose_name="Beschreibung")
|
||||
kontext = models.CharField(
|
||||
max_length=30,
|
||||
choices=KONTEXT_CHOICES,
|
||||
default="anderes",
|
||||
verbose_name="Dokumententyp",
|
||||
)
|
||||
datei = models.FileField(
|
||||
upload_to=dokument_upload_path,
|
||||
verbose_name="Datei",
|
||||
)
|
||||
dateiname_original = models.CharField(
|
||||
max_length=255, blank=True, verbose_name="Originaldateiname"
|
||||
)
|
||||
dateityp = models.CharField(
|
||||
max_length=100, blank=True, verbose_name="MIME-Typ"
|
||||
)
|
||||
dateigroesse = models.PositiveIntegerField(
|
||||
default=0, verbose_name="Dateigröße (Bytes)"
|
||||
)
|
||||
|
||||
# Volltext-Index (PostgreSQL FTS, befüllt per Signal)
|
||||
inhaltstext = models.TextField(
|
||||
blank=True,
|
||||
verbose_name="Extrahierter Textinhalt",
|
||||
help_text="Automatisch befüllt für PDF/TXT-Dateien. Basis für Volltextsuche.",
|
||||
)
|
||||
suchvektor = SearchVectorField(
|
||||
null=True, blank=True, verbose_name="Such-Vektor (FTS)"
|
||||
)
|
||||
|
||||
# Zuordnungsfelder – optional, ein Dokument kann mehreren Entitäten gehören
|
||||
land = models.ForeignKey(
|
||||
"Land",
|
||||
on_delete=models.SET_NULL,
|
||||
null=True, blank=True,
|
||||
related_name="dms_dokumente",
|
||||
verbose_name="Länderei",
|
||||
)
|
||||
paechter = models.ForeignKey(
|
||||
"Paechter",
|
||||
on_delete=models.SET_NULL,
|
||||
null=True, blank=True,
|
||||
related_name="dms_dokumente",
|
||||
verbose_name="Pächter",
|
||||
)
|
||||
verpachtung = models.ForeignKey(
|
||||
"LandVerpachtung",
|
||||
on_delete=models.SET_NULL,
|
||||
null=True, blank=True,
|
||||
related_name="dms_dokumente",
|
||||
verbose_name="Verpachtung",
|
||||
)
|
||||
destinataer = models.ForeignKey(
|
||||
"Destinataer",
|
||||
on_delete=models.SET_NULL,
|
||||
null=True, blank=True,
|
||||
related_name="dms_dokumente",
|
||||
verbose_name="Destinatär",
|
||||
)
|
||||
foerderung = models.ForeignKey(
|
||||
"Foerderung",
|
||||
on_delete=models.SET_NULL,
|
||||
null=True, blank=True,
|
||||
related_name="dms_dokumente",
|
||||
verbose_name="Förderung",
|
||||
)
|
||||
rentmeister = models.ForeignKey(
|
||||
"Rentmeister",
|
||||
on_delete=models.SET_NULL,
|
||||
null=True, blank=True,
|
||||
related_name="dms_dokumente",
|
||||
verbose_name="Rentmeister",
|
||||
)
|
||||
verwaltungskosten = models.ForeignKey(
|
||||
"Verwaltungskosten",
|
||||
on_delete=models.SET_NULL,
|
||||
null=True, blank=True,
|
||||
related_name="dms_dokumente",
|
||||
verbose_name="Verwaltungskosten / Rechnung",
|
||||
)
|
||||
|
||||
# Herkunft (optional: Verweis auf altes Paperless-Dokument zur Rückverfolgung)
|
||||
paperless_dokument_id = models.IntegerField(
|
||||
null=True, blank=True,
|
||||
verbose_name="Paperless-ID (Migration)",
|
||||
help_text="Wird nach vollständiger Migration entfernt.",
|
||||
)
|
||||
|
||||
# Audit
|
||||
erstellt_von = models.ForeignKey(
|
||||
"auth.User",
|
||||
on_delete=models.SET_NULL,
|
||||
null=True, blank=True,
|
||||
related_name="hochgeladene_dokumente",
|
||||
verbose_name="Erstellt von",
|
||||
)
|
||||
erstellt_am = models.DateTimeField(auto_now_add=True, verbose_name="Erstellt am")
|
||||
aktualisiert_am = models.DateTimeField(auto_now=True, verbose_name="Aktualisiert am")
|
||||
|
||||
class Meta:
|
||||
verbose_name = "Dokument"
|
||||
verbose_name_plural = "Dokumente (DMS)"
|
||||
ordering = ["-erstellt_am"]
|
||||
indexes = [
|
||||
# PostgreSQL GIN-Index für Volltextsuche
|
||||
GinIndex(fields=["suchvektor"], name="dms_suchvektor_gin_idx"),
|
||||
models.Index(fields=["kontext"]),
|
||||
models.Index(fields=["destinataer", "kontext"]),
|
||||
models.Index(fields=["land", "kontext"]),
|
||||
models.Index(fields=["paechter", "kontext"]),
|
||||
]
|
||||
|
||||
def __str__(self):
|
||||
return self.titel or self.dateiname_original or str(self.id)
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
# Originaldateiname aus FileField ableiten
|
||||
if self.datei and not self.dateiname_original:
|
||||
self.dateiname_original = os.path.basename(self.datei.name)
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
def update_suchvektor(self):
|
||||
"""Aktualisiert den Such-Vektor aus Titel, Beschreibung und Inhaltstext."""
|
||||
DokumentDatei.objects.filter(pk=self.pk).update(
|
||||
suchvektor=SearchVector("titel", weight="A")
|
||||
+ SearchVector("beschreibung", weight="B")
|
||||
+ SearchVector("inhaltstext", weight="C"),
|
||||
)
|
||||
|
||||
def get_datei_url(self):
|
||||
"""Gibt die Download-URL zurück."""
|
||||
if self.datei:
|
||||
return self.datei.url
|
||||
return None
|
||||
|
||||
def is_pdf(self):
|
||||
return self.dateityp == "application/pdf" or (
|
||||
self.dateiname_original and self.dateiname_original.lower().endswith(".pdf")
|
||||
)
|
||||
|
||||
def get_human_size(self):
|
||||
"""Gibt die Dateigröße leserlich zurück."""
|
||||
size = self.dateigroesse
|
||||
if size < 1024:
|
||||
return f"{size} B"
|
||||
elif size < 1024 * 1024:
|
||||
return f"{size / 1024:.1f} KB"
|
||||
else:
|
||||
return f"{size / (1024 * 1024):.1f} MB"
|
||||
385
app/stiftung/models/finanzen.py
Normal file
385
app/stiftung/models/finanzen.py
Normal file
@@ -0,0 +1,385 @@
|
||||
import uuid
|
||||
|
||||
from django.db import models
|
||||
|
||||
|
||||
class Rentmeister(models.Model):
|
||||
"""Geschäftsführer der Stiftung (natürliche Personen)"""
|
||||
|
||||
ANREDE_CHOICES = [
|
||||
("herr", "Herr"),
|
||||
("frau", "Frau"),
|
||||
("dr", "Dr."),
|
||||
("prof", "Prof."),
|
||||
("prof_dr", "Prof. Dr."),
|
||||
]
|
||||
|
||||
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
|
||||
anrede = models.CharField(
|
||||
max_length=10, choices=ANREDE_CHOICES, blank=True, verbose_name="Anrede"
|
||||
)
|
||||
vorname = models.CharField(max_length=100, verbose_name="Vorname")
|
||||
nachname = models.CharField(max_length=100, verbose_name="Nachname")
|
||||
titel = models.CharField(max_length=50, blank=True, verbose_name="Titel")
|
||||
|
||||
# Kontaktdaten
|
||||
email = models.EmailField(blank=True, verbose_name="E-Mail")
|
||||
telefon = models.CharField(max_length=20, blank=True, verbose_name="Telefon")
|
||||
mobil = models.CharField(max_length=20, blank=True, verbose_name="Mobil")
|
||||
|
||||
# Adresse
|
||||
strasse = models.CharField(max_length=200, blank=True, verbose_name="Straße")
|
||||
plz = models.CharField(max_length=10, blank=True, verbose_name="PLZ")
|
||||
ort = models.CharField(max_length=100, blank=True, verbose_name="Ort")
|
||||
|
||||
# Bankdaten für Abrechnungen
|
||||
iban = models.CharField(max_length=34, blank=True, verbose_name="IBAN")
|
||||
bic = models.CharField(max_length=11, blank=True, verbose_name="BIC")
|
||||
bank_name = models.CharField(max_length=100, blank=True, verbose_name="Bank")
|
||||
|
||||
# Stiftungs-spezifisch
|
||||
seit_datum = models.DateField(verbose_name="Rentmeister seit")
|
||||
bis_datum = models.DateField(null=True, blank=True, verbose_name="Rentmeister bis")
|
||||
aktiv = models.BooleanField(default=True, verbose_name="Aktiv")
|
||||
|
||||
# Vergütung/Aufwandsentschädigung
|
||||
monatliche_verguetung = models.DecimalField(
|
||||
max_digits=8,
|
||||
decimal_places=2,
|
||||
null=True,
|
||||
blank=True,
|
||||
verbose_name="Monatliche Vergütung (€)",
|
||||
)
|
||||
km_pauschale = models.DecimalField(
|
||||
max_digits=4,
|
||||
decimal_places=2,
|
||||
default=0.30,
|
||||
verbose_name="Kilometerpauschale (€/km)",
|
||||
)
|
||||
|
||||
notizen = models.TextField(blank=True, verbose_name="Notizen")
|
||||
|
||||
erstellt_am = models.DateTimeField(auto_now_add=True)
|
||||
aktualisiert_am = models.DateTimeField(auto_now=True)
|
||||
|
||||
class Meta:
|
||||
verbose_name = "Rentmeister"
|
||||
verbose_name_plural = "Rentmeister"
|
||||
ordering = ["nachname", "vorname"]
|
||||
|
||||
def __str__(self):
|
||||
name_parts = []
|
||||
if self.anrede:
|
||||
name_parts.append(self.get_anrede_display())
|
||||
if self.vorname:
|
||||
name_parts.append(self.vorname)
|
||||
name_parts.append(self.nachname)
|
||||
if self.titel:
|
||||
name_parts.append(f"({self.titel})")
|
||||
return " ".join(name_parts)
|
||||
|
||||
def get_full_name(self):
|
||||
"""Vollständiger Name ohne Anrede"""
|
||||
if self.vorname:
|
||||
return f"{self.vorname} {self.nachname}"
|
||||
return self.nachname
|
||||
|
||||
def get_address(self):
|
||||
"""Vollständige Adresse als String"""
|
||||
parts = []
|
||||
if self.strasse:
|
||||
parts.append(self.strasse)
|
||||
if self.plz and self.ort:
|
||||
parts.append(f"{self.plz} {self.ort}")
|
||||
elif self.ort:
|
||||
parts.append(self.ort)
|
||||
return ", ".join(parts)
|
||||
|
||||
|
||||
class StiftungsKonto(models.Model):
|
||||
"""Bankkonten der Stiftung"""
|
||||
|
||||
KONTO_TYP_CHOICES = [
|
||||
("girokonto", "Girokonto"),
|
||||
("sparkonto", "Sparkonto"),
|
||||
("festgeld", "Festgeld"),
|
||||
("tagesgeld", "Tagesgeld"),
|
||||
("depot", "Depot"),
|
||||
("sonstiges", "Sonstiges"),
|
||||
]
|
||||
|
||||
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
|
||||
kontoname = models.CharField(max_length=200, verbose_name="Kontoname")
|
||||
bank_name = models.CharField(max_length=200, verbose_name="Bank")
|
||||
iban = models.CharField(max_length=34, verbose_name="IBAN")
|
||||
bic = models.CharField(max_length=11, blank=True, verbose_name="BIC")
|
||||
konto_typ = models.CharField(
|
||||
max_length=20,
|
||||
choices=KONTO_TYP_CHOICES,
|
||||
default="girokonto",
|
||||
verbose_name="Kontotyp",
|
||||
)
|
||||
saldo = models.DecimalField(
|
||||
max_digits=10, decimal_places=2, default=0.00, verbose_name="Aktueller Saldo"
|
||||
)
|
||||
saldo_datum = models.DateField(null=True, blank=True, verbose_name="Saldo-Datum")
|
||||
zinssatz = models.DecimalField(
|
||||
max_digits=5,
|
||||
decimal_places=2,
|
||||
null=True,
|
||||
blank=True,
|
||||
verbose_name="Zinssatz (%)",
|
||||
)
|
||||
laufzeit_bis = models.DateField(null=True, blank=True, verbose_name="Laufzeit bis")
|
||||
aktiv = models.BooleanField(default=True, verbose_name="Aktiv")
|
||||
notizen = models.TextField(blank=True, verbose_name="Notizen")
|
||||
|
||||
erstellt_am = models.DateTimeField(auto_now_add=True)
|
||||
aktualisiert_am = models.DateTimeField(auto_now=True)
|
||||
|
||||
class Meta:
|
||||
verbose_name = "Stiftungskonto"
|
||||
verbose_name_plural = "Stiftungskonten"
|
||||
ordering = ["bank_name", "kontoname"]
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.bank_name} - {self.kontoname}"
|
||||
|
||||
|
||||
class BankTransaction(models.Model):
|
||||
"""Banktransaktionen aus importierten Kontodaten"""
|
||||
|
||||
TRANSACTION_TYPE_CHOICES = [
|
||||
("eingang", "Eingang"),
|
||||
("ausgang", "Ausgang"),
|
||||
("lastschrift", "Lastschrift"),
|
||||
("ueberweisung", "Überweisung"),
|
||||
("dauerauftrag", "Dauerauftrag"),
|
||||
("kartenzahlung", "Kartenzahlung"),
|
||||
("zinsen", "Zinsen"),
|
||||
("gebuehren", "Gebühren"),
|
||||
("sonstiges", "Sonstiges"),
|
||||
]
|
||||
|
||||
STATUS_CHOICES = [
|
||||
("imported", "Importiert"),
|
||||
("verified", "Geprüft"),
|
||||
("assigned", "Zugeordnet"),
|
||||
("ignored", "Ignoriert"),
|
||||
]
|
||||
|
||||
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
|
||||
konto = models.ForeignKey(
|
||||
StiftungsKonto, on_delete=models.CASCADE, verbose_name="Konto"
|
||||
)
|
||||
|
||||
# Transaktionsdaten
|
||||
datum = models.DateField(verbose_name="Buchungsdatum")
|
||||
valuta = models.DateField(null=True, blank=True, verbose_name="Valutadatum")
|
||||
betrag = models.DecimalField(
|
||||
max_digits=12, decimal_places=2, verbose_name="Betrag (€)"
|
||||
)
|
||||
waehrung = models.CharField(max_length=3, default="EUR", verbose_name="Währung")
|
||||
|
||||
# Transaktionsdetails
|
||||
verwendungszweck = models.TextField(verbose_name="Verwendungszweck")
|
||||
empfaenger_zahlungspflichtiger = models.CharField(
|
||||
max_length=200, blank=True, verbose_name="Empfänger/Zahlungspflichtiger"
|
||||
)
|
||||
iban_gegenpartei = models.CharField(
|
||||
max_length=34, blank=True, verbose_name="IBAN Gegenpartei"
|
||||
)
|
||||
bic_gegenpartei = models.CharField(
|
||||
max_length=11, blank=True, verbose_name="BIC Gegenpartei"
|
||||
)
|
||||
|
||||
# Bankspezifische Daten
|
||||
referenz = models.CharField(
|
||||
max_length=100, blank=True, verbose_name="Referenz/Transaktions-ID"
|
||||
)
|
||||
transaction_type = models.CharField(
|
||||
max_length=20,
|
||||
choices=TRANSACTION_TYPE_CHOICES,
|
||||
default="sonstiges",
|
||||
verbose_name="Transaktionsart",
|
||||
)
|
||||
|
||||
# Verwaltung
|
||||
status = models.CharField(
|
||||
max_length=20, choices=STATUS_CHOICES, default="imported", verbose_name="Status"
|
||||
)
|
||||
kommentare = models.TextField(blank=True, verbose_name="Kommentare")
|
||||
verwaltungskosten = models.ForeignKey(
|
||||
"Verwaltungskosten",
|
||||
null=True,
|
||||
blank=True,
|
||||
on_delete=models.SET_NULL,
|
||||
verbose_name="Zugeordnete Verwaltungskosten",
|
||||
)
|
||||
|
||||
# Import-Metadaten
|
||||
import_datei = models.CharField(
|
||||
max_length=255, blank=True, verbose_name="Import-Datei"
|
||||
)
|
||||
importiert_am = models.DateTimeField(
|
||||
auto_now_add=True, verbose_name="Importiert am"
|
||||
)
|
||||
saldo_nach_buchung = models.DecimalField(
|
||||
max_digits=12,
|
||||
decimal_places=2,
|
||||
null=True,
|
||||
blank=True,
|
||||
verbose_name="Saldo nach Buchung",
|
||||
)
|
||||
|
||||
class Meta:
|
||||
verbose_name = "Banktransaktion"
|
||||
verbose_name_plural = "Banktransaktionen"
|
||||
ordering = ["-datum", "-importiert_am"]
|
||||
unique_together = ["konto", "datum", "betrag", "referenz"] # Prevent duplicates
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.datum} - {self.betrag}€ - {self.verwendungszweck[:50]}"
|
||||
|
||||
def is_income(self):
|
||||
"""Prüft ob es sich um einen Geldeingang handelt"""
|
||||
return self.betrag > 0
|
||||
|
||||
def get_absolute_amount(self):
|
||||
"""Gibt den absoluten Betrag zurück"""
|
||||
return abs(self.betrag)
|
||||
|
||||
|
||||
class Verwaltungskosten(models.Model):
|
||||
"""Administrative Kosten und Ausgaben der Stiftung"""
|
||||
|
||||
KATEGORIE_CHOICES = [
|
||||
("rechnung_intern", "Interne Rechnung"),
|
||||
("bueroausstattung", "Büroausstattung"),
|
||||
("fahrtkosten", "Fahrtkosten"),
|
||||
("porto", "Porto & Versand"),
|
||||
("telefon_internet", "Telefon & Internet"),
|
||||
("software", "Software & Lizenzen"),
|
||||
("beratung", "Beratung & Dienstleistungen"),
|
||||
("versicherung", "Versicherungen"),
|
||||
("steuerberatung", "Steuerberatung"),
|
||||
("bankgebuehren", "Bankgebühren"),
|
||||
("sonstiges", "Sonstiges"),
|
||||
]
|
||||
|
||||
STATUS_CHOICES = [
|
||||
("geplant", "Geplant"),
|
||||
("bestellt", "Bestellt"),
|
||||
("erhalten", "Erhalten"),
|
||||
("in_bearbeitung", "In Bearbeitung"),
|
||||
("bezahlt", "Bezahlt"),
|
||||
("storniert", "Storniert"),
|
||||
]
|
||||
|
||||
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
|
||||
bezeichnung = models.CharField(max_length=200, verbose_name="Bezeichnung")
|
||||
kategorie = models.CharField(
|
||||
max_length=30, choices=KATEGORIE_CHOICES, verbose_name="Kategorie"
|
||||
)
|
||||
betrag = models.DecimalField(
|
||||
max_digits=10, decimal_places=2, verbose_name="Betrag (€)"
|
||||
)
|
||||
datum = models.DateField(verbose_name="Datum")
|
||||
lieferant_firma = models.CharField(
|
||||
max_length=200, blank=True, verbose_name="Lieferant/Firma"
|
||||
)
|
||||
rechnungsnummer = models.CharField(
|
||||
max_length=100, blank=True, verbose_name="Rechnungsnummer"
|
||||
)
|
||||
status = models.CharField(
|
||||
max_length=20, choices=STATUS_CHOICES, default="geplant", verbose_name="Status"
|
||||
)
|
||||
|
||||
# Zuständigkeit und Zahlung
|
||||
rentmeister = models.ForeignKey(
|
||||
Rentmeister,
|
||||
on_delete=models.SET_NULL,
|
||||
null=True,
|
||||
blank=True,
|
||||
verbose_name="Zuständiger Rentmeister",
|
||||
)
|
||||
zahlungskonto = models.ForeignKey(
|
||||
StiftungsKonto,
|
||||
on_delete=models.SET_NULL,
|
||||
null=True,
|
||||
blank=True,
|
||||
related_name="zahlungen",
|
||||
verbose_name="Zahlungskonto",
|
||||
)
|
||||
quellkonto = models.ForeignKey(
|
||||
StiftungsKonto,
|
||||
on_delete=models.SET_NULL,
|
||||
null=True,
|
||||
blank=True,
|
||||
related_name="ausgaben",
|
||||
verbose_name="Quellkonto",
|
||||
)
|
||||
|
||||
# Legacy field für Rückwärtskompatibilität
|
||||
konto = models.ForeignKey(
|
||||
StiftungsKonto,
|
||||
on_delete=models.SET_NULL,
|
||||
null=True,
|
||||
blank=True,
|
||||
verbose_name="Konto (Legacy)",
|
||||
help_text="Veraltet - verwende Zahlungskonto und Quellkonto",
|
||||
)
|
||||
|
||||
# Fahrtkosten spezifisch
|
||||
km_anzahl = models.DecimalField(
|
||||
max_digits=8, decimal_places=1, null=True, blank=True, verbose_name="Kilometer"
|
||||
)
|
||||
km_satz = models.DecimalField(
|
||||
max_digits=4, decimal_places=2, null=True, blank=True, verbose_name="€/km"
|
||||
)
|
||||
von_ort = models.CharField(max_length=100, blank=True, verbose_name="Von (Ort)")
|
||||
nach_ort = models.CharField(max_length=100, blank=True, verbose_name="Nach (Ort)")
|
||||
zweck = models.CharField(max_length=200, blank=True, verbose_name="Zweck der Fahrt")
|
||||
|
||||
beschreibung = models.TextField(blank=True, verbose_name="Beschreibung")
|
||||
notizen = models.TextField(blank=True, verbose_name="Notizen")
|
||||
|
||||
erstellt_am = models.DateTimeField(auto_now_add=True)
|
||||
aktualisiert_am = models.DateTimeField(auto_now=True)
|
||||
|
||||
class Meta:
|
||||
verbose_name = "Verwaltungskosten"
|
||||
verbose_name_plural = "Verwaltungskosten"
|
||||
ordering = ["-datum", "-erstellt_am"]
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.bezeichnung} - €{self.betrag} ({self.datum})"
|
||||
|
||||
def get_status_color(self):
|
||||
colors = {
|
||||
"geplant": "secondary",
|
||||
"bestellt": "warning",
|
||||
"erhalten": "info",
|
||||
"in_bearbeitung": "primary",
|
||||
"bezahlt": "success",
|
||||
"storniert": "danger",
|
||||
}
|
||||
return colors.get(self.status, "secondary")
|
||||
|
||||
def get_effective_zahlungskonto(self):
|
||||
"""Gibt das Zahlungskonto zurück, fallback auf Legacy-Konto"""
|
||||
return self.zahlungskonto or self.konto
|
||||
|
||||
def get_effective_quellkonto(self):
|
||||
"""Gibt das Quellkonto zurück, fallback auf Zahlungskonto oder Legacy-Konto"""
|
||||
return self.quellkonto or self.zahlungskonto or self.konto
|
||||
|
||||
def is_fahrtkosten(self):
|
||||
"""Prüft ob es sich um Fahrtkosten handelt"""
|
||||
return self.kategorie == "fahrtkosten"
|
||||
|
||||
def calculate_fahrtkosten(self):
|
||||
"""Berechnet Fahrtkosten automatisch wenn km_anzahl und km_satz gesetzt sind"""
|
||||
if self.km_anzahl and self.km_satz:
|
||||
return self.km_anzahl * self.km_satz
|
||||
return None
|
||||
221
app/stiftung/models/geschichte.py
Normal file
221
app/stiftung/models/geschichte.py
Normal file
@@ -0,0 +1,221 @@
|
||||
import uuid
|
||||
|
||||
from django.db import models
|
||||
from django.utils import timezone
|
||||
|
||||
|
||||
class GeschichteSeite(models.Model):
|
||||
"""Wiki-style pages for foundation history"""
|
||||
|
||||
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
|
||||
titel = models.CharField(max_length=200, verbose_name="Titel")
|
||||
slug = models.SlugField(max_length=200, unique=True, verbose_name="URL-Slug")
|
||||
inhalt = models.TextField(
|
||||
verbose_name="Inhalt (Markdown)",
|
||||
blank=True,
|
||||
help_text="Sie können Markdown verwenden: **fett**, *kursiv*, # Überschriften, [Links](URL), Listen, etc."
|
||||
)
|
||||
|
||||
# Metadata
|
||||
erstellt_am = models.DateTimeField(auto_now_add=True, verbose_name="Erstellt am")
|
||||
aktualisiert_am = models.DateTimeField(auto_now=True, verbose_name="Aktualisiert am")
|
||||
erstellt_von = models.ForeignKey(
|
||||
'auth.User',
|
||||
on_delete=models.SET_NULL,
|
||||
null=True,
|
||||
blank=True,
|
||||
related_name='geschichte_seiten_erstellt',
|
||||
verbose_name="Erstellt von"
|
||||
)
|
||||
aktualisiert_von = models.ForeignKey(
|
||||
'auth.User',
|
||||
on_delete=models.SET_NULL,
|
||||
null=True,
|
||||
blank=True,
|
||||
related_name='geschichte_seiten_aktualisiert',
|
||||
verbose_name="Aktualisiert von"
|
||||
)
|
||||
|
||||
# Verknüpfte DMS-Dokumente
|
||||
dokumente = models.ManyToManyField(
|
||||
"DokumentDatei",
|
||||
blank=True,
|
||||
related_name="geschichte_seiten",
|
||||
verbose_name="Verknüpfte Dokumente",
|
||||
)
|
||||
|
||||
# Options
|
||||
ist_veroeffentlicht = models.BooleanField(default=True, verbose_name="Veröffentlicht")
|
||||
sortierung = models.IntegerField(default=0, verbose_name="Sortierung")
|
||||
|
||||
class Meta:
|
||||
verbose_name = "Geschichte Seite"
|
||||
verbose_name_plural = "Geschichte Seiten"
|
||||
ordering = ['sortierung', 'titel']
|
||||
|
||||
def __str__(self):
|
||||
return self.titel
|
||||
|
||||
def get_absolute_url(self):
|
||||
from django.urls import reverse
|
||||
return reverse('stiftung:geschichte_detail', kwargs={'slug': self.slug})
|
||||
|
||||
|
||||
class GeschichteBild(models.Model):
|
||||
"""Images for history pages"""
|
||||
|
||||
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
|
||||
seite = models.ForeignKey(
|
||||
GeschichteSeite,
|
||||
on_delete=models.CASCADE,
|
||||
related_name='bilder',
|
||||
verbose_name="Geschichte Seite"
|
||||
)
|
||||
titel = models.CharField(max_length=200, verbose_name="Bildtitel")
|
||||
bild = models.ImageField(
|
||||
upload_to='geschichte/bilder/%Y/%m/',
|
||||
verbose_name="Bild"
|
||||
)
|
||||
beschreibung = models.TextField(blank=True, verbose_name="Beschreibung")
|
||||
alt_text = models.CharField(max_length=200, blank=True, verbose_name="Alt-Text")
|
||||
|
||||
# Metadata
|
||||
hochgeladen_am = models.DateTimeField(auto_now_add=True, verbose_name="Hochgeladen am")
|
||||
hochgeladen_von = models.ForeignKey(
|
||||
'auth.User',
|
||||
on_delete=models.SET_NULL,
|
||||
null=True,
|
||||
blank=True,
|
||||
verbose_name="Hochgeladen von"
|
||||
)
|
||||
|
||||
sortierung = models.IntegerField(default=0, verbose_name="Sortierung")
|
||||
|
||||
class Meta:
|
||||
verbose_name = "Geschichte Bild"
|
||||
verbose_name_plural = "Geschichte Bilder"
|
||||
ordering = ['sortierung', 'titel']
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.titel} ({self.seite.titel})"
|
||||
|
||||
|
||||
class StiftungsKalenderEintrag(models.Model):
|
||||
"""Custom calendar events for foundation management"""
|
||||
|
||||
KATEGORIE_CHOICES = [
|
||||
('termin', 'Termin/Meeting'),
|
||||
('zahlung', 'Zahlungserinnerung'),
|
||||
('deadline', 'Frist/Deadline'),
|
||||
('geburtstag', 'Geburtstag'),
|
||||
('vertrag', 'Vertrag läuft aus'),
|
||||
('pruefung', 'Prüfung/Nachweis'),
|
||||
('sonstiges', 'Sonstiges'),
|
||||
]
|
||||
|
||||
PRIORITAET_CHOICES = [
|
||||
('niedrig', 'Niedrig'),
|
||||
('normal', 'Normal'),
|
||||
('hoch', 'Hoch'),
|
||||
('kritisch', 'Kritisch'),
|
||||
]
|
||||
|
||||
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
|
||||
titel = models.CharField(max_length=200, verbose_name="Titel")
|
||||
beschreibung = models.TextField(blank=True, verbose_name="Beschreibung")
|
||||
|
||||
# Date and time
|
||||
datum = models.DateField(verbose_name="Datum")
|
||||
uhrzeit = models.TimeField(null=True, blank=True, verbose_name="Uhrzeit")
|
||||
ganztags = models.BooleanField(default=True, verbose_name="Ganztägig")
|
||||
|
||||
# Categorization
|
||||
kategorie = models.CharField(
|
||||
max_length=20,
|
||||
choices=KATEGORIE_CHOICES,
|
||||
default='termin',
|
||||
verbose_name="Kategorie"
|
||||
)
|
||||
prioritaet = models.CharField(
|
||||
max_length=20,
|
||||
choices=PRIORITAET_CHOICES,
|
||||
default='normal',
|
||||
verbose_name="Priorität"
|
||||
)
|
||||
|
||||
# Links to related objects
|
||||
destinataer = models.ForeignKey(
|
||||
'stiftung.Destinataer',
|
||||
null=True,
|
||||
blank=True,
|
||||
on_delete=models.CASCADE,
|
||||
verbose_name="Bezogener Destinatär"
|
||||
)
|
||||
verpachtung = models.ForeignKey(
|
||||
'stiftung.LandVerpachtung',
|
||||
null=True,
|
||||
blank=True,
|
||||
on_delete=models.CASCADE,
|
||||
verbose_name="Bezogene Verpachtung"
|
||||
)
|
||||
|
||||
# Status and completion
|
||||
erledigt = models.BooleanField(default=False, verbose_name="Erledigt")
|
||||
erledigt_am = models.DateTimeField(null=True, blank=True, verbose_name="Erledigt am")
|
||||
|
||||
# Metadata
|
||||
erstellt_von = models.CharField(
|
||||
max_length=100,
|
||||
null=True,
|
||||
blank=True,
|
||||
verbose_name="Erstellt von"
|
||||
)
|
||||
erstellt_am = models.DateTimeField(auto_now_add=True, verbose_name="Erstellt am")
|
||||
aktualisiert_am = models.DateTimeField(auto_now=True, verbose_name="Aktualisiert am")
|
||||
|
||||
class Meta:
|
||||
verbose_name = "Kalender Eintrag"
|
||||
verbose_name_plural = "Kalender Einträge"
|
||||
ordering = ['datum', 'uhrzeit']
|
||||
indexes = [
|
||||
models.Index(fields=['datum']),
|
||||
models.Index(fields=['kategorie', 'datum']),
|
||||
models.Index(fields=['erledigt', 'datum']),
|
||||
]
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.datum}: {self.titel}"
|
||||
|
||||
def get_kategorie_icon(self):
|
||||
icons = {
|
||||
'termin': 'fas fa-calendar-alt',
|
||||
'zahlung': 'fas fa-euro-sign',
|
||||
'deadline': 'fas fa-exclamation-triangle',
|
||||
'geburtstag': 'fas fa-birthday-cake',
|
||||
'vertrag': 'fas fa-file-contract',
|
||||
'pruefung': 'fas fa-clipboard-check',
|
||||
'sonstiges': 'fas fa-calendar',
|
||||
}
|
||||
return icons.get(self.kategorie, 'fas fa-calendar')
|
||||
|
||||
def get_prioritaet_color(self):
|
||||
colors = {
|
||||
'niedrig': 'success',
|
||||
'normal': 'primary',
|
||||
'hoch': 'warning',
|
||||
'kritisch': 'danger',
|
||||
}
|
||||
return colors.get(self.prioritaet, 'primary')
|
||||
|
||||
def is_overdue(self):
|
||||
"""Check if event is overdue (past due and not completed)"""
|
||||
if self.erledigt:
|
||||
return False
|
||||
return self.datum < timezone.now().date()
|
||||
|
||||
def is_upcoming(self, days=7):
|
||||
"""Check if event is upcoming within specified days"""
|
||||
if self.erledigt:
|
||||
return False
|
||||
today = timezone.now().date()
|
||||
return today <= self.datum <= (today + timezone.timedelta(days=days))
|
||||
1089
app/stiftung/models/land.py
Normal file
1089
app/stiftung/models/land.py
Normal file
File diff suppressed because it is too large
Load Diff
479
app/stiftung/models/system.py
Normal file
479
app/stiftung/models/system.py
Normal file
@@ -0,0 +1,479 @@
|
||||
import uuid
|
||||
|
||||
from django.db import models
|
||||
|
||||
|
||||
class CSVImport(models.Model):
|
||||
"""Track CSV import operations for audit purposes"""
|
||||
|
||||
IMPORT_TYPE_CHOICES = [
|
||||
("destinataere", "Destinatäre"),
|
||||
("paechter", "Pächter"),
|
||||
("laendereien", "Ländereien"),
|
||||
("verpachtungen", "Verpachtungen"),
|
||||
("foerderungen", "Förderungen"),
|
||||
("konten", "Stiftungskonten"),
|
||||
("verwaltungskosten", "Verwaltungskosten"),
|
||||
("rentmeister", "Rentmeister"),
|
||||
("personen", "Personen (Legacy)"),
|
||||
]
|
||||
|
||||
STATUS_CHOICES = [
|
||||
("pending", "Ausstehend"),
|
||||
("processing", "Wird verarbeitet"),
|
||||
("completed", "Abgeschlossen"),
|
||||
("failed", "Fehlgeschlagen"),
|
||||
("partial", "Teilweise erfolgreich"),
|
||||
]
|
||||
|
||||
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
|
||||
import_type = models.CharField(
|
||||
max_length=20, choices=IMPORT_TYPE_CHOICES, verbose_name="Import-Typ"
|
||||
)
|
||||
filename = models.CharField(max_length=255, verbose_name="Dateiname")
|
||||
file_size = models.IntegerField(verbose_name="Dateigröße (Bytes)")
|
||||
status = models.CharField(max_length=20, choices=STATUS_CHOICES, default="pending")
|
||||
|
||||
# Results
|
||||
total_rows = models.IntegerField(default=0, verbose_name="Gesamtzeilen")
|
||||
imported_rows = models.IntegerField(default=0, verbose_name="Importierte Zeilen")
|
||||
failed_rows = models.IntegerField(default=0, verbose_name="Fehlgeschlagene Zeilen")
|
||||
error_log = models.TextField(null=True, blank=True, verbose_name="Fehlerprotokoll")
|
||||
|
||||
# Metadata
|
||||
created_by = models.CharField(
|
||||
max_length=100, null=True, blank=True, verbose_name="Erstellt von"
|
||||
)
|
||||
started_at = models.DateTimeField(auto_now_add=True, verbose_name="Gestartet um")
|
||||
completed_at = models.DateTimeField(
|
||||
null=True, blank=True, verbose_name="Abgeschlossen um"
|
||||
)
|
||||
|
||||
class Meta:
|
||||
verbose_name = "CSV Import"
|
||||
verbose_name_plural = "CSV Imports"
|
||||
ordering = ["-started_at"]
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.get_import_type_display()} - {self.filename} ({self.status})"
|
||||
|
||||
def get_duration(self):
|
||||
"""Calculate import duration"""
|
||||
if self.completed_at and self.started_at:
|
||||
return self.completed_at - self.started_at
|
||||
return None
|
||||
|
||||
def get_success_rate(self):
|
||||
"""Calculate success rate percentage"""
|
||||
if self.total_rows > 0:
|
||||
return (self.imported_rows / self.total_rows) * 100
|
||||
return 0
|
||||
|
||||
|
||||
class ApplicationPermission(models.Model):
|
||||
"""Custom permissions for application functions"""
|
||||
|
||||
class Meta:
|
||||
managed = False # No database table creation
|
||||
default_permissions = () # Remove default Django permissions
|
||||
permissions = [
|
||||
# Entity Management 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"),
|
||||
# Document Management Permissions
|
||||
("manage_documents", "Kann Dokumente verwalten"),
|
||||
("view_documents", "Kann Dokumente anzeigen"),
|
||||
("link_documents", "Kann Dokumente verknüpfen"),
|
||||
# Financial Management Permissions
|
||||
("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"),
|
||||
# Administration Permissions
|
||||
("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"),
|
||||
# Veranstaltungen Permissions
|
||||
("manage_veranstaltungen", "Kann Veranstaltungen verwalten"),
|
||||
("view_veranstaltungen", "Kann Veranstaltungen anzeigen"),
|
||||
# Import/Export Permissions
|
||||
("import_data", "Kann Daten importieren"),
|
||||
("export_data", "Kann Daten exportieren"),
|
||||
# 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"),
|
||||
]
|
||||
|
||||
|
||||
class AuditLog(models.Model):
|
||||
"""Audit Log für alle Benutzeraktionen im System"""
|
||||
|
||||
ACTION_TYPES = [
|
||||
("create", "Erstellt"),
|
||||
("update", "Aktualisiert"),
|
||||
("delete", "Gelöscht"),
|
||||
("link", "Verknüpft"),
|
||||
("unlink", "Verknüpfung entfernt"),
|
||||
("login", "Anmeldung"),
|
||||
("logout", "Abmeldung"),
|
||||
("backup", "Backup erstellt"),
|
||||
("restore", "Wiederherstellung"),
|
||||
("export", "Export"),
|
||||
("import", "Import"),
|
||||
]
|
||||
|
||||
ENTITY_TYPES = [
|
||||
("destinataer", "Destinatär"),
|
||||
("land", "Länderei"),
|
||||
("paechter", "Pächter"),
|
||||
("verpachtung", "Verpachtung"),
|
||||
("foerderung", "Förderung"),
|
||||
("rentmeister", "Rentmeister"),
|
||||
("stiftungskonto", "Stiftungskonto"),
|
||||
("verwaltungskosten", "Verwaltungskosten"),
|
||||
("banktransaction", "Bank-Transaktion"),
|
||||
("dokumentlink", "Dokument-Verknüpfung"),
|
||||
("system", "System"),
|
||||
("user", "Benutzer"),
|
||||
]
|
||||
|
||||
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
|
||||
|
||||
# Benutzer und Zeitpunkt
|
||||
user = models.ForeignKey(
|
||||
"auth.User", on_delete=models.SET_NULL, null=True, verbose_name="Benutzer"
|
||||
)
|
||||
username = models.CharField(
|
||||
max_length=150, verbose_name="Benutzername"
|
||||
) # Fallback falls User gelöscht wird
|
||||
timestamp = models.DateTimeField(auto_now_add=True, verbose_name="Zeitpunkt")
|
||||
|
||||
# Aktion
|
||||
action = models.CharField(
|
||||
max_length=20, choices=ACTION_TYPES, verbose_name="Aktion"
|
||||
)
|
||||
entity_type = models.CharField(
|
||||
max_length=20, choices=ENTITY_TYPES, verbose_name="Entitätstyp"
|
||||
)
|
||||
entity_id = models.CharField(max_length=100, blank=True, verbose_name="Entitäts-ID")
|
||||
entity_name = models.CharField(max_length=255, verbose_name="Entitätsname")
|
||||
|
||||
# Details
|
||||
description = models.TextField(verbose_name="Beschreibung")
|
||||
changes = models.JSONField(
|
||||
null=True, blank=True, verbose_name="Änderungen"
|
||||
) # Alte und neue Werte
|
||||
|
||||
# Request-Informationen
|
||||
ip_address = models.GenericIPAddressField(
|
||||
null=True, blank=True, verbose_name="IP-Adresse"
|
||||
)
|
||||
user_agent = models.TextField(blank=True, verbose_name="User Agent")
|
||||
session_key = models.CharField(
|
||||
max_length=40, blank=True, verbose_name="Session-Key"
|
||||
)
|
||||
|
||||
class Meta:
|
||||
verbose_name = "Audit Log Eintrag"
|
||||
verbose_name_plural = "Audit Log Einträge"
|
||||
ordering = ["-timestamp"]
|
||||
indexes = [
|
||||
models.Index(fields=["timestamp"]),
|
||||
models.Index(fields=["user", "timestamp"]),
|
||||
models.Index(fields=["entity_type", "timestamp"]),
|
||||
models.Index(fields=["action", "timestamp"]),
|
||||
]
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.username} - {self.get_action_display()} {self.get_entity_type_display()} '{self.entity_name}' ({self.timestamp.strftime('%d.%m.%Y %H:%M')})"
|
||||
|
||||
def get_changes_summary(self):
|
||||
"""Erstellt eine lesbare Zusammenfassung der Änderungen"""
|
||||
if not self.changes:
|
||||
return "Keine Details verfügbar"
|
||||
|
||||
if isinstance(self.changes, dict):
|
||||
summary = []
|
||||
for field, values in self.changes.items():
|
||||
if isinstance(values, dict) and "old" in values and "new" in values:
|
||||
old_val = values["old"] or "Leer"
|
||||
new_val = values["new"] or "Leer"
|
||||
summary.append(f"{field}: '{old_val}' → '{new_val}'")
|
||||
return "; ".join(summary) if summary else "Keine Änderungen dokumentiert"
|
||||
|
||||
return str(self.changes)
|
||||
|
||||
|
||||
class BackupJob(models.Model):
|
||||
"""Backup-Jobs und deren Status"""
|
||||
|
||||
STATUS_CHOICES = [
|
||||
("pending", "Wartend"),
|
||||
("running", "Läuft"),
|
||||
("completed", "Abgeschlossen"),
|
||||
("failed", "Fehlgeschlagen"),
|
||||
("cancelled", "Abgebrochen"),
|
||||
]
|
||||
|
||||
TYPE_CHOICES = [
|
||||
("full", "Vollständiges Backup"),
|
||||
("database", "Nur Datenbank"),
|
||||
("files", "Nur Dateien"),
|
||||
]
|
||||
|
||||
OPERATION_CHOICES = [
|
||||
("backup", "Backup"),
|
||||
("restore", "Wiederherstellung"),
|
||||
]
|
||||
|
||||
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
|
||||
|
||||
# Job-Details
|
||||
operation = models.CharField(
|
||||
max_length=20, choices=OPERATION_CHOICES, default="backup", verbose_name="Vorgang"
|
||||
)
|
||||
backup_type = models.CharField(
|
||||
max_length=20, choices=TYPE_CHOICES, verbose_name="Backup-Typ"
|
||||
)
|
||||
status = models.CharField(
|
||||
max_length=20, choices=STATUS_CHOICES, default="pending", verbose_name="Status"
|
||||
)
|
||||
|
||||
# Ausführung
|
||||
created_by = models.ForeignKey(
|
||||
"auth.User", on_delete=models.SET_NULL, null=True, verbose_name="Erstellt von"
|
||||
)
|
||||
created_at = models.DateTimeField(auto_now_add=True, verbose_name="Erstellt am")
|
||||
started_at = models.DateTimeField(
|
||||
null=True, blank=True, verbose_name="Gestartet am"
|
||||
)
|
||||
completed_at = models.DateTimeField(
|
||||
null=True, blank=True, verbose_name="Abgeschlossen am"
|
||||
)
|
||||
|
||||
# Ergebnis
|
||||
backup_filename = models.CharField(
|
||||
max_length=255, blank=True, verbose_name="Backup-Dateiname"
|
||||
)
|
||||
backup_size = models.BigIntegerField(
|
||||
null=True, blank=True, verbose_name="Backup-Größe (Bytes)"
|
||||
)
|
||||
error_message = models.TextField(blank=True, verbose_name="Fehlermeldung")
|
||||
|
||||
# Metadaten
|
||||
database_size = models.BigIntegerField(
|
||||
null=True, blank=True, verbose_name="Datenbankgröße (Bytes)"
|
||||
)
|
||||
files_count = models.IntegerField(
|
||||
null=True, blank=True, verbose_name="Anzahl Dateien"
|
||||
)
|
||||
|
||||
class Meta:
|
||||
verbose_name = "Backup-Job"
|
||||
verbose_name_plural = "Backup-Jobs"
|
||||
ordering = ["-created_at"]
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.get_backup_type_display()} - {self.get_status_display()} ({self.created_at.strftime('%d.%m.%Y %H:%M')})"
|
||||
|
||||
def get_duration(self):
|
||||
"""Berechnet die Dauer des Backup-Jobs"""
|
||||
if self.started_at and self.completed_at:
|
||||
return self.completed_at - self.started_at
|
||||
elif self.started_at:
|
||||
from django.utils import timezone
|
||||
|
||||
return timezone.now() - self.started_at
|
||||
return None
|
||||
|
||||
def get_size_display(self):
|
||||
"""Formatiert die Backup-Größe für die Anzeige"""
|
||||
if not self.backup_size:
|
||||
return "Unbekannt"
|
||||
|
||||
size = self.backup_size
|
||||
for unit in ["B", "KB", "MB", "GB"]:
|
||||
if size < 1024:
|
||||
return f"{size:.1f} {unit}"
|
||||
size /= 1024
|
||||
return f"{size:.1f} TB"
|
||||
|
||||
|
||||
class AppConfiguration(models.Model):
|
||||
"""Application configuration settings that can be managed through the admin interface"""
|
||||
|
||||
SETTING_TYPE_CHOICES = [
|
||||
("text", "Text"),
|
||||
("password", "Password"),
|
||||
("number", "Number"),
|
||||
("boolean", "Boolean"),
|
||||
("url", "URL"),
|
||||
("tag", "Tag Name"),
|
||||
("tag_id", "Tag ID"),
|
||||
]
|
||||
|
||||
CATEGORY_CHOICES = [
|
||||
("paperless", "Paperless Integration"),
|
||||
("email", "E-Mail / IMAP"),
|
||||
("general", "General Settings"),
|
||||
("corporate", "Corporate Identity"),
|
||||
("notifications", "Notifications"),
|
||||
("system", "System Settings"),
|
||||
]
|
||||
|
||||
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
|
||||
key = models.CharField(max_length=100, unique=True, verbose_name="Setting Key")
|
||||
display_name = models.CharField(max_length=200, verbose_name="Display Name")
|
||||
description = models.TextField(blank=True, null=True, verbose_name="Description")
|
||||
value = models.TextField(verbose_name="Value")
|
||||
default_value = models.TextField(verbose_name="Default Value")
|
||||
setting_type = models.CharField(
|
||||
max_length=20, choices=SETTING_TYPE_CHOICES, default="text", verbose_name="Type"
|
||||
)
|
||||
category = models.CharField(
|
||||
max_length=50,
|
||||
choices=CATEGORY_CHOICES,
|
||||
default="general",
|
||||
verbose_name="Category",
|
||||
)
|
||||
is_active = models.BooleanField(default=True, verbose_name="Active")
|
||||
is_system = models.BooleanField(
|
||||
default=False, verbose_name="System Setting (read-only)"
|
||||
)
|
||||
order = models.IntegerField(default=0, verbose_name="Display Order")
|
||||
|
||||
# Metadata
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
|
||||
class Meta:
|
||||
verbose_name = "App Configuration"
|
||||
verbose_name_plural = "App Configurations"
|
||||
ordering = ["category", "order", "display_name"]
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.display_name} ({self.key})"
|
||||
|
||||
def get_typed_value(self):
|
||||
"""Return the value converted to the appropriate type"""
|
||||
if self.setting_type == "boolean":
|
||||
return self.value.lower() in ("true", "1", "yes", "on")
|
||||
elif self.setting_type == "number":
|
||||
try:
|
||||
if "." in self.value:
|
||||
return float(self.value)
|
||||
return int(self.value)
|
||||
except (ValueError, TypeError):
|
||||
return 0
|
||||
return self.value
|
||||
|
||||
@classmethod
|
||||
def get_setting(cls, key, default=None):
|
||||
"""Get a setting value by key"""
|
||||
try:
|
||||
setting = cls.objects.get(key=key, is_active=True)
|
||||
return setting.get_typed_value()
|
||||
except cls.DoesNotExist:
|
||||
return default
|
||||
|
||||
@classmethod
|
||||
def set_setting(
|
||||
cls,
|
||||
key,
|
||||
value,
|
||||
display_name=None,
|
||||
description=None,
|
||||
setting_type="text",
|
||||
category="general",
|
||||
):
|
||||
"""Set or update a setting value"""
|
||||
setting, created = cls.objects.get_or_create(
|
||||
key=key,
|
||||
defaults={
|
||||
"display_name": display_name or key,
|
||||
"description": description,
|
||||
"value": str(value),
|
||||
"default_value": str(value),
|
||||
"setting_type": setting_type,
|
||||
"category": category,
|
||||
},
|
||||
)
|
||||
if not created:
|
||||
setting.value = str(value)
|
||||
setting.save()
|
||||
return setting
|
||||
|
||||
|
||||
class HelpBox(models.Model):
|
||||
"""Editierbare Hilfe-Infoboxen für Formulare"""
|
||||
|
||||
PAGE_CHOICES = [
|
||||
("destinataer_new", "Neuer Destinatär"),
|
||||
("unterstuetzung_new", "Neue Unterstützung"),
|
||||
("foerderung_new", "Neue Förderung"),
|
||||
("paechter_new", "Neuer Pächter"),
|
||||
("laenderei_new", "Neue Länderei"),
|
||||
("verpachtung_new", "Neue Verpachtung"),
|
||||
("land_abrechnung_new", "Neue Landabrechnung"),
|
||||
("person_new", "Neue Person"),
|
||||
("konto_new", "Neues Konto"),
|
||||
("verwaltungskosten_new", "Neue Verwaltungskosten"),
|
||||
("rentmeister_new", "Neuer Rentmeister"),
|
||||
("dokument_new", "Neues Dokument"),
|
||||
("user_new", "Neuer Benutzer"),
|
||||
("csv_import_new", "CSV Import"),
|
||||
("destinataer_notiz_new", "Destinatär Notiz"),
|
||||
]
|
||||
|
||||
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
|
||||
page_key = models.CharField(
|
||||
max_length=50, choices=PAGE_CHOICES, unique=True, verbose_name="Seite"
|
||||
)
|
||||
title = models.CharField(max_length=200, verbose_name="Titel der Hilfsbox")
|
||||
content = models.TextField(
|
||||
verbose_name="Inhalt (Markdown unterstützt)",
|
||||
help_text="Sie können Markdown verwenden: **fett**, *kursiv*, `code`, [Link](url), etc.",
|
||||
)
|
||||
is_active = models.BooleanField(default=True, verbose_name="Aktiv")
|
||||
|
||||
# Metadata
|
||||
created_at = models.DateTimeField(auto_now_add=True, verbose_name="Erstellt am")
|
||||
updated_at = models.DateTimeField(auto_now=True, verbose_name="Aktualisiert am")
|
||||
created_by = models.CharField(
|
||||
max_length=100, null=True, blank=True, verbose_name="Erstellt von"
|
||||
)
|
||||
updated_by = models.CharField(
|
||||
max_length=100, null=True, blank=True, verbose_name="Aktualisiert von"
|
||||
)
|
||||
|
||||
class Meta:
|
||||
verbose_name = "Hilfs-Infobox"
|
||||
verbose_name_plural = "Hilfs-Infoboxen"
|
||||
ordering = ["page_key"]
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.get_page_key_display()}: {self.title}"
|
||||
|
||||
@classmethod
|
||||
def get_help_for_page(cls, page_key):
|
||||
"""Hole die aktive Hilfs-Infobox für eine bestimmte Seite"""
|
||||
try:
|
||||
return cls.objects.get(page_key=page_key, is_active=True)
|
||||
except cls.DoesNotExist:
|
||||
return None
|
||||
|
||||
215
app/stiftung/models/veranstaltungen.py
Normal file
215
app/stiftung/models/veranstaltungen.py
Normal file
@@ -0,0 +1,215 @@
|
||||
import uuid
|
||||
|
||||
from django.db import models
|
||||
|
||||
|
||||
class BriefVorlage(models.Model):
|
||||
"""Wiederverwendbare Briefvorlagen für Serienbriefe (Veranstaltungseinladungen u.ä.)"""
|
||||
|
||||
name = models.CharField(max_length=100, verbose_name="Vorlagenname")
|
||||
beschreibung = models.TextField(
|
||||
blank=True,
|
||||
verbose_name="Beschreibung",
|
||||
help_text="Kurze Beschreibung des Verwendungszwecks dieser Vorlage.",
|
||||
)
|
||||
briefvorlage = models.TextField(
|
||||
verbose_name="Brieftext (HTML)",
|
||||
help_text=(
|
||||
"HTML-Text des Briefs. Verfügbare Platzhalter: "
|
||||
"{{ anrede }}, {{ vorname }}, {{ nachname }}, {{ strasse }}, "
|
||||
"{{ plz }}, {{ ort }}, {{ datum }}, {{ uhrzeit }}, "
|
||||
"{{ veranstaltungsort }}, {{ gasthaus_adresse }}"
|
||||
),
|
||||
)
|
||||
betreff = models.CharField(
|
||||
max_length=300,
|
||||
blank=True,
|
||||
verbose_name="Standard-Betreff",
|
||||
help_text="Wird beim Laden der Vorlage in die Veranstaltung übernommen. Leer = unveränderter Betreff.",
|
||||
)
|
||||
|
||||
erstellt_am = models.DateTimeField(auto_now_add=True)
|
||||
aktualisiert_am = models.DateTimeField(auto_now=True)
|
||||
|
||||
class Meta:
|
||||
verbose_name = "Briefvorlage"
|
||||
verbose_name_plural = "Briefvorlagen"
|
||||
ordering = ["name"]
|
||||
|
||||
def __str__(self):
|
||||
return self.name
|
||||
|
||||
|
||||
class Veranstaltung(models.Model):
|
||||
"""Veranstaltungen der Stiftung, z.B. Stiftungsessen mit Rechnungslegung"""
|
||||
|
||||
STATUS_CHOICES = [
|
||||
("geplant", "Geplant"),
|
||||
("einladungen_versendet", "Einladungen versendet"),
|
||||
("abgeschlossen", "Abgeschlossen"),
|
||||
("abgesagt", "Abgesagt"),
|
||||
]
|
||||
|
||||
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
|
||||
titel = models.CharField(max_length=200, verbose_name="Titel")
|
||||
datum = models.DateField(verbose_name="Datum")
|
||||
uhrzeit = models.TimeField(null=True, blank=True, verbose_name="Uhrzeit")
|
||||
ort = models.CharField(max_length=200, verbose_name="Ort / Gasthaus")
|
||||
adresse = models.TextField(blank=True, verbose_name="Adresse Gasthaus")
|
||||
beschreibung = models.TextField(blank=True, verbose_name="Beschreibung / Zweck")
|
||||
status = models.CharField(
|
||||
max_length=30,
|
||||
choices=STATUS_CHOICES,
|
||||
default="geplant",
|
||||
verbose_name="Status",
|
||||
)
|
||||
budget_pro_person = models.DecimalField(
|
||||
max_digits=8,
|
||||
decimal_places=2,
|
||||
null=True,
|
||||
blank=True,
|
||||
verbose_name="Budget pro Person (€)",
|
||||
help_text="Geschätztes Budget je Teilnehmer in €",
|
||||
)
|
||||
briefvorlage = models.TextField(
|
||||
blank=True,
|
||||
verbose_name="Briefvorlage",
|
||||
help_text=(
|
||||
"HTML/Text-Template für Serienbrief. Platzhalter: "
|
||||
"{{ anrede }}, {{ vorname }}, {{ nachname }}, {{ strasse }}, "
|
||||
"{{ plz }}, {{ ort }}, {{ datum }}, {{ uhrzeit }}, "
|
||||
"{{ veranstaltungsort }}, {{ gasthaus_adresse }}"
|
||||
),
|
||||
)
|
||||
betreff = models.CharField(
|
||||
max_length=300,
|
||||
blank=True,
|
||||
verbose_name="Betreff",
|
||||
help_text="Betreffzeile des Serienbriefs. Leer = Standardbetreff.",
|
||||
)
|
||||
unterschrift_1_name = models.CharField(
|
||||
max_length=100,
|
||||
blank=True,
|
||||
default="Katrin Kleinpaß",
|
||||
verbose_name="Unterschrift 1 – Name",
|
||||
)
|
||||
unterschrift_1_titel = models.CharField(
|
||||
max_length=100,
|
||||
blank=True,
|
||||
default="Rentmeisterin",
|
||||
verbose_name="Unterschrift 1 – Titel",
|
||||
)
|
||||
unterschrift_2_name = models.CharField(
|
||||
max_length=100,
|
||||
blank=True,
|
||||
default="Jan Remmer Siebels",
|
||||
verbose_name="Unterschrift 2 – Name",
|
||||
)
|
||||
unterschrift_2_titel = models.CharField(
|
||||
max_length=100,
|
||||
blank=True,
|
||||
default="Rentmeister",
|
||||
verbose_name="Unterschrift 2 – Titel",
|
||||
)
|
||||
|
||||
erstellt_am = models.DateTimeField(auto_now_add=True)
|
||||
aktualisiert_am = models.DateTimeField(auto_now=True)
|
||||
|
||||
class Meta:
|
||||
verbose_name = "Veranstaltung"
|
||||
verbose_name_plural = "Veranstaltungen"
|
||||
ordering = ["-datum"]
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.titel} ({self.datum})"
|
||||
|
||||
def get_teilnehmer_count(self):
|
||||
return self.teilnehmer.count()
|
||||
|
||||
def get_zugesagte_count(self):
|
||||
return self.teilnehmer.filter(rsvp_status="zugesagt").count()
|
||||
|
||||
def get_abgesagte_count(self):
|
||||
return self.teilnehmer.filter(rsvp_status="abgesagt").count()
|
||||
|
||||
def get_keine_rueckmeldung_count(self):
|
||||
return self.teilnehmer.filter(rsvp_status="keine_rueckmeldung").count()
|
||||
|
||||
|
||||
class Veranstaltungsteilnehmer(models.Model):
|
||||
"""Teilnehmer einer Veranstaltung – primär freie Eingabe für Familienmitglieder"""
|
||||
|
||||
ANREDE_CHOICES = [
|
||||
("Herr", "Herr"),
|
||||
("Frau", "Frau"),
|
||||
("", "Keine Anrede"),
|
||||
]
|
||||
|
||||
RSVP_CHOICES = [
|
||||
("eingeladen", "Eingeladen"),
|
||||
("zugesagt", "Zugesagt"),
|
||||
("abgesagt", "Abgesagt"),
|
||||
("keine_rueckmeldung", "Keine Rückmeldung"),
|
||||
]
|
||||
|
||||
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
|
||||
veranstaltung = models.ForeignKey(
|
||||
Veranstaltung,
|
||||
on_delete=models.CASCADE,
|
||||
related_name="teilnehmer",
|
||||
verbose_name="Veranstaltung",
|
||||
)
|
||||
|
||||
# Optionale Verknüpfung zu bestehenden Datensätzen
|
||||
paechter = models.ForeignKey(
|
||||
"stiftung.Paechter",
|
||||
null=True,
|
||||
blank=True,
|
||||
on_delete=models.SET_NULL,
|
||||
verbose_name="Pächter (optional)",
|
||||
)
|
||||
destinataer = models.ForeignKey(
|
||||
"stiftung.Destinataer",
|
||||
null=True,
|
||||
blank=True,
|
||||
on_delete=models.SET_NULL,
|
||||
verbose_name="Destinatär (optional)",
|
||||
)
|
||||
|
||||
# Freie Felder (Pflichtfelder für Serienbrief)
|
||||
anrede = models.CharField(
|
||||
max_length=10, choices=ANREDE_CHOICES, blank=True, verbose_name="Anrede"
|
||||
)
|
||||
vorname = models.CharField(max_length=100, verbose_name="Vorname")
|
||||
nachname = models.CharField(max_length=100, verbose_name="Nachname")
|
||||
strasse = models.CharField(max_length=200, blank=True, verbose_name="Straße")
|
||||
plz = models.CharField(max_length=10, blank=True, verbose_name="PLZ")
|
||||
ort = models.CharField(max_length=100, blank=True, verbose_name="Ort")
|
||||
email = models.EmailField(
|
||||
blank=True, verbose_name="E-Mail", help_text="Optional, für späteren E-Mail-Versand"
|
||||
)
|
||||
|
||||
rsvp_status = models.CharField(
|
||||
max_length=20,
|
||||
choices=RSVP_CHOICES,
|
||||
default="eingeladen",
|
||||
verbose_name="RSVP-Status",
|
||||
)
|
||||
bemerkungen = models.TextField(blank=True, verbose_name="Bemerkungen")
|
||||
|
||||
erstellt_am = models.DateTimeField(auto_now_add=True)
|
||||
|
||||
class Meta:
|
||||
verbose_name = "Veranstaltungsteilnehmer"
|
||||
verbose_name_plural = "Veranstaltungsteilnehmer"
|
||||
ordering = ["nachname", "vorname"]
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.anrede} {self.vorname} {self.nachname}".strip()
|
||||
|
||||
def get_full_name(self):
|
||||
return f"{self.vorname} {self.nachname}".strip()
|
||||
|
||||
def get_full_address(self):
|
||||
parts = [self.strasse, f"{self.plz} {self.ort}".strip()]
|
||||
return ", ".join(p for p in parts if p)
|
||||
54
app/stiftung/models/vorlagen.py
Normal file
54
app/stiftung/models/vorlagen.py
Normal file
@@ -0,0 +1,54 @@
|
||||
import uuid
|
||||
|
||||
from django.contrib.auth.models import User
|
||||
from django.db import models
|
||||
|
||||
|
||||
class DokumentVorlage(models.Model):
|
||||
"""Web-editierbare Vorlagen für generierte Dokumente (PDF, E-Mail, Berichte)."""
|
||||
|
||||
KATEGORIE_CHOICES = [
|
||||
("pdf", "PDF-Dokument"),
|
||||
("email", "E-Mail"),
|
||||
("bericht", "Bericht"),
|
||||
("serienbrief", "Serienbrief"),
|
||||
]
|
||||
|
||||
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
|
||||
schluessel = models.CharField(
|
||||
max_length=200,
|
||||
unique=True,
|
||||
verbose_name="Schlüssel",
|
||||
help_text="Interner Template-Pfad, z.B. pdf/bestaetigung.html",
|
||||
)
|
||||
bezeichnung = models.CharField(max_length=200, verbose_name="Bezeichnung")
|
||||
kategorie = models.CharField(
|
||||
max_length=30,
|
||||
choices=KATEGORIE_CHOICES,
|
||||
verbose_name="Kategorie",
|
||||
)
|
||||
html_inhalt = models.TextField(verbose_name="HTML-Inhalt")
|
||||
verfuegbare_variablen = models.JSONField(
|
||||
default=dict,
|
||||
blank=True,
|
||||
verbose_name="Verfügbare Variablen",
|
||||
help_text="JSON-Dokumentation der verfügbaren Template-Variablen",
|
||||
)
|
||||
zuletzt_bearbeitet_von = models.ForeignKey(
|
||||
User,
|
||||
null=True,
|
||||
blank=True,
|
||||
on_delete=models.SET_NULL,
|
||||
related_name="bearbeitete_vorlagen",
|
||||
verbose_name="Zuletzt bearbeitet von",
|
||||
)
|
||||
zuletzt_bearbeitet_am = models.DateTimeField(auto_now=True, verbose_name="Zuletzt bearbeitet")
|
||||
erstellt_am = models.DateTimeField(auto_now_add=True, verbose_name="Erstellt am")
|
||||
|
||||
class Meta:
|
||||
verbose_name = "Dokument-Vorlage"
|
||||
verbose_name_plural = "Dokument-Vorlagen"
|
||||
ordering = ["kategorie", "bezeichnung"]
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.bezeichnung} ({self.schluessel})"
|
||||
53
app/stiftung/portal_urls.py
Normal file
53
app/stiftung/portal_urls.py
Normal file
@@ -0,0 +1,53 @@
|
||||
"""
|
||||
URL-Konfiguration für das öffentliche Destinatär-Portal.
|
||||
|
||||
Diese URLs sind ohne Login zugänglich (tokenbasierte Authentifizierung).
|
||||
"""
|
||||
from django.urls import path
|
||||
|
||||
from stiftung.views.portal import (
|
||||
datenschutzerklaerung,
|
||||
onboarding_danke,
|
||||
onboarding_schritt,
|
||||
upload_danke,
|
||||
upload_formular,
|
||||
)
|
||||
|
||||
app_name = "portal"
|
||||
|
||||
urlpatterns = [
|
||||
# Datenschutzerklärung (öffentlich, kein Token erforderlich)
|
||||
path(
|
||||
"datenschutz/",
|
||||
datenschutzerklaerung,
|
||||
name="datenschutzerklaerung",
|
||||
),
|
||||
# Upload-Portal (bestehende Destinatäre – Token-basiert)
|
||||
path(
|
||||
"upload/<str:token>/",
|
||||
upload_formular,
|
||||
name="upload_formular",
|
||||
),
|
||||
path(
|
||||
"upload/<str:token>/danke/",
|
||||
upload_danke,
|
||||
name="upload_danke",
|
||||
),
|
||||
# Onboarding-Portal (neue Destinatäre – Einladungs-Token)
|
||||
path(
|
||||
"onboarding/<str:token>/",
|
||||
onboarding_schritt,
|
||||
{"schritt": 1},
|
||||
name="onboarding_start",
|
||||
),
|
||||
path(
|
||||
"onboarding/<str:token>/schritt/<int:schritt>/",
|
||||
onboarding_schritt,
|
||||
name="onboarding_schritt",
|
||||
),
|
||||
path(
|
||||
"onboarding/<str:token>/danke/",
|
||||
onboarding_danke,
|
||||
name="onboarding_danke",
|
||||
),
|
||||
]
|
||||
@@ -1,20 +1,20 @@
|
||||
"""
|
||||
Celery-Tasks für die automatische Verarbeitung von Destinatär-E-Mails.
|
||||
Celery-Tasks fuer die automatische Verarbeitung eingehender E-Mails.
|
||||
|
||||
Workflow:
|
||||
1. `poll_destinataer_emails` läuft alle 15 Minuten (Celery Beat)
|
||||
2. Er liest ungelesene E-Mails aus dem IMAP-Postfach (paperless@vhtv-stiftung.de)
|
||||
3. Für jede E-Mail:
|
||||
a) Absender wird mit Destinatär-Datenbank abgeglichen (E-Mail-Feld)
|
||||
b) Ein DestinataerEmailEingang-Datensatz wird angelegt
|
||||
c) Alle Anhänge werden per Paperless-API hochgeladen
|
||||
d) Für jeden Anhang wird ein DokumentLink erstellt
|
||||
1. `poll_emails` laeuft alle 15 Minuten (Celery Beat)
|
||||
2. Er liest ungelesene E-Mails aus dem IMAP-Postfach
|
||||
3. Fuer jede E-Mail:
|
||||
a) Absender wird mit Destinataer-Datenbank abgeglichen (E-Mail-Feld)
|
||||
b) Betreff/Body wird auf Rechnungs-Keywords geprueft
|
||||
c) Ein EmailEingang-Datensatz wird angelegt (mit Kategorie)
|
||||
d) Alle Anhaenge werden als DokumentDatei im Django-DMS gespeichert
|
||||
4. Nicht erkannte Absender werden als "unbekannt" markiert (manuelle Nachbearbeitung)
|
||||
|
||||
Konfiguration (Umgebungsvariablen in .env / compose.yml):
|
||||
IMAP_HOST — IMAP-Server-Adresse (z. B. mail.vhtv-stiftung.de)
|
||||
IMAP_PORT — Port (Standard: 993 für SSL)
|
||||
IMAP_USER — Benutzername (z. B. paperless@vhtv-stiftung.de)
|
||||
IMAP_PORT — Port (Standard: 993 fuer SSL)
|
||||
IMAP_USER — Benutzername
|
||||
IMAP_PASSWORD — Passwort
|
||||
IMAP_FOLDER — Ordner (Standard: INBOX)
|
||||
"""
|
||||
@@ -22,20 +22,39 @@ Konfiguration (Umgebungsvariablen in .env / compose.yml):
|
||||
import email
|
||||
import email.utils
|
||||
import imaplib
|
||||
import io
|
||||
import logging
|
||||
import mimetypes
|
||||
import re
|
||||
from datetime import datetime, timezone as dt_timezone
|
||||
from email.header import decode_header, make_header
|
||||
|
||||
import requests
|
||||
from celery import shared_task
|
||||
from django.conf import settings
|
||||
from django.utils import timezone
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# Patterns fuer Rechnungserkennung im Betreff/Body
|
||||
RECHNUNG_PATTERNS = [
|
||||
re.compile(r"\brechnung\b", re.IGNORECASE),
|
||||
re.compile(r"\binvoice\b", re.IGNORECASE),
|
||||
re.compile(r"\brechnungs[-\s]?nr\.?\s*[:\s]?\s*\d+", re.IGNORECASE),
|
||||
re.compile(r"\bRE[-/]\d{4,}", re.IGNORECASE), # RE-2024001, RE/20240315
|
||||
]
|
||||
|
||||
GESCHICHTE_PATTERNS = [
|
||||
re.compile(r"\bstiftungsgeschichte\b", re.IGNORECASE),
|
||||
re.compile(r"\bahnenforschung\b", re.IGNORECASE),
|
||||
re.compile(r"\bgenealogie\b", re.IGNORECASE),
|
||||
re.compile(r"\bstammbaum\b", re.IGNORECASE),
|
||||
re.compile(r"\bhistorisch", re.IGNORECASE),
|
||||
re.compile(r"\bchronik\b", re.IGNORECASE),
|
||||
re.compile(r"\barchiv\b", re.IGNORECASE),
|
||||
re.compile(r"\bfamiliengeschichte\b", re.IGNORECASE),
|
||||
re.compile(r"\burkunde\b", re.IGNORECASE),
|
||||
]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Hilfsfunktionen
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -52,7 +71,7 @@ def _decode_header_value(raw_value: str) -> str:
|
||||
|
||||
|
||||
def _parse_email_date(date_str: str) -> datetime:
|
||||
"""Parst das E-Mail-Datum und gibt ein timezone-aware datetime zurück."""
|
||||
"""Parst das E-Mail-Datum und gibt ein timezone-aware datetime zurueck."""
|
||||
try:
|
||||
parsed = email.utils.parsedate_to_datetime(date_str)
|
||||
if parsed.tzinfo is None:
|
||||
@@ -84,103 +103,108 @@ def _get_email_body(msg) -> str:
|
||||
return "\n".join(body_parts).strip()
|
||||
|
||||
|
||||
def _upload_to_paperless(content: bytes, filename: str, destinataer=None, betreff: str = "") -> int | None:
|
||||
def _detect_kategorie(betreff: str, email_text: str, has_destinataer: bool) -> str:
|
||||
"""
|
||||
Lädt einen Anhang in Paperless-NGX hoch.
|
||||
|
||||
Gibt die neue Paperless-Dokument-ID zurück, oder None bei Fehler.
|
||||
Erkennt die Kategorie einer Email anhand von Betreff und Body.
|
||||
Gibt 'destinataer', 'rechnung', 'stiftungsgeschichte', oder 'allgemein' zurueck.
|
||||
"""
|
||||
api_url = getattr(settings, "PAPERLESS_API_URL", None)
|
||||
api_token = getattr(settings, "PAPERLESS_API_TOKEN", None)
|
||||
if has_destinataer:
|
||||
return "destinataer"
|
||||
|
||||
if not api_url or not api_token:
|
||||
logger.warning("Paperless nicht konfiguriert – Anhang '%s' wird nicht hochgeladen.", filename)
|
||||
return None
|
||||
text_to_check = f"{betreff}\n{email_text[:2000]}"
|
||||
|
||||
# Tag-ID für Destinatäre ermitteln
|
||||
tag_ids = []
|
||||
dest_tag_id = getattr(settings, "PAPERLESS_DESTINATAERE_TAG_ID", None)
|
||||
if dest_tag_id:
|
||||
try:
|
||||
tag_ids.append(int(dest_tag_id))
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
# Rechnungserkennung via Patterns
|
||||
for pattern in RECHNUNG_PATTERNS:
|
||||
if pattern.search(text_to_check):
|
||||
return "rechnung"
|
||||
|
||||
# Correspondent: Name des Destinatärs (optional, Paperless sucht/erstellt ihn)
|
||||
correspondent_name = None
|
||||
if destinataer:
|
||||
correspondent_name = f"{destinataer.vorname} {destinataer.nachname}".strip()
|
||||
# Stiftungsgeschichte-Erkennung
|
||||
for pattern in GESCHICHTE_PATTERNS:
|
||||
if pattern.search(text_to_check):
|
||||
return "stiftungsgeschichte"
|
||||
|
||||
# Dateiname bereinigen
|
||||
safe_filename = filename or "anhang.pdf"
|
||||
return "allgemein"
|
||||
|
||||
# Mime-Type bestimmen
|
||||
|
||||
def _save_to_dms(content: bytes, filename: str, destinataer=None, betreff: str = "", kontext: str = "korrespondenz"):
|
||||
"""
|
||||
Speichert einen E-Mail-Anhang direkt als DokumentDatei im Django-DMS.
|
||||
|
||||
Gibt das DokumentDatei-Objekt zurueck, oder None bei Fehler.
|
||||
"""
|
||||
from stiftung.models import DokumentDatei
|
||||
from django.core.files.base import ContentFile
|
||||
|
||||
safe_filename = filename or "anhang.bin"
|
||||
mime_type, _ = mimetypes.guess_type(safe_filename)
|
||||
mime_type = mime_type or "application/octet-stream"
|
||||
|
||||
upload_url = f"{api_url.rstrip('/')}/api/documents/post_document/"
|
||||
headers = {"Authorization": f"Token {api_token}"}
|
||||
|
||||
form_data = {}
|
||||
if tag_ids:
|
||||
form_data["tags"] = tag_ids
|
||||
if correspondent_name:
|
||||
form_data["correspondent_name"] = correspondent_name
|
||||
if betreff:
|
||||
form_data["title"] = betreff[:128]
|
||||
|
||||
files = {"document": (safe_filename, io.BytesIO(content), mime_type)}
|
||||
titel = f"{betreff[:100]} – {safe_filename}" if betreff else safe_filename
|
||||
beschreibung = ""
|
||||
if destinataer:
|
||||
beschreibung = (
|
||||
f"Automatisch importiert aus E-Mail-Eingang.\n"
|
||||
f"Absender: {destinataer.vorname} {destinataer.nachname} <{destinataer.email}>"
|
||||
)
|
||||
|
||||
try:
|
||||
response = requests.post(
|
||||
upload_url,
|
||||
headers=headers,
|
||||
data=form_data,
|
||||
files=files,
|
||||
timeout=60,
|
||||
doc = DokumentDatei(
|
||||
titel=titel[:255],
|
||||
beschreibung=beschreibung,
|
||||
kontext=kontext,
|
||||
dateiname_original=safe_filename,
|
||||
dateityp=mime_type,
|
||||
dateigroesse=len(content),
|
||||
destinataer=destinataer,
|
||||
)
|
||||
response.raise_for_status()
|
||||
# Paperless gibt die neue Dokument-ID zurück (als Integer oder UUID-String)
|
||||
result = response.json()
|
||||
doc_id = result if isinstance(result, int) else result.get("id")
|
||||
logger.info("Anhang '%s' erfolgreich in Paperless hochgeladen (ID: %s).", safe_filename, doc_id)
|
||||
return doc_id
|
||||
except requests.RequestException as exc:
|
||||
logger.error("Fehler beim Hochladen von '%s' in Paperless: %s", safe_filename, exc)
|
||||
doc.datei.save(safe_filename, ContentFile(content), save=False)
|
||||
doc.save()
|
||||
logger.info("Anhang '%s' als DokumentDatei gespeichert (ID: %s).", safe_filename, doc.pk)
|
||||
return doc
|
||||
except Exception as exc:
|
||||
logger.error("Fehler beim Speichern von '%s' im DMS: %s", safe_filename, exc)
|
||||
return None
|
||||
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Haupttask
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@shared_task(bind=True, max_retries=3, default_retry_delay=300, name="stiftung.tasks.poll_destinataer_emails")
|
||||
def poll_destinataer_emails(self):
|
||||
@shared_task(bind=True, max_retries=3, default_retry_delay=300, name="stiftung.tasks.poll_emails")
|
||||
def poll_emails(self, search_all_recent_days=0):
|
||||
"""
|
||||
Liest ungelesene E-Mails aus dem IMAP-Postfach und verarbeitet sie.
|
||||
Liest E-Mails aus dem IMAP-Postfach und verarbeitet sie.
|
||||
|
||||
Wird durch Celery Beat alle 15 Minuten ausgeführt.
|
||||
Wird durch Celery Beat alle 15 Minuten ausgefuehrt.
|
||||
Erkennt automatisch Destinataer-Emails, Rechnungen und allgemeine Post.
|
||||
|
||||
Args:
|
||||
search_all_recent_days: Wenn > 0, werden alle E-Mails der letzten N Tage
|
||||
durchsucht (nicht nur ungelesene). Nuetzlich fuer manuellen Abruf.
|
||||
"""
|
||||
from stiftung.models import Destinataer, DestinataerEmailEingang, DokumentLink
|
||||
from stiftung.models import Destinataer, EmailEingang
|
||||
|
||||
# IMAP-Konfiguration aus Settings
|
||||
imap_host = getattr(settings, "IMAP_HOST", None)
|
||||
imap_port = int(getattr(settings, "IMAP_PORT", 993))
|
||||
imap_user = getattr(settings, "IMAP_USER", None)
|
||||
imap_password = getattr(settings, "IMAP_PASSWORD", None)
|
||||
imap_folder = getattr(settings, "IMAP_FOLDER", "INBOX")
|
||||
imap_use_ssl = getattr(settings, "IMAP_USE_SSL", True)
|
||||
# IMAP-Konfiguration: DB (AppConfiguration) mit Fallback auf env/settings
|
||||
from stiftung.utils.config import get_config
|
||||
|
||||
imap_host = get_config("imap_host")
|
||||
imap_port = int(get_config("imap_port", 993))
|
||||
imap_user = get_config("imap_user")
|
||||
imap_password = get_config("imap_password")
|
||||
imap_folder = get_config("imap_folder", "INBOX")
|
||||
imap_use_ssl = get_config("imap_use_ssl", True)
|
||||
|
||||
if not all([imap_host, imap_user, imap_password]):
|
||||
logger.warning(
|
||||
"IMAP nicht konfiguriert (IMAP_HOST, IMAP_USER, IMAP_PASSWORD fehlen). "
|
||||
"Task wird übersprungen."
|
||||
"Task wird uebersprungen."
|
||||
)
|
||||
return {"status": "skipped", "reason": "IMAP not configured"}
|
||||
|
||||
# Vorab: Destinatär-E-Mail-Index für schnelle Zuordnung
|
||||
# Nur aktive Destinatäre mit gesetzter E-Mail-Adresse
|
||||
# Vorab: Destinataer-E-Mail-Index fuer schnelle Zuordnung
|
||||
# Nur aktive Destinataere mit gesetzter E-Mail-Adresse
|
||||
destinataer_by_email = {
|
||||
d.email.lower(): d
|
||||
for d in Destinataer.objects.filter(aktiv=True, email__isnull=False).exclude(email="")
|
||||
@@ -190,20 +214,28 @@ def poll_destinataer_emails(self):
|
||||
errors = 0
|
||||
|
||||
try:
|
||||
# IMAP-Verbindung aufbauen
|
||||
# IMAP-Verbindung aufbauen (mit Socket-Timeout fuer grosse E-Mails)
|
||||
imap_timeout = 120 # Sekunden – genug fuer grosse Anhaenge
|
||||
if imap_use_ssl:
|
||||
mail = imaplib.IMAP4_SSL(imap_host, imap_port)
|
||||
mail = imaplib.IMAP4_SSL(imap_host, imap_port, timeout=imap_timeout)
|
||||
else:
|
||||
mail = imaplib.IMAP4(imap_host, imap_port)
|
||||
mail = imaplib.IMAP4(imap_host, imap_port, timeout=imap_timeout)
|
||||
|
||||
mail.login(imap_user, imap_password)
|
||||
mail.select(imap_folder)
|
||||
|
||||
# Ungelesene Nachrichten suchen
|
||||
_, message_ids_raw = mail.search(None, "UNSEEN")
|
||||
# Nachrichten suchen
|
||||
if search_all_recent_days and search_all_recent_days > 0:
|
||||
from datetime import timedelta
|
||||
since_date = (datetime.now(dt_timezone.utc) - timedelta(days=search_all_recent_days)).strftime("%d-%b-%Y")
|
||||
_, message_ids_raw = mail.search(None, "SINCE", since_date)
|
||||
search_mode = f"ALL seit {since_date}"
|
||||
else:
|
||||
_, message_ids_raw = mail.search(None, "UNSEEN")
|
||||
search_mode = "UNSEEN"
|
||||
message_ids = message_ids_raw[0].split()
|
||||
|
||||
logger.info("Postfach '%s': %d ungelesene Nachricht(en) gefunden.", imap_folder, len(message_ids))
|
||||
logger.info("Postfach '%s' (%s): %d Nachricht(en) gefunden.", imap_folder, search_mode, len(message_ids))
|
||||
|
||||
for msg_id in message_ids:
|
||||
try:
|
||||
@@ -214,7 +246,7 @@ def poll_destinataer_emails(self):
|
||||
# Absender ermitteln
|
||||
from_raw = msg.get("From", "")
|
||||
absender_name_raw, absender_email_raw = email.utils.parseaddr(from_raw)
|
||||
absender_email = absender_email_raw.lower().strip()
|
||||
absender_email_addr = absender_email_raw.lower().strip()
|
||||
absender_name = _decode_header_value(absender_name_raw)
|
||||
|
||||
# Betreff
|
||||
@@ -226,30 +258,48 @@ def poll_destinataer_emails(self):
|
||||
# E-Mail-Text
|
||||
email_text = _get_email_body(msg)
|
||||
|
||||
# Destinatär zuordnen
|
||||
destinataer = destinataer_by_email.get(absender_email)
|
||||
status = "zugewiesen" if destinataer else "unbekannt"
|
||||
# Destinataer zuordnen
|
||||
destinataer = destinataer_by_email.get(absender_email_addr)
|
||||
|
||||
# Prüfen ob diese E-Mail bereits verarbeitet wurde (Duplikat-Check via
|
||||
# Kategorie erkennen
|
||||
kategorie = _detect_kategorie(betreff, email_text, has_destinataer=bool(destinataer))
|
||||
|
||||
# Status basierend auf Kategorie
|
||||
if destinataer:
|
||||
status = "zugewiesen"
|
||||
elif kategorie == "rechnung":
|
||||
status = "neu" # Muss manuell als Rechnung erfasst werden
|
||||
else:
|
||||
status = "unbekannt"
|
||||
|
||||
# DMS-Kontext fuer Anhaenge basierend auf Kategorie
|
||||
dms_kontext_map = {
|
||||
"rechnung": "rechnung",
|
||||
"stiftungsgeschichte": "stiftungsgeschichte",
|
||||
}
|
||||
dms_kontext = dms_kontext_map.get(kategorie, "korrespondenz")
|
||||
|
||||
# Pruefen ob diese E-Mail bereits verarbeitet wurde (Duplikat-Check via
|
||||
# Datum + Absender + Betreff)
|
||||
already_exists = DestinataerEmailEingang.objects.filter(
|
||||
absender_email=absender_email,
|
||||
already_exists = EmailEingang.objects.filter(
|
||||
absender_email=absender_email_addr,
|
||||
eingangsdatum=eingangsdatum,
|
||||
betreff=betreff[:500],
|
||||
).exists()
|
||||
if already_exists:
|
||||
logger.debug(
|
||||
"E-Mail von %s am %s bereits vorhanden – wird übersprungen.",
|
||||
absender_email, eingangsdatum,
|
||||
"E-Mail von %s am %s bereits vorhanden – wird uebersprungen.",
|
||||
absender_email_addr, eingangsdatum,
|
||||
)
|
||||
# Als gelesen markieren
|
||||
mail.store(msg_id, "+FLAGS", "\\Seen")
|
||||
continue
|
||||
|
||||
# Datensatz anlegen
|
||||
eingang = DestinataerEmailEingang(
|
||||
eingang = EmailEingang(
|
||||
kategorie=kategorie,
|
||||
destinataer=destinataer,
|
||||
absender_email=absender_email,
|
||||
absender_email=absender_email_addr,
|
||||
absender_name=absender_name,
|
||||
betreff=betreff[:500],
|
||||
eingangsdatum=eingangsdatum,
|
||||
@@ -257,8 +307,8 @@ def poll_destinataer_emails(self):
|
||||
status=status,
|
||||
)
|
||||
|
||||
# Anhänge verarbeiten
|
||||
paperless_ids = []
|
||||
# Anhaenge verarbeiten und als DokumentDatei im DMS speichern
|
||||
dms_dokumente = []
|
||||
if msg.is_multipart():
|
||||
for part in msg.walk():
|
||||
disposition = str(part.get_content_disposition() or "")
|
||||
@@ -266,48 +316,90 @@ def poll_destinataer_emails(self):
|
||||
filename = _decode_header_value(part.get_filename() or "")
|
||||
content = part.get_payload(decode=True)
|
||||
if not content:
|
||||
logger.warning(
|
||||
"Anhang '%s' hat keinen Inhalt – wird uebersprungen.",
|
||||
filename,
|
||||
)
|
||||
continue
|
||||
|
||||
doc_id = _upload_to_paperless(
|
||||
doc = _save_to_dms(
|
||||
content=content,
|
||||
filename=filename,
|
||||
destinataer=destinataer,
|
||||
betreff=betreff,
|
||||
kontext=dms_kontext,
|
||||
)
|
||||
if doc_id:
|
||||
paperless_ids.append(doc_id)
|
||||
# DokumentLink anlegen
|
||||
DokumentLink.objects.create(
|
||||
paperless_document_id=doc_id,
|
||||
kontext="verwendungsnachweis",
|
||||
titel=f"{betreff[:100]} – {filename}" if filename else betreff[:200],
|
||||
beschreibung=(
|
||||
f"Automatisch importiert aus E-Mail-Eingang.\n"
|
||||
f"Absender: {absender_name} <{absender_email}>\n"
|
||||
f"Datum: {eingangsdatum.strftime('%d.%m.%Y %H:%M')}"
|
||||
),
|
||||
destinataer_id=destinataer.pk if destinataer else None,
|
||||
)
|
||||
if doc:
|
||||
dms_dokumente.append(doc)
|
||||
|
||||
eingang.paperless_dokument_ids = paperless_ids
|
||||
if paperless_ids:
|
||||
eingang.status = "verarbeitet" if destinataer else "unbekannt"
|
||||
# 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 alle_dms_dokumente:
|
||||
eingang.dokument_dateien.set(alle_dms_dokumente)
|
||||
|
||||
# Als gelesen markieren
|
||||
mail.store(msg_id, "+FLAGS", "\\Seen")
|
||||
processed += 1
|
||||
logger.info(
|
||||
"E-Mail verarbeitet: von=%s, Destinatär=%s, Anhänge=%d",
|
||||
absender_email,
|
||||
str(destinataer) if destinataer else "unbekannt",
|
||||
len(paperless_ids),
|
||||
"E-Mail verarbeitet: von=%s, Kategorie=%s, Destinataer=%s, Anhaenge=%d",
|
||||
absender_email_addr,
|
||||
kategorie,
|
||||
str(destinataer) if destinataer else "–",
|
||||
len(dms_dokumente),
|
||||
)
|
||||
|
||||
except Exception as exc:
|
||||
errors += 1
|
||||
logger.exception("Fehler bei Verarbeitung von Nachricht %s: %s", msg_id, exc)
|
||||
# Nicht als gelesen markieren – wird beim nächsten Lauf erneut versucht
|
||||
# Nicht als gelesen markieren – wird beim naechsten Lauf erneut versucht
|
||||
|
||||
mail.close()
|
||||
mail.logout()
|
||||
@@ -316,9 +408,442 @@ def poll_destinataer_emails(self):
|
||||
logger.error("IMAP-Fehler: %s", exc)
|
||||
raise self.retry(exc=exc)
|
||||
except Exception as exc:
|
||||
logger.exception("Unerwarteter Fehler im poll_destinataer_emails-Task: %s", exc)
|
||||
logger.exception("Unerwarteter Fehler im poll_emails-Task: %s", exc)
|
||||
raise self.retry(exc=exc)
|
||||
|
||||
result = {"status": "done", "processed": processed, "errors": errors}
|
||||
logger.info("poll_destinataer_emails abgeschlossen: %s", result)
|
||||
logger.info("poll_emails abgeschlossen: %s", result)
|
||||
return result
|
||||
|
||||
|
||||
# Backward-compatible alias for existing Celery Beat schedules
|
||||
poll_destinataer_emails = poll_emails
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# SMTP-Ausgangs-Tasks: Nachweis-Aufforderungen und Token-Erinnerungen
|
||||
# =============================================================================
|
||||
|
||||
import secrets # noqa: E402 (wird hier benötigt)
|
||||
from datetime import timedelta # noqa: E402
|
||||
|
||||
|
||||
def _get_smtp_connection():
|
||||
"""
|
||||
Erstellt eine Django-E-Mail-Verbindung mit SMTP-Einstellungen aus der DB.
|
||||
"""
|
||||
from django.core.mail import get_connection
|
||||
from stiftung.utils.config import get_config
|
||||
|
||||
return get_connection(
|
||||
backend="django.core.mail.backends.smtp.EmailBackend",
|
||||
host=get_config("smtp_host", "smtp.ionos.de"),
|
||||
port=int(get_config("smtp_port", 465)),
|
||||
username=get_config("smtp_user", ""),
|
||||
password=get_config("smtp_password", ""),
|
||||
use_ssl=bool(get_config("smtp_use_ssl", True)),
|
||||
use_tls=False,
|
||||
fail_silently=False,
|
||||
)
|
||||
|
||||
|
||||
def _get_smtp_from_email():
|
||||
"""Gibt die konfigurierte Absenderadresse zurück."""
|
||||
from stiftung.utils.config import get_config
|
||||
return get_config("smtp_from_email", "buero@vhtv-stiftung.de")
|
||||
|
||||
|
||||
@shared_task(bind=True, max_retries=3, default_retry_delay=300)
|
||||
def send_nachweis_aufforderung(self, destinataer_id, nachweis_id, base_url=None):
|
||||
"""
|
||||
Erstellt einen UploadToken und sendet eine Nachweis-Aufforderungs-E-Mail
|
||||
mit Einmallink und QR-Code an den Destinatär.
|
||||
|
||||
Args:
|
||||
destinataer_id: UUID des Destinatärs
|
||||
nachweis_id: UUID des VierteljahresNachweises
|
||||
base_url: Basis-URL der Anwendung (z.B. 'https://vhtv-stiftung.de')
|
||||
"""
|
||||
from django.conf import settings
|
||||
from django.core.mail import EmailMultiAlternatives
|
||||
from django.template.loader import render_to_string
|
||||
from django.utils import timezone
|
||||
import io
|
||||
try:
|
||||
import qrcode
|
||||
from PIL import Image
|
||||
import base64
|
||||
qr_available = True
|
||||
except ImportError:
|
||||
qr_available = False
|
||||
|
||||
from stiftung.models import Destinataer, VierteljahresNachweis, UploadToken
|
||||
|
||||
try:
|
||||
destinataer = Destinataer.objects.get(id=destinataer_id)
|
||||
nachweis = VierteljahresNachweis.objects.get(id=nachweis_id)
|
||||
except (Destinataer.DoesNotExist, VierteljahresNachweis.DoesNotExist) as exc:
|
||||
logger.error("send_nachweis_aufforderung: Objekt nicht gefunden: %s", exc)
|
||||
return {"status": "error", "message": str(exc)}
|
||||
|
||||
if not destinataer.email:
|
||||
logger.warning(
|
||||
"send_nachweis_aufforderung: Destinatär %s hat keine E-Mail-Adresse",
|
||||
destinataer_id,
|
||||
)
|
||||
return {"status": "skipped", "reason": "no_email"}
|
||||
|
||||
# Bestehende aktive Tokens für diesen Nachweis deaktivieren
|
||||
UploadToken.objects.filter(
|
||||
destinataer=destinataer,
|
||||
nachweis=nachweis,
|
||||
ist_aktiv=True,
|
||||
).update(ist_aktiv=False)
|
||||
|
||||
# Neuen Token erstellen
|
||||
token_str = secrets.token_urlsafe(48)
|
||||
gueltig_bis = timezone.now() + timedelta(days=30)
|
||||
upload_token = UploadToken.objects.create(
|
||||
token=token_str,
|
||||
destinataer=destinataer,
|
||||
nachweis=nachweis,
|
||||
gueltig_bis=gueltig_bis,
|
||||
)
|
||||
|
||||
if base_url is None:
|
||||
base_url = getattr(settings, "SITE_URL", "https://vhtv-stiftung.de")
|
||||
|
||||
upload_url = f"{base_url}/portal/upload/{token_str}/"
|
||||
|
||||
# QR-Code generieren
|
||||
qr_code_base64 = None
|
||||
if qr_available:
|
||||
try:
|
||||
qr = qrcode.QRCode(
|
||||
version=1,
|
||||
error_correction=qrcode.constants.ERROR_CORRECT_M,
|
||||
box_size=6,
|
||||
border=4,
|
||||
)
|
||||
qr.add_data(upload_url)
|
||||
qr.make(fit=True)
|
||||
img = qr.make_image(fill_color="black", back_color="white")
|
||||
buffer = io.BytesIO()
|
||||
img.save(buffer, format="PNG")
|
||||
qr_code_base64 = base64.b64encode(buffer.getvalue()).decode()
|
||||
except Exception as qr_exc:
|
||||
logger.warning("QR-Code-Generierung fehlgeschlagen: %s", qr_exc)
|
||||
|
||||
# Halbjahr bestimmen (Q1+Q2 = 1. Halbjahr, Q3+Q4 = 2. Halbjahr)
|
||||
halbjahr = 1 if nachweis.quartal in [1, 2] else 2
|
||||
halbjahr_label = f"{halbjahr}. Halbjahr {nachweis.jahr}"
|
||||
quartal_label = f"Q{nachweis.quartal} {nachweis.jahr}"
|
||||
|
||||
context = {
|
||||
"destinataer": destinataer,
|
||||
"nachweis": nachweis,
|
||||
"upload_url": upload_url,
|
||||
"qr_code_base64": qr_code_base64,
|
||||
"gueltig_bis": gueltig_bis,
|
||||
"halbjahr_label": halbjahr_label,
|
||||
"quartal_label": quartal_label,
|
||||
"datenschutz_url": f"{base_url}/portal/datenschutz/",
|
||||
}
|
||||
|
||||
subject = f"Nachweis-Aufforderung: {quartal_label} ({halbjahr_label}) – vHTV-Stiftung"
|
||||
from_email = _get_smtp_from_email()
|
||||
to_email = destinataer.email
|
||||
|
||||
from stiftung.utils.vorlagen import render_vorlage
|
||||
text_body = render_vorlage("email/nachweis_aufforderung.txt", context)
|
||||
html_body = render_vorlage("email/nachweis_aufforderung.html", context)
|
||||
|
||||
try:
|
||||
connection = _get_smtp_connection()
|
||||
msg = EmailMultiAlternatives(subject, text_body, from_email, [to_email], connection=connection)
|
||||
msg.attach_alternative(html_body, "text/html")
|
||||
msg.send()
|
||||
logger.info(
|
||||
"Nachweis-Aufforderung gesendet an %s (Token %s)",
|
||||
to_email,
|
||||
upload_token.id,
|
||||
)
|
||||
return {
|
||||
"status": "sent",
|
||||
"destinataer_id": str(destinataer_id),
|
||||
"nachweis_id": str(nachweis_id),
|
||||
"token_id": str(upload_token.id),
|
||||
}
|
||||
except Exception as exc:
|
||||
logger.exception("E-Mail-Versand fehlgeschlagen für %s: %s", to_email, exc)
|
||||
raise self.retry(exc=exc)
|
||||
|
||||
|
||||
@shared_task(bind=True, max_retries=3, default_retry_delay=300)
|
||||
def send_nachweis_erinnerung(self, token_id, base_url=None):
|
||||
"""
|
||||
Sendet eine Erinnerungs-E-Mail für einen bald ablaufenden Upload-Token.
|
||||
Wird durch Celery Beat ausgelöst (7 Tage vor Ablauf).
|
||||
"""
|
||||
from django.conf import settings
|
||||
from django.core.mail import EmailMultiAlternatives
|
||||
from django.template.loader import render_to_string
|
||||
|
||||
from stiftung.models import UploadToken
|
||||
|
||||
try:
|
||||
upload_token = UploadToken.objects.select_related(
|
||||
"destinataer", "nachweis"
|
||||
).get(id=token_id, ist_aktiv=True)
|
||||
except UploadToken.DoesNotExist:
|
||||
return {"status": "skipped", "reason": "token_not_found_or_inactive"}
|
||||
|
||||
if not upload_token.ist_gueltig():
|
||||
return {"status": "skipped", "reason": "token_invalid"}
|
||||
|
||||
if not upload_token.destinataer.email:
|
||||
return {"status": "skipped", "reason": "no_email"}
|
||||
|
||||
if base_url is None:
|
||||
base_url = getattr(settings, "SITE_URL", "https://vhtv-stiftung.de")
|
||||
|
||||
upload_url = f"{base_url}/portal/upload/{upload_token.token}/"
|
||||
nachweis = upload_token.nachweis
|
||||
halbjahr = 1 if nachweis.quartal in [1, 2] else 2
|
||||
halbjahr_label = f"{halbjahr}. Halbjahr {nachweis.jahr}"
|
||||
|
||||
context = {
|
||||
"destinataer": upload_token.destinataer,
|
||||
"nachweis": nachweis,
|
||||
"upload_url": upload_url,
|
||||
"gueltig_bis": upload_token.gueltig_bis,
|
||||
"halbjahr_label": halbjahr_label,
|
||||
"ist_erinnerung": True,
|
||||
"datenschutz_url": f"{base_url}/portal/datenschutz/",
|
||||
}
|
||||
|
||||
subject = f"Erinnerung: Nachweis-Upload noch ausstehend – {halbjahr_label}"
|
||||
from_email = _get_smtp_from_email()
|
||||
to_email = upload_token.destinataer.email
|
||||
|
||||
from stiftung.utils.vorlagen import render_vorlage
|
||||
text_body = render_vorlage("email/nachweis_aufforderung.txt", context)
|
||||
html_body = render_vorlage("email/nachweis_aufforderung.html", context)
|
||||
|
||||
try:
|
||||
connection = _get_smtp_connection()
|
||||
msg = EmailMultiAlternatives(subject, text_body, from_email, [to_email], connection=connection)
|
||||
msg.attach_alternative(html_body, "text/html")
|
||||
msg.send()
|
||||
upload_token.erinnerung_gesendet = True
|
||||
upload_token.save(update_fields=["erinnerung_gesendet"])
|
||||
logger.info("Erinnerung gesendet an %s (Token %s)", to_email, token_id)
|
||||
return {"status": "sent", "token_id": str(token_id)}
|
||||
except Exception as exc:
|
||||
logger.exception("Erinnerungs-E-Mail fehlgeschlagen für %s: %s", to_email, exc)
|
||||
raise self.retry(exc=exc)
|
||||
|
||||
|
||||
@shared_task(bind=True, max_retries=3, default_retry_delay=300)
|
||||
def send_onboarding_einladung(self, einladung_id, base_url=None):
|
||||
"""
|
||||
Sendet eine Onboarding-Einladungs-E-Mail an eine neue potenzielle Destinatärin/
|
||||
einen neuen potenziellen Destinatär.
|
||||
|
||||
Args:
|
||||
einladung_id: UUID der OnboardingEinladung
|
||||
base_url: Basis-URL der Anwendung
|
||||
"""
|
||||
from django.conf import settings
|
||||
from django.core.mail import EmailMultiAlternatives
|
||||
from django.template.loader import render_to_string
|
||||
|
||||
from stiftung.models import OnboardingEinladung
|
||||
|
||||
try:
|
||||
einladung = OnboardingEinladung.objects.get(id=einladung_id)
|
||||
except OnboardingEinladung.DoesNotExist as exc:
|
||||
logger.error("send_onboarding_einladung: Einladung %s nicht gefunden", einladung_id)
|
||||
return {"status": "error", "message": str(exc)}
|
||||
|
||||
if not einladung.ist_gueltig():
|
||||
return {"status": "skipped", "reason": "einladung_ungueltig"}
|
||||
|
||||
if base_url is None:
|
||||
base_url = getattr(settings, "SITE_URL", "https://vhtv-stiftung.de")
|
||||
|
||||
onboarding_url = f"{base_url}/portal/onboarding/{einladung.token}/"
|
||||
|
||||
context = {
|
||||
"einladung": einladung,
|
||||
"onboarding_url": onboarding_url,
|
||||
"gueltig_bis": einladung.gueltig_bis,
|
||||
}
|
||||
|
||||
subject = "Einladung zum Onboarding – van Hees-Theyssen-Vogel'sche Stiftung"
|
||||
from_email = _get_smtp_from_email()
|
||||
to_email = einladung.email
|
||||
|
||||
from stiftung.utils.vorlagen import render_vorlage
|
||||
text_body = render_vorlage("email/onboarding_einladung.txt", context)
|
||||
html_body = render_vorlage("email/onboarding_einladung.html", context)
|
||||
|
||||
try:
|
||||
connection = _get_smtp_connection()
|
||||
msg = EmailMultiAlternatives(subject, text_body, from_email, [to_email], connection=connection)
|
||||
msg.attach_alternative(html_body, "text/html")
|
||||
msg.send()
|
||||
logger.info(
|
||||
"Onboarding-Einladung gesendet an %s (Einladung %s)",
|
||||
to_email,
|
||||
einladung_id,
|
||||
)
|
||||
return {"status": "sent", "einladung_id": str(einladung_id), "email": to_email}
|
||||
except Exception as exc:
|
||||
logger.exception("Onboarding-E-Mail-Versand fehlgeschlagen für %s: %s", to_email, exc)
|
||||
raise self.retry(exc=exc)
|
||||
|
||||
|
||||
def _send_bestaetigung_sync(destinataer_id):
|
||||
"""
|
||||
Generiert ein Bestätigungsschreiben (PDF) für einen Destinatär und sendet es
|
||||
per E-Mail. Das PDF wird zusätzlich im DMS unter Kontext "korrespondenz" abgelegt.
|
||||
|
||||
Kann direkt (synchron) oder via Celery-Task aufgerufen werden.
|
||||
Bei Fehlern wird eine Exception geworfen (kein stilles Verschlucken).
|
||||
"""
|
||||
from decimal import Decimal
|
||||
from django.core.files.base import ContentFile
|
||||
from django.core.mail import EmailMultiAlternatives
|
||||
from django.utils import timezone
|
||||
|
||||
from stiftung.models import Destinataer, DestinataerUnterstuetzung, DokumentDatei
|
||||
|
||||
try:
|
||||
destinataer = Destinataer.objects.get(id=destinataer_id)
|
||||
except Destinataer.DoesNotExist as exc:
|
||||
logger.error("send_bestaetigung: Destinatär %s nicht gefunden", destinataer_id)
|
||||
return {"status": "error", "message": str(exc)}
|
||||
|
||||
if not destinataer.email:
|
||||
logger.warning("send_bestaetigung: Destinatär %s hat keine E-Mail-Adresse", destinataer_id)
|
||||
return {"status": "skipped", "reason": "no_email"}
|
||||
|
||||
# Alle abgeschlossenen Unterstützungen laden
|
||||
unterstuetzungen = list(DestinataerUnterstuetzung.objects.filter(
|
||||
destinataer=destinataer,
|
||||
status__in=["ausgezahlt", "abgeschlossen"],
|
||||
).order_by("faellig_am"))
|
||||
|
||||
gesamtbetrag = sum(u.betrag for u in unterstuetzungen) if unterstuetzungen else Decimal("0")
|
||||
|
||||
zeitraum = None
|
||||
if unterstuetzungen:
|
||||
erste = unterstuetzungen[0].faellig_am
|
||||
letzte = unterstuetzungen[-1].faellig_am
|
||||
if erste == letzte:
|
||||
zeitraum = erste.strftime("%d.%m.%Y")
|
||||
else:
|
||||
zeitraum = f"{erste.strftime('%d.%m.%Y')} – {letzte.strftime('%d.%m.%Y')}"
|
||||
|
||||
betrag_quartal = destinataer.vierteljaehrlicher_betrag
|
||||
betrag_jaehrlich = (betrag_quartal * 4) if betrag_quartal else None
|
||||
zweck = destinataer.ausbildungsstand or (destinataer.get_berufsgruppe_display() if destinataer.berufsgruppe else None)
|
||||
|
||||
datum = timezone.now().date()
|
||||
context = {
|
||||
"destinataer": destinataer,
|
||||
"unterstuetzungen": unterstuetzungen,
|
||||
"gesamtbetrag": gesamtbetrag,
|
||||
"datum": datum,
|
||||
"zeitraum": zeitraum,
|
||||
"betrag_quartal": betrag_quartal,
|
||||
"betrag_jaehrlich": betrag_jaehrlich,
|
||||
"zweck": zweck,
|
||||
}
|
||||
|
||||
# PDF generieren via WeasyPrint
|
||||
pdf_bytes = None
|
||||
try:
|
||||
from weasyprint import HTML
|
||||
from stiftung.utils.vorlagen import render_vorlage
|
||||
html_content = render_vorlage("pdf/bestaetigung.html", context)
|
||||
pdf_bytes = HTML(string=html_content).write_pdf()
|
||||
except Exception as exc:
|
||||
logger.error("send_bestaetigung: PDF-Generierung fehlgeschlagen: %s", exc)
|
||||
raise
|
||||
|
||||
# PDF im DMS ablegen
|
||||
filename = (
|
||||
f"bestaetigung_{destinataer.nachname}_{destinataer.vorname}"
|
||||
f"_{datum.strftime('%Y%m%d')}.pdf"
|
||||
)
|
||||
try:
|
||||
doc = DokumentDatei(
|
||||
titel=f"Bestätigungsschreiben {datum.strftime('%d.%m.%Y')} – {destinataer.get_full_name()}",
|
||||
beschreibung="Automatisch generiertes Bestätigungsschreiben über Förderleistungen.",
|
||||
kontext="korrespondenz",
|
||||
dateiname_original=filename,
|
||||
dateityp="application/pdf",
|
||||
dateigroesse=len(pdf_bytes),
|
||||
destinataer=destinataer,
|
||||
)
|
||||
doc.datei.save(filename, ContentFile(pdf_bytes), save=False)
|
||||
doc.save()
|
||||
logger.info("Bestätigung im DMS gespeichert (ID: %s).", doc.pk)
|
||||
except Exception as exc:
|
||||
logger.error("send_bestaetigung: DMS-Speicherung fehlgeschlagen: %s", exc)
|
||||
# Weiter mit E-Mail-Versand auch wenn DMS-Speicherung schlägt fehl
|
||||
|
||||
# E-Mail senden
|
||||
from stiftung.utils.vorlagen import render_vorlage
|
||||
html_body = render_vorlage("email/bestaetigung.html", context)
|
||||
subject = "Bestätigung Ihrer Stiftungsförderung – van Hees-Theyssen-Vogel'sche Stiftung"
|
||||
from_email = _get_smtp_from_email()
|
||||
to_email = destinataer.email
|
||||
|
||||
connection = _get_smtp_connection()
|
||||
msg = EmailMultiAlternatives(subject, "", from_email, [to_email], connection=connection)
|
||||
msg.attach_alternative(html_body, "text/html")
|
||||
if pdf_bytes:
|
||||
msg.attach(filename, pdf_bytes, "application/pdf")
|
||||
msg.send()
|
||||
logger.info("Bestätigung gesendet an %s (Destinatär %s)", to_email, destinataer_id)
|
||||
return {"status": "sent", "destinataer_id": str(destinataer_id), "email": to_email}
|
||||
|
||||
|
||||
@shared_task(bind=True, max_retries=3, default_retry_delay=300)
|
||||
def send_bestaetigung(self, destinataer_id, base_url=None):
|
||||
"""Celery-Wrapper für _send_bestaetigung_sync (für asynchronen Aufruf)."""
|
||||
try:
|
||||
return _send_bestaetigung_sync(destinataer_id)
|
||||
except Exception as exc:
|
||||
logger.exception("send_bestaetigung task fehlgeschlagen: %s", exc)
|
||||
raise self.retry(exc=exc)
|
||||
|
||||
|
||||
@shared_task
|
||||
def check_ablaufende_tokens():
|
||||
"""
|
||||
Prüft täglich Upload-Tokens, die in 7 Tagen ablaufen,
|
||||
und sendet Erinnerungs-E-Mails (falls noch nicht gesendet).
|
||||
Wird durch Celery Beat aufgerufen.
|
||||
"""
|
||||
from django.utils import timezone
|
||||
|
||||
from stiftung.models import UploadToken
|
||||
|
||||
grenze = timezone.now() + timedelta(days=7)
|
||||
tokens = UploadToken.objects.filter(
|
||||
ist_aktiv=True,
|
||||
eingeloest_am__isnull=True,
|
||||
erinnerung_gesendet=False,
|
||||
gueltig_bis__lte=grenze,
|
||||
gueltig_bis__gt=timezone.now(),
|
||||
)
|
||||
count = 0
|
||||
for token in tokens:
|
||||
send_nachweis_erinnerung.delay(str(token.id))
|
||||
count += 1
|
||||
logger.info("check_ablaufende_tokens: %d Erinnerungen angestoßen", count)
|
||||
return {"triggered": count}
|
||||
|
||||
@@ -34,6 +34,17 @@ def help_box_exists(page_key):
|
||||
return HelpBox.get_help_for_page(page_key) is not None
|
||||
|
||||
|
||||
@register.filter
|
||||
def get_item(dictionary, key):
|
||||
"""Lookup a key in a dictionary, trying int conversion for numeric keys."""
|
||||
if dictionary is None:
|
||||
return None
|
||||
result = dictionary.get(key)
|
||||
if result is None and isinstance(key, str) and key.isdigit():
|
||||
result = dictionary.get(int(key))
|
||||
return result
|
||||
|
||||
|
||||
@register.filter
|
||||
def markdown_to_html(text):
|
||||
"""Konvertiere Markdown-Text zu HTML"""
|
||||
|
||||
@@ -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(
|
||||
@@ -26,6 +34,11 @@ urlpatterns = [
|
||||
views.destinataer_delete,
|
||||
name="destinataer_delete",
|
||||
),
|
||||
path(
|
||||
"destinataere/<uuid:pk>/archivieren/",
|
||||
views.destinataer_toggle_archiv,
|
||||
name="destinataer_toggle_archiv",
|
||||
),
|
||||
path(
|
||||
"destinataere/<uuid:pk>/notiz/",
|
||||
views.destinataer_notiz_create,
|
||||
@@ -135,46 +148,7 @@ urlpatterns = [
|
||||
views.foerderung_delete,
|
||||
name="foerderung_delete",
|
||||
),
|
||||
# Dokumente URLs
|
||||
path("dokumente/", views.dokument_list, name="dokument_list"),
|
||||
path("dokumente/<uuid:pk>/", views.dokument_detail, name="dokument_detail"),
|
||||
path("dokumente/neu/", views.dokument_create, name="dokument_create"),
|
||||
path(
|
||||
"dokumente/<uuid:pk>/bearbeiten/", views.dokument_update, name="dokument_update"
|
||||
),
|
||||
path(
|
||||
"dokumente/<uuid:pk>/loeschen/", views.dokument_delete, name="dokument_delete"
|
||||
),
|
||||
# Dokumentenverwaltung (Paperless-Integration, Verwaltung & Verknüpfung)
|
||||
path(
|
||||
"dokumente/verwaltung/", views.dokument_management, name="dokument_management"
|
||||
),
|
||||
# Legacy document URLs removed - use dokument_management instead
|
||||
# Dokument-Verknüpfung
|
||||
path(
|
||||
"api/link-document/search/",
|
||||
views.link_document_search,
|
||||
name="link_document_search",
|
||||
),
|
||||
path(
|
||||
"api/link-document/create/",
|
||||
views.link_document_create,
|
||||
name="link_document_create",
|
||||
),
|
||||
path(
|
||||
"api/link-document/list/", views.link_document_list, name="link_document_list"
|
||||
),
|
||||
path(
|
||||
"api/link-document/update/",
|
||||
views.link_document_update,
|
||||
name="link_document_update",
|
||||
),
|
||||
path(
|
||||
"api/link-document/delete/<uuid:link_id>/",
|
||||
views.link_document_delete,
|
||||
name="link_document_delete",
|
||||
),
|
||||
# Legacy dokument_verknuepfung URL removed - use dokument_management instead
|
||||
# Dokumente-URLs (DMS) – Legacy-Paperless-URLs entfernt (Phase 3)
|
||||
# Jahresbericht URLs
|
||||
path("berichte/", views.bericht_list, name="bericht_list"),
|
||||
path(
|
||||
@@ -192,6 +166,16 @@ urlpatterns = [
|
||||
views.jahresbericht_pdf,
|
||||
name="jahresbericht_pdf",
|
||||
),
|
||||
path(
|
||||
"berichte/zusammenstellen/",
|
||||
views.bericht_zusammenstellen,
|
||||
name="bericht_zusammenstellen",
|
||||
),
|
||||
path(
|
||||
"berichte/<str:vorlage_key>/",
|
||||
views.bericht_vorlage,
|
||||
name="bericht_vorlage",
|
||||
),
|
||||
# Geschäftsführung URLs
|
||||
path("geschaeftsfuehrung/", views.geschaeftsfuehrung, name="geschaeftsfuehrung"),
|
||||
path("geschaeftsfuehrung/konten/", views.konto_list, name="konto_list"),
|
||||
@@ -214,6 +198,11 @@ urlpatterns = [
|
||||
views.verwaltungskosten_create,
|
||||
name="verwaltungskosten_create",
|
||||
),
|
||||
path(
|
||||
"geschaeftsfuehrung/verwaltungskosten/<uuid:pk>/",
|
||||
views.verwaltungskosten_detail,
|
||||
name="verwaltungskosten_detail",
|
||||
),
|
||||
path(
|
||||
"geschaeftsfuehrung/verwaltungskosten/<uuid:pk>/bearbeiten/",
|
||||
views.verwaltungskosten_edit,
|
||||
@@ -257,6 +246,7 @@ urlpatterns = [
|
||||
# Administration URLs
|
||||
path("administration/", views.administration, name="administration"),
|
||||
path("administration/settings/", views.app_settings, name="app_settings"),
|
||||
path("administration/email/", views.email_settings, name="email_settings"),
|
||||
path("administration/audit-log/", views.audit_log_list, name="audit_log_list"),
|
||||
path("administration/backup/", views.backup_management, name="backup_management"),
|
||||
path(
|
||||
@@ -343,30 +333,44 @@ urlpatterns = [
|
||||
# Hilfsbox URLs
|
||||
path("help-box/edit/", views.edit_help_box, name="edit_help_box"),
|
||||
path("help-box/admin/", views.edit_help_box, name="help_boxes_admin"),
|
||||
# Phase 4: Globale Suche (Cmd+K)
|
||||
path("api/suche/", views.globale_suche_api, name="globale_suche_api"),
|
||||
|
||||
# API URLs
|
||||
path("api/land-stats/", views.land_stats_api, name="land_stats_api"),
|
||||
path("api/health/", views.health_check, name="health_check"),
|
||||
path("api/paperless/ping/", views.paperless_ping, name="paperless_ping"),
|
||||
path(
|
||||
"api/paperless/documents/",
|
||||
views.paperless_documents,
|
||||
name="paperless_documents",
|
||||
),
|
||||
path("api/paperless/tags/", views.paperless_tags_only, name="paperless_tags_only"),
|
||||
path("api/paperless/debug/", views.paperless_debug, name="paperless_debug"),
|
||||
path(
|
||||
"api/paperless/documents/<int:doc_id>/",
|
||||
views.paperless_document_redirect,
|
||||
name="paperless_document_redirect",
|
||||
),
|
||||
# Veranstaltungsmodul
|
||||
path("veranstaltungen/", views.veranstaltung_list, name="veranstaltung_list"),
|
||||
path("veranstaltungen/neu/", views.veranstaltung_create, name="veranstaltung_create"),
|
||||
path("veranstaltungen/<uuid:pk>/", views.veranstaltung_detail, name="veranstaltung_detail"),
|
||||
path("veranstaltungen/<uuid:pk>/bearbeiten/", views.veranstaltung_update, name="veranstaltung_update"),
|
||||
path("veranstaltungen/<uuid:pk>/loeschen/", views.veranstaltung_delete, name="veranstaltung_delete"),
|
||||
path(
|
||||
"veranstaltungen/<uuid:pk>/serienbrief/",
|
||||
views.veranstaltung_serienbrief_pdf,
|
||||
name="veranstaltung_serienbrief_pdf",
|
||||
),
|
||||
path(
|
||||
"veranstaltungen/<uuid:pk>/serienbrief-vorschau/",
|
||||
views.veranstaltung_serienbrief_vorschau,
|
||||
name="veranstaltung_serienbrief_vorschau",
|
||||
),
|
||||
# Teilnehmer CRUD
|
||||
path(
|
||||
"veranstaltungen/<uuid:veranstaltung_pk>/teilnehmer/neu/",
|
||||
views.teilnehmer_create,
|
||||
name="teilnehmer_create",
|
||||
),
|
||||
path(
|
||||
"veranstaltungen/<uuid:veranstaltung_pk>/teilnehmer/<uuid:pk>/bearbeiten/",
|
||||
views.teilnehmer_update,
|
||||
name="teilnehmer_update",
|
||||
),
|
||||
path(
|
||||
"veranstaltungen/<uuid:veranstaltung_pk>/teilnehmer/<uuid:pk>/loeschen/",
|
||||
views.teilnehmer_delete,
|
||||
name="teilnehmer_delete",
|
||||
),
|
||||
# Gramps integration (probe)
|
||||
path("api/gramps/search/", views.gramps_search_api, name="gramps_search_api"),
|
||||
path("api/gramps/debug/", views.gramps_debug_api, name="gramps_debug_api"),
|
||||
@@ -407,6 +411,7 @@ urlpatterns = [
|
||||
# E-Mail-Eingang Destinatäre
|
||||
path("email-eingang/", views.email_eingang_list, name="email_eingang_list"),
|
||||
path("email-eingang/<uuid:pk>/", views.email_eingang_detail, name="email_eingang_detail"),
|
||||
path("email-eingang/<uuid:pk>/loeschen/", views.email_eingang_delete, name="email_eingang_delete"),
|
||||
path("email-eingang/poll/", views.email_eingang_poll_trigger, name="email_eingang_poll_trigger"),
|
||||
# Kalender URLs
|
||||
path("kalender/", views.kalender_view, name="kalender"),
|
||||
@@ -416,4 +421,97 @@ urlpatterns = [
|
||||
path("kalender/<uuid:pk>/bearbeiten/", views.kalender_edit, name="kalender_edit"),
|
||||
path("kalender/<uuid:pk>/loeschen/", views.kalender_delete, name="kalender_delete"),
|
||||
path("kalender/api/events/", views.kalender_api_events, name="kalender_api_events"),
|
||||
|
||||
# Phase 2: Destinatär-Timeline (2a)
|
||||
path(
|
||||
"destinataere/<uuid:pk>/timeline/",
|
||||
views.destinataer_timeline,
|
||||
name="destinataer_timeline",
|
||||
),
|
||||
|
||||
# Phase 2: Nachweis-Board (2b)
|
||||
path("nachweis-board/", views.nachweis_board, name="nachweis_board"),
|
||||
path(
|
||||
"nachweis-board/erinnerung/",
|
||||
views.batch_erinnerung_senden,
|
||||
name="batch_erinnerung_senden",
|
||||
),
|
||||
|
||||
# Phase 2: Zahlungs-Pipeline (2c)
|
||||
path("zahlungs-pipeline/", views.zahlungs_pipeline, name="zahlungs_pipeline"),
|
||||
path(
|
||||
"unterstuetzungen/<uuid:pk>/freigeben/",
|
||||
views.unterstuetzung_freigeben,
|
||||
name="unterstuetzung_freigeben",
|
||||
),
|
||||
path(
|
||||
"unterstuetzungen/<uuid:pk>/nachweis-eingereicht/",
|
||||
views.unterstuetzung_nachweis_eingereicht,
|
||||
name="unterstuetzung_nachweis_eingereicht",
|
||||
),
|
||||
path(
|
||||
"unterstuetzungen/<uuid:pk>/abschliessen/",
|
||||
views.unterstuetzung_abschliessen,
|
||||
name="unterstuetzung_abschliessen",
|
||||
),
|
||||
path("sepa-export/", views.sepa_xml_export, name="sepa_xml_export"),
|
||||
|
||||
# Phase 2: Pächter-Workflow (2d)
|
||||
path("paechter/workflow/", views.paechter_workflow, name="paechter_workflow"),
|
||||
|
||||
# Phase 4: Upload-Portal – Admin-seitige Auslöser
|
||||
path(
|
||||
"quarterly-confirmations/<uuid:nachweis_pk>/aufforderung-senden/",
|
||||
views.nachweis_aufforderung_senden,
|
||||
name="nachweis_aufforderung_senden",
|
||||
),
|
||||
path(
|
||||
"nachweis-board/batch-aufforderung-senden/",
|
||||
views.batch_nachweis_aufforderung_senden,
|
||||
name="batch_nachweis_aufforderung_senden",
|
||||
),
|
||||
|
||||
# Phase 5: Onboarding – Admin-Seite
|
||||
path(
|
||||
"destinataere/onboarding/einladen/",
|
||||
views.onboarding_einladung_senden,
|
||||
name="onboarding_einladung_senden",
|
||||
),
|
||||
path(
|
||||
"destinataere/onboarding/einladungen/",
|
||||
views.onboarding_einladung_liste,
|
||||
name="onboarding_einladung_liste",
|
||||
),
|
||||
path(
|
||||
"destinataere/onboarding/einladungen/<uuid:pk>/widerrufen/",
|
||||
views.onboarding_einladung_widerrufen,
|
||||
name="onboarding_einladung_widerrufen",
|
||||
),
|
||||
# Bestätigungsschreiben
|
||||
path(
|
||||
"destinataere/<uuid:pk>/bestaetigung/",
|
||||
views.bestaetigung_vorschau,
|
||||
name="bestaetigung_vorschau",
|
||||
),
|
||||
path(
|
||||
"destinataere/<uuid:pk>/bestaetigung/versenden/",
|
||||
views.bestaetigung_versenden,
|
||||
name="bestaetigung_versenden",
|
||||
),
|
||||
|
||||
# Dokument-Vorlagen-Editor
|
||||
path("administration/vorlagen/", views.vorlagen_liste, name="vorlagen_liste"),
|
||||
path("administration/vorlagen/<uuid:pk>/", views.vorlage_editor, name="vorlage_editor"),
|
||||
path("administration/vorlagen/<uuid:pk>/zuruecksetzen/", views.vorlage_zuruecksetzen, name="vorlage_zuruecksetzen"),
|
||||
path("administration/vorlagen/<uuid:pk>/vorschau/", views.vorlage_vorschau, name="vorlage_vorschau"),
|
||||
path("administration/vorlagen/alle-zuruecksetzen/", views.vorlagen_alle_zuruecksetzen, name="vorlagen_alle_zuruecksetzen"),
|
||||
|
||||
# Phase 3: DMS – Django-natives Dokumentenmanagement
|
||||
path("dms/", views.dms_list, name="dms_list"),
|
||||
path("dms/hochladen/", views.dms_upload, name="dms_upload"),
|
||||
path("dms/suche/", views.dms_search_api, name="dms_search_api"),
|
||||
path("dms/<uuid:pk>/", views.dms_detail, name="dms_detail"),
|
||||
path("dms/<uuid:pk>/herunterladen/", views.dms_download, name="dms_download"),
|
||||
path("dms/<uuid:pk>/bearbeiten/", views.dms_edit, name="dms_edit"),
|
||||
path("dms/<uuid:pk>/loeschen/", views.dms_delete, name="dms_delete"),
|
||||
]
|
||||
|
||||
@@ -30,25 +30,6 @@ def get_config(key, default=None, fallback_to_settings=True):
|
||||
return value if value is not None else default
|
||||
|
||||
|
||||
def get_paperless_config():
|
||||
"""
|
||||
Get all Paperless-related configuration values
|
||||
|
||||
Returns:
|
||||
dict: Dictionary containing all Paperless configuration
|
||||
"""
|
||||
return {
|
||||
"api_url": get_config("paperless_api_url"),
|
||||
"api_token": get_config("paperless_api_token"),
|
||||
"destinataere_tag": get_config("paperless_destinataere_tag"),
|
||||
"destinataere_tag_id": get_config("paperless_destinataere_tag_id"),
|
||||
"land_tag": get_config("paperless_land_tag"),
|
||||
"land_tag_id": get_config("paperless_land_tag_id"),
|
||||
"admin_tag": get_config("paperless_admin_tag"),
|
||||
"admin_tag_id": get_config("paperless_admin_tag_id"),
|
||||
}
|
||||
|
||||
|
||||
def set_config(key, value, **kwargs):
|
||||
"""
|
||||
Set a configuration value
|
||||
@@ -63,13 +44,3 @@ def set_config(key, value, **kwargs):
|
||||
"""
|
||||
return AppConfiguration.set_setting(key, value, **kwargs)
|
||||
|
||||
|
||||
def is_paperless_configured():
|
||||
"""
|
||||
Check if Paperless is properly configured
|
||||
|
||||
Returns:
|
||||
bool: True if API URL and token are configured
|
||||
"""
|
||||
config = get_paperless_config()
|
||||
return bool(config["api_url"] and config["api_token"])
|
||||
|
||||
59
app/stiftung/utils/vorlagen.py
Normal file
59
app/stiftung/utils/vorlagen.py
Normal file
@@ -0,0 +1,59 @@
|
||||
"""Utility für das Rendering von Dokument-Vorlagen.
|
||||
|
||||
Prüft zuerst die Datenbank (DokumentVorlage), fällt dann auf die Datei-Vorlage zurück.
|
||||
"""
|
||||
|
||||
from django.template import Context, Engine, Template
|
||||
from django.template.loader import render_to_string
|
||||
|
||||
|
||||
def render_vorlage(template_name: str, context: dict, request=None) -> str:
|
||||
"""Rendert eine Vorlage.
|
||||
|
||||
Schaut zuerst in der DB nach (DokumentVorlage), fällt auf die Datei zurück.
|
||||
|
||||
Args:
|
||||
template_name: Template-Pfad, z.B. "pdf/bestaetigung.html"
|
||||
context: Template-Kontext-Dictionary
|
||||
request: Optionaler Request (für RequestContext)
|
||||
|
||||
Returns:
|
||||
Gerenderter HTML-String
|
||||
"""
|
||||
from stiftung.models import DokumentVorlage
|
||||
|
||||
try:
|
||||
vorlage = DokumentVorlage.objects.get(schluessel=template_name)
|
||||
# Eigene Engine mit den Standard-Builtins, aber ohne Dateisystem-Loader
|
||||
engine = Engine.get_default()
|
||||
t = engine.from_string(vorlage.html_inhalt)
|
||||
return t.render(Context(context))
|
||||
except DokumentVorlage.DoesNotExist:
|
||||
pass
|
||||
|
||||
# Fallback: Datei-Template
|
||||
return render_to_string(template_name, context, request=request)
|
||||
|
||||
|
||||
def get_vorlage_original(template_name: str) -> str:
|
||||
"""Liest den Original-Dateiinhalt einer Vorlage (für Reset-Funktion)."""
|
||||
from django.template.loaders.filesystem import Loader
|
||||
from django.template import Engine
|
||||
|
||||
engine = Engine.get_default()
|
||||
for loader in engine.template_loaders:
|
||||
try:
|
||||
source, _ = loader.get_contents_and_origin(template_name)
|
||||
return source
|
||||
except Exception:
|
||||
# Try get_template_sources
|
||||
try:
|
||||
for origin in loader.get_template_sources(template_name):
|
||||
try:
|
||||
with open(origin.name, "r", encoding="utf-8") as f:
|
||||
return f.read()
|
||||
except OSError:
|
||||
continue
|
||||
except Exception:
|
||||
continue
|
||||
raise FileNotFoundError(f"Template-Datei nicht gefunden: {template_name}")
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user