Compare commits
36 Commits
042114b1e7
...
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 |
1
.github/workflows/ci-cd.yml
vendored
1
.github/workflows/ci-cd.yml
vendored
@@ -183,6 +183,7 @@ jobs:
|
||||
|
||||
# Build and start containers from source code
|
||||
echo "🔨 Building and starting containers from source code..."
|
||||
export APP_VERSION=$(cat VERSION 2>/dev/null || echo "unknown")
|
||||
docker-compose -f compose.yml up -d --build
|
||||
|
||||
# Wait for containers to be ready
|
||||
|
||||
10
.mcp.json
Normal file
10
.mcp.json
Normal file
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"mcpServers": {
|
||||
"stiftung": {
|
||||
"command": "bash",
|
||||
"args": [
|
||||
"/home/remmer/stiftung/app/mcp_server/connect.sh"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,8 @@
|
||||
FROM python:3.12-slim
|
||||
ARG APP_VERSION=unknown
|
||||
ENV PYTHONDONTWRITEBYTECODE=1 \
|
||||
PYTHONUNBUFFERED=1
|
||||
PYTHONUNBUFFERED=1 \
|
||||
APP_VERSION=$APP_VERSION
|
||||
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
build-essential libpq-dev postgresql-client \
|
||||
|
||||
26
app/core/context_processors.py
Normal file
26
app/core/context_processors.py
Normal file
@@ -0,0 +1,26 @@
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
_VERSION = None
|
||||
|
||||
|
||||
def app_version(request):
|
||||
global _VERSION
|
||||
if _VERSION is None:
|
||||
# 1. Environment variable (set in Docker/deployment)
|
||||
_VERSION = os.environ.get("APP_VERSION", "").strip()
|
||||
if not _VERSION:
|
||||
# 2. Try VERSION file at common locations
|
||||
base = Path(__file__).resolve().parent.parent # app/
|
||||
for candidate in [
|
||||
base.parent / "VERSION", # repo root (local dev)
|
||||
base / "VERSION", # app/ dir (Docker)
|
||||
]:
|
||||
try:
|
||||
_VERSION = candidate.read_text().strip()
|
||||
break
|
||||
except FileNotFoundError:
|
||||
continue
|
||||
else:
|
||||
_VERSION = "unknown"
|
||||
return {"APP_VERSION": _VERSION}
|
||||
@@ -72,6 +72,7 @@ TEMPLATES = [
|
||||
"django.template.context_processors.request",
|
||||
"django.contrib.auth.context_processors.auth",
|
||||
"django.contrib.messages.context_processors.messages",
|
||||
"core.context_processors.app_version",
|
||||
],
|
||||
},
|
||||
},
|
||||
@@ -117,8 +118,11 @@ MEDIA_URL = "/media/"
|
||||
MEDIA_ROOT = BASE_DIR / "media"
|
||||
|
||||
# Celery
|
||||
CELERY_BROKER_URL = os.getenv("REDIS_URL", "redis://redis:6379/0")
|
||||
CELERY_RESULT_BACKEND = os.getenv("REDIS_URL", "redis://redis:6379/0")
|
||||
CELERY_BROKER_URL = os.getenv("CELERY_REDIS_URL", os.getenv("REDIS_URL", "redis://redis:6379/2"))
|
||||
CELERY_RESULT_BACKEND = os.getenv("CELERY_REDIS_URL", os.getenv("REDIS_URL", "redis://redis:6379/2"))
|
||||
CELERY_TASK_SERIALIZER = "json"
|
||||
CELERY_RESULT_SERIALIZER = "json"
|
||||
CELERY_ACCEPT_CONTENT = ["json"]
|
||||
|
||||
# Celery Beat – periodische Tasks
|
||||
from celery.schedules import crontab # noqa: E402
|
||||
@@ -129,6 +133,11 @@ CELERY_BEAT_SCHEDULE = {
|
||||
"task": "stiftung.tasks.poll_emails",
|
||||
"schedule": crontab(minute="*/15"),
|
||||
},
|
||||
# Täglich um 08:00 Uhr: Ablaufende Upload-Tokens prüfen und Erinnerungen versenden
|
||||
"check-ablaufende-tokens": {
|
||||
"task": "stiftung.tasks.check_ablaufende_tokens",
|
||||
"schedule": crontab(hour="8", minute="0"),
|
||||
},
|
||||
}
|
||||
|
||||
# IMAP-Konfiguration für E-Mail-Eingang (Destinatäre)
|
||||
@@ -140,6 +149,16 @@ IMAP_PASSWORD = os.getenv("IMAP_PASSWORD", "")
|
||||
IMAP_FOLDER = os.getenv("IMAP_FOLDER", "INBOX")
|
||||
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
|
||||
LOGIN_URL = "/login/"
|
||||
@@ -147,7 +166,7 @@ LOGIN_REDIRECT_URL = "/"
|
||||
LOGOUT_REDIRECT_URL = "/login/"
|
||||
|
||||
# Gramps integration
|
||||
GRAMPS_URL = os.environ.get("GRAMPS_URL", "http://grampsweb:80")
|
||||
GRAMPS_URL = os.environ.get("GRAMPS_URL", "http://grampsweb:5000")
|
||||
GRAMPS_API_TOKEN = os.environ.get("GRAMPS_API_TOKEN", "")
|
||||
GRAMPS_STIFTER_IDS = os.environ.get("GRAMPS_STIFTER_IDS", "") # comma-separated
|
||||
GRAMPS_USERNAME = os.environ.get("GRAMPS_USERNAME", "")
|
||||
|
||||
@@ -8,6 +8,8 @@ from stiftung.views import home
|
||||
|
||||
urlpatterns = [
|
||||
path("api/v1/", include("stiftung.api_urls")),
|
||||
# Öffentliches Portal (kein Login erforderlich – tokenbasiert)
|
||||
path("portal/", include("stiftung.portal_urls")),
|
||||
path("", include("stiftung.urls")),
|
||||
path("admin/", admin.site.urls),
|
||||
# Authentication URLs
|
||||
|
||||
21
app/mcp_server/.env.example
Normal file
21
app/mcp_server/.env.example
Normal file
@@ -0,0 +1,21 @@
|
||||
# Stiftung MCP Server – Umgebungsvariablen
|
||||
# Kopiere diese Datei nach .env und passe die Werte an.
|
||||
|
||||
# ── Token-Konfiguration ─────────────────────────────────────────────────
|
||||
# Generiere sichere Token: openssl rand -hex 32
|
||||
MCP_TOKEN_READONLY=
|
||||
MCP_TOKEN_EDITOR=
|
||||
MCP_TOKEN_ADMIN=
|
||||
|
||||
# Aktives Token für die aktuelle Sitzung (eines der obigen)
|
||||
MCP_AUTH_TOKEN=
|
||||
|
||||
# ── Datenbank ────────────────────────────────────────────────────────────
|
||||
DB_HOST=localhost
|
||||
DB_PORT=5432
|
||||
POSTGRES_DB=stiftung
|
||||
POSTGRES_USER=stiftung
|
||||
POSTGRES_PASSWORD=
|
||||
|
||||
# ── Django ───────────────────────────────────────────────────────────────
|
||||
DJANGO_SETTINGS_MODULE=core.settings
|
||||
302
app/mcp_server/README.md
Normal file
302
app/mcp_server/README.md
Normal file
@@ -0,0 +1,302 @@
|
||||
# Stiftung MCP Server
|
||||
|
||||
MCP (Model Context Protocol) Server für die Stiftungsverwaltung. Ermöglicht AI-Assistenten den strukturierten Zugriff auf alle Stiftungsdaten.
|
||||
|
||||
## Funktionsumfang
|
||||
|
||||
### Lese-Tools (alle Rollen)
|
||||
| Tool | Beschreibung |
|
||||
|------|-------------|
|
||||
| `destinataer_suchen` | Suche nach Destinatären (Name, Status, Familienzweig) |
|
||||
| `destinataer_details` | Vollständige Details eines Destinatärs |
|
||||
| `land_suchen` | Suche nach Ländereien (Gemarkung, Gemeinde) |
|
||||
| `land_details` | Details einer Länderei inkl. Verpachtungen |
|
||||
| `paechter_suchen` | Suche nach Pächtern |
|
||||
| `konten_uebersicht` | Alle Stiftungskonten mit Salden |
|
||||
| `verwaltungskosten` | Verwaltungskosten filtern (Jahr, Kategorie, Status) |
|
||||
| `transaktionen_suchen` | Banktransaktionen durchsuchen |
|
||||
| `dokument_suchen` | Volltextsuche im DMS |
|
||||
| `dokument_details` | Metadaten eines Dokuments |
|
||||
| `termine_anzeigen` | Kalendereinträge und Termine |
|
||||
| `globale_suche` | Suche über alle Entitätstypen |
|
||||
| `dashboard` | Kennzahlen-Übersicht |
|
||||
| `statistiken` | Detaillierte Auswertungen |
|
||||
|
||||
### Schreib-Tools (editor/admin)
|
||||
| Tool | Beschreibung |
|
||||
|------|-------------|
|
||||
| `destinataer_anlegen` | Neuen Destinatär erfassen |
|
||||
| `destinataer_aktualisieren` | Bestehenden Destinatär aktualisieren |
|
||||
| `foerderung_anlegen` | Neue Förderung zuweisen |
|
||||
| `unterstuetzung_anlegen` | Unterstützungszahlung erfassen |
|
||||
| `land_anlegen` | Neue Länderei erfassen |
|
||||
| `verpachtung_anlegen` | Pachtvertrag erstellen |
|
||||
| `paechter_anlegen` | Neuen Pächter erfassen |
|
||||
| `verwaltungskosten_erfassen` | Verwaltungskosten buchen |
|
||||
| `termin_anlegen` | Neuen Kalendereintrag erstellen |
|
||||
| `dokument_verknuepfen` | Dokument mit Entität verknüpfen |
|
||||
|
||||
## Voraussetzungen
|
||||
|
||||
- Python 3.11+
|
||||
- Zugriff auf die PostgreSQL-Datenbank der Stiftung
|
||||
- Django-App Abhängigkeiten installiert (`app/requirements.txt`)
|
||||
- MCP SDK: `pip install mcp`
|
||||
|
||||
## Authentifizierung & Rollen
|
||||
|
||||
Der Server verwendet Token-basierte Authentifizierung mit drei Rollen:
|
||||
|
||||
| Rolle | Lesen | Schreiben | PII-Daten |
|
||||
|-------|-------|-----------|-----------|
|
||||
| `readonly` | Ja | Nein | Maskiert |
|
||||
| `editor` | Ja | Ja | Maskiert |
|
||||
| `admin` | Ja | Ja | Vollzugriff |
|
||||
|
||||
### PII-Maskierung (readonly/editor)
|
||||
- IBAN: `****4567`
|
||||
- E-Mail: `***@example.de`
|
||||
- Telefon: `****1234`
|
||||
- Geburtsdatum: nur Jahrgang
|
||||
- Einkommen/Vermögen: Bereichsangabe
|
||||
|
||||
## Umgebungsvariablen
|
||||
|
||||
```bash
|
||||
# Pflicht: Eines der drei Token setzen
|
||||
MCP_TOKEN_READONLY=<geheimes-token-readonly>
|
||||
MCP_TOKEN_EDITOR=<geheimes-token-editor>
|
||||
MCP_TOKEN_ADMIN=<geheimes-token-admin>
|
||||
|
||||
# Pflicht: Das aktive Token für diese Sitzung
|
||||
MCP_AUTH_TOKEN=<das-token-das-gerade-verwendet-wird>
|
||||
|
||||
# Django (automatisch wenn im Docker-Netzwerk)
|
||||
DJANGO_SETTINGS_MODULE=core.settings
|
||||
DB_HOST=db
|
||||
DB_PORT=5432
|
||||
POSTGRES_DB=stiftung
|
||||
POSTGRES_USER=stiftung
|
||||
POSTGRES_PASSWORD=<db-passwort>
|
||||
```
|
||||
|
||||
## Einrichtung
|
||||
|
||||
### 1. Token generieren
|
||||
|
||||
Generiere sichere, zufällige Token für jede Rolle:
|
||||
|
||||
```bash
|
||||
# Beispiel mit openssl
|
||||
export MCP_TOKEN_READONLY=$(openssl rand -hex 32)
|
||||
export MCP_TOKEN_EDITOR=$(openssl rand -hex 32)
|
||||
export MCP_TOKEN_ADMIN=$(openssl rand -hex 32)
|
||||
echo "READONLY: $MCP_TOKEN_READONLY"
|
||||
echo "EDITOR: $MCP_TOKEN_EDITOR"
|
||||
echo "ADMIN: $MCP_TOKEN_ADMIN"
|
||||
```
|
||||
|
||||
Speichere die Token sicher (z.B. in `.env` oder einem Passwort-Manager).
|
||||
|
||||
### 2. Starten
|
||||
|
||||
```bash
|
||||
# Aus dem app/-Verzeichnis:
|
||||
cd /pfad/zum/projekt/app
|
||||
MCP_AUTH_TOKEN=<dein-token> python -m mcp_server
|
||||
```
|
||||
|
||||
Oder mit dem Start-Skript:
|
||||
```bash
|
||||
MCP_AUTH_TOKEN=<dein-token> ./app/mcp_server/start.sh
|
||||
```
|
||||
|
||||
## Client-Konfigurationen
|
||||
|
||||
### Claude Desktop / Claude Code
|
||||
|
||||
Datei: `~/.claude/claude_desktop_config.json` (macOS/Linux) oder `%APPDATA%\Claude\claude_desktop_config.json` (Windows)
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"stiftung": {
|
||||
"command": "python",
|
||||
"args": ["-m", "mcp_server"],
|
||||
"cwd": "/pfad/zum/projekt/app",
|
||||
"env": {
|
||||
"DJANGO_SETTINGS_MODULE": "core.settings",
|
||||
"MCP_AUTH_TOKEN": "<dein-token>",
|
||||
"MCP_TOKEN_READONLY": "<readonly-token>",
|
||||
"MCP_TOKEN_EDITOR": "<editor-token>",
|
||||
"MCP_TOKEN_ADMIN": "<admin-token>",
|
||||
"DB_HOST": "localhost",
|
||||
"DB_PORT": "5432",
|
||||
"POSTGRES_DB": "stiftung",
|
||||
"POSTGRES_USER": "stiftung",
|
||||
"POSTGRES_PASSWORD": "<db-passwort>"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Claude Code (Projekt-spezifisch)
|
||||
|
||||
Datei: `.mcp.json` im Projekt-Root:
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"stiftung": {
|
||||
"command": "python",
|
||||
"args": ["-m", "mcp_server"],
|
||||
"cwd": "./app",
|
||||
"env": {
|
||||
"DJANGO_SETTINGS_MODULE": "core.settings",
|
||||
"MCP_AUTH_TOKEN": "<dein-token>",
|
||||
"MCP_TOKEN_READONLY": "<readonly-token>",
|
||||
"MCP_TOKEN_EDITOR": "<editor-token>",
|
||||
"MCP_TOKEN_ADMIN": "<admin-token>",
|
||||
"DB_HOST": "localhost",
|
||||
"DB_PORT": "5432",
|
||||
"POSTGRES_DB": "stiftung",
|
||||
"POSTGRES_USER": "stiftung",
|
||||
"POSTGRES_PASSWORD": "<db-passwort>"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Cursor
|
||||
|
||||
Datei: `.cursor/mcp.json` im Projekt-Root:
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"stiftung": {
|
||||
"command": "python",
|
||||
"args": ["-m", "mcp_server"],
|
||||
"cwd": "/pfad/zum/projekt/app",
|
||||
"env": {
|
||||
"DJANGO_SETTINGS_MODULE": "core.settings",
|
||||
"MCP_AUTH_TOKEN": "<dein-token>",
|
||||
"MCP_TOKEN_READONLY": "<readonly-token>",
|
||||
"MCP_TOKEN_EDITOR": "<editor-token>",
|
||||
"MCP_TOKEN_ADMIN": "<admin-token>",
|
||||
"DB_HOST": "localhost",
|
||||
"DB_PORT": "5432",
|
||||
"POSTGRES_DB": "stiftung",
|
||||
"POSTGRES_USER": "stiftung",
|
||||
"POSTGRES_PASSWORD": "<db-passwort>"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Windsurf
|
||||
|
||||
Datei: `~/.codeium/windsurf/mcp_config.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"stiftung": {
|
||||
"command": "python",
|
||||
"args": ["-m", "mcp_server"],
|
||||
"cwd": "/pfad/zum/projekt/app",
|
||||
"env": {
|
||||
"DJANGO_SETTINGS_MODULE": "core.settings",
|
||||
"MCP_AUTH_TOKEN": "<dein-token>",
|
||||
"MCP_TOKEN_READONLY": "<readonly-token>",
|
||||
"MCP_TOKEN_EDITOR": "<editor-token>",
|
||||
"MCP_TOKEN_ADMIN": "<admin-token>",
|
||||
"DB_HOST": "localhost",
|
||||
"DB_PORT": "5432",
|
||||
"POSTGRES_DB": "stiftung",
|
||||
"POSTGRES_USER": "stiftung",
|
||||
"POSTGRES_PASSWORD": "<db-passwort>"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Docker (empfohlen für Produktion)
|
||||
|
||||
```bash
|
||||
docker compose exec mcp python -m mcp_server
|
||||
```
|
||||
|
||||
Oder als MCP-Client-Konfiguration:
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"stiftung": {
|
||||
"command": "docker",
|
||||
"args": ["compose", "-f", "/pfad/zum/projekt/compose.yml", "exec", "-T", "mcp", "python", "-m", "mcp_server"],
|
||||
"env": {
|
||||
"MCP_AUTH_TOKEN": "<dein-token>"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Generisch (jeder MCP-kompatible Client)
|
||||
|
||||
Transport: **stdio** (Standard)
|
||||
|
||||
```bash
|
||||
# Direkt starten
|
||||
cd /pfad/zum/projekt/app
|
||||
MCP_AUTH_TOKEN=<token> \
|
||||
MCP_TOKEN_READONLY=<readonly> \
|
||||
MCP_TOKEN_EDITOR=<editor> \
|
||||
MCP_TOKEN_ADMIN=<admin> \
|
||||
DB_HOST=localhost \
|
||||
POSTGRES_DB=stiftung \
|
||||
POSTGRES_USER=stiftung \
|
||||
POSTGRES_PASSWORD=<pw> \
|
||||
python -m mcp_server
|
||||
```
|
||||
|
||||
## Datenschutz
|
||||
|
||||
- Alle Aktionen werden im AuditLog erfasst (Quelle: `mcp:<rolle>`)
|
||||
- PII-Felder werden bei readonly/editor automatisch maskiert
|
||||
- Kein Bulk-Export möglich (Ergebnis-Limits pro Abfrage)
|
||||
- Listen-Abfragen liefern reduzierte Felder
|
||||
- Der Server läuft im Docker-internen Netzwerk ohne externen Port
|
||||
|
||||
## Dateistruktur
|
||||
|
||||
```
|
||||
app/mcp_server/
|
||||
├── __init__.py # Paket-Marker
|
||||
├── __main__.py # python -m mcp_server Einstiegspunkt
|
||||
├── server.py # MCP Server Hauptmodul (Tool-Registrierung)
|
||||
├── auth.py # Token-Authentifizierung, Rollen-System
|
||||
├── privacy.py # PII-Maskierung
|
||||
├── audit.py # AuditLog-Integration
|
||||
├── start.sh # Shell-Startskript
|
||||
├── requirements.txt # MCP-spezifische Abhängigkeiten
|
||||
├── README.md # Diese Datei
|
||||
└── tools/
|
||||
├── __init__.py
|
||||
├── helpers.py # Serialisierung, Model→Dict Konvertierung
|
||||
├── lesen.py # 14 Lese-Tools
|
||||
└── schreiben.py # 10 Schreib-Tools
|
||||
```
|
||||
|
||||
## Sicherheitshinweise
|
||||
|
||||
- Token niemals im Code oder in Git committen
|
||||
- Für Produktion: Token in `.env`-Datei oder Secret-Manager speichern
|
||||
- Empfohlene Token-Rotation: alle 90 Tage
|
||||
- Bei Verdacht auf Token-Kompromittierung: sofort rotieren
|
||||
- Der MCP Server sollte nur im lokalen Netzwerk oder via VPN erreichbar sein
|
||||
3
app/mcp_server/__init__.py
Normal file
3
app/mcp_server/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
||||
# MCP Server für die Stiftungsverwaltung
|
||||
# Portabler MCP Server – kompatibel mit Claude Desktop, Cursor, Windsurf und
|
||||
# allen MCP-kompatiblen AI-Tools. Siehe README.md für Einrichtung.
|
||||
4
app/mcp_server/__main__.py
Normal file
4
app/mcp_server/__main__.py
Normal file
@@ -0,0 +1,4 @@
|
||||
"""Ermöglicht Start via: python -m mcp_server"""
|
||||
from mcp_server.server import mcp
|
||||
|
||||
mcp.run(transport="stdio")
|
||||
103
app/mcp_server/audit.py
Normal file
103
app/mcp_server/audit.py
Normal file
@@ -0,0 +1,103 @@
|
||||
"""
|
||||
Audit-Integration für MCP-Aktionen.
|
||||
|
||||
Alle MCP-Aktionen werden im bestehenden AuditLog erfasst.
|
||||
Da MCP kein HTTP-Request-Objekt hat, werden Felder direkt gesetzt:
|
||||
- user_agent = "MCP/<rolle>"
|
||||
- session_key = "mcp"
|
||||
- ip_address = None
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
|
||||
def log_mcp_action(
|
||||
role: str,
|
||||
action: str,
|
||||
entity_type: str,
|
||||
entity_id: str,
|
||||
entity_name: str,
|
||||
description: str,
|
||||
changes: dict | None = None,
|
||||
) -> None:
|
||||
"""
|
||||
Schreibt einen Audit-Log-Eintrag für eine MCP-Aktion.
|
||||
|
||||
Args:
|
||||
role: Aktuelle MCP-Rolle ("readonly", "editor", "admin")
|
||||
action: Aktionstyp (aus AuditLog.ACTION_TYPES)
|
||||
entity_type: Entitätstyp (aus AuditLog.ENTITY_TYPES oder freier Text)
|
||||
entity_id: ID der Entität
|
||||
entity_name: Lesbarer Name der Entität
|
||||
description: Beschreibung der Aktion
|
||||
changes: Optionales Dict mit Änderungen
|
||||
"""
|
||||
# Import hier, damit Django bereits initialisiert ist wenn diese Funktion aufgerufen wird
|
||||
from stiftung.models import AuditLog
|
||||
|
||||
# Normalisiere entity_type: muss in den AuditLog.ENTITY_TYPES-Choices sein
|
||||
# oder auf "system" fallen, da AuditLog choices-Validierung ggf. nicht hart durchgesetzt wird
|
||||
valid_entity_types = {choice[0] for choice in AuditLog.ENTITY_TYPES}
|
||||
if entity_type not in valid_entity_types:
|
||||
entity_type = "system"
|
||||
|
||||
# Normalisiere action: muss in ACTION_TYPES sein
|
||||
valid_actions = {choice[0] for choice in AuditLog.ACTION_TYPES}
|
||||
if action not in valid_actions:
|
||||
action = "export" # Generischer Fallback für MCP-Leseoperationen
|
||||
|
||||
AuditLog.objects.create(
|
||||
user=None,
|
||||
username=f"mcp:{role}",
|
||||
action=action,
|
||||
entity_type=entity_type,
|
||||
entity_id=str(entity_id) if entity_id else "",
|
||||
entity_name=entity_name,
|
||||
description=description,
|
||||
changes=changes,
|
||||
ip_address=None,
|
||||
user_agent=f"MCP/{role}",
|
||||
session_key="mcp",
|
||||
)
|
||||
|
||||
|
||||
def log_mcp_read(role: str, entity_type: str, entity_name: str, description: str) -> None:
|
||||
"""Loggt eine Leseoperation via MCP (als 'export'-Aktion)."""
|
||||
log_mcp_action(
|
||||
role=role,
|
||||
action="export",
|
||||
entity_type=entity_type,
|
||||
entity_id="",
|
||||
entity_name=entity_name,
|
||||
description=description,
|
||||
)
|
||||
|
||||
|
||||
def log_mcp_create(
|
||||
role: str, entity_type: str, entity_id: str, entity_name: str
|
||||
) -> None:
|
||||
"""Loggt eine Erstellungsoperation via MCP."""
|
||||
log_mcp_action(
|
||||
role=role,
|
||||
action="create",
|
||||
entity_type=entity_type,
|
||||
entity_id=entity_id,
|
||||
entity_name=entity_name,
|
||||
description=f"[MCP] {entity_type} '{entity_name}' erstellt",
|
||||
)
|
||||
|
||||
|
||||
def log_mcp_update(
|
||||
role: str, entity_type: str, entity_id: str, entity_name: str, changes: dict
|
||||
) -> None:
|
||||
"""Loggt eine Aktualisierungsoperation via MCP."""
|
||||
changed_fields = ", ".join(changes.keys()) if changes else ""
|
||||
log_mcp_action(
|
||||
role=role,
|
||||
action="update",
|
||||
entity_type=entity_type,
|
||||
entity_id=entity_id,
|
||||
entity_name=entity_name,
|
||||
description=f"[MCP] {entity_type} '{entity_name}' aktualisiert: {changed_fields}",
|
||||
changes=changes,
|
||||
)
|
||||
70
app/mcp_server/auth.py
Normal file
70
app/mcp_server/auth.py
Normal file
@@ -0,0 +1,70 @@
|
||||
"""
|
||||
MCP-Authentifizierung – Token-basierte Authentifizierung mit 3 Rollen.
|
||||
|
||||
Tokens werden über Umgebungsvariablen konfiguriert:
|
||||
MCP_TOKEN_READONLY – Nur-Lese-Zugriff (alle Daten, PII maskiert)
|
||||
MCP_TOKEN_EDITOR – Lesen + Schreiben (PII maskiert)
|
||||
MCP_TOKEN_ADMIN – Voll-Zugriff (keine PII-Maskierung, alle Schreib-Ops)
|
||||
|
||||
Das aktive Token wird per MCP_AUTH_TOKEN übergeben (wird vom MCP-Client gesetzt).
|
||||
"""
|
||||
|
||||
import os
|
||||
|
||||
ROLE_READONLY = "readonly"
|
||||
ROLE_EDITOR = "editor"
|
||||
ROLE_ADMIN = "admin"
|
||||
|
||||
# Rollenrangfolge (höher = mehr Rechte)
|
||||
ROLE_RANK = {ROLE_READONLY: 1, ROLE_EDITOR: 2, ROLE_ADMIN: 3}
|
||||
|
||||
|
||||
def _token_map() -> dict[str, str]:
|
||||
"""Erstellt Mapping token → Rolle aus Umgebungsvariablen."""
|
||||
mapping: dict[str, str] = {}
|
||||
for role, env_var in [
|
||||
(ROLE_READONLY, "MCP_TOKEN_READONLY"),
|
||||
(ROLE_EDITOR, "MCP_TOKEN_EDITOR"),
|
||||
(ROLE_ADMIN, "MCP_TOKEN_ADMIN"),
|
||||
]:
|
||||
token = os.environ.get(env_var, "").strip()
|
||||
if token:
|
||||
mapping[token] = role
|
||||
return mapping
|
||||
|
||||
|
||||
def get_role_for_token(token: str) -> str | None:
|
||||
"""
|
||||
Gibt die Rolle für einen Token zurück oder None bei ungültigem Token.
|
||||
"""
|
||||
if not token:
|
||||
return None
|
||||
return _token_map().get(token)
|
||||
|
||||
|
||||
def get_current_role() -> str | None:
|
||||
"""
|
||||
Gibt die Rolle des aktuell gesetzten MCP_AUTH_TOKEN zurück.
|
||||
Wird vom Server beim Start einmalig ausgewertet.
|
||||
"""
|
||||
token = os.environ.get("MCP_AUTH_TOKEN", "").strip()
|
||||
return get_role_for_token(token)
|
||||
|
||||
|
||||
def can_write(role: str | None) -> bool:
|
||||
"""Darf die Rolle Schreiboperationen ausführen?"""
|
||||
return role in (ROLE_EDITOR, ROLE_ADMIN)
|
||||
|
||||
|
||||
def can_read_unmasked(role: str | None) -> bool:
|
||||
"""Darf die Rolle ungemaskierte PII-Daten lesen?"""
|
||||
return role == ROLE_ADMIN
|
||||
|
||||
|
||||
def require_role(role: str | None) -> None:
|
||||
"""Wirft ValueError wenn keine gültige Rolle vorhanden."""
|
||||
if not role:
|
||||
raise ValueError(
|
||||
"Ungültiger oder fehlender MCP_AUTH_TOKEN. "
|
||||
"Bitte MCP_AUTH_TOKEN-Umgebungsvariable setzen."
|
||||
)
|
||||
16
app/mcp_server/connect.sh
Normal file
16
app/mcp_server/connect.sh
Normal file
@@ -0,0 +1,16 @@
|
||||
#!/usr/bin/env bash
|
||||
# MCP-Verbindungsskript zum Remote-Server
|
||||
# Token wird aus der Umgebungsvariable MCP_AUTH_TOKEN gelesen – nie hardcoden.
|
||||
# Einrichten: export MCP_AUTH_TOKEN=<token> in ~/.bashrc oder per Secrets-Manager.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
: "${MCP_AUTH_TOKEN:?MCP_AUTH_TOKEN nicht gesetzt. Bitte in ~/.bashrc oder ~/.profile exportieren.}"
|
||||
|
||||
exec ssh \
|
||||
-o StrictHostKeyChecking=no \
|
||||
deployment@217.154.84.225 \
|
||||
"cd /opt/stiftung && docker compose run --rm -T \
|
||||
-e MCP_AUTH_TOKEN=${MCP_AUTH_TOKEN} \
|
||||
-e DJANGO_ALLOW_ASYNC_UNSAFE=true \
|
||||
mcp"
|
||||
152
app/mcp_server/privacy.py
Normal file
152
app/mcp_server/privacy.py
Normal file
@@ -0,0 +1,152 @@
|
||||
"""
|
||||
PII-Maskierung für MCP-Ausgaben.
|
||||
|
||||
Bei readonly- und editor-Rollen werden folgende Felder maskiert:
|
||||
- iban → "****" + letzte 4 Stellen
|
||||
- email → "***@" + Domain
|
||||
- telefon → "****" + letzte 4 Ziffern
|
||||
- geburtsdatum → nur Jahreszahl
|
||||
- jaehrliches_einkommen / monatliche_bezuege / vermoegen → Bereichsangabe
|
||||
|
||||
Admin-Rolle erhält ungemaskierte Daten.
|
||||
"""
|
||||
|
||||
import re
|
||||
from decimal import Decimal
|
||||
|
||||
|
||||
def mask_iban(value: str | None) -> str | None:
|
||||
if not value:
|
||||
return value
|
||||
clean = value.replace(" ", "")
|
||||
if len(clean) > 4:
|
||||
return "****" + clean[-4:]
|
||||
return "****"
|
||||
|
||||
|
||||
def mask_email(value: str | None) -> str | None:
|
||||
if not value:
|
||||
return value
|
||||
parts = value.split("@", 1)
|
||||
if len(parts) == 2:
|
||||
return "***@" + parts[1]
|
||||
return "***"
|
||||
|
||||
|
||||
def mask_telefon(value: str | None) -> str | None:
|
||||
if not value:
|
||||
return value
|
||||
digits = re.sub(r"\D", "", value)
|
||||
if len(digits) > 4:
|
||||
return "****" + digits[-4:]
|
||||
return "****"
|
||||
|
||||
|
||||
def mask_geburtsdatum(value) -> str | None:
|
||||
"""Zeigt nur das Jahr des Geburtsdatums."""
|
||||
if not value:
|
||||
return None
|
||||
try:
|
||||
return str(value)[:4] # "YYYY-MM-DD" → "YYYY"
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def mask_einkommen(value) -> str | None:
|
||||
"""Gibt Einkommensbereich statt genauen Wert zurück."""
|
||||
if value is None:
|
||||
return None
|
||||
try:
|
||||
amount = float(value)
|
||||
if amount < 10000:
|
||||
return "< 10.000 €"
|
||||
elif amount < 20000:
|
||||
return "10.000–20.000 €"
|
||||
elif amount < 30000:
|
||||
return "20.000–30.000 €"
|
||||
elif amount < 50000:
|
||||
return "30.000–50.000 €"
|
||||
elif amount < 75000:
|
||||
return "50.000–75.000 €"
|
||||
else:
|
||||
return "> 75.000 €"
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
def mask_monatsbezuege(value) -> str | None:
|
||||
"""Gibt Monatsbezüge-Bereich statt genauen Wert zurück."""
|
||||
if value is None:
|
||||
return None
|
||||
try:
|
||||
amount = float(value)
|
||||
if amount < 500:
|
||||
return "< 500 €/Mon."
|
||||
elif amount < 1000:
|
||||
return "500–1.000 €/Mon."
|
||||
elif amount < 2000:
|
||||
return "1.000–2.000 €/Mon."
|
||||
elif amount < 3000:
|
||||
return "2.000–3.000 €/Mon."
|
||||
else:
|
||||
return "> 3.000 €/Mon."
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
# PII-Felder nach Modell
|
||||
PII_FIELDS: dict[str, dict] = {
|
||||
"destinataer": {
|
||||
"iban": mask_iban,
|
||||
"email": mask_email,
|
||||
"telefon": mask_telefon,
|
||||
"geburtsdatum": mask_geburtsdatum,
|
||||
"jaehrliches_einkommen": mask_einkommen,
|
||||
"monatliche_bezuege": mask_monatsbezuege,
|
||||
"vermoegen": mask_einkommen,
|
||||
},
|
||||
"paechter": {
|
||||
"iban": mask_iban,
|
||||
"email": mask_email,
|
||||
"telefon": mask_telefon,
|
||||
"geburtsdatum": mask_geburtsdatum,
|
||||
},
|
||||
"rentmeister": {
|
||||
"iban": mask_iban,
|
||||
"email": mask_email,
|
||||
"telefon": mask_telefon,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def apply_privacy_filter(data: dict, model_type: str, role: str) -> dict:
|
||||
"""
|
||||
Maskiert PII-Felder in einem Daten-Dictionary basierend auf Rolle und Modelltyp.
|
||||
|
||||
Args:
|
||||
data: Rohdaten-Dictionary
|
||||
model_type: Modelltyp (z.B. "destinataer", "paechter")
|
||||
role: Aktuelle Rolle ("readonly", "editor", "admin")
|
||||
|
||||
Returns:
|
||||
Gefiltertes Dictionary (bei admin: unveränderter Input)
|
||||
"""
|
||||
from .auth import can_read_unmasked
|
||||
|
||||
if can_read_unmasked(role):
|
||||
return data
|
||||
|
||||
maskers = PII_FIELDS.get(model_type, {})
|
||||
if not maskers:
|
||||
return data
|
||||
|
||||
result = dict(data)
|
||||
for field, mask_fn in maskers.items():
|
||||
if field in result:
|
||||
result[field] = mask_fn(result[field])
|
||||
return result
|
||||
|
||||
|
||||
def apply_privacy_filter_list(items: list[dict], model_type: str, role: str) -> list[dict]:
|
||||
"""Wendet apply_privacy_filter auf eine Liste von Dicts an."""
|
||||
return [apply_privacy_filter(item, model_type, role) for item in items]
|
||||
3
app/mcp_server/requirements.txt
Normal file
3
app/mcp_server/requirements.txt
Normal file
@@ -0,0 +1,3 @@
|
||||
# MCP Server Dependencies
|
||||
# Install alongside the main Django app requirements
|
||||
mcp>=1.0.0
|
||||
160
app/mcp_server/server.py
Normal file
160
app/mcp_server/server.py
Normal file
@@ -0,0 +1,160 @@
|
||||
"""
|
||||
MCP Server für die Stiftungsverwaltung.
|
||||
|
||||
Startmodus:
|
||||
python -m mcp_server.server
|
||||
|
||||
Konfiguration über Umgebungsvariablen:
|
||||
MCP_AUTH_TOKEN – Aktiver Zugriffstoken (vom MCP-Client gesetzt)
|
||||
MCP_TOKEN_READONLY – Token für readonly-Rolle
|
||||
MCP_TOKEN_EDITOR – Token für editor-Rolle
|
||||
MCP_TOKEN_ADMIN – Token für admin-Rolle
|
||||
|
||||
DJANGO_SETTINGS_MODULE – Django-Settings (Standard: core.settings)
|
||||
DB_HOST, DB_PORT, POSTGRES_DB, POSTGRES_USER, POSTGRES_PASSWORD – DB-Verbindung
|
||||
"""
|
||||
|
||||
import logging
|
||||
import os
|
||||
import sys
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
# Django Standalone-Setup (ORM ohne HTTP-Server)
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
# Pfad zum app/-Verzeichnis in sys.path aufnehmen (damit Imports funktionieren)
|
||||
_app_dir = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
if _app_dir not in sys.path:
|
||||
sys.path.insert(0, _app_dir)
|
||||
|
||||
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "core.settings")
|
||||
os.environ.setdefault("DJANGO_ALLOW_ASYNC_UNSAFE", "true")
|
||||
|
||||
import django # noqa: E402
|
||||
|
||||
django.setup()
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
# Logging
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
logging.basicConfig(
|
||||
level=logging.WARNING,
|
||||
format="%(asctime)s %(levelname)s %(name)s: %(message)s",
|
||||
stream=sys.stderr,
|
||||
)
|
||||
logger = logging.getLogger("mcp_server")
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
# Auth-Check vor Server-Start
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
from mcp_server.auth import get_current_role, require_role # noqa: E402
|
||||
|
||||
_current_role = get_current_role()
|
||||
try:
|
||||
require_role(_current_role)
|
||||
except ValueError as exc:
|
||||
logger.error("MCP Auth-Fehler: %s", exc)
|
||||
sys.exit(1)
|
||||
|
||||
logger.info("MCP Server startet mit Rolle: %s", _current_role)
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
# MCP Server Initialisierung
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
from mcp.server.fastmcp import FastMCP # noqa: E402
|
||||
|
||||
mcp = FastMCP(
|
||||
"Stiftungsverwaltung",
|
||||
instructions=(
|
||||
"MCP-Server der gemeinnützigen Familienstiftung. "
|
||||
f"Aktive Rolle: {_current_role}. "
|
||||
"Lese-Zugriff auf alle Stiftungsdaten. "
|
||||
+ ("Schreib-Zugriff aktiv. " if _current_role in ("editor", "admin") else "")
|
||||
+ "PII-Felder werden bei readonly/editor maskiert."
|
||||
),
|
||||
)
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
# Lese-Tools registrieren (alle Rollen)
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
from mcp_server.tools.lesen import ( # noqa: E402
|
||||
dashboard,
|
||||
destinataer_details,
|
||||
destinataer_suchen,
|
||||
dokument_details,
|
||||
dokument_suchen,
|
||||
globale_suche,
|
||||
konten_uebersicht,
|
||||
land_details,
|
||||
land_suchen,
|
||||
paechter_suchen,
|
||||
statistiken,
|
||||
termine_anzeigen,
|
||||
transaktionen_suchen,
|
||||
veranstaltung_teilnehmer_anzeigen,
|
||||
veranstaltungen_anzeigen,
|
||||
verwaltungskosten,
|
||||
)
|
||||
|
||||
mcp.tool()(destinataer_suchen)
|
||||
mcp.tool()(destinataer_details)
|
||||
mcp.tool()(land_suchen)
|
||||
mcp.tool()(land_details)
|
||||
mcp.tool()(paechter_suchen)
|
||||
mcp.tool()(konten_uebersicht)
|
||||
mcp.tool()(verwaltungskosten)
|
||||
mcp.tool()(transaktionen_suchen)
|
||||
mcp.tool()(dokument_suchen)
|
||||
mcp.tool()(dokument_details)
|
||||
mcp.tool()(termine_anzeigen)
|
||||
mcp.tool()(veranstaltungen_anzeigen)
|
||||
mcp.tool()(veranstaltung_teilnehmer_anzeigen)
|
||||
mcp.tool()(globale_suche)
|
||||
mcp.tool()(dashboard)
|
||||
mcp.tool()(statistiken)
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
# Schreib-Tools registrieren (nur editor/admin)
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
from mcp_server.auth import can_write # noqa: E402
|
||||
|
||||
if can_write(_current_role):
|
||||
from mcp_server.tools.schreiben import ( # noqa: E402
|
||||
destinataer_aktualisieren,
|
||||
destinataer_anlegen,
|
||||
dokument_verknuepfen,
|
||||
foerderung_anlegen,
|
||||
land_anlegen,
|
||||
paechter_anlegen,
|
||||
termin_anlegen,
|
||||
unterstuetzung_anlegen,
|
||||
veranstaltung_teilnehmer_anlegen,
|
||||
veranstaltung_teilnehmer_importieren,
|
||||
verpachtung_anlegen,
|
||||
verwaltungskosten_erfassen,
|
||||
)
|
||||
|
||||
mcp.tool()(destinataer_anlegen)
|
||||
mcp.tool()(destinataer_aktualisieren)
|
||||
mcp.tool()(foerderung_anlegen)
|
||||
mcp.tool()(unterstuetzung_anlegen)
|
||||
mcp.tool()(land_anlegen)
|
||||
mcp.tool()(verpachtung_anlegen)
|
||||
mcp.tool()(paechter_anlegen)
|
||||
mcp.tool()(verwaltungskosten_erfassen)
|
||||
mcp.tool()(termin_anlegen)
|
||||
mcp.tool()(dokument_verknuepfen)
|
||||
mcp.tool()(veranstaltung_teilnehmer_anlegen)
|
||||
mcp.tool()(veranstaltung_teilnehmer_importieren)
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
# Server starten
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
if __name__ == "__main__":
|
||||
mcp.run(transport="stdio")
|
||||
18
app/mcp_server/start.sh
Normal file
18
app/mcp_server/start.sh
Normal file
@@ -0,0 +1,18 @@
|
||||
#!/bin/sh
|
||||
# MCP Server Startskript (direkter Aufruf ohne Docker)
|
||||
#
|
||||
# Voraussetzung: Python-Umgebung mit allen requirements.txt Paketen
|
||||
# Nutzung: MCP_AUTH_TOKEN=<token> ./app/mcp_server/start.sh
|
||||
#
|
||||
# Dieses Skript wird von MCP-Clients (z.B. Claude Desktop) aufgerufen.
|
||||
# Das Arbeitsverzeichnis muss das app/-Verzeichnis sein.
|
||||
|
||||
set -e
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
APP_DIR="$(dirname "$SCRIPT_DIR")"
|
||||
|
||||
export DJANGO_SETTINGS_MODULE="${DJANGO_SETTINGS_MODULE:-core.settings}"
|
||||
export PYTHONPATH="$APP_DIR:${PYTHONPATH:-}"
|
||||
|
||||
exec python -m mcp_server
|
||||
1
app/mcp_server/tools/__init__.py
Normal file
1
app/mcp_server/tools/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
# MCP Tools
|
||||
59
app/mcp_server/tools/helpers.py
Normal file
59
app/mcp_server/tools/helpers.py
Normal file
@@ -0,0 +1,59 @@
|
||||
"""
|
||||
Hilfsfunktionen für MCP-Tool-Implementierungen.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from datetime import date, datetime, time
|
||||
from decimal import Decimal
|
||||
from uuid import UUID
|
||||
|
||||
|
||||
def serialize_value(value):
|
||||
"""Konvertiert Django-Feldwerte in JSON-serialisierbare Typen."""
|
||||
if isinstance(value, UUID):
|
||||
return str(value)
|
||||
if isinstance(value, Decimal):
|
||||
return float(value)
|
||||
if isinstance(value, (date, datetime)):
|
||||
return value.isoformat()
|
||||
if isinstance(value, time):
|
||||
return value.isoformat()
|
||||
return value
|
||||
|
||||
|
||||
def model_to_dict(instance, fields=None, exclude=None) -> dict:
|
||||
"""
|
||||
Konvertiert eine Django-Model-Instanz in ein serialisierbares Dict.
|
||||
|
||||
Args:
|
||||
instance: Django Model Instanz
|
||||
fields: Nur diese Felder einschließen (None = alle)
|
||||
exclude: Diese Felder ausschließen
|
||||
"""
|
||||
exclude = exclude or []
|
||||
result = {}
|
||||
for field in instance._meta.fields:
|
||||
name = field.name
|
||||
if fields and name not in fields:
|
||||
continue
|
||||
if name in exclude:
|
||||
continue
|
||||
value = getattr(instance, name)
|
||||
# ForeignKey: _id-Suffix-Wert (nicht ganzes Objekt)
|
||||
result[name] = serialize_value(value)
|
||||
|
||||
# Auch ForeignKey-IDs explizit aufnehmen (z.B. konto_id)
|
||||
for field in instance._meta.fields:
|
||||
if hasattr(field, "attname") and field.attname != field.name:
|
||||
attname = field.attname
|
||||
if attname not in result:
|
||||
result[attname] = serialize_value(getattr(instance, attname))
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def format_result(data) -> str:
|
||||
"""Gibt Daten als formatiertes JSON zurück."""
|
||||
return json.dumps(data, ensure_ascii=False, indent=2, default=str)
|
||||
849
app/mcp_server/tools/lesen.py
Normal file
849
app/mcp_server/tools/lesen.py
Normal file
@@ -0,0 +1,849 @@
|
||||
"""
|
||||
Lese-Tools für den MCP Server der Stiftungsverwaltung.
|
||||
|
||||
Alle Tools:
|
||||
- Prüfen die Rolle (readonly/editor/admin erforderlich)
|
||||
- Wenden PII-Maskierung an (außer bei admin)
|
||||
- Schreiben Audit-Log-Einträge
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from django.db.models import Q, Sum
|
||||
|
||||
from mcp_server.audit import log_mcp_read
|
||||
from mcp_server.auth import require_role
|
||||
from mcp_server.privacy import apply_privacy_filter, apply_privacy_filter_list
|
||||
from mcp_server.tools.helpers import format_result, model_to_dict
|
||||
|
||||
|
||||
def _get_role() -> str:
|
||||
from mcp_server.auth import get_current_role, require_role as _require
|
||||
role = get_current_role()
|
||||
_require(role)
|
||||
return role
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
# Destinatäre
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
def destinataer_suchen(
|
||||
suchbegriff: str = "",
|
||||
aktiv: bool | None = None,
|
||||
familienzweig: str = "",
|
||||
limit: int = 20,
|
||||
) -> str:
|
||||
"""
|
||||
Sucht Destinatäre nach Name, Familienzweig oder Aktivstatus.
|
||||
|
||||
Args:
|
||||
suchbegriff: Freitext (Vor-/Nachname, Institution)
|
||||
aktiv: True=nur Aktive, False=nur Inaktive, None=alle
|
||||
familienzweig: Filtert nach Familienzweig (hauptzweig/nebenzweig/verwandt/anderer)
|
||||
limit: Maximale Anzahl Ergebnisse (max. 100)
|
||||
"""
|
||||
from stiftung.models import Destinataer
|
||||
|
||||
role = _get_role()
|
||||
limit = min(limit, 100)
|
||||
|
||||
qs = Destinataer.objects.all()
|
||||
if suchbegriff:
|
||||
qs = qs.filter(
|
||||
Q(vorname__icontains=suchbegriff)
|
||||
| Q(nachname__icontains=suchbegriff)
|
||||
| Q(institution__icontains=suchbegriff)
|
||||
)
|
||||
if aktiv is not None:
|
||||
qs = qs.filter(aktiv=aktiv)
|
||||
if familienzweig:
|
||||
qs = qs.filter(familienzweig=familienzweig)
|
||||
|
||||
qs = qs.order_by("nachname", "vorname")[:limit]
|
||||
|
||||
# Reduzierte Felder für Listen-Ausgabe
|
||||
results = []
|
||||
for obj in qs:
|
||||
item = {
|
||||
"id": str(obj.id),
|
||||
"vorname": obj.vorname,
|
||||
"nachname": obj.nachname,
|
||||
"familienzweig": obj.familienzweig,
|
||||
"aktiv": obj.aktiv,
|
||||
"berufsgruppe": obj.berufsgruppe,
|
||||
"ort": obj.ort,
|
||||
"email": obj.email,
|
||||
}
|
||||
results.append(apply_privacy_filter(item, "destinataer", role))
|
||||
|
||||
log_mcp_read(role, "destinataer", "Destinatär-Suche", f"Suche: '{suchbegriff}', {len(results)} Ergebnisse")
|
||||
return format_result({"anzahl": len(results), "destinataere": results})
|
||||
|
||||
|
||||
def destinataer_details(destinataer_id: str) -> str:
|
||||
"""
|
||||
Gibt vollständige Details eines Destinatärs zurück.
|
||||
|
||||
Args:
|
||||
destinataer_id: UUID des Destinatärs
|
||||
"""
|
||||
from stiftung.models import Destinataer, DestinataerUnterstuetzung, Foerderung
|
||||
|
||||
role = _get_role()
|
||||
|
||||
try:
|
||||
obj = Destinataer.objects.get(id=destinataer_id)
|
||||
except Destinataer.DoesNotExist:
|
||||
return format_result({"fehler": f"Destinatär {destinataer_id} nicht gefunden"})
|
||||
|
||||
data = model_to_dict(obj)
|
||||
data = apply_privacy_filter(data, "destinataer", role)
|
||||
|
||||
# Aktuelle Unterstützungen
|
||||
unterstuetzungen = list(
|
||||
DestinataerUnterstuetzung.objects.filter(destinataer=obj)
|
||||
.exclude(status="storniert")
|
||||
.order_by("-faellig_am")[:10]
|
||||
.values("id", "betrag", "faellig_am", "status", "beschreibung")
|
||||
)
|
||||
for u in unterstuetzungen:
|
||||
for k, v in u.items():
|
||||
from mcp_server.tools.helpers import serialize_value
|
||||
u[k] = serialize_value(v)
|
||||
|
||||
# Förderungen
|
||||
foerderungen = list(
|
||||
Foerderung.objects.filter(destinataer=obj)
|
||||
.order_by("-jahr")[:10]
|
||||
.values("id", "jahr", "betrag", "kategorie", "status")
|
||||
)
|
||||
for f in foerderungen:
|
||||
for k, v in f.items():
|
||||
from mcp_server.tools.helpers import serialize_value
|
||||
f[k] = serialize_value(v)
|
||||
|
||||
data["aktuelle_unterstuetzungen"] = unterstuetzungen
|
||||
data["foerderungen"] = foerderungen
|
||||
|
||||
name = f"{obj.vorname} {obj.nachname}"
|
||||
log_mcp_read(role, "destinataer", name, f"Details abgerufen für {name}")
|
||||
return format_result(data)
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
# Ländereien
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
def land_suchen(
|
||||
suchbegriff: str = "",
|
||||
gemeinde: str = "",
|
||||
limit: int = 20,
|
||||
) -> str:
|
||||
"""
|
||||
Sucht Ländereien nach Bezeichnung, Gemarkung oder Gemeinde.
|
||||
|
||||
Args:
|
||||
suchbegriff: Freitext (Bezeichnung, Gemarkung)
|
||||
gemeinde: Filtert nach Gemeinde
|
||||
limit: Maximale Anzahl Ergebnisse (max. 100)
|
||||
"""
|
||||
from stiftung.models import Land
|
||||
|
||||
role = _get_role()
|
||||
limit = min(limit, 100)
|
||||
|
||||
qs = Land.objects.all()
|
||||
if suchbegriff:
|
||||
qs = qs.filter(
|
||||
Q(gemeinde__icontains=suchbegriff)
|
||||
| Q(gemarkung__icontains=suchbegriff)
|
||||
| Q(flur__icontains=suchbegriff)
|
||||
| Q(lfd_nr__icontains=suchbegriff)
|
||||
| Q(adresse__icontains=suchbegriff)
|
||||
)
|
||||
if gemeinde:
|
||||
qs = qs.filter(gemeinde__icontains=gemeinde)
|
||||
|
||||
qs = qs.order_by("gemeinde", "gemarkung")[:limit]
|
||||
|
||||
results = []
|
||||
for obj in qs:
|
||||
results.append({
|
||||
"id": str(obj.id),
|
||||
"bezeichnung": str(obj),
|
||||
"lfd_nr": obj.lfd_nr,
|
||||
"gemeinde": obj.gemeinde,
|
||||
"gemarkung": obj.gemarkung,
|
||||
"flur": obj.flur,
|
||||
"flurstueck": obj.flurstueck,
|
||||
"groesse_qm": float(obj.groesse_qm) if obj.groesse_qm else None,
|
||||
"aktiv_verpachtet": obj.neue_verpachtungen.filter(status="aktiv").exists(),
|
||||
})
|
||||
|
||||
log_mcp_read(role, "land", "Länderei-Suche", f"Suche: '{suchbegriff}', {len(results)} Ergebnisse")
|
||||
return format_result({"anzahl": len(results), "laendereien": results})
|
||||
|
||||
|
||||
def land_details(land_id: str) -> str:
|
||||
"""
|
||||
Gibt vollständige Details einer Länderei zurück.
|
||||
|
||||
Args:
|
||||
land_id: UUID der Länderei
|
||||
"""
|
||||
from stiftung.models import Land, LandVerpachtung
|
||||
|
||||
role = _get_role()
|
||||
|
||||
try:
|
||||
obj = Land.objects.get(id=land_id)
|
||||
except Land.DoesNotExist:
|
||||
return format_result({"fehler": f"Länderei {land_id} nicht gefunden"})
|
||||
|
||||
data = model_to_dict(obj)
|
||||
|
||||
# Aktive Verpachtungen
|
||||
verpachtungen = []
|
||||
for v in obj.neue_verpachtungen.all().order_by("-pachtbeginn")[:5]:
|
||||
verpachtungen.append({
|
||||
"id": str(v.id),
|
||||
"paechter": str(v.paechter) if v.paechter else None,
|
||||
"pachtbeginn": v.pachtbeginn.isoformat() if v.pachtbeginn else None,
|
||||
"pachtende": v.pachtende.isoformat() if v.pachtende else None,
|
||||
"pachtzins_pauschal": float(v.pachtzins_pauschal) if v.pachtzins_pauschal else None,
|
||||
"status": v.status,
|
||||
})
|
||||
|
||||
data["verpachtungen"] = verpachtungen
|
||||
log_mcp_read(role, "land", str(obj), f"Land-Details abgerufen: {obj}")
|
||||
return format_result(data)
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
# Pächter
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
def paechter_suchen(
|
||||
suchbegriff: str = "",
|
||||
limit: int = 20,
|
||||
) -> str:
|
||||
"""
|
||||
Sucht Pächter nach Name.
|
||||
|
||||
Args:
|
||||
suchbegriff: Freitext (Vor-/Nachname)
|
||||
limit: Maximale Anzahl Ergebnisse (max. 100)
|
||||
"""
|
||||
from stiftung.models import Paechter
|
||||
|
||||
role = _get_role()
|
||||
limit = min(limit, 100)
|
||||
|
||||
qs = Paechter.objects.all()
|
||||
if suchbegriff:
|
||||
qs = qs.filter(
|
||||
Q(vorname__icontains=suchbegriff) | Q(nachname__icontains=suchbegriff)
|
||||
)
|
||||
|
||||
qs = qs.order_by("nachname", "vorname")[:limit]
|
||||
|
||||
results = []
|
||||
for obj in qs:
|
||||
item = {
|
||||
"id": str(obj.id),
|
||||
"vorname": obj.vorname,
|
||||
"nachname": obj.nachname,
|
||||
"personentyp": obj.personentyp,
|
||||
"ort": obj.ort,
|
||||
"email": obj.email,
|
||||
"telefon": obj.telefon,
|
||||
"aktive_verpachtungen": obj.neue_verpachtungen.filter(status="aktiv").count() if hasattr(obj, "neue_verpachtungen") else 0,
|
||||
}
|
||||
results.append(apply_privacy_filter(item, "paechter", role))
|
||||
|
||||
log_mcp_read(role, "paechter", "Pächter-Suche", f"Suche: '{suchbegriff}', {len(results)} Ergebnisse")
|
||||
return format_result({"anzahl": len(results), "paechter": results})
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
# Konten
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
def konten_uebersicht() -> str:
|
||||
"""
|
||||
Gibt eine Übersicht aller Stiftungskonten mit aktuellem Saldo zurück.
|
||||
"""
|
||||
from stiftung.models import StiftungsKonto
|
||||
|
||||
role = _get_role()
|
||||
|
||||
konten = []
|
||||
gesamt_saldo = 0.0
|
||||
for konto in StiftungsKonto.objects.filter(aktiv=True).order_by("bank_name"):
|
||||
saldo = float(konto.saldo) if konto.saldo else 0.0
|
||||
gesamt_saldo += saldo
|
||||
konten.append({
|
||||
"id": str(konto.id),
|
||||
"kontoname": konto.kontoname,
|
||||
"bank_name": konto.bank_name,
|
||||
"konto_typ": konto.konto_typ,
|
||||
"saldo": saldo,
|
||||
"saldo_datum": konto.saldo_datum.isoformat() if konto.saldo_datum else None,
|
||||
"zinssatz": float(konto.zinssatz) if konto.zinssatz else None,
|
||||
# IBAN nur für Admin
|
||||
"iban": konto.iban if role == "admin" else "****" + konto.iban[-4:] if konto.iban and len(konto.iban) > 4 else "****",
|
||||
})
|
||||
|
||||
log_mcp_read(role, "stiftungskonto", "Kontenübersicht", f"{len(konten)} Konten abgerufen")
|
||||
return format_result({
|
||||
"konten": konten,
|
||||
"gesamt_saldo": round(gesamt_saldo, 2),
|
||||
"anzahl_konten": len(konten),
|
||||
})
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
# Verwaltungskosten
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
def verwaltungskosten(
|
||||
jahr: int | None = None,
|
||||
kategorie: str = "",
|
||||
status: str = "",
|
||||
limit: int = 50,
|
||||
) -> str:
|
||||
"""
|
||||
Listet Verwaltungskosten auf.
|
||||
|
||||
Args:
|
||||
jahr: Filtert nach Jahr (z.B. 2024)
|
||||
kategorie: Filtert nach Kategorie (rechnung_intern, bueroausstattung, ...)
|
||||
status: Filtert nach Status (geplant, bezahlt, ...)
|
||||
limit: Maximale Anzahl (max. 200)
|
||||
"""
|
||||
from stiftung.models import Verwaltungskosten
|
||||
|
||||
role = _get_role()
|
||||
limit = min(limit, 200)
|
||||
|
||||
qs = Verwaltungskosten.objects.all()
|
||||
if jahr:
|
||||
qs = qs.filter(datum__year=jahr)
|
||||
if kategorie:
|
||||
qs = qs.filter(kategorie=kategorie)
|
||||
if status:
|
||||
qs = qs.filter(status=status)
|
||||
|
||||
qs = qs.order_by("-datum")[:limit]
|
||||
|
||||
results = []
|
||||
gesamt = 0.0
|
||||
for obj in qs:
|
||||
betrag = float(obj.betrag) if obj.betrag else 0.0
|
||||
gesamt += betrag
|
||||
results.append({
|
||||
"id": str(obj.id),
|
||||
"bezeichnung": obj.bezeichnung,
|
||||
"kategorie": obj.kategorie,
|
||||
"betrag": betrag,
|
||||
"datum": obj.datum.isoformat(),
|
||||
"lieferant_firma": obj.lieferant_firma,
|
||||
"status": obj.status,
|
||||
"rechnungsnummer": obj.rechnungsnummer,
|
||||
})
|
||||
|
||||
log_mcp_read(role, "verwaltungskosten", "Verwaltungskosten", f"{len(results)} Einträge")
|
||||
return format_result({
|
||||
"anzahl": len(results),
|
||||
"gesamt_betrag": round(gesamt, 2),
|
||||
"verwaltungskosten": results,
|
||||
})
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
# Transaktionen
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
def transaktionen_suchen(
|
||||
suchbegriff: str = "",
|
||||
konto_id: str = "",
|
||||
von_datum: str = "",
|
||||
bis_datum: str = "",
|
||||
transaction_type: str = "",
|
||||
limit: int = 50,
|
||||
) -> str:
|
||||
"""
|
||||
Sucht Banktransaktionen.
|
||||
|
||||
Args:
|
||||
suchbegriff: Freitext in Verwendungszweck oder Empfänger
|
||||
konto_id: UUID des Kontos (optional)
|
||||
von_datum: Startdatum YYYY-MM-DD (optional)
|
||||
bis_datum: Enddatum YYYY-MM-DD (optional)
|
||||
transaction_type: eingang/ausgang/dauerauftrag/... (optional)
|
||||
limit: Maximale Anzahl (max. 200)
|
||||
"""
|
||||
from stiftung.models import BankTransaction
|
||||
|
||||
role = _get_role()
|
||||
limit = min(limit, 200)
|
||||
|
||||
qs = BankTransaction.objects.select_related("konto").all()
|
||||
if suchbegriff:
|
||||
qs = qs.filter(
|
||||
Q(verwendungszweck__icontains=suchbegriff)
|
||||
| Q(empfaenger_zahlungspflichtiger__icontains=suchbegriff)
|
||||
)
|
||||
if konto_id:
|
||||
qs = qs.filter(konto_id=konto_id)
|
||||
if von_datum:
|
||||
qs = qs.filter(datum__gte=von_datum)
|
||||
if bis_datum:
|
||||
qs = qs.filter(datum__lte=bis_datum)
|
||||
if transaction_type:
|
||||
qs = qs.filter(transaction_type=transaction_type)
|
||||
|
||||
qs = qs.order_by("-datum")[:limit]
|
||||
|
||||
results = []
|
||||
for obj in qs:
|
||||
results.append({
|
||||
"id": str(obj.id),
|
||||
"datum": obj.datum.isoformat(),
|
||||
"betrag": float(obj.betrag),
|
||||
"waehrung": obj.waehrung,
|
||||
"verwendungszweck": obj.verwendungszweck[:200],
|
||||
"empfaenger_zahlungspflichtiger": obj.empfaenger_zahlungspflichtiger,
|
||||
"transaction_type": obj.transaction_type,
|
||||
"status": obj.status,
|
||||
"konto": obj.konto.kontoname if obj.konto else None,
|
||||
})
|
||||
|
||||
log_mcp_read(role, "banktransaction", "Transaktionssuche", f"Suche: '{suchbegriff}', {len(results)} Ergebnisse")
|
||||
return format_result({"anzahl": len(results), "transaktionen": results})
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
# Dokumente
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
def dokument_suchen(
|
||||
suchbegriff: str = "",
|
||||
kontext: str = "",
|
||||
limit: int = 30,
|
||||
) -> str:
|
||||
"""
|
||||
Sucht Dokumente im DMS nach Titel, Beschreibung oder Kontext.
|
||||
|
||||
Args:
|
||||
suchbegriff: Freitext (Titel, Beschreibung, Volltext)
|
||||
kontext: Dokumententyp (pachtvertrag, antrag, rechnung, ...)
|
||||
limit: Maximale Anzahl (max. 100)
|
||||
"""
|
||||
from stiftung.models import DokumentDatei
|
||||
|
||||
role = _get_role()
|
||||
limit = min(limit, 100)
|
||||
|
||||
qs = DokumentDatei.objects.all()
|
||||
if suchbegriff:
|
||||
qs = qs.filter(
|
||||
Q(titel__icontains=suchbegriff)
|
||||
| Q(beschreibung__icontains=suchbegriff)
|
||||
| Q(inhaltstext__icontains=suchbegriff)
|
||||
)
|
||||
if kontext:
|
||||
qs = qs.filter(kontext=kontext)
|
||||
|
||||
qs = qs.order_by("-erstellt_am")[:limit]
|
||||
|
||||
results = []
|
||||
for obj in qs:
|
||||
results.append({
|
||||
"id": str(obj.id),
|
||||
"titel": obj.titel,
|
||||
"kontext": obj.kontext,
|
||||
"beschreibung": obj.beschreibung[:200] if obj.beschreibung else "",
|
||||
"dateityp": obj.dateityp,
|
||||
"dateigroesse": obj.dateigroesse,
|
||||
"dateiname_original": obj.dateiname_original,
|
||||
})
|
||||
|
||||
log_mcp_read(role, "dokumentlink", "Dokumentsuche", f"Suche: '{suchbegriff}', {len(results)} Ergebnisse")
|
||||
return format_result({"anzahl": len(results), "dokumente": results})
|
||||
|
||||
|
||||
def dokument_details(dokument_id: str) -> str:
|
||||
"""
|
||||
Gibt Details eines Dokuments zurück (ohne Dateiinhalt).
|
||||
|
||||
Args:
|
||||
dokument_id: UUID des Dokuments
|
||||
"""
|
||||
from stiftung.models import DokumentDatei
|
||||
|
||||
role = _get_role()
|
||||
|
||||
try:
|
||||
obj = DokumentDatei.objects.get(id=dokument_id)
|
||||
except DokumentDatei.DoesNotExist:
|
||||
return format_result({"fehler": f"Dokument {dokument_id} nicht gefunden"})
|
||||
|
||||
data = {
|
||||
"id": str(obj.id),
|
||||
"titel": obj.titel,
|
||||
"kontext": obj.kontext,
|
||||
"beschreibung": obj.beschreibung,
|
||||
"dateityp": obj.dateityp,
|
||||
"dateigroesse": obj.dateigroesse,
|
||||
"dateiname_original": obj.dateiname_original,
|
||||
# Verknüpfungen
|
||||
"land_id": str(obj.land_id) if obj.land_id else None,
|
||||
"paechter_id": str(obj.paechter_id) if obj.paechter_id else None,
|
||||
}
|
||||
# Inhaltstext nur für Nicht-Binary-Dokumente und wenn vorhanden
|
||||
if obj.inhaltstext:
|
||||
data["inhaltsvorschau"] = obj.inhaltstext[:500]
|
||||
|
||||
log_mcp_read(role, "dokumentlink", obj.titel, f"Dokumentdetails abgerufen: {obj.titel}")
|
||||
return format_result(data)
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
# Termine
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
def termine_anzeigen(
|
||||
von_datum: str = "",
|
||||
bis_datum: str = "",
|
||||
kategorie: str = "",
|
||||
prioritaet: str = "",
|
||||
limit: int = 50,
|
||||
) -> str:
|
||||
"""
|
||||
Zeigt Kalendereinträge und Termine an.
|
||||
|
||||
Args:
|
||||
von_datum: Startdatum YYYY-MM-DD (optional, Standard: heute)
|
||||
bis_datum: Enddatum YYYY-MM-DD (optional)
|
||||
kategorie: termin/zahlung/deadline/geburtstag/vertrag/pruefung/sonstiges
|
||||
prioritaet: niedrig/normal/hoch/kritisch
|
||||
limit: Maximale Anzahl (max. 200)
|
||||
"""
|
||||
from datetime import date as date_type
|
||||
from stiftung.models import StiftungsKalenderEintrag
|
||||
|
||||
role = _get_role()
|
||||
limit = min(limit, 200)
|
||||
|
||||
qs = StiftungsKalenderEintrag.objects.all()
|
||||
if von_datum:
|
||||
qs = qs.filter(datum__gte=von_datum)
|
||||
else:
|
||||
qs = qs.filter(datum__gte=date_type.today())
|
||||
if bis_datum:
|
||||
qs = qs.filter(datum__lte=bis_datum)
|
||||
if kategorie:
|
||||
qs = qs.filter(kategorie=kategorie)
|
||||
if prioritaet:
|
||||
qs = qs.filter(prioritaet=prioritaet)
|
||||
|
||||
qs = qs.order_by("datum", "uhrzeit")[:limit]
|
||||
|
||||
results = []
|
||||
for obj in qs:
|
||||
results.append({
|
||||
"id": str(obj.id),
|
||||
"titel": obj.titel,
|
||||
"datum": obj.datum.isoformat(),
|
||||
"uhrzeit": obj.uhrzeit.isoformat() if obj.uhrzeit else None,
|
||||
"ganztags": obj.ganztags,
|
||||
"kategorie": obj.kategorie,
|
||||
"prioritaet": obj.prioritaet,
|
||||
"beschreibung": obj.beschreibung[:300] if obj.beschreibung else "",
|
||||
"destinataer_id": str(obj.destinataer_id) if obj.destinataer_id else None,
|
||||
})
|
||||
|
||||
log_mcp_read(role, "system", "Terminübersicht", f"{len(results)} Termine abgerufen")
|
||||
return format_result({"anzahl": len(results), "termine": results})
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
# Veranstaltungen
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
def veranstaltungen_anzeigen(
|
||||
status: str = "",
|
||||
limit: int = 20,
|
||||
) -> str:
|
||||
"""
|
||||
Zeigt Veranstaltungen der Stiftung an.
|
||||
|
||||
Args:
|
||||
status: geplant/einladungen_versendet/abgeschlossen/abgesagt (optional, leer = alle)
|
||||
limit: Maximale Anzahl (max. 50)
|
||||
"""
|
||||
from stiftung.models.veranstaltungen import Veranstaltung
|
||||
|
||||
role = _get_role()
|
||||
limit = min(limit, 50)
|
||||
|
||||
qs = Veranstaltung.objects.all()
|
||||
if status:
|
||||
qs = qs.filter(status=status)
|
||||
|
||||
qs = qs.order_by("-datum")[:limit]
|
||||
|
||||
results = []
|
||||
for v in qs:
|
||||
results.append({
|
||||
"id": str(v.id),
|
||||
"titel": v.titel,
|
||||
"datum": v.datum.isoformat(),
|
||||
"uhrzeit": v.uhrzeit.isoformat() if v.uhrzeit else None,
|
||||
"ort": v.ort,
|
||||
"status": v.status,
|
||||
"teilnehmer_gesamt": v.get_teilnehmer_count(),
|
||||
"zugesagt": v.get_zugesagte_count(),
|
||||
"abgesagt": v.get_abgesagte_count(),
|
||||
})
|
||||
|
||||
log_mcp_read(role, "veranstaltung", "Veranstaltungsübersicht", f"{len(results)} Veranstaltungen")
|
||||
return format_result({"anzahl": len(results), "veranstaltungen": results})
|
||||
|
||||
|
||||
def veranstaltung_teilnehmer_anzeigen(
|
||||
veranstaltung_id: str,
|
||||
rsvp_status: str = "",
|
||||
) -> str:
|
||||
"""
|
||||
Zeigt die Teilnehmer einer Veranstaltung an.
|
||||
|
||||
Args:
|
||||
veranstaltung_id: UUID der Veranstaltung (Pflichtfeld)
|
||||
rsvp_status: eingeladen/zugesagt/abgesagt/keine_rueckmeldung (optional, leer = alle)
|
||||
"""
|
||||
from stiftung.models.veranstaltungen import Veranstaltung
|
||||
|
||||
role = _get_role()
|
||||
|
||||
try:
|
||||
veranstaltung = Veranstaltung.objects.get(id=veranstaltung_id)
|
||||
except Veranstaltung.DoesNotExist:
|
||||
return format_result({"fehler": f"Veranstaltung {veranstaltung_id} nicht gefunden"})
|
||||
|
||||
qs = veranstaltung.teilnehmer.all()
|
||||
if rsvp_status:
|
||||
qs = qs.filter(rsvp_status=rsvp_status)
|
||||
|
||||
results = []
|
||||
for t in qs:
|
||||
results.append({
|
||||
"id": str(t.id),
|
||||
"anrede": t.anrede,
|
||||
"vorname": t.vorname,
|
||||
"nachname": t.nachname,
|
||||
"strasse": t.strasse,
|
||||
"plz": t.plz,
|
||||
"ort": t.ort,
|
||||
"email": t.email,
|
||||
"rsvp_status": t.rsvp_status,
|
||||
"destinataer_id": str(t.destinataer_id) if t.destinataer_id else None,
|
||||
})
|
||||
|
||||
log_mcp_read(
|
||||
role, "veranstaltung", str(veranstaltung.id),
|
||||
f"{len(results)} Teilnehmer von '{veranstaltung.titel}'",
|
||||
)
|
||||
return format_result({
|
||||
"veranstaltung": str(veranstaltung),
|
||||
"anzahl": len(results),
|
||||
"teilnehmer": results,
|
||||
})
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
# Globale Suche & Dashboard
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
def globale_suche(suchbegriff: str, limit_pro_typ: int = 5) -> str:
|
||||
"""
|
||||
Sucht über alle Entitätstypen gleichzeitig.
|
||||
|
||||
Args:
|
||||
suchbegriff: Suchbegriff (mindestens 2 Zeichen)
|
||||
limit_pro_typ: Ergebnisse pro Entitätstyp (max. 20)
|
||||
"""
|
||||
from stiftung.models import (
|
||||
BankTransaction, Destinataer, Land, Paechter,
|
||||
StiftungsKalenderEintrag, Verwaltungskosten,
|
||||
)
|
||||
|
||||
role = _get_role()
|
||||
|
||||
if len(suchbegriff) < 2:
|
||||
return format_result({"fehler": "Suchbegriff muss mindestens 2 Zeichen lang sein"})
|
||||
|
||||
limit_pro_typ = min(limit_pro_typ, 20)
|
||||
ergebnisse = {}
|
||||
|
||||
# Destinatäre
|
||||
dest = Destinataer.objects.filter(
|
||||
Q(vorname__icontains=suchbegriff) | Q(nachname__icontains=suchbegriff)
|
||||
)[:limit_pro_typ]
|
||||
ergebnisse["destinataere"] = [
|
||||
{"id": str(d.id), "name": f"{d.vorname} {d.nachname}", "typ": "destinataer"}
|
||||
for d in dest
|
||||
]
|
||||
|
||||
# Ländereien
|
||||
laender = Land.objects.filter(
|
||||
Q(bezeichnung__icontains=suchbegriff) | Q(gemarkung__icontains=suchbegriff)
|
||||
)[:limit_pro_typ]
|
||||
ergebnisse["laendereien"] = [
|
||||
{"id": str(l.id), "name": str(l), "typ": "land"}
|
||||
for l in laender
|
||||
]
|
||||
|
||||
# Pächter
|
||||
paechter = Paechter.objects.filter(
|
||||
Q(vorname__icontains=suchbegriff) | Q(nachname__icontains=suchbegriff)
|
||||
)[:limit_pro_typ]
|
||||
ergebnisse["paechter"] = [
|
||||
{"id": str(p.id), "name": f"{p.vorname} {p.nachname}", "typ": "paechter"}
|
||||
for p in paechter
|
||||
]
|
||||
|
||||
# Transaktionen
|
||||
transaktionen = BankTransaction.objects.filter(
|
||||
Q(verwendungszweck__icontains=suchbegriff)
|
||||
| Q(empfaenger_zahlungspflichtiger__icontains=suchbegriff)
|
||||
)[:limit_pro_typ]
|
||||
ergebnisse["transaktionen"] = [
|
||||
{"id": str(t.id), "verwendungszweck": t.verwendungszweck[:100], "betrag": float(t.betrag), "datum": t.datum.isoformat(), "typ": "transaktion"}
|
||||
for t in transaktionen
|
||||
]
|
||||
|
||||
log_mcp_read(role, "system", "Globale Suche", f"Suche: '{suchbegriff}'")
|
||||
return format_result(ergebnisse)
|
||||
|
||||
|
||||
def dashboard() -> str:
|
||||
"""
|
||||
Gibt eine Übersicht der wichtigsten Stiftungsdaten zurück.
|
||||
"""
|
||||
from datetime import date as date_type
|
||||
from stiftung.models import (
|
||||
BankTransaction, Destinataer, DestinataerUnterstuetzung,
|
||||
Land, LandVerpachtung, StiftungsKalenderEintrag, StiftungsKonto,
|
||||
)
|
||||
|
||||
role = _get_role()
|
||||
heute = date_type.today()
|
||||
|
||||
# Konten-Gesamtsaldo
|
||||
konten = StiftungsKonto.objects.filter(aktiv=True)
|
||||
gesamt_saldo = sum(float(k.saldo or 0) for k in konten)
|
||||
|
||||
# Destinatäre
|
||||
aktive_dest = Destinataer.objects.filter(aktiv=True).count()
|
||||
|
||||
# Offene Zahlungen
|
||||
offene_zahlungen = DestinataerUnterstuetzung.objects.filter(
|
||||
status__in=["geplant", "faellig", "nachweis_eingereicht", "freigegeben"]
|
||||
).aggregate(anzahl=Sum("betrag"))
|
||||
offene_zahlungen_betrag = float(offene_zahlungen["anzahl"] or 0)
|
||||
offene_zahlungen_anzahl = DestinataerUnterstuetzung.objects.filter(
|
||||
status__in=["geplant", "faellig", "nachweis_eingereicht", "freigegeben"]
|
||||
).count()
|
||||
|
||||
# Fällige Termine (nächste 30 Tage)
|
||||
from datetime import timedelta
|
||||
naechste_termine = StiftungsKalenderEintrag.objects.filter(
|
||||
datum__gte=heute,
|
||||
datum__lte=heute + timedelta(days=30),
|
||||
).order_by("datum")[:5]
|
||||
|
||||
termine_liste = [
|
||||
{"titel": t.titel, "datum": t.datum.isoformat(), "prioritaet": t.prioritaet, "kategorie": t.kategorie}
|
||||
for t in naechste_termine
|
||||
]
|
||||
|
||||
# Aktive Verpachtungen
|
||||
aktive_verpachtungen = LandVerpachtung.objects.filter(status="aktiv").count()
|
||||
|
||||
log_mcp_read(role, "system", "Dashboard", "Dashboard abgerufen")
|
||||
return format_result({
|
||||
"stand": heute.isoformat(),
|
||||
"finanzen": {
|
||||
"gesamt_saldo_eur": round(gesamt_saldo, 2),
|
||||
"anzahl_konten": konten.count(),
|
||||
},
|
||||
"destinataere": {
|
||||
"aktiv": aktive_dest,
|
||||
},
|
||||
"zahlungen": {
|
||||
"offen_anzahl": offene_zahlungen_anzahl,
|
||||
"offen_betrag_eur": round(offene_zahlungen_betrag, 2),
|
||||
},
|
||||
"verpachtungen": {
|
||||
"aktiv": aktive_verpachtungen,
|
||||
},
|
||||
"naechste_termine": termine_liste,
|
||||
})
|
||||
|
||||
|
||||
def statistiken() -> str:
|
||||
"""
|
||||
Gibt detaillierte Statistiken der Stiftungsverwaltung zurück.
|
||||
"""
|
||||
from datetime import date as date_type
|
||||
from stiftung.models import (
|
||||
BankTransaction, Destinataer, Foerderung,
|
||||
Land, LandVerpachtung, Verwaltungskosten,
|
||||
)
|
||||
|
||||
role = _get_role()
|
||||
aktuelles_jahr = date_type.today().year
|
||||
|
||||
# Förderungen dieses Jahr
|
||||
foerderungen_jahr = Foerderung.objects.filter(jahr=aktuelles_jahr)
|
||||
foerderungen_gesamt = foerderungen_jahr.aggregate(summe=Sum("betrag"))
|
||||
|
||||
# Destinatäre nach Familienzweig
|
||||
from django.db.models import Count
|
||||
dest_zweige = list(
|
||||
Destinataer.objects.filter(aktiv=True)
|
||||
.values("familienzweig")
|
||||
.annotate(anzahl=Count("id"))
|
||||
.order_by("-anzahl")
|
||||
)
|
||||
|
||||
# Verwaltungskosten dieses Jahr
|
||||
vk_jahr = Verwaltungskosten.objects.filter(datum__year=aktuelles_jahr)
|
||||
vk_gesamt = vk_jahr.aggregate(summe=Sum("betrag"))
|
||||
|
||||
# Ländereien
|
||||
laender_ges = Land.objects.count()
|
||||
laender_verpachtet = Land.objects.filter(
|
||||
neue_verpachtungen__status="aktiv"
|
||||
).distinct().count()
|
||||
|
||||
log_mcp_read(role, "system", "Statistiken", f"Statistiken für {aktuelles_jahr} abgerufen")
|
||||
return format_result({
|
||||
"jahr": aktuelles_jahr,
|
||||
"foerderungen": {
|
||||
"anzahl": foerderungen_jahr.count(),
|
||||
"gesamt_betrag_eur": float(foerderungen_gesamt["summe"] or 0),
|
||||
},
|
||||
"verwaltungskosten": {
|
||||
"anzahl": vk_jahr.count(),
|
||||
"gesamt_betrag_eur": float(vk_gesamt["summe"] or 0),
|
||||
},
|
||||
"destinataere_nach_zweig": dest_zweige,
|
||||
"laendereien": {
|
||||
"gesamt": laender_ges,
|
||||
"aktiv_verpachtet": laender_verpachtet,
|
||||
},
|
||||
})
|
||||
732
app/mcp_server/tools/schreiben.py
Normal file
732
app/mcp_server/tools/schreiben.py
Normal file
@@ -0,0 +1,732 @@
|
||||
"""
|
||||
Schreib-Tools für den MCP Server der Stiftungsverwaltung.
|
||||
|
||||
Alle Tools:
|
||||
- Prüfen die Rolle (editor oder admin erforderlich)
|
||||
- Schreiben Audit-Log-Einträge
|
||||
- Validieren Pflichtfelder vor dem Speichern
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from mcp_server.audit import log_mcp_create, log_mcp_update
|
||||
from mcp_server.auth import can_write, require_role
|
||||
from mcp_server.tools.helpers import format_result
|
||||
|
||||
|
||||
def _require_write_role() -> str:
|
||||
from mcp_server.auth import get_current_role
|
||||
|
||||
role = get_current_role()
|
||||
require_role(role)
|
||||
if not can_write(role):
|
||||
raise PermissionError(
|
||||
f"Rolle '{role}' hat keine Schreibrechte. "
|
||||
"editor- oder admin-Rolle erforderlich."
|
||||
)
|
||||
return role
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
# Destinatäre
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
def destinataer_anlegen(
|
||||
vorname: str,
|
||||
nachname: str,
|
||||
familienzweig: str = "",
|
||||
email: str = "",
|
||||
telefon: str = "",
|
||||
geburtsdatum: str = "",
|
||||
ort: str = "",
|
||||
plz: str = "",
|
||||
strasse: str = "",
|
||||
berufsgruppe: str = "",
|
||||
notizen: str = "",
|
||||
) -> str:
|
||||
"""
|
||||
Legt einen neuen Destinatär an.
|
||||
|
||||
Args:
|
||||
vorname: Vorname (Pflichtfeld)
|
||||
nachname: Nachname (Pflichtfeld)
|
||||
familienzweig: hauptzweig/nebenzweig/verwandt/anderer
|
||||
email: E-Mail-Adresse
|
||||
telefon: Telefonnummer
|
||||
geburtsdatum: Geburtsdatum YYYY-MM-DD
|
||||
ort: Wohnort
|
||||
plz: Postleitzahl
|
||||
strasse: Straße und Hausnummer
|
||||
berufsgruppe: student/wissenschaftler/künstler/sozialarbeiter/umweltschützer/andere
|
||||
notizen: Freitext-Notizen
|
||||
"""
|
||||
from stiftung.models import Destinataer
|
||||
|
||||
role = _require_write_role()
|
||||
|
||||
kwargs = {
|
||||
"vorname": vorname.strip(),
|
||||
"nachname": nachname.strip(),
|
||||
"aktiv": True,
|
||||
}
|
||||
if familienzweig:
|
||||
kwargs["familienzweig"] = familienzweig
|
||||
if email:
|
||||
kwargs["email"] = email
|
||||
if telefon:
|
||||
kwargs["telefon"] = telefon
|
||||
if geburtsdatum:
|
||||
kwargs["geburtsdatum"] = geburtsdatum
|
||||
if ort:
|
||||
kwargs["ort"] = ort
|
||||
if plz:
|
||||
kwargs["plz"] = plz
|
||||
if strasse:
|
||||
kwargs["strasse"] = strasse
|
||||
if berufsgruppe:
|
||||
kwargs["berufsgruppe"] = berufsgruppe
|
||||
if notizen:
|
||||
kwargs["notizen"] = notizen
|
||||
|
||||
obj = Destinataer.objects.create(**kwargs)
|
||||
log_mcp_create(role, "destinataer", str(obj.id), f"{vorname} {nachname}")
|
||||
return format_result({"erfolg": True, "id": str(obj.id), "name": f"{vorname} {nachname}"})
|
||||
|
||||
|
||||
def destinataer_aktualisieren(
|
||||
destinataer_id: str,
|
||||
vorname: str = "",
|
||||
nachname: str = "",
|
||||
email: str = "",
|
||||
telefon: str = "",
|
||||
ort: str = "",
|
||||
plz: str = "",
|
||||
strasse: str = "",
|
||||
aktiv: bool | None = None,
|
||||
notizen: str = "",
|
||||
familienzweig: str = "",
|
||||
) -> str:
|
||||
"""
|
||||
Aktualisiert einen bestehenden Destinatär.
|
||||
|
||||
Args:
|
||||
destinataer_id: UUID des Destinatärs (Pflichtfeld)
|
||||
vorname: Neuer Vorname (optional)
|
||||
nachname: Neuer Nachname (optional)
|
||||
email: Neue E-Mail (optional)
|
||||
telefon: Neue Telefonnummer (optional)
|
||||
ort: Neuer Ort (optional)
|
||||
plz: Neue PLZ (optional)
|
||||
strasse: Neue Straße (optional)
|
||||
aktiv: Aktivstatus (optional)
|
||||
notizen: Neue Notizen (optional)
|
||||
familienzweig: Neuer Familienzweig (optional)
|
||||
"""
|
||||
from stiftung.models import Destinataer
|
||||
|
||||
role = _require_write_role()
|
||||
|
||||
try:
|
||||
obj = Destinataer.objects.get(id=destinataer_id)
|
||||
except Destinataer.DoesNotExist:
|
||||
return format_result({"fehler": f"Destinatär {destinataer_id} nicht gefunden"})
|
||||
|
||||
changes = {}
|
||||
update_fields = []
|
||||
|
||||
def _set(field, value):
|
||||
if value != "" and value is not None:
|
||||
old = getattr(obj, field)
|
||||
if str(old) != str(value):
|
||||
changes[field] = {"alt": str(old), "neu": str(value)}
|
||||
setattr(obj, field, value)
|
||||
update_fields.append(field)
|
||||
|
||||
_set("vorname", vorname)
|
||||
_set("nachname", nachname)
|
||||
_set("email", email)
|
||||
_set("telefon", telefon)
|
||||
_set("ort", ort)
|
||||
_set("plz", plz)
|
||||
_set("strasse", strasse)
|
||||
_set("notizen", notizen)
|
||||
_set("familienzweig", familienzweig)
|
||||
if aktiv is not None:
|
||||
_set("aktiv", aktiv)
|
||||
|
||||
if not update_fields:
|
||||
return format_result({"erfolg": True, "hinweis": "Keine Änderungen"})
|
||||
|
||||
obj.save(update_fields=update_fields)
|
||||
name = f"{obj.vorname} {obj.nachname}"
|
||||
log_mcp_update(role, "destinataer", str(obj.id), name, changes)
|
||||
return format_result({"erfolg": True, "id": str(obj.id), "geaenderte_felder": list(changes.keys())})
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
# Förderungen & Unterstützungen
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
def foerderung_anlegen(
|
||||
destinataer_id: str,
|
||||
jahr: int,
|
||||
betrag: float,
|
||||
kategorie: str = "anderes",
|
||||
bemerkungen: str = "",
|
||||
) -> str:
|
||||
"""
|
||||
Legt eine neue Förderung für einen Destinatär an.
|
||||
|
||||
Args:
|
||||
destinataer_id: UUID des Destinatärs (Pflichtfeld)
|
||||
jahr: Förderjahr (Pflichtfeld)
|
||||
betrag: Förderbetrag in EUR (Pflichtfeld)
|
||||
kategorie: bildung/forschung/kultur/soziales/umwelt/anderes
|
||||
bemerkungen: Freitext
|
||||
"""
|
||||
from datetime import date
|
||||
|
||||
from stiftung.models import Destinataer, Foerderung
|
||||
|
||||
role = _require_write_role()
|
||||
|
||||
try:
|
||||
destinataer = Destinataer.objects.get(id=destinataer_id)
|
||||
except Destinataer.DoesNotExist:
|
||||
return format_result({"fehler": f"Destinatär {destinataer_id} nicht gefunden"})
|
||||
|
||||
foerderung = Foerderung.objects.create(
|
||||
destinataer=destinataer,
|
||||
jahr=jahr,
|
||||
betrag=betrag,
|
||||
kategorie=kategorie,
|
||||
bemerkungen=bemerkungen,
|
||||
status="beantragt",
|
||||
antragsdatum=date.today(),
|
||||
)
|
||||
|
||||
name = f"{destinataer.vorname} {destinataer.nachname} – {jahr}"
|
||||
log_mcp_create(role, "foerderung", str(foerderung.id), name)
|
||||
return format_result({"erfolg": True, "id": str(foerderung.id), "foerderung": name})
|
||||
|
||||
|
||||
def unterstuetzung_anlegen(
|
||||
destinataer_id: str,
|
||||
konto_id: str,
|
||||
betrag: float,
|
||||
faellig_am: str,
|
||||
beschreibung: str = "",
|
||||
verwendungszweck: str = "",
|
||||
) -> str:
|
||||
"""
|
||||
Legt eine neue Unterstützungszahlung für einen Destinatär an.
|
||||
|
||||
Args:
|
||||
destinataer_id: UUID des Destinatärs (Pflichtfeld)
|
||||
konto_id: UUID des Zahlungskontos (Pflichtfeld)
|
||||
betrag: Betrag in EUR (Pflichtfeld)
|
||||
faellig_am: Fälligkeitsdatum YYYY-MM-DD (Pflichtfeld)
|
||||
beschreibung: Kurzbeschreibung (optional)
|
||||
verwendungszweck: Verwendungszweck für Überweisung (optional)
|
||||
"""
|
||||
from stiftung.models import Destinataer, DestinataerUnterstuetzung, StiftungsKonto
|
||||
|
||||
role = _require_write_role()
|
||||
|
||||
try:
|
||||
destinataer = Destinataer.objects.get(id=destinataer_id)
|
||||
except Destinataer.DoesNotExist:
|
||||
return format_result({"fehler": f"Destinatär {destinataer_id} nicht gefunden"})
|
||||
|
||||
try:
|
||||
konto = StiftungsKonto.objects.get(id=konto_id)
|
||||
except StiftungsKonto.DoesNotExist:
|
||||
return format_result({"fehler": f"Konto {konto_id} nicht gefunden"})
|
||||
|
||||
unterstuetzung = DestinataerUnterstuetzung.objects.create(
|
||||
destinataer=destinataer,
|
||||
konto=konto,
|
||||
betrag=betrag,
|
||||
faellig_am=faellig_am,
|
||||
beschreibung=beschreibung,
|
||||
verwendungszweck=verwendungszweck,
|
||||
status="geplant",
|
||||
)
|
||||
|
||||
name = f"{destinataer.vorname} {destinataer.nachname} – {faellig_am}"
|
||||
log_mcp_create(role, "destinataer", str(unterstuetzung.id), name)
|
||||
return format_result({"erfolg": True, "id": str(unterstuetzung.id)})
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
# Ländereien & Verpachtungen
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
def land_anlegen(
|
||||
lfd_nr: str,
|
||||
amtsgericht: str,
|
||||
gemeinde: str,
|
||||
gemarkung: str,
|
||||
flur: str,
|
||||
flurstueck: str,
|
||||
groesse_qm: float,
|
||||
verpachtete_gesamtflaeche: float = 0.0,
|
||||
adresse: str = "",
|
||||
) -> str:
|
||||
"""
|
||||
Legt eine neue Länderei an.
|
||||
|
||||
Args:
|
||||
lfd_nr: Laufende Nummer (Pflichtfeld, eindeutig)
|
||||
amtsgericht: Zuständiges Amtsgericht (Pflichtfeld)
|
||||
gemeinde: Gemeinde (Pflichtfeld)
|
||||
gemarkung: Gemarkung (Pflichtfeld)
|
||||
flur: Flur (Pflichtfeld)
|
||||
flurstueck: Flurstück (Pflichtfeld)
|
||||
groesse_qm: Gesamtgröße in Quadratmetern (Pflichtfeld)
|
||||
verpachtete_gesamtflaeche: Verpachtete Fläche in qm (Standard: 0)
|
||||
adresse: Adresse/Ortsangabe (optional)
|
||||
"""
|
||||
from stiftung.models import Land
|
||||
|
||||
role = _require_write_role()
|
||||
|
||||
if Land.objects.filter(lfd_nr=lfd_nr).exists():
|
||||
return format_result({"fehler": f"Länderei mit lfd_nr '{lfd_nr}' existiert bereits"})
|
||||
|
||||
land = Land.objects.create(
|
||||
lfd_nr=lfd_nr,
|
||||
amtsgericht=amtsgericht,
|
||||
gemeinde=gemeinde,
|
||||
gemarkung=gemarkung,
|
||||
flur=flur,
|
||||
flurstueck=flurstueck,
|
||||
groesse_qm=groesse_qm,
|
||||
verpachtete_gesamtflaeche=verpachtete_gesamtflaeche,
|
||||
adresse=adresse,
|
||||
)
|
||||
|
||||
log_mcp_create(role, "land", str(land.id), str(land))
|
||||
return format_result({"erfolg": True, "id": str(land.id), "bezeichnung": str(land)})
|
||||
|
||||
|
||||
def verpachtung_anlegen(
|
||||
land_id: str,
|
||||
paechter_id: str,
|
||||
vertragsnummer: str,
|
||||
pachtbeginn: str,
|
||||
verpachtete_flaeche: float,
|
||||
pachtzins_pauschal: float,
|
||||
zahlungsweise: str = "jaehrlich",
|
||||
pachtende: str = "",
|
||||
) -> str:
|
||||
"""
|
||||
Legt einen neuen Pachtvertrag für eine Länderei an.
|
||||
|
||||
Args:
|
||||
land_id: UUID der Länderei (Pflichtfeld)
|
||||
paechter_id: UUID des Pächters (Pflichtfeld)
|
||||
vertragsnummer: Eindeutige Vertragsnummer (Pflichtfeld)
|
||||
pachtbeginn: Datum YYYY-MM-DD (Pflichtfeld)
|
||||
verpachtete_flaeche: Fläche in qm (Pflichtfeld)
|
||||
pachtzins_pauschal: Jährlicher Pachtzins in EUR (Pflichtfeld)
|
||||
zahlungsweise: jaehrlich/halbjaehrlich/vierteljaehrlich/monatlich
|
||||
pachtende: Datum YYYY-MM-DD (optional)
|
||||
"""
|
||||
from stiftung.models import Land, LandVerpachtung, Paechter
|
||||
|
||||
role = _require_write_role()
|
||||
|
||||
try:
|
||||
land = Land.objects.get(id=land_id)
|
||||
except Land.DoesNotExist:
|
||||
return format_result({"fehler": f"Länderei {land_id} nicht gefunden"})
|
||||
|
||||
try:
|
||||
paechter = Paechter.objects.get(id=paechter_id)
|
||||
except Paechter.DoesNotExist:
|
||||
return format_result({"fehler": f"Pächter {paechter_id} nicht gefunden"})
|
||||
|
||||
if LandVerpachtung.objects.filter(vertragsnummer=vertragsnummer).exists():
|
||||
return format_result({"fehler": f"Vertragsnummer '{vertragsnummer}' existiert bereits"})
|
||||
|
||||
kwargs = {
|
||||
"land": land,
|
||||
"paechter": paechter,
|
||||
"vertragsnummer": vertragsnummer,
|
||||
"pachtbeginn": pachtbeginn,
|
||||
"verpachtete_flaeche": verpachtete_flaeche,
|
||||
"pachtzins_pauschal": pachtzins_pauschal,
|
||||
"zahlungsweise": zahlungsweise,
|
||||
"status": "aktiv",
|
||||
}
|
||||
if pachtende:
|
||||
kwargs["pachtende"] = pachtende
|
||||
|
||||
verpachtung = LandVerpachtung.objects.create(**kwargs)
|
||||
name = f"{land} – {paechter}"
|
||||
log_mcp_create(role, "verpachtung", str(verpachtung.id), name)
|
||||
return format_result({"erfolg": True, "id": str(verpachtung.id)})
|
||||
|
||||
|
||||
def paechter_anlegen(
|
||||
vorname: str,
|
||||
nachname: str,
|
||||
email: str = "",
|
||||
telefon: str = "",
|
||||
ort: str = "",
|
||||
plz: str = "",
|
||||
strasse: str = "",
|
||||
personentyp: str = "natuerlich",
|
||||
) -> str:
|
||||
"""
|
||||
Legt einen neuen Pächter an.
|
||||
|
||||
Args:
|
||||
vorname: Vorname (Pflichtfeld)
|
||||
nachname: Nachname (Pflichtfeld)
|
||||
email: E-Mail (optional)
|
||||
telefon: Telefon (optional)
|
||||
ort: Ort (optional)
|
||||
plz: Postleitzahl (optional)
|
||||
strasse: Straße (optional)
|
||||
personentyp: natuerlich/gesellschaft
|
||||
"""
|
||||
from stiftung.models import Paechter
|
||||
|
||||
role = _require_write_role()
|
||||
|
||||
kwargs = {
|
||||
"vorname": vorname.strip(),
|
||||
"nachname": nachname.strip(),
|
||||
"personentyp": personentyp,
|
||||
}
|
||||
for field, value in [("email", email), ("telefon", telefon), ("ort", ort), ("plz", plz), ("strasse", strasse)]:
|
||||
if value:
|
||||
kwargs[field] = value
|
||||
|
||||
paechter = Paechter.objects.create(**kwargs)
|
||||
name = f"{vorname} {nachname}"
|
||||
log_mcp_create(role, "paechter", str(paechter.id), name)
|
||||
return format_result({"erfolg": True, "id": str(paechter.id), "name": name})
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
# Verwaltungskosten
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
def verwaltungskosten_erfassen(
|
||||
bezeichnung: str,
|
||||
kategorie: str,
|
||||
betrag: float,
|
||||
datum: str,
|
||||
lieferant_firma: str = "",
|
||||
rechnungsnummer: str = "",
|
||||
status: str = "geplant",
|
||||
) -> str:
|
||||
"""
|
||||
Erfasst eine neue Verwaltungskosten-Position.
|
||||
|
||||
Args:
|
||||
bezeichnung: Bezeichnung (Pflichtfeld)
|
||||
kategorie: rechnung_intern/bueroausstattung/fahrtkosten/porto/telefon_internet/
|
||||
software/beratung/versicherung/steuerberatung/bankgebuehren/sonstiges
|
||||
betrag: Betrag in EUR (Pflichtfeld)
|
||||
datum: Datum YYYY-MM-DD (Pflichtfeld)
|
||||
lieferant_firma: Lieferant oder Firma (optional)
|
||||
rechnungsnummer: Rechnungsnummer (optional)
|
||||
status: geplant/bestellt/erhalten/in_bearbeitung/bezahlt/storniert
|
||||
"""
|
||||
from stiftung.models import Verwaltungskosten
|
||||
|
||||
role = _require_write_role()
|
||||
|
||||
vk = Verwaltungskosten.objects.create(
|
||||
bezeichnung=bezeichnung,
|
||||
kategorie=kategorie,
|
||||
betrag=betrag,
|
||||
datum=datum,
|
||||
lieferant_firma=lieferant_firma,
|
||||
rechnungsnummer=rechnungsnummer,
|
||||
status=status,
|
||||
)
|
||||
|
||||
log_mcp_create(role, "verwaltungskosten", str(vk.id), bezeichnung)
|
||||
return format_result({"erfolg": True, "id": str(vk.id), "bezeichnung": bezeichnung})
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
# Termine
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
def termin_anlegen(
|
||||
titel: str,
|
||||
datum: str,
|
||||
kategorie: str = "termin",
|
||||
prioritaet: str = "normal",
|
||||
beschreibung: str = "",
|
||||
uhrzeit: str = "",
|
||||
ganztags: bool = True,
|
||||
destinataer_id: str = "",
|
||||
) -> str:
|
||||
"""
|
||||
Legt einen neuen Kalendertermin an.
|
||||
|
||||
Args:
|
||||
titel: Titel des Termins (Pflichtfeld)
|
||||
datum: Datum YYYY-MM-DD (Pflichtfeld)
|
||||
kategorie: termin/zahlung/deadline/geburtstag/vertrag/pruefung/sonstiges
|
||||
prioritaet: niedrig/normal/hoch/kritisch
|
||||
beschreibung: Beschreibung (optional)
|
||||
uhrzeit: Uhrzeit HH:MM (optional)
|
||||
ganztags: Ganztägig (Standard: True)
|
||||
destinataer_id: UUID eines zugehörigen Destinatärs (optional)
|
||||
"""
|
||||
from stiftung.models import Destinataer, StiftungsKalenderEintrag
|
||||
|
||||
role = _require_write_role()
|
||||
|
||||
kwargs = {
|
||||
"titel": titel,
|
||||
"datum": datum,
|
||||
"kategorie": kategorie,
|
||||
"prioritaet": prioritaet,
|
||||
"beschreibung": beschreibung,
|
||||
"ganztags": ganztags,
|
||||
}
|
||||
if uhrzeit:
|
||||
kwargs["uhrzeit"] = uhrzeit
|
||||
kwargs["ganztags"] = False
|
||||
if destinataer_id:
|
||||
try:
|
||||
kwargs["destinataer"] = Destinataer.objects.get(id=destinataer_id)
|
||||
except Destinataer.DoesNotExist:
|
||||
return format_result({"fehler": f"Destinatär {destinataer_id} nicht gefunden"})
|
||||
|
||||
termin = StiftungsKalenderEintrag.objects.create(**kwargs)
|
||||
log_mcp_create(role, "system", str(termin.id), titel)
|
||||
return format_result({"erfolg": True, "id": str(termin.id), "titel": titel, "datum": datum})
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
# Dokument verknüpfen
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
def dokument_verknuepfen(
|
||||
dokument_id: str,
|
||||
land_id: str = "",
|
||||
paechter_id: str = "",
|
||||
destinataer_id: str = "",
|
||||
) -> str:
|
||||
"""
|
||||
Verknüpft ein vorhandenes Dokument mit einer Länderei, einem Pächter oder Destinatär.
|
||||
|
||||
Args:
|
||||
dokument_id: UUID des Dokuments (Pflichtfeld)
|
||||
land_id: UUID der Länderei (optional)
|
||||
paechter_id: UUID des Pächters (optional)
|
||||
destinataer_id: UUID des Destinatärs (optional)
|
||||
"""
|
||||
from stiftung.models import DokumentDatei
|
||||
|
||||
role = _require_write_role()
|
||||
|
||||
try:
|
||||
dokument = DokumentDatei.objects.get(id=dokument_id)
|
||||
except DokumentDatei.DoesNotExist:
|
||||
return format_result({"fehler": f"Dokument {dokument_id} nicht gefunden"})
|
||||
|
||||
changes = {}
|
||||
update_fields = []
|
||||
|
||||
if land_id:
|
||||
from stiftung.models import Land
|
||||
try:
|
||||
land = Land.objects.get(id=land_id)
|
||||
dokument.land = land
|
||||
update_fields.append("land")
|
||||
changes["land"] = {"neu": str(land)}
|
||||
except Land.DoesNotExist:
|
||||
return format_result({"fehler": f"Länderei {land_id} nicht gefunden"})
|
||||
|
||||
if paechter_id:
|
||||
from stiftung.models import Paechter
|
||||
try:
|
||||
paechter = Paechter.objects.get(id=paechter_id)
|
||||
dokument.paechter = paechter
|
||||
update_fields.append("paechter")
|
||||
changes["paechter"] = {"neu": str(paechter)}
|
||||
except Paechter.DoesNotExist:
|
||||
return format_result({"fehler": f"Pächter {paechter_id} nicht gefunden"})
|
||||
|
||||
if destinataer_id:
|
||||
from stiftung.models import Destinataer
|
||||
try:
|
||||
dest = Destinataer.objects.get(id=destinataer_id)
|
||||
dokument.destinataer = dest
|
||||
update_fields.append("destinataer")
|
||||
changes["destinataer"] = {"neu": f"{dest.vorname} {dest.nachname}"}
|
||||
except Destinataer.DoesNotExist:
|
||||
return format_result({"fehler": f"Destinatär {destinataer_id} nicht gefunden"})
|
||||
|
||||
if not update_fields:
|
||||
return format_result({"fehler": "Keine Verknüpfung angegeben (land_id, paechter_id oder destinataer_id)"})
|
||||
|
||||
dokument.save(update_fields=update_fields)
|
||||
log_mcp_update(role, "dokumentlink", str(dokument.id), dokument.titel, changes)
|
||||
return format_result({"erfolg": True, "id": str(dokument.id), "verknuepft_mit": list(changes.keys())})
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
# Veranstaltungen – Teilnehmer
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
def veranstaltung_teilnehmer_anlegen(
|
||||
veranstaltung_id: str,
|
||||
vorname: str,
|
||||
nachname: str,
|
||||
anrede: str = "",
|
||||
strasse: str = "",
|
||||
plz: str = "",
|
||||
ort: str = "",
|
||||
email: str = "",
|
||||
rsvp_status: str = "eingeladen",
|
||||
bemerkungen: str = "",
|
||||
destinataer_id: str = "",
|
||||
) -> str:
|
||||
"""
|
||||
Fügt einen Teilnehmer zu einer Veranstaltung hinzu.
|
||||
|
||||
Args:
|
||||
veranstaltung_id: UUID der Veranstaltung (Pflichtfeld)
|
||||
vorname: Vorname (Pflichtfeld)
|
||||
nachname: Nachname (Pflichtfeld)
|
||||
anrede: Herr/Frau (optional). Akzeptiert auch 'Herrn' → wird zu 'Herr' normalisiert.
|
||||
strasse: Straße und Hausnummer (optional)
|
||||
plz: Postleitzahl (optional)
|
||||
ort: Ort (optional)
|
||||
email: E-Mail-Adresse (optional)
|
||||
rsvp_status: eingeladen/zugesagt/abgesagt/keine_rueckmeldung (Standard: eingeladen)
|
||||
bemerkungen: Freitext-Bemerkungen (optional)
|
||||
destinataer_id: UUID eines bestehenden Destinatärs zum Verknüpfen (optional)
|
||||
"""
|
||||
from stiftung.models import Destinataer
|
||||
from stiftung.models.veranstaltungen import Veranstaltung, Veranstaltungsteilnehmer
|
||||
|
||||
role = _require_write_role()
|
||||
|
||||
try:
|
||||
veranstaltung = Veranstaltung.objects.get(id=veranstaltung_id)
|
||||
except Veranstaltung.DoesNotExist:
|
||||
return format_result({"fehler": f"Veranstaltung {veranstaltung_id} nicht gefunden"})
|
||||
|
||||
# Normalize anrede: 'Herrn' → 'Herr'
|
||||
anrede_norm = anrede.strip()
|
||||
if anrede_norm.lower() == "herrn":
|
||||
anrede_norm = "Herr"
|
||||
|
||||
kwargs = {
|
||||
"veranstaltung": veranstaltung,
|
||||
"vorname": vorname.strip(),
|
||||
"nachname": nachname.strip(),
|
||||
"anrede": anrede_norm,
|
||||
"strasse": strasse.strip(),
|
||||
"plz": plz.strip(),
|
||||
"ort": ort.strip(),
|
||||
"email": email.strip(),
|
||||
"rsvp_status": rsvp_status,
|
||||
"bemerkungen": bemerkungen,
|
||||
}
|
||||
|
||||
if destinataer_id:
|
||||
try:
|
||||
kwargs["destinataer"] = Destinataer.objects.get(id=destinataer_id)
|
||||
except Destinataer.DoesNotExist:
|
||||
return format_result({"fehler": f"Destinatär {destinataer_id} nicht gefunden"})
|
||||
|
||||
teilnehmer = Veranstaltungsteilnehmer.objects.create(**kwargs)
|
||||
name = f"{vorname} {nachname}"
|
||||
log_mcp_create(role, "veranstaltung", str(teilnehmer.id), f"Teilnehmer: {name}")
|
||||
return format_result({
|
||||
"erfolg": True,
|
||||
"id": str(teilnehmer.id),
|
||||
"name": name,
|
||||
"veranstaltung": str(veranstaltung),
|
||||
})
|
||||
|
||||
|
||||
def veranstaltung_teilnehmer_importieren(
|
||||
veranstaltung_id: str,
|
||||
teilnehmer_liste: str,
|
||||
) -> str:
|
||||
"""
|
||||
Importiert mehrere Teilnehmer auf einmal in eine Veranstaltung.
|
||||
|
||||
Args:
|
||||
veranstaltung_id: UUID der Veranstaltung (Pflichtfeld)
|
||||
teilnehmer_liste: JSON-Array mit Teilnehmerdaten. Jedes Objekt kann enthalten:
|
||||
vorname (Pflicht), nachname (Pflicht), anrede, strasse, plz, ort, email,
|
||||
rsvp_status, bemerkungen.
|
||||
Beispiel: [{"vorname": "Max", "nachname": "Muster", "anrede": "Herr",
|
||||
"strasse": "Musterstr. 1", "plz": "12345", "ort": "Berlin"}]
|
||||
"""
|
||||
import json as _json
|
||||
|
||||
from stiftung.models.veranstaltungen import Veranstaltung, Veranstaltungsteilnehmer
|
||||
|
||||
role = _require_write_role()
|
||||
|
||||
try:
|
||||
veranstaltung = Veranstaltung.objects.get(id=veranstaltung_id)
|
||||
except Veranstaltung.DoesNotExist:
|
||||
return format_result({"fehler": f"Veranstaltung {veranstaltung_id} nicht gefunden"})
|
||||
|
||||
try:
|
||||
teilnehmer_data = _json.loads(teilnehmer_liste)
|
||||
except _json.JSONDecodeError as e:
|
||||
return format_result({"fehler": f"Ungültiges JSON: {e}"})
|
||||
|
||||
if not isinstance(teilnehmer_data, list):
|
||||
return format_result({"fehler": "teilnehmer_liste muss ein JSON-Array sein"})
|
||||
|
||||
erstellt = []
|
||||
fehler = []
|
||||
|
||||
for idx, entry in enumerate(teilnehmer_data):
|
||||
vorname = (entry.get("vorname") or "").strip()
|
||||
nachname = (entry.get("nachname") or "").strip()
|
||||
|
||||
if not vorname or not nachname:
|
||||
fehler.append({"index": idx, "grund": "vorname und nachname sind Pflichtfelder"})
|
||||
continue
|
||||
|
||||
anrede = (entry.get("anrede") or "").strip()
|
||||
if anrede.lower() == "herrn":
|
||||
anrede = "Herr"
|
||||
|
||||
teilnehmer = Veranstaltungsteilnehmer.objects.create(
|
||||
veranstaltung=veranstaltung,
|
||||
vorname=vorname,
|
||||
nachname=nachname,
|
||||
anrede=anrede,
|
||||
strasse=(entry.get("strasse") or "").strip(),
|
||||
plz=(entry.get("plz") or "").strip(),
|
||||
ort=(entry.get("ort") or "").strip(),
|
||||
email=(entry.get("email") or "").strip(),
|
||||
rsvp_status=entry.get("rsvp_status", "eingeladen"),
|
||||
bemerkungen=entry.get("bemerkungen", ""),
|
||||
)
|
||||
erstellt.append({"id": str(teilnehmer.id), "name": f"{vorname} {nachname}"})
|
||||
|
||||
log_mcp_create(
|
||||
role, "veranstaltung", str(veranstaltung.id),
|
||||
f"{len(erstellt)} Teilnehmer importiert",
|
||||
)
|
||||
return format_result({
|
||||
"erfolg": True,
|
||||
"veranstaltung": str(veranstaltung),
|
||||
"erstellt": len(erstellt),
|
||||
"fehler": len(fehler),
|
||||
"teilnehmer": erstellt,
|
||||
"fehler_details": fehler if fehler else None,
|
||||
})
|
||||
@@ -14,3 +14,5 @@ django-otp==1.2.4
|
||||
django-htmx==1.19.0
|
||||
qrcode[pil]==7.4.2
|
||||
schwifty==2026.3.0
|
||||
mcp>=1.0.0
|
||||
httpx>=0.27.0
|
||||
|
||||
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 veranstaltung # noqa: F401
|
||||
from . import system # noqa: F401
|
||||
from stiftung.agent import admin as agent_admin # noqa: F401
|
||||
|
||||
# Customize admin site
|
||||
admin.site.site_header = "Stiftungsverwaltung Administration"
|
||||
|
||||
@@ -24,7 +24,7 @@ class DestinataerAdmin(admin.ModelAdmin):
|
||||
fieldsets = (
|
||||
(
|
||||
"Persönliche Daten",
|
||||
{"fields": ("vorname", "nachname", "geburtsdatum", "email", "telefon")},
|
||||
{"fields": ("anrede", "vorname", "nachname", "geburtsdatum", "email", "telefon")},
|
||||
),
|
||||
(
|
||||
"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():
|
||||
if field_name not in ["vorname", "nachname"]:
|
||||
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["berufsgruppe"].choices = [("", "Bitte wählen...")] + list(Destinataer.BERUFSGRUPPE_CHOICES)
|
||||
if "anrede" in self.fields:
|
||||
self.fields["anrede"].choices = [("", "Bitte wählen...")] + list(Destinataer.ANREDE_CHOICES)
|
||||
# Set choices for standard_konto to allow blank
|
||||
self.fields["standard_konto"].empty_label = "---"
|
||||
|
||||
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):
|
||||
"""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_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(
|
||||
'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
|
||||
@@ -408,9 +409,13 @@ class VierteljahresNachweisForm(forms.ModelForm):
|
||||
vermogenssituation_datei = cleaned_data.get('vermogenssituation_datei')
|
||||
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(
|
||||
'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
|
||||
@@ -420,9 +425,15 @@ class VierteljahresNachweisForm(forms.ModelForm):
|
||||
studiennachweis_bemerkung = cleaned_data.get('studiennachweis_bemerkung')
|
||||
|
||||
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(
|
||||
'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
|
||||
|
||||
@@ -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",
|
||||
"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
|
||||
|
||||
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.")
|
||||
)
|
||||
@@ -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,
|
||||
DestinataerUnterstuetzung,
|
||||
Foerderung,
|
||||
OnboardingEinladung,
|
||||
Person,
|
||||
UnterstuetzungWiederkehrend,
|
||||
UploadToken,
|
||||
VierteljahresNachweis,
|
||||
)
|
||||
|
||||
@@ -52,3 +54,7 @@ from .veranstaltungen import ( # noqa: F401
|
||||
Veranstaltung,
|
||||
Veranstaltungsteilnehmer,
|
||||
)
|
||||
|
||||
from .vorlagen import ( # noqa: F401
|
||||
DokumentVorlage,
|
||||
)
|
||||
|
||||
@@ -26,7 +26,20 @@ class Destinataer(models.Model):
|
||||
("andere", "Andere"),
|
||||
]
|
||||
|
||||
ANREDE_CHOICES = [
|
||||
("Herr", "Herr"),
|
||||
("Frau", "Frau"),
|
||||
("Divers", "Divers"),
|
||||
]
|
||||
|
||||
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(
|
||||
max_length=100, choices=FAMILIENZWIG_CHOICES, blank=True, null=True
|
||||
)
|
||||
@@ -362,7 +375,6 @@ class DestinataerUnterstuetzung(models.Model):
|
||||
("nachweis_eingereicht", "Nachweis eingereicht"),
|
||||
("freigegeben", "Freigegeben"),
|
||||
("ausgezahlt", "Überwiesen"),
|
||||
("abgeschlossen", "Abgeschlossen"),
|
||||
]
|
||||
|
||||
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
|
||||
@@ -485,7 +497,7 @@ class DestinataerUnterstuetzung(models.Model):
|
||||
"in_bearbeitung": 3,
|
||||
"freigegeben": 3,
|
||||
"ausgezahlt": 4,
|
||||
"abgeschlossen": 5,
|
||||
"abgeschlossen": 4,
|
||||
"storniert": 0,
|
||||
}
|
||||
return stage_map.get(self.status, 1)
|
||||
@@ -755,6 +767,38 @@ class VierteljahresNachweis(models.Model):
|
||||
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
|
||||
status = models.CharField(
|
||||
max_length=20,
|
||||
@@ -840,19 +884,27 @@ class VierteljahresNachweis(models.Model):
|
||||
"""Check if all required documents/confirmations are provided"""
|
||||
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)
|
||||
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 (
|
||||
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 (
|
||||
bool(self.vermogenssituation_text) or bool(self.vermogenssituation_datei)
|
||||
or bool(self.vermogenssituation_dms_dokument_id)
|
||||
)
|
||||
|
||||
return complete
|
||||
@@ -868,23 +920,30 @@ class VierteljahresNachweis(models.Model):
|
||||
total_requirements = 2 # Income and assets always required
|
||||
completed_requirements = 0
|
||||
|
||||
has_dms_studiennachweis = (
|
||||
bool(self.studiennachweis_dms_dokument_id)
|
||||
or self.nachweis_dokumente.filter(kontext="studiennachweis").exists()
|
||||
)
|
||||
|
||||
# Study proof (if required)
|
||||
if self.studiennachweis_erforderlich:
|
||||
total_requirements += 1
|
||||
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
|
||||
|
||||
# Income situation
|
||||
if self.einkommenssituation_bestaetigt and (
|
||||
bool(self.einkommenssituation_text) or bool(self.einkommenssituation_datei)
|
||||
or bool(self.einkommenssituation_dms_dokument_id)
|
||||
):
|
||||
completed_requirements += 1
|
||||
|
||||
# Asset situation
|
||||
if self.vermogenssituation_bestaetigt and (
|
||||
bool(self.vermogenssituation_text) or bool(self.vermogenssituation_datei)
|
||||
or bool(self.vermogenssituation_dms_dokument_id)
|
||||
):
|
||||
completed_requirements += 1
|
||||
|
||||
@@ -1260,3 +1319,153 @@ class EmailEingang(models.Model):
|
||||
|
||||
# Backward-compatible alias
|
||||
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"),
|
||||
("bescheid", "Bescheid / Behörde"),
|
||||
("stiftungsgeschichte", "Stiftungsgeschichte / Archiv"),
|
||||
("email", "E-Mail-Nachricht"),
|
||||
("anderes", "Sonstiges"),
|
||||
]
|
||||
|
||||
|
||||
@@ -11,6 +11,10 @@ class CSVImport(models.Model):
|
||||
("paechter", "Pächter"),
|
||||
("laendereien", "Ländereien"),
|
||||
("verpachtungen", "Verpachtungen"),
|
||||
("foerderungen", "Förderungen"),
|
||||
("konten", "Stiftungskonten"),
|
||||
("verwaltungskosten", "Verwaltungskosten"),
|
||||
("rentmeister", "Rentmeister"),
|
||||
("personen", "Personen (Legacy)"),
|
||||
]
|
||||
|
||||
@@ -111,6 +115,8 @@ class ApplicationPermission(models.Model):
|
||||
# System Permissions
|
||||
("access_django_admin", "Kann Django Admin aufrufen"),
|
||||
("view_system_stats", "Kann Systemstatistiken anzeigen"),
|
||||
# AI Agent Permissions
|
||||
("can_use_agent", "Kann AI-Assistenten nutzen"),
|
||||
]
|
||||
|
||||
|
||||
|
||||
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:
|
||||
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:
|
||||
eingang.status = "verarbeitet" if destinataer else status
|
||||
eingang.save()
|
||||
if dms_dokumente:
|
||||
eingang.dokument_dateien.set(dms_dokumente)
|
||||
if alle_dms_dokumente:
|
||||
eingang.dokument_dateien.set(alle_dms_dokumente)
|
||||
|
||||
# Als gelesen markieren
|
||||
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
|
||||
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
|
||||
|
||||
app_name = "stiftung"
|
||||
|
||||
urlpatterns = [
|
||||
# AI Agent
|
||||
path("agent/", include("stiftung.agent.urls")),
|
||||
|
||||
# Home - Main landing page after login
|
||||
path("", views.home, name="home"),
|
||||
# CSV Import URLs
|
||||
# CSV Import URLs (legacy)
|
||||
path("import/", views.csv_import_list, name="csv_import_list"),
|
||||
path("import/neu/", views.csv_import_create, name="csv_import_create"),
|
||||
# Unified Import/Export Hub
|
||||
path("daten/", views.import_export_hub, name="import_export_hub"),
|
||||
path("daten/export/", views.csv_export, name="csv_export"),
|
||||
path("daten/import/upload/", views.csv_import_upload, name="csv_import_upload"),
|
||||
path("daten/import/ausfuehren/", views.csv_import_execute, name="csv_import_execute"),
|
||||
# Destinatär URLs (Förderungsempfänger)
|
||||
path("destinataere/", views.destinataer_list, name="destinataer_list"),
|
||||
path(
|
||||
@@ -158,6 +166,16 @@ urlpatterns = [
|
||||
views.jahresbericht_pdf,
|
||||
name="jahresbericht_pdf",
|
||||
),
|
||||
path(
|
||||
"berichte/zusammenstellen/",
|
||||
views.bericht_zusammenstellen,
|
||||
name="bericht_zusammenstellen",
|
||||
),
|
||||
path(
|
||||
"berichte/<str:vorlage_key>/",
|
||||
views.bericht_vorlage,
|
||||
name="bericht_vorlage",
|
||||
),
|
||||
# Geschäftsführung URLs
|
||||
path("geschaeftsfuehrung/", views.geschaeftsfuehrung, name="geschaeftsfuehrung"),
|
||||
path("geschaeftsfuehrung/konten/", views.konto_list, name="konto_list"),
|
||||
@@ -441,6 +459,53 @@ urlpatterns = [
|
||||
# Phase 2: Pächter-Workflow (2d)
|
||||
path("paechter/workflow/", views.paechter_workflow, name="paechter_workflow"),
|
||||
|
||||
# Phase 4: Upload-Portal – Admin-seitige Auslöser
|
||||
path(
|
||||
"quarterly-confirmations/<uuid:nachweis_pk>/aufforderung-senden/",
|
||||
views.nachweis_aufforderung_senden,
|
||||
name="nachweis_aufforderung_senden",
|
||||
),
|
||||
path(
|
||||
"nachweis-board/batch-aufforderung-senden/",
|
||||
views.batch_nachweis_aufforderung_senden,
|
||||
name="batch_nachweis_aufforderung_senden",
|
||||
),
|
||||
|
||||
# Phase 5: Onboarding – Admin-Seite
|
||||
path(
|
||||
"destinataere/onboarding/einladen/",
|
||||
views.onboarding_einladung_senden,
|
||||
name="onboarding_einladung_senden",
|
||||
),
|
||||
path(
|
||||
"destinataere/onboarding/einladungen/",
|
||||
views.onboarding_einladung_liste,
|
||||
name="onboarding_einladung_liste",
|
||||
),
|
||||
path(
|
||||
"destinataere/onboarding/einladungen/<uuid:pk>/widerrufen/",
|
||||
views.onboarding_einladung_widerrufen,
|
||||
name="onboarding_einladung_widerrufen",
|
||||
),
|
||||
# Bestätigungsschreiben
|
||||
path(
|
||||
"destinataere/<uuid:pk>/bestaetigung/",
|
||||
views.bestaetigung_vorschau,
|
||||
name="bestaetigung_vorschau",
|
||||
),
|
||||
path(
|
||||
"destinataere/<uuid:pk>/bestaetigung/versenden/",
|
||||
views.bestaetigung_versenden,
|
||||
name="bestaetigung_versenden",
|
||||
),
|
||||
|
||||
# Dokument-Vorlagen-Editor
|
||||
path("administration/vorlagen/", views.vorlagen_liste, name="vorlagen_liste"),
|
||||
path("administration/vorlagen/<uuid:pk>/", views.vorlage_editor, name="vorlage_editor"),
|
||||
path("administration/vorlagen/<uuid:pk>/zuruecksetzen/", views.vorlage_zuruecksetzen, name="vorlage_zuruecksetzen"),
|
||||
path("administration/vorlagen/<uuid:pk>/vorschau/", views.vorlage_vorschau, name="vorlage_vorschau"),
|
||||
path("administration/vorlagen/alle-zuruecksetzen/", views.vorlagen_alle_zuruecksetzen, name="vorlagen_alle_zuruecksetzen"),
|
||||
|
||||
# Phase 3: DMS – Django-natives Dokumentenmanagement
|
||||
path("dms/", views.dms_list, name="dms_list"),
|
||||
path("dms/hochladen/", views.dms_upload, name="dms_upload"),
|
||||
|
||||
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_notiz_create,
|
||||
destinataer_export,
|
||||
# Bestätigungsschreiben
|
||||
bestaetigung_vorschau,
|
||||
bestaetigung_versenden,
|
||||
)
|
||||
|
||||
|
||||
@@ -29,6 +32,10 @@ from .finanzen import ( # noqa: F401
|
||||
jahresbericht_generate,
|
||||
jahresbericht_generate_redirect,
|
||||
jahresbericht_pdf,
|
||||
bericht_zusammenstellen,
|
||||
bericht_vorlage,
|
||||
BERICHT_SEKTIONEN,
|
||||
BERICHT_VORLAGEN,
|
||||
geschaeftsfuehrung,
|
||||
konto_list,
|
||||
verwaltungskosten_list,
|
||||
@@ -177,6 +184,13 @@ from .unterstuetzungen import ( # noqa: F401
|
||||
unterstuetzung_nachweis_eingereicht,
|
||||
unterstuetzung_abschliessen,
|
||||
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
|
||||
@@ -202,5 +216,20 @@ from .veranstaltung import ( # noqa: F401
|
||||
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)
|
||||
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.response import Response
|
||||
|
||||
from stiftung.audit import log_action
|
||||
from stiftung.models import (AppConfiguration, AuditLog, BackupJob, BankTransaction,
|
||||
BriefVorlage, CSVImport, Destinataer,
|
||||
DestinataerEmailEingang, DestinataerNotiz,
|
||||
@@ -476,11 +477,13 @@ def destinataer_toggle_archiv(request, pk):
|
||||
destinataer.aktiv = not destinataer.aktiv
|
||||
destinataer.save(update_fields=["aktiv"])
|
||||
status_text = "aktiviert" if destinataer.aktiv else "archiviert (inaktiv gesetzt)"
|
||||
AuditLog.objects.create(
|
||||
user=request.user,
|
||||
action=f"Destinatär {destinataer.get_full_name()} wurde {status_text}.",
|
||||
model_name="Destinataer",
|
||||
object_id=str(destinataer.pk),
|
||||
log_action(
|
||||
request,
|
||||
action="update",
|
||||
entity_type="destinataer",
|
||||
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}.')
|
||||
return redirect("stiftung:destinataer_detail", pk=destinataer.pk)
|
||||
@@ -760,3 +763,117 @@ def destinataer_export(request, pk):
|
||||
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()
|
||||
verp_id = request.POST.get("verpachtung_id", "").strip()
|
||||
|
||||
dok.destinataer_id = int(dest_id) if dest_id else None
|
||||
dok.land_id = int(land_id) if land_id else None
|
||||
dok.paechter_id = int(paechter_id) if paechter_id else None
|
||||
dok.destinataer_id = dest_id if dest_id else None
|
||||
dok.land_id = land_id if land_id else None
|
||||
dok.paechter_id = paechter_id if paechter_id else None
|
||||
dok.verpachtung_id = verp_id if verp_id else None
|
||||
|
||||
dok.save()
|
||||
|
||||
@@ -59,7 +59,7 @@ from stiftung.forms import (
|
||||
|
||||
@login_required
|
||||
def bericht_list(request):
|
||||
"""List available reports"""
|
||||
"""List available reports with modular report builder"""
|
||||
# Get available years from data
|
||||
jahre = sorted(
|
||||
set(
|
||||
@@ -69,7 +69,7 @@ def bericht_list(request):
|
||||
reverse=True,
|
||||
)
|
||||
|
||||
# Statistics for overview tiles (removed legacy Person and Verpachtung)
|
||||
# Statistics for overview tiles
|
||||
total_destinataere = Destinataer.objects.count()
|
||||
total_laendereien = Land.objects.count()
|
||||
total_verpachtungen = LandVerpachtung.objects.count()
|
||||
@@ -82,10 +82,25 @@ def bericht_list(request):
|
||||
"total_laendereien": total_laendereien,
|
||||
"total_verpachtungen": total_verpachtungen,
|
||||
"total_foerderungen": total_foerderungen,
|
||||
"bericht_vorlagen": BERICHT_VORLAGEN,
|
||||
"bericht_sektionen": BERICHT_SEKTIONEN,
|
||||
}
|
||||
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):
|
||||
"""Phase 4: Aggregiert alle Daten für den Jahresbericht."""
|
||||
from stiftung.models import (
|
||||
@@ -138,7 +153,7 @@ def _jahresbericht_context(jahr):
|
||||
total_ausgaben = total_ausgaben_foerderung + total_verwaltungskosten
|
||||
netto = total_einnahmen - total_ausgaben
|
||||
|
||||
return {
|
||||
context = {
|
||||
"jahr": jahr,
|
||||
"title": f"Jahresbericht {jahr}",
|
||||
"foerderungen": foerderungen,
|
||||
@@ -157,9 +172,14 @@ def _jahresbericht_context(jahr):
|
||||
"total_einnahmen": total_einnahmen,
|
||||
"total_ausgaben": total_ausgaben,
|
||||
"netto": netto,
|
||||
# Rückwärtskompatibilität
|
||||
"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
|
||||
@@ -181,24 +201,276 @@ def jahresbericht_generate_redirect(request):
|
||||
|
||||
@login_required
|
||||
def jahresbericht_pdf(request, jahr):
|
||||
"""Phase 4: PDF-Export des Jahresberichts."""
|
||||
from django.http import HttpResponse
|
||||
"""Phase 4: PDF-Export des Jahresberichts via PDFGenerator."""
|
||||
from django.template.loader import render_to_string
|
||||
from weasyprint import HTML
|
||||
from stiftung.utils.pdf_generator import pdf_generator
|
||||
|
||||
context = _jahresbericht_context(jahr)
|
||||
|
||||
# Render HTML
|
||||
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")
|
||||
response["Content-Disposition"] = f'attachment; filename="jahresbericht_{jahr}.pdf"'
|
||||
# =============================================================================
|
||||
# MODULARE BERICHTE – Berichts-Baukasten
|
||||
# =============================================================================
|
||||
|
||||
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
|
||||
|
||||
@@ -750,8 +750,10 @@ def email_eingang_detail(request, pk):
|
||||
messages.success(request, "Notizen gespeichert.")
|
||||
return redirect("stiftung:email_eingang_detail", pk=pk)
|
||||
|
||||
# DMS-Dokumente
|
||||
dms_dokumente = eingang.dokument_dateien.all().order_by("erstellt_am")
|
||||
# DMS-Dokumente: E-Mail-Body und Anhaenge trennen
|
||||
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_destinataere = Destinataer.objects.filter(aktiv=True).order_by("nachname", "vorname")
|
||||
@@ -759,7 +761,8 @@ def email_eingang_detail(request, pk):
|
||||
context = {
|
||||
"title": f"E-Mail-Eingang: {eingang}",
|
||||
"eingang": eingang,
|
||||
"dms_dokumente": dms_dokumente,
|
||||
"email_dokument": email_dokument,
|
||||
"anhaenge_dokumente": anhaenge_dokumente,
|
||||
"alle_destinataere": alle_destinataere,
|
||||
"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.auth.decorators import login_required
|
||||
from django.core.paginator import Paginator
|
||||
from django.db.models import (Avg, Count, DecimalField, F, IntegerField, Q,
|
||||
Sum, Value)
|
||||
from django.db.models import (Avg, BigIntegerField, Count, DecimalField, F,
|
||||
IntegerField, Q, Sum, Value)
|
||||
from django.db.models.functions import Cast, Coalesce, NullIf, Replace
|
||||
from django.http import HttpResponse, JsonResponse
|
||||
from django.shortcuts import get_object_or_404, redirect, render
|
||||
@@ -274,24 +274,25 @@ def land_list(request):
|
||||
lands = lands.filter(aktiv=False)
|
||||
|
||||
# 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):
|
||||
expr = Replace(field_expr, Value(" "), Value(""))
|
||||
expr = Replace(expr, Value("-"), Value(""))
|
||||
expr = Replace(expr, Value("."), Value(""))
|
||||
expr = Replace(expr, Value("/"), Value(""))
|
||||
expr = Replace(expr, Value("L"), Value(""))
|
||||
return expr
|
||||
return RegexpReplace(field_expr)
|
||||
|
||||
lands = lands.extra(
|
||||
select={
|
||||
"verpachtungsgrad": "CASE WHEN groesse_qm > 0 THEN (verp_flaeche_aktuell / groesse_qm) * 100 ELSE 0 END"
|
||||
}
|
||||
).annotate(
|
||||
lfd_nr_num=Cast(NullIf(digits_only(F("lfd_nr")), Value("")), IntegerField()),
|
||||
flur_num=Cast(NullIf(digits_only(F("flur")), Value("")), IntegerField()),
|
||||
lfd_nr_num=Cast(NullIf(digits_only(F("lfd_nr")), Value("")), BigIntegerField()),
|
||||
flur_num=Cast(NullIf(digits_only(F("flur")), Value("")), BigIntegerField()),
|
||||
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
|
||||
|
||||
@@ -1887,7 +1930,8 @@ def email_settings(request):
|
||||
|
||||
if action == "save":
|
||||
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}"
|
||||
if setting.setting_type == "boolean":
|
||||
new_val = "True" if field_name in request.POST else "False"
|
||||
@@ -1954,13 +1998,148 @@ def email_settings(request):
|
||||
"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
|
||||
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 = {
|
||||
"imap_settings": imap_settings,
|
||||
"smtp_settings": smtp_settings,
|
||||
"notification_settings": notification_settings,
|
||||
"test_result": test_result,
|
||||
"title": "E-Mail / IMAP Konfiguration",
|
||||
"title": "E-Mail-Konfiguration (IMAP & SMTP)",
|
||||
}
|
||||
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.response import Response
|
||||
|
||||
from stiftung.audit import log_action
|
||||
from stiftung.models import (AppConfiguration, AuditLog, BackupJob, BankTransaction,
|
||||
BriefVorlage, CSVImport, Destinataer,
|
||||
DestinataerEmailEingang, DestinataerNotiz,
|
||||
@@ -1299,16 +1300,53 @@ def quarterly_confirmation_create(request, destinataer_id):
|
||||
@login_required
|
||||
def quarterly_confirmation_edit(request, pk):
|
||||
"""Standalone edit view for quarterly confirmation"""
|
||||
from stiftung.models import DokumentDatei
|
||||
|
||||
nachweis = get_object_or_404(VierteljahresNachweis, pk=pk)
|
||||
|
||||
|
||||
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)
|
||||
if form.is_valid():
|
||||
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
|
||||
old_status = nachweis.status
|
||||
|
||||
|
||||
# Auto-update status based on completion
|
||||
if quarterly_proof.is_complete():
|
||||
if quarterly_proof.status in ['offen', 'teilweise']:
|
||||
@@ -1317,15 +1355,15 @@ def quarterly_confirmation_edit(request, pk):
|
||||
else:
|
||||
# If not complete, set to teilweise if some fields are filled
|
||||
has_partial_data = (
|
||||
quarterly_proof.einkommenssituation_bestaetigt or
|
||||
quarterly_proof.einkommenssituation_bestaetigt or
|
||||
quarterly_proof.vermogenssituation_bestaetigt or
|
||||
quarterly_proof.studiennachweis_eingereicht
|
||||
)
|
||||
if has_partial_data and quarterly_proof.status == 'offen':
|
||||
quarterly_proof.status = 'teilweise'
|
||||
|
||||
|
||||
quarterly_proof.save()
|
||||
|
||||
|
||||
# Try to create automatic support payment if complete
|
||||
if quarterly_proof.is_complete() and quarterly_proof.status == 'eingereicht':
|
||||
support_payment = create_quarterly_support_payment(quarterly_proof)
|
||||
@@ -1343,17 +1381,17 @@ def quarterly_confirmation_edit(request, pk):
|
||||
reasons.append("keine IBAN hinterlegt")
|
||||
if not quarterly_proof.destinataer.standard_konto and not StiftungsKonto.objects.exists():
|
||||
reasons.append("kein Auszahlungskonto verfügbar")
|
||||
|
||||
|
||||
if reasons:
|
||||
messages.warning(
|
||||
request,
|
||||
f"Automatische Unterstützung konnte nicht erstellt werden: {', '.join(reasons)}"
|
||||
)
|
||||
|
||||
|
||||
# Debug message to see what happened
|
||||
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})"
|
||||
|
||||
|
||||
messages.success(
|
||||
request,
|
||||
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}")
|
||||
else:
|
||||
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 = {
|
||||
'form': form,
|
||||
'nachweis': nachweis,
|
||||
'destinataer': nachweis.destinataer,
|
||||
'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)
|
||||
|
||||
@@ -1644,11 +1697,13 @@ def batch_erinnerung_senden(request):
|
||||
count = 0
|
||||
for nachweis in overdue:
|
||||
try:
|
||||
AuditLog.objects.create(
|
||||
user=request.user,
|
||||
action=f"ERINNERUNG: Nachweis {nachweis.jahr} Q{nachweis.quartal} – {nachweis.destinataer.get_full_name()} ({nachweis.destinataer.email})",
|
||||
model_name="VierteljahresNachweis",
|
||||
object_id=str(nachweis.id),
|
||||
log_action(
|
||||
request,
|
||||
action="update",
|
||||
entity_type="destinataer",
|
||||
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
|
||||
except Exception:
|
||||
@@ -1661,9 +1716,104 @@ def batch_erinnerung_senden(request):
|
||||
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
|
||||
def zahlungs_pipeline(request):
|
||||
"""2c: Zahlungs-Pipeline – 5-Stufen-Kanban-Ansicht."""
|
||||
"""2c: Zahlungs-Pipeline – 4-Stufen-Kanban-Ansicht."""
|
||||
heute = date.today()
|
||||
destinataer_id = request.GET.get("destinataer", "")
|
||||
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"),
|
||||
"nachweis_eingereicht": qs.filter(status="nachweis_eingereicht").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"),
|
||||
"abgeschlossen": qs.filter(status="abgeschlossen").order_by("-ausgezahlt_am"),
|
||||
"ueberwiesen": qs.filter(status__in=["ausgezahlt", "abgeschlossen"]).order_by("-ausgezahlt_am"),
|
||||
}
|
||||
|
||||
stage_meta = {
|
||||
@@ -1690,7 +1839,6 @@ def zahlungs_pipeline(request):
|
||||
"nachweis_eingereicht": ("Nachweis eingereicht", "info", "fa-file-alt"),
|
||||
"freigegeben": ("Freigegeben (4-Augen)", "warning", "fa-shield-alt"),
|
||||
"ueberwiesen": ("Überwiesen", "success", "fa-university"),
|
||||
"abgeschlossen": ("Abgeschlossen", "dark", "fa-check-double"),
|
||||
}
|
||||
|
||||
pipeline_stages = [
|
||||
@@ -1702,7 +1850,7 @@ def zahlungs_pipeline(request):
|
||||
"zahlungen": list(pipeline[key]),
|
||||
"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")
|
||||
@@ -1883,5 +2031,127 @@ def sepa_xml_export(request):
|
||||
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
|
||||
|
||||
|
||||
@@ -84,13 +84,13 @@ def veranstaltung_detail(request, pk):
|
||||
def veranstaltung_serienbrief_pdf(request, pk):
|
||||
"""Generiert Serienbrief-PDF für alle Teilnehmer einer Veranstaltung"""
|
||||
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)
|
||||
teilnehmer = veranstaltung.teilnehmer.all().order_by("nachname", "vorname")
|
||||
|
||||
# Render HTML for all letters
|
||||
html_string = render_to_string(
|
||||
# Render HTML for all letters (DB-Vorlage first, file fallback)
|
||||
html_string = render_vorlage(
|
||||
"stiftung/veranstaltung/serienbrief_pdf.html",
|
||||
{
|
||||
"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>
|
||||
<span>Destinataere</span>
|
||||
</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' %}">
|
||||
<i class="fas fa-gift"></i>
|
||||
<span>Foerderungen</span>
|
||||
@@ -661,6 +665,15 @@
|
||||
</a>
|
||||
</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 -->
|
||||
<div class="sidebar-section">
|
||||
<div class="sidebar-heading">System</div>
|
||||
@@ -676,6 +689,10 @@
|
||||
<i class="fas fa-book-open"></i>
|
||||
<span>Geschichte</span>
|
||||
</a>
|
||||
<a class="sidebar-link" href="/ahnenforschung/" target="_blank">
|
||||
<i class="fas fa-tree"></i>
|
||||
<span>Ahnenforschung</span>
|
||||
</a>
|
||||
{% if perms.stiftung.access_administration %}
|
||||
<a class="sidebar-link" href="{% url 'stiftung:administration' %}">
|
||||
<i class="fas fa-cogs"></i>
|
||||
@@ -741,7 +758,7 @@
|
||||
<!-- Footer -->
|
||||
<footer class="main-footer">
|
||||
© 2026 van Hees-Theyssen-Vogel'sche Stiftung ·
|
||||
<small>Vision 2026 · v4.0.0</small>
|
||||
<small>Vision 2026 · v{{ APP_VERSION }}</small>
|
||||
</footer>
|
||||
</main>
|
||||
|
||||
@@ -995,5 +1012,464 @@
|
||||
});
|
||||
})();
|
||||
</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>
|
||||
</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 %}
|
||||
24
app/templates/portal/onboarding_fehler.html
Normal file
24
app/templates/portal/onboarding_fehler.html
Normal file
@@ -0,0 +1,24 @@
|
||||
{% extends "portal/onboarding_basis.html" %}
|
||||
{% block title %}Fehler – Onboarding{% endblock %}
|
||||
{% block fortschritt %}{% endblock %}
|
||||
{% block inhalt %}
|
||||
<div class="card mb-3">
|
||||
<div class="card-body text-center py-5">
|
||||
<div style="font-size: 2.5rem; color: #c0392b; margin-bottom: 16px;">⚠</div>
|
||||
{% if fehler_typ == "bereits_abgeschlossen" %}
|
||||
<h2 class="text-danger">Dieser Link wurde bereits verwendet</h2>
|
||||
<p>Das Onboarding-Verfahren für diesen Einladungslink wurde bereits abgeschlossen.</p>
|
||||
<p>Wenn Sie Fragen haben, wenden Sie sich bitte direkt an die Stiftung.</p>
|
||||
{% elif fehler_typ == "abgelaufen" %}
|
||||
<h2 class="text-danger">Dieser Einladungslink ist abgelaufen</h2>
|
||||
<p>Der Einladungslink ist nicht mehr gültig. Bitte kontaktieren Sie die Stiftung, um einen neuen Link zu erhalten.</p>
|
||||
{% else %}
|
||||
<h2 class="text-danger">Ungültiger Link</h2>
|
||||
<p>Dieser Einladungslink ist ungültig oder wurde nicht gefunden.</p>
|
||||
{% endif %}
|
||||
<p class="mt-4 text-muted small">
|
||||
van Hees-Theyssen-Vogel'sche Stiftung · Raesfelder Str. 3, 46499 Hamminkeln · Tel. 02858/836780
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
59
app/templates/portal/onboarding_schritt1.html
Normal file
59
app/templates/portal/onboarding_schritt1.html
Normal file
@@ -0,0 +1,59 @@
|
||||
{% extends "portal/onboarding_basis.html" %}
|
||||
{% block title %}Schritt 1: Datenschutz{% endblock %}
|
||||
{% block fortschritt %}
|
||||
<div class="fortschritt-bar"><div class="fortschritt-fill" style="width:20%"></div></div>
|
||||
<p class="fortschritt-label">Schritt 1 von 5 – Datenschutz & Erklärung</p>
|
||||
{% endblock %}
|
||||
{% block inhalt %}
|
||||
<div class="card mb-3">
|
||||
<div class="card-header py-3">
|
||||
<h2>Schritt 1: Datenschutzerklärung & Erklärung des Leistungsempfängers</h2>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
{% if fehler %}
|
||||
<div class="alert alert-danger">{{ fehler }}</div>
|
||||
{% endif %}
|
||||
|
||||
<p class="text-muted mb-3">Bitte lesen Sie die nachfolgende Datenschutzerklärung sowie die Erklärung des Leistungsempfängers und stimmen Sie beiden zu.</p>
|
||||
|
||||
<h5>1. Datenschutzerklärung</h5>
|
||||
<div class="dse-scroll mb-2">
|
||||
<strong>Verantwortliche Stelle:</strong> van Hees-Theyssen-Vogel'sche Stiftung, Raesfelder Str. 3, 46499 Hamminkeln<br><br>
|
||||
<strong>Verarbeitungszweck:</strong> Die von Ihnen übermittelten personenbezogenen Daten werden ausschließlich zum Zweck der Prüfung und Gewährung von Stiftungsleistungen gemäß der Stiftungssatzung verarbeitet.<br><br>
|
||||
<strong>Rechtsgrundlage:</strong> Die Verarbeitung erfolgt auf Grundlage von Art. 6 Abs. 1 lit. b DSGVO (Vertragserfüllung) sowie für besondere Kategorien personenbezogener Daten (Einkommenssituation, Pflegegrad u.ä.) auf Grundlage Ihrer ausdrücklichen Einwilligung gem. Art. 9 Abs. 2 lit. a DSGVO.<br><br>
|
||||
<strong>Gespeicherte Daten:</strong> Name, Adresse, Geburtsdatum, Kontaktdaten, Einkommens- und Vermögensdaten, Ausbildungsnachweise, hochgeladene Dokumente.<br><br>
|
||||
<strong>Speicherdauer:</strong> Ihre Daten werden für die Dauer der Förderbeziehung sowie darüber hinaus für die gesetzlich vorgeschriebene Aufbewahrungszeit (i.d.R. 10 Jahre) gespeichert. Nicht angenommene Anträge werden nach 3 Jahren gelöscht.<br><br>
|
||||
<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) sowie das Recht auf Datenübertragbarkeit (Art. 20 DSGVO). Sie können Ihre Einwilligung jederzeit widerrufen, ohne dass die Rechtmäßigkeit der bis dahin erfolgten Verarbeitung berührt wird. Beschwerden können Sie an die Landesbeauftragte für Datenschutz und Informationsfreiheit NRW (LDI NRW) richten.<br><br>
|
||||
<strong>Weitergabe:</strong> Eine Weitergabe Ihrer Daten an Dritte erfolgt nur im gesetzlich zulässigen Rahmen (z.B. Steuerberater, Stiftungsaufsicht) oder auf Basis Ihrer ausdrücklichen Einwilligung.
|
||||
</div>
|
||||
|
||||
<h5 class="mt-4">2. Erklärung des Leistungsempfängers</h5>
|
||||
<div class="dse-scroll mb-2">
|
||||
<p>Das Merkblatt für die Bewilligung und Zahlung von Zuwendungen der van Hees-Theyssen-Vogel'schen Stiftung habe ich gelesen, verstanden und erkenne die dort genannten Angabepflichten als verbindlich an.</p>
|
||||
<p>Hinsichtlich der Regelungen insbesondere des Sozialgesetzbuches und der Abgabenordnung habe ich mich kundig gemacht und, soweit für mein Verständnis der Regelungen erforderlich, fachlichen Rat eingeholt.</p>
|
||||
<p>Ich verpflichte mich, alle erforderlichen Angaben unaufgefordert zu machen. Mir ist bekannt, dass Verstöße zur Einstellung jeglicher Förderung führen und rechtliche Folgen (z.B. Schadenersatz, strafrechtliche Folgen) nach sich ziehen, für die ich uneingeschränkt die Verantwortung übernehme.</p>
|
||||
<p><strong>Förderbedingungen (§ 53 AO):</strong> Grundsätzlich können nur Personen gefördert werden, die als Alleinstehende keine höheren monatlichen Bezüge als 2.245,00 € haben und deren Vermögen nicht zur nachhaltigen Verbesserung ihres Unterhalts ausreicht (Schonvermögen i.d.R. max. 15.500 €). Die Sätze erhöhen sich bei weiteren Haushaltsangehörigen.</p>
|
||||
</div>
|
||||
|
||||
<form method="post">
|
||||
{% csrf_token %}
|
||||
<div class="form-check mt-3">
|
||||
<input class="form-check-input" type="checkbox" name="dse_zustimmung" id="dse_zustimmung" required>
|
||||
<label class="form-check-label" for="dse_zustimmung">
|
||||
Ich habe die <strong>Datenschutzerklärung</strong> gelesen und stimme der Verarbeitung meiner personenbezogenen Daten zu. <span class="required-mark">*</span>
|
||||
</label>
|
||||
</div>
|
||||
<div class="form-check mt-2">
|
||||
<input class="form-check-input" type="checkbox" name="merkblatt_zustimmung" id="merkblatt_zustimmung" required>
|
||||
<label class="form-check-label" for="merkblatt_zustimmung">
|
||||
Ich habe die <strong>Erklärung des Leistungsempfängers</strong> gelesen und erkenne die Angabepflichten als verbindlich an. <span class="required-mark">*</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="d-flex justify-content-end mt-4">
|
||||
<button type="submit" class="btn btn-primary btn-weiter px-4">Weiter →</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
97
app/templates/portal/onboarding_schritt2.html
Normal file
97
app/templates/portal/onboarding_schritt2.html
Normal file
@@ -0,0 +1,97 @@
|
||||
{% extends "portal/onboarding_basis.html" %}
|
||||
{% block title %}Schritt 2: Persönliche Daten{% endblock %}
|
||||
{% block fortschritt %}
|
||||
<div class="fortschritt-bar"><div class="fortschritt-fill" style="width:40%"></div></div>
|
||||
<p class="fortschritt-label">Schritt 2 von 5 – Persönliche Daten</p>
|
||||
{% endblock %}
|
||||
{% block inhalt %}
|
||||
<div class="card mb-3">
|
||||
<div class="card-header py-3">
|
||||
<h2>Schritt 2: Persönliche Angaben</h2>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
{% if fehler %}
|
||||
<div class="alert alert-danger">{{ fehler }}</div>
|
||||
{% endif %}
|
||||
<p class="text-muted small">Pflichtfelder sind mit <span class="required-mark">*</span> markiert. (Merkblatt Punkte 1–4)</p>
|
||||
|
||||
<form method="post">
|
||||
{% csrf_token %}
|
||||
|
||||
<div class="row g-3 mb-3">
|
||||
<div class="col-sm-6">
|
||||
<label for="vorname" class="form-label">Vorname <span class="required-mark">*</span></label>
|
||||
<input type="text" class="form-control{% if 'vorname' in fehlende_felder %} is-invalid{% endif %}" id="vorname" name="vorname" value="{{ post_data.vorname|default:data.schritt2.vorname|default:'' }}" required>
|
||||
</div>
|
||||
<div class="col-sm-6">
|
||||
<label for="nachname" class="form-label">Nachname <span class="required-mark">*</span></label>
|
||||
<input type="text" class="form-control{% if 'nachname' in fehlende_felder %} is-invalid{% endif %}" id="nachname" name="nachname" value="{{ post_data.nachname|default:data.schritt2.nachname|default:'' }}" required>
|
||||
</div>
|
||||
<div class="col-sm-6">
|
||||
<label for="geburtsdatum" class="form-label">Geburtsdatum <span class="required-mark">*</span></label>
|
||||
<input type="date" class="form-control{% if 'geburtsdatum' in fehlende_felder %} is-invalid{% endif %}" id="geburtsdatum" name="geburtsdatum" value="{{ post_data.geburtsdatum|default:data.schritt2.geburtsdatum|default:'' }}" required>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<hr class="my-3">
|
||||
<h6 class="text-muted">Adresse (Punkt 1)</h6>
|
||||
<div class="row g-3 mb-3">
|
||||
<div class="col-12">
|
||||
<label for="strasse" class="form-label">Straße und Hausnummer <span class="required-mark">*</span></label>
|
||||
<input type="text" class="form-control{% if 'strasse' in fehlende_felder %} is-invalid{% endif %}" id="strasse" name="strasse" value="{{ post_data.strasse|default:data.schritt2.strasse|default:'' }}" required>
|
||||
</div>
|
||||
<div class="col-sm-4">
|
||||
<label for="plz" class="form-label">PLZ <span class="required-mark">*</span></label>
|
||||
<input type="text" class="form-control{% if 'plz' in fehlende_felder %} is-invalid{% endif %}" id="plz" name="plz" maxlength="10" value="{{ post_data.plz|default:data.schritt2.plz|default:'' }}" required>
|
||||
</div>
|
||||
<div class="col-sm-8">
|
||||
<label for="ort" class="form-label">Ort <span class="required-mark">*</span></label>
|
||||
<input type="text" class="form-control{% if 'ort' in fehlende_felder %} is-invalid{% endif %}" id="ort" name="ort" value="{{ post_data.ort|default:data.schritt2.ort|default:'' }}" required>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<hr class="my-3">
|
||||
<h6 class="text-muted">Kontaktdaten (Punkt 1)</h6>
|
||||
<div class="row g-3 mb-3">
|
||||
<div class="col-sm-6">
|
||||
<label for="telefon" class="form-label">Telefonnummer <span class="required-mark">*</span></label>
|
||||
<input type="tel" class="form-control{% if 'telefon' in fehlende_felder %} is-invalid{% endif %}" id="telefon" name="telefon" value="{{ post_data.telefon|default:data.schritt2.telefon|default:'' }}" required>
|
||||
</div>
|
||||
<div class="col-sm-6">
|
||||
<label for="handynummer" class="form-label">Handynummer</label>
|
||||
<input type="tel" class="form-control" id="handynummer" name="handynummer" value="{{ post_data.handynummer|default:data.schritt2.handynummer|default:'' }}">
|
||||
</div>
|
||||
<div class="col-12">
|
||||
<label for="email" class="form-label">E-Mail-Adresse <span class="required-mark">*</span></label>
|
||||
<input type="email" class="form-control{% if 'email' in fehlende_felder %} is-invalid{% endif %}" id="email" name="email" value="{{ post_data.email|default:data.schritt2.email|default:einladung.email }}" required>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<hr class="my-3">
|
||||
<h6 class="text-muted">Verwandtschaftsverhältnis (Punkt 4)</h6>
|
||||
<div class="mb-3">
|
||||
<label for="verwandtschaftsverhaeltnis" class="form-label">
|
||||
Verwandtschaftsverhältnis zu einem Geschwisterteil des Stifters Hendrik van Hees oder seiner Ehefrau Aletta Theyssen-Vogel <span class="required-mark">*</span>
|
||||
</label>
|
||||
<textarea class="form-control{% if 'verwandtschaftsverhaeltnis' in fehlende_felder %} is-invalid{% endif %}" id="verwandtschaftsverhaeltnis" name="verwandtschaftsverhaeltnis" rows="2" required>{{ post_data.verwandtschaftsverhaeltnis|default:data.schritt2.verwandtschaftsverhaeltnis|default:'' }}</textarea>
|
||||
<div class="form-text">z.B. „Enkelin von Margarethe van Hees, Schwester des Stifters"</div>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label for="familienzweig" class="form-label">Familienzweig</label>
|
||||
<select class="form-select" id="familienzweig" name="familienzweig">
|
||||
<option value="">– bitte wählen –</option>
|
||||
<option value="hauptzweig" {% if data.schritt2.familienzweig == 'hauptzweig' or post_data.familienzweig == 'hauptzweig' %}selected{% endif %}>Hauptzweig</option>
|
||||
<option value="nebenzweig" {% if data.schritt2.familienzweig == 'nebenzweig' or post_data.familienzweig == 'nebenzweig' %}selected{% endif %}>Nebenzweig</option>
|
||||
<option value="verwandt" {% if data.schritt2.familienzweig == 'verwandt' or post_data.familienzweig == 'verwandt' %}selected{% endif %}>Verwandt</option>
|
||||
<option value="anderer" {% if data.schritt2.familienzweig == 'anderer' or post_data.familienzweig == 'anderer' %}selected{% endif %}>Anderer</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="d-flex justify-content-between mt-4">
|
||||
<button type="submit" name="aktion" value="zurueck" class="btn btn-outline-secondary btn-zurueck">← Zurück</button>
|
||||
<button type="submit" class="btn btn-primary btn-weiter px-4">Weiter →</button>
|
||||
</div>
|
||||
</form>
|
||||
</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