Compare commits
39 Commits
vision-202
...
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 |
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
|
# Build and start containers from source code
|
||||||
echo "🔨 Building and starting 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
|
docker-compose -f compose.yml up -d --build
|
||||||
|
|
||||||
# Wait for containers to be ready
|
# 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
|
FROM python:3.12-slim
|
||||||
|
ARG APP_VERSION=unknown
|
||||||
ENV PYTHONDONTWRITEBYTECODE=1 \
|
ENV PYTHONDONTWRITEBYTECODE=1 \
|
||||||
PYTHONUNBUFFERED=1
|
PYTHONUNBUFFERED=1 \
|
||||||
|
APP_VERSION=$APP_VERSION
|
||||||
|
|
||||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
build-essential libpq-dev postgresql-client \
|
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}
|
||||||
@@ -72,6 +72,7 @@ TEMPLATES = [
|
|||||||
"django.template.context_processors.request",
|
"django.template.context_processors.request",
|
||||||
"django.contrib.auth.context_processors.auth",
|
"django.contrib.auth.context_processors.auth",
|
||||||
"django.contrib.messages.context_processors.messages",
|
"django.contrib.messages.context_processors.messages",
|
||||||
|
"core.context_processors.app_version",
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -117,8 +118,11 @@ MEDIA_URL = "/media/"
|
|||||||
MEDIA_ROOT = BASE_DIR / "media"
|
MEDIA_ROOT = BASE_DIR / "media"
|
||||||
|
|
||||||
# Celery
|
# Celery
|
||||||
CELERY_BROKER_URL = 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("REDIS_URL", "redis://redis:6379/0")
|
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
|
# Celery Beat – periodische Tasks
|
||||||
from celery.schedules import crontab # noqa: E402
|
from celery.schedules import crontab # noqa: E402
|
||||||
@@ -129,6 +133,11 @@ CELERY_BEAT_SCHEDULE = {
|
|||||||
"task": "stiftung.tasks.poll_emails",
|
"task": "stiftung.tasks.poll_emails",
|
||||||
"schedule": crontab(minute="*/15"),
|
"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)
|
# IMAP-Konfiguration für E-Mail-Eingang (Destinatäre)
|
||||||
@@ -140,6 +149,16 @@ IMAP_PASSWORD = os.getenv("IMAP_PASSWORD", "")
|
|||||||
IMAP_FOLDER = os.getenv("IMAP_FOLDER", "INBOX")
|
IMAP_FOLDER = os.getenv("IMAP_FOLDER", "INBOX")
|
||||||
IMAP_USE_SSL = os.getenv("IMAP_USE_SSL", "true").lower() == "true"
|
IMAP_USE_SSL = os.getenv("IMAP_USE_SSL", "true").lower() == "true"
|
||||||
|
|
||||||
|
# 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
|
# Authentication
|
||||||
LOGIN_URL = "/login/"
|
LOGIN_URL = "/login/"
|
||||||
@@ -147,7 +166,7 @@ LOGIN_REDIRECT_URL = "/"
|
|||||||
LOGOUT_REDIRECT_URL = "/login/"
|
LOGOUT_REDIRECT_URL = "/login/"
|
||||||
|
|
||||||
# Gramps integration
|
# 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_API_TOKEN = os.environ.get("GRAMPS_API_TOKEN", "")
|
||||||
GRAMPS_STIFTER_IDS = os.environ.get("GRAMPS_STIFTER_IDS", "") # comma-separated
|
GRAMPS_STIFTER_IDS = os.environ.get("GRAMPS_STIFTER_IDS", "") # comma-separated
|
||||||
GRAMPS_USERNAME = os.environ.get("GRAMPS_USERNAME", "")
|
GRAMPS_USERNAME = os.environ.get("GRAMPS_USERNAME", "")
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ from stiftung.views import home
|
|||||||
|
|
||||||
urlpatterns = [
|
urlpatterns = [
|
||||||
path("api/v1/", include("stiftung.api_urls")),
|
path("api/v1/", include("stiftung.api_urls")),
|
||||||
|
# Öffentliches Portal (kein Login erforderlich – tokenbasiert)
|
||||||
|
path("portal/", include("stiftung.portal_urls")),
|
||||||
path("", include("stiftung.urls")),
|
path("", include("stiftung.urls")),
|
||||||
path("admin/", admin.site.urls),
|
path("admin/", admin.site.urls),
|
||||||
# Authentication 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,
|
||||||
|
})
|
||||||
@@ -14,3 +14,5 @@ django-otp==1.2.4
|
|||||||
django-htmx==1.19.0
|
django-htmx==1.19.0
|
||||||
qrcode[pil]==7.4.2
|
qrcode[pil]==7.4.2
|
||||||
schwifty==2026.3.0
|
schwifty==2026.3.0
|
||||||
|
mcp>=1.0.0
|
||||||
|
httpx>=0.27.0
|
||||||
|
|||||||
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}));
|
||||||
@@ -7,6 +7,7 @@ from . import foerderung # noqa: F401
|
|||||||
from . import dokumente # noqa: F401
|
from . import dokumente # noqa: F401
|
||||||
from . import veranstaltung # noqa: F401
|
from . import veranstaltung # noqa: F401
|
||||||
from . import system # noqa: F401
|
from . import system # noqa: F401
|
||||||
|
from stiftung.agent import admin as agent_admin # noqa: F401
|
||||||
|
|
||||||
# Customize admin site
|
# Customize admin site
|
||||||
admin.site.site_header = "Stiftungsverwaltung Administration"
|
admin.site.site_header = "Stiftungsverwaltung Administration"
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ class DestinataerAdmin(admin.ModelAdmin):
|
|||||||
fieldsets = (
|
fieldsets = (
|
||||||
(
|
(
|
||||||
"Persönliche Daten",
|
"Persönliche Daten",
|
||||||
{"fields": ("vorname", "nachname", "geburtsdatum", "email", "telefon")},
|
{"fields": ("anrede", "vorname", "nachname", "geburtsdatum", "email", "telefon")},
|
||||||
),
|
),
|
||||||
(
|
(
|
||||||
"Berufliche Informationen",
|
"Berufliche Informationen",
|
||||||
|
|||||||
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})
|
||||||
@@ -54,18 +54,14 @@ class DestinataerForm(forms.ModelForm):
|
|||||||
for field_name, field in self.fields.items():
|
for field_name, field in self.fields.items():
|
||||||
if field_name not in ["vorname", "nachname"]:
|
if field_name not in ["vorname", "nachname"]:
|
||||||
field.required = False
|
field.required = False
|
||||||
# Set choices for familienzweig and berufsgruppe to match model
|
# Set choices for familienzweig, berufsgruppe and anrede to match model
|
||||||
self.fields["familienzweig"].choices = [("", "Bitte wählen...")] + list(Destinataer.FAMILIENZWIG_CHOICES)
|
self.fields["familienzweig"].choices = [("", "Bitte wählen...")] + list(Destinataer.FAMILIENZWIG_CHOICES)
|
||||||
self.fields["berufsgruppe"].choices = [("", "Bitte wählen...")] + list(Destinataer.BERUFSGRUPPE_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
|
# Set choices for standard_konto to allow blank
|
||||||
self.fields["standard_konto"].empty_label = "---"
|
self.fields["standard_konto"].empty_label = "---"
|
||||||
|
|
||||||
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
|
|
||||||
|
|
||||||
|
|
||||||
class DestinataerUnterstuetzungForm(forms.ModelForm):
|
class DestinataerUnterstuetzungForm(forms.ModelForm):
|
||||||
"""Form für geplante/ausgeführte Destinatärunterstützungen"""
|
"""Form für geplante/ausgeführte Destinatärunterstützungen"""
|
||||||
@@ -398,9 +394,14 @@ class VierteljahresNachweisForm(forms.ModelForm):
|
|||||||
einkommenssituation_datei = cleaned_data.get('einkommenssituation_datei')
|
einkommenssituation_datei = cleaned_data.get('einkommenssituation_datei')
|
||||||
einkommenssituation_bestaetigt = cleaned_data.get('einkommenssituation_bestaetigt')
|
einkommenssituation_bestaetigt = cleaned_data.get('einkommenssituation_bestaetigt')
|
||||||
|
|
||||||
if einkommenssituation_bestaetigt and not einkommenssituation_text and not einkommenssituation_datei:
|
# DMS-Dokumente aus POST-Daten beruecksichtigen (werden parallel zum Formular gesendet)
|
||||||
|
has_einkommens_dms = (
|
||||||
|
self.instance and self.instance.pk and
|
||||||
|
bool(self.instance.einkommenssituation_dms_dokument_id)
|
||||||
|
)
|
||||||
|
if einkommenssituation_bestaetigt and not einkommenssituation_text and not einkommenssituation_datei and not has_einkommens_dms:
|
||||||
raise ValidationError(
|
raise ValidationError(
|
||||||
'Wenn die Einkommenssituation bestätigt wird, muss entweder ein Text oder eine Datei angegeben werden.'
|
'Wenn die Einkommenssituation bestätigt wird, muss entweder ein Text, eine Datei oder ein DMS-Dokument angegeben werden.'
|
||||||
)
|
)
|
||||||
|
|
||||||
# Validate that at least one form of confirmation is provided for asset situation
|
# Validate that at least one form of confirmation is provided for asset situation
|
||||||
@@ -408,9 +409,13 @@ class VierteljahresNachweisForm(forms.ModelForm):
|
|||||||
vermogenssituation_datei = cleaned_data.get('vermogenssituation_datei')
|
vermogenssituation_datei = cleaned_data.get('vermogenssituation_datei')
|
||||||
vermogenssituation_bestaetigt = cleaned_data.get('vermogenssituation_bestaetigt')
|
vermogenssituation_bestaetigt = cleaned_data.get('vermogenssituation_bestaetigt')
|
||||||
|
|
||||||
if vermogenssituation_bestaetigt and not vermogenssituation_text and not vermogenssituation_datei:
|
has_vermogens_dms = (
|
||||||
|
self.instance and self.instance.pk and
|
||||||
|
bool(self.instance.vermogenssituation_dms_dokument_id)
|
||||||
|
)
|
||||||
|
if vermogenssituation_bestaetigt and not vermogenssituation_text and not vermogenssituation_datei and not has_vermogens_dms:
|
||||||
raise ValidationError(
|
raise ValidationError(
|
||||||
'Wenn die Vermögenssituation bestätigt wird, muss entweder ein Text oder eine Datei angegeben werden.'
|
'Wenn die Vermögenssituation bestätigt wird, muss entweder ein Text, eine Datei oder ein DMS-Dokument angegeben werden.'
|
||||||
)
|
)
|
||||||
|
|
||||||
# Validate study proof if required and marked as submitted
|
# Validate study proof if required and marked as submitted
|
||||||
@@ -420,9 +425,15 @@ class VierteljahresNachweisForm(forms.ModelForm):
|
|||||||
studiennachweis_bemerkung = cleaned_data.get('studiennachweis_bemerkung')
|
studiennachweis_bemerkung = cleaned_data.get('studiennachweis_bemerkung')
|
||||||
|
|
||||||
if studiennachweis_erforderlich and studiennachweis_eingereicht:
|
if studiennachweis_erforderlich and studiennachweis_eingereicht:
|
||||||
if not studiennachweis_datei and not studiennachweis_bemerkung:
|
has_dms_studiennachweis = (
|
||||||
|
self.instance and self.instance.pk and (
|
||||||
|
bool(self.instance.studiennachweis_dms_dokument_id)
|
||||||
|
or self.instance.nachweis_dokumente.filter(kontext="studiennachweis").exists()
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if not studiennachweis_datei and not studiennachweis_bemerkung and not has_dms_studiennachweis:
|
||||||
raise ValidationError(
|
raise ValidationError(
|
||||||
'Wenn der Studiennachweis als eingereicht markiert wird, muss entweder eine Datei oder eine Bemerkung angegeben werden.'
|
'Wenn der Studiennachweis als eingereicht markiert wird, muss entweder eine Datei, eine Bemerkung oder ein DMS-Dokument angegeben werden.'
|
||||||
)
|
)
|
||||||
|
|
||||||
return cleaned_data
|
return cleaned_data
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ class LandForm(forms.ModelForm):
|
|||||||
"flur",
|
"flur",
|
||||||
"flurstueck",
|
"flurstueck",
|
||||||
"adresse",
|
"adresse",
|
||||||
|
"alkis_kennzeichen",
|
||||||
# Flächenangaben
|
# Flächenangaben
|
||||||
"groesse_qm",
|
"groesse_qm",
|
||||||
"gruenland_qm",
|
"gruenland_qm",
|
||||||
|
|||||||
@@ -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."))
|
||||||
@@ -69,6 +69,67 @@ class Command(BaseCommand):
|
|||||||
"category": "email",
|
"category": "email",
|
||||||
"order": 6,
|
"order": 6,
|
||||||
},
|
},
|
||||||
|
# SMTP Settings
|
||||||
|
{
|
||||||
|
"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": "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
|
all_settings = email_settings
|
||||||
|
|||||||
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.")
|
||||||
|
)
|
||||||
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'),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -36,8 +36,10 @@ from .destinataere import ( # noqa: F401
|
|||||||
DestinataerNotiz,
|
DestinataerNotiz,
|
||||||
DestinataerUnterstuetzung,
|
DestinataerUnterstuetzung,
|
||||||
Foerderung,
|
Foerderung,
|
||||||
|
OnboardingEinladung,
|
||||||
Person,
|
Person,
|
||||||
UnterstuetzungWiederkehrend,
|
UnterstuetzungWiederkehrend,
|
||||||
|
UploadToken,
|
||||||
VierteljahresNachweis,
|
VierteljahresNachweis,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -52,3 +54,7 @@ from .veranstaltungen import ( # noqa: F401
|
|||||||
Veranstaltung,
|
Veranstaltung,
|
||||||
Veranstaltungsteilnehmer,
|
Veranstaltungsteilnehmer,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
from .vorlagen import ( # noqa: F401
|
||||||
|
DokumentVorlage,
|
||||||
|
)
|
||||||
|
|||||||
@@ -26,7 +26,20 @@ class Destinataer(models.Model):
|
|||||||
("andere", "Andere"),
|
("andere", "Andere"),
|
||||||
]
|
]
|
||||||
|
|
||||||
|
ANREDE_CHOICES = [
|
||||||
|
("Herr", "Herr"),
|
||||||
|
("Frau", "Frau"),
|
||||||
|
("Divers", "Divers"),
|
||||||
|
]
|
||||||
|
|
||||||
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
|
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
|
||||||
|
anrede = models.CharField(
|
||||||
|
max_length=20,
|
||||||
|
choices=ANREDE_CHOICES,
|
||||||
|
blank=True,
|
||||||
|
null=True,
|
||||||
|
verbose_name="Anrede",
|
||||||
|
)
|
||||||
familienzweig = models.CharField(
|
familienzweig = models.CharField(
|
||||||
max_length=100, choices=FAMILIENZWIG_CHOICES, blank=True, null=True
|
max_length=100, choices=FAMILIENZWIG_CHOICES, blank=True, null=True
|
||||||
)
|
)
|
||||||
@@ -362,7 +375,6 @@ class DestinataerUnterstuetzung(models.Model):
|
|||||||
("nachweis_eingereicht", "Nachweis eingereicht"),
|
("nachweis_eingereicht", "Nachweis eingereicht"),
|
||||||
("freigegeben", "Freigegeben"),
|
("freigegeben", "Freigegeben"),
|
||||||
("ausgezahlt", "Überwiesen"),
|
("ausgezahlt", "Überwiesen"),
|
||||||
("abgeschlossen", "Abgeschlossen"),
|
|
||||||
]
|
]
|
||||||
|
|
||||||
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
|
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
|
||||||
@@ -485,7 +497,7 @@ class DestinataerUnterstuetzung(models.Model):
|
|||||||
"in_bearbeitung": 3,
|
"in_bearbeitung": 3,
|
||||||
"freigegeben": 3,
|
"freigegeben": 3,
|
||||||
"ausgezahlt": 4,
|
"ausgezahlt": 4,
|
||||||
"abgeschlossen": 5,
|
"abgeschlossen": 4,
|
||||||
"storniert": 0,
|
"storniert": 0,
|
||||||
}
|
}
|
||||||
return stage_map.get(self.status, 1)
|
return stage_map.get(self.status, 1)
|
||||||
@@ -755,6 +767,38 @@ class VierteljahresNachweis(models.Model):
|
|||||||
verbose_name="Beschreibung weitere Dokumente"
|
verbose_name="Beschreibung weitere Dokumente"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# DMS-Dokumente als Nachweise verknuepfen (aus dem allgemeinen DMS)
|
||||||
|
nachweis_dokumente = models.ManyToManyField(
|
||||||
|
"DokumentDatei",
|
||||||
|
blank=True,
|
||||||
|
related_name="quartalsnachweise",
|
||||||
|
verbose_name="Verknuepfte DMS-Dokumente",
|
||||||
|
help_text="Dokumente aus dem DMS, die als Nachweise fuer dieses Quartal dienen.",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Kategorie-spezifische DMS-Verknuepfungen
|
||||||
|
studiennachweis_dms_dokument = models.ForeignKey(
|
||||||
|
"DokumentDatei",
|
||||||
|
on_delete=models.SET_NULL,
|
||||||
|
null=True, blank=True,
|
||||||
|
related_name="als_studiennachweis",
|
||||||
|
verbose_name="Studiennachweis (DMS-Dokument)",
|
||||||
|
)
|
||||||
|
einkommenssituation_dms_dokument = models.ForeignKey(
|
||||||
|
"DokumentDatei",
|
||||||
|
on_delete=models.SET_NULL,
|
||||||
|
null=True, blank=True,
|
||||||
|
related_name="als_einkommensnachweis",
|
||||||
|
verbose_name="Einkommenssituation (DMS-Dokument)",
|
||||||
|
)
|
||||||
|
vermogenssituation_dms_dokument = models.ForeignKey(
|
||||||
|
"DokumentDatei",
|
||||||
|
on_delete=models.SET_NULL,
|
||||||
|
null=True, blank=True,
|
||||||
|
related_name="als_vermoegensnachweis",
|
||||||
|
verbose_name="Vermoegenssituation (DMS-Dokument)",
|
||||||
|
)
|
||||||
|
|
||||||
# Review and approval
|
# Review and approval
|
||||||
status = models.CharField(
|
status = models.CharField(
|
||||||
max_length=20,
|
max_length=20,
|
||||||
@@ -840,19 +884,27 @@ class VierteljahresNachweis(models.Model):
|
|||||||
"""Check if all required documents/confirmations are provided"""
|
"""Check if all required documents/confirmations are provided"""
|
||||||
complete = True
|
complete = True
|
||||||
|
|
||||||
|
# DMS-Dokumente (kategorie-spezifisch oder generisch) zaehlen als Nachweis
|
||||||
|
has_dms_studiennachweis = (
|
||||||
|
bool(self.studiennachweis_dms_dokument_id)
|
||||||
|
or self.nachweis_dokumente.filter(kontext="studiennachweis").exists()
|
||||||
|
)
|
||||||
|
|
||||||
# Check study proof (always required now)
|
# Check study proof (always required now)
|
||||||
complete &= self.studiennachweis_eingereicht and (
|
complete &= self.studiennachweis_eingereicht and (
|
||||||
bool(self.studiennachweis_datei) or bool(self.studiennachweis_bemerkung)
|
bool(self.studiennachweis_datei) or bool(self.studiennachweis_bemerkung) or has_dms_studiennachweis
|
||||||
)
|
)
|
||||||
|
|
||||||
# Check income situation (either text or file)
|
# Check income situation (either text, file, or DMS document)
|
||||||
complete &= self.einkommenssituation_bestaetigt and (
|
complete &= self.einkommenssituation_bestaetigt and (
|
||||||
bool(self.einkommenssituation_text) or bool(self.einkommenssituation_datei)
|
bool(self.einkommenssituation_text) or bool(self.einkommenssituation_datei)
|
||||||
|
or bool(self.einkommenssituation_dms_dokument_id)
|
||||||
)
|
)
|
||||||
|
|
||||||
# Check asset situation (either text or file)
|
# Check asset situation (either text, file, or DMS document)
|
||||||
complete &= self.vermogenssituation_bestaetigt and (
|
complete &= self.vermogenssituation_bestaetigt and (
|
||||||
bool(self.vermogenssituation_text) or bool(self.vermogenssituation_datei)
|
bool(self.vermogenssituation_text) or bool(self.vermogenssituation_datei)
|
||||||
|
or bool(self.vermogenssituation_dms_dokument_id)
|
||||||
)
|
)
|
||||||
|
|
||||||
return complete
|
return complete
|
||||||
@@ -868,23 +920,30 @@ class VierteljahresNachweis(models.Model):
|
|||||||
total_requirements = 2 # Income and assets always required
|
total_requirements = 2 # Income and assets always required
|
||||||
completed_requirements = 0
|
completed_requirements = 0
|
||||||
|
|
||||||
|
has_dms_studiennachweis = (
|
||||||
|
bool(self.studiennachweis_dms_dokument_id)
|
||||||
|
or self.nachweis_dokumente.filter(kontext="studiennachweis").exists()
|
||||||
|
)
|
||||||
|
|
||||||
# Study proof (if required)
|
# Study proof (if required)
|
||||||
if self.studiennachweis_erforderlich:
|
if self.studiennachweis_erforderlich:
|
||||||
total_requirements += 1
|
total_requirements += 1
|
||||||
if self.studiennachweis_eingereicht and (
|
if self.studiennachweis_eingereicht and (
|
||||||
bool(self.studiennachweis_datei) or bool(self.studiennachweis_bemerkung)
|
bool(self.studiennachweis_datei) or bool(self.studiennachweis_bemerkung) or has_dms_studiennachweis
|
||||||
):
|
):
|
||||||
completed_requirements += 1
|
completed_requirements += 1
|
||||||
|
|
||||||
# Income situation
|
# Income situation
|
||||||
if self.einkommenssituation_bestaetigt and (
|
if self.einkommenssituation_bestaetigt and (
|
||||||
bool(self.einkommenssituation_text) or bool(self.einkommenssituation_datei)
|
bool(self.einkommenssituation_text) or bool(self.einkommenssituation_datei)
|
||||||
|
or bool(self.einkommenssituation_dms_dokument_id)
|
||||||
):
|
):
|
||||||
completed_requirements += 1
|
completed_requirements += 1
|
||||||
|
|
||||||
# Asset situation
|
# Asset situation
|
||||||
if self.vermogenssituation_bestaetigt and (
|
if self.vermogenssituation_bestaetigt and (
|
||||||
bool(self.vermogenssituation_text) or bool(self.vermogenssituation_datei)
|
bool(self.vermogenssituation_text) or bool(self.vermogenssituation_datei)
|
||||||
|
or bool(self.vermogenssituation_dms_dokument_id)
|
||||||
):
|
):
|
||||||
completed_requirements += 1
|
completed_requirements += 1
|
||||||
|
|
||||||
@@ -1260,3 +1319,153 @@ class EmailEingang(models.Model):
|
|||||||
|
|
||||||
# Backward-compatible alias
|
# Backward-compatible alias
|
||||||
DestinataerEmailEingang = EmailEingang
|
DestinataerEmailEingang = EmailEingang
|
||||||
|
|
||||||
|
|
||||||
|
class UploadToken(models.Model):
|
||||||
|
"""
|
||||||
|
Einmaliger Upload-Token für tokenbasiertes Nachweis-Upload-Portal.
|
||||||
|
|
||||||
|
Ermöglicht Destinatären den Dokumenten-Upload ohne Nutzerkonto.
|
||||||
|
Der Token wird per E-Mail (mit QR-Code) versendet und ist 30 Tage gültig.
|
||||||
|
Nach einmaliger Nutzung (Upload) wird eingeloest_am gesetzt.
|
||||||
|
Die IP-Adresse wird nur als SHA-256-Hash gespeichert (DSGVO-konform).
|
||||||
|
"""
|
||||||
|
|
||||||
|
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
|
||||||
|
token = models.CharField(
|
||||||
|
max_length=128,
|
||||||
|
unique=True,
|
||||||
|
db_index=True,
|
||||||
|
verbose_name="Token",
|
||||||
|
)
|
||||||
|
destinataer = models.ForeignKey(
|
||||||
|
"Destinataer",
|
||||||
|
on_delete=models.CASCADE,
|
||||||
|
related_name="upload_tokens",
|
||||||
|
verbose_name="Destinatär",
|
||||||
|
)
|
||||||
|
nachweis = models.ForeignKey(
|
||||||
|
"VierteljahresNachweis",
|
||||||
|
on_delete=models.CASCADE,
|
||||||
|
related_name="upload_tokens",
|
||||||
|
verbose_name="Nachweis",
|
||||||
|
)
|
||||||
|
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(
|
||||||
|
null=True, blank=True, verbose_name="Eingelöst am"
|
||||||
|
)
|
||||||
|
ist_aktiv = models.BooleanField(default=True, verbose_name="Aktiv")
|
||||||
|
ip_hash = models.CharField(
|
||||||
|
max_length=64, blank=True, null=True, verbose_name="IP-Hash (SHA-256)"
|
||||||
|
)
|
||||||
|
erinnerung_gesendet = models.BooleanField(
|
||||||
|
default=False, verbose_name="Erinnerung gesendet"
|
||||||
|
)
|
||||||
|
einwilligung_erteilt_am = models.DateTimeField(
|
||||||
|
null=True, blank=True, verbose_name="Einwilligung erteilt am",
|
||||||
|
help_text="Zeitpunkt der DSGVO-Einwilligung beim Upload (Art. 7 Abs. 1 DSGVO)"
|
||||||
|
)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
verbose_name = "Upload-Token"
|
||||||
|
verbose_name_plural = "Upload-Token"
|
||||||
|
ordering = ["-erstellt_am"]
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return f"Token für {self.destinataer} ({self.nachweis})"
|
||||||
|
|
||||||
|
def ist_gueltig(self):
|
||||||
|
"""Prüft ob der Token noch gültig und aktiv ist."""
|
||||||
|
from django.utils import timezone
|
||||||
|
return (
|
||||||
|
self.ist_aktiv
|
||||||
|
and self.eingeloest_am is None
|
||||||
|
and self.gueltig_bis > timezone.now()
|
||||||
|
)
|
||||||
|
|
||||||
|
def einloesen(self, ip_address=None):
|
||||||
|
"""Markiert den Token als eingelöst. IP wird als Hash gespeichert."""
|
||||||
|
import hashlib
|
||||||
|
from django.utils import timezone
|
||||||
|
self.eingeloest_am = timezone.now()
|
||||||
|
self.ist_aktiv = False
|
||||||
|
if ip_address:
|
||||||
|
self.ip_hash = hashlib.sha256(ip_address.encode()).hexdigest()
|
||||||
|
self.save(update_fields=["eingeloest_am", "ist_aktiv", "ip_hash"])
|
||||||
|
|
||||||
|
|
||||||
|
class OnboardingEinladung(models.Model):
|
||||||
|
"""
|
||||||
|
Einladung zum Onboarding für neue Destinatäre.
|
||||||
|
|
||||||
|
Verwaltungsmitarbeiter versenden eine Einladungs-E-Mail.
|
||||||
|
Der Eingeladene füllt das mehrstufige Onboarding-Formular aus.
|
||||||
|
Nach Abschluss wird ein neuer Destinatär mit unterstuetzung_bestaetigt=False angelegt.
|
||||||
|
"""
|
||||||
|
|
||||||
|
STATUS_CHOICES = [
|
||||||
|
("offen", "Offen"),
|
||||||
|
("abgeschlossen", "Abgeschlossen"),
|
||||||
|
("abgelaufen", "Abgelaufen"),
|
||||||
|
("widerrufen", "Widerrufen"),
|
||||||
|
]
|
||||||
|
|
||||||
|
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
|
||||||
|
token = models.CharField(
|
||||||
|
max_length=128,
|
||||||
|
unique=True,
|
||||||
|
db_index=True,
|
||||||
|
verbose_name="Token",
|
||||||
|
)
|
||||||
|
email = models.EmailField(verbose_name="E-Mail-Adresse des Eingeladenen")
|
||||||
|
vorname = models.CharField(
|
||||||
|
max_length=100, blank=True, verbose_name="Vorname (optional)"
|
||||||
|
)
|
||||||
|
nachname = models.CharField(
|
||||||
|
max_length=100, blank=True, verbose_name="Nachname (optional)"
|
||||||
|
)
|
||||||
|
eingeladen_von = models.ForeignKey(
|
||||||
|
"auth.User",
|
||||||
|
on_delete=models.SET_NULL,
|
||||||
|
null=True,
|
||||||
|
blank=True,
|
||||||
|
related_name="onboarding_einladungen",
|
||||||
|
verbose_name="Eingeladen von",
|
||||||
|
)
|
||||||
|
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(
|
||||||
|
null=True, blank=True, verbose_name="Abgeschlossen am"
|
||||||
|
)
|
||||||
|
destinataer = models.ForeignKey(
|
||||||
|
"Destinataer",
|
||||||
|
on_delete=models.SET_NULL,
|
||||||
|
null=True,
|
||||||
|
blank=True,
|
||||||
|
related_name="onboarding_einladung",
|
||||||
|
verbose_name="Resultierender Destinatär",
|
||||||
|
)
|
||||||
|
status = models.CharField(
|
||||||
|
max_length=20,
|
||||||
|
choices=STATUS_CHOICES,
|
||||||
|
default="offen",
|
||||||
|
verbose_name="Status",
|
||||||
|
)
|
||||||
|
notizen = models.TextField(blank=True, verbose_name="Interne Notizen")
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
verbose_name = "Onboarding-Einladung"
|
||||||
|
verbose_name_plural = "Onboarding-Einladungen"
|
||||||
|
ordering = ["-erstellt_am"]
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return f"Einladung für {self.email} ({self.get_status_display()})"
|
||||||
|
|
||||||
|
def ist_gueltig(self):
|
||||||
|
"""Prüft ob die Einladung noch gültig ist."""
|
||||||
|
from django.utils import timezone
|
||||||
|
return (
|
||||||
|
self.status == "offen"
|
||||||
|
and self.gueltig_bis > timezone.now()
|
||||||
|
)
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ class DokumentDatei(models.Model):
|
|||||||
("korrespondenz", "Korrespondenz / Brief"),
|
("korrespondenz", "Korrespondenz / Brief"),
|
||||||
("bescheid", "Bescheid / Behörde"),
|
("bescheid", "Bescheid / Behörde"),
|
||||||
("stiftungsgeschichte", "Stiftungsgeschichte / Archiv"),
|
("stiftungsgeschichte", "Stiftungsgeschichte / Archiv"),
|
||||||
|
("email", "E-Mail-Nachricht"),
|
||||||
("anderes", "Sonstiges"),
|
("anderes", "Sonstiges"),
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
@@ -137,6 +137,13 @@ class Land(models.Model):
|
|||||||
adresse = models.CharField(
|
adresse = models.CharField(
|
||||||
max_length=200, null=True, blank=True, verbose_name="Adresse/Ortsangabe"
|
max_length=200, null=True, blank=True, verbose_name="Adresse/Ortsangabe"
|
||||||
)
|
)
|
||||||
|
alkis_kennzeichen = models.CharField(
|
||||||
|
max_length=30,
|
||||||
|
null=True,
|
||||||
|
blank=True,
|
||||||
|
verbose_name="ALKIS Flurstückskennzeichen",
|
||||||
|
help_text="z.B. 05300800400030______ — für direkte Verlinkung zum Katasteramt",
|
||||||
|
)
|
||||||
|
|
||||||
# Flächenangaben
|
# Flächenangaben
|
||||||
groesse_qm = models.DecimalField(
|
groesse_qm = models.DecimalField(
|
||||||
|
|||||||
@@ -11,6 +11,10 @@ class CSVImport(models.Model):
|
|||||||
("paechter", "Pächter"),
|
("paechter", "Pächter"),
|
||||||
("laendereien", "Ländereien"),
|
("laendereien", "Ländereien"),
|
||||||
("verpachtungen", "Verpachtungen"),
|
("verpachtungen", "Verpachtungen"),
|
||||||
|
("foerderungen", "Förderungen"),
|
||||||
|
("konten", "Stiftungskonten"),
|
||||||
|
("verwaltungskosten", "Verwaltungskosten"),
|
||||||
|
("rentmeister", "Rentmeister"),
|
||||||
("personen", "Personen (Legacy)"),
|
("personen", "Personen (Legacy)"),
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -111,6 +115,8 @@ class ApplicationPermission(models.Model):
|
|||||||
# System Permissions
|
# System Permissions
|
||||||
("access_django_admin", "Kann Django Admin aufrufen"),
|
("access_django_admin", "Kann Django Admin aufrufen"),
|
||||||
("view_system_stats", "Kann Systemstatistiken anzeigen"),
|
("view_system_stats", "Kann Systemstatistiken anzeigen"),
|
||||||
|
# AI Agent Permissions
|
||||||
|
("can_use_agent", "Kann AI-Assistenten nutzen"),
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
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",
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -332,11 +332,58 @@ def poll_emails(self, search_all_recent_days=0):
|
|||||||
if doc:
|
if doc:
|
||||||
dms_dokumente.append(doc)
|
dms_dokumente.append(doc)
|
||||||
|
|
||||||
|
# Cover-Email als eigenes DMS-Dokument speichern
|
||||||
|
email_body_doc = None
|
||||||
|
if email_text.strip():
|
||||||
|
email_filename = f"Email_{eingangsdatum.strftime('%Y%m%d_%H%M')}_{betreff[:50]}.txt"
|
||||||
|
# Bereinige Dateinamen
|
||||||
|
email_filename = re.sub(r'[^\w\s\-._]', '', email_filename)
|
||||||
|
anhang_count = len(dms_dokumente)
|
||||||
|
anhang_hinweis = (
|
||||||
|
f"\n\n--- Anhänge: {anhang_count} ---\n"
|
||||||
|
+ "\n".join(f" • {d.dateiname_original or d.titel}" for d in dms_dokumente)
|
||||||
|
if dms_dokumente else ""
|
||||||
|
)
|
||||||
|
email_body_content = (
|
||||||
|
f"Von: {absender_name} <{absender_email_addr}>\n"
|
||||||
|
f"Datum: {eingangsdatum.strftime('%d.%m.%Y %H:%M')}\n"
|
||||||
|
f"Betreff: {betreff}\n"
|
||||||
|
f"{'=' * 60}\n\n"
|
||||||
|
f"{email_text}"
|
||||||
|
f"{anhang_hinweis}"
|
||||||
|
)
|
||||||
|
email_body_doc = _save_to_dms(
|
||||||
|
content=email_body_content.encode("utf-8"),
|
||||||
|
filename=email_filename,
|
||||||
|
destinataer=destinataer,
|
||||||
|
betreff=betreff,
|
||||||
|
kontext="email",
|
||||||
|
)
|
||||||
|
if email_body_doc:
|
||||||
|
# Beschreibung mit Anhang-Verweis ergaenzen
|
||||||
|
if dms_dokumente:
|
||||||
|
email_body_doc.beschreibung = (
|
||||||
|
f"E-Mail-Nachricht mit {anhang_count} Anhang/Anhängen.\n"
|
||||||
|
f"Absender: {absender_name} <{absender_email_addr}>"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
email_body_doc.beschreibung = (
|
||||||
|
f"E-Mail-Nachricht (ohne Anhänge).\n"
|
||||||
|
f"Absender: {absender_name} <{absender_email_addr}>"
|
||||||
|
)
|
||||||
|
email_body_doc.save(update_fields=["beschreibung"])
|
||||||
|
|
||||||
|
# Alle DMS-Dokumente (Email-Body + Anhaenge) verknuepfen
|
||||||
|
alle_dms_dokumente = []
|
||||||
|
if email_body_doc:
|
||||||
|
alle_dms_dokumente.append(email_body_doc)
|
||||||
|
alle_dms_dokumente.extend(dms_dokumente)
|
||||||
|
|
||||||
if dms_dokumente:
|
if dms_dokumente:
|
||||||
eingang.status = "verarbeitet" if destinataer else status
|
eingang.status = "verarbeitet" if destinataer else status
|
||||||
eingang.save()
|
eingang.save()
|
||||||
if dms_dokumente:
|
if alle_dms_dokumente:
|
||||||
eingang.dokument_dateien.set(dms_dokumente)
|
eingang.dokument_dateien.set(alle_dms_dokumente)
|
||||||
|
|
||||||
# Als gelesen markieren
|
# Als gelesen markieren
|
||||||
mail.store(msg_id, "+FLAGS", "\\Seen")
|
mail.store(msg_id, "+FLAGS", "\\Seen")
|
||||||
@@ -371,3 +418,432 @@ def poll_emails(self, search_all_recent_days=0):
|
|||||||
|
|
||||||
# Backward-compatible alias for existing Celery Beat schedules
|
# Backward-compatible alias for existing Celery Beat schedules
|
||||||
poll_destinataer_emails = poll_emails
|
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}
|
||||||
|
|||||||
@@ -1,15 +1,23 @@
|
|||||||
from django.urls import path
|
from django.urls import include, path
|
||||||
|
|
||||||
from . import views
|
from . import views
|
||||||
|
|
||||||
app_name = "stiftung"
|
app_name = "stiftung"
|
||||||
|
|
||||||
urlpatterns = [
|
urlpatterns = [
|
||||||
|
# AI Agent
|
||||||
|
path("agent/", include("stiftung.agent.urls")),
|
||||||
|
|
||||||
# Home - Main landing page after login
|
# Home - Main landing page after login
|
||||||
path("", views.home, name="home"),
|
path("", views.home, name="home"),
|
||||||
# CSV Import URLs
|
# CSV Import URLs (legacy)
|
||||||
path("import/", views.csv_import_list, name="csv_import_list"),
|
path("import/", views.csv_import_list, name="csv_import_list"),
|
||||||
path("import/neu/", views.csv_import_create, name="csv_import_create"),
|
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)
|
# Destinatär URLs (Förderungsempfänger)
|
||||||
path("destinataere/", views.destinataer_list, name="destinataer_list"),
|
path("destinataere/", views.destinataer_list, name="destinataer_list"),
|
||||||
path(
|
path(
|
||||||
@@ -158,6 +166,16 @@ urlpatterns = [
|
|||||||
views.jahresbericht_pdf,
|
views.jahresbericht_pdf,
|
||||||
name="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
|
# Geschäftsführung URLs
|
||||||
path("geschaeftsfuehrung/", views.geschaeftsfuehrung, name="geschaeftsfuehrung"),
|
path("geschaeftsfuehrung/", views.geschaeftsfuehrung, name="geschaeftsfuehrung"),
|
||||||
path("geschaeftsfuehrung/konten/", views.konto_list, name="konto_list"),
|
path("geschaeftsfuehrung/konten/", views.konto_list, name="konto_list"),
|
||||||
@@ -441,6 +459,53 @@ urlpatterns = [
|
|||||||
# Phase 2: Pächter-Workflow (2d)
|
# Phase 2: Pächter-Workflow (2d)
|
||||||
path("paechter/workflow/", views.paechter_workflow, name="paechter_workflow"),
|
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
|
# Phase 3: DMS – Django-natives Dokumentenmanagement
|
||||||
path("dms/", views.dms_list, name="dms_list"),
|
path("dms/", views.dms_list, name="dms_list"),
|
||||||
path("dms/hochladen/", views.dms_upload, name="dms_upload"),
|
path("dms/hochladen/", views.dms_upload, name="dms_upload"),
|
||||||
|
|||||||
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}")
|
||||||
@@ -21,6 +21,9 @@ from .destinataere import ( # noqa: F401
|
|||||||
destinataer_toggle_archiv,
|
destinataer_toggle_archiv,
|
||||||
destinataer_notiz_create,
|
destinataer_notiz_create,
|
||||||
destinataer_export,
|
destinataer_export,
|
||||||
|
# Bestätigungsschreiben
|
||||||
|
bestaetigung_vorschau,
|
||||||
|
bestaetigung_versenden,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -29,6 +32,10 @@ from .finanzen import ( # noqa: F401
|
|||||||
jahresbericht_generate,
|
jahresbericht_generate,
|
||||||
jahresbericht_generate_redirect,
|
jahresbericht_generate_redirect,
|
||||||
jahresbericht_pdf,
|
jahresbericht_pdf,
|
||||||
|
bericht_zusammenstellen,
|
||||||
|
bericht_vorlage,
|
||||||
|
BERICHT_SEKTIONEN,
|
||||||
|
BERICHT_VORLAGEN,
|
||||||
geschaeftsfuehrung,
|
geschaeftsfuehrung,
|
||||||
konto_list,
|
konto_list,
|
||||||
verwaltungskosten_list,
|
verwaltungskosten_list,
|
||||||
@@ -177,6 +184,13 @@ from .unterstuetzungen import ( # noqa: F401
|
|||||||
unterstuetzung_nachweis_eingereicht,
|
unterstuetzung_nachweis_eingereicht,
|
||||||
unterstuetzung_abschliessen,
|
unterstuetzung_abschliessen,
|
||||||
sepa_xml_export,
|
sepa_xml_export,
|
||||||
|
# Phase 4: Upload-Portal (Admin-Seite)
|
||||||
|
nachweis_aufforderung_senden,
|
||||||
|
batch_nachweis_aufforderung_senden,
|
||||||
|
# Phase 5: Onboarding (Admin-Seite)
|
||||||
|
onboarding_einladung_senden,
|
||||||
|
onboarding_einladung_liste,
|
||||||
|
onboarding_einladung_widerrufen,
|
||||||
)
|
)
|
||||||
|
|
||||||
from .dms import ( # noqa: F401
|
from .dms import ( # noqa: F401
|
||||||
@@ -202,5 +216,20 @@ from .veranstaltung import ( # noqa: F401
|
|||||||
teilnehmer_delete,
|
teilnehmer_delete,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
from .import_export import ( # noqa: F401
|
||||||
|
import_export_hub,
|
||||||
|
csv_export,
|
||||||
|
csv_import_upload,
|
||||||
|
csv_import_execute,
|
||||||
|
)
|
||||||
|
|
||||||
|
from .vorlagen import ( # noqa: F401
|
||||||
|
vorlagen_liste,
|
||||||
|
vorlage_editor,
|
||||||
|
vorlage_zuruecksetzen,
|
||||||
|
vorlagen_alle_zuruecksetzen,
|
||||||
|
vorlage_vorschau,
|
||||||
|
)
|
||||||
|
|
||||||
# Non-view exports (helpers used elsewhere)
|
# Non-view exports (helpers used elsewhere)
|
||||||
from .system import GrampsClient, get_gramps_client, get_pdf_generator # noqa: F401
|
from .system import GrampsClient, get_gramps_client, get_pdf_generator # noqa: F401
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ from django_otp.util import random_hex
|
|||||||
from rest_framework.decorators import api_view
|
from rest_framework.decorators import api_view
|
||||||
from rest_framework.response import Response
|
from rest_framework.response import Response
|
||||||
|
|
||||||
|
from stiftung.audit import log_action
|
||||||
from stiftung.models import (AppConfiguration, AuditLog, BackupJob, BankTransaction,
|
from stiftung.models import (AppConfiguration, AuditLog, BackupJob, BankTransaction,
|
||||||
BriefVorlage, CSVImport, Destinataer,
|
BriefVorlage, CSVImport, Destinataer,
|
||||||
DestinataerEmailEingang, DestinataerNotiz,
|
DestinataerEmailEingang, DestinataerNotiz,
|
||||||
@@ -476,11 +477,13 @@ def destinataer_toggle_archiv(request, pk):
|
|||||||
destinataer.aktiv = not destinataer.aktiv
|
destinataer.aktiv = not destinataer.aktiv
|
||||||
destinataer.save(update_fields=["aktiv"])
|
destinataer.save(update_fields=["aktiv"])
|
||||||
status_text = "aktiviert" if destinataer.aktiv else "archiviert (inaktiv gesetzt)"
|
status_text = "aktiviert" if destinataer.aktiv else "archiviert (inaktiv gesetzt)"
|
||||||
AuditLog.objects.create(
|
log_action(
|
||||||
user=request.user,
|
request,
|
||||||
action=f"Destinatär {destinataer.get_full_name()} wurde {status_text}.",
|
action="update",
|
||||||
model_name="Destinataer",
|
entity_type="destinataer",
|
||||||
object_id=str(destinataer.pk),
|
entity_id=str(destinataer.pk),
|
||||||
|
entity_name=destinataer.get_full_name(),
|
||||||
|
description=f"Destinatär {destinataer.get_full_name()} wurde {status_text}.",
|
||||||
)
|
)
|
||||||
messages.success(request, f'Destinatär "{destinataer.get_full_name()}" wurde {status_text}.')
|
messages.success(request, f'Destinatär "{destinataer.get_full_name()}" wurde {status_text}.')
|
||||||
return redirect("stiftung:destinataer_detail", pk=destinataer.pk)
|
return redirect("stiftung:destinataer_detail", pk=destinataer.pk)
|
||||||
@@ -760,3 +763,117 @@ def destinataer_export(request, pk):
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Bestätigungsschreiben
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
@login_required
|
||||||
|
def bestaetigung_vorschau(request, pk):
|
||||||
|
"""
|
||||||
|
PDF-Vorschau eines Bestätigungsschreibens für einen Destinatär im Browser.
|
||||||
|
Generiert das PDF on-the-fly via WeasyPrint.
|
||||||
|
"""
|
||||||
|
from decimal import Decimal
|
||||||
|
from django.template.loader import render_to_string
|
||||||
|
|
||||||
|
destinataer = get_object_or_404(Destinataer, id=pk)
|
||||||
|
|
||||||
|
unterstuetzungen = DestinataerUnterstuetzung.objects.filter(
|
||||||
|
destinataer=destinataer,
|
||||||
|
status__in=["ausgezahlt", "abgeschlossen"],
|
||||||
|
).order_by("faellig_am")
|
||||||
|
|
||||||
|
gesamtbetrag = unterstuetzungen.aggregate(total=Sum("betrag"))["total"] or Decimal("0")
|
||||||
|
|
||||||
|
zeitraum = None
|
||||||
|
if unterstuetzungen.exists():
|
||||||
|
erste = unterstuetzungen.first().faellig_am
|
||||||
|
letzte = unterstuetzungen.last().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)
|
||||||
|
|
||||||
|
context = {
|
||||||
|
"destinataer": destinataer,
|
||||||
|
"unterstuetzungen": unterstuetzungen,
|
||||||
|
"gesamtbetrag": gesamtbetrag,
|
||||||
|
"datum": timezone.now().date(),
|
||||||
|
"zeitraum": zeitraum,
|
||||||
|
"betrag_quartal": betrag_quartal,
|
||||||
|
"betrag_jaehrlich": betrag_jaehrlich,
|
||||||
|
"zweck": zweck,
|
||||||
|
}
|
||||||
|
|
||||||
|
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()
|
||||||
|
response = HttpResponse(pdf_bytes, content_type="application/pdf")
|
||||||
|
filename = f"bestaetigung_{destinataer.nachname}_{destinataer.vorname}.pdf"
|
||||||
|
response["Content-Disposition"] = f'inline; filename="{filename}"'
|
||||||
|
return response
|
||||||
|
except Exception as exc:
|
||||||
|
messages.error(request, f"PDF-Generierung fehlgeschlagen: {exc}")
|
||||||
|
return redirect("stiftung:destinataer_detail", pk=pk)
|
||||||
|
|
||||||
|
|
||||||
|
@login_required
|
||||||
|
def bestaetigung_versenden(request, pk):
|
||||||
|
"""
|
||||||
|
Sendet das Bestätigungsschreiben per E-Mail an den Destinatär.
|
||||||
|
POST-only (CSRF-geschützt). Sendet synchron für direktes Feedback.
|
||||||
|
"""
|
||||||
|
from stiftung.tasks import _send_bestaetigung_sync
|
||||||
|
|
||||||
|
if request.method != "POST":
|
||||||
|
return redirect("stiftung:destinataer_detail", pk=pk)
|
||||||
|
|
||||||
|
destinataer = get_object_or_404(Destinataer, id=pk)
|
||||||
|
|
||||||
|
if not destinataer.email:
|
||||||
|
messages.error(
|
||||||
|
request,
|
||||||
|
f"Destinatär {destinataer.get_full_name()} hat keine E-Mail-Adresse hinterlegt.",
|
||||||
|
)
|
||||||
|
return redirect("stiftung:destinataer_detail", pk=pk)
|
||||||
|
|
||||||
|
try:
|
||||||
|
result = _send_bestaetigung_sync(str(destinataer.id))
|
||||||
|
except Exception as exc:
|
||||||
|
import logging
|
||||||
|
logging.getLogger(__name__).exception("Bestätigung versenden fehlgeschlagen: %s", exc)
|
||||||
|
messages.error(
|
||||||
|
request,
|
||||||
|
f"Bestätigungsschreiben konnte nicht gesendet werden: {exc}",
|
||||||
|
)
|
||||||
|
return redirect("stiftung:destinataer_detail", pk=pk)
|
||||||
|
|
||||||
|
if result and result.get("status") == "skipped":
|
||||||
|
messages.warning(request, "Versand übersprungen: Keine E-Mail-Adresse hinterlegt.")
|
||||||
|
return redirect("stiftung:destinataer_detail", pk=pk)
|
||||||
|
|
||||||
|
if result and result.get("status") == "error":
|
||||||
|
messages.error(request, f"Fehler: {result.get('message', 'Unbekannter Fehler')}")
|
||||||
|
return redirect("stiftung:destinataer_detail", pk=pk)
|
||||||
|
|
||||||
|
log_action(
|
||||||
|
request,
|
||||||
|
action="update",
|
||||||
|
entity_type="destinataer",
|
||||||
|
entity_id=str(destinataer.id),
|
||||||
|
entity_name=destinataer.get_full_name(),
|
||||||
|
description=f"Bestätigungsschreiben per E-Mail gesendet an {destinataer.get_full_name()} ({destinataer.email})",
|
||||||
|
)
|
||||||
|
|
||||||
|
messages.success(
|
||||||
|
request,
|
||||||
|
f"Bestätigungsschreiben wurde erfolgreich an {destinataer.email} gesendet.",
|
||||||
|
)
|
||||||
|
return redirect("stiftung:destinataer_detail", pk=pk)
|
||||||
|
|
||||||
|
|||||||
@@ -254,9 +254,9 @@ def dms_edit(request, pk):
|
|||||||
paechter_id = request.POST.get("paechter_id", "").strip()
|
paechter_id = request.POST.get("paechter_id", "").strip()
|
||||||
verp_id = request.POST.get("verpachtung_id", "").strip()
|
verp_id = request.POST.get("verpachtung_id", "").strip()
|
||||||
|
|
||||||
dok.destinataer_id = int(dest_id) if dest_id else None
|
dok.destinataer_id = dest_id if dest_id else None
|
||||||
dok.land_id = int(land_id) if land_id else None
|
dok.land_id = land_id if land_id else None
|
||||||
dok.paechter_id = int(paechter_id) if paechter_id else None
|
dok.paechter_id = paechter_id if paechter_id else None
|
||||||
dok.verpachtung_id = verp_id if verp_id else None
|
dok.verpachtung_id = verp_id if verp_id else None
|
||||||
|
|
||||||
dok.save()
|
dok.save()
|
||||||
|
|||||||
@@ -59,7 +59,7 @@ from stiftung.forms import (
|
|||||||
|
|
||||||
@login_required
|
@login_required
|
||||||
def bericht_list(request):
|
def bericht_list(request):
|
||||||
"""List available reports"""
|
"""List available reports with modular report builder"""
|
||||||
# Get available years from data
|
# Get available years from data
|
||||||
jahre = sorted(
|
jahre = sorted(
|
||||||
set(
|
set(
|
||||||
@@ -69,7 +69,7 @@ def bericht_list(request):
|
|||||||
reverse=True,
|
reverse=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Statistics for overview tiles (removed legacy Person and Verpachtung)
|
# Statistics for overview tiles
|
||||||
total_destinataere = Destinataer.objects.count()
|
total_destinataere = Destinataer.objects.count()
|
||||||
total_laendereien = Land.objects.count()
|
total_laendereien = Land.objects.count()
|
||||||
total_verpachtungen = LandVerpachtung.objects.count()
|
total_verpachtungen = LandVerpachtung.objects.count()
|
||||||
@@ -82,10 +82,25 @@ def bericht_list(request):
|
|||||||
"total_laendereien": total_laendereien,
|
"total_laendereien": total_laendereien,
|
||||||
"total_verpachtungen": total_verpachtungen,
|
"total_verpachtungen": total_verpachtungen,
|
||||||
"total_foerderungen": total_foerderungen,
|
"total_foerderungen": total_foerderungen,
|
||||||
|
"bericht_vorlagen": BERICHT_VORLAGEN,
|
||||||
|
"bericht_sektionen": BERICHT_SEKTIONEN,
|
||||||
}
|
}
|
||||||
return render(request, "stiftung/bericht_list.html", context)
|
return render(request, "stiftung/bericht_list.html", context)
|
||||||
|
|
||||||
|
|
||||||
|
def _get_corporate_context():
|
||||||
|
"""Holt Corporate-Identity-Einstellungen und CSS für Berichte."""
|
||||||
|
from stiftung.utils.pdf_generator import pdf_generator
|
||||||
|
corporate_settings = pdf_generator.get_corporate_settings()
|
||||||
|
logo_base64 = pdf_generator.get_logo_base64(corporate_settings.get("logo_path", ""))
|
||||||
|
css_content = pdf_generator.get_base_css(corporate_settings)
|
||||||
|
return {
|
||||||
|
"corporate_settings": corporate_settings,
|
||||||
|
"logo_base64": logo_base64,
|
||||||
|
"css_content": css_content,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def _jahresbericht_context(jahr):
|
def _jahresbericht_context(jahr):
|
||||||
"""Phase 4: Aggregiert alle Daten für den Jahresbericht."""
|
"""Phase 4: Aggregiert alle Daten für den Jahresbericht."""
|
||||||
from stiftung.models import (
|
from stiftung.models import (
|
||||||
@@ -138,7 +153,7 @@ def _jahresbericht_context(jahr):
|
|||||||
total_ausgaben = total_ausgaben_foerderung + total_verwaltungskosten
|
total_ausgaben = total_ausgaben_foerderung + total_verwaltungskosten
|
||||||
netto = total_einnahmen - total_ausgaben
|
netto = total_einnahmen - total_ausgaben
|
||||||
|
|
||||||
return {
|
context = {
|
||||||
"jahr": jahr,
|
"jahr": jahr,
|
||||||
"title": f"Jahresbericht {jahr}",
|
"title": f"Jahresbericht {jahr}",
|
||||||
"foerderungen": foerderungen,
|
"foerderungen": foerderungen,
|
||||||
@@ -157,9 +172,14 @@ def _jahresbericht_context(jahr):
|
|||||||
"total_einnahmen": total_einnahmen,
|
"total_einnahmen": total_einnahmen,
|
||||||
"total_ausgaben": total_ausgaben,
|
"total_ausgaben": total_ausgaben,
|
||||||
"netto": netto,
|
"netto": netto,
|
||||||
# Rückwärtskompatibilität
|
|
||||||
"total_foerderungen": total_ausgaben_foerderung,
|
"total_foerderungen": total_ausgaben_foerderung,
|
||||||
|
"show_cover": True,
|
||||||
|
"bericht_titel": f"Jahresbericht {jahr}",
|
||||||
|
"bericht_untertitel": "Gesamtübersicht des Geschäftsjahres",
|
||||||
|
"berichtszeitraum": str(jahr),
|
||||||
}
|
}
|
||||||
|
context.update(_get_corporate_context())
|
||||||
|
return context
|
||||||
|
|
||||||
|
|
||||||
@login_required
|
@login_required
|
||||||
@@ -181,24 +201,276 @@ def jahresbericht_generate_redirect(request):
|
|||||||
|
|
||||||
@login_required
|
@login_required
|
||||||
def jahresbericht_pdf(request, jahr):
|
def jahresbericht_pdf(request, jahr):
|
||||||
"""Phase 4: PDF-Export des Jahresberichts."""
|
"""Phase 4: PDF-Export des Jahresberichts via PDFGenerator."""
|
||||||
from django.http import HttpResponse
|
|
||||||
from django.template.loader import render_to_string
|
from django.template.loader import render_to_string
|
||||||
from weasyprint import HTML
|
from stiftung.utils.pdf_generator import pdf_generator
|
||||||
|
|
||||||
context = _jahresbericht_context(jahr)
|
context = _jahresbericht_context(jahr)
|
||||||
|
|
||||||
# Render HTML
|
|
||||||
html_string = render_to_string("stiftung/jahresbericht.html", context)
|
html_string = render_to_string("stiftung/jahresbericht.html", context)
|
||||||
|
return pdf_generator.generate_pdf_response(
|
||||||
|
html_string, f"jahresbericht_{jahr}.pdf", context.get("css_content")
|
||||||
|
)
|
||||||
|
|
||||||
# Generate PDF
|
|
||||||
pdf = HTML(string=html_string).write_pdf()
|
|
||||||
|
|
||||||
# Create response
|
# =============================================================================
|
||||||
response = HttpResponse(pdf, content_type="application/pdf")
|
# MODULARE BERICHTE – Berichts-Baukasten
|
||||||
response["Content-Disposition"] = f'attachment; filename="jahresbericht_{jahr}.pdf"'
|
# =============================================================================
|
||||||
|
|
||||||
return response
|
# Verfügbare Sektionen mit Metadaten
|
||||||
|
BERICHT_SEKTIONEN = {
|
||||||
|
"bilanz": {"label": "Jahresbilanz", "icon": "fa-balance-scale", "needs_jahr": True},
|
||||||
|
"unterstuetzungen": {"label": "Unterstützungszahlungen", "icon": "fa-hand-holding-heart", "needs_jahr": True},
|
||||||
|
"foerderungen": {"label": "Förderungen", "icon": "fa-gift", "needs_jahr": True},
|
||||||
|
"grundstuecke": {"label": "Grundstücksverwaltung", "icon": "fa-map", "needs_jahr": True},
|
||||||
|
"verwaltungskosten": {"label": "Verwaltungskosten", "icon": "fa-file-invoice-dollar", "needs_jahr": True},
|
||||||
|
"destinataere_uebersicht": {"label": "Destinatär-Übersicht", "icon": "fa-users", "needs_jahr": False},
|
||||||
|
"konten_uebersicht": {"label": "Kontenübersicht", "icon": "fa-university", "needs_jahr": False},
|
||||||
|
"verpachtungen": {"label": "Pachtbericht", "icon": "fa-handshake", "needs_jahr": False},
|
||||||
|
}
|
||||||
|
|
||||||
|
# Vordefinierte Berichtstypen
|
||||||
|
BERICHT_VORLAGEN = {
|
||||||
|
"jahresbericht": {
|
||||||
|
"label": "Jahresbericht",
|
||||||
|
"beschreibung": "Vollständige Übersicht eines Geschäftsjahres",
|
||||||
|
"sektionen": ["bilanz", "unterstuetzungen", "foerderungen", "grundstuecke", "verwaltungskosten"],
|
||||||
|
"needs_jahr": True,
|
||||||
|
"icon": "fa-calendar-alt",
|
||||||
|
},
|
||||||
|
"destinataerbericht": {
|
||||||
|
"label": "Destinatärbericht",
|
||||||
|
"beschreibung": "Übersicht aller Destinatäre mit Förderstatus",
|
||||||
|
"sektionen": ["destinataere_uebersicht", "unterstuetzungen", "foerderungen"],
|
||||||
|
"needs_jahr": True,
|
||||||
|
"icon": "fa-users",
|
||||||
|
},
|
||||||
|
"grundstuecksbericht": {
|
||||||
|
"label": "Grundstücksbericht",
|
||||||
|
"beschreibung": "Liegenschaftsübersicht mit Pachtverträgen",
|
||||||
|
"sektionen": ["grundstuecke", "verpachtungen"],
|
||||||
|
"needs_jahr": True,
|
||||||
|
"icon": "fa-map",
|
||||||
|
},
|
||||||
|
"finanzbericht": {
|
||||||
|
"label": "Finanzbericht",
|
||||||
|
"beschreibung": "Einnahmen/Ausgaben und Kontenübersicht",
|
||||||
|
"sektionen": ["bilanz", "konten_uebersicht", "verwaltungskosten"],
|
||||||
|
"needs_jahr": True,
|
||||||
|
"icon": "fa-euro-sign",
|
||||||
|
},
|
||||||
|
"foerderbericht": {
|
||||||
|
"label": "Förderbericht",
|
||||||
|
"beschreibung": "Detailansicht aller Förderungen",
|
||||||
|
"sektionen": ["foerderungen", "unterstuetzungen"],
|
||||||
|
"needs_jahr": True,
|
||||||
|
"icon": "fa-gift",
|
||||||
|
},
|
||||||
|
"pachtbericht": {
|
||||||
|
"label": "Pachtbericht",
|
||||||
|
"beschreibung": "Pachtzinseinnahmen und Vertragsübersicht",
|
||||||
|
"sektionen": ["verpachtungen", "grundstuecke"],
|
||||||
|
"needs_jahr": True,
|
||||||
|
"icon": "fa-handshake",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _build_section_context(sektionen, jahr=None):
|
||||||
|
"""Baut den Context für die gewählten Sektionen zusammen."""
|
||||||
|
from stiftung.models import (
|
||||||
|
DestinataerUnterstuetzung, LandAbrechnung, Verwaltungskosten,
|
||||||
|
)
|
||||||
|
context = {}
|
||||||
|
|
||||||
|
if jahr:
|
||||||
|
context["jahr"] = jahr
|
||||||
|
|
||||||
|
needs_jahresbericht = any(s in sektionen for s in [
|
||||||
|
"bilanz", "unterstuetzungen", "foerderungen", "grundstuecke", "verwaltungskosten"
|
||||||
|
])
|
||||||
|
if needs_jahresbericht and jahr:
|
||||||
|
jb = _jahresbericht_context(jahr)
|
||||||
|
context.update(jb)
|
||||||
|
|
||||||
|
if "destinataere_uebersicht" in sektionen:
|
||||||
|
from django.db.models import Count, Sum as DSum
|
||||||
|
qs = Destinataer.objects.all()
|
||||||
|
context["destinataere_aktiv"] = qs.filter(aktiv=True).count()
|
||||||
|
context["destinataere_gesamt"] = qs.count()
|
||||||
|
|
||||||
|
# Annotate with support stats
|
||||||
|
if jahr:
|
||||||
|
dest_qs = qs.annotate(
|
||||||
|
unterstuetzung_count=Count(
|
||||||
|
"unterstuetzungen",
|
||||||
|
filter=Q(unterstuetzungen__faellig_am__year=jahr),
|
||||||
|
),
|
||||||
|
unterstuetzung_summe=Coalesce(
|
||||||
|
DSum(
|
||||||
|
"unterstuetzungen__betrag",
|
||||||
|
filter=Q(
|
||||||
|
unterstuetzungen__faellig_am__year=jahr,
|
||||||
|
unterstuetzungen__status__in=["ausgezahlt", "abgeschlossen"],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Decimal("0"),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
dest_qs = qs.annotate(
|
||||||
|
unterstuetzung_count=Count("unterstuetzungen"),
|
||||||
|
unterstuetzung_summe=Coalesce(
|
||||||
|
DSum(
|
||||||
|
"unterstuetzungen__betrag",
|
||||||
|
filter=Q(unterstuetzungen__status__in=["ausgezahlt", "abgeschlossen"]),
|
||||||
|
),
|
||||||
|
Decimal("0"),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
context["destinataere_liste"] = dest_qs.order_by("nachname", "vorname")
|
||||||
|
context["destinataere_total_unterstuetzung"] = (
|
||||||
|
dest_qs.aggregate(total=DSum("unterstuetzung_summe"))["total"] or 0
|
||||||
|
)
|
||||||
|
|
||||||
|
if "konten_uebersicht" in sektionen:
|
||||||
|
konten = StiftungsKonto.objects.filter(aktiv=True).order_by("bank_name", "kontoname")
|
||||||
|
context["konten_liste"] = konten
|
||||||
|
context["konten_anzahl"] = konten.count()
|
||||||
|
context["konten_gesamtsaldo"] = konten.aggregate(total=Sum("saldo"))["total"] or 0
|
||||||
|
|
||||||
|
if "verpachtungen" in sektionen:
|
||||||
|
from datetime import timedelta
|
||||||
|
heute = date.today()
|
||||||
|
in_12_monaten = heute + timedelta(days=365)
|
||||||
|
|
||||||
|
aktive = LandVerpachtung.objects.filter(
|
||||||
|
status="aktiv"
|
||||||
|
).select_related("land", "paechter")
|
||||||
|
|
||||||
|
auslaufend = aktive.filter(
|
||||||
|
pachtende__isnull=False,
|
||||||
|
pachtende__lte=in_12_monaten,
|
||||||
|
pachtende__gte=heute,
|
||||||
|
).order_by("pachtende")
|
||||||
|
|
||||||
|
total_flaeche = aktive.aggregate(total=Sum("verpachtete_flaeche"))["total"] or 0
|
||||||
|
total_pz = aktive.aggregate(total=Sum("pachtzins_pauschal"))["total"] or 0
|
||||||
|
|
||||||
|
context["pacht_statistik"] = {
|
||||||
|
"aktive_vertraege": aktive.count(),
|
||||||
|
"total_pachtzins": total_pz,
|
||||||
|
"total_flaeche": total_flaeche,
|
||||||
|
"auslaufend_12m": auslaufend.count(),
|
||||||
|
}
|
||||||
|
context["pacht_auslaufend"] = auslaufend
|
||||||
|
# Also provide full list if not already from jahresbericht
|
||||||
|
if "verpachtungen" not in context or not context.get("verpachtungen"):
|
||||||
|
if jahr:
|
||||||
|
context["verpachtungen"] = LandVerpachtung.objects.filter(
|
||||||
|
pachtbeginn__year__lte=jahr
|
||||||
|
).filter(
|
||||||
|
Q(pachtende__isnull=True) | Q(pachtende__year__gte=jahr)
|
||||||
|
).select_related("land", "paechter")
|
||||||
|
else:
|
||||||
|
context["verpachtungen"] = aktive
|
||||||
|
|
||||||
|
return context
|
||||||
|
|
||||||
|
|
||||||
|
@login_required
|
||||||
|
def bericht_zusammenstellen(request):
|
||||||
|
"""Modularer Bericht: Sektionen auswählen und zusammenstellen."""
|
||||||
|
if request.method == "POST":
|
||||||
|
sektionen = request.POST.getlist("sektionen")
|
||||||
|
jahr_str = request.POST.get("jahr", "")
|
||||||
|
show_cover = request.POST.get("show_cover") == "on"
|
||||||
|
output_format = request.POST.get("format", "html")
|
||||||
|
vorlage = request.POST.get("vorlage", "")
|
||||||
|
|
||||||
|
# Vorlage anwenden falls gewählt
|
||||||
|
if vorlage and vorlage in BERICHT_VORLAGEN and not sektionen:
|
||||||
|
sektionen = BERICHT_VORLAGEN[vorlage]["sektionen"]
|
||||||
|
|
||||||
|
if not sektionen:
|
||||||
|
messages.error(request, "Bitte wählen Sie mindestens eine Sektion aus.")
|
||||||
|
return redirect("stiftung:bericht_list")
|
||||||
|
|
||||||
|
jahr = int(jahr_str) if jahr_str and jahr_str.isdigit() else None
|
||||||
|
|
||||||
|
# Build context
|
||||||
|
context = _build_section_context(sektionen, jahr)
|
||||||
|
context["sektionen"] = sektionen
|
||||||
|
context["show_cover"] = show_cover
|
||||||
|
|
||||||
|
# Set titles
|
||||||
|
if vorlage and vorlage in BERICHT_VORLAGEN:
|
||||||
|
titel = BERICHT_VORLAGEN[vorlage]["label"]
|
||||||
|
else:
|
||||||
|
titel = "Bericht"
|
||||||
|
if jahr:
|
||||||
|
context["bericht_titel"] = f"{titel} {jahr}"
|
||||||
|
context["berichtszeitraum"] = str(jahr)
|
||||||
|
else:
|
||||||
|
context["bericht_titel"] = titel
|
||||||
|
context["berichtszeitraum"] = "Aktuell"
|
||||||
|
|
||||||
|
context["bericht_untertitel"] = BERICHT_VORLAGEN.get(vorlage, {}).get("beschreibung", "")
|
||||||
|
|
||||||
|
# Add corporate context if not already present
|
||||||
|
if "corporate_settings" not in context:
|
||||||
|
context.update(_get_corporate_context())
|
||||||
|
|
||||||
|
if output_format == "pdf":
|
||||||
|
from django.template.loader import render_to_string
|
||||||
|
from stiftung.utils.pdf_generator import pdf_generator
|
||||||
|
html_string = render_to_string("berichte/bericht_modular.html", context)
|
||||||
|
filename = f"bericht_{vorlage or 'custom'}_{jahr or 'aktuell'}.pdf"
|
||||||
|
return pdf_generator.generate_pdf_response(
|
||||||
|
html_string, filename, context.get("css_content")
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
return render(request, "berichte/bericht_modular.html", context)
|
||||||
|
|
||||||
|
# GET: Redirect to bericht_list
|
||||||
|
return redirect("stiftung:bericht_list")
|
||||||
|
|
||||||
|
|
||||||
|
@login_required
|
||||||
|
def bericht_vorlage(request, vorlage_key):
|
||||||
|
"""Schnellzugriff: Vordefinierte Berichtsvorlage generieren."""
|
||||||
|
if vorlage_key not in BERICHT_VORLAGEN:
|
||||||
|
messages.error(request, f"Unbekannter Berichtstyp: {vorlage_key}")
|
||||||
|
return redirect("stiftung:bericht_list")
|
||||||
|
|
||||||
|
vorlage = BERICHT_VORLAGEN[vorlage_key]
|
||||||
|
jahr_str = request.GET.get("jahr", "")
|
||||||
|
jahr = int(jahr_str) if jahr_str and jahr_str.isdigit() else None
|
||||||
|
output_format = request.GET.get("format", "html")
|
||||||
|
|
||||||
|
if vorlage["needs_jahr"] and not jahr:
|
||||||
|
messages.error(request, "Bitte wählen Sie ein Jahr für diesen Bericht.")
|
||||||
|
return redirect("stiftung:bericht_list")
|
||||||
|
|
||||||
|
context = _build_section_context(vorlage["sektionen"], jahr)
|
||||||
|
context["sektionen"] = vorlage["sektionen"]
|
||||||
|
context["show_cover"] = True
|
||||||
|
context["bericht_titel"] = f"{vorlage['label']}" + (f" {jahr}" if jahr else "")
|
||||||
|
context["bericht_untertitel"] = vorlage["beschreibung"]
|
||||||
|
context["berichtszeitraum"] = str(jahr) if jahr else "Aktuell"
|
||||||
|
|
||||||
|
if "corporate_settings" not in context:
|
||||||
|
context.update(_get_corporate_context())
|
||||||
|
|
||||||
|
if output_format == "pdf":
|
||||||
|
from django.template.loader import render_to_string
|
||||||
|
from stiftung.utils.pdf_generator import pdf_generator
|
||||||
|
html_string = render_to_string("berichte/bericht_modular.html", context)
|
||||||
|
filename = f"{vorlage_key}_{jahr or 'aktuell'}.pdf"
|
||||||
|
return pdf_generator.generate_pdf_response(
|
||||||
|
html_string, filename, context.get("css_content")
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
return render(request, "berichte/bericht_modular.html", context)
|
||||||
|
|
||||||
|
|
||||||
# API Views for AJAX
|
# API Views for AJAX
|
||||||
|
|||||||
@@ -750,8 +750,10 @@ def email_eingang_detail(request, pk):
|
|||||||
messages.success(request, "Notizen gespeichert.")
|
messages.success(request, "Notizen gespeichert.")
|
||||||
return redirect("stiftung:email_eingang_detail", pk=pk)
|
return redirect("stiftung:email_eingang_detail", pk=pk)
|
||||||
|
|
||||||
# DMS-Dokumente
|
# DMS-Dokumente: E-Mail-Body und Anhaenge trennen
|
||||||
dms_dokumente = eingang.dokument_dateien.all().order_by("erstellt_am")
|
alle_dms_dokumente = eingang.dokument_dateien.all().order_by("erstellt_am")
|
||||||
|
email_dokument = alle_dms_dokumente.filter(kontext="email").first()
|
||||||
|
anhaenge_dokumente = alle_dms_dokumente.exclude(kontext="email")
|
||||||
|
|
||||||
# Alle aktiven Destinataere fuer manuelle Zuordnung
|
# Alle aktiven Destinataere fuer manuelle Zuordnung
|
||||||
alle_destinataere = Destinataer.objects.filter(aktiv=True).order_by("nachname", "vorname")
|
alle_destinataere = Destinataer.objects.filter(aktiv=True).order_by("nachname", "vorname")
|
||||||
@@ -759,7 +761,8 @@ def email_eingang_detail(request, pk):
|
|||||||
context = {
|
context = {
|
||||||
"title": f"E-Mail-Eingang: {eingang}",
|
"title": f"E-Mail-Eingang: {eingang}",
|
||||||
"eingang": eingang,
|
"eingang": eingang,
|
||||||
"dms_dokumente": dms_dokumente,
|
"email_dokument": email_dokument,
|
||||||
|
"anhaenge_dokumente": anhaenge_dokumente,
|
||||||
"alle_destinataere": alle_destinataere,
|
"alle_destinataere": alle_destinataere,
|
||||||
"vk_kategorie_choices": Verwaltungskosten.KATEGORIE_CHOICES,
|
"vk_kategorie_choices": Verwaltungskosten.KATEGORIE_CHOICES,
|
||||||
}
|
}
|
||||||
|
|||||||
920
app/stiftung/views/import_export.py
Normal file
920
app/stiftung/views/import_export.py
Normal file
@@ -0,0 +1,920 @@
|
|||||||
|
# views/import_export.py
|
||||||
|
# Unified Import/Export Workflow for all Stiftung content types
|
||||||
|
|
||||||
|
import csv
|
||||||
|
import io
|
||||||
|
import json
|
||||||
|
from datetime import datetime
|
||||||
|
from decimal import Decimal, InvalidOperation
|
||||||
|
|
||||||
|
from django.contrib import messages
|
||||||
|
from django.contrib.auth.decorators import login_required
|
||||||
|
from django.http import HttpResponse
|
||||||
|
from django.shortcuts import redirect, render
|
||||||
|
from django.utils import timezone
|
||||||
|
|
||||||
|
from stiftung.models import (
|
||||||
|
CSVImport, Destinataer, Foerderung, Land, LandAbrechnung,
|
||||||
|
LandVerpachtung, Paechter, Person, Rentmeister,
|
||||||
|
StiftungsKonto, Verwaltungskosten,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Export field definitions for each entity type
|
||||||
|
# Each entry: (csv_header, model_field_or_lambda)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _date_fmt(val):
|
||||||
|
"""Format date for CSV export."""
|
||||||
|
if val is None:
|
||||||
|
return ""
|
||||||
|
return val.strftime("%d.%m.%Y")
|
||||||
|
|
||||||
|
|
||||||
|
def _datetime_fmt(val):
|
||||||
|
if val is None:
|
||||||
|
return ""
|
||||||
|
return val.strftime("%d.%m.%Y %H:%M")
|
||||||
|
|
||||||
|
|
||||||
|
def _decimal_fmt(val):
|
||||||
|
if val is None:
|
||||||
|
return ""
|
||||||
|
return f"{val:.2f}"
|
||||||
|
|
||||||
|
|
||||||
|
def _bool_fmt(val):
|
||||||
|
if val is None:
|
||||||
|
return ""
|
||||||
|
return "ja" if val else "nein"
|
||||||
|
|
||||||
|
|
||||||
|
EXPORT_DEFINITIONS = {
|
||||||
|
"destinataere": {
|
||||||
|
"model": Destinataer,
|
||||||
|
"label": "Destinatäre",
|
||||||
|
"queryset": lambda: Destinataer.objects.all().order_by("nachname", "vorname"),
|
||||||
|
"fields": [
|
||||||
|
("Vorname", lambda o: o.vorname),
|
||||||
|
("Nachname", lambda o: o.nachname),
|
||||||
|
("Geburtsdatum", lambda o: _date_fmt(o.geburtsdatum)),
|
||||||
|
("E-Mail", lambda o: o.email or ""),
|
||||||
|
("Telefon", lambda o: o.telefon or ""),
|
||||||
|
("IBAN", lambda o: o.iban or ""),
|
||||||
|
("Straße", lambda o: o.strasse or ""),
|
||||||
|
("PLZ", lambda o: o.plz or ""),
|
||||||
|
("Ort", lambda o: o.ort or ""),
|
||||||
|
("Familienzweig", lambda o: o.familienzweig or ""),
|
||||||
|
("Berufsgruppe", lambda o: o.berufsgruppe or ""),
|
||||||
|
("Ausbildungsstand", lambda o: o.ausbildungsstand or ""),
|
||||||
|
("Institution", lambda o: o.institution or ""),
|
||||||
|
("Projektbeschreibung", lambda o: o.projekt_beschreibung or ""),
|
||||||
|
("Jährliches_Einkommen", lambda o: _decimal_fmt(o.jaehrliches_einkommen)),
|
||||||
|
("Finanzielle_Notlage", lambda o: _bool_fmt(o.finanzielle_notlage)),
|
||||||
|
("Ist_Abkömmling", lambda o: _bool_fmt(o.ist_abkoemmling)),
|
||||||
|
("Haushaltsgroesse", lambda o: str(o.haushaltsgroesse) if o.haushaltsgroesse else ""),
|
||||||
|
("Monatliche_Bezuege", lambda o: _decimal_fmt(o.monatliche_bezuege)),
|
||||||
|
("Vermoegen", lambda o: _decimal_fmt(o.vermoegen)),
|
||||||
|
("Unterstuetzung_Bestaetigt", lambda o: _bool_fmt(o.unterstuetzung_bestaetigt)),
|
||||||
|
("Vierteljaehrlicher_Betrag", lambda o: _decimal_fmt(o.vierteljaehrlicher_betrag)),
|
||||||
|
("Studiennachweis_Erforderlich", lambda o: _bool_fmt(o.studiennachweis_erforderlich)),
|
||||||
|
("Letzter_Studiennachweis", lambda o: _date_fmt(o.letzter_studiennachweis)),
|
||||||
|
("Notizen", lambda o: o.notizen or ""),
|
||||||
|
("Aktiv", lambda o: _bool_fmt(o.aktiv)),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
"paechter": {
|
||||||
|
"model": Paechter,
|
||||||
|
"label": "Pächter",
|
||||||
|
"queryset": lambda: Paechter.objects.all().order_by("nachname", "vorname"),
|
||||||
|
"fields": [
|
||||||
|
("Vorname", lambda o: o.vorname),
|
||||||
|
("Nachname", lambda o: o.nachname),
|
||||||
|
("Geburtsdatum", lambda o: _date_fmt(o.geburtsdatum)),
|
||||||
|
("E-Mail", lambda o: o.email or ""),
|
||||||
|
("Telefon", lambda o: o.telefon or ""),
|
||||||
|
("IBAN", lambda o: o.iban or ""),
|
||||||
|
("Straße", lambda o: o.strasse or ""),
|
||||||
|
("PLZ", lambda o: o.plz or ""),
|
||||||
|
("Ort", lambda o: o.ort or ""),
|
||||||
|
("Personentyp", lambda o: o.personentyp or ""),
|
||||||
|
("Pachtnummer", lambda o: o.pachtnummer or ""),
|
||||||
|
("Pachtbeginn_Erste", lambda o: _date_fmt(o.pachtbeginn_erste)),
|
||||||
|
("Pachtende_Letzte", lambda o: _date_fmt(o.pachtende_letzte)),
|
||||||
|
("Pachtzins_Aktuell", lambda o: _decimal_fmt(o.pachtzins_aktuell)),
|
||||||
|
("Landwirtschaftliche_Ausbildung", lambda o: _bool_fmt(o.landwirtschaftliche_ausbildung)),
|
||||||
|
("Berufserfahrung_Jahre", lambda o: str(o.berufserfahrung_jahre) if o.berufserfahrung_jahre else ""),
|
||||||
|
("Spezialisierung", lambda o: o.spezialisierung or ""),
|
||||||
|
("Notizen", lambda o: o.notizen or ""),
|
||||||
|
("Aktiv", lambda o: _bool_fmt(o.aktiv)),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
"laendereien": {
|
||||||
|
"model": Land,
|
||||||
|
"label": "Ländereien",
|
||||||
|
"queryset": lambda: Land.objects.select_related("aktueller_paechter").all().order_by("lfd_nr"),
|
||||||
|
"fields": [
|
||||||
|
("Lfd_Nr", lambda o: o.lfd_nr or ""),
|
||||||
|
("EW_Nummer", lambda o: o.ew_nummer or ""),
|
||||||
|
("Grundbuchblatt", lambda o: o.grundbuchblatt or ""),
|
||||||
|
("ALKIS_Kennzeichen", lambda o: o.alkis_kennzeichen or ""),
|
||||||
|
("Amtsgericht", lambda o: o.amtsgericht or ""),
|
||||||
|
("Gemeinde", lambda o: o.gemeinde or ""),
|
||||||
|
("Gemarkung", lambda o: o.gemarkung or ""),
|
||||||
|
("Flur", lambda o: o.flur or ""),
|
||||||
|
("Flurstück", lambda o: o.flurstueck or ""),
|
||||||
|
("Adresse", lambda o: o.adresse or ""),
|
||||||
|
("Größe_qm", lambda o: _decimal_fmt(o.groesse_qm)),
|
||||||
|
("Grünland_qm", lambda o: _decimal_fmt(o.gruenland_qm)),
|
||||||
|
("Acker_qm", lambda o: _decimal_fmt(o.acker_qm)),
|
||||||
|
("Wald_qm", lambda o: _decimal_fmt(o.wald_qm)),
|
||||||
|
("Sonstiges_qm", lambda o: _decimal_fmt(o.sonstiges_qm)),
|
||||||
|
("Verpachtete_Gesamtfläche_qm", lambda o: _decimal_fmt(o.verpachtete_gesamtflaeche)),
|
||||||
|
("Verp_Fläche_aktuell_qm", lambda o: _decimal_fmt(o.verp_flaeche_aktuell)),
|
||||||
|
("Pächter_Name", lambda o: o.paechter_name or ""),
|
||||||
|
("Pachtbeginn", lambda o: _date_fmt(o.pachtbeginn)),
|
||||||
|
("Pachtende", lambda o: _date_fmt(o.pachtende)),
|
||||||
|
("Zahlungsweise", lambda o: o.zahlungsweise or ""),
|
||||||
|
("Pachtzins_pro_ha", lambda o: _decimal_fmt(o.pachtzins_pro_ha)),
|
||||||
|
("Pachtzins_pauschal", lambda o: _decimal_fmt(o.pachtzins_pauschal)),
|
||||||
|
("USt_Option", lambda o: _bool_fmt(o.ust_option)),
|
||||||
|
("USt_Satz", lambda o: _decimal_fmt(o.ust_satz)),
|
||||||
|
("Aktiv", lambda o: _bool_fmt(o.aktiv)),
|
||||||
|
("Notizen", lambda o: o.notizen or ""),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
"verpachtungen": {
|
||||||
|
"model": LandVerpachtung,
|
||||||
|
"label": "Verpachtungen",
|
||||||
|
"queryset": lambda: LandVerpachtung.objects.select_related("land", "paechter").all().order_by("vertragsnummer"),
|
||||||
|
"fields": [
|
||||||
|
("Vertragsnummer", lambda o: o.vertragsnummer or ""),
|
||||||
|
("Land_Lfd_Nr", lambda o: o.land.lfd_nr if o.land else ""),
|
||||||
|
("Land_Gemeinde", lambda o: o.land.gemeinde if o.land else ""),
|
||||||
|
("Land_Gemarkung", lambda o: o.land.gemarkung if o.land else ""),
|
||||||
|
("Pächter_Name", lambda o: f"{o.paechter.vorname} {o.paechter.nachname}" if o.paechter else ""),
|
||||||
|
("Pachtbeginn", lambda o: _date_fmt(o.pachtbeginn)),
|
||||||
|
("Pachtende", lambda o: _date_fmt(o.pachtende)),
|
||||||
|
("Verlängerung_Klausel", lambda o: o.verlaengerung_klausel or ""),
|
||||||
|
("Verpachtete_Fläche_qm", lambda o: _decimal_fmt(o.verpachtete_flaeche)),
|
||||||
|
("Pachtzins_pauschal", lambda o: _decimal_fmt(o.pachtzins_pauschal)),
|
||||||
|
("Pachtzins_pro_ha", lambda o: _decimal_fmt(o.pachtzins_pro_ha)),
|
||||||
|
("Zahlungsweise", lambda o: o.zahlungsweise or ""),
|
||||||
|
("USt_Option", lambda o: _bool_fmt(o.ust_option)),
|
||||||
|
("USt_Satz", lambda o: _decimal_fmt(o.ust_satz)),
|
||||||
|
("Status", lambda o: o.status or ""),
|
||||||
|
("Bemerkungen", lambda o: o.bemerkungen or ""),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
"foerderungen": {
|
||||||
|
"model": Foerderung,
|
||||||
|
"label": "Förderungen",
|
||||||
|
"queryset": lambda: Foerderung.objects.select_related("destinataer").all().order_by("-jahr"),
|
||||||
|
"fields": [
|
||||||
|
("Destinatär_Vorname", lambda o: o.destinataer.vorname if o.destinataer else ""),
|
||||||
|
("Destinatär_Nachname", lambda o: o.destinataer.nachname if o.destinataer else ""),
|
||||||
|
("Jahr", lambda o: str(o.jahr)),
|
||||||
|
("Betrag", lambda o: _decimal_fmt(o.betrag)),
|
||||||
|
("Kategorie", lambda o: o.get_kategorie_display() if o.kategorie else ""),
|
||||||
|
("Status", lambda o: o.get_status_display() if o.status else ""),
|
||||||
|
("Antragsdatum", lambda o: _date_fmt(o.antragsdatum)),
|
||||||
|
("Entscheidungsdatum", lambda o: _date_fmt(o.entscheidungsdatum)),
|
||||||
|
("Bemerkungen", lambda o: o.bemerkungen or ""),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
"konten": {
|
||||||
|
"model": StiftungsKonto,
|
||||||
|
"label": "Stiftungskonten",
|
||||||
|
"queryset": lambda: StiftungsKonto.objects.all().order_by("kontoname"),
|
||||||
|
"fields": [
|
||||||
|
("Kontoname", lambda o: o.kontoname),
|
||||||
|
("Bank", lambda o: o.bank_name or ""),
|
||||||
|
("IBAN", lambda o: o.iban or ""),
|
||||||
|
("BIC", lambda o: o.bic or ""),
|
||||||
|
("Konto_Typ", lambda o: o.get_konto_typ_display() if o.konto_typ else ""),
|
||||||
|
("Saldo", lambda o: _decimal_fmt(o.saldo)),
|
||||||
|
("Saldo_Datum", lambda o: _date_fmt(o.saldo_datum)),
|
||||||
|
("Zinssatz", lambda o: _decimal_fmt(o.zinssatz)),
|
||||||
|
("Laufzeit_Bis", lambda o: _date_fmt(o.laufzeit_bis)),
|
||||||
|
("Aktiv", lambda o: _bool_fmt(o.aktiv)),
|
||||||
|
("Notizen", lambda o: o.notizen or ""),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
"verwaltungskosten": {
|
||||||
|
"model": Verwaltungskosten,
|
||||||
|
"label": "Verwaltungskosten",
|
||||||
|
"queryset": lambda: Verwaltungskosten.objects.select_related("rentmeister").all().order_by("-datum"),
|
||||||
|
"fields": [
|
||||||
|
("Bezeichnung", lambda o: o.bezeichnung),
|
||||||
|
("Kategorie", lambda o: o.get_kategorie_display() if o.kategorie else ""),
|
||||||
|
("Betrag", lambda o: _decimal_fmt(o.betrag)),
|
||||||
|
("Datum", lambda o: _date_fmt(o.datum)),
|
||||||
|
("Lieferant", lambda o: o.lieferant_firma or ""),
|
||||||
|
("Rechnungsnummer", lambda o: o.rechnungsnummer or ""),
|
||||||
|
("Status", lambda o: o.get_status_display() if o.status else ""),
|
||||||
|
("Rentmeister", lambda o: f"{o.rentmeister.vorname} {o.rentmeister.nachname}" if o.rentmeister else ""),
|
||||||
|
("Beschreibung", lambda o: o.beschreibung or ""),
|
||||||
|
("Notizen", lambda o: o.notizen or ""),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
"rentmeister": {
|
||||||
|
"model": Rentmeister,
|
||||||
|
"label": "Rentmeister",
|
||||||
|
"queryset": lambda: Rentmeister.objects.all().order_by("nachname", "vorname"),
|
||||||
|
"fields": [
|
||||||
|
("Anrede", lambda o: o.get_anrede_display() if o.anrede else ""),
|
||||||
|
("Vorname", lambda o: o.vorname),
|
||||||
|
("Nachname", lambda o: o.nachname),
|
||||||
|
("Titel", lambda o: o.titel or ""),
|
||||||
|
("E-Mail", lambda o: o.email or ""),
|
||||||
|
("Telefon", lambda o: o.telefon or ""),
|
||||||
|
("Mobil", lambda o: o.mobil or ""),
|
||||||
|
("Straße", lambda o: o.strasse or ""),
|
||||||
|
("PLZ", lambda o: o.plz or ""),
|
||||||
|
("Ort", lambda o: o.ort or ""),
|
||||||
|
("IBAN", lambda o: o.iban or ""),
|
||||||
|
("BIC", lambda o: o.bic or ""),
|
||||||
|
("Bank", lambda o: o.bank_name or ""),
|
||||||
|
("Seit", lambda o: _date_fmt(o.seit_datum)),
|
||||||
|
("Bis", lambda o: _date_fmt(o.bis_datum)),
|
||||||
|
("Monatliche_Vergütung", lambda o: _decimal_fmt(o.monatliche_verguetung)),
|
||||||
|
("Km_Pauschale", lambda o: _decimal_fmt(o.km_pauschale)),
|
||||||
|
("Aktiv", lambda o: _bool_fmt(o.aktiv)),
|
||||||
|
("Notizen", lambda o: o.notizen or ""),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Import field definitions for field mapping
|
||||||
|
# Each: (display_label, model_field, field_type, required)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
IMPORT_FIELD_DEFINITIONS = {
|
||||||
|
"destinataere": {
|
||||||
|
"model": Destinataer,
|
||||||
|
"label": "Destinatäre",
|
||||||
|
"unique_fields": ["vorname", "nachname"],
|
||||||
|
"fields": [
|
||||||
|
("Vorname", "vorname", "text", True),
|
||||||
|
("Nachname", "nachname", "text", True),
|
||||||
|
("Geburtsdatum", "geburtsdatum", "date", False),
|
||||||
|
("E-Mail", "email", "text", False),
|
||||||
|
("Telefon", "telefon", "text", False),
|
||||||
|
("IBAN", "iban", "text", False),
|
||||||
|
("Straße", "strasse", "text", False),
|
||||||
|
("PLZ", "plz", "text", False),
|
||||||
|
("Ort", "ort", "text", False),
|
||||||
|
("Familienzweig", "familienzweig", "text", False),
|
||||||
|
("Berufsgruppe", "berufsgruppe", "text", False),
|
||||||
|
("Ausbildungsstand", "ausbildungsstand", "text", False),
|
||||||
|
("Institution", "institution", "text", False),
|
||||||
|
("Projektbeschreibung", "projekt_beschreibung", "text", False),
|
||||||
|
("Jährliches Einkommen", "jaehrliches_einkommen", "decimal", False),
|
||||||
|
("Finanzielle Notlage", "finanzielle_notlage", "bool", False),
|
||||||
|
("Ist Abkömmling", "ist_abkoemmling", "bool", False),
|
||||||
|
("Haushaltsgröße", "haushaltsgroesse", "int", False),
|
||||||
|
("Monatliche Bezüge", "monatliche_bezuege", "decimal", False),
|
||||||
|
("Vermögen", "vermoegen", "decimal", False),
|
||||||
|
("Unterstützung bestätigt", "unterstuetzung_bestaetigt", "bool", False),
|
||||||
|
("Vierteljährlicher Betrag", "vierteljaehrlicher_betrag", "decimal", False),
|
||||||
|
("Studiennachweis erforderlich", "studiennachweis_erforderlich", "bool", False),
|
||||||
|
("Letzter Studiennachweis", "letzter_studiennachweis", "date", False),
|
||||||
|
("Notizen", "notizen", "text", False),
|
||||||
|
("Aktiv", "aktiv", "bool", False),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
"paechter": {
|
||||||
|
"model": Paechter,
|
||||||
|
"label": "Pächter",
|
||||||
|
"unique_fields": ["vorname", "nachname"],
|
||||||
|
"fields": [
|
||||||
|
("Vorname", "vorname", "text", True),
|
||||||
|
("Nachname", "nachname", "text", True),
|
||||||
|
("Geburtsdatum", "geburtsdatum", "date", False),
|
||||||
|
("E-Mail", "email", "text", False),
|
||||||
|
("Telefon", "telefon", "text", False),
|
||||||
|
("IBAN", "iban", "text", False),
|
||||||
|
("Straße", "strasse", "text", False),
|
||||||
|
("PLZ", "plz", "text", False),
|
||||||
|
("Ort", "ort", "text", False),
|
||||||
|
("Personentyp", "personentyp", "text", False),
|
||||||
|
("Pachtnummer", "pachtnummer", "text", False),
|
||||||
|
("Pachtbeginn Erste", "pachtbeginn_erste", "date", False),
|
||||||
|
("Pachtende Letzte", "pachtende_letzte", "date", False),
|
||||||
|
("Pachtzins Aktuell", "pachtzins_aktuell", "decimal", False),
|
||||||
|
("Landw. Ausbildung", "landwirtschaftliche_ausbildung", "bool", False),
|
||||||
|
("Berufserfahrung Jahre", "berufserfahrung_jahre", "int", False),
|
||||||
|
("Spezialisierung", "spezialisierung", "text", False),
|
||||||
|
("Notizen", "notizen", "text", False),
|
||||||
|
("Aktiv", "aktiv", "bool", False),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
"laendereien": {
|
||||||
|
"model": Land,
|
||||||
|
"label": "Ländereien",
|
||||||
|
"unique_fields": ["lfd_nr"],
|
||||||
|
"unique_fields_alt": ["gemeinde", "gemarkung", "flur", "flurstueck"],
|
||||||
|
"fields": [
|
||||||
|
("Lfd Nr", "lfd_nr", "text", False),
|
||||||
|
("EW Nummer", "ew_nummer", "text", False),
|
||||||
|
("Grundbuchblatt", "grundbuchblatt", "text", False),
|
||||||
|
("ALKIS Kennzeichen", "alkis_kennzeichen", "text", False),
|
||||||
|
("Amtsgericht", "amtsgericht", "text", False),
|
||||||
|
("Gemeinde", "gemeinde", "text", False),
|
||||||
|
("Gemarkung", "gemarkung", "text", False),
|
||||||
|
("Flur", "flur", "text", False),
|
||||||
|
("Flurstück", "flurstueck", "text", False),
|
||||||
|
("Adresse", "adresse", "text", False),
|
||||||
|
("Größe qm", "groesse_qm", "decimal", False),
|
||||||
|
("Grünland qm", "gruenland_qm", "decimal", False),
|
||||||
|
("Acker qm", "acker_qm", "decimal", False),
|
||||||
|
("Wald qm", "wald_qm", "decimal", False),
|
||||||
|
("Sonstiges qm", "sonstiges_qm", "decimal", False),
|
||||||
|
("Verpachtete Gesamtfläche", "verpachtete_gesamtflaeche", "decimal", False),
|
||||||
|
("Verp Fläche aktuell", "verp_flaeche_aktuell", "decimal", False),
|
||||||
|
("Pächter Name", "paechter_name", "text", False),
|
||||||
|
("Pächter Anschrift", "paechter_anschrift", "text", False),
|
||||||
|
("Pachtbeginn", "pachtbeginn", "date", False),
|
||||||
|
("Pachtende", "pachtende", "date", False),
|
||||||
|
("Verlängerung Klausel", "verlaengerung_klausel", "text", False),
|
||||||
|
("Zahlungsweise", "zahlungsweise", "text", False),
|
||||||
|
("Pachtzins pro ha", "pachtzins_pro_ha", "decimal", False),
|
||||||
|
("Pachtzins pauschal", "pachtzins_pauschal", "decimal", False),
|
||||||
|
("USt Option", "ust_option", "bool", False),
|
||||||
|
("USt Satz", "ust_satz", "decimal", False),
|
||||||
|
("Grundsteuer Umlage", "grundsteuer_umlage", "bool", False),
|
||||||
|
("Versicherungen Umlage", "versicherungen_umlage", "bool", False),
|
||||||
|
("Verbandsbeiträge Umlage", "verbandsbeitraege_umlage", "bool", False),
|
||||||
|
("Jagdpacht Anteil Umlage", "jagdpacht_anteil_umlage", "bool", False),
|
||||||
|
("Anteil Grundsteuer", "anteil_grundsteuer", "decimal", False),
|
||||||
|
("Anteil LWK", "anteil_lwk", "decimal", False),
|
||||||
|
("Aktiv", "aktiv", "bool", False),
|
||||||
|
("Notizen", "notizen", "text", False),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
"foerderungen": {
|
||||||
|
"model": Foerderung,
|
||||||
|
"label": "Förderungen",
|
||||||
|
"unique_fields": [],
|
||||||
|
"fields": [
|
||||||
|
("Destinatär Vorname", "_destinataer_vorname", "text", True),
|
||||||
|
("Destinatär Nachname", "_destinataer_nachname", "text", True),
|
||||||
|
("Jahr", "jahr", "int", True),
|
||||||
|
("Betrag", "betrag", "decimal", True),
|
||||||
|
("Kategorie", "kategorie", "text", False),
|
||||||
|
("Status", "status", "text", False),
|
||||||
|
("Antragsdatum", "antragsdatum", "date", False),
|
||||||
|
("Entscheidungsdatum", "entscheidungsdatum", "date", False),
|
||||||
|
("Bemerkungen", "bemerkungen", "text", False),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
"konten": {
|
||||||
|
"model": StiftungsKonto,
|
||||||
|
"label": "Stiftungskonten",
|
||||||
|
"unique_fields": ["iban"],
|
||||||
|
"fields": [
|
||||||
|
("Kontoname", "kontoname", "text", True),
|
||||||
|
("Bank", "bank_name", "text", False),
|
||||||
|
("IBAN", "iban", "text", False),
|
||||||
|
("BIC", "bic", "text", False),
|
||||||
|
("Konto Typ", "konto_typ", "text", False),
|
||||||
|
("Saldo", "saldo", "decimal", False),
|
||||||
|
("Zinssatz", "zinssatz", "decimal", False),
|
||||||
|
("Aktiv", "aktiv", "bool", False),
|
||||||
|
("Notizen", "notizen", "text", False),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
"verwaltungskosten": {
|
||||||
|
"model": Verwaltungskosten,
|
||||||
|
"label": "Verwaltungskosten",
|
||||||
|
"unique_fields": [],
|
||||||
|
"fields": [
|
||||||
|
("Bezeichnung", "bezeichnung", "text", True),
|
||||||
|
("Kategorie", "kategorie", "text", False),
|
||||||
|
("Betrag", "betrag", "decimal", True),
|
||||||
|
("Datum", "datum", "date", True),
|
||||||
|
("Lieferant", "lieferant_firma", "text", False),
|
||||||
|
("Rechnungsnummer", "rechnungsnummer", "text", False),
|
||||||
|
("Status", "status", "text", False),
|
||||||
|
("Beschreibung", "beschreibung", "text", False),
|
||||||
|
("Notizen", "notizen", "text", False),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
"rentmeister": {
|
||||||
|
"model": Rentmeister,
|
||||||
|
"label": "Rentmeister",
|
||||||
|
"unique_fields": ["vorname", "nachname"],
|
||||||
|
"fields": [
|
||||||
|
("Anrede", "anrede", "text", False),
|
||||||
|
("Vorname", "vorname", "text", True),
|
||||||
|
("Nachname", "nachname", "text", True),
|
||||||
|
("Titel", "titel", "text", False),
|
||||||
|
("E-Mail", "email", "text", False),
|
||||||
|
("Telefon", "telefon", "text", False),
|
||||||
|
("Mobil", "mobil", "text", False),
|
||||||
|
("Straße", "strasse", "text", False),
|
||||||
|
("PLZ", "plz", "text", False),
|
||||||
|
("Ort", "ort", "text", False),
|
||||||
|
("IBAN", "iban", "text", False),
|
||||||
|
("BIC", "bic", "text", False),
|
||||||
|
("Bank", "bank_name", "text", False),
|
||||||
|
("Seit", "seit_datum", "date", False),
|
||||||
|
("Bis", "bis_datum", "date", False),
|
||||||
|
("Monatliche Vergütung", "monatliche_verguetung", "decimal", False),
|
||||||
|
("Km Pauschale", "km_pauschale", "decimal", False),
|
||||||
|
("Aktiv", "aktiv", "bool", False),
|
||||||
|
("Notizen", "notizen", "text", False),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Value parsing helpers
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _parse_bool(value):
|
||||||
|
if not value:
|
||||||
|
return None
|
||||||
|
v = str(value).strip().lower()
|
||||||
|
if v in ("true", "ja", "yes", "1", "wahr", "x"):
|
||||||
|
return True
|
||||||
|
if v in ("false", "nein", "no", "0", "falsch", ""):
|
||||||
|
return False
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_date(value):
|
||||||
|
if not value or not str(value).strip():
|
||||||
|
return None
|
||||||
|
v = str(value).strip()
|
||||||
|
for fmt in ("%d.%m.%Y", "%Y-%m-%d", "%d/%m/%Y"):
|
||||||
|
try:
|
||||||
|
return datetime.strptime(v, fmt).date()
|
||||||
|
except ValueError:
|
||||||
|
continue
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_decimal(value):
|
||||||
|
if not value or not str(value).strip():
|
||||||
|
return None
|
||||||
|
v = str(value).strip().replace(",", ".")
|
||||||
|
try:
|
||||||
|
return Decimal(v)
|
||||||
|
except (InvalidOperation, ValueError):
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_int(value):
|
||||||
|
if not value or not str(value).strip():
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
return int(str(value).strip().replace(",", "").replace(".", ""))
|
||||||
|
except ValueError:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_value(raw, field_type):
|
||||||
|
"""Parse a raw CSV string into the appropriate Python type."""
|
||||||
|
if field_type == "date":
|
||||||
|
return _parse_date(raw)
|
||||||
|
elif field_type == "decimal":
|
||||||
|
return _parse_decimal(raw)
|
||||||
|
elif field_type == "int":
|
||||||
|
return _parse_int(raw)
|
||||||
|
elif field_type == "bool":
|
||||||
|
return _parse_bool(raw)
|
||||||
|
else:
|
||||||
|
return str(raw).strip() if raw else None
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Views
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@login_required
|
||||||
|
def import_export_hub(request):
|
||||||
|
"""Unified import/export hub page."""
|
||||||
|
# Get recent imports for display
|
||||||
|
recent_imports = CSVImport.objects.all().order_by("-started_at")[:10]
|
||||||
|
|
||||||
|
# Count records per entity type
|
||||||
|
entity_counts = {}
|
||||||
|
for key, defn in EXPORT_DEFINITIONS.items():
|
||||||
|
try:
|
||||||
|
entity_counts[key] = defn["model"].objects.count()
|
||||||
|
except Exception:
|
||||||
|
entity_counts[key] = 0
|
||||||
|
|
||||||
|
export_types = [
|
||||||
|
{"key": k, "label": v["label"], "count": entity_counts.get(k, 0)}
|
||||||
|
for k, v in EXPORT_DEFINITIONS.items()
|
||||||
|
]
|
||||||
|
|
||||||
|
import_types = [
|
||||||
|
{"key": k, "label": v["label"]}
|
||||||
|
for k, v in IMPORT_FIELD_DEFINITIONS.items()
|
||||||
|
]
|
||||||
|
|
||||||
|
context = {
|
||||||
|
"export_types": export_types,
|
||||||
|
"import_types": import_types,
|
||||||
|
"recent_imports": recent_imports,
|
||||||
|
}
|
||||||
|
return render(request, "stiftung/import_export_hub.html", context)
|
||||||
|
|
||||||
|
|
||||||
|
@login_required
|
||||||
|
def csv_export(request):
|
||||||
|
"""Export any entity type as CSV."""
|
||||||
|
export_type = request.GET.get("type")
|
||||||
|
if export_type not in EXPORT_DEFINITIONS:
|
||||||
|
messages.error(request, "Unbekannter Export-Typ.")
|
||||||
|
return redirect("stiftung:import_export_hub")
|
||||||
|
|
||||||
|
defn = EXPORT_DEFINITIONS[export_type]
|
||||||
|
queryset = defn["queryset"]()
|
||||||
|
|
||||||
|
response = HttpResponse(content_type="text/csv; charset=utf-8")
|
||||||
|
response["Content-Disposition"] = f'attachment; filename="{export_type}_{timezone.now().strftime("%Y%m%d_%H%M")}.csv"'
|
||||||
|
# BOM for Excel compatibility
|
||||||
|
response.write("\ufeff")
|
||||||
|
|
||||||
|
writer = csv.writer(response, delimiter=";")
|
||||||
|
headers = [f[0] for f in defn["fields"]]
|
||||||
|
writer.writerow(headers)
|
||||||
|
|
||||||
|
for obj in queryset:
|
||||||
|
row = []
|
||||||
|
for _header, extractor in defn["fields"]:
|
||||||
|
try:
|
||||||
|
row.append(extractor(obj))
|
||||||
|
except Exception:
|
||||||
|
row.append("")
|
||||||
|
writer.writerow(row)
|
||||||
|
|
||||||
|
return response
|
||||||
|
|
||||||
|
|
||||||
|
@login_required
|
||||||
|
def csv_import_upload(request):
|
||||||
|
"""Step 1: Upload CSV file and show field mapping UI."""
|
||||||
|
if request.method != "POST":
|
||||||
|
return redirect("stiftung:import_export_hub")
|
||||||
|
|
||||||
|
import_type = request.POST.get("import_type")
|
||||||
|
csv_file = request.FILES.get("csv_file")
|
||||||
|
|
||||||
|
if not csv_file or not import_type:
|
||||||
|
messages.error(request, "Bitte wählen Sie einen Import-Typ und eine CSV-Datei aus.")
|
||||||
|
return redirect("stiftung:import_export_hub")
|
||||||
|
|
||||||
|
if not csv_file.name.lower().endswith(".csv"):
|
||||||
|
messages.error(request, "Bitte wählen Sie eine gültige CSV-Datei aus.")
|
||||||
|
return redirect("stiftung:import_export_hub")
|
||||||
|
|
||||||
|
if import_type not in IMPORT_FIELD_DEFINITIONS:
|
||||||
|
messages.error(request, "Unbekannter Import-Typ.")
|
||||||
|
return redirect("stiftung:import_export_hub")
|
||||||
|
|
||||||
|
defn = IMPORT_FIELD_DEFINITIONS[import_type]
|
||||||
|
|
||||||
|
try:
|
||||||
|
raw_bytes = csv_file.read()
|
||||||
|
# Try UTF-8 first, fallback to latin-1
|
||||||
|
try:
|
||||||
|
decoded = raw_bytes.decode("utf-8-sig")
|
||||||
|
except UnicodeDecodeError:
|
||||||
|
decoded = raw_bytes.decode("latin-1")
|
||||||
|
|
||||||
|
# Detect delimiter
|
||||||
|
first_line = decoded.split("\n")[0]
|
||||||
|
delimiter = ";" if ";" in first_line else ","
|
||||||
|
|
||||||
|
reader = csv.reader(io.StringIO(decoded), delimiter=delimiter)
|
||||||
|
csv_headers = next(reader)
|
||||||
|
csv_headers = [h.strip() for h in csv_headers]
|
||||||
|
|
||||||
|
# Read preview rows (up to 5)
|
||||||
|
preview_rows = []
|
||||||
|
for i, row in enumerate(reader):
|
||||||
|
if i >= 5:
|
||||||
|
break
|
||||||
|
preview_rows.append(row)
|
||||||
|
|
||||||
|
# Count remaining rows
|
||||||
|
remaining = sum(1 for _ in reader)
|
||||||
|
total_rows = len(preview_rows) + remaining
|
||||||
|
|
||||||
|
# Auto-match CSV headers to model fields
|
||||||
|
# Build mapping suggestions based on fuzzy matching
|
||||||
|
model_fields = defn["fields"] # list of (label, field_name, type, required)
|
||||||
|
auto_mapping = {}
|
||||||
|
|
||||||
|
for csv_idx, csv_header in enumerate(csv_headers):
|
||||||
|
csv_h_lower = csv_header.lower().replace("_", " ").replace("-", " ").strip()
|
||||||
|
best_match = ""
|
||||||
|
best_score = 0
|
||||||
|
|
||||||
|
for label, field_name, _ftype, _req in model_fields:
|
||||||
|
label_lower = label.lower().replace("_", " ").replace("-", " ").strip()
|
||||||
|
field_lower = field_name.lower().replace("_", " ").strip()
|
||||||
|
|
||||||
|
# Exact match
|
||||||
|
if csv_h_lower == label_lower or csv_h_lower == field_lower:
|
||||||
|
best_match = field_name
|
||||||
|
best_score = 100
|
||||||
|
break
|
||||||
|
|
||||||
|
# Partial match
|
||||||
|
if csv_h_lower in label_lower or label_lower in csv_h_lower:
|
||||||
|
score = 80
|
||||||
|
if score > best_score:
|
||||||
|
best_match = field_name
|
||||||
|
best_score = score
|
||||||
|
elif csv_h_lower in field_lower or field_lower in csv_h_lower:
|
||||||
|
score = 70
|
||||||
|
if score > best_score:
|
||||||
|
best_match = field_name
|
||||||
|
best_score = score
|
||||||
|
|
||||||
|
if best_score >= 70:
|
||||||
|
auto_mapping[str(csv_idx)] = best_match
|
||||||
|
|
||||||
|
# Build header_previews: list of dicts with header + first-row preview
|
||||||
|
header_previews = []
|
||||||
|
first_row = preview_rows[0] if preview_rows else []
|
||||||
|
for idx, header in enumerate(csv_headers):
|
||||||
|
preview_val = first_row[idx] if idx < len(first_row) else ""
|
||||||
|
header_previews.append({"header": header, "preview": preview_val})
|
||||||
|
|
||||||
|
# Store CSV data in session for step 2
|
||||||
|
request.session["csv_import_data"] = decoded
|
||||||
|
request.session["csv_import_delimiter"] = delimiter
|
||||||
|
request.session["csv_import_type"] = import_type
|
||||||
|
request.session["csv_import_filename"] = csv_file.name
|
||||||
|
request.session["csv_import_filesize"] = len(raw_bytes)
|
||||||
|
|
||||||
|
context = {
|
||||||
|
"import_type": import_type,
|
||||||
|
"import_label": defn["label"],
|
||||||
|
"header_previews": header_previews,
|
||||||
|
"model_fields": model_fields,
|
||||||
|
"preview_rows": preview_rows,
|
||||||
|
"total_rows": total_rows,
|
||||||
|
"filename": csv_file.name,
|
||||||
|
"auto_mapping_json": json.dumps(auto_mapping),
|
||||||
|
}
|
||||||
|
return render(request, "stiftung/csv_import_mapping.html", context)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
messages.error(request, f"Fehler beim Lesen der CSV-Datei: {str(e)}")
|
||||||
|
return redirect("stiftung:import_export_hub")
|
||||||
|
|
||||||
|
|
||||||
|
@login_required
|
||||||
|
def csv_import_execute(request):
|
||||||
|
"""Step 2: Execute the import with user-defined field mapping."""
|
||||||
|
if request.method != "POST":
|
||||||
|
return redirect("stiftung:import_export_hub")
|
||||||
|
|
||||||
|
import_type = request.session.get("csv_import_type")
|
||||||
|
csv_data = request.session.get("csv_import_data")
|
||||||
|
delimiter = request.session.get("csv_import_delimiter", ",")
|
||||||
|
filename = request.session.get("csv_import_filename", "unknown.csv")
|
||||||
|
filesize = request.session.get("csv_import_filesize", 0)
|
||||||
|
|
||||||
|
if not import_type or not csv_data:
|
||||||
|
messages.error(request, "Keine Import-Daten gefunden. Bitte starten Sie den Import erneut.")
|
||||||
|
return redirect("stiftung:import_export_hub")
|
||||||
|
|
||||||
|
if import_type not in IMPORT_FIELD_DEFINITIONS:
|
||||||
|
messages.error(request, "Unbekannter Import-Typ.")
|
||||||
|
return redirect("stiftung:import_export_hub")
|
||||||
|
|
||||||
|
defn = IMPORT_FIELD_DEFINITIONS[import_type]
|
||||||
|
|
||||||
|
# Import mode: merge (update existing), skip (skip existing), create (always new)
|
||||||
|
import_mode = request.POST.get("import_mode", "merge")
|
||||||
|
if import_mode not in ("merge", "skip", "create"):
|
||||||
|
import_mode = "merge"
|
||||||
|
|
||||||
|
# Parse field mapping from POST data
|
||||||
|
# Format: mapping_0=field_name, mapping_1=field_name, ...
|
||||||
|
reader = csv.reader(io.StringIO(csv_data), delimiter=delimiter)
|
||||||
|
csv_headers = next(reader)
|
||||||
|
csv_headers = [h.strip() for h in csv_headers]
|
||||||
|
|
||||||
|
field_mapping = {} # csv_index -> (model_field, field_type)
|
||||||
|
field_types = {f[1]: f[2] for f in defn["fields"]}
|
||||||
|
|
||||||
|
for i in range(len(csv_headers)):
|
||||||
|
mapped_field = request.POST.get(f"mapping_{i}", "")
|
||||||
|
if mapped_field and mapped_field != "__skip__":
|
||||||
|
ftype = field_types.get(mapped_field, "text")
|
||||||
|
field_mapping[i] = (mapped_field, ftype)
|
||||||
|
|
||||||
|
if not field_mapping:
|
||||||
|
messages.error(request, "Keine Felder zugeordnet. Bitte ordnen Sie mindestens ein Feld zu.")
|
||||||
|
return redirect("stiftung:import_export_hub")
|
||||||
|
|
||||||
|
# Check required fields - warn but don't block (per-row validation will handle it)
|
||||||
|
required_fields = {f[1] for f in defn["fields"] if f[3]}
|
||||||
|
mapped_fields = {v[0] for v in field_mapping.values()}
|
||||||
|
missing_required = required_fields - mapped_fields
|
||||||
|
missing_required = {f for f in missing_required if not f.startswith("_")}
|
||||||
|
|
||||||
|
if missing_required:
|
||||||
|
field_labels = {f[1]: f[0] for f in defn["fields"]}
|
||||||
|
missing_labels = [field_labels.get(f, f) for f in missing_required]
|
||||||
|
messages.warning(
|
||||||
|
request,
|
||||||
|
f"Hinweis: Pflichtfelder nicht zugeordnet: {', '.join(missing_labels)}. "
|
||||||
|
f"Zeilen ohne diese Daten werden übersprungen."
|
||||||
|
)
|
||||||
|
|
||||||
|
# Create import record
|
||||||
|
csv_import = CSVImport.objects.create(
|
||||||
|
import_type=import_type,
|
||||||
|
filename=filename,
|
||||||
|
file_size=filesize,
|
||||||
|
created_by=request.user.username if request.user.is_authenticated else "Unknown",
|
||||||
|
status="processing",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Process the import
|
||||||
|
model = defn["model"]
|
||||||
|
unique_fields = defn["unique_fields"]
|
||||||
|
unique_fields_alt = defn.get("unique_fields_alt", [])
|
||||||
|
total_rows = 0
|
||||||
|
imported_rows = 0
|
||||||
|
failed_rows = 0
|
||||||
|
skipped_rows = 0
|
||||||
|
error_log = []
|
||||||
|
|
||||||
|
reader = csv.reader(io.StringIO(csv_data), delimiter=delimiter)
|
||||||
|
next(reader) # Skip header
|
||||||
|
|
||||||
|
for row_num, row in enumerate(reader, start=2):
|
||||||
|
total_rows += 1
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Build data dict from mapping
|
||||||
|
data = {}
|
||||||
|
for csv_idx, (model_field, field_type) in field_mapping.items():
|
||||||
|
if csv_idx < len(row):
|
||||||
|
raw_value = row[csv_idx]
|
||||||
|
parsed = _parse_value(raw_value, field_type)
|
||||||
|
data[model_field] = parsed
|
||||||
|
|
||||||
|
# Special handling for Förderungen (link to Destinatär)
|
||||||
|
if import_type == "foerderungen":
|
||||||
|
vorname = data.pop("_destinataer_vorname", None)
|
||||||
|
nachname = data.pop("_destinataer_nachname", None)
|
||||||
|
if vorname and nachname:
|
||||||
|
dest = Destinataer.objects.filter(
|
||||||
|
vorname__iexact=vorname, nachname__iexact=nachname
|
||||||
|
).first()
|
||||||
|
if dest:
|
||||||
|
data["destinataer"] = dest
|
||||||
|
else:
|
||||||
|
error_log.append(
|
||||||
|
f"Zeile {row_num}: Destinatär '{vorname} {nachname}' nicht gefunden"
|
||||||
|
)
|
||||||
|
failed_rows += 1
|
||||||
|
continue
|
||||||
|
else:
|
||||||
|
error_log.append(f"Zeile {row_num}: Destinatär Vor-/Nachname erforderlich")
|
||||||
|
failed_rows += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Validate required fields
|
||||||
|
required_missing = []
|
||||||
|
for label, field_name, _ftype, required in defn["fields"]:
|
||||||
|
if required and not field_name.startswith("_"):
|
||||||
|
val = data.get(field_name)
|
||||||
|
if val is None or (isinstance(val, str) and not val.strip()):
|
||||||
|
required_missing.append(label)
|
||||||
|
|
||||||
|
if required_missing:
|
||||||
|
error_log.append(
|
||||||
|
f"Zeile {row_num}: Pflichtfelder leer: {', '.join(required_missing)}"
|
||||||
|
)
|
||||||
|
failed_rows += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Clean None values from data - don't set fields that weren't mapped
|
||||||
|
clean_data = {k: v for k, v in data.items() if v is not None and not k.startswith("_")}
|
||||||
|
|
||||||
|
# For fields not in clean_data, check if the DB column requires
|
||||||
|
# a value (NOT NULL without a model default). If so, provide a
|
||||||
|
# sensible zero-value so the INSERT doesn't fail.
|
||||||
|
for fname, ftype in ((f[1], f[2]) for f in defn["fields"]):
|
||||||
|
if fname.startswith("_") or fname in clean_data:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
mf = model._meta.get_field(fname)
|
||||||
|
if not mf.null and not mf.has_default():
|
||||||
|
if ftype == "decimal":
|
||||||
|
clean_data[fname] = Decimal("0")
|
||||||
|
elif ftype == "int":
|
||||||
|
clean_data[fname] = 0
|
||||||
|
elif ftype == "bool":
|
||||||
|
clean_data[fname] = False
|
||||||
|
elif ftype == "text":
|
||||||
|
# For unique text fields, generate a value
|
||||||
|
# instead of empty string to avoid unique violations
|
||||||
|
if mf.unique:
|
||||||
|
import uuid as _uuid
|
||||||
|
clean_data[fname] = f"AUTO-{_uuid.uuid4().hex[:8]}"
|
||||||
|
else:
|
||||||
|
clean_data[fname] = ""
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Try to find existing record using user-mapped data (not auto-generated defaults)
|
||||||
|
existing = None
|
||||||
|
|
||||||
|
if import_mode != "create":
|
||||||
|
# Use original user data (before defaults) for dedup lookup
|
||||||
|
for uf_set in ([unique_fields] if unique_fields else []) + ([unique_fields_alt] if unique_fields_alt else []):
|
||||||
|
if existing:
|
||||||
|
break
|
||||||
|
lookup = {}
|
||||||
|
for uf in uf_set:
|
||||||
|
val = data.get(uf)
|
||||||
|
if val and (not isinstance(val, str) or val.strip()):
|
||||||
|
lookup[f"{uf}__iexact"] = val if isinstance(val, str) else val
|
||||||
|
else:
|
||||||
|
lookup = None
|
||||||
|
break
|
||||||
|
if lookup:
|
||||||
|
existing = model.objects.filter(**lookup).first()
|
||||||
|
|
||||||
|
if existing:
|
||||||
|
if import_mode == "skip":
|
||||||
|
skipped_rows += 1
|
||||||
|
continue
|
||||||
|
else:
|
||||||
|
# Merge mode: update existing record with mapped values
|
||||||
|
for field, value in clean_data.items():
|
||||||
|
setattr(existing, field, value)
|
||||||
|
existing.save()
|
||||||
|
else:
|
||||||
|
model.objects.create(**clean_data)
|
||||||
|
|
||||||
|
imported_rows += 1
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
error_log.append(f"Zeile {row_num}: {str(e)}")
|
||||||
|
failed_rows += 1
|
||||||
|
|
||||||
|
# Determine status
|
||||||
|
if failed_rows == 0 and (imported_rows > 0 or skipped_rows > 0):
|
||||||
|
status = "completed"
|
||||||
|
elif imported_rows > 0 or skipped_rows > 0:
|
||||||
|
status = "partial"
|
||||||
|
elif total_rows == 0:
|
||||||
|
status = "completed"
|
||||||
|
else:
|
||||||
|
status = "failed"
|
||||||
|
|
||||||
|
# Build skip info for error log
|
||||||
|
if skipped_rows > 0:
|
||||||
|
error_log.insert(0, f"Übersprungen: {skipped_rows} bereits vorhandene Einträge")
|
||||||
|
|
||||||
|
# Update import record
|
||||||
|
csv_import.total_rows = total_rows
|
||||||
|
csv_import.imported_rows = imported_rows
|
||||||
|
csv_import.failed_rows = failed_rows
|
||||||
|
csv_import.error_log = "\n".join(error_log) if error_log else None
|
||||||
|
csv_import.status = status
|
||||||
|
csv_import.completed_at = timezone.now()
|
||||||
|
csv_import.save()
|
||||||
|
|
||||||
|
# Clean session
|
||||||
|
for key in ["csv_import_data", "csv_import_delimiter", "csv_import_type",
|
||||||
|
"csv_import_filename", "csv_import_filesize"]:
|
||||||
|
request.session.pop(key, None)
|
||||||
|
|
||||||
|
skip_info = f", {skipped_rows} übersprungen" if skipped_rows > 0 else ""
|
||||||
|
if status == "completed":
|
||||||
|
messages.success(
|
||||||
|
request,
|
||||||
|
f"Import erfolgreich! {imported_rows} Datensätze importiert{skip_info}.",
|
||||||
|
)
|
||||||
|
elif status == "partial":
|
||||||
|
messages.warning(
|
||||||
|
request,
|
||||||
|
f"Import teilweise erfolgreich. {imported_rows} importiert, {failed_rows} fehlgeschlagen{skip_info}.",
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
messages.error(
|
||||||
|
request,
|
||||||
|
f"Import fehlgeschlagen. {failed_rows} Zeilen konnten nicht importiert werden{skip_info}.",
|
||||||
|
)
|
||||||
|
|
||||||
|
return redirect("stiftung:import_export_hub")
|
||||||
@@ -14,8 +14,8 @@ import qrcode.image.svg
|
|||||||
from django.contrib import messages
|
from django.contrib import messages
|
||||||
from django.contrib.auth.decorators import login_required
|
from django.contrib.auth.decorators import login_required
|
||||||
from django.core.paginator import Paginator
|
from django.core.paginator import Paginator
|
||||||
from django.db.models import (Avg, Count, DecimalField, F, IntegerField, Q,
|
from django.db.models import (Avg, BigIntegerField, Count, DecimalField, F,
|
||||||
Sum, Value)
|
IntegerField, Q, Sum, Value)
|
||||||
from django.db.models.functions import Cast, Coalesce, NullIf, Replace
|
from django.db.models.functions import Cast, Coalesce, NullIf, Replace
|
||||||
from django.http import HttpResponse, JsonResponse
|
from django.http import HttpResponse, JsonResponse
|
||||||
from django.shortcuts import get_object_or_404, redirect, render
|
from django.shortcuts import get_object_or_404, redirect, render
|
||||||
@@ -274,24 +274,25 @@ def land_list(request):
|
|||||||
lands = lands.filter(aktiv=False)
|
lands = lands.filter(aktiv=False)
|
||||||
|
|
||||||
# Annotate with verpachtungsgrad and numeric casts for natural sorting
|
# Annotate with verpachtungsgrad and numeric casts for natural sorting
|
||||||
# Prepare numeric versions of textual fields by stripping common non-digits
|
# Use regexp_replace to strip ALL non-digit characters for safe integer casting
|
||||||
|
from django.db.models import Func
|
||||||
|
|
||||||
|
class RegexpReplace(Func):
|
||||||
|
function = "REGEXP_REPLACE"
|
||||||
|
template = "%(function)s(%(expressions)s, '[^0-9]', '', 'g')"
|
||||||
|
|
||||||
def digits_only(field_expr):
|
def digits_only(field_expr):
|
||||||
expr = Replace(field_expr, Value(" "), Value(""))
|
return RegexpReplace(field_expr)
|
||||||
expr = Replace(expr, Value("-"), Value(""))
|
|
||||||
expr = Replace(expr, Value("."), Value(""))
|
|
||||||
expr = Replace(expr, Value("/"), Value(""))
|
|
||||||
expr = Replace(expr, Value("L"), Value(""))
|
|
||||||
return expr
|
|
||||||
|
|
||||||
lands = lands.extra(
|
lands = lands.extra(
|
||||||
select={
|
select={
|
||||||
"verpachtungsgrad": "CASE WHEN groesse_qm > 0 THEN (verp_flaeche_aktuell / groesse_qm) * 100 ELSE 0 END"
|
"verpachtungsgrad": "CASE WHEN groesse_qm > 0 THEN (verp_flaeche_aktuell / groesse_qm) * 100 ELSE 0 END"
|
||||||
}
|
}
|
||||||
).annotate(
|
).annotate(
|
||||||
lfd_nr_num=Cast(NullIf(digits_only(F("lfd_nr")), Value("")), IntegerField()),
|
lfd_nr_num=Cast(NullIf(digits_only(F("lfd_nr")), Value("")), BigIntegerField()),
|
||||||
flur_num=Cast(NullIf(digits_only(F("flur")), Value("")), IntegerField()),
|
flur_num=Cast(NullIf(digits_only(F("flur")), Value("")), BigIntegerField()),
|
||||||
flurstueck_num=Cast(
|
flurstueck_num=Cast(
|
||||||
NullIf(digits_only(F("flurstueck")), Value("")), IntegerField()
|
NullIf(digits_only(F("flurstueck")), Value("")), BigIntegerField()
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
624
app/stiftung/views/portal.py
Normal file
624
app/stiftung/views/portal.py
Normal file
@@ -0,0 +1,624 @@
|
|||||||
|
"""
|
||||||
|
Portal-Views: Öffentlich zugängliche Seiten für Destinatäre (kein Login erforderlich).
|
||||||
|
|
||||||
|
Workflow Upload-Portal:
|
||||||
|
1. Destinatär erhält E-Mail mit Einmallink (Token)
|
||||||
|
2. GET /portal/upload/<token>/ → Formular anzeigen
|
||||||
|
3. POST /portal/upload/<token>/ → Dateien hochladen, Token einlösen
|
||||||
|
4. Redirect → /portal/upload/<token>/danke/
|
||||||
|
|
||||||
|
Workflow Onboarding-Portal (neue Destinatäre):
|
||||||
|
1. Verwaltung sendet OnboardingEinladung per E-Mail
|
||||||
|
2. GET/POST /portal/onboarding/<token>/schritt/<n>/ → je Schritt ein Formular
|
||||||
|
3. Schritte 1-5 via Session-State (kein Login)
|
||||||
|
4. Nach Schritt 5: Destinatär (unbestaetigt) anlegen + Stiftung benachrichtigen
|
||||||
|
|
||||||
|
Sicherheit:
|
||||||
|
- Token ist 64 Zeichen, kryptographisch sicher
|
||||||
|
- Einmalige Nutzung (abgeschlossen_am wird gesetzt)
|
||||||
|
- Automatische Ablaufzeit (30 Tage)
|
||||||
|
- CSRF-Schutz aktiv
|
||||||
|
"""
|
||||||
|
import hashlib
|
||||||
|
import logging
|
||||||
|
import mimetypes
|
||||||
|
import os
|
||||||
|
|
||||||
|
from django.contrib import messages
|
||||||
|
from django.http import Http404, HttpResponse
|
||||||
|
from django.shortcuts import get_object_or_404, redirect, render
|
||||||
|
from django.utils import timezone
|
||||||
|
from django.views.decorators.cache import never_cache
|
||||||
|
from django.views.decorators.http import require_http_methods
|
||||||
|
|
||||||
|
from stiftung.models import DokumentDatei, OnboardingEinladung, UploadToken, VierteljahresNachweis
|
||||||
|
|
||||||
|
|
||||||
|
def datenschutzerklaerung(request):
|
||||||
|
"""Datenschutzerklärung für das öffentliche Portal."""
|
||||||
|
return render(request, "portal/datenschutzerklaerung.html")
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# Erlaubte Dateitypen für Uploads
|
||||||
|
ERLAUBTE_MIME_TYPES = {
|
||||||
|
"application/pdf",
|
||||||
|
"image/jpeg",
|
||||||
|
"image/png",
|
||||||
|
"image/tiff",
|
||||||
|
}
|
||||||
|
MAX_DATEIGROESSE = 20 * 1024 * 1024 # 20 MB
|
||||||
|
|
||||||
|
|
||||||
|
def _get_client_ip(request):
|
||||||
|
"""Extrahiert die Client-IP-Adresse aus dem Request."""
|
||||||
|
x_forwarded_for = request.META.get("HTTP_X_FORWARDED_FOR")
|
||||||
|
if x_forwarded_for:
|
||||||
|
return x_forwarded_for.split(",")[0].strip()
|
||||||
|
return request.META.get("REMOTE_ADDR", "")
|
||||||
|
|
||||||
|
|
||||||
|
@never_cache
|
||||||
|
@require_http_methods(["GET", "POST"])
|
||||||
|
def upload_formular(request, token):
|
||||||
|
"""
|
||||||
|
Zeigt das Upload-Formular für einen Nachweis-Token an
|
||||||
|
und verarbeitet den Datei-Upload.
|
||||||
|
"""
|
||||||
|
upload_token = get_object_or_404(
|
||||||
|
UploadToken.objects.select_related("destinataer", "nachweis"),
|
||||||
|
token=token,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Token-Gültigkeitsprüfung
|
||||||
|
if not upload_token.ist_gueltig():
|
||||||
|
if upload_token.eingeloest_am is not None:
|
||||||
|
return render(
|
||||||
|
request,
|
||||||
|
"portal/upload_fehler.html",
|
||||||
|
{
|
||||||
|
"fehler_typ": "bereits_verwendet",
|
||||||
|
"message": "Dieser Upload-Link wurde bereits verwendet.",
|
||||||
|
},
|
||||||
|
status=410,
|
||||||
|
)
|
||||||
|
return render(
|
||||||
|
request,
|
||||||
|
"portal/upload_fehler.html",
|
||||||
|
{
|
||||||
|
"fehler_typ": "abgelaufen",
|
||||||
|
"message": "Dieser Upload-Link ist abgelaufen. "
|
||||||
|
"Bitte wenden Sie sich an die Stiftung.",
|
||||||
|
},
|
||||||
|
status=410,
|
||||||
|
)
|
||||||
|
|
||||||
|
destinataer = upload_token.destinataer
|
||||||
|
nachweis = upload_token.nachweis
|
||||||
|
halbjahr = 1 if nachweis.quartal in [1, 2] else 2
|
||||||
|
halbjahr_label = f"{halbjahr}. Halbjahr {nachweis.jahr}"
|
||||||
|
|
||||||
|
base_context = {
|
||||||
|
"token": upload_token,
|
||||||
|
"destinataer": destinataer,
|
||||||
|
"nachweis": nachweis,
|
||||||
|
"halbjahr_label": halbjahr_label,
|
||||||
|
"gueltig_bis": upload_token.gueltig_bis,
|
||||||
|
"max_dateigroesse_mb": MAX_DATEIGROESSE // (1024 * 1024),
|
||||||
|
}
|
||||||
|
|
||||||
|
if request.method == "GET":
|
||||||
|
return render(request, "portal/upload_formular.html", base_context)
|
||||||
|
|
||||||
|
# POST: Einwilligung prüfen
|
||||||
|
einwilligung = request.POST.get("einwilligung")
|
||||||
|
if not einwilligung:
|
||||||
|
ctx = {
|
||||||
|
**base_context,
|
||||||
|
"einwilligung_fehler": "Bitte erteilen Sie Ihre Einwilligung zur Datenverarbeitung, um fortzufahren.",
|
||||||
|
}
|
||||||
|
for kat in [
|
||||||
|
"studiennachweis", "einkommenssituation", "vermogenssituation", "weitere_dokumente"
|
||||||
|
]:
|
||||||
|
ctx[f"{kat}_text"] = request.POST.get(f"{kat}_text", "")
|
||||||
|
return render(request, "portal/upload_formular.html", ctx)
|
||||||
|
|
||||||
|
# POST: Kategorisierte Dateien und Texte verarbeiten
|
||||||
|
# Kategorien mit ihren DMS-Kontext-Werten und FK-Feldern auf VierteljahresNachweis
|
||||||
|
KATEGORIEN = [
|
||||||
|
{
|
||||||
|
"key": "studiennachweis",
|
||||||
|
"label": "Studiennachweis",
|
||||||
|
"kontext": "studiennachweis",
|
||||||
|
"dms_fk_field": "studiennachweis_dms_dokument",
|
||||||
|
"text_field": "studiennachweis_bemerkung",
|
||||||
|
"bestaetigt_field": "studiennachweis_eingereicht",
|
||||||
|
"pflicht": True,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "einkommenssituation",
|
||||||
|
"label": "Einkommenssituation",
|
||||||
|
"kontext": "einkommenssituation",
|
||||||
|
"dms_fk_field": "einkommenssituation_dms_dokument",
|
||||||
|
"text_field": "einkommenssituation_text",
|
||||||
|
"bestaetigt_field": "einkommenssituation_bestaetigt",
|
||||||
|
"pflicht": True,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "vermogenssituation",
|
||||||
|
"label": "Vermögenssituation",
|
||||||
|
"kontext": "vermoegenssituation",
|
||||||
|
"dms_fk_field": "vermogenssituation_dms_dokument",
|
||||||
|
"text_field": "vermogenssituation_text",
|
||||||
|
"bestaetigt_field": "vermogenssituation_bestaetigt",
|
||||||
|
"pflicht": True,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "weitere_dokumente",
|
||||||
|
"label": "Weitere Dokumente",
|
||||||
|
"kontext": "sonstiges",
|
||||||
|
"dms_fk_field": None,
|
||||||
|
"text_field": "weitere_dokumente_beschreibung",
|
||||||
|
"bestaetigt_field": None,
|
||||||
|
"pflicht": False,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
fehler_liste = []
|
||||||
|
gespeicherte_dokumente = []
|
||||||
|
nachweis_update_fields = []
|
||||||
|
|
||||||
|
for kat in KATEGORIEN:
|
||||||
|
datei = request.FILES.get(kat["key"])
|
||||||
|
text = request.POST.get(f"{kat['key']}_text", "").strip()
|
||||||
|
|
||||||
|
# Pflichtprüfung: mindestens Datei oder Text
|
||||||
|
if kat["pflicht"] and not datei and not text:
|
||||||
|
fehler_liste.append(
|
||||||
|
f'Bitte laden Sie für „{kat["label"]}" eine Datei hoch '
|
||||||
|
f"oder geben Sie einen Texteintrag ein."
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Datei verarbeiten
|
||||||
|
if datei:
|
||||||
|
if datei.size > MAX_DATEIGROESSE:
|
||||||
|
fehler_liste.append(
|
||||||
|
f'„{kat["label"]}": Datei „{datei.name}" ist zu groß (max. 20 MB).'
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
mime_type, _ = mimetypes.guess_type(datei.name)
|
||||||
|
if mime_type not in ERLAUBTE_MIME_TYPES:
|
||||||
|
fehler_liste.append(
|
||||||
|
f'„{kat["label"]}": Dateiformat von „{datei.name}" '
|
||||||
|
f"nicht erlaubt (PDF, JPG, PNG, TIFF)."
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
try:
|
||||||
|
dok = DokumentDatei(
|
||||||
|
titel=f"{kat['label']} {halbjahr_label}: {os.path.splitext(datei.name)[0]}",
|
||||||
|
beschreibung=f"Hochgeladen über Upload-Portal am {timezone.now().strftime('%d.%m.%Y')}",
|
||||||
|
kontext=kat["kontext"],
|
||||||
|
datei=datei,
|
||||||
|
dateiname_original=datei.name,
|
||||||
|
dateityp=mime_type or "application/octet-stream",
|
||||||
|
dateigroesse=datei.size,
|
||||||
|
destinataer=destinataer,
|
||||||
|
)
|
||||||
|
dok.save()
|
||||||
|
nachweis.nachweis_dokumente.add(dok)
|
||||||
|
gespeicherte_dokumente.append(dok)
|
||||||
|
|
||||||
|
# Kategorie-spezifische FK setzen
|
||||||
|
if kat["dms_fk_field"]:
|
||||||
|
setattr(nachweis, kat["dms_fk_field"], dok)
|
||||||
|
nachweis_update_fields.append(kat["dms_fk_field"])
|
||||||
|
|
||||||
|
# Bestätigt-Flag setzen
|
||||||
|
if kat["bestaetigt_field"]:
|
||||||
|
setattr(nachweis, kat["bestaetigt_field"], True)
|
||||||
|
nachweis_update_fields.append(kat["bestaetigt_field"])
|
||||||
|
except Exception as exc:
|
||||||
|
logger.exception("Fehler beim Speichern von %s (%s): %s", datei.name, kat["label"], exc)
|
||||||
|
fehler_liste.append(
|
||||||
|
f'Fehler beim Speichern von „{datei.name}" ({kat["label"]}).'
|
||||||
|
)
|
||||||
|
|
||||||
|
# Text verarbeiten
|
||||||
|
if text:
|
||||||
|
if kat["text_field"]:
|
||||||
|
setattr(nachweis, kat["text_field"], text)
|
||||||
|
nachweis_update_fields.append(kat["text_field"])
|
||||||
|
# Auch bei reinem Text: bestätigt setzen
|
||||||
|
if not datei and kat["bestaetigt_field"]:
|
||||||
|
setattr(nachweis, kat["bestaetigt_field"], True)
|
||||||
|
nachweis_update_fields.append(kat["bestaetigt_field"])
|
||||||
|
|
||||||
|
# Bei Pflicht-Fehlern und keinen gespeicherten Dokumenten: Formular erneut anzeigen
|
||||||
|
if fehler_liste and not gespeicherte_dokumente:
|
||||||
|
ctx = {**base_context, "fehler": " ".join(fehler_liste)}
|
||||||
|
# Texte wieder einfüllen
|
||||||
|
for kat in KATEGORIEN:
|
||||||
|
ctx[f"{kat['key']}_text"] = request.POST.get(f"{kat['key']}_text", "")
|
||||||
|
return render(request, "portal/upload_formular.html", ctx)
|
||||||
|
|
||||||
|
# Nachweis-Felder speichern
|
||||||
|
if nachweis_update_fields:
|
||||||
|
nachweis.save(update_fields=list(set(nachweis_update_fields)))
|
||||||
|
|
||||||
|
# DSGVO-Einwilligung protokollieren (Art. 7 Abs. 1 DSGVO)
|
||||||
|
upload_token.einwilligung_erteilt_am = timezone.now()
|
||||||
|
upload_token.save(update_fields=["einwilligung_erteilt_am"])
|
||||||
|
|
||||||
|
# Token einlösen
|
||||||
|
ip = _get_client_ip(request)
|
||||||
|
upload_token.einloesen(ip_address=ip)
|
||||||
|
|
||||||
|
# Nachweis-Status aktualisieren
|
||||||
|
if nachweis.status in ("offen", "nachbesserung"):
|
||||||
|
nachweis.status = "eingereicht"
|
||||||
|
nachweis.eingereicht_am = timezone.now()
|
||||||
|
nachweis.save(update_fields=["status", "eingereicht_am"])
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
"Upload-Portal: %d Datei(en) für Destinatär %s (Nachweis %s) gespeichert.",
|
||||||
|
len(gespeicherte_dokumente),
|
||||||
|
destinataer.id,
|
||||||
|
nachweis.id,
|
||||||
|
)
|
||||||
|
|
||||||
|
return redirect("portal:upload_danke", token=token)
|
||||||
|
|
||||||
|
|
||||||
|
@never_cache
|
||||||
|
def upload_danke(request, token):
|
||||||
|
"""Bestätigungsseite nach erfolgreichem Upload."""
|
||||||
|
upload_token = get_object_or_404(
|
||||||
|
UploadToken.objects.select_related("destinataer", "nachweis"),
|
||||||
|
token=token,
|
||||||
|
)
|
||||||
|
nachweis = upload_token.nachweis
|
||||||
|
halbjahr = 1 if nachweis.quartal in [1, 2] else 2
|
||||||
|
halbjahr_label = f"{halbjahr}. Halbjahr {nachweis.jahr}"
|
||||||
|
|
||||||
|
return render(
|
||||||
|
request,
|
||||||
|
"portal/upload_danke.html",
|
||||||
|
{
|
||||||
|
"destinataer": upload_token.destinataer,
|
||||||
|
"nachweis": nachweis,
|
||||||
|
"halbjahr_label": halbjahr_label,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Onboarding-Portal: Mehrstufiges Antragsformular für neue Destinatäre
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
ONBOARDING_SCHRITTE = 5
|
||||||
|
SESSION_KEY = "onboarding_data"
|
||||||
|
|
||||||
|
ERLAUBTE_MIME_TYPES_ONBOARDING = {
|
||||||
|
"application/pdf",
|
||||||
|
"image/jpeg",
|
||||||
|
"image/png",
|
||||||
|
"image/tiff",
|
||||||
|
}
|
||||||
|
MAX_DATEIGROESSE_ONBOARDING = 20 * 1024 * 1024 # 20 MB
|
||||||
|
|
||||||
|
|
||||||
|
def _get_onboarding_einladung(token):
|
||||||
|
"""Holt und validiert eine OnboardingEinladung anhand des Tokens."""
|
||||||
|
try:
|
||||||
|
einladung = OnboardingEinladung.objects.get(token=token)
|
||||||
|
except OnboardingEinladung.DoesNotExist:
|
||||||
|
return None, "nicht_gefunden"
|
||||||
|
if not einladung.ist_gueltig():
|
||||||
|
if einladung.status == "abgeschlossen":
|
||||||
|
return None, "bereits_abgeschlossen"
|
||||||
|
return None, "abgelaufen"
|
||||||
|
return einladung, None
|
||||||
|
|
||||||
|
|
||||||
|
def _onboarding_fehler(request, fehler_typ):
|
||||||
|
"""Rendert die Fehlerseite für das Onboarding-Portal."""
|
||||||
|
return render(
|
||||||
|
request,
|
||||||
|
"portal/onboarding_fehler.html",
|
||||||
|
{"fehler_typ": fehler_typ},
|
||||||
|
status=410,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@never_cache
|
||||||
|
def onboarding_schritt(request, token, schritt=1):
|
||||||
|
"""
|
||||||
|
Mehrstufiges Onboarding-Formular für neue Destinatäre.
|
||||||
|
Schritt 1-5, sessionbasiert, kein Login erforderlich.
|
||||||
|
"""
|
||||||
|
einladung, fehler = _get_onboarding_einladung(token)
|
||||||
|
if fehler:
|
||||||
|
return _onboarding_fehler(request, fehler)
|
||||||
|
|
||||||
|
schritt = int(schritt)
|
||||||
|
if schritt < 1 or schritt > ONBOARDING_SCHRITTE:
|
||||||
|
return redirect("portal:onboarding_schritt", token=token, schritt=1)
|
||||||
|
|
||||||
|
session_key = f"{SESSION_KEY}_{token}"
|
||||||
|
data = request.session.get(session_key, {})
|
||||||
|
|
||||||
|
# Navigationspfade: Zurück-Button
|
||||||
|
if request.method == "POST" and request.POST.get("aktion") == "zurueck" and schritt > 1:
|
||||||
|
return redirect("portal:onboarding_schritt", token=token, schritt=schritt - 1)
|
||||||
|
|
||||||
|
if request.method == "POST":
|
||||||
|
if schritt == 1:
|
||||||
|
return _onboarding_schritt1_post(request, token, einladung, data, session_key)
|
||||||
|
elif schritt == 2:
|
||||||
|
return _onboarding_schritt2_post(request, token, einladung, data, session_key)
|
||||||
|
elif schritt == 3:
|
||||||
|
return _onboarding_schritt3_post(request, token, einladung, data, session_key)
|
||||||
|
elif schritt == 4:
|
||||||
|
return _onboarding_schritt4_post(request, token, einladung, data, session_key)
|
||||||
|
elif schritt == 5:
|
||||||
|
return _onboarding_schritt5_post(request, token, einladung, data, session_key)
|
||||||
|
|
||||||
|
# GET: Formular anzeigen
|
||||||
|
context = {
|
||||||
|
"einladung": einladung,
|
||||||
|
"token": token,
|
||||||
|
"schritt": schritt,
|
||||||
|
"schritte_gesamt": ONBOARDING_SCHRITTE,
|
||||||
|
"data": data,
|
||||||
|
}
|
||||||
|
return render(request, f"portal/onboarding_schritt{schritt}.html", context)
|
||||||
|
|
||||||
|
|
||||||
|
def _onboarding_schritt1_post(request, token, einladung, data, session_key):
|
||||||
|
"""Schritt 1: Datenschutzerklärung + Erklärung des Leistungsempfängers."""
|
||||||
|
if not request.POST.get("dse_zustimmung"):
|
||||||
|
context = {
|
||||||
|
"einladung": einladung,
|
||||||
|
"token": token,
|
||||||
|
"schritt": 1,
|
||||||
|
"schritte_gesamt": ONBOARDING_SCHRITTE,
|
||||||
|
"data": data,
|
||||||
|
"fehler": "Bitte stimmen Sie der Datenschutzerklärung zu, um fortzufahren.",
|
||||||
|
}
|
||||||
|
return render(request, "portal/onboarding_schritt1.html", context)
|
||||||
|
if not request.POST.get("merkblatt_zustimmung"):
|
||||||
|
context = {
|
||||||
|
"einladung": einladung,
|
||||||
|
"token": token,
|
||||||
|
"schritt": 1,
|
||||||
|
"schritte_gesamt": ONBOARDING_SCHRITTE,
|
||||||
|
"data": data,
|
||||||
|
"fehler": "Bitte bestätigen Sie die Erklärung des Leistungsempfängers.",
|
||||||
|
}
|
||||||
|
return render(request, "portal/onboarding_schritt1.html", context)
|
||||||
|
|
||||||
|
data["schritt1"] = {
|
||||||
|
"dse_zustimmung": True,
|
||||||
|
"dse_zeitstempel": timezone.now().isoformat(),
|
||||||
|
"merkblatt_zustimmung": True,
|
||||||
|
}
|
||||||
|
request.session[session_key] = data
|
||||||
|
return redirect("portal:onboarding_schritt", token=token, schritt=2)
|
||||||
|
|
||||||
|
|
||||||
|
def _onboarding_schritt2_post(request, token, einladung, data, session_key):
|
||||||
|
"""Schritt 2: Persönliche Daten (Merkblatt 1-4)."""
|
||||||
|
pflichtfelder = ["vorname", "nachname", "geburtsdatum", "strasse", "plz", "ort",
|
||||||
|
"email", "telefon", "verwandtschaftsverhaeltnis"]
|
||||||
|
fehlende = [f for f in pflichtfelder if not request.POST.get(f, "").strip()]
|
||||||
|
|
||||||
|
if fehlende:
|
||||||
|
context = {
|
||||||
|
"einladung": einladung,
|
||||||
|
"token": token,
|
||||||
|
"schritt": 2,
|
||||||
|
"schritte_gesamt": ONBOARDING_SCHRITTE,
|
||||||
|
"data": data,
|
||||||
|
"post_data": request.POST,
|
||||||
|
"fehler": "Bitte füllen Sie alle Pflichtfelder aus.",
|
||||||
|
"fehlende_felder": fehlende,
|
||||||
|
}
|
||||||
|
return render(request, "portal/onboarding_schritt2.html", context)
|
||||||
|
|
||||||
|
data["schritt2"] = {
|
||||||
|
"vorname": request.POST["vorname"].strip(),
|
||||||
|
"nachname": request.POST["nachname"].strip(),
|
||||||
|
"geburtsdatum": request.POST["geburtsdatum"].strip(),
|
||||||
|
"strasse": request.POST["strasse"].strip(),
|
||||||
|
"plz": request.POST["plz"].strip(),
|
||||||
|
"ort": request.POST["ort"].strip(),
|
||||||
|
"email": request.POST["email"].strip(),
|
||||||
|
"telefon": request.POST["telefon"].strip(),
|
||||||
|
"handynummer": request.POST.get("handynummer", "").strip(),
|
||||||
|
"verwandtschaftsverhaeltnis": request.POST["verwandtschaftsverhaeltnis"].strip(),
|
||||||
|
"familienzweig": request.POST.get("familienzweig", "").strip(),
|
||||||
|
}
|
||||||
|
request.session[session_key] = data
|
||||||
|
return redirect("portal:onboarding_schritt", token=token, schritt=3)
|
||||||
|
|
||||||
|
|
||||||
|
def _onboarding_schritt3_post(request, token, einladung, data, session_key):
|
||||||
|
"""Schritt 3: Ausbildung/Studium (Merkblatt 5-6)."""
|
||||||
|
in_ausbildung = request.POST.get("in_ausbildung") == "ja"
|
||||||
|
|
||||||
|
data["schritt3"] = {
|
||||||
|
"in_ausbildung": in_ausbildung,
|
||||||
|
"ausbildungsart": request.POST.get("ausbildungsart", "").strip(),
|
||||||
|
"institution": request.POST.get("institution", "").strip(),
|
||||||
|
"voraussichtliche_dauer": request.POST.get("voraussichtliche_dauer", "").strip(),
|
||||||
|
}
|
||||||
|
request.session[session_key] = data
|
||||||
|
return redirect("portal:onboarding_schritt", token=token, schritt=4)
|
||||||
|
|
||||||
|
|
||||||
|
def _onboarding_schritt4_post(request, token, einladung, data, session_key):
|
||||||
|
"""Schritt 4: Finanzielle Situation (Merkblatt 7-12)."""
|
||||||
|
data["schritt4"] = {
|
||||||
|
"haushaltstyp": request.POST.get("haushaltstyp", "").strip(),
|
||||||
|
"haushaltsgroesse": request.POST.get("haushaltsgroesse", "").strip(),
|
||||||
|
"monatliche_bezuege": request.POST.get("monatliche_bezuege", "").strip(),
|
||||||
|
"bezuege_art": request.POST.get("bezuege_art", "").strip(),
|
||||||
|
"unterhalt": request.POST.get("unterhalt", "").strip(),
|
||||||
|
"miete_heizung": request.POST.get("miete_heizung", "").strip(),
|
||||||
|
"vermoegen": request.POST.get("vermoegen", "").strip(),
|
||||||
|
"lebensunterhalt_aufwendungen": request.POST.get("lebensunterhalt_aufwendungen", "").strip(),
|
||||||
|
}
|
||||||
|
request.session[session_key] = data
|
||||||
|
return redirect("portal:onboarding_schritt", token=token, schritt=5)
|
||||||
|
|
||||||
|
|
||||||
|
def _onboarding_schritt5_post(request, token, einladung, data, session_key):
|
||||||
|
"""Schritt 5: Zusammenfassung, Datei-Upload und Bestätigung."""
|
||||||
|
if not request.POST.get("finale_bestaetigung"):
|
||||||
|
context = {
|
||||||
|
"einladung": einladung,
|
||||||
|
"token": token,
|
||||||
|
"schritt": 5,
|
||||||
|
"schritte_gesamt": ONBOARDING_SCHRITTE,
|
||||||
|
"data": data,
|
||||||
|
"fehler": "Bitte bestätigen Sie die Richtigkeit Ihrer Angaben.",
|
||||||
|
}
|
||||||
|
return render(request, "portal/onboarding_schritt5.html", context)
|
||||||
|
|
||||||
|
# Dateien prüfen und im DMS speichern (werden dem neuen Destinatär zugeordnet)
|
||||||
|
from stiftung.models import Destinataer, DokumentDatei
|
||||||
|
|
||||||
|
schritt2 = data.get("schritt2", {})
|
||||||
|
schritt3 = data.get("schritt3", {})
|
||||||
|
|
||||||
|
# Neuen Destinatär anlegen (unbestätigt – 4-Augen-Prinzip)
|
||||||
|
try:
|
||||||
|
import datetime
|
||||||
|
geb_str = schritt2.get("geburtsdatum", "")
|
||||||
|
geburtsdatum = None
|
||||||
|
if geb_str:
|
||||||
|
try:
|
||||||
|
geburtsdatum = datetime.date.fromisoformat(geb_str)
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
destinataer = Destinataer(
|
||||||
|
vorname=schritt2.get("vorname", ""),
|
||||||
|
nachname=schritt2.get("nachname", ""),
|
||||||
|
geburtsdatum=geburtsdatum,
|
||||||
|
email=schritt2.get("email", ""),
|
||||||
|
telefon=schritt2.get("telefon", ""),
|
||||||
|
strasse=schritt2.get("strasse", ""),
|
||||||
|
plz=schritt2.get("plz", ""),
|
||||||
|
ort=schritt2.get("ort", ""),
|
||||||
|
familienzweig=schritt2.get("familienzweig") or "anderer",
|
||||||
|
unterstuetzung_bestaetigt=False,
|
||||||
|
aktiv=False, # Erst nach Vorstandsfreigabe aktivieren
|
||||||
|
)
|
||||||
|
destinataer.save()
|
||||||
|
except Exception as exc:
|
||||||
|
logger.exception("Fehler beim Anlegen des Destinatärs aus Onboarding: %s", exc)
|
||||||
|
context = {
|
||||||
|
"einladung": einladung,
|
||||||
|
"token": token,
|
||||||
|
"schritt": 5,
|
||||||
|
"schritte_gesamt": ONBOARDING_SCHRITTE,
|
||||||
|
"data": data,
|
||||||
|
"fehler": "Technischer Fehler beim Speichern. Bitte versuchen Sie es erneut.",
|
||||||
|
}
|
||||||
|
return render(request, "portal/onboarding_schritt5.html", context)
|
||||||
|
|
||||||
|
# Hochgeladene Dokumente im DMS speichern
|
||||||
|
dms_dokumente_gespeichert = []
|
||||||
|
for datei_key, datei in request.FILES.items():
|
||||||
|
if datei.size > MAX_DATEIGROESSE_ONBOARDING:
|
||||||
|
continue
|
||||||
|
mime_type, _ = mimetypes.guess_type(datei.name)
|
||||||
|
if mime_type not in ERLAUBTE_MIME_TYPES_ONBOARDING:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
dok = DokumentDatei(
|
||||||
|
titel=f"Onboarding-Dokument: {os.path.splitext(datei.name)[0]}",
|
||||||
|
beschreibung=f"Onboarding von {destinataer.vorname} {destinataer.nachname}",
|
||||||
|
kontext="onboarding",
|
||||||
|
datei=datei,
|
||||||
|
dateiname_original=datei.name,
|
||||||
|
dateityp=mime_type or "application/octet-stream",
|
||||||
|
dateigroesse=datei.size,
|
||||||
|
destinataer=destinataer,
|
||||||
|
)
|
||||||
|
dok.save()
|
||||||
|
dms_dokumente_gespeichert.append(dok)
|
||||||
|
except Exception as exc:
|
||||||
|
logger.exception("Fehler beim Speichern von Onboarding-Dokument %s: %s", datei.name, exc)
|
||||||
|
|
||||||
|
# Einladung als abgeschlossen markieren
|
||||||
|
einladung.abgeschlossen_am = timezone.now()
|
||||||
|
einladung.status = "abgeschlossen"
|
||||||
|
einladung.destinataer = destinataer
|
||||||
|
einladung.save(update_fields=["abgeschlossen_am", "status", "destinataer"])
|
||||||
|
|
||||||
|
# Interne Benachrichtigung: E-Mail an Stiftung
|
||||||
|
_benachrichtige_stiftung_onboarding(destinataer, einladung, data)
|
||||||
|
|
||||||
|
# Session aufräumen
|
||||||
|
if session_key in request.session:
|
||||||
|
del request.session[session_key]
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
"Onboarding abgeschlossen: Destinatär %s angelegt (Einladung %s), %d Dokumente.",
|
||||||
|
destinataer.id,
|
||||||
|
einladung.id,
|
||||||
|
len(dms_dokumente_gespeichert),
|
||||||
|
)
|
||||||
|
|
||||||
|
return redirect("portal:onboarding_danke", token=token)
|
||||||
|
|
||||||
|
|
||||||
|
def _benachrichtige_stiftung_onboarding(destinataer, einladung, data):
|
||||||
|
"""Sendet eine interne Benachrichtigungs-E-Mail nach Abschluss des Onboardings."""
|
||||||
|
from django.conf import settings
|
||||||
|
from django.core.mail import EmailMessage
|
||||||
|
|
||||||
|
from stiftung.utils.config import get_config
|
||||||
|
|
||||||
|
empfaenger = get_config("notification_email") or getattr(settings, "STIFTUNG_NOTIFICATION_EMAIL", settings.DEFAULT_FROM_EMAIL)
|
||||||
|
subject = f"Neues Onboarding abgeschlossen: {destinataer.vorname} {destinataer.nachname}"
|
||||||
|
body = (
|
||||||
|
f"Ein neues Onboarding-Verfahren wurde abgeschlossen.\n\n"
|
||||||
|
f"Name: {destinataer.vorname} {destinataer.nachname}\n"
|
||||||
|
f"E-Mail: {destinataer.email}\n"
|
||||||
|
f"Einladung: {einladung.id}\n\n"
|
||||||
|
f"Bitte prüfen und freigeben:\n"
|
||||||
|
f"{getattr(settings, 'SITE_URL', 'https://vhtv-stiftung.de')}"
|
||||||
|
f"/destinataere/{destinataer.id}/\n\n"
|
||||||
|
f"Der Destinatär ist noch NICHT aktiv (unterstuetzung_bestaetigt=False).\n"
|
||||||
|
f"Freigabe durch den Vorstand erforderlich.\n"
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
from_email = get_config("smtp_from_email") or settings.DEFAULT_FROM_EMAIL
|
||||||
|
EmailMessage(subject, body, from_email, [empfaenger]).send()
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning("Onboarding-Benachrichtigung konnte nicht gesendet werden: %s", exc)
|
||||||
|
|
||||||
|
|
||||||
|
@never_cache
|
||||||
|
def onboarding_danke(request, token):
|
||||||
|
"""Abschlussseite nach erfolgreichem Onboarding."""
|
||||||
|
try:
|
||||||
|
einladung = OnboardingEinladung.objects.select_related("destinataer").get(
|
||||||
|
token=token, status="abgeschlossen"
|
||||||
|
)
|
||||||
|
except OnboardingEinladung.DoesNotExist:
|
||||||
|
return render(
|
||||||
|
request,
|
||||||
|
"portal/onboarding_fehler.html",
|
||||||
|
{"fehler_typ": "nicht_gefunden"},
|
||||||
|
status=404,
|
||||||
|
)
|
||||||
|
return render(
|
||||||
|
request,
|
||||||
|
"portal/onboarding_danke.html",
|
||||||
|
{"einladung": einladung},
|
||||||
|
)
|
||||||
@@ -1878,7 +1878,50 @@ def email_settings(request):
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
imap_settings = AppConfiguration.objects.filter(category="email", is_active=True).order_by("order")
|
# Ensure SMTP settings exist in DB (auto-init)
|
||||||
|
smtp_defaults = [
|
||||||
|
("smtp_host", "SMTP Server", "Hostname des SMTP-Servers (z.B. smtp.ionos.de)", "smtp.ionos.de", "text", 10),
|
||||||
|
("smtp_port", "SMTP Port", "Port des SMTP-Servers (465 für SSL, 587 für STARTTLS)", "465", "number", 11),
|
||||||
|
("smtp_user", "SMTP Benutzername", "Benutzername / E-Mail-Adresse für die SMTP-Anmeldung", "", "text", 12),
|
||||||
|
("smtp_password", "SMTP Passwort", "Passwort für die SMTP-Anmeldung", "", "password", 13),
|
||||||
|
("smtp_use_ssl", "SSL/TLS verwenden (SMTP)", "Sichere Verbindung zum SMTP-Server (empfohlen für Port 465)", "True", "boolean", 14),
|
||||||
|
("smtp_from_email", "Absenderadresse", "Absenderadresse für ausgehende E-Mails", "buero@vhtv-stiftung.de", "text", 15),
|
||||||
|
]
|
||||||
|
for key, name, desc, default, stype, order in smtp_defaults:
|
||||||
|
AppConfiguration.objects.get_or_create(
|
||||||
|
key=key,
|
||||||
|
defaults={
|
||||||
|
"display_name": name,
|
||||||
|
"description": desc,
|
||||||
|
"value": default,
|
||||||
|
"default_value": default,
|
||||||
|
"setting_type": stype,
|
||||||
|
"category": "email",
|
||||||
|
"order": order,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
# Ensure notification settings exist in DB (auto-init)
|
||||||
|
notification_defaults = [
|
||||||
|
("notification_email", "Benachrichtigungs-E-Mail", "Empfänger für interne Benachrichtigungen (z.B. neue Onboardings). Wenn leer, wird die Absenderadresse verwendet.", "", "text", 20),
|
||||||
|
]
|
||||||
|
for key, name, desc, default, stype, order in notification_defaults:
|
||||||
|
AppConfiguration.objects.get_or_create(
|
||||||
|
key=key,
|
||||||
|
defaults={
|
||||||
|
"display_name": name,
|
||||||
|
"description": desc,
|
||||||
|
"value": default,
|
||||||
|
"default_value": default,
|
||||||
|
"setting_type": stype,
|
||||||
|
"category": "email",
|
||||||
|
"order": order,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
imap_settings = AppConfiguration.objects.filter(category="email", key__startswith="imap_", is_active=True).order_by("order")
|
||||||
|
smtp_settings = AppConfiguration.objects.filter(category="email", key__startswith="smtp_", is_active=True).order_by("order")
|
||||||
|
notification_settings = AppConfiguration.objects.filter(category="email", key="notification_email", is_active=True).order_by("order")
|
||||||
|
|
||||||
test_result = None
|
test_result = None
|
||||||
|
|
||||||
@@ -1887,7 +1930,8 @@ def email_settings(request):
|
|||||||
|
|
||||||
if action == "save":
|
if action == "save":
|
||||||
updated = 0
|
updated = 0
|
||||||
for setting in imap_settings:
|
all_email_settings = AppConfiguration.objects.filter(category="email", is_active=True)
|
||||||
|
for setting in all_email_settings:
|
||||||
field_name = f"setting_{setting.key}"
|
field_name = f"setting_{setting.key}"
|
||||||
if setting.setting_type == "boolean":
|
if setting.setting_type == "boolean":
|
||||||
new_val = "True" if field_name in request.POST else "False"
|
new_val = "True" if field_name in request.POST else "False"
|
||||||
@@ -1954,13 +1998,148 @@ def email_settings(request):
|
|||||||
"message": f"Verbindungsfehler: {e}",
|
"message": f"Verbindungsfehler: {e}",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
elif action == "test_smtp":
|
||||||
|
import smtplib
|
||||||
|
import ssl as ssl_module
|
||||||
|
host = get_config("smtp_host")
|
||||||
|
port = int(get_config("smtp_port", 465))
|
||||||
|
user = get_config("smtp_user")
|
||||||
|
password = get_config("smtp_password")
|
||||||
|
use_ssl = get_config("smtp_use_ssl", True)
|
||||||
|
|
||||||
|
if not all([host, user, password]):
|
||||||
|
test_result = {
|
||||||
|
"success": False,
|
||||||
|
"message": "SMTP-Server, Benutzername und Passwort müssen konfiguriert sein.",
|
||||||
|
"section": "smtp",
|
||||||
|
}
|
||||||
|
else:
|
||||||
|
try:
|
||||||
|
if use_ssl:
|
||||||
|
context = ssl_module.create_default_context()
|
||||||
|
conn = smtplib.SMTP_SSL(host, port, context=context, timeout=15)
|
||||||
|
else:
|
||||||
|
conn = smtplib.SMTP(host, port, timeout=15)
|
||||||
|
conn.starttls()
|
||||||
|
conn.login(user, password)
|
||||||
|
conn.quit()
|
||||||
|
test_result = {
|
||||||
|
"success": True,
|
||||||
|
"message": f"SMTP-Verbindung erfolgreich! Angemeldet als {user}.",
|
||||||
|
"section": "smtp",
|
||||||
|
}
|
||||||
|
except smtplib.SMTPAuthenticationError as e:
|
||||||
|
test_result = {
|
||||||
|
"success": False,
|
||||||
|
"message": f"SMTP-Authentifizierungsfehler: {e}",
|
||||||
|
"section": "smtp",
|
||||||
|
}
|
||||||
|
except Exception as e:
|
||||||
|
test_result = {
|
||||||
|
"success": False,
|
||||||
|
"message": f"SMTP-Verbindungsfehler: {e}",
|
||||||
|
"section": "smtp",
|
||||||
|
}
|
||||||
|
|
||||||
|
elif action == "test_smtp_send":
|
||||||
|
from django.core.mail import EmailMultiAlternatives, get_connection
|
||||||
|
from django.utils import timezone
|
||||||
|
|
||||||
|
test_email = request.POST.get("test_email", "").strip()
|
||||||
|
if not test_email:
|
||||||
|
test_result = {
|
||||||
|
"success": False,
|
||||||
|
"message": "Bitte geben Sie eine Empfänger-E-Mail-Adresse ein.",
|
||||||
|
"section": "smtp",
|
||||||
|
}
|
||||||
|
else:
|
||||||
|
host = get_config("smtp_host")
|
||||||
|
port = int(get_config("smtp_port", 465))
|
||||||
|
user = get_config("smtp_user")
|
||||||
|
password = get_config("smtp_password")
|
||||||
|
use_ssl = get_config("smtp_use_ssl", True)
|
||||||
|
from_email = get_config("smtp_from_email", "buero@vhtv-stiftung.de")
|
||||||
|
|
||||||
|
if not all([host, user, password]):
|
||||||
|
test_result = {
|
||||||
|
"success": False,
|
||||||
|
"message": "SMTP-Server, Benutzername und Passwort müssen konfiguriert sein.",
|
||||||
|
"section": "smtp",
|
||||||
|
}
|
||||||
|
else:
|
||||||
|
try:
|
||||||
|
connection = get_connection(
|
||||||
|
backend="django.core.mail.backends.smtp.EmailBackend",
|
||||||
|
host=host,
|
||||||
|
port=port,
|
||||||
|
username=user,
|
||||||
|
password=password,
|
||||||
|
use_ssl=bool(use_ssl),
|
||||||
|
use_tls=False,
|
||||||
|
fail_silently=False,
|
||||||
|
)
|
||||||
|
now = timezone.now().strftime("%d.%m.%Y %H:%M")
|
||||||
|
text_body = (
|
||||||
|
f"Dies ist eine Test-E-Mail der Stiftungsverwaltung.\n\n"
|
||||||
|
f"Zeitpunkt: {now}\n"
|
||||||
|
f"SMTP-Server: {host}:{port}\n"
|
||||||
|
f"Absender: {from_email}\n"
|
||||||
|
f"Gesendet von: {request.user.get_full_name() or request.user.username}\n\n"
|
||||||
|
f"Wenn Sie diese E-Mail erhalten, funktioniert der E-Mail-Versand korrekt."
|
||||||
|
)
|
||||||
|
html_body = (
|
||||||
|
'<!DOCTYPE html><html lang="de"><head><meta charset="UTF-8"></head><body>'
|
||||||
|
'<div style="max-width:600px;margin:32px auto;font-family:Arial,sans-serif;'
|
||||||
|
'border-radius:8px;overflow:hidden;box-shadow:0 2px 8px rgba(0,0,0,0.08);">'
|
||||||
|
'<div style="background:#1a3a5c;color:#fff;padding:28px 32px 20px;">'
|
||||||
|
'<h1 style="margin:0 0 4px;font-size:20px;">van Hees-Theyssen-Vogel\'sche Stiftung</h1>'
|
||||||
|
'<p style="margin:0;font-size:13px;opacity:0.8;">SMTP-Test</p></div>'
|
||||||
|
'<div style="padding:28px 32px;">'
|
||||||
|
'<p style="line-height:1.6;">Dies ist eine <strong>Test-E-Mail</strong> der Stiftungsverwaltung.</p>'
|
||||||
|
'<div style="background:#f0f6ff;border:1px solid #b0cce8;border-radius:6px;padding:16px 20px;margin:20px 0;">'
|
||||||
|
f'<p style="margin:0 0 8px;"><strong>Zeitpunkt:</strong> {now}</p>'
|
||||||
|
f'<p style="margin:0 0 8px;"><strong>SMTP-Server:</strong> {host}:{port}</p>'
|
||||||
|
f'<p style="margin:0 0 8px;"><strong>Absender:</strong> {from_email}</p>'
|
||||||
|
f'<p style="margin:0;"><strong>Gesendet von:</strong> {request.user.get_full_name() or request.user.username}</p>'
|
||||||
|
'</div>'
|
||||||
|
'<p style="line-height:1.6;color:#28a745;"><strong>✔ E-Mail-Versand funktioniert korrekt.</strong></p>'
|
||||||
|
'</div>'
|
||||||
|
'<div style="background:#f0f0f0;padding:16px 32px;font-size:12px;color:#777;border-top:1px solid #e0e0e0;">'
|
||||||
|
'van Hees-Theyssen-Vogel\'sche Stiftung • Raesfelder Str. 3 • 46499 Hamminkeln</div>'
|
||||||
|
'</div></body></html>'
|
||||||
|
)
|
||||||
|
msg = EmailMultiAlternatives(
|
||||||
|
subject=f"[vHTV-Stiftung] SMTP-Test ({now})",
|
||||||
|
body=text_body,
|
||||||
|
from_email=from_email,
|
||||||
|
to=[test_email],
|
||||||
|
connection=connection,
|
||||||
|
)
|
||||||
|
msg.attach_alternative(html_body, "text/html")
|
||||||
|
msg.send()
|
||||||
|
test_result = {
|
||||||
|
"success": True,
|
||||||
|
"message": f"Test-E-Mail wurde an {test_email} gesendet! Bitte prüfen Sie den Posteingang (und Spam-Ordner).",
|
||||||
|
"section": "smtp",
|
||||||
|
}
|
||||||
|
except Exception as e:
|
||||||
|
test_result = {
|
||||||
|
"success": False,
|
||||||
|
"message": f"E-Mail-Versand fehlgeschlagen: {e}",
|
||||||
|
"section": "smtp",
|
||||||
|
}
|
||||||
|
|
||||||
# Refresh after save
|
# Refresh after save
|
||||||
imap_settings = AppConfiguration.objects.filter(category="email", is_active=True).order_by("order")
|
imap_settings = AppConfiguration.objects.filter(category="email", key__startswith="imap_", is_active=True).order_by("order")
|
||||||
|
smtp_settings = AppConfiguration.objects.filter(category="email", key__startswith="smtp_", is_active=True).order_by("order")
|
||||||
|
notification_settings = AppConfiguration.objects.filter(category="email", key="notification_email", is_active=True).order_by("order")
|
||||||
|
|
||||||
context = {
|
context = {
|
||||||
"imap_settings": imap_settings,
|
"imap_settings": imap_settings,
|
||||||
|
"smtp_settings": smtp_settings,
|
||||||
|
"notification_settings": notification_settings,
|
||||||
"test_result": test_result,
|
"test_result": test_result,
|
||||||
"title": "E-Mail / IMAP Konfiguration",
|
"title": "E-Mail-Konfiguration (IMAP & SMTP)",
|
||||||
}
|
}
|
||||||
return render(request, "stiftung/email_settings.html", context)
|
return render(request, "stiftung/email_settings.html", context)
|
||||||
|
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ from django_otp.util import random_hex
|
|||||||
from rest_framework.decorators import api_view
|
from rest_framework.decorators import api_view
|
||||||
from rest_framework.response import Response
|
from rest_framework.response import Response
|
||||||
|
|
||||||
|
from stiftung.audit import log_action
|
||||||
from stiftung.models import (AppConfiguration, AuditLog, BackupJob, BankTransaction,
|
from stiftung.models import (AppConfiguration, AuditLog, BackupJob, BankTransaction,
|
||||||
BriefVorlage, CSVImport, Destinataer,
|
BriefVorlage, CSVImport, Destinataer,
|
||||||
DestinataerEmailEingang, DestinataerNotiz,
|
DestinataerEmailEingang, DestinataerNotiz,
|
||||||
@@ -1299,16 +1300,53 @@ def quarterly_confirmation_create(request, destinataer_id):
|
|||||||
@login_required
|
@login_required
|
||||||
def quarterly_confirmation_edit(request, pk):
|
def quarterly_confirmation_edit(request, pk):
|
||||||
"""Standalone edit view for quarterly confirmation"""
|
"""Standalone edit view for quarterly confirmation"""
|
||||||
|
from stiftung.models import DokumentDatei
|
||||||
|
|
||||||
nachweis = get_object_or_404(VierteljahresNachweis, pk=pk)
|
nachweis = get_object_or_404(VierteljahresNachweis, pk=pk)
|
||||||
|
|
||||||
if request.method == "POST":
|
if request.method == "POST":
|
||||||
|
# DMS-Dokument entfernen (Verknuepfung loesen)
|
||||||
|
entferne_dok_id = request.POST.get("entferne_dms_dokument")
|
||||||
|
if entferne_dok_id:
|
||||||
|
nachweis.nachweis_dokumente.remove(entferne_dok_id)
|
||||||
|
messages.success(request, "DMS-Dokument-Verknuepfung entfernt.")
|
||||||
|
return redirect("stiftung:quarterly_confirmation_edit", pk=pk)
|
||||||
|
|
||||||
form = VierteljahresNachweisForm(request.POST, request.FILES, instance=nachweis)
|
form = VierteljahresNachweisForm(request.POST, request.FILES, instance=nachweis)
|
||||||
if form.is_valid():
|
if form.is_valid():
|
||||||
quarterly_proof = form.save(commit=False)
|
quarterly_proof = form.save(commit=False)
|
||||||
|
|
||||||
|
# Kategorie-spezifische DMS-Dokumente zuweisen
|
||||||
|
for field_name, dms_field in [
|
||||||
|
("studiennachweis_dms_id", "studiennachweis_dms_dokument"),
|
||||||
|
("einkommenssituation_dms_id", "einkommenssituation_dms_dokument"),
|
||||||
|
("vermogenssituation_dms_id", "vermogenssituation_dms_dokument"),
|
||||||
|
]:
|
||||||
|
dms_id = request.POST.get(field_name)
|
||||||
|
if dms_id:
|
||||||
|
try:
|
||||||
|
dok = DokumentDatei.objects.get(pk=dms_id)
|
||||||
|
setattr(quarterly_proof, dms_field, dok)
|
||||||
|
except DokumentDatei.DoesNotExist:
|
||||||
|
pass
|
||||||
|
elif dms_id == "":
|
||||||
|
# Leere Auswahl = Verknuepfung entfernen
|
||||||
|
setattr(quarterly_proof, dms_field, None)
|
||||||
|
|
||||||
|
# Generisches DMS-Dokument hinzufuegen (Abwaertskompatibilitaet)
|
||||||
|
dms_dok_id = request.POST.get("dms_dokument_hinzufuegen")
|
||||||
|
if dms_dok_id:
|
||||||
|
try:
|
||||||
|
dok = DokumentDatei.objects.get(pk=dms_dok_id)
|
||||||
|
# Save first so M2M can be set
|
||||||
|
quarterly_proof.save()
|
||||||
|
quarterly_proof.nachweis_dokumente.add(dok)
|
||||||
|
except DokumentDatei.DoesNotExist:
|
||||||
|
pass
|
||||||
|
|
||||||
# Calculate current status before saving
|
# Calculate current status before saving
|
||||||
old_status = nachweis.status
|
old_status = nachweis.status
|
||||||
|
|
||||||
# Auto-update status based on completion
|
# Auto-update status based on completion
|
||||||
if quarterly_proof.is_complete():
|
if quarterly_proof.is_complete():
|
||||||
if quarterly_proof.status in ['offen', 'teilweise']:
|
if quarterly_proof.status in ['offen', 'teilweise']:
|
||||||
@@ -1317,15 +1355,15 @@ def quarterly_confirmation_edit(request, pk):
|
|||||||
else:
|
else:
|
||||||
# If not complete, set to teilweise if some fields are filled
|
# If not complete, set to teilweise if some fields are filled
|
||||||
has_partial_data = (
|
has_partial_data = (
|
||||||
quarterly_proof.einkommenssituation_bestaetigt or
|
quarterly_proof.einkommenssituation_bestaetigt or
|
||||||
quarterly_proof.vermogenssituation_bestaetigt or
|
quarterly_proof.vermogenssituation_bestaetigt or
|
||||||
quarterly_proof.studiennachweis_eingereicht
|
quarterly_proof.studiennachweis_eingereicht
|
||||||
)
|
)
|
||||||
if has_partial_data and quarterly_proof.status == 'offen':
|
if has_partial_data and quarterly_proof.status == 'offen':
|
||||||
quarterly_proof.status = 'teilweise'
|
quarterly_proof.status = 'teilweise'
|
||||||
|
|
||||||
quarterly_proof.save()
|
quarterly_proof.save()
|
||||||
|
|
||||||
# Try to create automatic support payment if complete
|
# Try to create automatic support payment if complete
|
||||||
if quarterly_proof.is_complete() and quarterly_proof.status == 'eingereicht':
|
if quarterly_proof.is_complete() and quarterly_proof.status == 'eingereicht':
|
||||||
support_payment = create_quarterly_support_payment(quarterly_proof)
|
support_payment = create_quarterly_support_payment(quarterly_proof)
|
||||||
@@ -1343,17 +1381,17 @@ def quarterly_confirmation_edit(request, pk):
|
|||||||
reasons.append("keine IBAN hinterlegt")
|
reasons.append("keine IBAN hinterlegt")
|
||||||
if not quarterly_proof.destinataer.standard_konto and not StiftungsKonto.objects.exists():
|
if not quarterly_proof.destinataer.standard_konto and not StiftungsKonto.objects.exists():
|
||||||
reasons.append("kein Auszahlungskonto verfügbar")
|
reasons.append("kein Auszahlungskonto verfügbar")
|
||||||
|
|
||||||
if reasons:
|
if reasons:
|
||||||
messages.warning(
|
messages.warning(
|
||||||
request,
|
request,
|
||||||
f"Automatische Unterstützung konnte nicht erstellt werden: {', '.join(reasons)}"
|
f"Automatische Unterstützung konnte nicht erstellt werden: {', '.join(reasons)}"
|
||||||
)
|
)
|
||||||
|
|
||||||
# Debug message to see what happened
|
# Debug message to see what happened
|
||||||
status_changed = old_status != quarterly_proof.status
|
status_changed = old_status != quarterly_proof.status
|
||||||
status_msg = f" (Status: {old_status} → {quarterly_proof.status})" if status_changed else f" (Status: {quarterly_proof.status})"
|
status_msg = f" (Status: {old_status} → {quarterly_proof.status})" if status_changed else f" (Status: {quarterly_proof.status})"
|
||||||
|
|
||||||
messages.success(
|
messages.success(
|
||||||
request,
|
request,
|
||||||
f"Vierteljahresnachweis für {nachweis.destinataer.get_full_name()} "
|
f"Vierteljahresnachweis für {nachweis.destinataer.get_full_name()} "
|
||||||
@@ -1367,12 +1405,27 @@ def quarterly_confirmation_edit(request, pk):
|
|||||||
messages.error(request, f"Fehler in {field}: {error}")
|
messages.error(request, f"Fehler in {field}: {error}")
|
||||||
else:
|
else:
|
||||||
form = VierteljahresNachweisForm(instance=nachweis)
|
form = VierteljahresNachweisForm(instance=nachweis)
|
||||||
|
|
||||||
|
# Alle DMS-Dokumente des Destinataers (fuer Kategorie-Auswahl in den Sektionen)
|
||||||
|
alle_dms_dokumente = (
|
||||||
|
DokumentDatei.objects.filter(destinataer=nachweis.destinataer)
|
||||||
|
.exclude(kontext="email")
|
||||||
|
.order_by("kontext", "titel")
|
||||||
|
)
|
||||||
|
|
||||||
|
# Generisch verknuepfte Dokumente (M2M) und noch nicht verknuepfte (fuer Bottom-Sektion)
|
||||||
|
verknuepfte_nachweis_dokumente = nachweis.nachweis_dokumente.all().order_by("kontext", "titel")
|
||||||
|
verknuepfte_ids = set(verknuepfte_nachweis_dokumente.values_list("pk", flat=True))
|
||||||
|
verfuegbare_dms_dokumente = alle_dms_dokumente.exclude(pk__in=verknuepfte_ids)
|
||||||
|
|
||||||
context = {
|
context = {
|
||||||
'form': form,
|
'form': form,
|
||||||
'nachweis': nachweis,
|
'nachweis': nachweis,
|
||||||
'destinataer': nachweis.destinataer,
|
'destinataer': nachweis.destinataer,
|
||||||
'title': f'Vierteljahresnachweis bearbeiten - {nachweis.destinataer.get_full_name()} {nachweis.jahr} Q{nachweis.quartal}',
|
'title': f'Vierteljahresnachweis bearbeiten - {nachweis.destinataer.get_full_name()} {nachweis.jahr} Q{nachweis.quartal}',
|
||||||
|
'alle_dms_dokumente': alle_dms_dokumente,
|
||||||
|
'verknuepfte_nachweis_dokumente': verknuepfte_nachweis_dokumente,
|
||||||
|
'verfuegbare_dms_dokumente': verfuegbare_dms_dokumente,
|
||||||
}
|
}
|
||||||
return render(request, 'stiftung/quarterly_confirmation_edit.html', context)
|
return render(request, 'stiftung/quarterly_confirmation_edit.html', context)
|
||||||
|
|
||||||
@@ -1644,11 +1697,13 @@ def batch_erinnerung_senden(request):
|
|||||||
count = 0
|
count = 0
|
||||||
for nachweis in overdue:
|
for nachweis in overdue:
|
||||||
try:
|
try:
|
||||||
AuditLog.objects.create(
|
log_action(
|
||||||
user=request.user,
|
request,
|
||||||
action=f"ERINNERUNG: Nachweis {nachweis.jahr} Q{nachweis.quartal} – {nachweis.destinataer.get_full_name()} ({nachweis.destinataer.email})",
|
action="update",
|
||||||
model_name="VierteljahresNachweis",
|
entity_type="destinataer",
|
||||||
object_id=str(nachweis.id),
|
entity_id=str(nachweis.id),
|
||||||
|
entity_name=nachweis.destinataer.get_full_name(),
|
||||||
|
description=f"ERINNERUNG: Nachweis {nachweis.jahr} Q{nachweis.quartal} – {nachweis.destinataer.get_full_name()} ({nachweis.destinataer.email})",
|
||||||
)
|
)
|
||||||
count += 1
|
count += 1
|
||||||
except Exception:
|
except Exception:
|
||||||
@@ -1661,9 +1716,104 @@ def batch_erinnerung_senden(request):
|
|||||||
return redirect("stiftung:nachweis_board")
|
return redirect("stiftung:nachweis_board")
|
||||||
|
|
||||||
|
|
||||||
|
@login_required
|
||||||
|
def nachweis_aufforderung_senden(request, nachweis_pk):
|
||||||
|
"""
|
||||||
|
Sendet eine Nachweis-Aufforderungs-E-Mail für einen einzelnen Nachweis.
|
||||||
|
Erstellt einen UploadToken und versendet den Link per E-Mail an den Destinatär.
|
||||||
|
POST-only (CSRF-geschützt).
|
||||||
|
"""
|
||||||
|
from stiftung.tasks import send_nachweis_aufforderung
|
||||||
|
|
||||||
|
if request.method != "POST":
|
||||||
|
return redirect("stiftung:nachweis_board")
|
||||||
|
|
||||||
|
nachweis = get_object_or_404(
|
||||||
|
VierteljahresNachweis.objects.select_related("destinataer"),
|
||||||
|
id=nachweis_pk,
|
||||||
|
)
|
||||||
|
destinataer = nachweis.destinataer
|
||||||
|
|
||||||
|
if not destinataer.email:
|
||||||
|
messages.error(
|
||||||
|
request,
|
||||||
|
f"Destinatär {destinataer.get_full_name()} hat keine E-Mail-Adresse hinterlegt.",
|
||||||
|
)
|
||||||
|
return redirect("stiftung:destinataer_detail", pk=destinataer.id)
|
||||||
|
|
||||||
|
base_url = request.build_absolute_uri("/").rstrip("/")
|
||||||
|
send_nachweis_aufforderung.delay(
|
||||||
|
str(destinataer.id), str(nachweis.id), base_url=base_url
|
||||||
|
)
|
||||||
|
|
||||||
|
log_action(
|
||||||
|
request,
|
||||||
|
action="update",
|
||||||
|
entity_type="destinataer",
|
||||||
|
entity_id=str(nachweis.id),
|
||||||
|
entity_name=destinataer.get_full_name(),
|
||||||
|
description=f"Nachweis-Aufforderung per E-Mail gesendet an {destinataer.get_full_name()} ({destinataer.email}) – Nachweis {nachweis.jahr} Q{nachweis.quartal}",
|
||||||
|
)
|
||||||
|
|
||||||
|
messages.success(
|
||||||
|
request,
|
||||||
|
f"Nachweis-Aufforderung wird per E-Mail an {destinataer.email} gesendet.",
|
||||||
|
)
|
||||||
|
return redirect("stiftung:destinataer_detail", pk=destinataer.id)
|
||||||
|
|
||||||
|
|
||||||
|
@login_required
|
||||||
|
def batch_nachweis_aufforderung_senden(request):
|
||||||
|
"""
|
||||||
|
Batch: Nachweis-Aufforderungen an alle Destinatäre mit offenen Nachweisen versenden.
|
||||||
|
POST-only. Sendet für jeden offenen Nachweis einen UploadToken per E-Mail.
|
||||||
|
"""
|
||||||
|
from stiftung.tasks import send_nachweis_aufforderung
|
||||||
|
|
||||||
|
if request.method != "POST":
|
||||||
|
return redirect("stiftung:nachweis_board")
|
||||||
|
|
||||||
|
heute = date.today()
|
||||||
|
jahr = int(request.POST.get("jahr", heute.year))
|
||||||
|
|
||||||
|
offene_nachweise = VierteljahresNachweis.objects.filter(
|
||||||
|
jahr=jahr,
|
||||||
|
status__in=["offen", "teilweise", "nachbesserung"],
|
||||||
|
destinataer__aktiv=True,
|
||||||
|
).select_related("destinataer")
|
||||||
|
|
||||||
|
base_url = request.build_absolute_uri("/").rstrip("/")
|
||||||
|
count = 0
|
||||||
|
ohne_email = 0
|
||||||
|
|
||||||
|
for nachweis in offene_nachweise:
|
||||||
|
if not nachweis.destinataer.email:
|
||||||
|
ohne_email += 1
|
||||||
|
continue
|
||||||
|
send_nachweis_aufforderung.delay(
|
||||||
|
str(nachweis.destinataer.id), str(nachweis.id), base_url=base_url
|
||||||
|
)
|
||||||
|
count += 1
|
||||||
|
|
||||||
|
log_action(
|
||||||
|
request,
|
||||||
|
action="update",
|
||||||
|
entity_type="system",
|
||||||
|
entity_id="",
|
||||||
|
entity_name="Batch-Nachweis-Aufforderung",
|
||||||
|
description=f"Batch-Nachweis-Aufforderung {jahr}: {count} E-Mails angestoßen, {ohne_email} ohne E-Mail-Adresse.",
|
||||||
|
)
|
||||||
|
|
||||||
|
meldung = f"{count} Nachweis-Aufforderung(en) werden per E-Mail versendet."
|
||||||
|
if ohne_email:
|
||||||
|
meldung += f" {ohne_email} Destinatär(e) haben keine E-Mail-Adresse."
|
||||||
|
messages.success(request, meldung)
|
||||||
|
return redirect("stiftung:nachweis_board")
|
||||||
|
|
||||||
|
|
||||||
@login_required
|
@login_required
|
||||||
def zahlungs_pipeline(request):
|
def zahlungs_pipeline(request):
|
||||||
"""2c: Zahlungs-Pipeline – 5-Stufen-Kanban-Ansicht."""
|
"""2c: Zahlungs-Pipeline – 4-Stufen-Kanban-Ansicht."""
|
||||||
heute = date.today()
|
heute = date.today()
|
||||||
destinataer_id = request.GET.get("destinataer", "")
|
destinataer_id = request.GET.get("destinataer", "")
|
||||||
konto_id = request.GET.get("konto", "")
|
konto_id = request.GET.get("konto", "")
|
||||||
@@ -1681,8 +1831,7 @@ def zahlungs_pipeline(request):
|
|||||||
"offen": qs.filter(status__in=["geplant", "faellig"]).order_by("faellig_am"),
|
"offen": qs.filter(status__in=["geplant", "faellig"]).order_by("faellig_am"),
|
||||||
"nachweis_eingereicht": qs.filter(status="nachweis_eingereicht").order_by("faellig_am"),
|
"nachweis_eingereicht": qs.filter(status="nachweis_eingereicht").order_by("faellig_am"),
|
||||||
"freigegeben": qs.filter(status__in=["freigegeben", "in_bearbeitung"]).order_by("faellig_am"),
|
"freigegeben": qs.filter(status__in=["freigegeben", "in_bearbeitung"]).order_by("faellig_am"),
|
||||||
"ueberwiesen": qs.filter(status="ausgezahlt").order_by("-ausgezahlt_am"),
|
"ueberwiesen": qs.filter(status__in=["ausgezahlt", "abgeschlossen"]).order_by("-ausgezahlt_am"),
|
||||||
"abgeschlossen": qs.filter(status="abgeschlossen").order_by("-ausgezahlt_am"),
|
|
||||||
}
|
}
|
||||||
|
|
||||||
stage_meta = {
|
stage_meta = {
|
||||||
@@ -1690,7 +1839,6 @@ def zahlungs_pipeline(request):
|
|||||||
"nachweis_eingereicht": ("Nachweis eingereicht", "info", "fa-file-alt"),
|
"nachweis_eingereicht": ("Nachweis eingereicht", "info", "fa-file-alt"),
|
||||||
"freigegeben": ("Freigegeben (4-Augen)", "warning", "fa-shield-alt"),
|
"freigegeben": ("Freigegeben (4-Augen)", "warning", "fa-shield-alt"),
|
||||||
"ueberwiesen": ("Überwiesen", "success", "fa-university"),
|
"ueberwiesen": ("Überwiesen", "success", "fa-university"),
|
||||||
"abgeschlossen": ("Abgeschlossen", "dark", "fa-check-double"),
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pipeline_stages = [
|
pipeline_stages = [
|
||||||
@@ -1702,7 +1850,7 @@ def zahlungs_pipeline(request):
|
|||||||
"zahlungen": list(pipeline[key]),
|
"zahlungen": list(pipeline[key]),
|
||||||
"gesamt": pipeline[key].aggregate(s=Sum("betrag"))["s"] or Decimal("0"),
|
"gesamt": pipeline[key].aggregate(s=Sum("betrag"))["s"] or Decimal("0"),
|
||||||
}
|
}
|
||||||
for key in ["offen", "nachweis_eingereicht", "freigegeben", "ueberwiesen", "abgeschlossen"]
|
for key in ["offen", "nachweis_eingereicht", "freigegeben", "ueberwiesen"]
|
||||||
]
|
]
|
||||||
|
|
||||||
destinataere = Destinataer.objects.filter(aktiv=True).order_by("nachname", "vorname")
|
destinataere = Destinataer.objects.filter(aktiv=True).order_by("nachname", "vorname")
|
||||||
@@ -1883,5 +2031,127 @@ def sepa_xml_export(request):
|
|||||||
return response
|
return response
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Phase 5: Onboarding – Admin-seitige Verwaltung
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
@login_required
|
||||||
|
def onboarding_einladung_senden(request):
|
||||||
|
"""
|
||||||
|
Erstellt eine OnboardingEinladung und sendet den Einladungslink per E-Mail.
|
||||||
|
Aufruf: POST /destinataere/onboarding/einladen/
|
||||||
|
Erwartet: email, vorname (optional), nachname (optional).
|
||||||
|
"""
|
||||||
|
import secrets
|
||||||
|
from datetime import timedelta
|
||||||
|
from stiftung.models import OnboardingEinladung
|
||||||
|
from stiftung.tasks import send_onboarding_einladung
|
||||||
|
|
||||||
|
if request.method != "POST":
|
||||||
|
return redirect("stiftung:destinataer_list")
|
||||||
|
|
||||||
|
email = request.POST.get("email", "").strip()
|
||||||
|
if not email:
|
||||||
|
messages.error(request, "Bitte eine gültige E-Mail-Adresse angeben.")
|
||||||
|
return redirect("stiftung:destinataer_list")
|
||||||
|
|
||||||
|
vorname = request.POST.get("vorname", "").strip()
|
||||||
|
nachname = request.POST.get("nachname", "").strip()
|
||||||
|
|
||||||
|
# Prüfen ob bereits eine offene Einladung für diese E-Mail existiert
|
||||||
|
bestehend = OnboardingEinladung.objects.filter(
|
||||||
|
email=email,
|
||||||
|
status="offen",
|
||||||
|
gueltig_bis__gt=timezone.now(),
|
||||||
|
).first()
|
||||||
|
if bestehend:
|
||||||
|
messages.warning(
|
||||||
|
request,
|
||||||
|
f"Für {email} existiert bereits eine gültige Einladung (bis {bestehend.gueltig_bis.strftime('%d.%m.%Y')}). "
|
||||||
|
f"Keine neue Einladung erstellt.",
|
||||||
|
)
|
||||||
|
return redirect("stiftung:destinataer_list")
|
||||||
|
|
||||||
|
token_str = secrets.token_urlsafe(48)
|
||||||
|
gueltig_bis = timezone.now() + timedelta(days=30)
|
||||||
|
|
||||||
|
einladung = OnboardingEinladung.objects.create(
|
||||||
|
token=token_str,
|
||||||
|
email=email,
|
||||||
|
vorname=vorname,
|
||||||
|
nachname=nachname,
|
||||||
|
eingeladen_von=request.user,
|
||||||
|
gueltig_bis=gueltig_bis,
|
||||||
|
status="offen",
|
||||||
|
)
|
||||||
|
|
||||||
|
base_url = request.build_absolute_uri("/").rstrip("/")
|
||||||
|
send_onboarding_einladung.delay(str(einladung.id), base_url=base_url)
|
||||||
|
|
||||||
|
log_action(
|
||||||
|
request,
|
||||||
|
action="create",
|
||||||
|
entity_type="destinataer",
|
||||||
|
entity_id=str(einladung.id),
|
||||||
|
entity_name=email,
|
||||||
|
description=f"Onboarding-Einladung gesendet an {email}"
|
||||||
|
+ (f" ({vorname} {nachname})" if vorname or nachname else ""),
|
||||||
|
)
|
||||||
|
|
||||||
|
messages.success(
|
||||||
|
request,
|
||||||
|
f"Onboarding-Einladung wurde per E-Mail an {email} gesendet (gültig bis {gueltig_bis.strftime('%d.%m.%Y')}).",
|
||||||
|
)
|
||||||
|
return redirect("stiftung:onboarding_einladung_liste")
|
||||||
|
|
||||||
|
|
||||||
|
@login_required
|
||||||
|
def onboarding_einladung_liste(request):
|
||||||
|
"""Übersicht aller Onboarding-Einladungen."""
|
||||||
|
from stiftung.models import OnboardingEinladung
|
||||||
|
|
||||||
|
einladungen = OnboardingEinladung.objects.select_related(
|
||||||
|
"eingeladen_von", "destinataer"
|
||||||
|
).order_by("-erstellt_am")
|
||||||
|
|
||||||
|
return render(
|
||||||
|
request,
|
||||||
|
"stiftung/onboarding_einladung_liste.html",
|
||||||
|
{"einladungen": einladungen},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@login_required
|
||||||
|
def onboarding_einladung_widerrufen(request, pk):
|
||||||
|
"""Widerruft eine offene Onboarding-Einladung."""
|
||||||
|
from stiftung.models import OnboardingEinladung
|
||||||
|
|
||||||
|
einladung = get_object_or_404(OnboardingEinladung, id=pk)
|
||||||
|
|
||||||
|
if request.method == "POST":
|
||||||
|
if einladung.status == "offen":
|
||||||
|
einladung.status = "widerrufen"
|
||||||
|
einladung.save(update_fields=["status"])
|
||||||
|
log_action(
|
||||||
|
request,
|
||||||
|
action="update",
|
||||||
|
entity_type="destinataer",
|
||||||
|
entity_id=str(einladung.id),
|
||||||
|
entity_name=einladung.email,
|
||||||
|
description=f"Onboarding-Einladung für {einladung.email} widerrufen",
|
||||||
|
)
|
||||||
|
messages.success(request, f"Einladung für {einladung.email} wurde widerrufen.")
|
||||||
|
else:
|
||||||
|
messages.error(request, "Diese Einladung kann nicht mehr widerrufen werden.")
|
||||||
|
return redirect("stiftung:onboarding_einladung_liste")
|
||||||
|
|
||||||
|
return render(
|
||||||
|
request,
|
||||||
|
"stiftung/onboarding_einladung_widerrufen_bestaetigung.html",
|
||||||
|
{"einladung": einladung},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
# Two-Factor Authentication Views
|
# Two-Factor Authentication Views
|
||||||
|
|
||||||
|
|||||||
@@ -84,13 +84,13 @@ def veranstaltung_detail(request, pk):
|
|||||||
def veranstaltung_serienbrief_pdf(request, pk):
|
def veranstaltung_serienbrief_pdf(request, pk):
|
||||||
"""Generiert Serienbrief-PDF für alle Teilnehmer einer Veranstaltung"""
|
"""Generiert Serienbrief-PDF für alle Teilnehmer einer Veranstaltung"""
|
||||||
from weasyprint import HTML
|
from weasyprint import HTML
|
||||||
from django.template.loader import render_to_string
|
from stiftung.utils.vorlagen import render_vorlage
|
||||||
|
|
||||||
veranstaltung = get_object_or_404(Veranstaltung, pk=pk)
|
veranstaltung = get_object_or_404(Veranstaltung, pk=pk)
|
||||||
teilnehmer = veranstaltung.teilnehmer.all().order_by("nachname", "vorname")
|
teilnehmer = veranstaltung.teilnehmer.all().order_by("nachname", "vorname")
|
||||||
|
|
||||||
# Render HTML for all letters
|
# Render HTML for all letters (DB-Vorlage first, file fallback)
|
||||||
html_string = render_to_string(
|
html_string = render_vorlage(
|
||||||
"stiftung/veranstaltung/serienbrief_pdf.html",
|
"stiftung/veranstaltung/serienbrief_pdf.html",
|
||||||
{
|
{
|
||||||
"veranstaltung": veranstaltung,
|
"veranstaltung": veranstaltung,
|
||||||
|
|||||||
210
app/stiftung/views/vorlagen.py
Normal file
210
app/stiftung/views/vorlagen.py
Normal file
@@ -0,0 +1,210 @@
|
|||||||
|
"""Views für den web-basierten Dokument-Vorlagen-Editor."""
|
||||||
|
|
||||||
|
from django.contrib import messages
|
||||||
|
from django.contrib.auth.decorators import login_required
|
||||||
|
from django.http import HttpResponse, JsonResponse
|
||||||
|
from django.shortcuts import get_object_or_404, redirect, render
|
||||||
|
from django.views.decorators.http import require_POST
|
||||||
|
|
||||||
|
from stiftung.models import DokumentVorlage
|
||||||
|
from stiftung.utils.vorlagen import get_vorlage_original
|
||||||
|
|
||||||
|
|
||||||
|
@login_required
|
||||||
|
def vorlagen_liste(request):
|
||||||
|
"""Übersicht aller Dokument-Vorlagen nach Kategorie."""
|
||||||
|
vorlagen = DokumentVorlage.objects.select_related("zuletzt_bearbeitet_von").all()
|
||||||
|
|
||||||
|
kategorien = {}
|
||||||
|
for v in vorlagen:
|
||||||
|
if v.kategorie not in kategorien:
|
||||||
|
kategorien[v.kategorie] = []
|
||||||
|
kategorien[v.kategorie].append(v)
|
||||||
|
|
||||||
|
# Kategorie-Labels
|
||||||
|
kategorie_labels = dict(DokumentVorlage.KATEGORIE_CHOICES)
|
||||||
|
|
||||||
|
return render(request, "stiftung/vorlagen_liste.html", {
|
||||||
|
"kategorien": kategorien,
|
||||||
|
"kategorie_labels": kategorie_labels,
|
||||||
|
"vorlagen_count": vorlagen.count(),
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@login_required
|
||||||
|
def vorlage_editor(request, pk):
|
||||||
|
"""Editor für eine einzelne Vorlage."""
|
||||||
|
vorlage = get_object_or_404(DokumentVorlage, pk=pk)
|
||||||
|
|
||||||
|
if request.method == "POST":
|
||||||
|
html_inhalt = request.POST.get("html_inhalt", "")
|
||||||
|
vorlage.html_inhalt = html_inhalt
|
||||||
|
vorlage.zuletzt_bearbeitet_von = request.user
|
||||||
|
vorlage.save()
|
||||||
|
messages.success(request, f'Vorlage „{vorlage.bezeichnung}" wurde gespeichert.')
|
||||||
|
return redirect("stiftung:vorlage_editor", pk=pk)
|
||||||
|
|
||||||
|
try:
|
||||||
|
original = get_vorlage_original(vorlage.schluessel)
|
||||||
|
hat_original = True
|
||||||
|
except FileNotFoundError:
|
||||||
|
original = None
|
||||||
|
hat_original = False
|
||||||
|
|
||||||
|
import json
|
||||||
|
from django.utils.safestring import mark_safe
|
||||||
|
# JSON-encode and escape </script> to prevent XSS in script tag
|
||||||
|
html_json = json.dumps(vorlage.html_inhalt)
|
||||||
|
html_json = html_json.replace("<", "\\u003c").replace(">", "\\u003e")
|
||||||
|
|
||||||
|
# All templates contain Django template tags ({{ }}, {% %}) that
|
||||||
|
# Summernote WYSIWYG mangles on save. Use plain code editor for all.
|
||||||
|
use_code_editor = True
|
||||||
|
|
||||||
|
return render(request, "stiftung/vorlage_editor.html", {
|
||||||
|
"vorlage": vorlage,
|
||||||
|
"hat_original": hat_original,
|
||||||
|
"variablen": vorlage.verfuegbare_variablen,
|
||||||
|
"html_inhalt_json": mark_safe(html_json),
|
||||||
|
"use_code_editor": use_code_editor,
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@login_required
|
||||||
|
@require_POST
|
||||||
|
def vorlage_zuruecksetzen(request, pk):
|
||||||
|
"""Setzt eine Vorlage auf den Datei-Original-Inhalt zurück."""
|
||||||
|
vorlage = get_object_or_404(DokumentVorlage, pk=pk)
|
||||||
|
try:
|
||||||
|
original = get_vorlage_original(vorlage.schluessel)
|
||||||
|
vorlage.html_inhalt = original
|
||||||
|
vorlage.zuletzt_bearbeitet_von = request.user
|
||||||
|
vorlage.save()
|
||||||
|
messages.success(request, f'Vorlage „{vorlage.bezeichnung}" wurde auf die Original-Datei zurückgesetzt.')
|
||||||
|
except FileNotFoundError:
|
||||||
|
messages.error(request, "Original-Datei nicht gefunden. Zurücksetzen nicht möglich.")
|
||||||
|
return redirect("stiftung:vorlage_editor", pk=pk)
|
||||||
|
|
||||||
|
|
||||||
|
@login_required
|
||||||
|
@require_POST
|
||||||
|
def vorlagen_alle_zuruecksetzen(request):
|
||||||
|
"""Setzt ALLE Vorlagen auf die Original-Datei-Inhalte zurück."""
|
||||||
|
vorlagen = DokumentVorlage.objects.all()
|
||||||
|
restored = 0
|
||||||
|
for vorlage in vorlagen:
|
||||||
|
try:
|
||||||
|
original = get_vorlage_original(vorlage.schluessel)
|
||||||
|
vorlage.html_inhalt = original
|
||||||
|
vorlage.zuletzt_bearbeitet_von = request.user
|
||||||
|
vorlage.save()
|
||||||
|
restored += 1
|
||||||
|
except FileNotFoundError:
|
||||||
|
pass
|
||||||
|
messages.success(request, f"{restored} Vorlage(n) auf Original zurückgesetzt.")
|
||||||
|
return redirect("stiftung:vorlagen_liste")
|
||||||
|
|
||||||
|
|
||||||
|
@login_required
|
||||||
|
def vorlage_vorschau(request, pk):
|
||||||
|
"""Rendert eine Vorschau der Vorlage mit Beispieldaten (JSON-Response)."""
|
||||||
|
vorlage = get_object_or_404(DokumentVorlage, pk=pk)
|
||||||
|
|
||||||
|
# Rohinhalt aus POST (live-preview) oder aus DB
|
||||||
|
html_inhalt = request.POST.get("html_inhalt") if request.method == "POST" else vorlage.html_inhalt
|
||||||
|
|
||||||
|
# Einfache Beispieldaten je Kategorie
|
||||||
|
beispiel_context = _get_beispiel_context(vorlage.schluessel)
|
||||||
|
|
||||||
|
try:
|
||||||
|
from django.template import Context, Engine
|
||||||
|
engine = Engine.get_default()
|
||||||
|
t = engine.from_string(html_inhalt)
|
||||||
|
rendered = t.render(Context(beispiel_context))
|
||||||
|
return HttpResponse(rendered, content_type="text/html; charset=utf-8")
|
||||||
|
except Exception as exc:
|
||||||
|
return HttpResponse(
|
||||||
|
f"<pre style='color:red'>Template-Fehler: {exc}</pre>",
|
||||||
|
content_type="text/html; charset=utf-8",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _get_beispiel_context(schluessel: str) -> dict:
|
||||||
|
"""Gibt Beispieldaten für Vorschau-Rendering zurück."""
|
||||||
|
from datetime import date, time
|
||||||
|
|
||||||
|
class FakeObj(dict):
|
||||||
|
def __getattr__(self, k):
|
||||||
|
return self.get(k, "")
|
||||||
|
|
||||||
|
destinataer = FakeObj(
|
||||||
|
vorname="Maria",
|
||||||
|
nachname="Mustermann",
|
||||||
|
anrede="Frau",
|
||||||
|
strasse="Musterstraße 1",
|
||||||
|
plz="46499",
|
||||||
|
ort="Hamminkeln",
|
||||||
|
email="m.mustermann@example.com",
|
||||||
|
)
|
||||||
|
|
||||||
|
einladung = FakeObj(
|
||||||
|
vorname="Maria",
|
||||||
|
nachname="Mustermann",
|
||||||
|
email="m.mustermann@example.com",
|
||||||
|
)
|
||||||
|
|
||||||
|
base = {
|
||||||
|
"destinataer": destinataer,
|
||||||
|
"einladung": einladung,
|
||||||
|
"datum": date.today(),
|
||||||
|
"zeitraum": "01.01.2025 – 31.12.2025",
|
||||||
|
"betrag_quartal": 500,
|
||||||
|
"betrag_jaehrlich": 2000,
|
||||||
|
"gesamtbetrag": 2000,
|
||||||
|
"zweck": "Studienförderung",
|
||||||
|
"unterstuetzungen": [],
|
||||||
|
"halbjahr_label": "1. Halbjahr 2025",
|
||||||
|
"upload_url": "https://vhtv-stiftung.de/portal/upload/beispiel-token/",
|
||||||
|
"gueltig_bis": date.today(),
|
||||||
|
"qr_code_base64": "",
|
||||||
|
"ist_erinnerung": False,
|
||||||
|
"onboarding_url": "https://vhtv-stiftung.de/portal/onboarding/beispiel/",
|
||||||
|
"veranstaltung": FakeObj(titel="Stiftungsessen 2025"),
|
||||||
|
"teilnehmer_list": [],
|
||||||
|
}
|
||||||
|
|
||||||
|
# Serienbrief-Vorlage: vollständige Veranstaltungs- und Teilnehmer-Beispieldaten
|
||||||
|
if "serienbrief" in schluessel:
|
||||||
|
base["veranstaltung"] = FakeObj(
|
||||||
|
titel="Stiftungsessen 2025",
|
||||||
|
datum=date.today(),
|
||||||
|
uhrzeit=time(18, 0),
|
||||||
|
ort="Gasthaus zur Linde",
|
||||||
|
adresse="Lindenstraße 12, 46499 Hamminkeln",
|
||||||
|
betreff="",
|
||||||
|
briefvorlage="",
|
||||||
|
unterschrift_1_name="Katrin Kleinpaß",
|
||||||
|
unterschrift_1_titel="Rentmeisterin",
|
||||||
|
unterschrift_2_name="Jan Remmer Siebels",
|
||||||
|
unterschrift_2_titel="Rentmeister",
|
||||||
|
)
|
||||||
|
base["teilnehmer"] = [
|
||||||
|
FakeObj(
|
||||||
|
anrede="Frau",
|
||||||
|
vorname="Maria",
|
||||||
|
nachname="Mustermann",
|
||||||
|
strasse="Musterstraße 1",
|
||||||
|
plz="46499",
|
||||||
|
ort="Hamminkeln",
|
||||||
|
),
|
||||||
|
FakeObj(
|
||||||
|
anrede="Herr",
|
||||||
|
vorname="Hans",
|
||||||
|
nachname="Beispiel",
|
||||||
|
strasse="Beispielweg 7",
|
||||||
|
plz="46499",
|
||||||
|
ort="Hamminkeln",
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
||||||
|
return base
|
||||||
@@ -592,6 +592,10 @@
|
|||||||
<i class="fas fa-users"></i>
|
<i class="fas fa-users"></i>
|
||||||
<span>Destinataere</span>
|
<span>Destinataere</span>
|
||||||
</a>
|
</a>
|
||||||
|
<a class="sidebar-link" href="{% url 'stiftung:onboarding_einladung_liste' %}">
|
||||||
|
<i class="fas fa-user-plus"></i>
|
||||||
|
<span>Onboarding</span>
|
||||||
|
</a>
|
||||||
<a class="sidebar-link" href="{% url 'stiftung:foerderung_list' %}">
|
<a class="sidebar-link" href="{% url 'stiftung:foerderung_list' %}">
|
||||||
<i class="fas fa-gift"></i>
|
<i class="fas fa-gift"></i>
|
||||||
<span>Foerderungen</span>
|
<span>Foerderungen</span>
|
||||||
@@ -661,6 +665,15 @@
|
|||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Daten -->
|
||||||
|
<div class="sidebar-section">
|
||||||
|
<div class="sidebar-heading">Daten</div>
|
||||||
|
<a class="sidebar-link" href="{% url 'stiftung:import_export_hub' %}">
|
||||||
|
<i class="fas fa-exchange-alt"></i>
|
||||||
|
<span>Import & Export</span>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- System -->
|
<!-- System -->
|
||||||
<div class="sidebar-section">
|
<div class="sidebar-section">
|
||||||
<div class="sidebar-heading">System</div>
|
<div class="sidebar-heading">System</div>
|
||||||
@@ -676,6 +689,10 @@
|
|||||||
<i class="fas fa-book-open"></i>
|
<i class="fas fa-book-open"></i>
|
||||||
<span>Geschichte</span>
|
<span>Geschichte</span>
|
||||||
</a>
|
</a>
|
||||||
|
<a class="sidebar-link" href="/ahnenforschung/" target="_blank">
|
||||||
|
<i class="fas fa-tree"></i>
|
||||||
|
<span>Ahnenforschung</span>
|
||||||
|
</a>
|
||||||
{% if perms.stiftung.access_administration %}
|
{% if perms.stiftung.access_administration %}
|
||||||
<a class="sidebar-link" href="{% url 'stiftung:administration' %}">
|
<a class="sidebar-link" href="{% url 'stiftung:administration' %}">
|
||||||
<i class="fas fa-cogs"></i>
|
<i class="fas fa-cogs"></i>
|
||||||
@@ -741,7 +758,7 @@
|
|||||||
<!-- Footer -->
|
<!-- Footer -->
|
||||||
<footer class="main-footer">
|
<footer class="main-footer">
|
||||||
© 2026 van Hees-Theyssen-Vogel'sche Stiftung ·
|
© 2026 van Hees-Theyssen-Vogel'sche Stiftung ·
|
||||||
<small>Vision 2026 · v4.0.0</small>
|
<small>Vision 2026 · v{{ APP_VERSION }}</small>
|
||||||
</footer>
|
</footer>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
@@ -995,5 +1012,464 @@
|
|||||||
});
|
});
|
||||||
})();
|
})();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
{% if user.is_authenticated and perms.stiftung.can_use_agent %}
|
||||||
|
<!-- ══════════════════════════════════════════════════════════════
|
||||||
|
AI Agent Chat-Widget
|
||||||
|
══════════════════════════════════════════════════════════════ -->
|
||||||
|
<style>
|
||||||
|
#agent-fab {
|
||||||
|
position: fixed;
|
||||||
|
bottom: 1.5rem;
|
||||||
|
right: 1.5rem;
|
||||||
|
z-index: 9000;
|
||||||
|
width: 52px;
|
||||||
|
height: 52px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--racing-green);
|
||||||
|
color: #fff;
|
||||||
|
border: none;
|
||||||
|
box-shadow: 0 4px 16px rgba(0,0,0,0.25);
|
||||||
|
cursor: pointer;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 1.25rem;
|
||||||
|
transition: background 0.2s, transform 0.15s;
|
||||||
|
}
|
||||||
|
#agent-fab:hover { background: var(--racing-green-light); transform: scale(1.07); }
|
||||||
|
|
||||||
|
#agent-panel {
|
||||||
|
position: fixed;
|
||||||
|
bottom: 5.5rem;
|
||||||
|
right: 1.5rem;
|
||||||
|
z-index: 9001;
|
||||||
|
width: min(420px, calc(100vw - 2rem));
|
||||||
|
max-height: calc(100vh - 7rem);
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 1rem;
|
||||||
|
box-shadow: 0 8px 40px rgba(0,0,0,0.2);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
overflow: hidden;
|
||||||
|
transform: translateY(10px) scale(0.97);
|
||||||
|
opacity: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
transition: opacity 0.2s, transform 0.2s;
|
||||||
|
}
|
||||||
|
#agent-panel.open {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0) scale(1);
|
||||||
|
pointer-events: all;
|
||||||
|
}
|
||||||
|
|
||||||
|
#agent-header {
|
||||||
|
background: var(--racing-green);
|
||||||
|
color: #fff;
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
#agent-header h6 { margin: 0; font-size: 0.9rem; font-weight: 600; flex: 1; }
|
||||||
|
#agent-header button { background: none; border: none; color: rgba(255,255,255,0.7); cursor: pointer; padding: 0 4px; font-size: 1rem; }
|
||||||
|
#agent-header button:hover { color: #fff; }
|
||||||
|
|
||||||
|
#agent-session-bar {
|
||||||
|
background: #f8f9fa;
|
||||||
|
border-bottom: 1px solid #e9ecef;
|
||||||
|
padding: 0.4rem 0.75rem;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
#agent-session-bar select {
|
||||||
|
flex: 1;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
border: 1px solid #dee2e6;
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 2px 6px;
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
|
#agent-session-bar button { font-size: 0.75rem; white-space: nowrap; }
|
||||||
|
|
||||||
|
#agent-messages {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 0.75rem;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.5rem;
|
||||||
|
min-height: 200px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.agent-msg {
|
||||||
|
max-width: 85%;
|
||||||
|
padding: 0.5rem 0.75rem;
|
||||||
|
border-radius: 0.75rem;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
line-height: 1.5;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
.agent-msg.user {
|
||||||
|
background: var(--racing-green);
|
||||||
|
color: #fff;
|
||||||
|
align-self: flex-end;
|
||||||
|
border-bottom-right-radius: 4px;
|
||||||
|
}
|
||||||
|
.agent-msg.assistant {
|
||||||
|
background: #f0f2f5;
|
||||||
|
color: #212529;
|
||||||
|
align-self: flex-start;
|
||||||
|
border-bottom-left-radius: 4px;
|
||||||
|
}
|
||||||
|
.agent-msg.tool-indicator {
|
||||||
|
background: #fff3cd;
|
||||||
|
color: #856404;
|
||||||
|
align-self: flex-start;
|
||||||
|
font-size: 0.78rem;
|
||||||
|
padding: 0.3rem 0.6rem;
|
||||||
|
}
|
||||||
|
.agent-msg pre { margin: 0; white-space: pre-wrap; font-family: inherit; }
|
||||||
|
.agent-msg code { background: rgba(0,0,0,0.08); border-radius: 3px; padding: 1px 4px; font-size: 0.82em; }
|
||||||
|
|
||||||
|
#agent-input-area {
|
||||||
|
border-top: 1px solid #e9ecef;
|
||||||
|
padding: 0.6rem 0.75rem;
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
#agent-input {
|
||||||
|
flex: 1;
|
||||||
|
border: 1px solid #dee2e6;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
padding: 0.4rem 0.65rem;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
resize: none;
|
||||||
|
min-height: 36px;
|
||||||
|
max-height: 120px;
|
||||||
|
outline: none;
|
||||||
|
font-family: inherit;
|
||||||
|
}
|
||||||
|
#agent-input:focus { border-color: var(--racing-green); }
|
||||||
|
#agent-send-btn {
|
||||||
|
background: var(--racing-green);
|
||||||
|
color: #fff;
|
||||||
|
border: none;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
padding: 0 0.75rem;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 1rem;
|
||||||
|
transition: background 0.15s;
|
||||||
|
align-self: flex-end;
|
||||||
|
height: 36px;
|
||||||
|
}
|
||||||
|
#agent-send-btn:disabled { background: #adb5bd; cursor: not-allowed; }
|
||||||
|
#agent-send-btn:not(:disabled):hover { background: var(--racing-green-light); }
|
||||||
|
|
||||||
|
.agent-typing { display: flex; gap: 4px; align-items: center; padding: 0.5rem 0.75rem; }
|
||||||
|
.agent-typing span {
|
||||||
|
width: 7px; height: 7px; background: #adb5bd;
|
||||||
|
border-radius: 50%; animation: agent-bounce 1s infinite;
|
||||||
|
}
|
||||||
|
.agent-typing span:nth-child(2) { animation-delay: 0.15s; }
|
||||||
|
.agent-typing span:nth-child(3) { animation-delay: 0.3s; }
|
||||||
|
@keyframes agent-bounce {
|
||||||
|
0%,80%,100% { transform: translateY(0); }
|
||||||
|
40% { transform: translateY(-6px); }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<!-- FAB Button -->
|
||||||
|
<button id="agent-fab" title="AI-Assistent öffnen" onclick="agentToggle()">
|
||||||
|
<i class="fas fa-robot"></i>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<!-- Chat Panel -->
|
||||||
|
<div id="agent-panel">
|
||||||
|
<div id="agent-header">
|
||||||
|
<i class="fas fa-robot"></i>
|
||||||
|
<h6>RentmeisterAI</h6>
|
||||||
|
<button onclick="agentNewSession()" title="Neue Unterhaltung"><i class="fas fa-plus"></i></button>
|
||||||
|
<button onclick="agentToggle()" title="Schließen"><i class="fas fa-times"></i></button>
|
||||||
|
</div>
|
||||||
|
<div id="agent-session-bar">
|
||||||
|
<select id="agent-session-select" onchange="agentLoadSession(this.value)" title="Sitzung wechseln">
|
||||||
|
<option value="">— Neue Unterhaltung —</option>
|
||||||
|
</select>
|
||||||
|
<button class="btn btn-sm btn-outline-danger" onclick="agentDeleteSession()" title="Sitzung löschen">
|
||||||
|
<i class="fas fa-trash-alt"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div id="agent-messages">
|
||||||
|
<div class="agent-msg assistant">
|
||||||
|
Guten Tag! Ich bin RentmeisterAI. Wie kann ich Ihnen helfen?
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div id="agent-input-area">
|
||||||
|
<textarea id="agent-input" placeholder="Nachricht eingeben… (Enter = senden)" rows="1"
|
||||||
|
onkeydown="agentKeydown(event)"></textarea>
|
||||||
|
<button id="agent-send-btn" onclick="agentSend()" title="Senden">
|
||||||
|
<i class="fas fa-paper-plane"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
(function() {
|
||||||
|
const CSRF = '{{ csrf_token }}';
|
||||||
|
let currentSessionId = null;
|
||||||
|
let isStreaming = false;
|
||||||
|
|
||||||
|
window.agentToggle = function() {
|
||||||
|
const panel = document.getElementById('agent-panel');
|
||||||
|
const isOpen = panel.classList.contains('open');
|
||||||
|
panel.classList.toggle('open');
|
||||||
|
if (!isOpen) {
|
||||||
|
agentLoadSessions();
|
||||||
|
document.getElementById('agent-input').focus();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
window.agentNewSession = function() {
|
||||||
|
currentSessionId = null;
|
||||||
|
document.getElementById('agent-session-select').value = '';
|
||||||
|
document.getElementById('agent-messages').innerHTML =
|
||||||
|
'<div class="agent-msg assistant">Neue Unterhaltung gestartet. Wie kann ich helfen?</div>';
|
||||||
|
};
|
||||||
|
|
||||||
|
window.agentKeydown = function(e) {
|
||||||
|
if (e.key === 'Enter' && !e.shiftKey) {
|
||||||
|
e.preventDefault();
|
||||||
|
agentSend();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
window.agentSend = async function() {
|
||||||
|
if (isStreaming) return;
|
||||||
|
const input = document.getElementById('agent-input');
|
||||||
|
const msg = input.value.trim();
|
||||||
|
if (!msg) return;
|
||||||
|
|
||||||
|
input.value = '';
|
||||||
|
autoResizeTextarea(input);
|
||||||
|
|
||||||
|
appendMessage('user', msg);
|
||||||
|
const typingEl = appendTyping();
|
||||||
|
setSending(true);
|
||||||
|
|
||||||
|
// Seitenkontext: URL + Titel
|
||||||
|
const pageContext = `Seite: ${document.title}\nURL: ${window.location.href}`;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch('/agent/chat/', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'X-CSRFToken': CSRF,
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
message: msg,
|
||||||
|
session_id: currentSessionId,
|
||||||
|
page_context: pageContext,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
const err = await res.json().catch(() => ({}));
|
||||||
|
throw new Error(err.error || `HTTP ${res.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await res.json();
|
||||||
|
currentSessionId = data.session_id;
|
||||||
|
|
||||||
|
// Session-Select aktualisieren
|
||||||
|
updateSessionSelect(data.session_id);
|
||||||
|
|
||||||
|
// SSE-Stream starten
|
||||||
|
typingEl.remove();
|
||||||
|
await agentStream(data.stream_url);
|
||||||
|
|
||||||
|
} catch (e) {
|
||||||
|
typingEl.remove();
|
||||||
|
appendMessage('assistant', `Fehler: ${e.message}`);
|
||||||
|
} finally {
|
||||||
|
setSending(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
async function agentStream(url) {
|
||||||
|
const msgEl = appendMessage('assistant', '');
|
||||||
|
const contentEl = msgEl.querySelector('.agent-text');
|
||||||
|
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
const es = new EventSource(url);
|
||||||
|
es.onmessage = function(e) {
|
||||||
|
try {
|
||||||
|
const chunk = JSON.parse(e.data);
|
||||||
|
if (chunk.type === 'text') {
|
||||||
|
contentEl.textContent += chunk.content;
|
||||||
|
scrollMessages();
|
||||||
|
} else if (chunk.type === 'tool_start') {
|
||||||
|
appendToolIndicator(chunk.name);
|
||||||
|
} else if (chunk.type === 'done') {
|
||||||
|
es.close();
|
||||||
|
renderMarkdown(contentEl);
|
||||||
|
agentLoadSessions();
|
||||||
|
resolve();
|
||||||
|
} else if (chunk.type === 'error') {
|
||||||
|
contentEl.textContent = `Fehler: ${chunk.message}`;
|
||||||
|
es.close();
|
||||||
|
resolve();
|
||||||
|
}
|
||||||
|
} catch(err) { /* ignore parse errors */ }
|
||||||
|
};
|
||||||
|
es.onerror = function() {
|
||||||
|
es.close();
|
||||||
|
if (!contentEl.textContent) {
|
||||||
|
contentEl.textContent = 'Verbindungsfehler.';
|
||||||
|
}
|
||||||
|
resolve();
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function appendMessage(role, text) {
|
||||||
|
const messages = document.getElementById('agent-messages');
|
||||||
|
const div = document.createElement('div');
|
||||||
|
div.className = `agent-msg ${role}`;
|
||||||
|
const span = document.createElement('span');
|
||||||
|
span.className = 'agent-text';
|
||||||
|
span.textContent = text;
|
||||||
|
div.appendChild(span);
|
||||||
|
messages.appendChild(div);
|
||||||
|
scrollMessages();
|
||||||
|
return div;
|
||||||
|
}
|
||||||
|
|
||||||
|
function appendTyping() {
|
||||||
|
const messages = document.getElementById('agent-messages');
|
||||||
|
const div = document.createElement('div');
|
||||||
|
div.className = 'agent-msg assistant agent-typing-wrap';
|
||||||
|
div.innerHTML = '<div class="agent-typing"><span></span><span></span><span></span></div>';
|
||||||
|
messages.appendChild(div);
|
||||||
|
scrollMessages();
|
||||||
|
return div;
|
||||||
|
}
|
||||||
|
|
||||||
|
function appendToolIndicator(toolName) {
|
||||||
|
const messages = document.getElementById('agent-messages');
|
||||||
|
const div = document.createElement('div');
|
||||||
|
div.className = 'agent-msg tool-indicator';
|
||||||
|
div.innerHTML = `<i class="fas fa-cog fa-spin me-1"></i> Werkzeug: <em>${escHtml(toolName)}</em>`;
|
||||||
|
messages.appendChild(div);
|
||||||
|
scrollMessages();
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderMarkdown(el) {
|
||||||
|
// Simple markdown: **bold**, `code`, newlines
|
||||||
|
let html = escHtml(el.textContent)
|
||||||
|
.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>')
|
||||||
|
.replace(/`([^`]+)`/g, '<code>$1</code>')
|
||||||
|
.replace(/\n/g, '<br>');
|
||||||
|
el.innerHTML = html;
|
||||||
|
}
|
||||||
|
|
||||||
|
function scrollMessages() {
|
||||||
|
const m = document.getElementById('agent-messages');
|
||||||
|
m.scrollTop = m.scrollHeight;
|
||||||
|
}
|
||||||
|
|
||||||
|
function setSending(v) {
|
||||||
|
isStreaming = v;
|
||||||
|
document.getElementById('agent-send-btn').disabled = v;
|
||||||
|
document.getElementById('agent-input').disabled = v;
|
||||||
|
}
|
||||||
|
|
||||||
|
function autoResizeTextarea(el) {
|
||||||
|
el.style.height = 'auto';
|
||||||
|
el.style.height = Math.min(el.scrollHeight, 120) + 'px';
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
|
const input = document.getElementById('agent-input');
|
||||||
|
if (input) input.addEventListener('input', () => autoResizeTextarea(input));
|
||||||
|
});
|
||||||
|
|
||||||
|
window.agentLoadSessions = async function() {
|
||||||
|
try {
|
||||||
|
const res = await fetch('/agent/sessions/');
|
||||||
|
if (!res.ok) return;
|
||||||
|
const data = await res.json();
|
||||||
|
const sel = document.getElementById('agent-session-select');
|
||||||
|
// Keep current value
|
||||||
|
const current = sel.value;
|
||||||
|
sel.innerHTML = '<option value="">— Neue Unterhaltung —</option>';
|
||||||
|
data.sessions.forEach(s => {
|
||||||
|
const opt = document.createElement('option');
|
||||||
|
opt.value = s.id;
|
||||||
|
opt.textContent = (s.title || 'Unterhaltung').substring(0, 40);
|
||||||
|
sel.appendChild(opt);
|
||||||
|
});
|
||||||
|
if (currentSessionId) sel.value = currentSessionId;
|
||||||
|
else if (current) sel.value = current;
|
||||||
|
} catch(e) { /* ignore */ }
|
||||||
|
};
|
||||||
|
|
||||||
|
window.agentLoadSession = async function(sessionId) {
|
||||||
|
if (!sessionId) { agentNewSession(); return; }
|
||||||
|
currentSessionId = sessionId;
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/agent/sessions/${sessionId}/`);
|
||||||
|
if (!res.ok) return;
|
||||||
|
const data = await res.json();
|
||||||
|
const messages = document.getElementById('agent-messages');
|
||||||
|
messages.innerHTML = '';
|
||||||
|
data.messages.forEach(m => {
|
||||||
|
if (m.role === 'tool') return;
|
||||||
|
const div = appendMessage(m.role, m.content);
|
||||||
|
renderMarkdown(div.querySelector('.agent-text'));
|
||||||
|
});
|
||||||
|
if (data.messages.length === 0) {
|
||||||
|
appendMessage('assistant', 'Wie kann ich Ihnen helfen?');
|
||||||
|
}
|
||||||
|
} catch(e) { /* ignore */ }
|
||||||
|
};
|
||||||
|
|
||||||
|
window.agentDeleteSession = async function() {
|
||||||
|
if (!currentSessionId) return;
|
||||||
|
if (!confirm('Diese Unterhaltung wirklich löschen?')) return;
|
||||||
|
try {
|
||||||
|
await fetch(`/agent/sessions/${currentSessionId}/loeschen/`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'X-CSRFToken': CSRF },
|
||||||
|
});
|
||||||
|
agentNewSession();
|
||||||
|
agentLoadSessions();
|
||||||
|
} catch(e) { /* ignore */ }
|
||||||
|
};
|
||||||
|
|
||||||
|
function updateSessionSelect(id) {
|
||||||
|
const sel = document.getElementById('agent-session-select');
|
||||||
|
let found = false;
|
||||||
|
for (const opt of sel.options) { if (opt.value === id) { found = true; break; } }
|
||||||
|
if (!found) {
|
||||||
|
const opt = document.createElement('option');
|
||||||
|
opt.value = id;
|
||||||
|
opt.textContent = '(aktuelle Unterhaltung)';
|
||||||
|
sel.appendChild(opt);
|
||||||
|
}
|
||||||
|
sel.value = id;
|
||||||
|
}
|
||||||
|
|
||||||
|
function escHtml(s) {
|
||||||
|
return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
{% endif %}
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
109
app/templates/berichte/bericht_modular.html
Normal file
109
app/templates/berichte/bericht_modular.html
Normal file
@@ -0,0 +1,109 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="de">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<title>{{ bericht_titel }} – {{ corporate_settings.stiftung_name }}</title>
|
||||||
|
<style>
|
||||||
|
{{ css_content }}
|
||||||
|
/* Cover page styles */
|
||||||
|
.cover-page {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
min-height: 85vh;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.cover-logo-img { max-height: 100px; max-width: 250px; margin-bottom: 30px; }
|
||||||
|
.cover-title h1 {
|
||||||
|
font-size: 24pt;
|
||||||
|
color: {{ corporate_settings.primary_color|default:"#2c3e50" }};
|
||||||
|
margin: 0 0 10px 0;
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
.cover-title h2 {
|
||||||
|
font-size: 18pt;
|
||||||
|
color: {{ corporate_settings.secondary_color|default:"#3498db" }};
|
||||||
|
margin: 0 0 5px 0;
|
||||||
|
}
|
||||||
|
.cover-subtitle { font-size: 12pt; color: #666; }
|
||||||
|
.cover-meta { margin-top: 40px; font-size: 11pt; color: #555; }
|
||||||
|
.cover-meta p { margin: 5px 0; }
|
||||||
|
.cover-footer { margin-top: 60px; font-size: 9pt; color: #999; }
|
||||||
|
.cover-footer p { margin: 3px 0; }
|
||||||
|
.cover-confidential {
|
||||||
|
margin-top: 15px !important;
|
||||||
|
font-weight: bold;
|
||||||
|
color: {{ corporate_settings.primary_color|default:"#2c3e50" }} !important;
|
||||||
|
font-size: 10pt;
|
||||||
|
}
|
||||||
|
/* Bilanz cards */
|
||||||
|
.bilanz-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(3, 1fr);
|
||||||
|
gap: 16px;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
.bilanz-card { border-radius: 8px; padding: 16px; text-align: center; }
|
||||||
|
.bilanz-card.einnahmen { background: #d4edda; border: 1px solid #c3e6cb; }
|
||||||
|
.bilanz-card.ausgaben { background: #f8d7da; border: 1px solid #f5c6cb; }
|
||||||
|
.bilanz-card.netto-positiv { background: #d1ecf1; border: 1px solid #bee5eb; }
|
||||||
|
.bilanz-card.netto-negativ { background: #fff3cd; border: 1px solid #ffeeba; }
|
||||||
|
.bilanz-card .value { font-size: 1.5em; font-weight: bold; }
|
||||||
|
.bilanz-card .label { font-size: 0.85em; margin-top: 4px; color: #555; }
|
||||||
|
/* Status badges extra */
|
||||||
|
.status-aktiv { background-color: #d4edda; color: #155724; }
|
||||||
|
.status-beendet { background-color: #e2e3e5; color: #383d41; }
|
||||||
|
.status-gekuendigt { background-color: #f8d7da; color: #721c24; }
|
||||||
|
.status-geplant, .status-faellig { background-color: #e2e3e5; color: #383d41; }
|
||||||
|
.status-abgeschlossen { background-color: #d4edda; color: #155724; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
{% if show_cover %}
|
||||||
|
{% include "berichte/cover_page.html" %}
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<!-- Kopfzeile (auf jeder Seite nach dem Deckblatt) -->
|
||||||
|
<div class="header">
|
||||||
|
<div class="header-content">
|
||||||
|
<div class="header-left">
|
||||||
|
{% if logo_base64 %}
|
||||||
|
<img src="{{ logo_base64 }}" alt="Logo" class="logo">
|
||||||
|
{% endif %}
|
||||||
|
<p class="stiftung-name">{{ corporate_settings.stiftung_name }}</p>
|
||||||
|
<p class="document-title">{{ bericht_titel }}</p>
|
||||||
|
</div>
|
||||||
|
<div class="header-right">
|
||||||
|
<div class="contact-info">
|
||||||
|
{% if corporate_settings.address_line1 %}<p>{{ corporate_settings.address_line1 }}</p>{% endif %}
|
||||||
|
{% if corporate_settings.address_line2 %}<p>{{ corporate_settings.address_line2 }}</p>{% endif %}
|
||||||
|
{% if corporate_settings.phone %}<p>{{ corporate_settings.phone }}</p>{% endif %}
|
||||||
|
{% if corporate_settings.email %}<p>{{ corporate_settings.email }}</p>{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="header-info">
|
||||||
|
Erstellt am {% now "d.m.Y" %}{% if berichtszeitraum %} · Zeitraum: {{ berichtszeitraum }}{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Dynamische Sektionen -->
|
||||||
|
{% for sektion in sektionen %}
|
||||||
|
{% if sektion == "bilanz" %}{% include "berichte/sektionen/bilanz.html" %}
|
||||||
|
{% elif sektion == "unterstuetzungen" %}{% include "berichte/sektionen/unterstuetzungen.html" %}
|
||||||
|
{% elif sektion == "foerderungen" %}{% include "berichte/sektionen/foerderungen.html" %}
|
||||||
|
{% elif sektion == "grundstuecke" %}{% include "berichte/sektionen/grundstuecke.html" %}
|
||||||
|
{% elif sektion == "verwaltungskosten" %}{% include "berichte/sektionen/verwaltungskosten.html" %}
|
||||||
|
{% elif sektion == "destinataere_uebersicht" %}{% include "berichte/sektionen/destinataere_uebersicht.html" %}
|
||||||
|
{% elif sektion == "konten_uebersicht" %}{% include "berichte/sektionen/konten_uebersicht.html" %}
|
||||||
|
{% elif sektion == "verpachtungen" %}{% include "berichte/sektionen/verpachtungen.html" %}
|
||||||
|
{% endif %}
|
||||||
|
{% endfor %}
|
||||||
|
|
||||||
|
<div class="footer">
|
||||||
|
<p>{{ bericht_titel }} — automatisch generiert von der Stiftungsverwaltung</p>
|
||||||
|
<p>{{ corporate_settings.stiftung_name }} · {{ corporate_settings.footer_text }}</p>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
39
app/templates/berichte/cover_page.html
Normal file
39
app/templates/berichte/cover_page.html
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
<!-- Deckblatt / Cover Page für Berichte -->
|
||||||
|
<div class="cover-page">
|
||||||
|
<div class="cover-logo">
|
||||||
|
{% if logo_base64 %}
|
||||||
|
<img src="{{ logo_base64 }}" alt="Logo" class="cover-logo-img">
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
<div class="cover-title">
|
||||||
|
<h1>{{ corporate_settings.stiftung_name }}</h1>
|
||||||
|
<h2>{{ bericht_titel }}</h2>
|
||||||
|
{% if bericht_untertitel %}
|
||||||
|
<p class="cover-subtitle">{{ bericht_untertitel }}</p>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
<div class="cover-meta">
|
||||||
|
<p><strong>Berichtszeitraum:</strong> {{ berichtszeitraum }}</p>
|
||||||
|
<p><strong>Erstellt am:</strong> {% now "d.m.Y" %}</p>
|
||||||
|
{% if cover_freitext %}
|
||||||
|
<p>{{ cover_freitext }}</p>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
<div class="cover-footer">
|
||||||
|
{% if corporate_settings.address_line1 %}
|
||||||
|
<p>{{ corporate_settings.address_line1 }}</p>
|
||||||
|
{% endif %}
|
||||||
|
{% if corporate_settings.address_line2 %}
|
||||||
|
<p>{{ corporate_settings.address_line2 }}</p>
|
||||||
|
{% endif %}
|
||||||
|
{% if corporate_settings.phone or corporate_settings.email %}
|
||||||
|
<p>
|
||||||
|
{% if corporate_settings.phone %}Tel.: {{ corporate_settings.phone }}{% endif %}
|
||||||
|
{% if corporate_settings.phone and corporate_settings.email %} · {% endif %}
|
||||||
|
{% if corporate_settings.email %}{{ corporate_settings.email }}{% endif %}
|
||||||
|
</p>
|
||||||
|
{% endif %}
|
||||||
|
<p class="cover-confidential">Vertraulich</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style="page-break-after: always;"></div>
|
||||||
36
app/templates/berichte/sektionen/bilanz.html
Normal file
36
app/templates/berichte/sektionen/bilanz.html
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
<!-- Sektion: Jahresbilanz -->
|
||||||
|
<div class="section">
|
||||||
|
<h2>Jahresbilanz {{ jahr }}</h2>
|
||||||
|
<div class="bilanz-grid">
|
||||||
|
<div class="bilanz-card einnahmen">
|
||||||
|
<div class="value">€{{ total_einnahmen|floatformat:2 }}</div>
|
||||||
|
<div class="label">Einnahmen (Pacht)</div>
|
||||||
|
</div>
|
||||||
|
<div class="bilanz-card ausgaben">
|
||||||
|
<div class="value">€{{ total_ausgaben|floatformat:2 }}</div>
|
||||||
|
<div class="label">Ausgaben gesamt</div>
|
||||||
|
</div>
|
||||||
|
<div class="bilanz-card {% if netto >= 0 %}netto-positiv{% else %}netto-negativ{% endif %}">
|
||||||
|
<div class="value">{% if netto >= 0 %}+{% endif %}€{{ netto|floatformat:2 }}</div>
|
||||||
|
<div class="label">Nettosaldo</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="stats-grid">
|
||||||
|
<div class="stat-card">
|
||||||
|
<div class="value">€{{ total_ausgaben_foerderung|floatformat:2 }}</div>
|
||||||
|
<div class="label">Förderausgaben</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat-card">
|
||||||
|
<div class="value">€{{ total_verwaltungskosten|floatformat:2 }}</div>
|
||||||
|
<div class="label">Verwaltungskosten</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat-card">
|
||||||
|
<div class="value">€{{ pacht_vereinnahmt|floatformat:2 }}</div>
|
||||||
|
<div class="label">Pacht vereinnahmt</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat-card">
|
||||||
|
<div class="value">€{{ grundsteuer_gesamt|floatformat:2 }}</div>
|
||||||
|
<div class="label">Grundsteuer</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
<!-- Sektion: Destinatär-Übersicht -->
|
||||||
|
<div class="section">
|
||||||
|
<h2>Destinatär-Übersicht{% if jahr %} {{ jahr }}{% endif %}</h2>
|
||||||
|
|
||||||
|
<div class="stats-grid">
|
||||||
|
<div class="stat-card">
|
||||||
|
<div class="value">{{ destinataere_aktiv }}</div>
|
||||||
|
<div class="label">Aktive Destinatäre</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat-card">
|
||||||
|
<div class="value">{{ destinataere_gesamt }}</div>
|
||||||
|
<div class="label">Gesamt</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat-card">
|
||||||
|
<div class="value">€{{ destinataere_total_unterstuetzung|floatformat:2 }}</div>
|
||||||
|
<div class="label">Gesamte Unterstützungen</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if destinataere_liste %}
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Name</th>
|
||||||
|
<th>Ort</th>
|
||||||
|
<th>Berufsgruppe</th>
|
||||||
|
<th>Aktiv</th>
|
||||||
|
<th>Unterstützungen</th>
|
||||||
|
<th>Betrag gesamt</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for d in destinataere_liste %}
|
||||||
|
<tr>
|
||||||
|
<td>{{ d.get_full_name }}</td>
|
||||||
|
<td>{{ d.ort|default:"-" }}</td>
|
||||||
|
<td>{{ d.get_berufsgruppe_display|default:"-" }}</td>
|
||||||
|
<td>{% if d.aktiv %}Ja{% else %}Nein{% endif %}</td>
|
||||||
|
<td>{{ d.unterstuetzung_count }}</td>
|
||||||
|
<td class="amount">€{{ d.unterstuetzung_summe|floatformat:2 }}</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
41
app/templates/berichte/sektionen/foerderungen.html
Normal file
41
app/templates/berichte/sektionen/foerderungen.html
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
<!-- Sektion: Förderungen (Legacy) -->
|
||||||
|
{% if foerderungen %}
|
||||||
|
<div class="section">
|
||||||
|
<h2>Förderungen {{ jahr }}</h2>
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Begünstigter</th>
|
||||||
|
<th>Kategorie</th>
|
||||||
|
<th>Betrag</th>
|
||||||
|
<th>Status</th>
|
||||||
|
<th>Antragsdatum</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for f in foerderungen %}
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
{% if f.destinataer %}{{ f.destinataer.get_full_name }}
|
||||||
|
{% elif f.person %}{{ f.person.get_full_name }}
|
||||||
|
{% else %}–{% endif %}
|
||||||
|
</td>
|
||||||
|
<td>{{ f.get_kategorie_display }}</td>
|
||||||
|
<td class="amount">€{{ f.betrag|floatformat:2 }}</td>
|
||||||
|
<td>
|
||||||
|
<span class="status-badge status-{{ f.status }}">{{ f.get_status_display }}</span>
|
||||||
|
</td>
|
||||||
|
<td>{{ f.antragsdatum|date:"d.m.Y" }}</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
<tfoot>
|
||||||
|
<tr style="font-weight: bold; background: #f0f7f4;">
|
||||||
|
<td colspan="2">Summe</td>
|
||||||
|
<td class="amount">€{{ total_foerderungen_legacy|floatformat:2 }}</td>
|
||||||
|
<td colspan="2"></td>
|
||||||
|
</tr>
|
||||||
|
</tfoot>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
76
app/templates/berichte/sektionen/grundstuecke.html
Normal file
76
app/templates/berichte/sektionen/grundstuecke.html
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
<!-- Sektion: Grundstücksverwaltung -->
|
||||||
|
<div class="section">
|
||||||
|
<h2>Grundstücksverwaltung</h2>
|
||||||
|
|
||||||
|
{% if verpachtungen %}
|
||||||
|
<h3>Aktive Verpachtungen</h3>
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Länderei</th>
|
||||||
|
<th>Pächter</th>
|
||||||
|
<th>Verpachtete Fläche</th>
|
||||||
|
<th>Jahrespachtzins</th>
|
||||||
|
<th>Pachtende</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for v in verpachtungen %}
|
||||||
|
<tr>
|
||||||
|
<td>{{ v.land }}</td>
|
||||||
|
<td>{{ v.paechter.get_full_name }}</td>
|
||||||
|
<td class="amount">{{ v.verpachtete_flaeche|floatformat:0 }} qm</td>
|
||||||
|
<td class="amount">€{{ v.pachtzins_pauschal|floatformat:2 }}</td>
|
||||||
|
<td>{% if v.pachtende %}{{ v.pachtende|date:"d.m.Y" }}{% else %}unbefristet{% endif %}</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
<tfoot>
|
||||||
|
<tr style="font-weight: bold; background: #f0f7f4;">
|
||||||
|
<td colspan="3">Gesamtpachtzins (kalkuliert)</td>
|
||||||
|
<td class="amount">€{{ total_pachtzins|floatformat:2 }}</td>
|
||||||
|
<td></td>
|
||||||
|
</tr>
|
||||||
|
</tfoot>
|
||||||
|
</table>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if landabrechnungen %}
|
||||||
|
<h3>Landabrechnungen {{ jahr }}</h3>
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Länderei</th>
|
||||||
|
<th>Pacht vereinnahmt</th>
|
||||||
|
<th>Umlagen</th>
|
||||||
|
<th>Grundsteuer</th>
|
||||||
|
<th>Sonstige Einnahmen</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for a in landabrechnungen %}
|
||||||
|
<tr>
|
||||||
|
<td>{{ a.land }}</td>
|
||||||
|
<td class="amount">€{{ a.pacht_vereinnahmt|floatformat:2 }}</td>
|
||||||
|
<td class="amount">€{{ a.umlagen_vereinnahmt|floatformat:2 }}</td>
|
||||||
|
<td class="amount">€{{ a.grundsteuer_betrag|floatformat:2 }}</td>
|
||||||
|
<td class="amount">€{{ a.sonstige_einnahmen|floatformat:2 }}</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
<tfoot>
|
||||||
|
<tr style="font-weight: bold; background: #f0f7f4;">
|
||||||
|
<td>Summe</td>
|
||||||
|
<td class="amount">€{{ pacht_vereinnahmt|floatformat:2 }}</td>
|
||||||
|
<td></td>
|
||||||
|
<td class="amount">€{{ grundsteuer_gesamt|floatformat:2 }}</td>
|
||||||
|
<td></td>
|
||||||
|
</tr>
|
||||||
|
</tfoot>
|
||||||
|
</table>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if not verpachtungen and not landabrechnungen %}
|
||||||
|
<p style="color: #999;">Keine Verpachtungs- oder Abrechnungsdaten für {{ jahr }} vorhanden.</p>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
46
app/templates/berichte/sektionen/konten_uebersicht.html
Normal file
46
app/templates/berichte/sektionen/konten_uebersicht.html
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
<!-- Sektion: Konten-Übersicht -->
|
||||||
|
<div class="section">
|
||||||
|
<h2>Kontenübersicht</h2>
|
||||||
|
|
||||||
|
<div class="stats-grid">
|
||||||
|
<div class="stat-card">
|
||||||
|
<div class="value">{{ konten_anzahl }}</div>
|
||||||
|
<div class="label">Aktive Konten</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat-card">
|
||||||
|
<div class="value">€{{ konten_gesamtsaldo|floatformat:2 }}</div>
|
||||||
|
<div class="label">Gesamtsaldo</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if konten_liste %}
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Kontoname</th>
|
||||||
|
<th>Bank</th>
|
||||||
|
<th>Kontotyp</th>
|
||||||
|
<th>IBAN</th>
|
||||||
|
<th>Saldo</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for k in konten_liste %}
|
||||||
|
<tr>
|
||||||
|
<td>{{ k.kontoname }}</td>
|
||||||
|
<td>{{ k.bank_name }}</td>
|
||||||
|
<td>{{ k.get_konto_typ_display }}</td>
|
||||||
|
<td>{{ k.iban|default:"-" }}</td>
|
||||||
|
<td class="amount">€{{ k.saldo|floatformat:2 }}</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
<tfoot>
|
||||||
|
<tr style="font-weight: bold; background: #f0f7f4;">
|
||||||
|
<td colspan="4">Gesamtsaldo</td>
|
||||||
|
<td class="amount">€{{ konten_gesamtsaldo|floatformat:2 }}</td>
|
||||||
|
</tr>
|
||||||
|
</tfoot>
|
||||||
|
</table>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
41
app/templates/berichte/sektionen/unterstuetzungen.html
Normal file
41
app/templates/berichte/sektionen/unterstuetzungen.html
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
<!-- Sektion: Unterstützungszahlungen -->
|
||||||
|
{% if unterstuetzungen %}
|
||||||
|
<div class="section">
|
||||||
|
<h2>Unterstützungszahlungen {{ jahr }}</h2>
|
||||||
|
<p style="color: #666; margin-bottom: 12px;">
|
||||||
|
{{ unterstuetzungen.count }} Unterstützung(en) geplant/ausgezahlt ·
|
||||||
|
{{ unterstuetzungen_ausgezahlt.count }} überwiesen (€{{ total_unterstuetzungen|floatformat:2 }})
|
||||||
|
</p>
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Destinatär</th>
|
||||||
|
<th>Betrag</th>
|
||||||
|
<th>Fällig am</th>
|
||||||
|
<th>Status</th>
|
||||||
|
<th>Verwendungszweck</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for u in unterstuetzungen %}
|
||||||
|
<tr>
|
||||||
|
<td>{{ u.destinataer.get_full_name }}</td>
|
||||||
|
<td class="amount">€{{ u.betrag|floatformat:2 }}</td>
|
||||||
|
<td>{{ u.faellig_am|date:"d.m.Y" }}</td>
|
||||||
|
<td>
|
||||||
|
<span class="status-badge status-{{ u.status }}">{{ u.get_status_display }}</span>
|
||||||
|
</td>
|
||||||
|
<td>{{ u.beschreibung|default:"-" }}</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
<tfoot>
|
||||||
|
<tr style="font-weight: bold; background: #f0f7f4;">
|
||||||
|
<td>Summe ausgezahlt</td>
|
||||||
|
<td class="amount">€{{ total_unterstuetzungen|floatformat:2 }}</td>
|
||||||
|
<td colspan="3"></td>
|
||||||
|
</tr>
|
||||||
|
</tfoot>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
88
app/templates/berichte/sektionen/verpachtungen.html
Normal file
88
app/templates/berichte/sektionen/verpachtungen.html
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
<!-- Sektion: Pachtbericht -->
|
||||||
|
<div class="section">
|
||||||
|
<h2>Pachtbericht{% if jahr %} {{ jahr }}{% endif %}</h2>
|
||||||
|
|
||||||
|
{% if pacht_statistik %}
|
||||||
|
<div class="stats-grid">
|
||||||
|
<div class="stat-card">
|
||||||
|
<div class="value">{{ pacht_statistik.aktive_vertraege }}</div>
|
||||||
|
<div class="label">Aktive Pachtverträge</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat-card">
|
||||||
|
<div class="value">€{{ pacht_statistik.total_pachtzins|floatformat:2 }}</div>
|
||||||
|
<div class="label">Gesamtpachtzins p.a.</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat-card">
|
||||||
|
<div class="value">{{ pacht_statistik.total_flaeche|floatformat:0 }} qm</div>
|
||||||
|
<div class="label">Verpachtete Fläche</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat-card">
|
||||||
|
<div class="value">{{ pacht_statistik.auslaufend_12m }}</div>
|
||||||
|
<div class="label">Laufen in 12 Mon. aus</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if pacht_auslaufend %}
|
||||||
|
<h3>Auslaufende Verträge (nächste 12 Monate)</h3>
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Länderei</th>
|
||||||
|
<th>Pächter</th>
|
||||||
|
<th>Pachtende</th>
|
||||||
|
<th>Pachtzins</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for v in pacht_auslaufend %}
|
||||||
|
<tr>
|
||||||
|
<td>{{ v.land }}</td>
|
||||||
|
<td>{{ v.paechter.get_full_name }}</td>
|
||||||
|
<td>{{ v.pachtende|date:"d.m.Y" }}</td>
|
||||||
|
<td class="amount">€{{ v.pachtzins_pauschal|floatformat:2 }}</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if verpachtungen %}
|
||||||
|
<h3>Alle Verpachtungen</h3>
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Länderei</th>
|
||||||
|
<th>Pächter</th>
|
||||||
|
<th>Fläche</th>
|
||||||
|
<th>Pachtzins</th>
|
||||||
|
<th>Beginn</th>
|
||||||
|
<th>Ende</th>
|
||||||
|
<th>Status</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for v in verpachtungen %}
|
||||||
|
<tr>
|
||||||
|
<td>{{ v.land }}</td>
|
||||||
|
<td>{{ v.paechter.get_full_name }}</td>
|
||||||
|
<td class="amount">{{ v.verpachtete_flaeche|floatformat:0 }} qm</td>
|
||||||
|
<td class="amount">€{{ v.pachtzins_pauschal|floatformat:2 }}</td>
|
||||||
|
<td>{{ v.pachtbeginn|date:"d.m.Y" }}</td>
|
||||||
|
<td>{% if v.pachtende %}{{ v.pachtende|date:"d.m.Y" }}{% else %}unbefristet{% endif %}</td>
|
||||||
|
<td>
|
||||||
|
<span class="status-badge status-{{ v.status }}">{{ v.get_status_display }}</span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
<tfoot>
|
||||||
|
<tr style="font-weight: bold; background: #f0f7f4;">
|
||||||
|
<td colspan="3">Gesamtpachtzins</td>
|
||||||
|
<td class="amount">€{{ total_pachtzins|floatformat:2 }}</td>
|
||||||
|
<td colspan="3"></td>
|
||||||
|
</tr>
|
||||||
|
</tfoot>
|
||||||
|
</table>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
30
app/templates/berichte/sektionen/verwaltungskosten.html
Normal file
30
app/templates/berichte/sektionen/verwaltungskosten.html
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
<!-- Sektion: Verwaltungskosten -->
|
||||||
|
{% if verwaltungskosten_nach_kategorie %}
|
||||||
|
<div class="section">
|
||||||
|
<h2>Verwaltungskosten {{ jahr }}</h2>
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Kategorie</th>
|
||||||
|
<th>Anzahl</th>
|
||||||
|
<th>Betrag</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for k in verwaltungskosten_nach_kategorie %}
|
||||||
|
<tr>
|
||||||
|
<td>{{ k.kategorie|capfirst }}</td>
|
||||||
|
<td>{{ k.anzahl }}</td>
|
||||||
|
<td class="amount">€{{ k.summe|floatformat:2 }}</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
<tfoot>
|
||||||
|
<tr style="font-weight: bold; background: #f0f7f4;">
|
||||||
|
<td colspan="2">Gesamt</td>
|
||||||
|
<td class="amount">€{{ total_verwaltungskosten|floatformat:2 }}</td>
|
||||||
|
</tr>
|
||||||
|
</tfoot>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
56
app/templates/email/bestaetigung.html
Normal file
56
app/templates/email/bestaetigung.html
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="de">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Bestätigung Ihrer Förderung – van Hees-Theyssen-Vogel'sche Stiftung</title>
|
||||||
|
<style>
|
||||||
|
body { font-family: Arial, sans-serif; font-size: 15px; color: #222; background: #f5f5f5; margin: 0; padding: 0; }
|
||||||
|
.container { max-width: 600px; margin: 32px auto; background: #fff; border-radius: 8px; box-shadow: 0 2px 8px rgba(0,0,0,0.08); overflow: hidden; }
|
||||||
|
.header { background: #1a3a5c; color: #fff; padding: 28px 32px 20px; }
|
||||||
|
.header h1 { margin: 0 0 4px; font-size: 20px; }
|
||||||
|
.header p { margin: 0; font-size: 13px; opacity: 0.8; }
|
||||||
|
.body { padding: 28px 32px; }
|
||||||
|
.body p { line-height: 1.6; margin: 0 0 16px; }
|
||||||
|
.info-box { background: #f0f6ff; border: 1px solid #b0cce8; border-radius: 6px; padding: 16px 20px; margin: 20px 0; }
|
||||||
|
.info-box p { margin: 0 0 8px; }
|
||||||
|
.info-box p:last-child { margin: 0; }
|
||||||
|
.footer { background: #f0f0f0; padding: 16px 32px; font-size: 12px; color: #777; border-top: 1px solid #e0e0e0; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="container">
|
||||||
|
<div class="header">
|
||||||
|
<h1>van Hees-Theyssen-Vogel'sche Stiftung</h1>
|
||||||
|
<p>Bestätigung Ihrer Förderleistungen</p>
|
||||||
|
</div>
|
||||||
|
<div class="body">
|
||||||
|
<p>Sehr geehrte{% if destinataer.anrede == "Herr" %}r Herr{% elif destinataer.anrede == "Frau" %} Frau{% else %}{{ destinataer.anrede }}{% endif %} {{ destinataer.nachname }},</p>
|
||||||
|
|
||||||
|
<p>anbei erhalten Sie Ihre persönliche Bestätigung über die Ihnen gewährte
|
||||||
|
Unterstützung durch die van Hees-Theyssen-Vogel'sche Stiftung{% if zeitraum %}
|
||||||
|
für den Förderzeitraum {{ zeitraum }}{% endif %}.</p>
|
||||||
|
|
||||||
|
<p>Das beigefügte Dokument gilt als offizieller Nachweis der erhaltenen Förderung.</p>
|
||||||
|
|
||||||
|
<div class="info-box">
|
||||||
|
<p><strong>Empfänger:</strong> {{ destinataer.vorname }} {{ destinataer.nachname }}</p>
|
||||||
|
{% if zeitraum %}<p><strong>Förderzeitraum:</strong> {{ zeitraum }}</p>{% endif %}
|
||||||
|
{% if gesamtbetrag %}<p><strong>Gesamtbetrag:</strong> {{ gesamtbetrag|floatformat:2 }} €</p>{% endif %}
|
||||||
|
<p><strong>Erstellt am:</strong> {{ datum|date:"d.m.Y" }}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p>Bei Fragen stehen wir Ihnen gerne zur Verfügung.</p>
|
||||||
|
|
||||||
|
<p>Mit freundlichen Grüßen</p>
|
||||||
|
<p><strong>Jan Remmer Siebels</strong> & <strong>Katrin Kleinpaß</strong><br>
|
||||||
|
Rentmeister / Rentmeisterin<br>
|
||||||
|
van Hees-Theyssen-Vogel'sche Stiftung</p>
|
||||||
|
</div>
|
||||||
|
<div class="footer">
|
||||||
|
van Hees-Theyssen-Vogel'sche Stiftung • Raesfelder Str. 3 • 46499 Hamminkeln • Tel. 02858/836780<br>
|
||||||
|
Diese E-Mail wurde automatisch erzeugt. Bitte antworten Sie nicht direkt auf diese E-Mail.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
79
app/templates/email/nachweis_aufforderung.html
Normal file
79
app/templates/email/nachweis_aufforderung.html
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="de">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>{% if ist_erinnerung %}Erinnerung: {% endif %}Nachweis-Aufforderung {{ halbjahr_label }}</title>
|
||||||
|
<style>
|
||||||
|
body { font-family: Arial, sans-serif; font-size: 15px; color: #222; background: #f5f5f5; margin: 0; padding: 0; }
|
||||||
|
.container { max-width: 600px; margin: 32px auto; background: #fff; border-radius: 8px; box-shadow: 0 2px 8px rgba(0,0,0,0.08); overflow: hidden; }
|
||||||
|
.header { background: #1a3a5c; color: #fff; padding: 28px 32px 20px; }
|
||||||
|
.header h1 { margin: 0 0 4px; font-size: 20px; }
|
||||||
|
.header p { margin: 0; font-size: 13px; opacity: 0.8; }
|
||||||
|
.body { padding: 28px 32px; }
|
||||||
|
.body p { line-height: 1.6; margin: 0 0 16px; }
|
||||||
|
.cta-box { background: #f0f6ff; border: 1px solid #b0cce8; border-radius: 6px; padding: 20px; margin: 24px 0; text-align: center; }
|
||||||
|
.cta-button { display: inline-block; background: #1a3a5c; color: #fff !important; text-decoration: none; padding: 12px 28px; border-radius: 5px; font-weight: bold; font-size: 16px; margin-bottom: 12px; }
|
||||||
|
.qr-section { margin-top: 12px; }
|
||||||
|
.qr-section img { width: 140px; height: 140px; display: block; margin: 8px auto 0; }
|
||||||
|
.qr-section small { display: block; color: #666; font-size: 12px; margin-top: 4px; }
|
||||||
|
.info-list { background: #fafafa; border-left: 3px solid #1a3a5c; padding: 12px 16px; margin: 16px 0; }
|
||||||
|
.info-list li { margin-bottom: 4px; }
|
||||||
|
.footer { background: #f0f0f0; padding: 16px 32px; font-size: 12px; color: #777; border-top: 1px solid #e0e0e0; }
|
||||||
|
.reminder-banner { background: #fff3cd; border: 1px solid #ffc107; border-radius: 5px; padding: 10px 16px; margin-bottom: 16px; font-weight: bold; color: #856404; }
|
||||||
|
.url-fallback { font-size: 12px; color: #555; word-break: break-all; margin-top: 8px; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="container">
|
||||||
|
<div class="header">
|
||||||
|
<h1>van Hees-Theyssen-Vogel'sche Stiftung</h1>
|
||||||
|
<p>Nachweis-Aufforderung • {{ halbjahr_label }}</p>
|
||||||
|
</div>
|
||||||
|
<div class="body">
|
||||||
|
{% if ist_erinnerung %}
|
||||||
|
<div class="reminder-banner">🔔 Erinnerung: Ihre Unterlagen stehen noch aus</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<p>Sehr geehrte(r) {{ destinataer.vorname }} {{ destinataer.nachname }},</p>
|
||||||
|
|
||||||
|
{% if ist_erinnerung %}
|
||||||
|
<p>wir möchten Sie daran erinnern, dass Ihre Unterlagen für das <strong>{{ halbjahr_label }}</strong> noch ausstehen.</p>
|
||||||
|
<p>Der Upload-Link läuft am <strong>{{ gueltig_bis|date:"d.m.Y" }}</strong> ab.</p>
|
||||||
|
{% else %}
|
||||||
|
<p>die van Hees-Theyssen-Vogel'sche Stiftung bittet Sie, Ihre Unterlagen für das <strong>{{ halbjahr_label }}</strong> einzureichen.</p>
|
||||||
|
<p>Bitte reichen Sie bis zum <strong>{{ gueltig_bis|date:"d.m.Y" }}</strong> folgende Unterlagen ein:</p>
|
||||||
|
<ul class="info-list">
|
||||||
|
<li>Semesterbescheinigung / Ausbildungsnachweis</li>
|
||||||
|
<li>Leistungsnachweise (Zeugnisse, Kreditpunkte etc.)</li>
|
||||||
|
<li>Nachweis über Einkommenssituation und Vermögensverhältnisse</li>
|
||||||
|
</ul>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<div class="cta-box">
|
||||||
|
<a href="{{ upload_url }}" class="cta-button">Unterlagen hochladen</a>
|
||||||
|
{% if qr_code_base64 %}
|
||||||
|
<div class="qr-section">
|
||||||
|
<small>Oder QR-Code scannen:</small>
|
||||||
|
<img src="data:image/png;base64,{{ qr_code_base64 }}" alt="QR-Code für Upload-Link">
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
<p class="url-fallback">{{ upload_url }}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p>Dieser Link ist <strong>einmalig verwendbar</strong> und gültig bis <strong>{{ gueltig_bis|date:"d.m.Y" }}</strong>.</p>
|
||||||
|
<p>Falls Sie Fragen haben, wenden Sie sich bitte direkt an die Stiftung:<br>
|
||||||
|
Tel. 02858/836780 • <a href="mailto:Jan.Siebels@gmail.com">Jan.Siebels@gmail.com</a></p>
|
||||||
|
|
||||||
|
<p>Mit freundlichen Grüßen</p>
|
||||||
|
<p><strong>Jan Remmer Siebels</strong> & <strong>Katrin Kleinpaß</strong><br>
|
||||||
|
Rentmeister / Rentmeisterin</p>
|
||||||
|
</div>
|
||||||
|
<div class="footer">
|
||||||
|
van Hees-Theyssen-Vogel'sche Stiftung • Raesfelder Str. 3 • 46499 Hamminkeln • Tel. 02858/836780<br>
|
||||||
|
Diese E-Mail wurde automatisch erzeugt. Bitte antworten Sie nicht direkt auf diese E-Mail.<br>
|
||||||
|
<a href="{{ datenschutz_url }}" style="color:#999;">Datenschutzerklärung</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
38
app/templates/email/nachweis_aufforderung.txt
Normal file
38
app/templates/email/nachweis_aufforderung.txt
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
{% if ist_erinnerung %}ERINNERUNG: {% endif %}Nachweis-Aufforderung {{ halbjahr_label }} – van Hees-Theyssen-Vogel'sche Stiftung
|
||||||
|
================================================================================
|
||||||
|
|
||||||
|
Sehr geehrte(r) {{ destinataer.vorname }} {{ destinataer.nachname }},
|
||||||
|
{% if ist_erinnerung %}
|
||||||
|
wir möchten Sie daran erinnern, dass Ihre Unterlagen für das {{ halbjahr_label }}
|
||||||
|
noch ausstehen. Der Upload-Link läuft am {{ gueltig_bis|date:"d.m.Y" }} ab.
|
||||||
|
{% else %}
|
||||||
|
die van Hees-Theyssen-Vogel'sche Stiftung bittet Sie, Ihre Unterlagen
|
||||||
|
für das {{ halbjahr_label }} einzureichen.
|
||||||
|
|
||||||
|
Bitte reichen Sie bis zum {{ gueltig_bis|date:"d.m.Y" }} folgende Unterlagen ein:
|
||||||
|
- Semesterbescheinigung / Ausbildungsnachweis (mindestens einmal jährlich)
|
||||||
|
- Leistungsnachweise (Zeugnisse, Kreditpunkte etc.)
|
||||||
|
- Nachweis über Einkommenssituation und Vermögensverhältnisse
|
||||||
|
(falls sich Veränderungen ergeben haben)
|
||||||
|
{% endif %}
|
||||||
|
Ihre Unterlagen können Sie über folgenden Link hochladen:
|
||||||
|
{{ upload_url }}
|
||||||
|
|
||||||
|
Dieser Link ist einmalig verwendbar und gültig bis {{ gueltig_bis|date:"d.m.Y" }}.
|
||||||
|
|
||||||
|
Falls Sie den Link nicht verwenden können, wenden Sie sich bitte direkt
|
||||||
|
an die Stiftung (Tel. 02858/836780 oder Jan.Siebels@gmail.com).
|
||||||
|
|
||||||
|
Mit freundlichen Grüßen
|
||||||
|
|
||||||
|
Jan Remmer Siebels Katrin Kleinpaß
|
||||||
|
(Rentmeister) (Rentmeisterin)
|
||||||
|
|
||||||
|
van Hees-Theyssen-Vogel'sche Stiftung
|
||||||
|
Raesfelder Str. 3
|
||||||
|
46499 Hamminkeln
|
||||||
|
Tel. 02858/836780
|
||||||
|
|
||||||
|
---
|
||||||
|
Diese E-Mail wurde automatisch erzeugt. Bitte antworten Sie nicht direkt auf diese E-Mail.
|
||||||
|
Datenschutzerklärung: {{ datenschutz_url }}
|
||||||
63
app/templates/email/onboarding_einladung.html
Normal file
63
app/templates/email/onboarding_einladung.html
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="de">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Onboarding-Einladung – vHTV-Stiftung</title>
|
||||||
|
<style>
|
||||||
|
body { font-family: Arial, sans-serif; color: #333; background: #f5f5f5; margin: 0; padding: 0; }
|
||||||
|
.container { max-width: 600px; margin: 32px auto; background: #fff; border-radius: 8px; overflow: hidden; box-shadow: 0 2px 8px rgba(0,0,0,0.08); }
|
||||||
|
.header { background: #2c5f2e; color: #fff; padding: 32px 32px 24px; }
|
||||||
|
.header h1 { margin: 0; font-size: 22px; }
|
||||||
|
.header p { margin: 8px 0 0; opacity: 0.85; font-size: 14px; }
|
||||||
|
.body { padding: 32px; }
|
||||||
|
.body p { line-height: 1.6; }
|
||||||
|
.btn { display: inline-block; margin: 24px 0 16px; padding: 14px 28px; background: #2c5f2e; color: #fff !important; text-decoration: none; border-radius: 6px; font-size: 16px; font-weight: bold; }
|
||||||
|
.info-box { background: #f0f7f0; border-left: 4px solid #2c5f2e; padding: 16px; margin: 20px 0; border-radius: 4px; }
|
||||||
|
.info-box ul { margin: 8px 0; padding-left: 20px; }
|
||||||
|
.info-box li { margin: 6px 0; }
|
||||||
|
.link-fallback { font-size: 12px; color: #666; word-break: break-all; margin-top: 8px; }
|
||||||
|
.footer { background: #f8f8f8; padding: 20px 32px; font-size: 12px; color: #888; border-top: 1px solid #eee; }
|
||||||
|
.expiry { color: #c0392b; font-weight: bold; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="container">
|
||||||
|
<div class="header">
|
||||||
|
<h1>van Hees-Theyssen-Vogel'sche Stiftung</h1>
|
||||||
|
<p>Einladung zum Onboarding-Verfahren</p>
|
||||||
|
</div>
|
||||||
|
<div class="body">
|
||||||
|
<p>Sehr geehrte Damen und Herren,{% if einladung.vorname %} liebe/r <strong>{{ einladung.vorname }}{% if einladung.nachname %} {{ einladung.nachname }}{% endif %}</strong>{% endif %},</p>
|
||||||
|
|
||||||
|
<p>Sie wurden zur Aufnahme in die <strong>van Hees-Theyssen-Vogel'sche Stiftung</strong> eingeladen. Um das Antragsverfahren zu starten, klicken Sie bitte auf den folgenden Button:</p>
|
||||||
|
|
||||||
|
<a href="{{ onboarding_url }}" class="btn">Jetzt Onboarding starten</a>
|
||||||
|
|
||||||
|
<p class="link-fallback">Oder kopieren Sie diesen Link in Ihren Browser:<br>{{ onboarding_url }}</p>
|
||||||
|
|
||||||
|
<p class="expiry">Dieser Link ist gültig bis: {{ gueltig_bis|date:"d.m.Y H:i" }} Uhr.</p>
|
||||||
|
|
||||||
|
<div class="info-box">
|
||||||
|
<strong>Was Sie erwartet:</strong>
|
||||||
|
<ul>
|
||||||
|
<li>Zustimmung zur Datenschutzerklärung</li>
|
||||||
|
<li>Persönliche Daten (gemäß Stiftungsmerkblatt)</li>
|
||||||
|
<li>Angaben zu Ausbildung / Studium</li>
|
||||||
|
<li>Angaben zur finanziellen Situation</li>
|
||||||
|
<li>Upload relevanter Dokumente</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p>Nach Abschluss des Verfahrens prüft der Vorstand Ihren Antrag und setzt sich mit Ihnen in Verbindung.</p>
|
||||||
|
|
||||||
|
<p>Bei Fragen wenden Sie sich bitte an uns:</p>
|
||||||
|
</div>
|
||||||
|
<div class="footer">
|
||||||
|
van Hees-Theyssen-Vogel'sche Stiftung<br>
|
||||||
|
Raesfelder Str. 3, 46499 Hamminkeln<br>
|
||||||
|
Tel. 02858/836780
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
24
app/templates/email/onboarding_einladung.txt
Normal file
24
app/templates/email/onboarding_einladung.txt
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
Sehr geehrte Damen und Herren,{% if einladung.vorname %} liebe/r {{ einladung.vorname }}{% if einladung.nachname %} {{ einladung.nachname }}{% endif %}{% endif %},
|
||||||
|
|
||||||
|
Sie wurden zur Aufnahme in die van Hees-Theyssen-Vogel'sche Stiftung eingeladen.
|
||||||
|
|
||||||
|
Um das Antragsverfahren zu starten, folgen Sie bitte diesem Einmal-Link:
|
||||||
|
|
||||||
|
{{ onboarding_url }}
|
||||||
|
|
||||||
|
Der Link ist gültig bis: {{ gueltig_bis|date:"d.m.Y H:i" }} Uhr.
|
||||||
|
|
||||||
|
Im Onboarding-Verfahren werden Sie gebeten:
|
||||||
|
- der Datenschutzerklärung zuzustimmen,
|
||||||
|
- Ihre persönlichen Daten anzugeben (gemäß Stiftungsmerkblatt),
|
||||||
|
- Ausbildungs- und Einkommensnachweise hochzuladen.
|
||||||
|
|
||||||
|
Nach Abschluss des Verfahrens prüft der Vorstand Ihren Antrag und setzt sich mit Ihnen in Verbindung.
|
||||||
|
|
||||||
|
Falls Sie Fragen haben, wenden Sie sich bitte an:
|
||||||
|
van Hees-Theyssen-Vogel'sche Stiftung
|
||||||
|
Raesfelder Str. 3, 46499 Hamminkeln
|
||||||
|
Tel. 02858/836780
|
||||||
|
|
||||||
|
Mit freundlichen Grüßen
|
||||||
|
van Hees-Theyssen-Vogel'sche Stiftung
|
||||||
254
app/templates/pdf/bestaetigung.html
Normal file
254
app/templates/pdf/bestaetigung.html
Normal file
@@ -0,0 +1,254 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="de">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<title>Bestätigung – {{ destinataer.vorname }} {{ destinataer.nachname }}</title>
|
||||||
|
<style>
|
||||||
|
@page {
|
||||||
|
size: A4;
|
||||||
|
margin: 2cm 2.5cm 2cm 2.5cm;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: "Times New Roman", Times, serif;
|
||||||
|
font-size: 10pt;
|
||||||
|
line-height: 1.4;
|
||||||
|
color: #000;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Absenderzeile (klein, über Adressfeld) */
|
||||||
|
.absender-zeile {
|
||||||
|
font-size: 7.5pt;
|
||||||
|
border-bottom: 1px solid #000;
|
||||||
|
margin-bottom: 3pt;
|
||||||
|
padding-bottom: 1pt;
|
||||||
|
color: #444;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Empfängeradresse */
|
||||||
|
.empfaenger {
|
||||||
|
min-height: 35mm;
|
||||||
|
margin-bottom: 8mm;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empfaenger p {
|
||||||
|
margin: 0;
|
||||||
|
line-height: 1.3;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Datum und Ort */
|
||||||
|
.datum-zeile {
|
||||||
|
text-align: right;
|
||||||
|
margin-bottom: 6mm;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Betreff */
|
||||||
|
.betreff {
|
||||||
|
font-weight: bold;
|
||||||
|
margin-bottom: 6mm;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Brieftext */
|
||||||
|
.brieftext p {
|
||||||
|
margin: 0 0 4mm 0;
|
||||||
|
text-align: justify;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Tabelle der Unterstützungen */
|
||||||
|
.unterstuetzungs-tabelle {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
margin: 6mm 0;
|
||||||
|
font-size: 9.5pt;
|
||||||
|
}
|
||||||
|
|
||||||
|
.unterstuetzungs-tabelle th {
|
||||||
|
border-bottom: 1.5px solid #000;
|
||||||
|
padding: 2mm 3mm;
|
||||||
|
text-align: left;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
.unterstuetzungs-tabelle td {
|
||||||
|
border-bottom: 0.5px solid #ccc;
|
||||||
|
padding: 2mm 3mm;
|
||||||
|
}
|
||||||
|
|
||||||
|
.unterstuetzungs-tabelle tr:last-child td {
|
||||||
|
border-bottom: 1.5px solid #000;
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-right { text-align: right; }
|
||||||
|
|
||||||
|
/* Gesamtsumme */
|
||||||
|
.summen-zeile td {
|
||||||
|
font-weight: bold;
|
||||||
|
padding-top: 3mm;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Unterschrift */
|
||||||
|
.unterschrift {
|
||||||
|
margin-top: 12mm;
|
||||||
|
display: table;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.unterschrift-person {
|
||||||
|
display: inline-block;
|
||||||
|
width: 45%;
|
||||||
|
vertical-align: top;
|
||||||
|
}
|
||||||
|
|
||||||
|
.unterschrift-linie {
|
||||||
|
border-top: 1px solid #000;
|
||||||
|
margin-bottom: 2mm;
|
||||||
|
width: 80%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stiftungsname-header {
|
||||||
|
font-size: 12pt;
|
||||||
|
font-weight: bold;
|
||||||
|
margin-bottom: 1mm;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stiftungsadresse {
|
||||||
|
font-size: 8.5pt;
|
||||||
|
color: #444;
|
||||||
|
margin-bottom: 6mm;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer-hinweis {
|
||||||
|
margin-top: 14mm;
|
||||||
|
font-size: 8pt;
|
||||||
|
color: #555;
|
||||||
|
border-top: 0.5px solid #ccc;
|
||||||
|
padding-top: 3mm;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
|
||||||
|
<!-- Stiftungskopf -->
|
||||||
|
<div class="stiftungsname-header">van Hees-Theyssen-Vogel'sche Stiftung</div>
|
||||||
|
<div class="stiftungsadresse">
|
||||||
|
Raesfelder Str. 3 · 46499 Hamminkeln
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Empfänger (DIN 5008) -->
|
||||||
|
<div class="empfaenger">
|
||||||
|
<div class="absender-zeile">van Hees-Theyssen-Vogel'sche Stiftung · Raesfelder Str. 3 · 46499 Hamminkeln</div>
|
||||||
|
{% if destinataer.anrede %}<p>{{ destinataer.anrede }}</p>{% endif %}
|
||||||
|
<p>{{ destinataer.vorname }} {{ destinataer.nachname }}</p>
|
||||||
|
{% if destinataer.strasse %}<p>{{ destinataer.strasse }}</p>{% endif %}
|
||||||
|
{% if destinataer.plz or destinataer.ort %}<p>{{ destinataer.plz }} {{ destinataer.ort }}</p>{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Datum -->
|
||||||
|
<div class="datum-zeile">
|
||||||
|
Hamminkeln, den {{ datum|date:"j. F Y" }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Betreff -->
|
||||||
|
<div class="betreff">
|
||||||
|
Bestätigung über Förderleistungen der van Hees-Theyssen-Vogel'schen Stiftung
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Brieftext -->
|
||||||
|
<div class="brieftext">
|
||||||
|
<p>
|
||||||
|
Sehr geehrte{% if destinataer.anrede == "Herr" %}r Herr{% elif destinataer.anrede == "Frau" %} Frau{% else %}
|
||||||
|
{{ destinataer.anrede }}{% endif %} {{ destinataer.nachname }},
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p>
|
||||||
|
wir freuen uns, Ihnen hiermit die durch die van Hees-Theyssen-Vogel'sche
|
||||||
|
Stiftung gewährte finanzielle Unterstützung zu bestätigen.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{% if betrag_quartal %}
|
||||||
|
<p>
|
||||||
|
Die Stiftung hat Ihnen{% if zeitraum %} im Förderzeitraum {{ zeitraum }}{% endif %}
|
||||||
|
{% if zweck %} für den Zweck „{{ zweck }}"{% endif %}
|
||||||
|
eine Förderung in Höhe von {{ betrag_quartal|floatformat:2 }} € je Quartal
|
||||||
|
(jährlich: {{ betrag_jaehrlich|floatformat:2 }} €) zuerkannt und ausgezahlt.
|
||||||
|
</p>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<p>
|
||||||
|
Diese Bestätigung dient Ihnen als offizieller Nachweis der erhaltenen
|
||||||
|
Förderung gegenüber Behörden, Bildungseinrichtungen oder anderen
|
||||||
|
zuständigen Stellen.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p>
|
||||||
|
Die van Hees-Theyssen-Vogel'sche Stiftung ist eine gemeinnützige Stiftung
|
||||||
|
des bürgerlichen Rechts mit Sitz in Hamminkeln und verfolgt ausschließlich
|
||||||
|
und unmittelbar gemeinnützige Zwecke im Sinne der §§ 51 ff. AO.
|
||||||
|
Die Förderung erfolgte satzungsgemäß und zweckentsprechend.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{% if unterstuetzungen %}
|
||||||
|
<p>Im {% if zeitraum %}Zeitraum {{ zeitraum }}{% else %}zurückliegenden Förderzeitraum{% endif %}
|
||||||
|
wurden Ihnen folgende Leistungen gewährt:</p>
|
||||||
|
|
||||||
|
<table class="unterstuetzungs-tabelle">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Datum</th>
|
||||||
|
<th>Beschreibung / Zweck</th>
|
||||||
|
<th class="text-right">Betrag</th>
|
||||||
|
<th>Status</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for u in unterstuetzungen %}
|
||||||
|
<tr>
|
||||||
|
<td>{{ u.faellig_am|date:"d.m.Y" }}</td>
|
||||||
|
<td>{{ u.beschreibung|default:"Förderleistung" }}</td>
|
||||||
|
<td class="text-right">{{ u.betrag|floatformat:2 }} €</td>
|
||||||
|
<td>{{ u.get_status_display }}</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
<tfoot>
|
||||||
|
<tr class="summen-zeile">
|
||||||
|
<td colspan="2"><strong>Gesamtbetrag</strong></td>
|
||||||
|
<td class="text-right"><strong>{{ gesamtbetrag|floatformat:2 }} €</strong></td>
|
||||||
|
<td></td>
|
||||||
|
</tr>
|
||||||
|
</tfoot>
|
||||||
|
</table>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<p>
|
||||||
|
Wir wünschen Ihnen weiterhin viel Erfolg bei Ihrem Vorhaben und freuen uns,
|
||||||
|
Sie durch unsere Stiftung unterstützen zu dürfen.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p>Mit freundlichen Grüßen</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Unterschriften -->
|
||||||
|
<div class="unterschrift">
|
||||||
|
<div class="unterschrift-person">
|
||||||
|
<div class="unterschrift-linie"></div>
|
||||||
|
Jan Remmer Siebels<br>
|
||||||
|
Rentmeister<br>
|
||||||
|
van Hees-Theyssen-Vogel'sche Stiftung
|
||||||
|
</div>
|
||||||
|
<div class="unterschrift-person">
|
||||||
|
<div class="unterschrift-linie"></div>
|
||||||
|
Katrin Kleinpaß<br>
|
||||||
|
Rentmeisterin<br>
|
||||||
|
van Hees-Theyssen-Vogel'sche Stiftung
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="footer-hinweis">
|
||||||
|
van Hees-Theyssen-Vogel'sche Stiftung · Raesfelder Str. 3 · 46499 Hamminkeln
|
||||||
|
· Tel. 02858/836780 · buero@vhtv-stiftung.de<br>
|
||||||
|
Gemeinnützige Stiftung des bürgerlichen Rechts
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
424
app/templates/portal/datenschutzerklaerung.html
Normal file
424
app/templates/portal/datenschutzerklaerung.html
Normal file
@@ -0,0 +1,424 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="de">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Datenschutzerklärung – van Hees-Theyssen-Vogel'sche Stiftung</title>
|
||||||
|
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
|
||||||
|
<link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css" rel="stylesheet">
|
||||||
|
<style>
|
||||||
|
:root {
|
||||||
|
--racing-green: #004225;
|
||||||
|
--racing-green-light: #006837;
|
||||||
|
}
|
||||||
|
body {
|
||||||
|
background-color: #f8f9fa;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
line-height: 1.7;
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
.portal-header {
|
||||||
|
background: linear-gradient(135deg, var(--racing-green) 0%, var(--racing-green-light) 100%);
|
||||||
|
color: white;
|
||||||
|
padding: 2rem 0;
|
||||||
|
}
|
||||||
|
.portal-header h1 {
|
||||||
|
font-size: 1.5rem;
|
||||||
|
font-weight: 600;
|
||||||
|
margin-bottom: 0.25rem;
|
||||||
|
}
|
||||||
|
.portal-header .subtitle {
|
||||||
|
font-size: 0.9rem;
|
||||||
|
opacity: 0.85;
|
||||||
|
}
|
||||||
|
.content-card {
|
||||||
|
background: white;
|
||||||
|
border-radius: 12px;
|
||||||
|
box-shadow: 0 2px 12px rgba(0,0,0,0.08);
|
||||||
|
padding: 2.5rem;
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
}
|
||||||
|
h2 {
|
||||||
|
color: var(--racing-green);
|
||||||
|
font-size: 1.15rem;
|
||||||
|
font-weight: 600;
|
||||||
|
border-bottom: 2px solid var(--racing-green);
|
||||||
|
padding-bottom: 0.4rem;
|
||||||
|
margin-top: 2rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
h2:first-of-type {
|
||||||
|
margin-top: 0;
|
||||||
|
}
|
||||||
|
h3 {
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #333;
|
||||||
|
margin-top: 1.25rem;
|
||||||
|
}
|
||||||
|
.dsgvo-article {
|
||||||
|
background: #f0f7f4;
|
||||||
|
border-left: 4px solid var(--racing-green);
|
||||||
|
border-radius: 0 6px 6px 0;
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
margin: 0.5rem 0 1rem 0;
|
||||||
|
font-size: 0.88rem;
|
||||||
|
color: #004225;
|
||||||
|
}
|
||||||
|
.rights-list li {
|
||||||
|
padding: 0.4rem 0;
|
||||||
|
border-bottom: 1px solid #f0f0f0;
|
||||||
|
}
|
||||||
|
.rights-list li:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
.footer-note {
|
||||||
|
font-size: 0.82rem;
|
||||||
|
color: #6c757d;
|
||||||
|
text-align: center;
|
||||||
|
padding: 1.5rem 0;
|
||||||
|
}
|
||||||
|
.ao-hinweis {
|
||||||
|
background: #fff8e1;
|
||||||
|
border: 1px solid #ffc107;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 1rem 1.25rem;
|
||||||
|
margin: 1rem 0;
|
||||||
|
font-size: 0.88rem;
|
||||||
|
}
|
||||||
|
@media print {
|
||||||
|
.portal-header { background: #004225 !important; -webkit-print-color-adjust: exact; }
|
||||||
|
.content-card { box-shadow: none; border: 1px solid #ddd; }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
|
||||||
|
<!-- Header -->
|
||||||
|
<div class="portal-header">
|
||||||
|
<div class="container">
|
||||||
|
<div class="row align-items-center">
|
||||||
|
<div class="col">
|
||||||
|
<h1><i class="fas fa-shield-alt me-2"></i>Datenschutzerklärung</h1>
|
||||||
|
<div class="subtitle">van Hees-Theyssen-Vogel'sche Stiftung · Destinatär-Portal</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-auto text-end">
|
||||||
|
<small class="opacity-75">Stand: März 2026</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Content -->
|
||||||
|
<div class="container my-4">
|
||||||
|
<div class="content-card">
|
||||||
|
|
||||||
|
<!-- 1. Verantwortliche Stelle -->
|
||||||
|
<h2><i class="fas fa-building me-2"></i>1. Verantwortliche Stelle</h2>
|
||||||
|
<p>
|
||||||
|
Verantwortliche Stelle im Sinne der Datenschutz-Grundverordnung (DSGVO) und des Bundesdatenschutzgesetzes (BDSG) ist:
|
||||||
|
</p>
|
||||||
|
<address class="ms-3">
|
||||||
|
<strong>van Hees-Theyssen-Vogel'sche Stiftung</strong><br>
|
||||||
|
Raesfelder Str. 3<br>
|
||||||
|
46499 Hamminkeln<br><br>
|
||||||
|
<i class="fas fa-envelope me-1"></i> stiftung@vhtv-stiftung.de
|
||||||
|
</address>
|
||||||
|
<p class="text-muted small">
|
||||||
|
Die Stiftung ist als gemeinnützige Familienstiftung anerkannt und verfolgt ausschließlich und unmittelbar
|
||||||
|
gemeinnützige Zwecke im Sinne des § 52 der Abgabenordnung (AO).
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<!-- 2. Grundsätze -->
|
||||||
|
<h2><i class="fas fa-gavel me-2"></i>2. Grundsätze der Datenverarbeitung</h2>
|
||||||
|
<p>
|
||||||
|
Wir verarbeiten personenbezogene Daten nur, soweit dies zur Erfüllung unserer satzungsmäßigen Aufgaben
|
||||||
|
und gesetzlichen Pflichten erforderlich ist. Die Verarbeitung erfolgt stets im Einklang mit der
|
||||||
|
Datenschutz-Grundverordnung (DSGVO) und dem Bundesdatenschutzgesetz (BDSG).
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
Wir erheben, verarbeiten und speichern Ihre personenbezogenen Daten grundsätzlich nur
|
||||||
|
</p>
|
||||||
|
<ul>
|
||||||
|
<li>mit Ihrer ausdrücklichen Einwilligung (Art. 6 Abs. 1 lit. a DSGVO), oder</li>
|
||||||
|
<li>zur Erfüllung einer vertraglichen oder vorvertraglichen Verpflichtung (Art. 6 Abs. 1 lit. b DSGVO), oder</li>
|
||||||
|
<li>aufgrund einer rechtlichen Verpflichtung (Art. 6 Abs. 1 lit. c DSGVO), oder</li>
|
||||||
|
<li>zur Wahrung berechtigter Interessen (Art. 6 Abs. 1 lit. f DSGVO).</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<!-- 3. Upload-Portal -->
|
||||||
|
<h2><i class="fas fa-upload me-2"></i>3. Nachweis-Upload-Portal (bestehende Destinatäre)</h2>
|
||||||
|
<p>
|
||||||
|
Das Upload-Portal ermöglicht Ihnen die sichere, digitale Einreichung von Unterlagen im Rahmen
|
||||||
|
des halbjährlichen Nachweisverfahrens. Der Zugang erfolgt ausschließlich über einen persönlichen,
|
||||||
|
einmalig nutzbaren Token-Link, den Sie per E-Mail erhalten.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h3>3.1 Verarbeitete Daten</h3>
|
||||||
|
<ul>
|
||||||
|
<li><strong>Hochgeladene Dokumente</strong> (Studienbescheinigungen, Einkommensnachweise, Vermögensnachweise)</li>
|
||||||
|
<li><strong>IP-Adresse</strong> (ausschließlich als kryptographischer Hash gespeichert; keine Rückführung möglich)</li>
|
||||||
|
<li><strong>Zeitstempel</strong> der Token-Einlösung und des Dokumenten-Uploads</li>
|
||||||
|
<li><strong>Token-Status</strong> (eingelöst / abgelaufen)</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h3>3.2 Rechtsgrundlage</h3>
|
||||||
|
<div class="dsgvo-article">
|
||||||
|
<strong>Art. 6 Abs. 1 lit. b DSGVO</strong> — Verarbeitung zur Erfüllung der satzungsmäßigen Verpflichtung
|
||||||
|
der Stiftung, die Bedürftigkeit und Anspruchsberechtigung ihrer Destinatäre gemäß § 53 AO zu prüfen und
|
||||||
|
zu dokumentieren.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h3>3.3 Zweck der Verarbeitung</h3>
|
||||||
|
<p>
|
||||||
|
Die Verarbeitung dient ausschließlich der satzungsgemäßen Aufgabe der Stiftung: der Prüfung, ob die
|
||||||
|
Voraussetzungen für eine Unterstützungsleistung gemäß § 53 Abgabenordnung (AO) weiterhin vorliegen.
|
||||||
|
Dies umfasst insbesondere die Überprüfung der Bedürftigkeit (Einkommens- und Vermögensgrenzen)
|
||||||
|
sowie der Anspruchsvoraussetzungen.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="ao-hinweis">
|
||||||
|
<i class="fas fa-info-circle me-2 text-warning"></i>
|
||||||
|
<strong>Hinweis gemäß § 53 AO:</strong> Die van Hees-Theyssen-Vogel'sche Stiftung ist als gemeinnützige
|
||||||
|
Stiftung verpflichtet, die Bedürftigkeit der unterstützten Personen nachzuweisen.
|
||||||
|
Gemäß § 53 Nr. 2 AO dürfen nur Personen unterstützt werden, deren Bezüge
|
||||||
|
(einschließlich Leistungen nach SGB) nicht mehr als das Fünffache des Regelsatzes
|
||||||
|
nach dem Dritten Kapitel SGB XII überschreiten und deren Vermögen den
|
||||||
|
gemeinen Wert von 15.500 € nicht übersteigt. Die Einreichung der Nachweise ist
|
||||||
|
daher gesetzlich geboten und unverzichtbar.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h3>3.4 Speicherdauer und Löschkonzept</h3>
|
||||||
|
<table class="table table-sm table-bordered">
|
||||||
|
<thead class="table-light">
|
||||||
|
<tr>
|
||||||
|
<th>Datenkategorie</th>
|
||||||
|
<th>Speicherdauer</th>
|
||||||
|
<th>Begründung</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td>Upload-Token (abgelaufen, nicht eingelöst)</td>
|
||||||
|
<td>90 Tage nach Ablauf</td>
|
||||||
|
<td>Nachvollziehbarkeit von Zustellungsversuchen</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>IP-Hash</td>
|
||||||
|
<td>90 Tage</td>
|
||||||
|
<td>Sicherheit / Missbrauchsschutz</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>Hochgeladene Nachweisdokumente</td>
|
||||||
|
<td>10 Jahre nach letzter Unterstützungsleistung</td>
|
||||||
|
<td>Steuerrechtliche Aufbewahrungspflichten (§ 147 AO)</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>Zeitstempel und Protokolldaten</td>
|
||||||
|
<td>10 Jahre</td>
|
||||||
|
<td>Gemeinnützigkeitsnachweis gegenüber Finanzbehörden</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<!-- 4. Onboarding -->
|
||||||
|
<h2><i class="fas fa-user-plus me-2"></i>4. Onboarding-Formular (neue Destinatäre)</h2>
|
||||||
|
<p>
|
||||||
|
Das Onboarding-Formular dient der erstmaligen Aufnahme in den Kreis der Destinatäre der Stiftung.
|
||||||
|
Dabei werden im Rahmen eines mehrstufigen Verfahrens umfangreiche personenbezogene Daten erhoben.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h3>4.1 Erhobene Datenkategorien</h3>
|
||||||
|
|
||||||
|
<p><strong>Persönliche Identifikationsdaten:</strong></p>
|
||||||
|
<ul>
|
||||||
|
<li>Vor- und Nachname, Geburtsdatum, Geburtsort</li>
|
||||||
|
<li>Anschrift (Straße, PLZ, Ort)</li>
|
||||||
|
<li>Telefon- und Mobilnummer, E-Mail-Adresse</li>
|
||||||
|
<li>Kopie des Personalausweises oder Reisepasses</li>
|
||||||
|
<li>Tabellarischer Lebenslauf</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<p><strong>Verwandtschaftsnachweis:</strong></p>
|
||||||
|
<ul>
|
||||||
|
<li>Nachweis des Verwandtschaftsverhältnisses zu einem Geschwisterteil des Stifters
|
||||||
|
Hendrik van Hees oder seiner Ehefrau Aletta Theyssen-Vogel</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<p><strong>Ausbildungs- und Studiendaten:</strong></p>
|
||||||
|
<ul>
|
||||||
|
<li>Aktueller Ausbildungs-/Studienstatus</li>
|
||||||
|
<li>Studienbescheinigung oder Ausbildungsnachweis</li>
|
||||||
|
<li>Voraussichtliche Dauer der Ausbildung/des Studiums</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<p><strong>Finanzdaten:</strong></p>
|
||||||
|
<ul>
|
||||||
|
<li>Haushaltsgröße und Zusammensetzung des Haushalts</li>
|
||||||
|
<li>Bezüge und Einkünfte (einschließlich Leistungen nach SGB)</li>
|
||||||
|
<li>Einkommensteuerbescheid, Lohn-/Gehaltsnachweis, ggf. Rentenbescheid</li>
|
||||||
|
<li>Unterhaltsleistungen und sonstige Bezüge</li>
|
||||||
|
<li>Miet- und Heizungsaufwendungen (ggf. Mietvertragskopie)</li>
|
||||||
|
<li>Vermögensübersicht (Spareinlagen, Wertpapiere, Immobilien)</li>
|
||||||
|
<li>Monatliche Aufwendungen für Lebensunterhalt und Ausbildung</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h3>4.2 Rechtsgrundlagen</h3>
|
||||||
|
<div class="dsgvo-article">
|
||||||
|
<strong>Art. 6 Abs. 1 lit. b DSGVO</strong> — Verarbeitung zur Durchführung vorvertraglicher
|
||||||
|
Maßnahmen im Rahmen der Aufnahme als Destinatär der Stiftung.<br><br>
|
||||||
|
<strong>Art. 9 Abs. 2 lit. b DSGVO</strong> — Soweit Daten besonderer Kategorien verarbeitet
|
||||||
|
werden (z. B. Pflegegrad, Gesundheitsdaten im Zusammenhang mit Einkommenssituation),
|
||||||
|
erfolgt dies zur Erfüllung von Rechten und Pflichten im Bereich des Sozialrechts
|
||||||
|
sowie zur Überprüfung der Anspruchsvoraussetzungen gemäß § 53 AO.<br><br>
|
||||||
|
<strong>Art. 6 Abs. 1 lit. a DSGVO</strong> — Einwilligung für die Verarbeitung freiwillig
|
||||||
|
übermittelter Daten, die über das gesetzlich Erforderliche hinausgehen.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h3>4.3 Zweck der Verarbeitung</h3>
|
||||||
|
<p>
|
||||||
|
Die erhobenen Daten dienen ausschließlich der Prüfung der Aufnahmevoraussetzungen
|
||||||
|
als Destinatär sowie der laufenden Überprüfung der Anspruchsberechtigung.
|
||||||
|
Eine Weitergabe an Dritte erfolgt nicht, es sei denn, dies ist gesetzlich vorgeschrieben
|
||||||
|
(z. B. Finanzbehörden im Rahmen von Betriebsprüfungen).
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h3>4.4 Vier-Augen-Prinzip und Freigabeverfahren</h3>
|
||||||
|
<p>
|
||||||
|
Alle im Onboarding-Verfahren erfassten Daten werden erst nach ausdrücklicher
|
||||||
|
Freigabe durch den Stiftungsvorstand aktiviert. Bis zur Freigabe haben nur autorisierte
|
||||||
|
Stiftungsmitarbeiter Zugriff. Das Aufnahmeverfahren ist nicht automatisiert;
|
||||||
|
jede Aufnahme wird durch den Vorstand beschlossen.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h3>4.5 Speicherdauer</h3>
|
||||||
|
<p>
|
||||||
|
Abgebrochene oder nicht freigegebene Onboarding-Vorgänge werden spätestens nach
|
||||||
|
90 Tagen vollständig gelöscht. Für aufgenommene Destinatäre gilt die steuerrechtliche
|
||||||
|
Aufbewahrungsfrist gemäß § 147 AO (10 Jahre nach Ende des Förderzeitraums).
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<!-- 5. Technische Sicherheit -->
|
||||||
|
<h2><i class="fas fa-lock me-2"></i>5. Technische Sicherheitsmaßnahmen</h2>
|
||||||
|
<ul>
|
||||||
|
<li><strong>HTTPS-Verschlüsselung</strong> für alle Datenübertragungen</li>
|
||||||
|
<li><strong>Token-basierter Zugang</strong> (kryptographisch sicher, 64-Zeichen-Token, einmalig nutzbar)</li>
|
||||||
|
<li><strong>IP-Anonymisierung</strong> durch SHA-256-Hash; keine Klartextspeicherung</li>
|
||||||
|
<li><strong>CSRF-Schutz</strong> für alle Formularübertragungen</li>
|
||||||
|
<li><strong>Rate Limiting</strong> zum Schutz vor Missbrauch</li>
|
||||||
|
<li><strong>Automatische Tokenablaufzeit</strong> (30 Tage)</li>
|
||||||
|
<li><strong>Dateivalidierung</strong> (nur PDF, JPG, PNG; maximale Dateigröße 20 MB)</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<!-- 6. Keine automatisierte Entscheidungsfindung -->
|
||||||
|
<h2><i class="fas fa-robot me-2"></i>6. Keine automatisierte Entscheidungsfindung</h2>
|
||||||
|
<p>
|
||||||
|
Es findet keine automatisierte Entscheidungsfindung im Sinne von Art. 22 DSGVO statt.
|
||||||
|
Alle Entscheidungen über die Gewährung von Unterstützungsleistungen werden durch
|
||||||
|
den Stiftungsvorstand nach menschlicher Prüfung getroffen.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<!-- 7. Weitergabe an Dritte -->
|
||||||
|
<h2><i class="fas fa-share-alt me-2"></i>7. Weitergabe personenbezogener Daten</h2>
|
||||||
|
<p>
|
||||||
|
Eine Weitergabe Ihrer personenbezogenen Daten an Dritte erfolgt grundsätzlich nicht.
|
||||||
|
Ausnahmen gelten nur, soweit:
|
||||||
|
</p>
|
||||||
|
<ul>
|
||||||
|
<li>eine gesetzliche Verpflichtung zur Weitergabe besteht (z. B. im Rahmen
|
||||||
|
von Steuerprüfungen oder behördlichen Anfragen), oder</li>
|
||||||
|
<li>Sie ausdrücklich in die Weitergabe eingewilligt haben.</li>
|
||||||
|
</ul>
|
||||||
|
<p>
|
||||||
|
Auftragsverarbeiter (z. B. Hosting-Dienstleister) sind vertraglich zur Einhaltung
|
||||||
|
der DSGVO verpflichtet und dürfen die Daten nur zu den vereinbarten Zwecken verwenden.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<!-- 8. Ihre Rechte -->
|
||||||
|
<h2><i class="fas fa-user-shield me-2"></i>8. Ihre Rechte als betroffene Person</h2>
|
||||||
|
<p>
|
||||||
|
Sie haben nach der Datenschutz-Grundverordnung folgende Rechte gegenüber der
|
||||||
|
verantwortlichen Stelle:
|
||||||
|
</p>
|
||||||
|
<ul class="rights-list list-unstyled">
|
||||||
|
<li>
|
||||||
|
<i class="fas fa-eye text-success me-2"></i>
|
||||||
|
<strong>Auskunftsrecht (Art. 15 DSGVO):</strong> Sie haben das Recht, Auskunft über
|
||||||
|
die zu Ihrer Person gespeicherten Daten zu erhalten.
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<i class="fas fa-edit text-primary me-2"></i>
|
||||||
|
<strong>Berichtigungsrecht (Art. 16 DSGVO):</strong> Sie haben das Recht, unrichtige
|
||||||
|
oder unvollständige Daten berichtigen zu lassen.
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<i class="fas fa-trash text-danger me-2"></i>
|
||||||
|
<strong>Löschungsrecht (Art. 17 DSGVO):</strong> Sie haben das Recht auf Löschung
|
||||||
|
Ihrer Daten, soweit keine gesetzlichen Aufbewahrungspflichten entgegenstehen.
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<i class="fas fa-pause text-warning me-2"></i>
|
||||||
|
<strong>Einschränkungsrecht (Art. 18 DSGVO):</strong> Sie haben das Recht, die
|
||||||
|
Verarbeitung Ihrer Daten unter bestimmten Voraussetzungen einschränken zu lassen.
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<i class="fas fa-hand-paper text-secondary me-2"></i>
|
||||||
|
<strong>Widerspruchsrecht (Art. 21 DSGVO):</strong> Sie können der Verarbeitung
|
||||||
|
Ihrer Daten aus Gründen, die sich aus Ihrer besonderen Situation ergeben, widersprechen.
|
||||||
|
Bitte beachten Sie, dass ein Widerspruch bei gesetzlich vorgeschriebener Datenverarbeitung
|
||||||
|
(§ 53 AO) ggf. zur Einstellung der Unterstützungsleistungen führen kann.
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<i class="fas fa-download text-info me-2"></i>
|
||||||
|
<strong>Datenübertragbarkeit (Art. 20 DSGVO):</strong> Sie haben das Recht, die Ihnen
|
||||||
|
bereitgestellten Daten in einem strukturierten, maschinenlesbaren Format zu erhalten.
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<i class="fas fa-undo text-dark me-2"></i>
|
||||||
|
<strong>Widerrufsrecht (Art. 7 Abs. 3 DSGVO):</strong> Eine erteilte Einwilligung können
|
||||||
|
Sie jederzeit mit Wirkung für die Zukunft widerrufen. Der Widerruf berührt nicht die
|
||||||
|
Rechtmäßigkeit der bis dahin erfolgten Verarbeitung.
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<p>
|
||||||
|
Zur Ausübung Ihrer Rechte wenden Sie sich bitte schriftlich oder per E-Mail an die
|
||||||
|
verantwortliche Stelle (siehe Abschnitt 1).
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<!-- 9. Beschwerderecht -->
|
||||||
|
<h2><i class="fas fa-balance-scale me-2"></i>9. Beschwerderecht bei der Aufsichtsbehörde</h2>
|
||||||
|
<p>
|
||||||
|
Sie haben das Recht, sich bei einer Datenschutz-Aufsichtsbehörde über die Verarbeitung
|
||||||
|
Ihrer personenbezogenen Daten zu beschweren. Zuständig ist in der Regel die Behörde
|
||||||
|
des Bundeslandes, in dem Sie Ihren gewöhnlichen Aufenthaltsort haben, oder des Bundeslandes,
|
||||||
|
in dem die verantwortliche Stelle ihren Sitz hat:
|
||||||
|
</p>
|
||||||
|
<address class="ms-3">
|
||||||
|
<strong>Landesbeauftragte für Datenschutz und Informationsfreiheit Nordrhein-Westfalen</strong><br>
|
||||||
|
Postfach 20 04 44<br>
|
||||||
|
40102 Düsseldorf<br>
|
||||||
|
<i class="fas fa-globe me-1"></i> www.ldi.nrw.de
|
||||||
|
</address>
|
||||||
|
|
||||||
|
<!-- 10. Änderungen -->
|
||||||
|
<h2><i class="fas fa-history me-2"></i>10. Änderungen dieser Datenschutzerklärung</h2>
|
||||||
|
<p>
|
||||||
|
Wir behalten uns vor, diese Datenschutzerklärung bei Bedarf anzupassen, um sie
|
||||||
|
stets den aktuellen rechtlichen Anforderungen zu entsprechen oder um Änderungen
|
||||||
|
unserer Leistungen in der Erklärung umzusetzen. Für Ihren erneuten Besuch gilt
|
||||||
|
dann die neue Datenschutzerklärung.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
</div><!-- /content-card -->
|
||||||
|
|
||||||
|
<div class="footer-note">
|
||||||
|
© van Hees-Theyssen-Vogel'sche Stiftung · Raesfelder Str. 3, 46499 Hamminkeln ·
|
||||||
|
stiftung@vhtv-stiftung.de · Stand: März 2026
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div><!-- /container -->
|
||||||
|
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
216
app/templates/portal/einwilligung_onboarding.html
Normal file
216
app/templates/portal/einwilligung_onboarding.html
Normal file
@@ -0,0 +1,216 @@
|
|||||||
|
{# Einwilligungserklärung für das Onboarding-Formular (Schritt 1) #}
|
||||||
|
{# Wird eingebettet in das mehrstufige Onboarding-Formular. #}
|
||||||
|
{# Variablen: einladung_token, stiftung_email #}
|
||||||
|
|
||||||
|
<div class="dse-panel card border-0 shadow-sm mb-4">
|
||||||
|
<div class="card-header" style="background: linear-gradient(135deg, #004225 0%, #006837 100%); color: white;">
|
||||||
|
<h5 class="mb-0">
|
||||||
|
<i class="fas fa-shield-alt me-2"></i>
|
||||||
|
Datenschutz & Einwilligung
|
||||||
|
</h5>
|
||||||
|
<small class="opacity-85">Bitte lesen Sie die folgenden Erklärungen sorgfältig und bestätigen Sie diese, um fortzufahren.</small>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card-body p-4">
|
||||||
|
|
||||||
|
{# ─── Datenschutzerklärung ─────────────────────────────────────────────── #}
|
||||||
|
<div class="section mb-4">
|
||||||
|
<h6 class="text-uppercase text-muted fw-bold mb-3" style="font-size: 0.78rem; letter-spacing: 0.05em;">
|
||||||
|
<i class="fas fa-file-alt me-1"></i> Datenschutzerklärung
|
||||||
|
</h6>
|
||||||
|
|
||||||
|
<div class="border rounded p-3 bg-light" style="max-height: 280px; overflow-y: auto; font-size: 0.88rem; line-height: 1.6;">
|
||||||
|
|
||||||
|
<p><strong>Verantwortliche Stelle:</strong><br>
|
||||||
|
van Hees-Theyssen-Vogel'sche Stiftung, Raesfelder Str. 3, 46499 Hamminkeln<br>
|
||||||
|
E-Mail: {{ stiftung_email|default:"stiftung@vhtv-stiftung.de" }}</p>
|
||||||
|
|
||||||
|
<p><strong>Zweck der Datenerhebung:</strong><br>
|
||||||
|
Die von Ihnen in diesem Formular eingegebenen personenbezogenen Daten dienen ausschließlich
|
||||||
|
der Prüfung Ihrer Aufnahme als Destinatär (Begünstigter) der Stiftung. Dies umfasst die
|
||||||
|
Feststellung Ihrer Anspruchsberechtigung gemäß § 53 Abgabenordnung (AO) sowie der
|
||||||
|
Stiftungssatzung.</p>
|
||||||
|
|
||||||
|
<p><strong>Erhobene Daten:</strong>
|
||||||
|
Persönliche Identifikationsdaten (Name, Adresse, Geburtsdatum, Kontaktdaten),
|
||||||
|
Identitätsnachweise, Verwandtschaftsnachweis, Ausbildungs-/Studiendaten sowie
|
||||||
|
Angaben zur finanziellen Situation (Einkommen, Vermögen, Haushaltskosten).</p>
|
||||||
|
|
||||||
|
<p><strong>Rechtsgrundlagen:</strong>
|
||||||
|
Art. 6 Abs. 1 lit. b DSGVO (Vertragsanbahnung), Art. 9 Abs. 2 lit. b DSGVO
|
||||||
|
(besondere Datenkategorien im Bereich Sozialrecht) sowie Ihre nachstehende
|
||||||
|
Einwilligung gemäß Art. 6 Abs. 1 lit. a DSGVO.</p>
|
||||||
|
|
||||||
|
<p><strong>Speicherdauer:</strong>
|
||||||
|
Nicht abgeschlossene oder nicht freigegebene Anträge werden spätestens nach 90 Tagen gelöscht.
|
||||||
|
Daten aufgenommener Destinatäre werden für die Dauer des Förderverhältnisses sowie
|
||||||
|
10 Jahre darüber hinaus aufbewahrt (steuerrechtliche Aufbewahrungspflicht gem. § 147 AO).</p>
|
||||||
|
|
||||||
|
<p><strong>Ihre Rechte:</strong>
|
||||||
|
Sie haben das Recht auf Auskunft (Art. 15 DSGVO), Berichtigung (Art. 16 DSGVO),
|
||||||
|
Löschung (Art. 17 DSGVO), Einschränkung der Verarbeitung (Art. 18 DSGVO),
|
||||||
|
Datenübertragbarkeit (Art. 20 DSGVO) und Widerspruch (Art. 21 DSGVO).
|
||||||
|
Weiterhin können Sie eine erteilte Einwilligung jederzeit widerrufen (Art. 7 Abs. 3 DSGVO).
|
||||||
|
Zur Ausübung Ihrer Rechte wenden Sie sich an: stiftung@vhtv-stiftung.de.</p>
|
||||||
|
|
||||||
|
<p><strong>Beschwerderecht:</strong>
|
||||||
|
Sie haben das Recht, sich bei der Landesbeauftragten für Datenschutz und
|
||||||
|
Informationsfreiheit NRW zu beschweren (www.ldi.nrw.de).</p>
|
||||||
|
|
||||||
|
<p class="mb-0">
|
||||||
|
<a href="{% url 'portal:datenschutzerklaerung' %}" target="_blank" class="text-decoration-none">
|
||||||
|
<i class="fas fa-external-link-alt me-1"></i>
|
||||||
|
Vollständige Datenschutzerklärung öffnen
|
||||||
|
</a>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-check mt-3">
|
||||||
|
<input class="form-check-input" type="checkbox" id="zustimmung_dse" name="zustimmung_dse"
|
||||||
|
value="1" required>
|
||||||
|
<label class="form-check-label fw-semibold" for="zustimmung_dse">
|
||||||
|
Ich habe die Datenschutzerklärung gelesen und verstanden und stimme der
|
||||||
|
Verarbeitung meiner personenbezogenen Daten zum genannten Zweck zu.
|
||||||
|
</label>
|
||||||
|
<div class="invalid-feedback">
|
||||||
|
Bitte bestätigen Sie die Datenschutzerklärung, um fortzufahren.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<hr class="my-4">
|
||||||
|
|
||||||
|
{# ─── Erklärung des Leistungsempfängers ───────────────────────────────── #}
|
||||||
|
<div class="section mb-4">
|
||||||
|
<h6 class="text-uppercase text-muted fw-bold mb-3" style="font-size: 0.78rem; letter-spacing: 0.05em;">
|
||||||
|
<i class="fas fa-pen-fancy me-1"></i> Erklärung des Antragstellers (gemäß Stiftungsmerkblatt)
|
||||||
|
</h6>
|
||||||
|
|
||||||
|
<div class="border rounded p-3 bg-light" style="font-size: 0.88rem; line-height: 1.7;">
|
||||||
|
<p>
|
||||||
|
Ich erkläre, dass meine Angaben in diesem Formular sowie in allen beigefügten Unterlagen
|
||||||
|
<strong>vollständig und wahrheitsgemäß</strong> sind. Ich bin mir bewusst, dass
|
||||||
|
unvollständige, fehlerhafte oder wissentlich falsche Angaben zum Ausschluss
|
||||||
|
von Leistungen der Stiftung sowie ggf. zur Rückforderung bereits gewährter
|
||||||
|
Unterstützung führen können.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
Ich verpflichte mich, <strong>Änderungen meiner Einkommens- und Vermögenssituation</strong>
|
||||||
|
sowie meines Ausbildungsstatus unverzüglich der Stiftung mitzuteilen, sobald diese
|
||||||
|
zu einer Änderung der Anspruchsvoraussetzungen führen könnten.
|
||||||
|
</p>
|
||||||
|
<p class="mb-0">
|
||||||
|
Mir ist bekannt, dass die Stiftung ihre Unterstützungsleistungen nach Maßgabe
|
||||||
|
des <strong>§ 53 Abgabenordnung (AO)</strong> erbringt und daher die Einhaltung
|
||||||
|
der dort genannten Einkommens- und Vermögensgrenzen regelmäßig überprüfen muss.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="mt-3 p-2 rounded" style="background: #fff8e1; border: 1px solid #ffc107; font-size: 0.82rem;">
|
||||||
|
<i class="fas fa-info-circle me-1 text-warning"></i>
|
||||||
|
<strong>Aktuelle Grenzwerte gemäß § 53 Nr. 2 AO (Stand 01/2024):</strong>
|
||||||
|
Bezüge max. 2.815 € monatlich (5× Regelsatz 563 €); Vermögen max. 15.500 €.
|
||||||
|
Bei Haushaltsangehörigen erhöhen sich die Grenzen entsprechend.
|
||||||
|
Maßgeblich sind die jeweils gültigen Werte zum Zeitpunkt der Prüfung.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-check mt-3">
|
||||||
|
<input class="form-check-input" type="checkbox" id="zustimmung_erklaerung" name="zustimmung_erklaerung"
|
||||||
|
value="1" required>
|
||||||
|
<label class="form-check-label fw-semibold" for="zustimmung_erklaerung">
|
||||||
|
Ich bestätige die vorstehende Erklärung und erkenne die Angabepflichten
|
||||||
|
sowie die Folgen unvollständiger oder falscher Angaben an.
|
||||||
|
</label>
|
||||||
|
<div class="invalid-feedback">
|
||||||
|
Bitte bestätigen Sie die Erklärung des Antragstellers, um fortzufahren.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<hr class="my-4">
|
||||||
|
|
||||||
|
{# ─── Optionale Einwilligung ───────────────────────────────────────────── #}
|
||||||
|
<div class="section mb-2">
|
||||||
|
<h6 class="text-uppercase text-muted fw-bold mb-3" style="font-size: 0.78rem; letter-spacing: 0.05em;">
|
||||||
|
<i class="fas fa-envelope-open-text me-1"></i> Kommunikation (freiwillig)
|
||||||
|
</h6>
|
||||||
|
|
||||||
|
<div class="form-check">
|
||||||
|
<input class="form-check-input" type="checkbox" id="zustimmung_kommunikation"
|
||||||
|
name="zustimmung_kommunikation" value="1">
|
||||||
|
<label class="form-check-label" for="zustimmung_kommunikation">
|
||||||
|
Ich bin damit einverstanden, dass die Stiftung mich per E-Mail über
|
||||||
|
Fristen, Nachweisverpflichtungen und stiftungsbezogene Informationen kontaktiert.
|
||||||
|
</label>
|
||||||
|
<small class="d-block text-muted mt-1">
|
||||||
|
Diese Einwilligung ist freiwillig und kann jederzeit widerrufen werden.
|
||||||
|
Ohne diese Einwilligung ist ggf. nur postalische Kommunikation möglich.
|
||||||
|
</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{# Hidden: Zeitstempel der Einwilligung #}
|
||||||
|
<input type="hidden" name="einwilligung_zeitstempel" id="einwilligung_zeitstempel">
|
||||||
|
|
||||||
|
</div><!-- /card-body -->
|
||||||
|
</div><!-- /dse-panel -->
|
||||||
|
|
||||||
|
{# JavaScript: Zeitstempel bei Seitenaufruf setzen, Pflichtfelder validieren #}
|
||||||
|
<script>
|
||||||
|
(function () {
|
||||||
|
// Zeitstempel der Anzeige setzen (nicht des Absendens, soll als Nachweis dienen)
|
||||||
|
var ts = document.getElementById('einwilligung_zeitstempel');
|
||||||
|
if (ts) {
|
||||||
|
ts.value = new Date().toISOString();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bootstrap-Validierung für Pflicht-Checkboxen
|
||||||
|
var form = document.querySelector('form');
|
||||||
|
if (form) {
|
||||||
|
form.addEventListener('submit', function (evt) {
|
||||||
|
var dse = document.getElementById('zustimmung_dse');
|
||||||
|
var erkl = document.getElementById('zustimmung_erklaerung');
|
||||||
|
var valid = true;
|
||||||
|
|
||||||
|
if (dse && !dse.checked) {
|
||||||
|
dse.classList.add('is-invalid');
|
||||||
|
valid = false;
|
||||||
|
} else if (dse) {
|
||||||
|
dse.classList.remove('is-invalid');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (erkl && !erkl.checked) {
|
||||||
|
erkl.classList.add('is-invalid');
|
||||||
|
valid = false;
|
||||||
|
} else if (erkl) {
|
||||||
|
erkl.classList.remove('is-invalid');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!valid) {
|
||||||
|
evt.preventDefault();
|
||||||
|
evt.stopPropagation();
|
||||||
|
// Zum ersten Fehler scrollen
|
||||||
|
var firstInvalid = form.querySelector('.is-invalid');
|
||||||
|
if (firstInvalid) {
|
||||||
|
firstInvalid.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, true);
|
||||||
|
|
||||||
|
// Live-Feedback bei Checkbox-Änderung
|
||||||
|
['zustimmung_dse', 'zustimmung_erklaerung'].forEach(function (id) {
|
||||||
|
var el = document.getElementById(id);
|
||||||
|
if (el) {
|
||||||
|
el.addEventListener('change', function () {
|
||||||
|
if (this.checked) {
|
||||||
|
this.classList.remove('is-invalid');
|
||||||
|
this.classList.add('is-valid');
|
||||||
|
} else {
|
||||||
|
this.classList.remove('is-valid');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
46
app/templates/portal/onboarding_basis.html
Normal file
46
app/templates/portal/onboarding_basis.html
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="de">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>{% block title %}Onboarding{% endblock %} – vHTV-Stiftung</title>
|
||||||
|
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
|
||||||
|
<style>
|
||||||
|
:root { --gruen: #004225; --gruen-hell: #006837; }
|
||||||
|
body { background: #f8f9fa; font-size: 0.95rem; line-height: 1.7; color: #333; }
|
||||||
|
.portal-header { background: linear-gradient(135deg, var(--gruen) 0%, var(--gruen-hell) 100%); color: #fff; padding: 1.5rem 0; }
|
||||||
|
.portal-header h1 { font-size: 1.4rem; font-weight: 600; margin-bottom: 0.25rem; }
|
||||||
|
.portal-header .subtitle { font-size: 0.85rem; opacity: 0.85; }
|
||||||
|
.fortschritt-bar { background: rgba(255,255,255,0.2); border-radius: 4px; height: 8px; margin-top: 12px; }
|
||||||
|
.fortschritt-fill { background: #fff; border-radius: 4px; height: 8px; transition: width 0.3s; }
|
||||||
|
.fortschritt-label { font-size: 0.8rem; opacity: 0.9; margin-top: 4px; }
|
||||||
|
.card { border: none; box-shadow: 0 2px 12px rgba(0,0,0,0.08); }
|
||||||
|
.card-header { background: #e8f4ee; border-bottom: 2px solid var(--gruen); }
|
||||||
|
.card-header h2 { font-size: 1.15rem; color: var(--gruen); margin: 0; }
|
||||||
|
.btn-weiter { background: var(--gruen); border-color: var(--gruen); }
|
||||||
|
.btn-weiter:hover { background: var(--gruen-hell); border-color: var(--gruen-hell); }
|
||||||
|
.btn-zurueck { border-color: #aaa; color: #555; }
|
||||||
|
.required-mark { color: #c00; }
|
||||||
|
.hinweis-box { background: #fff8e1; border-left: 4px solid #f0ad4e; border-radius: 4px; padding: 12px 16px; font-size: 0.9rem; }
|
||||||
|
.dse-scroll { max-height: 300px; overflow-y: auto; border: 1px solid #dee2e6; border-radius: 4px; padding: 16px; background: #fff; font-size: 0.85rem; }
|
||||||
|
.portal-footer { text-align: center; font-size: 0.8rem; color: #aaa; margin-top: 2rem; padding-bottom: 2rem; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="portal-header">
|
||||||
|
<div class="container">
|
||||||
|
<h1>van Hees-Theyssen-Vogel'sche Stiftung</h1>
|
||||||
|
<p class="subtitle mb-0">Onboarding-Antrag</p>
|
||||||
|
{% block fortschritt %}{% endblock %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="container py-4">
|
||||||
|
<div class="row justify-content-center">
|
||||||
|
<div class="col-12 col-md-8 col-lg-7">
|
||||||
|
{% block inhalt %}{% endblock %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="portal-footer">van Hees-Theyssen-Vogel'sche Stiftung · Raesfelder Str. 3, 46499 Hamminkeln · Tel. 02858/836780</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
24
app/templates/portal/onboarding_danke.html
Normal file
24
app/templates/portal/onboarding_danke.html
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
{% extends "portal/onboarding_basis.html" %}
|
||||||
|
{% block title %}Antrag eingereicht{% endblock %}
|
||||||
|
{% block fortschritt %}{% endblock %}
|
||||||
|
{% block inhalt %}
|
||||||
|
<div class="card mb-3">
|
||||||
|
<div class="card-body text-center py-5">
|
||||||
|
<div style="font-size: 3rem; color: #2c5f2e; margin-bottom: 16px;">✓</div>
|
||||||
|
<h2 style="color: #2c5f2e;">Ihr Antrag wurde eingereicht!</h2>
|
||||||
|
<p class="lead mt-3">Vielen Dank für Ihre Angaben.</p>
|
||||||
|
<p>Ihr Onboarding-Antrag wurde erfolgreich übermittelt. Die Stiftung prüft Ihre Angaben und wird sich in Kürze mit Ihnen in Verbindung setzen.</p>
|
||||||
|
<div class="hinweis-box mt-4 text-start">
|
||||||
|
<strong>Nächste Schritte:</strong>
|
||||||
|
<ul class="mt-2 mb-0">
|
||||||
|
<li>Die Stiftung prüft Ihren Antrag (4-Augen-Prinzip durch den Vorstand).</li>
|
||||||
|
<li>Sie erhalten eine Rückmeldung per E-Mail an die angegebene Adresse.</li>
|
||||||
|
<li>Ggf. werden weitere Unterlagen angefordert.</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<p class="mt-4 text-muted small">
|
||||||
|
Bei Fragen: van Hees-Theyssen-Vogel'sche Stiftung · Tel. 02858/836780
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user