Compare commits

...

24 Commits

Author SHA1 Message Date
SysAdmin Agent
2a3577baff Fix GrampsWeb: patch service worker to respect subpath (STI-90)
Some checks failed
Code Quality / quality (push) Has been cancelled
CI/CD Pipeline / test (push) Has been cancelled
CI/CD Pipeline / deploy (push) Has been cancelled
The GrampsWeb service worker was serving index.html for ALL navigation
requests (including Django app routes), hijacking the entire domain.
Patched sw.js at startup to:
- Use subpath-prefixed index.html in createHandlerBoundToURL
- Update denylist regex to match subpath API routes

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-24 12:33:18 +00:00
SysAdmin Agent
d5eb072a46 Fix GrampsWeb: recursive CSS find + auto-create admin on startup (STI-90)
Some checks failed
CI/CD Pipeline / test (push) Has been cancelled
CI/CD Pipeline / deploy (push) Has been cancelled
Code Quality / quality (push) Has been cancelled
- Use `find` instead of `*.css` glob to catch fonts/fonts.css in subdirs
- Add Python script to auto-create Admin user if no users exist yet

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-24 11:18:18 +00:00
SysAdmin Agent
700a6472b7 Fix GrampsWeb subpath: patch CSS font paths from ../fonts/ to fonts/ (STI-90)
Some checks failed
CI/CD Pipeline / test (push) Has been cancelled
CI/CD Pipeline / deploy (push) Has been cancelled
Code Quality / quality (push) Has been cancelled
CSS url() resolves relative to the stylesheet, not <base href>. With
the stylesheet at /ahnenforschung/style.css, url('../fonts/...') resolves
to /fonts/ (root) instead of /ahnenforschung/fonts/. Changed to relative
url('fonts/...') which correctly resolves under the subpath.

Also fixes Material Icons font not loading (menu icons broken).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-24 10:27:47 +00:00
SysAdmin Agent
905e5a7d6c Fix GrampsWeb subpath: patch location.href redirects to root (STI-90)
Some checks failed
CI/CD Pipeline / test (push) Has been cancelled
CI/CD Pipeline / deploy (push) Has been cancelled
Code Quality / quality (push) Has been cancelled
GrampsWeb JS has 6 instances of location.href="/" that redirect users
to the root domain (Django app) instead of /ahnenforschung/. These
are now patched at container startup alongside the API path rewrites.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-24 10:08:35 +00:00
SysAdmin Agent
3cdf49419e Fix GrampsWeb subpath: patch API/lang/font paths in JS at startup (STI-90)
Some checks failed
CI/CD Pipeline / test (push) Has been cancelled
CI/CD Pipeline / deploy (push) Has been cancelled
Code Quality / quality (push) Has been cancelled
GrampsWeb's frontend JS hardcodes absolute paths like "/api/...",
"/lang/...", "/fonts/..." which bypass <base href>. These now get
rewritten to "/ahnenforschung/api/..." etc. at container startup,
matching both double-quoted and template-literal (backtick) patterns.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-24 09:57:29 +00:00
SysAdmin Agent
5d27f9235e Fix compose.dev.yml: remove duplicate gramps_data_dev volume (STI-90)
Some checks failed
CI/CD Pipeline / test (push) Has been cancelled
CI/CD Pipeline / deploy (push) Has been cancelled
Code Quality / quality (push) Has been cancelled
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-24 09:17:32 +00:00
SysAdmin Agent
c305417bb9 Add dev defaults for GrampsWeb admin credentials in compose.yml (STI-90)
Some checks failed
CI/CD Pipeline / test (push) Has been cancelled
CI/CD Pipeline / deploy (push) Has been cancelled
Code Quality / quality (push) Has been cancelled
Without these defaults, GrampsWeb starts without an admin user when
no .env file is present (common for local dev).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-24 08:57:54 +00:00
SysAdmin Agent
2a579c83c0 Improve GrampsWeb base href patching: find all index.html copies (STI-90)
Some checks failed
CI/CD Pipeline / test (push) Has been cancelled
CI/CD Pipeline / deploy (push) Has been cancelled
Code Quality / quality (push) Has been cancelled
The previous sed only patched two known paths. Now uses find to discover
and patch all index.html files containing <base href="/"> across the
entire container, with logging to show which files were patched.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-24 08:44:21 +00:00
SysAdmin Agent
55da366014 Fix GrampsWeb subpath: patch <base href> at container startup (STI-93)
Some checks failed
CI/CD Pipeline / test (push) Has been cancelled
CI/CD Pipeline / deploy (push) Has been cancelled
Code Quality / quality (push) Has been cancelled
The GrampsWeb SPA has <base href="/"> hardcoded at build time, causing
assets to load from / instead of /ahnenforschung/ when behind a reverse
proxy. Instead of relying on nginx sub_filter (which may not be available),
patch the HTML at container startup via GRAMPSWEB_SUBPATH env var.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-24 02:37:53 +00:00
SysAdmin Agent
66ccdc793c Fix compose.dev.yml: declare missing gramps_data_dev volume (STI-93)
Some checks failed
CI/CD Pipeline / test (push) Has been cancelled
CI/CD Pipeline / deploy (push) Has been cancelled
Code Quality / quality (push) Has been cancelled
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-24 01:30:58 +00:00
SysAdmin Agent
cee51ccec2 Fix deploy.sh: auto-update nginx config on deploy (STI-93)
Some checks failed
CI/CD Pipeline / test (push) Has been cancelled
CI/CD Pipeline / deploy (push) Has been cancelled
Code Quality / quality (push) Has been cancelled
deploy.sh was only updating Docker containers but never copying the
nginx config to the host. This meant changes like the sub_filter fix
for GrampsWeb's <base href> rewrite were never applied.

Now diffs deploy-production/nginx.conf against /etc/nginx/sites-enabled/stiftung
and reloads nginx when changed.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-24 00:25:55 +00:00
SysAdmin Agent
951c434ef2 Fix GrampsWeb subpath: use nginx sub_filter for <base href> rewrite (STI-93)
Some checks failed
CI/CD Pipeline / deploy (push) Has been cancelled
CI/CD Pipeline / test (push) Has been cancelled
Code Quality / quality (push) Has been cancelled
GrampsWeb's SPA has <base href="/"> hardcoded at build time. The
GRAMPSWEB_BASE_URL env var is a full URL for API/OIDC, not a path prefix.
This means assets always load from root, hitting Django instead of GrampsWeb.

Fix: nginx sub_filter rewrites <base href="/"> to <base href="/ahnenforschung/">
so the browser resolves all SPA assets under the correct subpath.

Also revert BASE_URL default to a proper URL (not a path).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-23 23:23:28 +00:00
SysAdmin Agent
b257fc090f Fix GrampsWeb: set BASE_URL default to /ahnenforschung for subpath (STI-93)
Some checks failed
CI/CD Pipeline / deploy (push) Has been cancelled
CI/CD Pipeline / test (push) Has been cancelled
Code Quality / quality (push) Has been cancelled
The SPA needs GRAMPSWEB_BASE_URL=/ahnenforschung to generate correct
asset URLs when served behind nginx at /ahnenforschung/. Without this,
JS/CSS assets load from / instead of /ahnenforschung/, causing a blank page.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-23 22:11:58 +00:00
SysAdmin Agent
5afa6e0ce1 Fix env-template: GRAMPSWEB_BASE_URL korrekt auf /ahnenforschung setzen (STI-91)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-23 21:10:58 +00:00
SysAdmin Agent
7c7dd6ed1c Fix GrampsWeb dev config: remove broken STATIC_PATH/STATIC_URL (STI-93)
Some checks failed
CI/CD Pipeline / test (push) Has been cancelled
CI/CD Pipeline / deploy (push) Has been cancelled
Code Quality / quality (push) Has been cancelled
Mirror the production fix from fd626a9 in compose.dev.yml. The
GRAMPSWEB_STATIC_PATH was set to a URL path instead of a filesystem path,
causing 404 on all routes. BASE_URL simplified to / (nginx handles subpath).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-23 21:07:41 +00:00
SysAdmin Agent
fd626a9c66 Fix GrampsWeb: remove broken STATIC_PATH/STATIC_URL config (STI-90)
Some checks failed
CI/CD Pipeline / test (push) Has been cancelled
CI/CD Pipeline / deploy (push) Has been cancelled
Code Quality / quality (push) Has been cancelled
GRAMPSWEB_STATIC_PATH was set to /ahnenforschung/static (a URL path)
instead of a filesystem path, causing GrampsWeb to return 404 on all
routes. Removed STATIC_PATH and STATIC_URL (defaults work correctly)
and simplified BASE_URL to /.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-23 21:02:01 +00:00
SysAdmin Agent
5807bf85f1 GrampsWeb Phase 1: Production Compose, Reverse Proxy & Deployment (STI-91)
- Fix grampsweb port mapping: 8090:80 → 8090:5000 (gunicorn, not nginx)
- Add full subpath ENV vars: GRAMPSWEB_TREE, BASE_URL, STATIC_PATH, STATIC_URL
- Add Celery/Redis config: broker_url, result_backend, ratelimit storage
- Add GRAMPSWEB_NEW_DB_BACKEND=sqlite
- Add depends_on: redis and restart: unless-stopped
- Add GRAMPS_URL/USERNAME/PASSWORD/API_TOKEN to web service for Django integration
- Add nginx.conf with /ahnenforschung/ reverse proxy route (proxy to localhost:8090)
- Add GRAMPSWEB_STATIC_PATH and GRAMPSWEB_STATIC_URL to env-template.txt

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-21 23:47:52 +00:00
SysAdmin Agent
f893172a2b GrampsWeb Phase 1: Sidebar-Link, Settings-Fix & Env-Template (STI-90)
- Fix GRAMPS_URL default port from 80 to 5000 to match dev compose
- Add "Ahnenforschung" sidebar link in navigation (links to /ahnenforschung/)
- Update env-template with all GRAMPSWEB_* variables for production setup

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-21 23:40:55 +00:00
SysAdmin Agent
4d751d861d DSGVO-Compliance: Einwilligung, Datenschutzerklärung & Consent-Logging im Upload-Portal (STI-89)
Some checks failed
CI/CD Pipeline / test (push) Has been cancelled
Code Quality / quality (push) Has been cancelled
CI/CD Pipeline / deploy (push) Has been cancelled
- Datenschutzerklärung unter /portal/datenschutz/ öffentlich erreichbar
- Link zur Datenschutzerklärung in Nachweis-Aufforderungs-E-Mails (HTML + TXT)
- Einwilligungs-Checkbox vor Upload mit Server-Side-Validierung
- Consent-Logging: einwilligung_erteilt_am auf UploadToken (Art. 7 Abs. 1 DSGVO)
- Regelsatz-Korrektur: 449€→563€ in Onboarding-Template (Stand 01/2024)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-21 22:43:01 +00:00
SysAdmin Agent
f7c122515f Fix MCP config: replace hardcoded token with env-var wrapper script
Some checks failed
CI/CD Pipeline / test (push) Has been cancelled
CI/CD Pipeline / deploy (push) Has been cancelled
Code Quality / quality (push) Has been cancelled
MCP_AUTH_TOKEN was stored in plain text in .mcp.json and thus in git
history. Now connect.sh reads the token from the environment variable
MCP_AUTH_TOKEN — set via export in ~/.bashrc or a secrets manager.

⚠️ Old token is in git history and should be rotated on the server.
Rotate: python manage.py create_agent_token <username>

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-21 22:05:21 +00:00
SysAdmin Agent
5f1a3fd27d Add MCP server for AI-assisted Stiftung data access
Some checks failed
CI/CD Pipeline / test (push) Has been cancelled
CI/CD Pipeline / deploy (push) Has been cancelled
Code Quality / quality (push) Has been cancelled
Provides a Model Context Protocol server exposing read-only tools
for Destinatäre, Ländereien, Pächter, Konten, Transaktionen and more.
Includes SSH-based remote connection config in .mcp.json.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-21 22:02:16 +00:00
SysAdmin Agent
33ca6c0a1c Fix CI/CD: export APP_VERSION before docker-compose build
Ensures APP_VERSION is available as an environment variable
when docker-compose starts, so containers pick up the correct version.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-21 22:02:11 +00:00
SysAdmin Agent
3200ff7563 Add Anrede field to Destinatär model (STI-86)
Adds optional salutation (Herr/Frau/Divers) to the Destinatär model
with migration, form support, admin integration and template display.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-21 22:02:07 +00:00
SysAdmin Agent
fe2c657586 Fix Vorlagen editor: drop Summernote, use code editor for all templates (STI-82)
Some checks failed
CI/CD Pipeline / test (push) Has been cancelled
CI/CD Pipeline / deploy (push) Has been cancelled
Code Quality / quality (push) Has been cancelled
Summernote WYSIWYG was mangling Django template syntax ({{ }}, {% %})
on save, causing content to revert to corrupted state. Switched all
template types to the plain code editor textarea which preserves
content exactly as-is.

Also removed jQuery/Summernote JS dependencies from the editor page,
and fixed getEditorContent reference in preview code.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-21 21:55:17 +00:00
31 changed files with 837 additions and 135 deletions

View File

@@ -183,6 +183,7 @@ jobs:
# Build and start containers from source code # Build and start containers from source code
echo "🔨 Building and starting containers from source code..." echo "🔨 Building and starting containers from source code..."
export APP_VERSION=$(cat VERSION 2>/dev/null || echo "unknown")
docker-compose -f compose.yml up -d --build docker-compose -f compose.yml up -d --build
# Wait for containers to be ready # Wait for containers to be ready

10
.mcp.json Normal file
View File

@@ -0,0 +1,10 @@
{
"mcpServers": {
"stiftung": {
"command": "bash",
"args": [
"/home/remmer/stiftung/app/mcp_server/connect.sh"
]
}
}
}

View File

@@ -166,7 +166,7 @@ LOGIN_REDIRECT_URL = "/"
LOGOUT_REDIRECT_URL = "/login/" LOGOUT_REDIRECT_URL = "/login/"
# Gramps integration # Gramps integration
GRAMPS_URL = os.environ.get("GRAMPS_URL", "http://grampsweb:80") GRAMPS_URL = os.environ.get("GRAMPS_URL", "http://grampsweb:5000")
GRAMPS_API_TOKEN = os.environ.get("GRAMPS_API_TOKEN", "") GRAMPS_API_TOKEN = os.environ.get("GRAMPS_API_TOKEN", "")
GRAMPS_STIFTER_IDS = os.environ.get("GRAMPS_STIFTER_IDS", "") # comma-separated GRAMPS_STIFTER_IDS = os.environ.get("GRAMPS_STIFTER_IDS", "") # comma-separated
GRAMPS_USERNAME = os.environ.get("GRAMPS_USERNAME", "") GRAMPS_USERNAME = os.environ.get("GRAMPS_USERNAME", "")

View 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
View 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

16
app/mcp_server/connect.sh Normal file
View 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"

View File

@@ -0,0 +1,3 @@
# MCP Server Dependencies
# Install alongside the main Django app requirements
mcp>=1.0.0

18
app/mcp_server/start.sh Normal file
View 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

View File

@@ -24,7 +24,7 @@ class DestinataerAdmin(admin.ModelAdmin):
fieldsets = ( fieldsets = (
( (
"Persönliche Daten", "Persönliche Daten",
{"fields": ("vorname", "nachname", "geburtsdatum", "email", "telefon")}, {"fields": ("anrede", "vorname", "nachname", "geburtsdatum", "email", "telefon")},
), ),
( (
"Berufliche Informationen", "Berufliche Informationen",

View File

@@ -54,18 +54,14 @@ class DestinataerForm(forms.ModelForm):
for field_name, field in self.fields.items(): for field_name, field in self.fields.items():
if field_name not in ["vorname", "nachname"]: if field_name not in ["vorname", "nachname"]:
field.required = False field.required = False
# Set choices for familienzweig and berufsgruppe to match model # Set choices for familienzweig, berufsgruppe and anrede to match model
self.fields["familienzweig"].choices = [("", "Bitte wählen...")] + list(Destinataer.FAMILIENZWIG_CHOICES) self.fields["familienzweig"].choices = [("", "Bitte wählen...")] + list(Destinataer.FAMILIENZWIG_CHOICES)
self.fields["berufsgruppe"].choices = [("", "Bitte wählen...")] + list(Destinataer.BERUFSGRUPPE_CHOICES) self.fields["berufsgruppe"].choices = [("", "Bitte wählen...")] + list(Destinataer.BERUFSGRUPPE_CHOICES)
if "anrede" in self.fields:
self.fields["anrede"].choices = [("", "Bitte wählen...")] + list(Destinataer.ANREDE_CHOICES)
# Set choices for standard_konto to allow blank # Set choices for standard_konto to allow blank
self.fields["standard_konto"].empty_label = "---" self.fields["standard_konto"].empty_label = "---"
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
for field_name, field in self.fields.items():
if field_name not in ["vorname", "nachname"]:
field.required = False
class DestinataerUnterstuetzungForm(forms.ModelForm): class DestinataerUnterstuetzungForm(forms.ModelForm):
"""Form für geplante/ausgeführte Destinatärunterstützungen""" """Form für geplante/ausgeführte Destinatärunterstützungen"""

View 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'),
),
]

View File

@@ -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'),
),
]

View File

@@ -26,7 +26,20 @@ class Destinataer(models.Model):
("andere", "Andere"), ("andere", "Andere"),
] ]
ANREDE_CHOICES = [
("Herr", "Herr"),
("Frau", "Frau"),
("Divers", "Divers"),
]
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
anrede = models.CharField(
max_length=20,
choices=ANREDE_CHOICES,
blank=True,
null=True,
verbose_name="Anrede",
)
familienzweig = models.CharField( familienzweig = models.CharField(
max_length=100, choices=FAMILIENZWIG_CHOICES, blank=True, null=True max_length=100, choices=FAMILIENZWIG_CHOICES, blank=True, null=True
) )
@@ -1349,6 +1362,10 @@ class UploadToken(models.Model):
erinnerung_gesendet = models.BooleanField( erinnerung_gesendet = models.BooleanField(
default=False, verbose_name="Erinnerung gesendet" 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: class Meta:
verbose_name = "Upload-Token" verbose_name = "Upload-Token"

View File

@@ -6,6 +6,7 @@ Diese URLs sind ohne Login zugänglich (tokenbasierte Authentifizierung).
from django.urls import path from django.urls import path
from stiftung.views.portal import ( from stiftung.views.portal import (
datenschutzerklaerung,
onboarding_danke, onboarding_danke,
onboarding_schritt, onboarding_schritt,
upload_danke, upload_danke,
@@ -15,6 +16,12 @@ from stiftung.views.portal import (
app_name = "portal" app_name = "portal"
urlpatterns = [ urlpatterns = [
# Datenschutzerklärung (öffentlich, kein Token erforderlich)
path(
"datenschutz/",
datenschutzerklaerung,
name="datenschutzerklaerung",
),
# Upload-Portal (bestehende Destinatäre Token-basiert) # Upload-Portal (bestehende Destinatäre Token-basiert)
path( path(
"upload/<str:token>/", "upload/<str:token>/",

View File

@@ -547,6 +547,7 @@ def send_nachweis_aufforderung(self, destinataer_id, nachweis_id, base_url=None)
"gueltig_bis": gueltig_bis, "gueltig_bis": gueltig_bis,
"halbjahr_label": halbjahr_label, "halbjahr_label": halbjahr_label,
"quartal_label": quartal_label, "quartal_label": quartal_label,
"datenschutz_url": f"{base_url}/portal/datenschutz/",
} }
subject = f"Nachweis-Aufforderung: {quartal_label} ({halbjahr_label}) vHTV-Stiftung" subject = f"Nachweis-Aufforderung: {quartal_label} ({halbjahr_label}) vHTV-Stiftung"
@@ -618,6 +619,7 @@ def send_nachweis_erinnerung(self, token_id, base_url=None):
"gueltig_bis": upload_token.gueltig_bis, "gueltig_bis": upload_token.gueltig_bis,
"halbjahr_label": halbjahr_label, "halbjahr_label": halbjahr_label,
"ist_erinnerung": True, "ist_erinnerung": True,
"datenschutz_url": f"{base_url}/portal/datenschutz/",
} }
subject = f"Erinnerung: Nachweis-Upload noch ausstehend {halbjahr_label}" subject = f"Erinnerung: Nachweis-Upload noch ausstehend {halbjahr_label}"

View File

@@ -33,6 +33,11 @@ from django.views.decorators.http import require_http_methods
from stiftung.models import DokumentDatei, OnboardingEinladung, UploadToken, VierteljahresNachweis 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__) logger = logging.getLogger(__name__)
# Erlaubte Dateitypen für Uploads # Erlaubte Dateitypen für Uploads
@@ -105,6 +110,19 @@ def upload_formular(request, token):
if request.method == "GET": if request.method == "GET":
return render(request, "portal/upload_formular.html", base_context) 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 # POST: Kategorisierte Dateien und Texte verarbeiten
# Kategorien mit ihren DMS-Kontext-Werten und FK-Feldern auf VierteljahresNachweis # Kategorien mit ihren DMS-Kontext-Werten und FK-Feldern auf VierteljahresNachweis
KATEGORIEN = [ KATEGORIEN = [
@@ -228,6 +246,10 @@ def upload_formular(request, token):
if nachweis_update_fields: if nachweis_update_fields:
nachweis.save(update_fields=list(set(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 # Token einlösen
ip = _get_client_ip(request) ip = _get_client_ip(request)
upload_token.einloesen(ip_address=ip) upload_token.einloesen(ip_address=ip)

View File

@@ -57,10 +57,9 @@ def vorlage_editor(request, pk):
html_json = json.dumps(vorlage.html_inhalt) html_json = json.dumps(vorlage.html_inhalt)
html_json = html_json.replace("<", "\\u003c").replace(">", "\\u003e") html_json = html_json.replace("<", "\\u003c").replace(">", "\\u003e")
# Serienbrief templates are full HTML documents with Django template tags # All templates contain Django template tags ({{ }}, {% %}) that
# ({% for %}, {% if %}) — Summernote WYSIWYG mangles these. # Summernote WYSIWYG mangles on save. Use plain code editor for all.
# Use a plain code editor textarea instead. use_code_editor = True
use_code_editor = vorlage.kategorie == "serienbrief"
return render(request, "stiftung/vorlage_editor.html", { return render(request, "stiftung/vorlage_editor.html", {
"vorlage": vorlage, "vorlage": vorlage,

View File

@@ -689,6 +689,10 @@
<i class="fas fa-book-open"></i> <i class="fas fa-book-open"></i>
<span>Geschichte</span> <span>Geschichte</span>
</a> </a>
<a class="sidebar-link" href="/ahnenforschung/" target="_blank">
<i class="fas fa-tree"></i>
<span>Ahnenforschung</span>
</a>
{% if perms.stiftung.access_administration %} {% if perms.stiftung.access_administration %}
<a class="sidebar-link" href="{% url 'stiftung:administration' %}"> <a class="sidebar-link" href="{% url 'stiftung:administration' %}">
<i class="fas fa-cogs"></i> <i class="fas fa-cogs"></i>

View File

@@ -71,7 +71,8 @@
</div> </div>
<div class="footer"> <div class="footer">
van Hees-Theyssen-Vogel'sche Stiftung &bull; Raesfelder Str. 3 &bull; 46499 Hamminkeln &bull; Tel. 02858/836780<br> van Hees-Theyssen-Vogel'sche Stiftung &bull; Raesfelder Str. 3 &bull; 46499 Hamminkeln &bull; Tel. 02858/836780<br>
Diese E-Mail wurde automatisch erzeugt. Bitte antworten Sie nicht direkt auf diese E-Mail. 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>
</div> </div>
</body> </body>

View File

@@ -35,3 +35,4 @@ Tel. 02858/836780
--- ---
Diese E-Mail wurde automatisch erzeugt. Bitte antworten Sie nicht direkt auf diese E-Mail. Diese E-Mail wurde automatisch erzeugt. Bitte antworten Sie nicht direkt auf diese E-Mail.
Datenschutzerklärung: {{ datenschutz_url }}

View File

@@ -108,7 +108,7 @@
<div class="mt-3 p-2 rounded" style="background: #fff8e1; border: 1px solid #ffc107; font-size: 0.82rem;"> <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> <i class="fas fa-info-circle me-1 text-warning"></i>
<strong>Aktuelle Grenzwerte gemäß § 53 Nr. 2 AO (Stand 01/2024):</strong> <strong>Aktuelle Grenzwerte gemäß § 53 Nr. 2 AO (Stand 01/2024):</strong>
Bezüge max. 2.245 € monatlich (5× Regelsatz 449 €); Vermögen max. 15.500 €. Bezüge max. 2.815 € monatlich (5× Regelsatz 563 €); Vermögen max. 15.500 €.
Bei Haushaltsangehörigen erhöhen sich die Grenzen entsprechend. Bei Haushaltsangehörigen erhöhen sich die Grenzen entsprechend.
Maßgeblich sind die jeweils gültigen Werte zum Zeitpunkt der Prüfung. Maßgeblich sind die jeweils gültigen Werte zum Zeitpunkt der Prüfung.
</div> </div>

View File

@@ -41,6 +41,10 @@
.deadline { font-size: 13px; color: #888; margin-top: 16px; text-align: center; } .deadline { font-size: 13px; color: #888; margin-top: 16px; text-align: center; }
.footer { text-align: center; margin-top: 24px; font-size: 12px; color: #aaa; } .footer { text-align: center; margin-top: 24px; font-size: 12px; color: #aaa; }
.pflicht-hinweis { font-size: 12px; color: #888; margin-bottom: 16px; } .pflicht-hinweis { font-size: 12px; color: #888; margin-bottom: 16px; }
.einwilligung-box { background: #f0f6ff; border: 1px solid #b0cce8; border-radius: 6px; padding: 14px 16px; margin: 20px 0; font-size: 14px; }
.einwilligung-box label { font-weight: normal; display: flex; align-items: flex-start; gap: 10px; cursor: pointer; }
.einwilligung-box input[type="checkbox"] { margin-top: 2px; flex-shrink: 0; width: 16px; height: 16px; cursor: pointer; }
.einwilligung-box .einwilligung-fehler { color: #c00; font-size: 13px; margin-top: 6px; }
</style> </style>
</head> </head>
<body> <body>
@@ -128,6 +132,17 @@
<textarea name="weitere_dokumente_text" placeholder="Optionale Anmerkungen oder Beschreibung">{{ weitere_dokumente_text|default:"" }}</textarea> <textarea name="weitere_dokumente_text" placeholder="Optionale Anmerkungen oder Beschreibung">{{ weitere_dokumente_text|default:"" }}</textarea>
</div> </div>
<div class="einwilligung-box">
<label>
<input type="checkbox" name="einwilligung" id="einwilligung" required {% if einwilligung_erteilt %}checked{% endif %}>
<span>Ich willige ein, dass die van Hees-Theyssen-Vogel'sche Stiftung die von mir hochgeladenen Dokumente und eingegebenen Daten zum Zweck der Förderprüfung verarbeitet und speichert. Ich habe die <a href="{% url 'portal:datenschutzerklaerung' %}" target="_blank">Datenschutzerklärung</a> gelesen und stimme ihr zu. Die Einwilligung kann ich jederzeit widerrufen (stiftung@vhtv-stiftung.de).
</span>
</label>
{% if einwilligung_fehler %}
<p class="einwilligung-fehler">{{ einwilligung_fehler }}</p>
{% endif %}
</div>
<button type="submit" class="submit-btn">Unterlagen jetzt einreichen</button> <button type="submit" class="submit-btn">Unterlagen jetzt einreichen</button>
</form> </form>

View File

@@ -157,6 +157,18 @@
<div class="card-body py-2"> <div class="card-body py-2">
<table class="table table-sm table-borderless mb-0"> <table class="table table-sm table-borderless mb-0">
<tbody> <tbody>
<tr>
<td class="text-muted" style="width:140px;">Anrede</td>
<td>
<span class="view-mode">{{ destinataer.anrede|default:"-" }}</span>
<select name="anrede" class="form-select form-select-sm edit-mode" style="display:none;">
<option value="">---</option>
<option value="Herr" {% if destinataer.anrede == 'Herr' %}selected{% endif %}>Herr</option>
<option value="Frau" {% if destinataer.anrede == 'Frau' %}selected{% endif %}>Frau</option>
<option value="Divers" {% if destinataer.anrede == 'Divers' %}selected{% endif %}>Divers</option>
</select>
</td>
</tr>
<tr> <tr>
<td class="text-muted" style="width:140px;">Vorname</td> <td class="text-muted" style="width:140px;">Vorname</td>
<td> <td>

View File

@@ -48,6 +48,13 @@
<div class="card-body py-2"> <div class="card-body py-2">
<table class="table table-sm table-borderless mb-0"> <table class="table table-sm table-borderless mb-0">
<tbody> <tbody>
<tr>
<td class="text-muted" style="width:140px;">Anrede</td>
<td>
{{ form.anrede }}
{% if form.anrede.errors %}<div class="invalid-feedback d-block">{{ form.anrede.errors.0 }}</div>{% endif %}
</td>
</tr>
<tr> <tr>
<td class="text-muted" style="width:140px;">Vorname *</td> <td class="text-muted" style="width:140px;">Vorname *</td>
<td> <td>

View File

@@ -1,11 +1,7 @@
{% extends 'base.html' %} {% extends 'base.html' %}
{% load static %}
{% block title %}{{ vorlage.bezeichnung }} Vorlage bearbeiten{% endblock %} {% block title %}{{ vorlage.bezeichnung }} Vorlage bearbeiten{% endblock %}
{% block extra_css %} {% block extra_css %}
<!-- Summernote WYSIWYG (lokal) -->
<link rel="stylesheet" href="{% static 'stiftung/vendor/summernote/summernote-bs5.min.css' %}">
<style> <style>
.preview-frame { .preview-frame {
width: 100%; width: 100%;
@@ -26,9 +22,6 @@
.var-item:hover { .var-item:hover {
background-color: #e9ecef; background-color: #e9ecef;
} }
.note-editor.note-frame {
border-radius: 4px;
}
.code-editor-textarea { .code-editor-textarea {
width: 100%; width: 100%;
height: 70vh; height: 70vh;
@@ -113,7 +106,7 @@
<div class="{% if variablen %}col-lg-9{% else %}col-12{% endif %}"> <div class="{% if variablen %}col-lg-9{% else %}col-12{% endif %}">
<form method="post" id="editor-form"> <form method="post" id="editor-form">
{% csrf_token %} {% csrf_token %}
<textarea name="html_inhalt" id="code-editor"{% if use_code_editor %} class="code-editor-textarea"{% endif %}>{{ vorlage.html_inhalt }}</textarea> <textarea name="html_inhalt" id="code-editor" class="code-editor-textarea">{{ vorlage.html_inhalt }}</textarea>
<script type="application/json" id="vorlage-html-inhalt">{{ html_inhalt_json }}</script> <script type="application/json" id="vorlage-html-inhalt">{{ html_inhalt_json }}</script>
</form> </form>
</div> </div>
@@ -179,11 +172,6 @@
{% endblock %} {% endblock %}
{% block javascript %} {% block javascript %}
<!-- jQuery (lokal) -->
<script src="{% static 'stiftung/vendor/jquery/jquery.min.js' %}"></script>
<!-- Summernote WYSIWYG (lokal) -->
<script src="{% static 'stiftung/vendor/summernote/summernote-bs5.min.js' %}"></script>
<script src="{% static 'stiftung/vendor/summernote/summernote-de-DE.min.js' %}"></script>
<script> <script>
(function() { (function() {
var initialContent; var initialContent;
@@ -193,22 +181,11 @@
initialContent = null; initialContent = null;
} }
var useCodeEditor = {{ use_code_editor|yesno:"true,false" }};
var editor = document.getElementById('code-editor'); var editor = document.getElementById('code-editor');
var summernoteActive = false;
// Returns current HTML content regardless of editor mode // ---- Editor setup: plain code editor for all templates ----
function getEditorContent() { // Always load content from JSON (the textarea's Django-rendered value may be HTML-escaped)
if (summernoteActive) { if (initialContent !== null) {
return $('#code-editor').summernote('code');
}
return editor.value;
}
// ---- Editor setup ----
if (useCodeEditor) {
// Plain textarea for full HTML documents (Serienbrief)
if (initialContent) {
editor.value = initialContent; editor.value = initialContent;
} }
editor.addEventListener('keydown', function(e) { editor.addEventListener('keydown', function(e) {
@@ -220,7 +197,7 @@
this.selectionStart = this.selectionEnd = start + 4; this.selectionStart = this.selectionEnd = start + 4;
} }
}); });
// Variable insertion for code editor // Variable insertion
document.querySelectorAll('.var-item').forEach(function(row) { document.querySelectorAll('.var-item').forEach(function(row) {
row.addEventListener('click', function() { row.addEventListener('click', function() {
var varName = this.getAttribute('data-var'); var varName = this.getAttribute('data-var');
@@ -231,73 +208,13 @@
editor.focus(); editor.focus();
}); });
}); });
} else if (typeof $ !== 'undefined' && typeof $.fn.summernote !== 'undefined') {
// Summernote WYSIWYG for HTML fragment templates
$('#code-editor').summernote({
lang: 'de-DE',
height: 520,
toolbar: [
['style', ['bold', 'italic', 'underline', 'strikethrough', 'clear']],
['para', ['style', 'ul', 'ol', 'paragraph']],
['table', ['table']],
['insert', ['link', 'hr']],
['view', ['fullscreen', 'codeview', 'undo', 'redo']],
],
callbacks: {
onInit: function() {
if (initialContent) {
$('#code-editor').summernote('code', initialContent);
}
}
}
});
summernoteActive = true;
// Variable insertion for Summernote
document.querySelectorAll('.var-item').forEach(function(row) {
row.addEventListener('click', function() {
var varName = this.getAttribute('data-var');
var placeholder = String.fromCharCode(123,123) + ' ' + varName + ' ' + String.fromCharCode(125,125);
$('#code-editor').summernote('focus');
$('#code-editor').summernote('insertText', placeholder);
});
});
// Sync Summernote to textarea on form submit
document.getElementById('editor-form').addEventListener('submit', function() {
document.querySelector('textarea[name=html_inhalt]').value = getEditorContent();
});
} else {
// Fallback: Summernote not loaded — style textarea as code editor
if (editor) {
editor.style.height = '70vh';
editor.style.fontFamily = "'SFMono-Regular', Consolas, monospace";
editor.style.fontSize = '13px';
editor.style.padding = '12px';
editor.style.background = '#f8f9fa';
}
if (initialContent) {
editor.value = initialContent;
}
// Variable insertion for plain textarea fallback
document.querySelectorAll('.var-item').forEach(function(row) {
row.addEventListener('click', function() {
var varName = this.getAttribute('data-var');
var placeholder = String.fromCharCode(123,123) + ' ' + varName + ' ' + String.fromCharCode(125,125);
var start = editor.selectionStart;
editor.value = editor.value.substring(0, start) + placeholder + editor.value.substring(editor.selectionEnd);
editor.selectionStart = editor.selectionEnd = start + placeholder.length;
editor.focus();
});
});
}
// ---- Preview (always set up, independent of editor mode) ---- // ---- Preview (always set up, independent of editor mode) ----
var previewFrame = document.getElementById('preview-frame'); var previewFrame = document.getElementById('preview-frame');
var previewLoading = document.getElementById('preview-loading'); var previewLoading = document.getElementById('preview-loading');
function loadPreview() { function loadPreview() {
var content = getEditorContent(); var content = editor.value;
var csrfEl = document.querySelector('[name=csrfmiddlewaretoken]'); var csrfEl = document.querySelector('[name=csrfmiddlewaretoken]');
if (!csrfEl) { if (!csrfEl) {
previewLoading.innerHTML = '<i class="fas fa-exclamation-triangle text-danger fa-2x mb-2 d-block"></i>CSRF-Token nicht gefunden.'; previewLoading.innerHTML = '<i class="fas fa-exclamation-triangle text-danger fa-2x mb-2 d-block"></i>CSRF-Token nicht gefunden.';

View File

@@ -197,9 +197,7 @@ services:
- GRAMPSWEB_ADMIN_EMAIL=admin@localhost - GRAMPSWEB_ADMIN_EMAIL=admin@localhost
- GRAMPSWEB_ADMIN_PASSWORD=gramps_dev_password - GRAMPSWEB_ADMIN_PASSWORD=gramps_dev_password
- GRAMPSWEB_TREE=Stiftung - GRAMPSWEB_TREE=Stiftung
- GRAMPSWEB_BASE_URL=/ahnenforschung - GRAMPSWEB_BASE_URL=/
- GRAMPSWEB_STATIC_PATH=/ahnenforschung/static
- GRAMPSWEB_STATIC_URL=/ahnenforschung/static/
- GRAMPSWEB_CELERY_CONFIG__broker_url=redis://redis:6379/0 - GRAMPSWEB_CELERY_CONFIG__broker_url=redis://redis:6379/0
- GRAMPSWEB_CELERY_CONFIG__result_backend=redis://redis:6379/0 - GRAMPSWEB_CELERY_CONFIG__result_backend=redis://redis:6379/0
- GRAMPSWEB_RATELIMIT_STORAGE_URI=redis://redis:6379/1 - GRAMPSWEB_RATELIMIT_STORAGE_URI=redis://redis:6379/1
@@ -211,9 +209,9 @@ services:
volumes: volumes:
dbdata_dev: dbdata_dev:
gramps_data_dev:
paperless_data_dev: paperless_data_dev:
paperless_media_dev: paperless_media_dev:
paperless_export_dev: paperless_export_dev:
paperless_consume_dev: paperless_consume_dev:
gramps_data_dev:
ollama_data_dev: ollama_data_dev:

View File

@@ -193,17 +193,81 @@ services:
ports: ports:
- "8090:5000" - "8090:5000"
environment: environment:
- GRAMPSWEB_SECRET_KEY=${GRAMPSWEB_SECRET_KEY} - GRAMPSWEB_SECRET_KEY=${GRAMPSWEB_SECRET_KEY:-dev-grampsweb-secret-key-not-for-production}
- GRAMPSWEB_ADMIN_EMAIL=${GRAMPSWEB_ADMIN_EMAIL} - GRAMPSWEB_ADMIN_EMAIL=${GRAMPSWEB_ADMIN_EMAIL:-admin@localhost}
- GRAMPSWEB_ADMIN_PASSWORD=${GRAMPSWEB_ADMIN_PASSWORD} - GRAMPSWEB_ADMIN_PASSWORD=${GRAMPSWEB_ADMIN_PASSWORD:-gramps_dev_password}
- GRAMPSWEB_TREE=${GRAMPSWEB_TREE:-Stiftung} - GRAMPSWEB_TREE=${GRAMPSWEB_TREE:-Stiftung}
- GRAMPSWEB_BASE_URL=${GRAMPSWEB_BASE_URL:-/ahnenforschung} - GRAMPSWEB_BASE_URL=${GRAMPSWEB_BASE_URL:-http://localhost:8090}
- GRAMPSWEB_STATIC_PATH=${GRAMPSWEB_STATIC_PATH:-/ahnenforschung/static}
- GRAMPSWEB_STATIC_URL=${GRAMPSWEB_STATIC_URL:-/ahnenforschung/static/}
- GRAMPSWEB_CELERY_CONFIG__broker_url=redis://redis:6379/0 - GRAMPSWEB_CELERY_CONFIG__broker_url=redis://redis:6379/0
- GRAMPSWEB_CELERY_CONFIG__result_backend=redis://redis:6379/0 - GRAMPSWEB_CELERY_CONFIG__result_backend=redis://redis:6379/0
- GRAMPSWEB_RATELIMIT_STORAGE_URI=redis://redis:6379/1 - GRAMPSWEB_RATELIMIT_STORAGE_URI=redis://redis:6379/1
- GRAMPSWEB_NEW_DB_BACKEND=sqlite - GRAMPSWEB_NEW_DB_BACKEND=sqlite
- GRAMPSWEB_SUBPATH=${GRAMPSWEB_SUBPATH:-/ahnenforschung}
command:
- sh
- -c
- |
if [ -n "$$GRAMPSWEB_SUBPATH" ] && [ "$$GRAMPSWEB_SUBPATH" != "/" ]; then
SUBPATH="$$GRAMPSWEB_SUBPATH"
case "$$SUBPATH" in */) ;; *) SUBPATH="$${SUBPATH}/" ;; esac
echo "[grampsweb] Patching static files for subpath $$SUBPATH ..."
find / -name index.html -path "*/gramps*" -o -name index.html -path "*/static/*" 2>/dev/null | while read f; do
if grep -q '<base href="/">' "$$f" 2>/dev/null; then
sed -i "s|<base href=\"/\">|<base href=\"$$SUBPATH\">|g" "$$f"
echo "[grampsweb] patched base href: $$f"
fi
done
for f in /app/static/*.js; do
if [ -f "$$f" ] && grep -q '/api/' "$$f" 2>/dev/null; then
sed -i "s|\"/api/|\"$${SUBPATH}api/|g" "$$f"
sed -i 's|`/api/|`'"$${SUBPATH}"'api/|g' "$$f"
sed -i "s|\"/lang/|\"$${SUBPATH}lang/|g" "$$f"
sed -i 's|`/lang/|`'"$${SUBPATH}"'lang/|g' "$$f"
sed -i "s|\"/fonts/|\"$${SUBPATH}fonts/|g" "$$f"
sed -i 's|`/fonts/|`'"$${SUBPATH}"'fonts/|g' "$$f"
sed -i "s|\"/assets/|\"$${SUBPATH}assets/|g" "$$f"
sed -i 's|`/assets/|`'"$${SUBPATH}"'assets/|g' "$$f"
sed -i "s|location\.href=\"/\"|location.href=\"$$SUBPATH\"|g" "$$f"
sed -i "s|document\.location\.href=\"/\"|document.location.href=\"$$SUBPATH\"|g" "$$f"
echo "[grampsweb] patched JS paths: $$f"
fi
done
if [ -f /app/static/sw.js ]; then
sed -i "s|createHandlerBoundToURL(\"/index.html\")|createHandlerBoundToURL(\"$${SUBPATH}index.html\")|g" /app/static/sw.js
SUBPATH_BS=$$(echo "$$SUBPATH" | sed "s|/|\\\\\\\\/|g")
sed -i "s|\\^\\\\/api|\\^$${SUBPATH_BS}api|g" /app/static/sw.js
echo "[grampsweb] patched sw.js navigation routes"
fi
find /app/static -name '*.css' 2>/dev/null | while read f; do
if grep -q '\.\./fonts/' "$$f" 2>/dev/null; then
sed -i "s|'../fonts/|'fonts/|g" "$$f"
sed -i "s|\"../fonts/|\"fonts/|g" "$$f"
echo "[grampsweb] patched CSS font paths: $$f"
fi
done
echo "[grampsweb] Done."
fi
echo "[grampsweb] Ensuring admin user exists ..."
python3 << 'PYEOF' 2>&1 | grep -v Gtk
from gramps_webapi.app import create_app
from gramps_webapi.auth import add_user, get_number_users, ROLE_OWNER
import os
email = os.environ.get('GRAMPSWEB_ADMIN_EMAIL', '')
pw = os.environ.get('GRAMPSWEB_ADMIN_PASSWORD', '')
if email and pw:
app = create_app()
with app.app_context():
if get_number_users() == 0:
add_user(name='Admin', email=email, password=pw, role=ROLE_OWNER)
print('[grampsweb] Admin user created')
else:
print('[grampsweb] Users already exist, skipping')
else:
print('[grampsweb] No admin credentials configured, skipping')
PYEOF
exec gunicorn -w $${GUNICORN_NUM_WORKERS:-8} -b 0.0.0.0:5000 \
gramps_webapi.wsgi:app --timeout $${GUNICORN_TIMEOUT:-120} \
--limit-request-line 8190
volumes: volumes:
- gramps_data:/app/data - gramps_data:/app/data
depends_on: depends_on:

View File

@@ -49,6 +49,10 @@ services:
- REDIS_URL=${REDIS_URL} - REDIS_URL=${REDIS_URL}
- PAPERLESS_API_URL=${PAPERLESS_API_URL} - PAPERLESS_API_URL=${PAPERLESS_API_URL}
- PAPERLESS_API_TOKEN=${PAPERLESS_API_TOKEN} - PAPERLESS_API_TOKEN=${PAPERLESS_API_TOKEN}
- GRAMPS_URL=${GRAMPS_URL}
- GRAMPS_USERNAME=${GRAMPS_USERNAME}
- GRAMPS_PASSWORD=${GRAMPS_PASSWORD}
- GRAMPS_API_TOKEN=${GRAMPS_API_TOKEN}
ports: ports:
- "8081:8000" - "8081:8000"
volumes: volumes:
@@ -111,14 +115,89 @@ services:
grampsweb: grampsweb:
image: ghcr.io/gramps-project/grampsweb:latest image: ghcr.io/gramps-project/grampsweb:latest
restart: unless-stopped
ports: ports:
- "8090:80" - "8090:5000"
environment: environment:
- GRAMPSWEB_SECRET_KEY=${GRAMPSWEB_SECRET_KEY} - GRAMPSWEB_SECRET_KEY=${GRAMPSWEB_SECRET_KEY}
- GRAMPSWEB_ADMIN_EMAIL=${GRAMPSWEB_ADMIN_EMAIL} - GRAMPSWEB_ADMIN_EMAIL=${GRAMPSWEB_ADMIN_EMAIL}
- GRAMPSWEB_ADMIN_PASSWORD=${GRAMPSWEB_ADMIN_PASSWORD} - GRAMPSWEB_ADMIN_PASSWORD=${GRAMPSWEB_ADMIN_PASSWORD}
- GRAMPSWEB_TREE=${GRAMPSWEB_TREE:-Stiftung}
- GRAMPSWEB_BASE_URL=${GRAMPSWEB_BASE_URL:-/}
- GRAMPSWEB_CELERY_CONFIG__broker_url=redis://redis:6379/0
- GRAMPSWEB_CELERY_CONFIG__result_backend=redis://redis:6379/0
- GRAMPSWEB_RATELIMIT_STORAGE_URI=redis://redis:6379/1
- GRAMPSWEB_NEW_DB_BACKEND=sqlite
- GRAMPSWEB_SUBPATH=${GRAMPSWEB_SUBPATH:-/ahnenforschung}
command:
- sh
- -c
- |
if [ -n "$$GRAMPSWEB_SUBPATH" ] && [ "$$GRAMPSWEB_SUBPATH" != "/" ]; then
SUBPATH="$$GRAMPSWEB_SUBPATH"
case "$$SUBPATH" in */) ;; *) SUBPATH="$${SUBPATH}/" ;; esac
echo "[grampsweb] Patching static files for subpath $$SUBPATH ..."
find / -name index.html -path "*/gramps*" -o -name index.html -path "*/static/*" 2>/dev/null | while read f; do
if grep -q '<base href="/">' "$$f" 2>/dev/null; then
sed -i "s|<base href=\"/\">|<base href=\"$$SUBPATH\">|g" "$$f"
echo "[grampsweb] patched base href: $$f"
fi
done
for f in /app/static/*.js; do
if [ -f "$$f" ] && grep -q '/api/' "$$f" 2>/dev/null; then
sed -i "s|\"/api/|\"$${SUBPATH}api/|g" "$$f"
sed -i 's|`/api/|`'"$${SUBPATH}"'api/|g' "$$f"
sed -i "s|\"/lang/|\"$${SUBPATH}lang/|g" "$$f"
sed -i 's|`/lang/|`'"$${SUBPATH}"'lang/|g' "$$f"
sed -i "s|\"/fonts/|\"$${SUBPATH}fonts/|g" "$$f"
sed -i 's|`/fonts/|`'"$${SUBPATH}"'fonts/|g' "$$f"
sed -i "s|\"/assets/|\"$${SUBPATH}assets/|g" "$$f"
sed -i 's|`/assets/|`'"$${SUBPATH}"'assets/|g' "$$f"
sed -i "s|location\.href=\"/\"|location.href=\"$$SUBPATH\"|g" "$$f"
sed -i "s|document\.location\.href=\"/\"|document.location.href=\"$$SUBPATH\"|g" "$$f"
echo "[grampsweb] patched JS paths: $$f"
fi
done
if [ -f /app/static/sw.js ]; then
sed -i "s|createHandlerBoundToURL(\"/index.html\")|createHandlerBoundToURL(\"$${SUBPATH}index.html\")|g" /app/static/sw.js
SUBPATH_BS=$$(echo "$$SUBPATH" | sed "s|/|\\\\\\\\/|g")
sed -i "s|\\^\\\\/api|\\^$${SUBPATH_BS}api|g" /app/static/sw.js
echo "[grampsweb] patched sw.js navigation routes"
fi
find /app/static -name '*.css' 2>/dev/null | while read f; do
if grep -q '\.\./fonts/' "$$f" 2>/dev/null; then
sed -i "s|'../fonts/|'fonts/|g" "$$f"
sed -i "s|\"../fonts/|\"fonts/|g" "$$f"
echo "[grampsweb] patched CSS font paths: $$f"
fi
done
echo "[grampsweb] Done."
fi
echo "[grampsweb] Ensuring admin user exists ..."
python3 << 'PYEOF' 2>&1 | grep -v Gtk
from gramps_webapi.app import create_app
from gramps_webapi.auth import add_user, get_number_users, ROLE_OWNER
import os
email = os.environ.get('GRAMPSWEB_ADMIN_EMAIL', '')
pw = os.environ.get('GRAMPSWEB_ADMIN_PASSWORD', '')
if email and pw:
app = create_app()
with app.app_context():
if get_number_users() == 0:
add_user(name='Admin', email=email, password=pw, role=ROLE_OWNER)
print('[grampsweb] Admin user created')
else:
print('[grampsweb] Users already exist, skipping')
else:
print('[grampsweb] No admin credentials configured, skipping')
PYEOF
exec gunicorn -w $${GUNICORN_NUM_WORKERS:-8} -b 0.0.0.0:5000 \
gramps_webapi.wsgi:app --timeout $${GUNICORN_TIMEOUT:-120} \
--limit-request-line 8190
volumes: volumes:
- gramps_data:/app/data - gramps_data:/app/data
depends_on:
- redis
paperless: paperless:
image: ghcr.io/paperless-ngx/paperless-ngx:latest image: ghcr.io/paperless-ngx/paperless-ngx:latest

View File

@@ -0,0 +1,128 @@
# HTTP server block - redirect to HTTPS
server {
listen 80;
server_name vhtv-stiftung.de www.vhtv-stiftung.de;
# Redirect all HTTP traffic to HTTPS
return 301 https://$server_name$request_uri;
}
# HTTPS server block
server {
listen 443 ssl http2;
server_name vhtv-stiftung.de www.vhtv-stiftung.de;
# SSL Certificate Configuration
ssl_certificate /etc/letsencrypt/live/vhtv-stiftung.de/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/vhtv-stiftung.de/privkey.pem;
# SSL Security Settings
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers ECDHE-RSA-AES128-GCM-SHA256:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-RSA-AES128-SHA256:ECDHE-RSA-AES256-SHA384;
ssl_prefer_server_ciphers off;
ssl_session_cache shared:SSL:10m;
ssl_session_timeout 10m;
# HSTS (HTTP Strict Transport Security)
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
# Enhanced Security headers
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-XSS-Protection "1; mode=block" always;
add_header X-Content-Type-Options "nosniff" always;
add_header Referrer-Policy "no-referrer-when-downgrade" always;
add_header Content-Security-Policy "default-src 'self' https: data: blob: 'unsafe-inline'" always;
# Static files
location /static/ {
alias /opt/stiftung/app/static/;
expires 1y;
add_header Cache-Control "public, immutable";
}
location /media/ {
alias /opt/stiftung/app/media/;
expires 1y;
add_header Cache-Control "public";
}
# Django application
location / {
proxy_pass http://127.0.0.1:8081;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
# Timeouts
proxy_connect_timeout 60s;
proxy_send_timeout 60s;
proxy_read_timeout 60s;
# Buffer settings
proxy_buffering on;
proxy_buffer_size 128k;
proxy_buffers 4 256k;
proxy_busy_buffers_size 256k;
}
# Paperless-ngx document management
location /paperless/ {
proxy_pass http://127.0.0.1:8080/;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Script-Name /paperless;
# Large file uploads for documents
client_max_body_size 100M;
proxy_read_timeout 300s;
proxy_connect_timeout 300s;
proxy_send_timeout 300s;
}
# GrampsWeb Ahnenforschung
# GrampsWeb SPA has <base href="/"> hardcoded — sub_filter rewrites it
# so asset URLs resolve under /ahnenforschung/ instead of /
location /ahnenforschung/ {
proxy_pass http://127.0.0.1:8090/;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Script-Name /ahnenforschung;
# Rewrite <base href="/"> to <base href="/ahnenforschung/">
# so the SPA loads JS/CSS from the correct subpath
proxy_set_header Accept-Encoding "";
sub_filter '<base href="/">' '<base href="/ahnenforschung/">';
sub_filter_once on;
sub_filter_types text/html;
# WebSocket support
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_read_timeout 300s;
proxy_connect_timeout 60s;
proxy_send_timeout 300s;
}
# Health check endpoint
location /health/ {
access_log off;
return 200 "healthy\n";
add_header Content-Type text/plain;
}
# Block access to sensitive files
location ~ /\. {
deny all;
}
location ~ ^/(\.env|docker-compose|Dockerfile) {
deny all;
}
}

View File

@@ -135,6 +135,23 @@ echo ""
echo "--- Collecting static files ---" echo "--- Collecting static files ---"
docker compose -f "$COMPOSE_FILE" exec -T web python manage.py collectstatic --noinput docker compose -f "$COMPOSE_FILE" exec -T web python manage.py collectstatic --noinput
echo ""
echo "--- Updating nginx config ---"
NGINX_CONF="$PROD_DIR/deploy-production/nginx.conf"
NGINX_DEST="/etc/nginx/sites-enabled/stiftung"
if [[ -f "$NGINX_CONF" ]]; then
if ! diff -q "$NGINX_CONF" "$NGINX_DEST" &>/dev/null; then
echo "Nginx config changed — updating and reloading"
sudo cp "$NGINX_CONF" "$NGINX_DEST"
sudo nginx -t && sudo systemctl reload nginx
echo "Nginx reloaded"
else
echo "Nginx config unchanged — skipping"
fi
else
echo "WARNUNG: $NGINX_CONF nicht gefunden — nginx nicht aktualisiert"
fi
echo "" echo ""
echo "--- Service status ---" echo "--- Service status ---"
docker compose -f "$COMPOSE_FILE" ps docker compose -f "$COMPOSE_FILE" ps

View File

@@ -53,8 +53,17 @@ IMAP_FOLDER=INBOX
IMAP_USE_SSL=true IMAP_USE_SSL=true
# Integration von Grampsweb zur Ahnenforschung und Prüfung # Integration von Grampsweb zur Ahnenforschung und Prüfung
GRAMPS_URL=http://192.168.178.167:30179 # Django-App Verbindung zu GrampsWeb API (internes Docker-Netzwerk)
GRAMPS_URL=http://grampsweb:5000
GRAMPS_USERNAME=Stiftung GRAMPS_USERNAME=Stiftung
GRAMPS_PASSWORD=home4Gty94rj*de GRAMPS_PASSWORD=your-gramps-password-here
GRAMPS_API_TOKEN=
# GrampsWeb Container Konfiguration
GRAMPSWEB_SECRET_KEY=your-grampsweb-secret-key-here
GRAMPSWEB_ADMIN_EMAIL=admin@vhtv-stiftung.de
GRAMPSWEB_ADMIN_PASSWORD=your-grampsweb-admin-password-here
GRAMPSWEB_TREE=Stiftung
GRAMPSWEB_BASE_URL=/ahnenforschung