Compare commits

...

68 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
SysAdmin Agent
b8fb35db7a Fix Bestätigung email: send synchronously for immediate error feedback (STI-77)
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 Bestätigung email was sent via Celery task (fire-and-forget), so the UI
always showed "wird gesendet" even when the task failed silently in the worker.
Now sends synchronously from the web process (matching the working test email
pattern) with proper error display to the user.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-21 21:48:30 +00:00
SysAdmin Agent
d7992558ee Fix Vorlagen preview: use click handler instead of Bootstrap tab event (STI-82)
The shown.bs.tab event never fired, leaving the preview spinner forever.
Switched to a direct click handler with setTimeout for reliability.
Also added explicit credentials and HTTP error handling to the fetch.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-21 21:42:56 +00:00
SysAdmin Agent
31bf348136 fix: Add media_files volume to worker service (STI-84)
Celery worker was missing the media_files:/app/media volume mount,
causing DMS files saved by background tasks (email attachments,
Bestätigungsschreiben PDFs) to land in ephemeral container storage
instead of the persistent named volume. After any container restart,
these files were lost while DB records remained → Http404.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-21 21:36:22 +00:00
SysAdmin Agent
4e9fe816d5 Fix version display: show actual version instead of 'vunknown'
Root cause: Dockerfile build context is ./app/ but VERSION file is at
repo root, so it's excluded from the Docker image. The context processor
tried parent.parent.parent which resolves to / inside the container.

Fix:
- Context processor now checks APP_VERSION env var first, then tries
  multiple file paths (repo root for local dev, app/ dir for Docker)
- Dockerfile accepts APP_VERSION build arg and sets it as ENV
- compose.yml passes APP_VERSION build arg to all service builds

Note: Deploy script needs `export APP_VERSION=$(cat VERSION)` before
docker-compose build for the build arg to pick up the version.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-21 21:11:52 +00:00
SysAdmin Agent
59e05856b4 Improve email test: prominent card, HTML email, 'An mich' button
- Move test email form to a standalone card at the top of the page
  (was buried at the bottom of SMTP settings)
- Add 'An mich' button that fills in the logged-in user's email
- Send HTML + plain text test email (multi-alternative) styled like
  actual Stiftung emails, instead of plain text only
- Include diagnostic info (SMTP server, sender, user) in test email

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-21 21:07:36 +00:00
SysAdmin Agent
0e129ae56a Fix Vorlagen editor: working preview tab and improved layout
- Fix preview bug: preview event handlers were never attached when
  Summernote failed to load (fallback returned early at line 240)
- Restructure layout with Bootstrap tabs (Editor | Vorschau) instead
  of stacked editor+hidden preview
- Preview loads automatically when switching to the Vorschau tab
- Editor content getter works in all modes (Summernote, code, fallback)
- Editor now uses full viewport height for more editing space
- Variables sidebar gets 3 cols (was 4) giving editor 9 cols (was 8)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-21 21:04:30 +00:00
SysAdmin Agent
4ef09750d6 Remove 'Abgeschlossen' from payment pipeline, make 'Überwiesen' the final step
The 'Abgeschlossen' column was redundant after 'Überwiesen' since no further
action occurs after a payment is transferred. The pipeline is now 4 stages:
Offen → Nachweis eingereicht → Freigegeben → Überwiesen.

Existing 'abgeschlossen' records are merged into the 'Überwiesen' column.
Financial reports and queries are unaffected as they already include both statuses.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-21 20:59:42 +00:00
SysAdmin Agent
7c7bd73404 Add vendor static files for Vorlagen editor (jQuery, Summernote)
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
These are required for the WYSIWYG template editor to work.
Without them, Summernote doesn't load and the preview button is non-functional.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-21 09:28:12 +00:00
SysAdmin Agent
aed540fe4b Add Vorlagen editor, upload portal, onboarding, and participant import command
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
- Dokument-Vorlagen-Editor: create/edit/reset document templates (admin)
- Upload-Portal: public portal for Nachweis uploads via token
- Onboarding: invite Destinatäre via email with multi-step wizard
- Bestätigungsschreiben: preview and send confirmation letters
- Email settings: SMTP configuration UI
- Management command: import_veranstaltung_teilnehmer for bulk participant import

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-21 09:25:18 +00:00
SysAdmin Agent
fdf078fa10 Add MCP tools for Veranstaltung participant management
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
- veranstaltungen_anzeigen: list events with participant counts
- veranstaltung_teilnehmer_anzeigen: list participants by event
- veranstaltung_teilnehmer_anlegen: add single participant
- veranstaltung_teilnehmer_importieren: bulk import via JSON array

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-21 09:15:35 +00:00
SysAdmin Agent
e0b377014c v4.1.0: DMS email documents, category-specific Nachweis linking, version system
Some checks failed
CI/CD Pipeline / test (push) Has been cancelled
CI/CD Pipeline / deploy (push) Has been cancelled
Code Quality / quality (push) Has been cancelled
- Save cover email body as DMS document with new 'email' context type
- Show email body separately from attachments in email detail view
- Add per-category DMS document assignment in quarterly confirmation
  (Studiennachweis, Einkommenssituation, Vermögenssituation)
- Add VERSION file and context processor for automatic version display
- Add MCP server, agent system, import/export, and new migrations
- Update compose files and production environment template

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-15 18:48:52 +00:00
SysAdmin Agent
faeb7c1073 Implement modular report system with 6 report types and composer UI
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
Refactors the Berichte section from a single hardcoded Jahresbericht into
a modular report-building system. Jahresbericht now uses PDFGenerator for
corporate identity (logo, colors, headers/footers, cover page). 8 reusable
section templates can be freely combined. 6 predefined report templates
(Jahres-, Destinatär-, Grundstücks-, Finanz-, Förder-, Pachtbericht) with
HTML preview and PDF export. New Bericht-Baukasten UI lets users compose
custom reports from individual sections.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-14 20:55:31 +00:00
SysAdmin Agent
042114b1e7 Fix OGC API URL: Items → items (lowercase)
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 OGC API endpoint is case-sensitive and requires lowercase 'items'.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 22:56:07 +00:00
SysAdmin Agent
cb3a75a5a8 Add ALKIS Kennzeichen field to land edit form template
The field was in the Django form but not rendered in the template.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 22:17:56 +00:00
SysAdmin Agent
dccd5e974f Add ALKIS Flurstückskennzeichen field for direct cadastre links
When an ALKIS identifier is set on a Land record, the button links to
ogc-api.nrw.de detail view instead of the imprecise TIM-Online search.
Falls back to TIM-Online when no ALKIS number is present.

Closes STI-57

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 21:57:27 +00:00
SysAdmin Agent
f1358d0131 Improve deploy.sh: show commit details, warn if unpushed
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
- Display current branch, local/remote main commit with message
- Warn if local main is ahead of Gitea remote
- Show last 5 commits on main before deploying
- Update server address to deployment@217.154.84.225

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 14:25:26 +00:00
SysAdmin Agent
7e42b50d5b Extend backup verification to include Vision 2026 tables
Add DokumentDatei, EmailEingang, Verwaltungskosten, and
GeschichteSeite to post-restore verification table checks.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 12:04:35 +00:00
SysAdmin Agent
7a9dc533c3 Show linked DMS documents on Verwaltungskosten edit page
- Display linked PDFs/documents in the edit form with download links
- Fix "Details ansehen" button to link to detail page
- Redirect edit save to detail page instead of list

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 11:46:58 +00:00
SysAdmin Agent
781d410f88 Fix DMS edit FieldError: use pachtbeginn instead of vertragsbeginn
LandVerpachtung model uses pachtbeginn, not vertragsbeginn.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 11:36:34 +00:00
SysAdmin Agent
d84421ea38 Add Verwaltungskosten detail view with linked documents and emails
- New detail view at /geschaeftsfuehrung/verwaltungskosten/<pk>/
  showing invoice data, status, linked DMS documents, and emails
- Status change form in sidebar for quick workflow updates
- Link Verwaltungskosten list items to detail page
- Update email detail to link to VK detail instead of edit

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 11:21:04 +00:00
SysAdmin Agent
5c9db56158 Fix DMS entity assignment and Geschichte document linking
- DMS edit view: add Destinatär, Land, Pächter, Verpachtung dropdowns
  so documents can be assigned to entities after upload
- Geschichte: add M2M dokumente field on GeschichteSeite model
- Geschichte form: checkboxes to select/link Stiftungsgeschichte docs
- Geschichte detail: show linked documents in sidebar with download

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 10:40:46 +00:00
SysAdmin Agent
e6f4c5ba1b Generalize email system with invoice workflow and Stiftungsgeschichte category
- Rename DestinataerEmailEingang → EmailEingang with category support
  (destinataer, rechnung, land_pacht, stiftungsgeschichte, allgemein)
- Add invoice capture workflow: create Verwaltungskosten from email,
  link DMS documents as invoice attachments, track payment status
- Add Stiftungsgeschichte email category with auto-detection patterns
  (Ahnenforschung, Genealogie, Chronik, etc.) and DMS integration
- Update poll_emails task with category detection and DMS context mapping
- Show available history documents in Geschichte editor sidebar
- Consolidate DMS views, remove legacy dokument templates
- Update all detail/form templates for DMS document linking
- Add deploy.sh script and streamline compose.yml

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 10:17:14 +00:00
SysAdmin Agent
f4fc512ad3 Fix email-Destinatär document linking and add email delete
- When manually assigning an email to a Destinatär, also update
  associated DokumentLink records so attachments appear in the
  Destinatär's Dokumente tab
- Add email delete functionality (view, URL, buttons in list and detail)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 07:57:25 +00:00
SysAdmin Agent
8c528308bd Redesign Destinataer create/edit form to mirror detail page layout
User requested the create form to look exactly like the detail page.
Now uses the same two-column table-based card layout with matching
card headers, gradient header bar, and field organization as the
Stammdaten tab on the detail page.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-11 22:11:17 +00:00
SysAdmin Agent
8ae7bff38c Modernize Destinataer create/edit form to match Vision 2026 style
Replace custom CSS (.form-section, gradients, var(--racing-green)) with
standard Bootstrap card-based layout matching the rest of the modernized UI:
dark card headers, responsive grid rows, consistent shadow-sm styling.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-11 22:06:07 +00:00
SysAdmin Agent
65e025d8c4 Fix Paperless upload: handle async task UUID response
Paperless-ngx post_document returns a task UUID string, not a document
ID directly. The code assumed it was a dict and called .get() on a
string, causing AttributeError. Now polls the task status endpoint to
retrieve the actual integer document ID.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-11 21:25:55 +00:00
SysAdmin Agent
83cf2798b1 Fix email fetch reliability for large emails with attachments
- Add 120s IMAP socket timeout (was unlimited, could hang on large emails)
- Increase Paperless upload timeout from 60s to 300s for large attachments
- Increase manual poll UI timeout from 60s to 300s
- Show error count in UI when emails fail to process
- Log warning when attachment payload is empty/corrupted

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-11 21:21:17 +00:00
SysAdmin Agent
2a7c9d8529 Add IMAP configuration UI and sidebar navigation for email inbox
- Email settings page at /administration/email/ with IMAP config form
- Connection test button to verify IMAP connectivity
- Sidebar link "E-Mail Eingang" for quick access
- AppConfiguration model extended with email category and password type
- init_config command includes IMAP default settings
- DB-based IMAP config with env var fallback

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-11 21:01:05 +00:00
SysAdmin Agent
96204c04dd Fix email poll: search all recent emails (not just UNSEEN) on manual trigger
The manual "Jetzt abrufen" button now runs synchronously and searches all
emails from the last 30 days instead of only unread ones. This fixes the
issue where already-read emails in IMAP were invisible to the poll task.
Duplicate detection (by sender+date+subject) prevents re-imports.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-11 21:00:50 +00:00
SysAdmin Agent
c3c6755027 Modernize Destinataer detail page: tabbed UI with integrated timeline
Replaces the old multi-card layout (1368 lines) with a compact, modern
tabbed interface (Stammdaten | Nachweise | Zahlungen | Timeline | Dokumente | Notizen).
All information is now accessible from one page without excessive clicking.

- Add timeline events to destinataer_detail view (merged from timeline view logic)
- Compact profile header with avatar initials, status badges, key contact info
- Inline editing preserved with table-based layout for cleaner data display
- Tab state persisted in URL hash for bookmarkable deep links
- Dropdown menu for less-used actions (export, archive, delete)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-11 20:55:21 +00:00
SysAdmin Agent
8e1db11f8d Fix NoReverseMatch in email_eingang views: add stiftung: namespace prefix
The redirect() calls in email_eingang_poll_trigger and email_eingang_detail
were missing the 'stiftung:' namespace prefix, causing NoReverseMatch errors.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-11 20:41:12 +00:00
SysAdmin Agent
b47ffd4a3c Fix Verpachtungen list + migrate legacy pacht data to LandVerpachtung
- Add migrate_to_landverpachtung management command that converts
  old Land-level pacht fields (aktueller_paechter, pachtbeginn, etc.)
  into proper LandVerpachtung records
- Fix SyntaxError in system.py (fancy Unicode quotes in f-strings)
- Ran migration: 1 LandVerpachtung record created for Jens Bodden

The old system stored pacht data directly on the Land model.
The new LandVerpachtung model supports multiple leases per Land.
The verpachtung_list view queries LandVerpachtung, which was empty.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-11 20:36:39 +00:00
SysAdmin Agent
cf127b043d Bug fixes + archive feature for Destinatäre
- Make Destinatär names clickable in list view (link to detail page)
- Nachweis-Board: auto-create missing VierteljahresNachweis records
  for active Destinatäre when viewing a year (fixes missing Q1 2026)
- Add archive/deactivate toggle for Destinatäre (button on detail page)
  with AuditLog entry and confirmation dialog
- Default Destinatär list to show active only (filter preset to "Aktiv")

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-11 20:07:30 +00:00
SysAdmin Agent
113bd53a3a Fix paechter_workflow FieldError: use correct related_name 'neue_verpachtungen'
The Paechter model's reverse relation from LandVerpachtung uses
related_name='neue_verpachtungen', not the default 'landverpachtung'.
Fixed the annotate() query in top_paechter section of paechter_workflow view.
2026-03-11 13:53:14 +00:00
SysAdmin Agent
502fab31fc Fix paechter_workflow FieldError: use correct related_name 'abrechnungen'
The Land model's ForeignKey from LandAbrechnung uses related_name='abrechnungen',
not the default 'landabrechnung'. Fixed the exclude() query in paechter_workflow view.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-11 13:25:05 +00:00
SysAdmin Agent
905aa879ee Fix nachweis-board TemplateSyntaxError: add missing get_item filter
The nachweis_board.html template used a get_item filter that was never
defined. Added it to help_tags.py and loaded the tag library in the template.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-11 13:20:23 +00:00
SysAdmin Agent
2be72c3990 Phase 4: SEPA-Validierung (schwifty), Globale Suche (Cmd+K) & Jahresbericht-Modul
- SEPA-Export: IBAN/BIC-Validierung via schwifty, Schuldner-Konto aus StiftungsKonto
- Globale Suche: Cmd+K Modal über Destinatäre, Pächter, Ländereien, Förderungen, Dokumente
- Jahresbericht: Vollständige Jahresbilanz mit Einnahmen/Ausgaben/Netto, Unterstützungen,
  Landabrechnungen, Verwaltungskosten nach Kategorie, PDF-Export

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-11 12:57:36 +00:00
SysAdmin Agent
a79a0989d6 Phase 3: Django-natives DMS – Paperless-NGX durch DokumentDatei ersetzt
- Neues Modell DokumentDatei mit PostgreSQL FTS (SearchVectorField, GinIndex)
- Upload-Pfad: dokumente/YYYY/MM/<uuid>/dateiname
- 7 DMS-Views: list, detail, download, upload (HTMX Drag&Drop), delete, edit, search_api
- Templates: list, detail, edit, upload mit Drag&Drop-Zone, Partials
- URLs: /dms/ komplett verdrahtet
- Sidebar: DMS als Primäreintrag, Paperless als Legacy
- Migrationsskript: manage.py migrate_paperless_dokumente (DokumentLink → DokumentDatei)
- compose.yml: paperless-Dienst deaktiviert (Legacy-Kommentarblock)
- Migration 0048 angewendet

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-11 11:10:08 +00:00
SysAdmin Agent
ee2c827d85 Phase 2: Destinatär-Timeline, Nachweis-Board, Zahlungs-Pipeline & Pächter-Workflow
2a. Destinatär-Timeline (/destinataere/<pk>/timeline/)
    - Chronologische Ansicht aller Events (Zahlungen, Nachweise, E-Mails, Notizen)
    - Filter nach Typ via GET-Parameter

2b. Nachweis-Board (/nachweis-board/)
    - Quartals-Übersicht aller aktiver Destinatäre (Q1–Q4) in einer Tabellenansicht
    - Batch-Erinnerung: erzeugt Audit-Log-Einträge für säumige Destinatäre
    - Semester-Logik erhalten (15.03 / 15.09 Fristen)

2c. Zahlungs-Pipeline (/zahlungs-pipeline/)
    - 5-Stufen-Kanban: Offen → Nachweis eingereicht → Freigegeben → Überwiesen → Abgeschlossen
    - Vier-Augen-Prinzip: can_be_freigegeben() prüft anderen Nutzer als Ersteller
    - SEPA pain.001 XML-Export (/sepa-export/) für freigegebene Zahlungen
    - Neue Status-Werte: nachweis_eingereicht, freigegeben, abgeschlossen
    - Neue Felder: freigegeben_von, freigegeben_am, erstellt_von

2d. Pächter-Workflow (/paechter/workflow/)
    - Pipeline nach Restlaufzeit: abgelaufen / <6M / 6–24M / >24M / unbefristet
    - Ausstehende Jahresabrechnungen (Vorjahr ohne Abrechnung)
    - Pachtanpassungen fällig (Verträge > 5 Jahre laufend)
    - Top-Pächter nach Gesamtfläche

Sidebar-Navigation um Pipeline, Nachweis-Board und Pacht-Workflow erweitert.
Migration 0047 erzeugt und angewendet.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-11 10:40:43 +00:00
SysAdmin Agent
bf47ba11c9 Phase 1: Sidebar-Navigation, Dashboard-Cockpit & HTMX-Integration
- New sidebar layout (6 sections: Dashboard, Personen, Land, Finanzen, Dokumente, System)
- Collapsible sidebar with localStorage persistence
- Top bar with user dropdown and breadcrumbs
- Dashboard cockpit with live KPI cards (Destinataere, Foerderungen, Zahlungen, Laendereien)
- Action items: overdue Nachweise, pending payments, upcoming events, new emails, expiring leases
- Quick actions panel and recent audit log
- HTMX (2.0.4) and Alpine.js (3.14.8) integration via CDN
- django-htmx middleware and CSRF token setup
- Fix IMAP_PORT empty string handling in settings

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-11 10:22:42 +00:00
SysAdmin Agent
3ca2706e5d Phase 0: forms.py, admin.py und views.py in Domain-Packages aufteilen
- forms.py → forms/ Package (8 Domänen: destinataere, land, finanzen,
  foerderung, dokumente, veranstaltung, system, geschichte)
- admin.py → admin/ Package (7 Domänen, alle 22 @admin.register dekoriert)
- views.py (8845 Zeilen) → views/ Package (10 Domänen: dashboard, destinataere,
  land, paechter, finanzen, foerderung, dokumente, unterstuetzungen,
  veranstaltung, geschichte, system)
- __init__.py in jedem Package re-exportiert alle Symbole für Rückwärtskompatibilität
- urls.py bleibt unverändert (funktioniert durch Re-Exports)
- Django system check: 0 Fehler, alle URL-Auflösungen funktionieren

Keine funktionalen Änderungen – reine Strukturverbesserung für Vision 2026.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-11 09:55:15 +00:00
SysAdmin Agent
7e9e4fddf1 Phase 0: Alte models.py entfernt (ersetzt durch models/ Package)
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-11 09:04:44 +00:00
SysAdmin Agent
b4bad7bc83 Phase 0: models.py → models/ Package aufgeteilt
models.py (3.496 Zeilen) in 6 Domain-Module aufgeteilt:
- system.py: CSVImport, ApplicationPermission, AuditLog, BackupJob, AppConfiguration, HelpBox
- land.py: Paechter, Land, LandVerpachtung, LandAbrechnung, DokumentLink
- finanzen.py: Rentmeister, StiftungsKonto, BankTransaction, Verwaltungskosten
- destinataere.py: Destinataer, Person, Foerderung, DestinataerUnterstuetzung,
  UnterstuetzungWiederkehrend, DestinataerNotiz, VierteljahresNachweis,
  DestinataerEmailEingang
- veranstaltungen.py: BriefVorlage, Veranstaltung, Veranstaltungsteilnehmer
- geschichte.py: GeschichteSeite, GeschichteBild, StiftungsKalenderEintrag

__init__.py re-exportiert alle Models für volle Rückwärtskompatibilität.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-11 09:02:08 +00:00
SysAdmin Agent
709903e627 Baseline für Vision 2026: Veranstaltungsmodul + ausstehende Änderungen
Alle bestehenden, nicht commiteten Änderungen als Ausgangsbasis für den
vision-2026 Branch übernommen (Veranstaltungsmodul, Serienbrief, etc.).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-11 08:51:48 +00:00
210 changed files with 36084 additions and 19982 deletions

View File

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

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

1
VERSION Normal file
View File

@@ -0,0 +1 @@
4.1.0

View File

@@ -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 \

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

View File

@@ -35,10 +35,12 @@ INSTALLED_APPS = [
"django.contrib.humanize",
"rest_framework",
"rest_framework.authtoken",
"django_htmx",
"django_otp",
"django_otp.plugins.otp_totp",
"django_otp.plugins.otp_static",
"stiftung",
"django.contrib.postgres",
]
# Add this to app/core/settings.py
SESSION_COOKIE_NAME = 'stiftung_sessionid' # Different from default 'sessionid'
@@ -48,6 +50,7 @@ MIDDLEWARE = [
"django.middleware.security.SecurityMiddleware",
"django.contrib.sessions.middleware.SessionMiddleware",
"django.middleware.common.CommonMiddleware",
"django_htmx.middleware.HtmxMiddleware",
"django.middleware.csrf.CsrfViewMiddleware",
"django.contrib.auth.middleware.AuthenticationMiddleware",
"django_otp.middleware.OTPMiddleware",
@@ -69,6 +72,7 @@ TEMPLATES = [
"django.template.context_processors.request",
"django.contrib.auth.context_processors.auth",
"django.contrib.messages.context_processors.messages",
"core.context_processors.app_version",
],
},
},
@@ -114,38 +118,47 @@ MEDIA_URL = "/media/"
MEDIA_ROOT = BASE_DIR / "media"
# Celery
CELERY_BROKER_URL = os.getenv("REDIS_URL", "redis://redis:6379/0")
CELERY_RESULT_BACKEND = os.getenv("REDIS_URL", "redis://redis:6379/0")
CELERY_BROKER_URL = os.getenv("CELERY_REDIS_URL", os.getenv("REDIS_URL", "redis://redis:6379/2"))
CELERY_RESULT_BACKEND = os.getenv("CELERY_REDIS_URL", os.getenv("REDIS_URL", "redis://redis:6379/2"))
CELERY_TASK_SERIALIZER = "json"
CELERY_RESULT_SERIALIZER = "json"
CELERY_ACCEPT_CONTENT = ["json"]
# Celery Beat periodische Tasks
from celery.schedules import crontab # noqa: E402
CELERY_BEAT_SCHEDULE = {
# E-Mail-Postfach alle 15 Minuten auf neue Destinatär-Nachrichten prüfen
"poll-destinataer-emails": {
"task": "stiftung.tasks.poll_destinataer_emails",
# E-Mail-Postfach alle 15 Minuten auf neue Nachrichten pruefen
"poll-emails": {
"task": "stiftung.tasks.poll_emails",
"schedule": crontab(minute="*/15"),
},
# Täglich um 08:00 Uhr: Ablaufende Upload-Tokens prüfen und Erinnerungen versenden
"check-ablaufende-tokens": {
"task": "stiftung.tasks.check_ablaufende_tokens",
"schedule": crontab(hour="8", minute="0"),
},
}
# IMAP-Konfiguration für E-Mail-Eingang (Destinatäre)
# Pflichtfelder: IMAP_HOST, IMAP_USER, IMAP_PASSWORD
IMAP_HOST = os.getenv("IMAP_HOST", "")
IMAP_PORT = int(os.getenv("IMAP_PORT", "993"))
IMAP_PORT = int(os.getenv("IMAP_PORT") or "993")
IMAP_USER = os.getenv("IMAP_USER", "paperless@vhtv-stiftung.de")
IMAP_PASSWORD = os.getenv("IMAP_PASSWORD", "")
IMAP_FOLDER = os.getenv("IMAP_FOLDER", "INBOX")
IMAP_USE_SSL = os.getenv("IMAP_USE_SSL", "true").lower() == "true"
# Paperless
PAPERLESS_API_URL = os.getenv("PAPERLESS_API_URL", "https://vhtv-stiftung.de/paperless")
PAPERLESS_API_TOKEN = os.getenv("PAPERLESS_API_TOKEN")
PAPERLESS_REQUIRED_TAG = os.getenv("PAPERLESS_REQUIRED_TAG", "Stiftung_Destinatäre")
PAPERLESS_LAND_TAG = os.getenv("PAPERLESS_LAND_TAG", "Stiftung_Land_und_Pächter")
PAPERLESS_ADMIN_TAG = os.getenv("PAPERLESS_ADMIN_TAG", "Stiftung_Administration")
PAPERLESS_DESTINATAERE_TAG_ID = os.getenv("PAPERLESS_DESTINATAERE_TAG_ID")
PAPERLESS_LAND_TAG_ID = os.getenv("PAPERLESS_LAND_TAG_ID")
PAPERLESS_ADMIN_TAG_ID = os.getenv("PAPERLESS_ADMIN_TAG_ID")
# SMTP-Konfiguration für E-Mail-Ausgang (Nachweis-Aufforderungen, Einladungen)
# Pflichtfelder: EMAIL_HOST_USER, EMAIL_HOST_PASSWORD
EMAIL_HOST = os.getenv("EMAIL_HOST", "smtp.ionos.de")
EMAIL_PORT = int(os.getenv("EMAIL_PORT") or "465")
EMAIL_USE_SSL = os.getenv("EMAIL_USE_SSL", "true").lower() == "true"
EMAIL_HOST_USER = os.getenv("EMAIL_HOST_USER", "")
EMAIL_HOST_PASSWORD = os.getenv("EMAIL_HOST_PASSWORD", "")
DEFAULT_FROM_EMAIL = os.getenv("DEFAULT_FROM_EMAIL", "stiftung@vhtv-stiftung.de")
EMAIL_SUBJECT_PREFIX = "[vHTV-Stiftung] "
# Authentication
LOGIN_URL = "/login/"
@@ -153,7 +166,7 @@ LOGIN_REDIRECT_URL = "/"
LOGOUT_REDIRECT_URL = "/login/"
# Gramps integration
GRAMPS_URL = os.environ.get("GRAMPS_URL", "http://grampsweb:80")
GRAMPS_URL = os.environ.get("GRAMPS_URL", "http://grampsweb:5000")
GRAMPS_API_TOKEN = os.environ.get("GRAMPS_API_TOKEN", "")
GRAMPS_STIFTER_IDS = os.environ.get("GRAMPS_STIFTER_IDS", "") # comma-separated
GRAMPS_USERNAME = os.environ.get("GRAMPS_USERNAME", "")
@@ -177,7 +190,7 @@ if not DEBUG:
# django-otp settings
OTP_TOTP_ISSUER = 'Stiftung Management System'
OTP_LOGIN_URL = '/two-factor/login/'
OTP_LOGIN_URL = '/auth/2fa/verify/'
# Optional: Hide sensitive data in admin when not verified
OTP_ADMIN_HIDE_SENSITIVE_DATA = True

View File

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

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

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

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

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

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

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

@@ -0,0 +1 @@
# MCP Tools

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

View 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,
},
})

View 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,
})

View File

@@ -11,4 +11,8 @@ gunicorn==22.0.0
python-dateutil==2.9.0
markdown==3.6
django-otp==1.2.4
django-htmx==1.19.0
qrcode[pil]==7.4.2
schwifty==2026.3.0
mcp>=1.0.0
httpx>=0.27.0

View File

@@ -0,0 +1,250 @@
/**
* Briefvorlage-Editor: Minimal-WYSIWYG + Vorschau-Panel + Vorlagen-Loader
* für Django Admin keine externen Abhängigkeiten.
*/
(function () {
"use strict";
// Warte auf DOM
document.addEventListener("DOMContentLoaded", function () {
var textareas = document.querySelectorAll("textarea.briefvorlage-textarea");
textareas.forEach(function (textarea) {
initEditor(textarea);
});
});
function initEditor(textarea) {
var wrapper = document.createElement("div");
wrapper.style.cssText = "border:1px solid #ccc;border-radius:4px;overflow:hidden;margin-top:4px;";
// ---- Toolbar ----
var toolbar = document.createElement("div");
toolbar.style.cssText = "background:#f5f5f5;border-bottom:1px solid #ccc;padding:5px 8px;display:flex;flex-wrap:wrap;gap:4px;align-items:center;";
var buttons = [
{ label: "B", cmd: "bold", title: "Fett (Strg+B)", style: "font-weight:bold;" },
{ label: "I", cmd: "italic", title: "Kursiv (Strg+I)", style: "font-style:italic;" },
{ label: "U", cmd: "underline", title: "Unterstrichen (Strg+U)", style: "text-decoration:underline;" },
{ label: "¶", cmd: "insertParagraph", title: "Absatz einfügen" },
{ label: "• Liste", cmd: "insertUnorderedList", title: "Aufzählung" },
{ label: "1. Liste", cmd: "insertOrderedList", title: "Nummerierte Liste" },
];
buttons.forEach(function (b) {
var btn = document.createElement("button");
btn.type = "button";
btn.title = b.title;
btn.innerHTML = b.label;
btn.style.cssText = "padding:3px 8px;cursor:pointer;border:1px solid #ccc;border-radius:3px;background:#fff;font-size:13px;" + (b.style || "");
btn.addEventListener("click", function (e) {
e.preventDefault();
editor.focus();
document.execCommand(b.cmd, false, null);
syncToTextarea();
});
toolbar.appendChild(btn);
});
// Trennlinie
var sep = document.createElement("span");
sep.style.cssText = "border-left:1px solid #ccc;height:20px;margin:0 4px;";
toolbar.appendChild(sep);
// Tab-Buttons: Editor / HTML / Vorschau
var tabEditor = createTabBtn("Editor", true);
var tabHtml = createTabBtn("HTML", false);
var tabVorschau = createTabBtn("Vorschau", false);
toolbar.appendChild(tabEditor);
toolbar.appendChild(tabHtml);
toolbar.appendChild(tabVorschau);
// Vorlage-Loader (nur wenn BriefVorlage-API verfügbar)
var sep2 = document.createElement("span");
sep2.style.cssText = "border-left:1px solid #ccc;height:20px;margin:0 4px;";
toolbar.appendChild(sep2);
var vorlagenSelect = document.createElement("select");
vorlagenSelect.style.cssText = "font-size:12px;padding:2px 6px;border:1px solid #ccc;border-radius:3px;max-width:200px;";
var defaultOption = document.createElement("option");
defaultOption.value = "";
defaultOption.textContent = " Vorlage laden ";
vorlagenSelect.appendChild(defaultOption);
toolbar.appendChild(vorlagenSelect);
// Vorlagen asynchron laden
loadVorlagen(vorlagenSelect);
var ladeBtn = document.createElement("button");
ladeBtn.type = "button";
ladeBtn.textContent = "Laden";
ladeBtn.title = "Ausgewählte Vorlage in den Editor laden";
ladeBtn.style.cssText = "padding:3px 8px;cursor:pointer;border:1px solid #0d6efd;border-radius:3px;background:#0d6efd;color:#fff;font-size:12px;";
ladeBtn.addEventListener("click", function (e) {
e.preventDefault();
var val = vorlagenSelect.value;
if (!val) return;
var opt = vorlagenSelect.querySelector("option[value='" + val + "']");
if (!opt) return;
var html = opt.dataset.briefvorlage || "";
var betreff = opt.dataset.betreff || "";
if (confirm("Vorlage \"" + opt.textContent + "\" laden?\nDer aktuelle Brieftext wird überschrieben.")) {
editor.innerHTML = html;
textarea.value = html;
// Betreff-Feld befüllen falls vorhanden und nicht leer
if (betreff) {
var betreffField = document.getElementById("id_betreff");
if (betreffField && !betreffField.value) {
betreffField.value = betreff;
}
}
updatePreview();
}
});
toolbar.appendChild(ladeBtn);
// ---- Editor-Div (WYSIWYG) ----
var editor = document.createElement("div");
editor.contentEditable = "true";
editor.style.cssText = "min-height:300px;padding:12px;font-family:Times New Roman,serif;font-size:11pt;line-height:1.4;outline:none;background:#fff;";
editor.innerHTML = textarea.value;
editor.addEventListener("input", syncToTextarea);
editor.addEventListener("keyup", syncToTextarea);
// ---- HTML-Textarea (Quelltext) ----
textarea.style.cssText += "display:none;width:100%;box-sizing:border-box;border:none;padding:12px;font-family:monospace;font-size:13px;";
textarea.addEventListener("input", function () {
editor.innerHTML = textarea.value;
updatePreview();
});
// ---- Vorschau-Panel ----
var preview = document.createElement("div");
preview.style.cssText = "display:none;min-height:300px;padding:12px;background:#fff;font-family:'Times New Roman',serif;font-size:11pt;line-height:1.4;";
// Tab-Logik
function showTab(which) {
editor.style.display = "none";
textarea.style.display = "none";
preview.style.display = "none";
tabEditor.style.background = "#f5f5f5";
tabHtml.style.background = "#f5f5f5";
tabVorschau.style.background = "#f5f5f5";
if (which === "editor") {
editor.style.display = "block";
tabEditor.style.background = "#fff";
tabEditor.style.fontWeight = "bold";
} else if (which === "html") {
textarea.style.display = "block";
tabHtml.style.background = "#fff";
tabHtml.style.fontWeight = "bold";
} else {
preview.style.display = "block";
tabVorschau.style.background = "#fff";
tabVorschau.style.fontWeight = "bold";
updatePreview();
}
}
tabEditor.addEventListener("click", function (e) { e.preventDefault(); showTab("editor"); });
tabHtml.addEventListener("click", function (e) {
e.preventDefault();
syncToTextarea();
showTab("html");
});
tabVorschau.addEventListener("click", function (e) { e.preventDefault(); showTab("vorschau"); });
// Zusammenbauen
wrapper.appendChild(toolbar);
wrapper.appendChild(editor);
wrapper.appendChild(preview);
// Textarea hinter Editor platzieren
textarea.parentNode.insertBefore(wrapper, textarea);
wrapper.appendChild(textarea);
// Initial: Editor-Tab aktiv
showTab("editor");
// ---- Hilfsfunktionen ----
function syncToTextarea() {
textarea.value = editor.innerHTML;
}
function updatePreview() {
// Platzhalter durch Beispielwerte ersetzen für Vorschau
var html = textarea.value;
var replacements = {
"{{ anrede }}": "Frau",
"{{ vorname }}": "Maria",
"{{ nachname }}": "Mustermann",
"{{ strasse }}": "Musterstraße 12",
"{{ plz }}": "46499",
"{{ ort }}": "Hamminkeln",
"{{ datum }}": "Freitag, 17. April 2026",
"{{ uhrzeit }}": "19:00 Uhr",
"{{ veranstaltungsort }}": "Marienthaler Gasthof",
"{{ gasthaus_adresse }}": "Pastor-Winkelmann-Str. 2, 46499 Hamminkeln",
};
for (var key in replacements) {
html = html.split(key).join(replacements[key]);
}
preview.innerHTML = html || "<em style='color:#999;'>Kein Brieftext eingegeben.</em>";
}
function createTabBtn(label, active) {
var btn = document.createElement("button");
btn.type = "button";
btn.textContent = label;
btn.style.cssText = "padding:3px 10px;cursor:pointer;border:1px solid #ccc;border-radius:3px;font-size:12px;background:" + (active ? "#fff" : "#f5f5f5") + ";";
if (active) btn.style.fontWeight = "bold";
return btn;
}
}
function loadVorlagen(selectEl) {
// Lese Vorlagen über einfachen Admin-API-Aufruf
fetch("/admin/stiftung/briefvorlage/?format=json", {
headers: { "X-Requested-With": "XMLHttpRequest" }
})
.then(function (r) { return r.ok ? r.json() : null; })
.then(function (data) {
if (!data || !data.results) return;
data.results.forEach(function (v) {
var opt = document.createElement("option");
opt.value = v.id || v.pk;
opt.textContent = v.name || v.fields && v.fields.name;
opt.dataset.briefvorlage = v.briefvorlage || v.fields && v.fields.briefvorlage || "";
opt.dataset.betreff = v.betreff || v.fields && v.fields.betreff || "";
selectEl.appendChild(opt);
});
})
.catch(function () {
// Kein API-Endpunkt Vorlage-Loader deaktivieren
});
// Alternativ: REST-API
fetch("/api/v1/briefvorlagen/", {
headers: { "X-Requested-With": "XMLHttpRequest" }
})
.then(function (r) { return r.ok ? r.json() : null; })
.then(function (data) {
if (!data) return;
var results = Array.isArray(data) ? data : data.results;
if (!results) return;
// Bereits vorhandene Optionen nicht doppeln
var existing = Array.from(selectEl.options).map(function (o) { return o.value; });
results.forEach(function (v) {
var id = String(v.id || v.pk || "");
if (existing.includes(id)) return;
var opt = document.createElement("option");
opt.value = id;
opt.textContent = v.name;
opt.dataset.briefvorlage = v.briefvorlage || "";
opt.dataset.betreff = v.betreff || "";
selectEl.appendChild(opt);
});
})
.catch(function () { /* kein REST-Endpunkt */ });
}
})();

File diff suppressed because one or more lines are too long

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,2 @@
/*! Summernote v0.8.20 | (c) 2013- Alan Hong and contributors | MIT license */
!function(e,t){if("object"==typeof exports&&"object"==typeof module)module.exports=t();else if("function"==typeof define&&define.amd)define([],t);else{var r=t();for(var i in r)("object"==typeof exports?exports:e)[i]=r[i]}}(self,(function(){return(e=jQuery).extend(e.summernote.lang,{"de-DE":{font:{bold:"Fett",italic:"Kursiv",underline:"Unterstrichen",clear:"Zurücksetzen",height:"Zeilenhöhe",name:"Schriftart",strikethrough:"Durchgestrichen",subscript:"Tiefgestellt",superscript:"Hochgestellt",size:"Schriftgröße"},image:{image:"Bild",insert:"Bild einfügen",resizeFull:"Originalgröße",resizeHalf:"1/2 Größe",resizeQuarter:"1/4 Größe",floatLeft:"Linksbündig",floatRight:"Rechtsbündig",floatNone:"Kein Textfluss",shapeRounded:"Abgerundete Ecken",shapeCircle:"Kreisförmig",shapeThumbnail:'"Vorschaubild"',shapeNone:"Kein Rahmen",dragImageHere:"Bild hierher ziehen",dropImage:"Bild oder Text nehmen",selectFromFiles:"Datei auswählen",maximumFileSize:"Maximale Dateigröße",maximumFileSizeError:"Maximale Dateigröße überschritten",url:"Bild URL",remove:"Bild entfernen",original:"Original"},video:{video:"Video",videoLink:"Videolink",insert:"Video einfügen",url:"Video URL",providers:"(YouTube, Vimeo, Vine, Instagram, DailyMotion oder Youku)"},link:{link:"Link",insert:"Link einfügen",unlink:"Link entfernen",edit:"Bearbeiten",textToDisplay:"Anzeigetext",url:"Link URL",openInNewWindow:"In neuem Fenster öffnen",useProtocol:"Standardprotokoll verwenden"},table:{table:"Tabelle",addRowAbove:"+ Zeile oberhalb",addRowBelow:"+ Zeile unterhalb",addColLeft:"+ Spalte links",addColRight:"+ Spalte rechts",delRow:"Zeile löschen",delCol:"Spalte löschen",delTable:"Tabelle löschen"},hr:{insert:"Horizontale Linie einfügen"},style:{style:"Stil",normal:"Normal",p:"Normal",blockquote:"Zitat",pre:"Quellcode",h1:"Überschrift 1",h2:"Überschrift 2",h3:"Überschrift 3",h4:"Überschrift 4",h5:"Überschrift 5",h6:"Überschrift 6"},lists:{unordered:"Aufzählung",ordered:"Nummerierung"},options:{help:"Hilfe",fullscreen:"Vollbild",codeview:"Quellcode anzeigen"},paragraph:{paragraph:"Absatz",outdent:"Einzug verkleinern",indent:"Einzug vergrößern",left:"Links ausrichten",center:"Zentriert ausrichten",right:"Rechts ausrichten",justify:"Blocksatz"},color:{recent:"Letzte Farbe",more:"Weitere Farben",background:"Hintergrundfarbe",foreground:"Schriftfarbe",transparent:"Transparenz",setTransparent:"Transparenz setzen",reset:"Zurücksetzen",resetToDefault:"Auf Standard zurücksetzen"},shortcut:{shortcuts:"Tastenkürzel",close:"Schließen",textFormatting:"Textformatierung",action:"Aktion",paragraphFormatting:"Absatzformatierung",documentStyle:"Dokumentenstil",extraKeys:"Weitere Tasten"},help:{insertParagraph:"Absatz einfügen",undo:"Letzte Anweisung rückgängig",redo:"Letzte Anweisung wiederholen",tab:"Einzug hinzufügen",untab:"Einzug entfernen",bold:"Schrift Fett",italic:"Schrift Kursiv",underline:"Unterstreichen",strikethrough:"Durchstreichen",removeFormat:"Entfernt Format",justifyLeft:"Linksbündig",justifyCenter:"Mittig",justifyRight:"Rechtsbündig",justifyFull:"Blocksatz",insertUnorderedList:"Unnummerierte Liste",insertOrderedList:"Nummerierte Liste",outdent:"Aktuellen Absatz ausrücken",indent:"Aktuellen Absatz einrücken",formatPara:"Formatiert aktuellen Block als Absatz (P-Tag)",formatH1:"Formatiert aktuellen Block als H1",formatH2:"Formatiert aktuellen Block als H2",formatH3:"Formatiert aktuellen Block als H3",formatH4:"Formatiert aktuellen Block als H4",formatH5:"Formatiert aktuellen Block als H5",formatH6:"Formatiert aktuellen Block als H6",insertHorizontalRule:"Fügt eine horizontale Linie ein","linkDialog.show":"Zeigt den Linkdialog"},history:{undo:"Rückgängig",redo:"Wiederholen"},specialChar:{specialChar:"Sonderzeichen",select:"Zeichen auswählen"}}}),{};var e}));

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,15 @@
from django.contrib import admin
from . import destinataere # noqa: F401
from . import land # noqa: F401
from . import finanzen # noqa: F401
from . import foerderung # noqa: F401
from . import dokumente # noqa: F401
from . import veranstaltung # noqa: F401
from . import system # noqa: F401
from stiftung.agent import admin as agent_admin # noqa: F401
# Customize admin site
admin.site.site_header = "Stiftungsverwaltung Administration"
admin.site.site_title = "Stiftungsverwaltung Admin"
admin.site.index_title = "Willkommen zur Stiftungsverwaltung"

View File

@@ -0,0 +1,178 @@
from django.contrib import admin
from django.urls import reverse
from django.utils.html import format_html
from ..models import Destinataer, DestinataerEmailEingang, DestinataerUnterstuetzung
@admin.register(Destinataer)
class DestinataerAdmin(admin.ModelAdmin):
list_display = [
"nachname",
"vorname",
"familienzweig",
"berufsgruppe",
"institution",
"finanzielle_notlage",
"aktiv",
]
list_filter = ["familienzweig", "berufsgruppe", "finanzielle_notlage", "aktiv"]
search_fields = ["nachname", "vorname", "email", "institution", "familienzweig"]
ordering = ["nachname", "vorname"]
readonly_fields = ["id"]
fieldsets = (
(
"Persönliche Daten",
{"fields": ("anrede", "vorname", "nachname", "geburtsdatum", "email", "telefon")},
),
(
"Berufliche Informationen",
{"fields": ("berufsgruppe", "ausbildungsstand", "institution")},
),
(
"Projekt & Finanzen",
{
"fields": (
"projekt_beschreibung",
"jaehrliches_einkommen",
"finanzielle_notlage",
)
},
),
(
"Stiftungsdaten",
{"fields": ("familienzweig", "iban", "strasse", "plz", "ort")},
),
("Zusätzlich", {"fields": ("notizen", "aktiv")}),
("System", {"fields": ("id",), "classes": ("collapse",)}),
)
def iban_display(self, obj):
if obj.iban:
return format_html(
'<span style="font-family: monospace;">{}</span>', obj.iban
)
return "-"
iban_display.short_description = "IBAN"
@admin.register(DestinataerUnterstuetzung)
class DestinataerUnterstuetzungAdmin(admin.ModelAdmin):
list_display = [
"__str__",
"destinataer",
"betrag",
"faellig_am",
"status",
"wiederkehrend_von",
"ausgezahlt_am",
]
list_filter = ["status", "faellig_am", "erstellt_am", "konto"]
search_fields = [
"destinataer__vorname",
"destinataer__nachname",
"beschreibung",
"empfaenger_name",
]
readonly_fields = ["id", "erstellt_am", "aktualisiert_am"]
fieldsets = (
(
"Grundinformationen",
{
"fields": (
"destinataer",
"konto",
"betrag",
"faellig_am",
"status",
"beschreibung",
)
},
),
(
"Überweisungsdaten",
{"fields": ("empfaenger_iban", "empfaenger_name", "verwendungszweck")},
),
("Zahlungsinformationen", {"fields": ("ausgezahlt_am", "ausgezahlt_von")}),
("Wiederkehrend", {"fields": ("wiederkehrend_von",)}),
(
"Metadaten",
{
"fields": ("id", "erstellt_am", "aktualisiert_am"),
"classes": ("collapse",),
},
),
)
@admin.register(DestinataerEmailEingang)
class DestinataerEmailEingangAdmin(admin.ModelAdmin):
list_display = [
"eingangsdatum",
"absender_email",
"absender_name",
"destinataer_link",
"betreff_kurz",
"anzahl_anhaenge",
"status",
"created_at",
]
list_filter = ["status", "eingangsdatum"]
search_fields = [
"absender_email",
"absender_name",
"betreff",
"destinataer__vorname",
"destinataer__nachname",
]
readonly_fields = ["created_at", "absender_email", "absender_name", "eingangsdatum",
"email_text", "paperless_dokument_ids", "fehler_details"]
raw_id_fields = ["destinataer", "quartalsnachweis"]
date_hierarchy = "eingangsdatum"
ordering = ["-eingangsdatum"]
fieldsets = [
("E-Mail-Metadaten", {
"fields": ["eingangsdatum", "absender_name", "absender_email", "betreff"],
}),
("Zuordnung", {
"fields": ["destinataer", "status", "quartalsnachweis"],
}),
("Inhalt & Anhänge", {
"fields": ["email_text", "paperless_dokument_ids"],
}),
("Notizen & Fehler", {
"fields": ["notizen", "fehler_details"],
"classes": ["collapse"],
}),
("System", {
"fields": ["created_at"],
"classes": ["collapse"],
}),
]
def destinataer_link(self, obj):
if obj.destinataer:
url = reverse("admin:stiftung_destinataer_change", args=[obj.destinataer.pk])
return format_html('<a href="{}">{}</a>', url, obj.destinataer)
return format_html('<span style="color:red;"></span>')
destinataer_link.short_description = "Destinatär"
def betreff_kurz(self, obj):
return (obj.betreff or "")[:60]
betreff_kurz.short_description = "Betreff"
def anzahl_anhaenge(self, obj):
n = len(obj.paperless_dokument_ids or [])
return n if n else ""
anzahl_anhaenge.short_description = "Anhänge"
actions = ["mark_verarbeitet"]
def mark_verarbeitet(self, request, queryset):
updated = queryset.filter(status__in=["neu", "zugewiesen"]).update(status="verarbeitet")
self.message_user(request, f"{updated} E-Mail(s) als verarbeitet markiert.")
mark_verarbeitet.short_description = "Als verarbeitet markieren"

View File

@@ -0,0 +1,20 @@
from django.contrib import admin
from ..models import DokumentLink
@admin.register(DokumentLink)
class DokumentLinkAdmin(admin.ModelAdmin):
list_display = ["titel", "kontext", "paperless_document_id"]
list_filter = ["kontext"]
search_fields = ["titel", "kontext"]
ordering = ["titel"]
readonly_fields = ["id"]
fieldsets = (
(
"Dokument",
{"fields": ("titel", "kontext", "paperless_document_id", "beschreibung")},
),
("System", {"fields": ("id",), "classes": ("collapse",)}),
)

View File

@@ -0,0 +1,191 @@
from django.contrib import admin
from ..models import BankTransaction, Rentmeister, StiftungsKonto, Verwaltungskosten
@admin.register(Rentmeister)
class RentmeisterAdmin(admin.ModelAdmin):
list_display = [
"__str__",
"email",
"telefon",
"seit_datum",
"bis_datum",
"aktiv",
"monatliche_verguetung",
]
list_filter = ["aktiv", "seit_datum", "anrede"]
search_fields = ["vorname", "nachname", "email", "telefon", "ort"]
ordering = ["nachname", "vorname"]
readonly_fields = ["id", "erstellt_am", "aktualisiert_am"]
fieldsets = (
("Persönliche Daten", {"fields": ("anrede", "vorname", "nachname", "titel")}),
(
"Kontaktdaten",
{"fields": ("email", "telefon", "mobil", "strasse", "plz", "ort")},
),
(
"Bankdaten",
{"fields": ("iban", "bic", "bank_name"), "classes": ["collapse"]},
),
(
"Stiftungsdaten",
{
"fields": (
"seit_datum",
"bis_datum",
"aktiv",
"monatliche_verguetung",
"km_pauschale",
)
},
),
(
"Zusätzliche Informationen",
{"fields": ("notizen",), "classes": ["collapse"]},
),
(
"System",
{
"fields": ("id", "erstellt_am", "aktualisiert_am"),
"classes": ["collapse"],
},
),
)
@admin.register(StiftungsKonto)
class StiftungsKontoAdmin(admin.ModelAdmin):
list_display = [
"kontoname",
"bank_name",
"konto_typ",
"saldo",
"saldo_datum",
"aktiv",
]
list_filter = ["konto_typ", "aktiv", "bank_name"]
search_fields = ["kontoname", "bank_name", "iban"]
ordering = ["bank_name", "kontoname"]
readonly_fields = ["id", "erstellt_am", "aktualisiert_am"]
fieldsets = (
(
"Kontodaten",
{"fields": ("kontoname", "bank_name", "iban", "bic", "konto_typ")},
),
(
"Finanzdaten",
{"fields": ("saldo", "saldo_datum", "zinssatz", "laufzeit_bis")},
),
("Status", {"fields": ("aktiv", "notizen")}),
(
"System",
{
"fields": ("id", "erstellt_am", "aktualisiert_am"),
"classes": ["collapse"],
},
),
)
@admin.register(Verwaltungskosten)
class VerwaltungskostenAdmin(admin.ModelAdmin):
list_display = [
"bezeichnung",
"kategorie",
"betrag",
"datum",
"status",
"rentmeister",
"konto",
]
list_filter = ["kategorie", "status", "datum", "rentmeister", "konto"]
search_fields = [
"bezeichnung",
"lieferant_firma",
"rechnungsnummer",
"beschreibung",
]
ordering = ["-datum", "-erstellt_am"]
readonly_fields = ["id", "erstellt_am", "aktualisiert_am"]
date_hierarchy = "datum"
fieldsets = (
(
"Grunddaten",
{"fields": ("bezeichnung", "kategorie", "betrag", "datum", "status")},
),
("Zuordnung", {"fields": ("rentmeister", "konto")}),
(
"Lieferant/Rechnung",
{"fields": ("lieferant_firma", "rechnungsnummer"), "classes": ["collapse"]},
),
(
"Fahrtkosten",
{
"fields": ("km_anzahl", "km_satz", "von_ort", "nach_ort", "zweck"),
"classes": ["collapse"],
"description": 'Nur für Kategorie "Fahrtkosten" relevant',
},
),
(
"Zusätzliche Informationen",
{"fields": ("beschreibung", "notizen"), "classes": ["collapse"]},
),
(
"System",
{
"fields": ("id", "erstellt_am", "aktualisiert_am"),
"classes": ["collapse"],
},
),
)
@admin.register(BankTransaction)
class BankTransactionAdmin(admin.ModelAdmin):
list_display = [
"datum",
"konto",
"betrag",
"empfaenger_zahlungspflichtiger",
"transaction_type",
"status",
"verwaltungskosten",
]
list_filter = ["konto", "transaction_type", "status", "datum", "importiert_am"]
search_fields = ["verwendungszweck", "empfaenger_zahlungspflichtiger", "referenz"]
readonly_fields = ["importiert_am", "import_datei"]
ordering = ["-datum", "-importiert_am"]
fieldsets = (
("Basisdaten", {"fields": ("konto", "datum", "valuta", "betrag", "waehrung")}),
(
"Transaktionsdetails",
{
"fields": (
"verwendungszweck",
"empfaenger_zahlungspflichtiger",
"iban_gegenpartei",
"bic_gegenpartei",
"referenz",
"transaction_type",
)
},
),
("Verwaltung", {"fields": ("status", "kommentare", "verwaltungskosten")}),
(
"Import-Information",
{
"fields": ("import_datei", "importiert_am", "saldo_nach_buchung"),
"classes": ("collapse",),
},
),
)
def get_queryset(self, request):
return (
super().get_queryset(request).select_related("konto", "verwaltungskosten")
)

View File

@@ -0,0 +1,69 @@
from django.contrib import admin
from django.db.models import Sum
from django.urls import reverse
from django.utils.html import format_html
from ..models import Foerderung
@admin.register(Foerderung)
class FoerderungAdmin(admin.ModelAdmin):
list_display = [
"destinataer",
"jahr",
"betrag",
"verwendungsnachweis_link",
"total_for_destinataer",
]
list_filter = ["jahr", "destinataer__familienzweig"]
search_fields = [
"destinataer__nachname",
"destinataer__vorname",
"destinataer__familienzweig",
]
ordering = ["-jahr", "-betrag"]
readonly_fields = ["id"]
fieldsets = (
(
"Förderung",
{
"fields": (
"destinataer",
"person",
"jahr",
"betrag",
"kategorie",
"status",
)
},
),
("Dokumentation", {"fields": ("verwendungsnachweis", "bemerkungen")}),
("Daten", {"fields": ("antragsdatum", "entscheidungsdatum")}),
("System", {"fields": ("id",), "classes": ("collapse",)}),
)
def verwendungsnachweis_link(self, obj):
if obj.verwendungsnachweis:
return format_html(
'<a href="{}">{}</a>',
reverse(
"admin:stiftung_dokumentlink_change",
args=[obj.verwendungsnachweis.id],
),
obj.verwendungsnachweis.titel,
)
return "-"
verwendungsnachweis_link.short_description = "Verwendungsnachweis"
def total_for_destinataer(self, obj):
total = (
Foerderung.objects.filter(destinataer=obj.destinataer).aggregate(
Sum("betrag")
)["betrag__sum"]
or 0
)
return f"{total:,.2f}"
total_for_destinataer.short_description = "Gesamt für Destinatär"

206
app/stiftung/admin/land.py Normal file
View File

@@ -0,0 +1,206 @@
from django.contrib import admin
from django.utils.html import format_html
from ..models import Land, LandVerpachtung, Paechter
@admin.register(Paechter)
class PaechterAdmin(admin.ModelAdmin):
list_display = [
"nachname",
"vorname",
"pachtnummer",
"pachtzins_aktuell",
"landwirtschaftliche_ausbildung",
"aktiv",
]
list_filter = ["landwirtschaftliche_ausbildung", "aktiv"]
search_fields = ["nachname", "vorname", "email", "pachtnummer"]
ordering = ["nachname", "vorname"]
readonly_fields = ["id"]
fieldsets = (
(
"Persönliche Daten",
{"fields": ("vorname", "nachname", "geburtsdatum", "email", "telefon")},
),
(
"Pacht-Informationen",
{
"fields": (
"pachtnummer",
"pachtbeginn_erste",
"pachtende_letzte",
"pachtzins_aktuell",
)
},
),
(
"Landwirtschaftliche Qualifikation",
{
"fields": (
"landwirtschaftliche_ausbildung",
"berufserfahrung_jahre",
"spezialisierung",
)
},
),
("Kontaktdaten", {"fields": ("iban", "strasse", "plz", "ort")}),
("Pächter-Typ", {"fields": ("personentyp",)}),
("Zusätzlich", {"fields": ("notizen", "aktiv")}),
("System", {"fields": ("id",), "classes": ("collapse",)}),
)
def iban_display(self, obj):
if obj.iban:
return format_html(
'<span style="font-family: monospace;">{}</span>', obj.iban
)
return "-"
iban_display.short_description = "IBAN"
@admin.register(Land)
class LandAdmin(admin.ModelAdmin):
list_display = [
"lfd_nr",
"gemeinde",
"gemarkung",
"flur",
"flurstueck",
"groesse_qm",
"verp_flaeche_aktuell",
"verpachtungsgrad_display",
"aktiv",
]
list_filter = ["gemeinde", "gemarkung", "aktiv"]
search_fields = ["lfd_nr", "gemeinde", "gemarkung", "flur", "flurstueck"]
ordering = ["gemeinde", "gemarkung", "flur", "flurstueck"]
readonly_fields = ["id", "gesamtflaeche_berechnet", "verpachtungsgrad_berechnet"]
fieldsets = (
("Identifikation", {"fields": ("lfd_nr", "ew_nummer")}),
("Gerichtliche Zuständigkeit", {"fields": ("amtsgericht",)}),
(
"Verwaltungsstruktur",
{"fields": ("gemeinde", "gemarkung", "flur", "flurstueck")},
),
(
"Flächenangaben",
{
"fields": (
"groesse_qm",
"gruenland_qm",
"acker_qm",
"wald_qm",
"sonstiges_qm",
)
},
),
(
"Verpachtung",
{
"fields": (
"verpachtete_gesamtflaeche",
"flaeche_alte_liste",
"verp_flaeche_aktuell",
)
},
),
("Steuern und Abgaben", {"fields": ("anteil_grundsteuer", "anteil_lwk")}),
("Status", {"fields": ("aktiv", "notizen")}),
(
"System",
{
"fields": ("id", "erstellt_am", "aktualisiert_am"),
"classes": ("collapse",),
},
),
)
def verpachtungsgrad_display(self, obj):
grad = obj.get_verpachtungsgrad()
if grad > 90:
color = "green"
elif grad > 70:
color = "orange"
else:
color = "red"
return format_html('<span style="color: {};">{:.1f}%</span>', color, grad)
verpachtungsgrad_display.short_description = "Verpachtungsgrad"
def gesamtflaeche_berechnet(self, obj):
return f"{obj.get_gesamtflaeche():.2f} qm"
gesamtflaeche_berechnet.short_description = "Berechnete Gesamtfläche"
def verpachtungsgrad_berechnet(self, obj):
return f"{obj.get_verpachtungsgrad():.1f}%"
verpachtungsgrad_berechnet.short_description = "Verpachtungsgrad"
@admin.register(LandVerpachtung)
class LandVerpachtungAdmin(admin.ModelAdmin):
list_display = [
"land",
"paechter",
"pachtzins_pauschal",
"pachtbeginn",
"pachtende",
"status_display",
"erstellt_am",
]
list_filter = ["status", "pachtbeginn", "pachtende", "erstellt_am"]
search_fields = ["land__lfd_nr", "land__gemeinde", "paechter__vorname", "paechter__nachname", "vertragsnummer"]
ordering = ["-erstellt_am"]
readonly_fields = ["id", "erstellt_am", "aktualisiert_am"]
fieldsets = (
("Verpachtungsdetails", {
"fields": ("land", "paechter", "vertragsnummer", "status")
}),
("Laufzeit", {
"fields": ("pachtbeginn", "pachtende", "verlaengerung_klausel")
}),
("Fläche", {
"fields": ("verpachtete_flaeche",)
}),
("Pachtzins", {
"fields": ("pachtzins_pauschal", "pachtzins_pro_ha", "zahlungsweise")
}),
("Umsatzsteuer", {
"fields": ("ust_option", "ust_satz"),
"classes": ("collapse",)
}),
("Umlagen", {
"fields": ("grundsteuer_umlage", "versicherungen_umlage", "verbandsbeitraege_umlage", "jagdpacht_anteil_umlage"),
"classes": ("collapse",)
}),
("Zusatzinformationen", {
"fields": ("bemerkungen",),
"classes": ("collapse",)
}),
("System", {
"fields": ("id", "erstellt_am", "aktualisiert_am"),
"classes": ("collapse",)
}),
)
def status_display(self, obj):
colors = {
'aktiv': 'green',
'beendet': 'red',
'geplant': 'orange',
'gekündigt': 'red'
}
color = colors.get(obj.status, 'black')
return format_html(
'<span style="color: {}; font-weight: bold;">{}</span>',
color,
obj.get_status_display()
)
status_display.short_description = "Status"

View File

@@ -0,0 +1,579 @@
from django.contrib import admin
from django.db.models import Sum
from django.utils import timezone
from django.utils.html import format_html
from .. import models
from ..models import (AppConfiguration, AuditLog, BackupJob, CSVImport, Person,
UnterstuetzungWiederkehrend, VierteljahresNachweis)
@admin.register(CSVImport)
class CSVImportAdmin(admin.ModelAdmin):
list_display = [
"import_type",
"filename",
"status",
"total_rows",
"imported_rows",
"failed_rows",
"created_by",
"started_at",
"duration_display",
]
list_filter = ["import_type", "status", "started_at"]
search_fields = ["filename", "created_by"]
readonly_fields = ["id", "started_at", "completed_at", "get_success_rate"]
ordering = ["-started_at"]
fieldsets = (
(
"Grundinformationen",
{"fields": ("import_type", "filename", "file_size", "status")},
),
(
"Ergebnisse",
{
"fields": (
"total_rows",
"imported_rows",
"failed_rows",
"get_success_rate",
"error_log",
)
},
),
("Metadaten", {"fields": ("created_by", "started_at", "completed_at")}),
)
def duration_display(self, obj):
duration = obj.get_duration()
if duration:
return f"{duration.total_seconds():.1f}s"
return "-"
duration_display.short_description = "Dauer"
def get_success_rate(self, obj):
rate = obj.get_success_rate()
if rate >= 90:
color = "success"
elif rate >= 70:
color = "warning"
else:
color = "danger"
return format_html('<span class="badge bg-{}">{:.1f}%</span>', color, rate)
get_success_rate.short_description = "Erfolgsrate"
@admin.register(Person)
class PersonAdmin(admin.ModelAdmin):
list_display = [
"nachname",
"vorname",
"familienzweig",
"geburtsdatum",
"email",
"iban_display",
]
list_filter = ["familienzweig", "geburtsdatum"]
search_fields = ["nachname", "vorname", "email", "familienzweig"]
ordering = ["nachname", "vorname"]
readonly_fields = ["id"]
fieldsets = (
(
"Persönliche Daten",
{"fields": ("vorname", "nachname", "geburtsdatum", "email", "telefon")},
),
("Stiftungsdaten", {"fields": ("familienzweig", "iban", "adresse")}),
("Zusätzlich", {"fields": ("notizen", "aktiv")}),
("System", {"fields": ("id",), "classes": ("collapse",)}),
)
def iban_display(self, obj):
if obj.iban:
return format_html(
'<span style="font-family: monospace;">{}</span>', obj.iban
)
return "-"
iban_display.short_description = "IBAN"
def get_queryset(self, request):
return (
super()
.get_queryset(request)
.annotate(total_foerderungen=Sum("foerderung__betrag"))
)
@admin.register(AuditLog)
class AuditLogAdmin(admin.ModelAdmin):
list_display = [
"timestamp",
"username",
"action",
"entity_type",
"entity_name",
"ip_address",
]
list_filter = ["action", "entity_type", "timestamp", "username"]
search_fields = ["username", "entity_name", "description", "ip_address"]
readonly_fields = [
"id",
"timestamp",
"user",
"username",
"action",
"entity_type",
"entity_id",
"entity_name",
"description",
"changes",
"ip_address",
"user_agent",
"session_key",
]
ordering = ["-timestamp"]
date_hierarchy = "timestamp"
fieldsets = (
(
"Benutzer und Zeit",
{"fields": ("timestamp", "user", "username", "session_key")},
),
(
"Aktion",
{
"fields": (
"action",
"entity_type",
"entity_id",
"entity_name",
"description",
)
},
),
("Änderungsdetails", {"fields": ("changes",), "classes": ["collapse"]}),
(
"Request-Informationen",
{"fields": ("ip_address", "user_agent"), "classes": ["collapse"]},
),
("System", {"fields": ("id",), "classes": ["collapse"]}),
)
def has_add_permission(self, request):
return False # Don't allow manual creation
def has_change_permission(self, request, obj=None):
return False # Don't allow editing
@admin.register(BackupJob)
class BackupJobAdmin(admin.ModelAdmin):
list_display = [
"created_at",
"backup_type",
"status",
"backup_size_display",
"duration_display",
"created_by",
]
list_filter = ["backup_type", "status", "created_at"]
search_fields = ["backup_filename", "created_by__username"]
readonly_fields = [
"id",
"created_at",
"started_at",
"completed_at",
"backup_size",
"get_duration",
]
ordering = ["-created_at"]
fieldsets = (
("Job-Details", {"fields": ("backup_type", "status", "created_by")}),
(
"Zeitpunkte",
{"fields": ("created_at", "started_at", "completed_at", "get_duration")},
),
(
"Ergebnis",
{
"fields": (
"backup_filename",
"backup_size",
"database_size",
"files_count",
)
},
),
("Fehlerbehandlung", {"fields": ("error_message",), "classes": ["collapse"]}),
("System", {"fields": ("id",), "classes": ["collapse"]}),
)
def backup_size_display(self, obj):
return obj.get_size_display()
backup_size_display.short_description = "Backup-Größe"
def duration_display(self, obj):
duration = obj.get_duration()
if duration:
return f"{duration.total_seconds():.1f}s"
return "-"
duration_display.short_description = "Dauer"
def has_add_permission(self, request):
return False # Use the web interface for creating backups
@admin.register(AppConfiguration)
class AppConfigurationAdmin(admin.ModelAdmin):
list_display = [
"display_name",
"key",
"value_display",
"category",
"setting_type",
"is_active",
"updated_at",
]
list_filter = ["category", "setting_type", "is_active"]
search_fields = ["key", "display_name", "description"]
readonly_fields = ["id", "created_at", "updated_at"]
ordering = ["category", "order", "display_name"]
fieldsets = (
(
"Basic Information",
{
"fields": (
"key",
"display_name",
"description",
"category",
"setting_type",
)
},
),
("Value Configuration", {"fields": ("value", "default_value")}),
("Options", {"fields": ("is_active", "is_system", "order")}),
(
"Metadata",
{"fields": ("created_at", "updated_at"), "classes": ("collapse",)},
),
)
def value_display(self, obj):
"""Display value with type formatting"""
value = obj.value
if obj.setting_type == "boolean":
icon = "" if obj.get_typed_value() else ""
return format_html("{} {}", icon, value)
elif obj.setting_type == "url":
return format_html(
'<a href="{}" target="_blank">{}</a>',
value,
value[:50] + "..." if len(value) > 50 else value,
)
elif len(value) > 100:
return value[:100] + "..."
return value
value_display.short_description = "Current Value"
def get_readonly_fields(self, request, obj=None):
readonly = list(self.readonly_fields)
if obj and obj.is_system:
readonly.extend(["key", "setting_type", "is_system"])
return readonly
@admin.register(models.HelpBox)
class HelpBoxAdmin(admin.ModelAdmin):
list_display = [
"get_page_display",
"title",
"is_active",
"updated_at",
"updated_by",
]
list_filter = ["page_key", "is_active", "updated_at"]
search_fields = ["title", "content"]
fieldsets = (
("Grundinformationen", {"fields": ("page_key", "title", "is_active")}),
(
"Inhalt",
{
"fields": ("content",),
"description": "Markdown wird unterstützt: **fett**, *kursiv*, `code`, [Link](url), Listen mit - oder 1.",
},
),
(
"Metadaten",
{
"fields": ("created_at", "updated_at", "created_by", "updated_by"),
"classes": ("collapse",),
},
),
)
readonly_fields = ["created_at", "updated_at"]
def get_page_display(self, obj):
return obj.get_page_key_display()
get_page_display.short_description = "Seite"
def save_model(self, request, obj, form, change):
if not change: # Neues Objekt
obj.created_by = request.user.username
obj.updated_by = request.user.username
super().save_model(request, obj, form, change)
@admin.register(UnterstuetzungWiederkehrend)
class UnterstuetzungWiederkehrendAdmin(admin.ModelAdmin):
list_display = [
"__str__",
"destinataer",
"betrag",
"intervall",
"aktiv",
"naechste_generierung",
]
list_filter = ["intervall", "aktiv", "erstellt_am"]
search_fields = [
"destinataer__vorname",
"destinataer__nachname",
"beschreibung",
"empfaenger_name",
]
readonly_fields = ["id", "erstellt_am"]
fieldsets = (
(
"Grundinformationen",
{
"fields": (
"destinataer",
"konto",
"betrag",
"intervall",
"beschreibung",
"aktiv",
)
},
),
(
"Überweisungsdaten",
{"fields": ("empfaenger_iban", "empfaenger_name", "verwendungszweck")},
),
(
"Zeitplanung",
{
"fields": (
"erste_zahlung_am",
"letzte_zahlung_am",
"naechste_generierung",
)
},
),
(
"Metadaten",
{"fields": ("id", "erstellt_von", "erstellt_am"), "classes": ("collapse",)},
),
)
@admin.register(VierteljahresNachweis)
class VierteljahresNachweisAdmin(admin.ModelAdmin):
list_display = [
"destinataer",
"jahr",
"quartal",
"status",
"completion_percentage",
"faelligkeitsdatum",
"is_overdue_display",
"eingereicht_am",
"geprueft_von",
]
list_filter = [
"jahr",
"quartal",
"status",
"studiennachweis_erforderlich",
"studiennachweis_eingereicht",
"einkommenssituation_bestaetigt",
"vermogenssituation_bestaetigt",
"faelligkeitsdatum",
]
search_fields = [
"destinataer__vorname",
"destinataer__nachname",
"destinataer__email",
]
readonly_fields = [
"id",
"erstellt_am",
"aktualisiert_am",
"completion_percentage",
"is_overdue_display",
]
ordering = ["-jahr", "-quartal", "destinataer__nachname"]
fieldsets = (
(
"Grundinformationen",
{
"fields": (
"destinataer",
"jahr",
"quartal",
"status",
"faelligkeitsdatum",
)
},
),
(
"Studiennachweis",
{
"fields": (
"studiennachweis_erforderlich",
"studiennachweis_eingereicht",
"studiennachweis_datei",
"studiennachweis_bemerkung",
),
"classes": ("collapse",),
},
),
(
"Einkommenssituation",
{
"fields": (
"einkommenssituation_bestaetigt",
"einkommenssituation_text",
"einkommenssituation_datei",
),
"classes": ("collapse",),
},
),
(
"Vermögenssituation",
{
"fields": (
"vermogenssituation_bestaetigt",
"vermogenssituation_text",
"vermogenssituation_datei",
),
"classes": ("collapse",),
},
),
(
"Weitere Dokumente",
{
"fields": (
"weitere_dokumente",
"weitere_dokumente_beschreibung",
),
"classes": ("collapse",),
},
),
(
"Verwaltung & Prüfung",
{
"fields": (
"interne_notizen",
"eingereicht_am",
"geprueft_am",
"geprueft_von",
),
"classes": ("collapse",),
},
),
(
"Metadaten",
{
"fields": (
"id",
"erstellt_am",
"aktualisiert_am",
"completion_percentage",
"is_overdue_display",
)
},
),
)
def completion_percentage(self, obj):
"""Show completion percentage as colored badge"""
percentage = obj.get_completion_percentage()
if percentage == 100:
color = "success"
elif percentage >= 70:
color = "info"
elif percentage >= 30:
color = "warning"
else:
color = "danger"
return format_html(
'<span class="badge bg-{}">{} %</span>',
color,
percentage
)
completion_percentage.short_description = "Fortschritt"
def is_overdue_display(self, obj):
"""Display overdue status with icon"""
if obj.is_overdue():
return format_html(
'<span class="text-danger"><i class="fas fa-exclamation-triangle"></i> Ja</span>'
)
return format_html(
'<span class="text-success"><i class="fas fa-check"></i> Nein</span>'
)
is_overdue_display.short_description = "Überfällig"
actions = ["mark_as_approved", "mark_as_needs_revision"]
def mark_as_approved(self, request, queryset):
"""Bulk action to approve submitted confirmations"""
count = 0
for nachweis in queryset.filter(status="eingereicht"):
nachweis.status = "geprueft"
nachweis.geprueft_am = timezone.now()
nachweis.geprueft_von = request.user
nachweis.save()
count += 1
if count:
self.message_user(
request,
f"{count} Nachweise wurden als geprüft und freigegeben markiert."
)
else:
self.message_user(
request,
"Keine eingereichten Nachweise gefunden.",
level="warning"
)
mark_as_approved.short_description = "Ausgewählte Nachweise freigeben"
def mark_as_needs_revision(self, request, queryset):
"""Bulk action to mark confirmations as needing revision"""
count = queryset.exclude(status__in=["offen", "nachbesserung"]).update(
status="nachbesserung"
)
if count:
self.message_user(
request,
f"{count} Nachweise wurden als nachbesserungsbedürftig markiert."
)
mark_as_needs_revision.short_description = "Nachbesserung erforderlich markieren"

View File

@@ -0,0 +1,190 @@
from django import forms
from django.contrib import admin
from django.utils.html import format_html
from ..models import BriefVorlage, Veranstaltung, Veranstaltungsteilnehmer
class VeranstaltungsteilnehmerInline(admin.TabularInline):
model = Veranstaltungsteilnehmer
extra = 1
fields = [
"anrede", "vorname", "nachname", "strasse", "plz", "ort",
"email", "rsvp_status", "bemerkungen",
]
class BriefVorlageWidget(forms.Textarea):
"""Erweitertes Textarea-Widget für HTML-Briefvorlagen mit Editor-Panel und Platzhalter-Hilfe."""
class Media:
js = ["stiftung/js/briefvorlage_editor.js"]
def __init__(self, attrs=None):
default_attrs = {"rows": 18, "cols": 80, "class": "briefvorlage-textarea", "style": "font-family: monospace; font-size: 13px;"}
if attrs:
default_attrs.update(attrs)
super().__init__(attrs=default_attrs)
class VeranstaltungAdminForm(forms.ModelForm):
class Meta:
model = Veranstaltung
fields = "__all__"
widgets = {
"briefvorlage": BriefVorlageWidget(),
}
@admin.register(Veranstaltung)
class VeranstaltungAdmin(admin.ModelAdmin):
form = VeranstaltungAdminForm
list_display = [
"titel", "datum", "uhrzeit", "ort", "status",
"get_teilnehmer_count", "get_zugesagte_count", "budget_pro_person",
]
list_filter = ["status", "datum"]
search_fields = ["titel", "ort", "beschreibung"]
ordering = ["-datum"]
readonly_fields = ["id", "erstellt_am", "aktualisiert_am", "serienbrief_aktionen", "platzhalter_dokumentation"]
inlines = [VeranstaltungsteilnehmerInline]
fieldsets = (
("Grunddaten", {"fields": ("titel", "datum", "uhrzeit", "status")}),
("Veranstaltungsort", {"fields": ("ort", "adresse")}),
("Details", {"fields": ("beschreibung", "budget_pro_person")}),
(
"Serienbrief Vorlage",
{
"fields": (
"platzhalter_dokumentation",
"betreff",
"briefvorlage",
),
},
),
(
"Serienbrief Unterschriften & Aktionen",
{
"fields": (
"unterschrift_1_name", "unterschrift_1_titel",
"unterschrift_2_name", "unterschrift_2_titel",
"serienbrief_aktionen",
),
},
),
("System", {"fields": ("id", "erstellt_am", "aktualisiert_am"), "classes": ("collapse",)}),
)
def get_teilnehmer_count(self, obj):
return obj.get_teilnehmer_count()
get_teilnehmer_count.short_description = "Teilnehmer gesamt"
def get_zugesagte_count(self, obj):
return obj.get_zugesagte_count()
get_zugesagte_count.short_description = "Zugesagt"
def platzhalter_dokumentation(self, obj):
return format_html(
"""<div class="help" style="background:#f8f9fa;border:1px solid #dee2e6;border-radius:4px;padding:10px 14px;margin-bottom:4px;">
<strong>Verfügbare Platzhalter im Brieftext:</strong><br>
<table style="margin-top:6px;border-collapse:collapse;font-size:13px;">
<tr><td style="padding:2px 12px 2px 0;font-family:monospace;color:#d63384;">{{{{ anrede }}}}</td><td>Anredetitel (Herr / Frau)</td></tr>
<tr><td style="padding:2px 12px 2px 0;font-family:monospace;color:#d63384;">{{{{ vorname }}}}</td><td>Vorname des Empfängers</td></tr>
<tr><td style="padding:2px 12px 2px 0;font-family:monospace;color:#d63384;">{{{{ nachname }}}}</td><td>Nachname des Empfängers</td></tr>
<tr><td style="padding:2px 12px 2px 0;font-family:monospace;color:#d63384;">{{{{ strasse }}}}</td><td>Straße und Hausnummer</td></tr>
<tr><td style="padding:2px 12px 2px 0;font-family:monospace;color:#d63384;">{{{{ plz }}}}</td><td>Postleitzahl</td></tr>
<tr><td style="padding:2px 12px 2px 0;font-family:monospace;color:#d63384;">{{{{ ort }}}}</td><td>Wohnort des Empfängers</td></tr>
<tr><td style="padding:2px 12px 2px 0;font-family:monospace;color:#d63384;">{{{{ datum }}}}</td><td>Datum der Veranstaltung (z.B. Freitag, 17. April 2026)</td></tr>
<tr><td style="padding:2px 12px 2px 0;font-family:monospace;color:#d63384;">{{{{ uhrzeit }}}}</td><td>Uhrzeit der Veranstaltung (z.B. 19:00 Uhr)</td></tr>
<tr><td style="padding:2px 12px 2px 0;font-family:monospace;color:#d63384;">{{{{ veranstaltungsort }}}}</td><td>Name des Veranstaltungsorts / Gasthaus</td></tr>
<tr><td style="padding:2px 12px 2px 0;font-family:monospace;color:#d63384;">{{{{ gasthaus_adresse }}}}</td><td>Adresse des Gasthauses</td></tr>
</table>
<div style="margin-top:8px;font-size:12px;color:#6c757d;">
Platzhalter werden beim PDF-Export automatisch mit den Empfänger- und Veranstaltungsdaten befüllt.
Tipp: Vorlagen unter <a href="/admin/stiftung/briefvorlage/" target="_blank">Verwaltung → Briefvorlagen</a> speichern und wiederverwenden.
</div>
</div>"""
)
platzhalter_dokumentation.short_description = "Platzhalter-Dokumentation"
platzhalter_dokumentation.allow_tags = True
def serienbrief_aktionen(self, obj):
if obj.pk:
from django.urls import reverse as url_reverse
pdf_url = url_reverse("stiftung:veranstaltung_serienbrief_pdf", args=[obj.pk])
vorschau_url = url_reverse("stiftung:veranstaltung_serienbrief_vorschau", args=[obj.pk])
return format_html(
'<a href="{}" target="_blank" class="button" style="margin-right:8px;">Serienbrief-PDF generieren</a>'
'<a href="{}" target="_blank" class="button default">Vorschau im Browser</a>',
pdf_url, vorschau_url,
)
return ""
serienbrief_aktionen.short_description = "Aktionen"
actions = ["generate_serienbrief"]
def generate_serienbrief(self, request, queryset):
if queryset.count() != 1:
self.message_user(
request,
"Bitte genau eine Veranstaltung auswählen.",
level="error",
)
return
from django.urls import reverse as url_reverse
from django.shortcuts import redirect
veranstaltung = queryset.first()
url = url_reverse("stiftung:veranstaltung_serienbrief_pdf", args=[veranstaltung.pk])
return redirect(url)
generate_serienbrief.short_description = "Serienbrief-PDF generieren"
@admin.register(BriefVorlage)
class BriefVorlageAdmin(admin.ModelAdmin):
list_display = ["name", "beschreibung_kurz", "erstellt_am", "aktualisiert_am"]
search_fields = ["name", "beschreibung"]
ordering = ["name"]
readonly_fields = ["erstellt_am", "aktualisiert_am"]
fieldsets = (
(None, {"fields": ("name", "beschreibung")}),
(
"Briefinhalt",
{
"fields": ("betreff", "briefvorlage"),
"description": (
"Verfügbare Platzhalter: {{ anrede }}, {{ vorname }}, {{ nachname }}, "
"{{ strasse }}, {{ plz }}, {{ ort }}, {{ datum }}, {{ uhrzeit }}, "
"{{ veranstaltungsort }}, {{ gasthaus_adresse }}"
),
},
),
("System", {"fields": ("erstellt_am", "aktualisiert_am"), "classes": ("collapse",)}),
)
def beschreibung_kurz(self, obj):
return obj.beschreibung[:80] + "" if len(obj.beschreibung) > 80 else obj.beschreibung
beschreibung_kurz.short_description = "Beschreibung"
@admin.register(Veranstaltungsteilnehmer)
class VeranstaltungsteilnehmerAdmin(admin.ModelAdmin):
list_display = [
"nachname", "vorname", "anrede", "veranstaltung", "rsvp_status", "ort",
]
list_filter = ["rsvp_status", "veranstaltung", "anrede"]
search_fields = ["vorname", "nachname", "ort", "email"]
ordering = ["veranstaltung", "nachname", "vorname"]
readonly_fields = ["id", "erstellt_am"]
fieldsets = (
("Zuordnung", {"fields": ("veranstaltung", "paechter", "destinataer")}),
(
"Persönliche Daten",
{"fields": ("anrede", "vorname", "nachname", "email")},
),
("Adresse", {"fields": ("strasse", "plz", "ort")}),
("RSVP", {"fields": ("rsvp_status", "bemerkungen")}),
("System", {"fields": ("id", "erstellt_am"), "classes": ("collapse",)}),
)

View File

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

View 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]}"

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

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

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

View File

@@ -389,7 +389,11 @@ def restore_database(db_backup_file):
from django.db import connection
with connection.cursor() as cursor:
# Check some key tables
test_tables = ['stiftung_person', 'stiftung_land', 'stiftung_destinataer']
test_tables = [
'stiftung_person', 'stiftung_land', 'stiftung_destinataer',
'stiftung_dokumentdatei', 'stiftung_emaileingang',
'stiftung_verwaltungskosten', 'stiftung_geschichteseite',
]
for table in test_tables:
try:
cursor.execute(f"SELECT COUNT(*) FROM {table}")

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,58 @@
from .destinataere import (DestinataerForm, DestinataerNotizForm,
DestinataerUnterstuetzungForm,
UnterstuetzungForm, UnterstuetzungMarkAsPaidForm,
UnterstuetzungWiederkehrendForm,
VierteljahresNachweisForm)
from .dokumente import DokumentLinkForm
from .finanzen import (BankImportForm, BankTransactionForm, RentmeisterForm,
StiftungsKontoForm, VerwaltungskostenForm)
from .foerderung import FoerderungForm
from .geschichte import GeschichteBildForm, GeschichteSeiteForm
from .land import LandAbrechnungForm, LandForm, LandVerpachtungForm, PaechterForm
from .system import (BackupTokenRegenerateForm, PasswordChangeForm, PersonForm,
TwoFactorDisableForm, TwoFactorSetupForm,
TwoFactorVerifyForm, UserCreationForm, UserPermissionForm,
UserUpdateForm)
from .veranstaltung import VeranstaltungForm, VeranstaltungsteilnehmerForm
__all__ = [
# destinataere
"DestinataerForm",
"DestinataerNotizForm",
"DestinataerUnterstuetzungForm",
"UnterstuetzungForm",
"UnterstuetzungMarkAsPaidForm",
"UnterstuetzungWiederkehrendForm",
"VierteljahresNachweisForm",
# dokumente
"DokumentLinkForm",
# finanzen
"BankImportForm",
"BankTransactionForm",
"RentmeisterForm",
"StiftungsKontoForm",
"VerwaltungskostenForm",
# foerderung
"FoerderungForm",
# geschichte
"GeschichteBildForm",
"GeschichteSeiteForm",
# land
"LandAbrechnungForm",
"LandForm",
"LandVerpachtungForm",
"PaechterForm",
# system
"BackupTokenRegenerateForm",
"PasswordChangeForm",
"PersonForm",
"TwoFactorDisableForm",
"TwoFactorSetupForm",
"TwoFactorVerifyForm",
"UserCreationForm",
"UserPermissionForm",
"UserUpdateForm",
# veranstaltung
"VeranstaltungForm",
"VeranstaltungsteilnehmerForm",
]

View File

@@ -0,0 +1,439 @@
from django import forms
from django.utils import timezone
from ..models import (Destinataer, DestinataerNotiz, DestinataerUnterstuetzung,
UnterstuetzungWiederkehrend, VierteljahresNachweis)
from django.core.exceptions import ValidationError
class DestinataerForm(forms.ModelForm):
"""Form für das Erstellen und Bearbeiten von Destinatären"""
class Meta:
model = Destinataer
fields = "__all__"
widgets = {
"anrede": forms.Select(attrs={"class": "form-select"}),
"vorname": forms.TextInput(attrs={"class": "form-control"}),
"nachname": forms.TextInput(attrs={"class": "form-control"}),
"titel": forms.TextInput(attrs={"class": "form-control"}),
"strasse": forms.TextInput(attrs={"class": "form-control"}),
"plz": forms.TextInput(attrs={"class": "form-control"}),
"ort": forms.TextInput(attrs={"class": "form-control"}),
"telefon": forms.TextInput(attrs={"class": "form-control"}),
"mobil": forms.TextInput(attrs={"class": "form-control"}),
"email": forms.EmailInput(attrs={"class": "form-control"}),
"aktiv": forms.CheckboxInput(attrs={"class": "form-check-input"}),
"notizen": forms.Textarea(attrs={"class": "form-control", "rows": 3}),
"ist_abkoemmling": forms.CheckboxInput(attrs={"class": "form-check-input"}),
"haushaltsgroesse": forms.NumberInput(
attrs={"class": "form-control", "min": 1}
),
"vermoegen": forms.NumberInput(
attrs={"class": "form-control", "step": "0.01"}
),
"unterstuetzung_bestaetigt": forms.CheckboxInput(
attrs={"class": "form-check-input"}
),
"standard_konto": forms.Select(attrs={"class": "form-select"}, choices=[(None, "---")] + [(c.pk, str(c)) for c in getattr(Destinataer, 'konten_queryset', lambda: [])()]),
"vierteljaehrlicher_betrag": forms.NumberInput(
attrs={"class": "form-control", "step": "0.01"}
),
"studiennachweis_erforderlich": forms.CheckboxInput(
attrs={"class": "form-check-input"}
),
"letzter_studiennachweis": forms.DateInput(
attrs={"class": "form-control", "type": "date"}
),
"familienzweig": forms.Select(attrs={"class": "form-select"}),
"berufsgruppe": forms.Select(attrs={"class": "form-select"}),
}
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
for field_name, field in self.fields.items():
if field_name not in ["vorname", "nachname"]:
field.required = False
# Set choices for familienzweig, berufsgruppe and anrede to match model
self.fields["familienzweig"].choices = [("", "Bitte wählen...")] + list(Destinataer.FAMILIENZWIG_CHOICES)
self.fields["berufsgruppe"].choices = [("", "Bitte wählen...")] + list(Destinataer.BERUFSGRUPPE_CHOICES)
if "anrede" in self.fields:
self.fields["anrede"].choices = [("", "Bitte wählen...")] + list(Destinataer.ANREDE_CHOICES)
# Set choices for standard_konto to allow blank
self.fields["standard_konto"].empty_label = "---"
class DestinataerUnterstuetzungForm(forms.ModelForm):
"""Form für geplante/ausgeführte Destinatärunterstützungen"""
class Meta:
model = DestinataerUnterstuetzung
fields = [
"destinataer",
"konto",
"betrag",
"faellig_am",
"status",
"beschreibung",
"empfaenger_iban",
"empfaenger_name",
"verwendungszweck",
]
widgets = {
"destinataer": forms.Select(attrs={"class": "form-select"}),
"konto": forms.Select(attrs={"class": "form-select"}),
"betrag": forms.NumberInput(
attrs={"class": "form-control", "step": "0.01"}
),
"faellig_am": forms.DateInput(
attrs={"class": "form-control", "type": "date"}
),
"status": forms.Select(attrs={"class": "form-select"}),
"beschreibung": forms.TextInput(attrs={"class": "form-control"}),
"empfaenger_iban": forms.TextInput(
attrs={"class": "form-control", "placeholder": "DE89 3704 0044 0532 0130 00"}
),
"empfaenger_name": forms.TextInput(
attrs={"class": "form-control", "placeholder": "Max Mustermann"}
),
"verwendungszweck": forms.TextInput(
attrs={"class": "form-control", "placeholder": "Vierteljährliche Unterstützung Q1/2025"}
),
}
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
# Make faellig_am read-only for automatically generated quarterly payments
self.is_auto_generated = False
if self.instance and self.instance.pk and self.instance.beschreibung:
if "Vierteljährliche Unterstützung" in self.instance.beschreibung and "(automatisch erstellt)" in self.instance.beschreibung:
self.is_auto_generated = True
# Use a TextInput widget with readonly attribute to display the date
from django import forms
current_date = self.instance.faellig_am
if current_date:
self.fields['faellig_am'].widget = forms.TextInput(
attrs={
"class": "form-control",
"readonly": True,
"value": current_date.strftime('%d.%m.%Y'), # German date format
"style": "background-color: #f8f9fa; cursor: not-allowed;"
}
)
self.fields['faellig_am'].initial = current_date
self.fields['faellig_am'].help_text = "Fälligkeitsdatum wird automatisch basierend auf Quartal berechnet"
def clean(self):
cleaned_data = super().clean()
# For auto-generated payments, preserve the original due date
if self.is_auto_generated and self.instance and self.instance.pk:
cleaned_data['faellig_am'] = self.instance.faellig_am
return cleaned_data
class DestinataerNotizForm(forms.ModelForm):
class Meta:
model = DestinataerNotiz
fields = ["titel", "text", "datei"]
widgets = {
"titel": forms.TextInput(
attrs={
"class": "form-control",
"placeholder": "z.B. Telefonat vom 29.08.2025",
}
),
"text": forms.Textarea(
attrs={
"class": "form-control",
"rows": 5,
"placeholder": "Notiztext...",
}
),
"datei": forms.ClearableFileInput(attrs={"class": "form-control"}),
}
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
# Make all fields optional
self.fields["datei"].required = False
self.fields["titel"].required = False
self.fields["text"].required = False
def clean(self):
cleaned = super().clean()
titel = cleaned.get("titel", "").strip()
text = cleaned.get("text", "").strip()
if not (titel or text):
raise forms.ValidationError(
"Bitte geben Sie einen Titel oder einen Text ein."
)
return cleaned
class UnterstuetzungForm(forms.ModelForm):
"""Form für das Erstellen und Bearbeiten von Unterstützungen"""
# Special field for creating recurring payments
ist_wiederkehrend = forms.BooleanField(
required=False,
label="Wiederkehrende Zahlung",
help_text="Aktivieren Sie diese Option um automatisch wiederkehrende Zahlungen zu erstellen",
)
intervall = forms.ChoiceField(
choices=[("", "--- Wählen Sie ein Intervall ---")]
+ UnterstuetzungWiederkehrend.INTERVALL_CHOICES,
required=False,
widget=forms.Select(attrs={"class": "form-select"}),
label="Zahlungsintervall",
)
letzte_zahlung_am = forms.DateField(
required=False,
widget=forms.DateInput(attrs={"class": "form-control", "type": "date"}),
label="Letzte Zahlung am (optional)",
help_text="Leer lassen für unbegrenzte Wiederholung",
)
class Meta:
model = DestinataerUnterstuetzung
fields = [
"destinataer",
"konto",
"faellig_am",
"betrag",
"status",
"beschreibung",
"empfaenger_iban",
"empfaenger_name",
"verwendungszweck",
]
widgets = {
"destinataer": forms.Select(attrs={"class": "form-select"}),
"konto": forms.Select(attrs={"class": "form-select"}),
"faellig_am": forms.DateInput(
attrs={"class": "form-control", "type": "date"}
),
"betrag": forms.NumberInput(
attrs={"class": "form-control", "step": "0.01"}
),
"status": forms.Select(attrs={"class": "form-select"}),
"beschreibung": forms.TextInput(attrs={"class": "form-control"}),
"empfaenger_iban": forms.TextInput(
attrs={
"class": "form-control",
"placeholder": "DE89 3704 0044 0532 0130 00",
}
),
"empfaenger_name": forms.TextInput(attrs={"class": "form-control"}),
"verwendungszweck": forms.TextInput(
attrs={"class": "form-control", "maxlength": "140"}
),
}
labels = {
"destinataer": "Destinatär",
"konto": "Zahlungskonto",
"faellig_am": "Fällig am",
"betrag": "Betrag (€)",
"status": "Status",
"beschreibung": "Beschreibung",
"empfaenger_iban": "Empfänger IBAN",
"empfaenger_name": "Empfänger Name",
"verwendungszweck": "Verwendungszweck",
}
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
# Add onchange event to destinataer field for AJAX IBAN fetching
self.fields["destinataer"].widget.attrs["onchange"] = "updateDestinataerInfo()"
def clean(self):
cleaned_data = super().clean()
ist_wiederkehrend = cleaned_data.get("ist_wiederkehrend")
intervall = cleaned_data.get("intervall")
if ist_wiederkehrend and not intervall:
raise forms.ValidationError(
"Bitte wählen Sie ein Zahlungsintervall für wiederkehrende Zahlungen."
)
return cleaned_data
class UnterstuetzungWiederkehrendForm(forms.ModelForm):
"""Form für das Bearbeiten von wiederkehrenden Unterstützungsvorlagen"""
class Meta:
model = UnterstuetzungWiederkehrend
fields = [
"destinataer",
"konto",
"betrag",
"intervall",
"beschreibung",
"empfaenger_iban",
"empfaenger_name",
"verwendungszweck",
"erste_zahlung_am",
"letzte_zahlung_am",
"aktiv",
]
widgets = {
"destinataer": forms.Select(attrs={"class": "form-select"}),
"konto": forms.Select(attrs={"class": "form-select"}),
"betrag": forms.NumberInput(
attrs={"class": "form-control", "step": "0.01"}
),
"intervall": forms.Select(attrs={"class": "form-select"}),
"beschreibung": forms.TextInput(attrs={"class": "form-control"}),
"empfaenger_iban": forms.TextInput(
attrs={
"class": "form-control",
"placeholder": "DE89 3704 0044 0532 0130 00",
}
),
"empfaenger_name": forms.TextInput(attrs={"class": "form-control"}),
"verwendungszweck": forms.TextInput(
attrs={"class": "form-control", "maxlength": "140"}
),
"erste_zahlung_am": forms.DateInput(
attrs={"class": "form-control", "type": "date"}
),
"letzte_zahlung_am": forms.DateInput(
attrs={"class": "form-control", "type": "date"}
),
"aktiv": forms.CheckboxInput(attrs={"class": "form-check-input"}),
}
class UnterstuetzungMarkAsPaidForm(forms.Form):
"""Simple form to mark an Unterstützung as paid"""
ausgezahlt_am = forms.DateField(
widget=forms.DateInput(attrs={"class": "form-control", "type": "date"}),
label="Ausgezahlt am",
initial=timezone.now().date(),
)
bemerkung = forms.CharField(
widget=forms.Textarea(attrs={"class": "form-control", "rows": 3}),
label="Bemerkung (optional)",
required=False,
help_text="Optionale Notiz zur Zahlung",
)
class VierteljahresNachweisForm(forms.ModelForm):
"""Form for quarterly confirmations (Vierteljahresnachweise)"""
class Meta:
model = VierteljahresNachweis
fields = [
'studiennachweis_eingereicht',
'studiennachweis_datei',
'studiennachweis_bemerkung',
'einkommenssituation_bestaetigt',
'einkommenssituation_text',
'einkommenssituation_datei',
'vermogenssituation_bestaetigt',
'vermogenssituation_text',
'vermogenssituation_datei',
'weitere_dokumente',
'weitere_dokumente_beschreibung',
'interne_notizen',
]
widgets = {
'studiennachweis_eingereicht': forms.CheckboxInput(attrs={'class': 'form-check-input'}),
'studiennachweis_datei': forms.FileInput(attrs={'class': 'form-control', 'accept': '.pdf,.jpg,.jpeg,.png,.doc,.docx'}),
'studiennachweis_bemerkung': forms.Textarea(attrs={'class': 'form-control', 'rows': 3}),
'einkommenssituation_bestaetigt': forms.CheckboxInput(attrs={'class': 'form-check-input'}),
'einkommenssituation_text': forms.Textarea(attrs={'class': 'form-control', 'rows': 3, 'placeholder': 'Z.B. "Keine Änderungen seit letzter Meldung"'}),
'einkommenssituation_datei': forms.FileInput(attrs={'class': 'form-control', 'accept': '.pdf,.jpg,.jpeg,.png,.doc,.docx'}),
'vermogenssituation_bestaetigt': forms.CheckboxInput(attrs={'class': 'form-check-input'}),
'vermogenssituation_text': forms.Textarea(attrs={'class': 'form-control', 'rows': 3, 'placeholder': 'Z.B. "Keine Änderungen seit letzter Meldung"'}),
'vermogenssituation_datei': forms.FileInput(attrs={'class': 'form-control', 'accept': '.pdf,.jpg,.jpeg,.png,.doc,.docx'}),
'weitere_dokumente': forms.FileInput(attrs={'class': 'form-control', 'accept': '.pdf,.jpg,.jpeg,.png,.doc,.docx'}),
'weitere_dokumente_beschreibung': forms.Textarea(attrs={'class': 'form-control', 'rows': 2}),
'interne_notizen': forms.Textarea(attrs={'class': 'form-control', 'rows': 3}),
}
labels = {
'studiennachweis_erforderlich': 'Studiennachweis erforderlich',
'studiennachweis_eingereicht': 'Studiennachweis eingereicht',
'studiennachweis_datei': 'Studiennachweis (Datei)',
'studiennachweis_bemerkung': 'Bemerkung zum Studiennachweis',
'einkommenssituation_bestaetigt': 'Einkommenssituation bestätigt',
'einkommenssituation_text': 'Einkommenssituation (Text)',
'einkommenssituation_datei': 'Einkommenssituation (Datei)',
'vermogenssituation_bestaetigt': 'Vermögenssituation bestätigt',
'vermogenssituation_text': 'Vermögenssituation (Text)',
'vermogenssituation_datei': 'Vermögenssituation (Datei)',
'weitere_dokumente': 'Weitere Dokumente',
'weitere_dokumente_beschreibung': 'Beschreibung weitere Dokumente',
'interne_notizen': 'Interne Notizen (nur für Verwaltung)',
}
help_texts = {
'einkommenssituation_text': 'Z.B. "Keine Änderungen seit letzter Meldung" oder Details zu Änderungen',
'vermogenssituation_text': 'Z.B. "Keine Änderungen seit letzter Meldung" oder Details zu Änderungen',
'interne_notizen': 'Diese Notizen sind nur für die interne Verwaltung sichtbar',
}
def clean(self):
cleaned_data = super().clean()
# Validate that at least one form of confirmation is provided for income situation
einkommenssituation_text = cleaned_data.get('einkommenssituation_text')
einkommenssituation_datei = cleaned_data.get('einkommenssituation_datei')
einkommenssituation_bestaetigt = cleaned_data.get('einkommenssituation_bestaetigt')
# DMS-Dokumente aus POST-Daten beruecksichtigen (werden parallel zum Formular gesendet)
has_einkommens_dms = (
self.instance and self.instance.pk and
bool(self.instance.einkommenssituation_dms_dokument_id)
)
if einkommenssituation_bestaetigt and not einkommenssituation_text and not einkommenssituation_datei and not has_einkommens_dms:
raise ValidationError(
'Wenn die Einkommenssituation bestätigt wird, muss entweder ein Text, eine Datei oder ein DMS-Dokument angegeben werden.'
)
# Validate that at least one form of confirmation is provided for asset situation
vermogenssituation_text = cleaned_data.get('vermogenssituation_text')
vermogenssituation_datei = cleaned_data.get('vermogenssituation_datei')
vermogenssituation_bestaetigt = cleaned_data.get('vermogenssituation_bestaetigt')
has_vermogens_dms = (
self.instance and self.instance.pk and
bool(self.instance.vermogenssituation_dms_dokument_id)
)
if vermogenssituation_bestaetigt and not vermogenssituation_text and not vermogenssituation_datei and not has_vermogens_dms:
raise ValidationError(
'Wenn die Vermögenssituation bestätigt wird, muss entweder ein Text, eine Datei oder ein DMS-Dokument angegeben werden.'
)
# Validate study proof if required and marked as submitted
studiennachweis_erforderlich = cleaned_data.get('studiennachweis_erforderlich')
studiennachweis_eingereicht = cleaned_data.get('studiennachweis_eingereicht')
studiennachweis_datei = cleaned_data.get('studiennachweis_datei')
studiennachweis_bemerkung = cleaned_data.get('studiennachweis_bemerkung')
if studiennachweis_erforderlich and studiennachweis_eingereicht:
has_dms_studiennachweis = (
self.instance and self.instance.pk and (
bool(self.instance.studiennachweis_dms_dokument_id)
or self.instance.nachweis_dokumente.filter(kontext="studiennachweis").exists()
)
)
if not studiennachweis_datei and not studiennachweis_bemerkung and not has_dms_studiennachweis:
raise ValidationError(
'Wenn der Studiennachweis als eingereicht markiert wird, muss entweder eine Datei, eine Bemerkung oder ein DMS-Dokument angegeben werden.'
)
return cleaned_data

View File

@@ -0,0 +1,19 @@
from django import forms
from ..models import DokumentLink
class DokumentLinkForm(forms.ModelForm):
"""Form für das Erstellen und Bearbeiten von Dokumentverknüpfungen"""
class Meta:
model = DokumentLink
fields = "__all__"
widgets = {
"paperless_id": forms.NumberInput(attrs={"class": "form-control"}),
"content_type": forms.Select(attrs={"class": "form-select"}),
"object_id": forms.TextInput(attrs={"class": "form-control"}),
"verknuepft_am": forms.DateTimeInput(
attrs={"class": "form-control", "type": "datetime-local"}
),
}

View File

@@ -0,0 +1,351 @@
import re
from django import forms
from django.core.exceptions import ValidationError
from ..models import BankTransaction, Rentmeister, StiftungsKonto, Verwaltungskosten
class RentmeisterForm(forms.ModelForm):
"""Form für das Erstellen und Bearbeiten von Rentmeistern"""
class Meta:
model = Rentmeister
fields = [
"anrede",
"vorname",
"nachname",
"titel",
"email",
"telefon",
"mobil",
"strasse",
"plz",
"ort",
"iban",
"bic",
"bank_name",
"seit_datum",
"bis_datum",
"aktiv",
"monatliche_verguetung",
"km_pauschale",
"notizen",
]
widgets = {
"anrede": forms.Select(attrs={"class": "form-select"}),
"vorname": forms.TextInput(attrs={"class": "form-control"}),
"nachname": forms.TextInput(attrs={"class": "form-control"}),
"titel": forms.TextInput(attrs={"class": "form-control"}),
"email": forms.EmailInput(attrs={"class": "form-control"}),
"telefon": forms.TextInput(attrs={"class": "form-control"}),
"mobil": forms.TextInput(attrs={"class": "form-control"}),
"strasse": forms.TextInput(attrs={"class": "form-control"}),
"plz": forms.TextInput(attrs={"class": "form-control"}),
"ort": forms.TextInput(attrs={"class": "form-control"}),
"iban": forms.TextInput(
attrs={"class": "form-control", "placeholder": "DE89370400440532013000"}
),
"bic": forms.TextInput(
attrs={"class": "form-control", "placeholder": "COBADEFFXXX"}
),
"bank_name": forms.TextInput(attrs={"class": "form-control"}),
"seit_datum": forms.DateInput(
attrs={"class": "form-control", "type": "date"}
),
"bis_datum": forms.DateInput(
attrs={"class": "form-control", "type": "date"}
),
"aktiv": forms.CheckboxInput(attrs={"class": "form-check-input"}),
"monatliche_verguetung": forms.NumberInput(
attrs={"class": "form-control", "step": "0.01"}
),
"km_pauschale": forms.NumberInput(
attrs={"class": "form-control", "step": "0.01", "value": "0.30"}
),
"notizen": forms.Textarea(attrs={"class": "form-control", "rows": 4}),
}
labels = {
"anrede": "Anrede",
"vorname": "Vorname *",
"nachname": "Nachname *",
"titel": "Titel",
"email": "E-Mail",
"telefon": "Telefon",
"mobil": "Mobil",
"strasse": "Straße",
"plz": "PLZ",
"ort": "Ort",
"iban": "IBAN",
"bic": "BIC",
"bank_name": "Bank",
"seit_datum": "Rentmeister seit *",
"bis_datum": "Rentmeister bis",
"aktiv": "Aktiv",
"monatliche_verguetung": "Monatliche Vergütung (€)",
"km_pauschale": "Kilometerpauschale (€/km)",
"notizen": "Notizen",
}
help_texts = {
"iban": "Internationale Bankkontonummer für Abrechnungen",
"km_pauschale": "Standard: 0,30 € pro Kilometer",
"seit_datum": "Datum des Amtsantritts als Rentmeister",
"bis_datum": "Leer lassen für aktive Rentmeister",
}
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
# Markiere Pflichtfelder
self.fields["vorname"].required = True
self.fields["nachname"].required = True
self.fields["seit_datum"].required = True
def clean_iban(self):
"""Validierung der IBAN"""
iban = self.cleaned_data.get("iban")
if iban:
# Entferne Leerzeichen und konvertiere zu Großbuchstaben
iban = re.sub(r"\s+", "", iban.upper())
# Einfache IBAN-Längenvalidierung für deutsche IBANs
if iban.startswith("DE") and len(iban) != 22:
raise ValidationError("Deutsche IBANs müssen 22 Zeichen lang sein.")
# Speichere die bereinigte IBAN
return iban
return iban
def clean_plz(self):
"""Validierung der PLZ"""
plz = self.cleaned_data.get("plz")
if plz and not re.match(r"^\d{5}$", plz):
raise ValidationError("PLZ muss aus 5 Ziffern bestehen.")
return plz
def clean(self):
"""Übergreifende Validierung"""
from django.utils.dateparse import parse_date
cleaned_data = super().clean()
seit_datum = cleaned_data.get("seit_datum")
bis_datum = cleaned_data.get("bis_datum")
# Helper function to ensure we have date objects
def ensure_date(date_value):
if not date_value:
return None
if isinstance(date_value, str):
return parse_date(date_value)
return date_value
# Convert to date objects if they're strings
seit_datum = ensure_date(seit_datum)
bis_datum = ensure_date(bis_datum)
# Prüfe Datum-Logik
if seit_datum and bis_datum and bis_datum <= seit_datum:
raise ValidationError("Das End-Datum muss nach dem Start-Datum liegen.")
return cleaned_data
class StiftungsKontoForm(forms.ModelForm):
"""Form für das Erstellen und Bearbeiten von Stiftungskonten"""
class Meta:
model = StiftungsKonto
fields = [
"kontoname",
"bank_name",
"iban",
"bic",
"konto_typ",
"saldo",
"saldo_datum",
"zinssatz",
"laufzeit_bis",
"aktiv",
"notizen",
]
widgets = {
"kontoname": forms.TextInput(attrs={"class": "form-control"}),
"bank_name": forms.TextInput(attrs={"class": "form-control"}),
"iban": forms.TextInput(
attrs={"class": "form-control", "placeholder": "DE89370400440532013000"}
),
"bic": forms.TextInput(
attrs={"class": "form-control", "placeholder": "COBADEFFXXX"}
),
"konto_typ": forms.Select(attrs={"class": "form-select"}),
"saldo": forms.NumberInput(attrs={"class": "form-control", "step": "0.01"}),
"saldo_datum": forms.DateInput(
attrs={"class": "form-control", "type": "date"}
),
"zinssatz": forms.NumberInput(
attrs={"class": "form-control", "step": "0.01"}
),
"laufzeit_bis": forms.DateInput(
attrs={"class": "form-control", "type": "date"}
),
"aktiv": forms.CheckboxInput(attrs={"class": "form-check-input"}),
"notizen": forms.Textarea(attrs={"class": "form-control", "rows": 3}),
}
class VerwaltungskostenForm(forms.ModelForm):
"""Form für das Erstellen und Bearbeiten von Verwaltungskosten"""
class Meta:
model = Verwaltungskosten
fields = [
"bezeichnung",
"kategorie",
"betrag",
"datum",
"status",
"rentmeister",
"zahlungskonto",
"quellkonto",
"lieferant_firma",
"rechnungsnummer",
"km_anzahl",
"km_satz",
"von_ort",
"nach_ort",
"zweck",
"beschreibung",
"notizen",
]
widgets = {
"bezeichnung": forms.TextInput(attrs={"class": "form-control"}),
"kategorie": forms.Select(attrs={"class": "form-select"}),
"betrag": forms.NumberInput(
attrs={"class": "form-control", "step": "0.01"}
),
"datum": forms.DateInput(attrs={"class": "form-control", "type": "date"}),
"status": forms.Select(attrs={"class": "form-select"}),
"rentmeister": forms.Select(attrs={"class": "form-select"}),
"zahlungskonto": forms.Select(attrs={"class": "form-select"}),
"quellkonto": forms.Select(attrs={"class": "form-select"}),
"lieferant_firma": forms.TextInput(attrs={"class": "form-control"}),
"rechnungsnummer": forms.TextInput(attrs={"class": "form-control"}),
"km_anzahl": forms.NumberInput(
attrs={"class": "form-control", "step": "0.1"}
),
"km_satz": forms.NumberInput(
attrs={"class": "form-control", "step": "0.01"}
),
"von_ort": forms.TextInput(attrs={"class": "form-control"}),
"nach_ort": forms.TextInput(attrs={"class": "form-control"}),
"zweck": forms.TextInput(attrs={"class": "form-control"}),
"beschreibung": forms.Textarea(attrs={"class": "form-control", "rows": 3}),
"notizen": forms.Textarea(attrs={"class": "form-control", "rows": 2}),
}
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
# Filtere nur aktive Rentmeister und Konten
self.fields["rentmeister"].queryset = Rentmeister.objects.filter(aktiv=True)
self.fields["zahlungskonto"].queryset = StiftungsKonto.objects.filter(
aktiv=True
)
self.fields["quellkonto"].queryset = StiftungsKonto.objects.filter(aktiv=True)
# Standardwerte setzen
if not self.instance.pk: # Nur bei neuen Objekten
# Standard km_satz auf 0.30 Euro setzen
self.fields["km_satz"].initial = 0.30
class BankTransactionForm(forms.ModelForm):
"""Form für das Bearbeiten von Banktransaktionen"""
class Meta:
model = BankTransaction
fields = [
"konto",
"datum",
"valuta",
"betrag",
"waehrung",
"verwendungszweck",
"empfaenger_zahlungspflichtiger",
"iban_gegenpartei",
"bic_gegenpartei",
"transaction_type",
"status",
"kommentare",
"verwaltungskosten",
]
widgets = {
"konto": forms.Select(attrs={"class": "form-select"}),
"datum": forms.DateInput(attrs={"class": "form-control", "type": "date"}),
"valuta": forms.DateInput(attrs={"class": "form-control", "type": "date"}),
"betrag": forms.NumberInput(
attrs={"class": "form-control", "step": "0.01"}
),
"waehrung": forms.TextInput(attrs={"class": "form-control"}),
"verwendungszweck": forms.Textarea(
attrs={"class": "form-control", "rows": 3}
),
"empfaenger_zahlungspflichtiger": forms.TextInput(
attrs={"class": "form-control"}
),
"iban_gegenpartei": forms.TextInput(attrs={"class": "form-control"}),
"bic_gegenpartei": forms.TextInput(attrs={"class": "form-control"}),
"transaction_type": forms.Select(attrs={"class": "form-select"}),
"status": forms.Select(attrs={"class": "form-select"}),
"kommentare": forms.Textarea(attrs={"class": "form-control", "rows": 4}),
"verwaltungskosten": forms.Select(attrs={"class": "form-select"}),
}
class BankImportForm(forms.Form):
"""Form für den Import von Bankdaten"""
konto = forms.ModelChoiceField(
queryset=StiftungsKonto.objects.filter(aktiv=True),
widget=forms.Select(attrs={"class": "form-select"}),
label="Zielkonto",
)
datei = forms.FileField(
widget=forms.FileInput(attrs={"class": "form-control", "accept": ".csv,.txt"}),
label="Bankdatei",
help_text="Unterstützte Formate: CSV, TXT (Sparkasse, Volksbank, etc.)",
)
encoding = forms.ChoiceField(
choices=[
("utf-8", "UTF-8"),
("latin1", "Latin-1 / ISO-8859-1"),
("cp1252", "Windows-1252"),
],
initial="utf-8",
widget=forms.Select(attrs={"class": "form-select"}),
label="Zeichenkodierung",
)
delimiter = forms.ChoiceField(
choices=[
(";", "Semikolon (;)"),
(",", "Komma (,)"),
("\t", "Tab"),
],
initial=";",
widget=forms.Select(attrs={"class": "form-select"}),
label="Trennzeichen",
)
skip_header = forms.BooleanField(
initial=True,
required=False,
widget=forms.CheckboxInput(attrs={"class": "form-check-input"}),
label="Erste Zeile überspringen (Spaltenüberschriften)",
)

View File

@@ -0,0 +1,73 @@
from django import forms
from ..models import Destinataer, DokumentLink, Foerderung
class FoerderungForm(forms.ModelForm):
"""Form für das Erstellen und Bearbeiten von Förderungen"""
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
# Add empty option for optional fields
self.fields["verwendungsnachweis"].empty_label = (
"--- Kein Dokument verknüpfen ---"
)
# Ensure destinataer has proper choices
from django.utils import timezone
from ..models import Destinataer, DokumentLink
self.fields["destinataer"].queryset = Destinataer.objects.all().order_by(
"nachname", "vorname"
)
self.fields["verwendungsnachweis"].queryset = (
DokumentLink.objects.all().order_by("titel")
)
# Set current year as default for new forms
if not self.instance.pk:
self.fields["jahr"].initial = timezone.now().year
class Meta:
model = Foerderung
fields = [
"destinataer",
"jahr",
"betrag",
"kategorie",
"status",
"antragsdatum",
"entscheidungsdatum",
"verwendungsnachweis",
"bemerkungen",
]
widgets = {
"destinataer": forms.Select(attrs={"class": "form-select"}),
"jahr": forms.NumberInput(attrs={"class": "form-control"}),
"betrag": forms.NumberInput(
attrs={"class": "form-control", "step": "0.01"}
),
"kategorie": forms.Select(attrs={"class": "form-select"}),
"status": forms.Select(attrs={"class": "form-select"}),
"antragsdatum": forms.DateInput(
attrs={"class": "form-control", "type": "date"}
),
"entscheidungsdatum": forms.DateInput(
attrs={"class": "form-control", "type": "date"}
),
"verwendungsnachweis": forms.Select(attrs={"class": "form-select"}),
"bemerkungen": forms.Textarea(attrs={"class": "form-control", "rows": 3}),
}
labels = {
"destinataer": "Destinatär",
"verwendungsnachweis": "Verknüpftes Dokument",
"bemerkungen": "Bemerkungen/Beschreibung",
"antragsdatum": "Antragsdatum",
"entscheidungsdatum": "Entscheidungsdatum",
}
help_texts = {
"verwendungsnachweis": "Optionale Verknüpfung zu einem Dokument aus dem Paperless-System",
"entscheidungsdatum": "Datum der Bewilligung/Ablehnung (optional)",
"bemerkungen": "Zusätzliche Informationen zur Förderung",
}

View File

@@ -0,0 +1,107 @@
from django import forms
from ..models import GeschichteBild, GeschichteSeite
class GeschichteSeiteForm(forms.ModelForm):
"""Form for creating and editing history pages"""
class Meta:
from ..models import GeschichteSeite
model = GeschichteSeite
fields = ['titel', 'slug', 'inhalt', 'ist_veroeffentlicht', 'sortierung']
widgets = {
'titel': forms.TextInput(attrs={
'class': 'form-control',
'placeholder': 'z.B. Gründung der Stiftung'
}),
'slug': forms.TextInput(attrs={
'class': 'form-control',
'placeholder': 'z.B. gruendung-der-stiftung'
}),
'inhalt': forms.Textarea(attrs={
'class': 'form-control rich-text-editor',
'rows': 20,
'placeholder': 'Schreiben Sie hier den Inhalt der Geschichtsseite...'
}),
'ist_veroeffentlicht': forms.CheckboxInput(attrs={
'class': 'form-check-input'
}),
'sortierung': forms.NumberInput(attrs={
'class': 'form-control',
'min': 0
})
}
help_texts = {
'slug': 'URL-freundliche Version des Titels (nur Buchstaben, Zahlen und Bindestriche)',
'inhalt': 'Unterstützt Rich-Text-Formatierung, Bilder und Videos',
'sortierung': 'Niedrigere Zahlen erscheinen zuerst in der Navigation'
}
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
# Auto-generate slug from title if not provided
if not self.instance.pk:
self.fields['slug'].required = False
def clean_slug(self):
slug = self.cleaned_data.get('slug')
titel = self.cleaned_data.get('titel', '')
if not slug and titel:
# Auto-generate slug from title
from django.utils.text import slugify
slug = slugify(titel)
if not slug:
raise forms.ValidationError('Slug ist erforderlich. Bitte geben Sie einen Titel ein.')
return slug
def clean(self):
cleaned_data = super().clean()
titel = cleaned_data.get('titel', '')
slug = cleaned_data.get('slug', '')
# Auto-generate slug if empty
if titel and not slug:
from django.utils.text import slugify
cleaned_data['slug'] = slugify(titel)
return cleaned_data
class GeschichteBildForm(forms.ModelForm):
"""Form for uploading images to history pages"""
class Meta:
from ..models import GeschichteBild
model = GeschichteBild
fields = ['titel', 'bild', 'beschreibung', 'alt_text', 'sortierung']
widgets = {
'titel': forms.TextInput(attrs={
'class': 'form-control',
'placeholder': 'z.B. Gründungsurkunde 1895'
}),
'bild': forms.ClearableFileInput(attrs={
'class': 'form-control'
}),
'beschreibung': forms.Textarea(attrs={
'class': 'form-control',
'rows': 3,
'placeholder': 'Beschreibung des Bildes...'
}),
'alt_text': forms.TextInput(attrs={
'class': 'form-control',
'placeholder': 'Alternativtext für Bildschirmleser'
}),
'sortierung': forms.NumberInput(attrs={
'class': 'form-control',
'min': 0
})
}
help_texts = {
'bild': 'Unterstützte Formate: JPG, PNG, GIF (max. 10MB)',
'alt_text': 'Wichtig für Barrierefreiheit',
'sortierung': 'Reihenfolge in der Bildergalerie'
}

294
app/stiftung/forms/land.py Normal file
View File

@@ -0,0 +1,294 @@
from django import forms
from ..models import Land, LandAbrechnung, LandVerpachtung, Paechter
class LandForm(forms.ModelForm):
"""Form für das Erstellen und Bearbeiten von Ländern"""
class Meta:
model = Land
fields = [
# Grundlegende Identifikation
"lfd_nr",
"ew_nummer",
"grundbuchblatt",
# Gerichtliche Zuständigkeit
"amtsgericht",
# Verwaltungsstruktur
"gemeinde",
"gemarkung",
"flur",
"flurstueck",
"adresse",
"alkis_kennzeichen",
# Flächenangaben
"groesse_qm",
"gruenland_qm",
"acker_qm",
"wald_qm",
"sonstiges_qm",
# Legacy Verpachtung (für Kompatibilität)
"verpachtete_gesamtflaeche",
"flaeche_alte_liste",
"verp_flaeche_aktuell",
# Aktuelle Verpachtung
"aktueller_paechter",
"paechter_name",
"paechter_anschrift",
"pachtbeginn",
"pachtende",
"verlaengerung_klausel",
"zahlungsweise",
"pachtzins_pro_ha",
"pachtzins_pauschal",
# Umsatzsteuer
"ust_option",
"ust_satz",
# Umlagen
"grundsteuer_umlage",
"versicherungen_umlage",
"verbandsbeitraege_umlage",
"jagdpacht_anteil_umlage",
# Legacy Steuern
"anteil_grundsteuer",
"anteil_lwk",
# Status
"aktiv",
"notizen",
]
widgets = {
# Grundlegende Identifikation
"lfd_nr": forms.TextInput(attrs={"class": "form-control"}),
"ew_nummer": forms.TextInput(attrs={"class": "form-control"}),
"grundbuchblatt": forms.TextInput(attrs={"class": "form-control"}),
# Gerichtliche Zuständigkeit
"amtsgericht": forms.TextInput(attrs={"class": "form-control"}),
# Verwaltungsstruktur
"gemeinde": forms.TextInput(attrs={"class": "form-control"}),
"gemarkung": forms.TextInput(attrs={"class": "form-control"}),
"flur": forms.TextInput(attrs={"class": "form-control"}),
"flurstueck": forms.TextInput(attrs={"class": "form-control"}),
"adresse": forms.TextInput(attrs={"class": "form-control"}),
# Flächenangaben
"groesse_qm": forms.NumberInput(
attrs={"class": "form-control", "step": "0.01"}
),
"gruenland_qm": forms.NumberInput(
attrs={"class": "form-control", "step": "0.01"}
),
"acker_qm": forms.NumberInput(
attrs={"class": "form-control", "step": "0.01"}
),
"wald_qm": forms.NumberInput(
attrs={"class": "form-control", "step": "0.01"}
),
"sonstiges_qm": forms.NumberInput(
attrs={"class": "form-control", "step": "0.01"}
),
# Legacy Verpachtung
"verpachtete_gesamtflaeche": forms.NumberInput(
attrs={"class": "form-control", "step": "0.01"}
),
"flaeche_alte_liste": forms.NumberInput(
attrs={"class": "form-control", "step": "0.01"}
),
"verp_flaeche_aktuell": forms.NumberInput(
attrs={"class": "form-control", "step": "0.01"}
),
# Aktuelle Verpachtung
"aktueller_paechter": forms.Select(attrs={"class": "form-select"}),
"paechter_name": forms.TextInput(attrs={"class": "form-control"}),
"paechter_anschrift": forms.Textarea(
attrs={"class": "form-control", "rows": 3}
),
"pachtbeginn": forms.DateInput(
attrs={"class": "form-control", "type": "date"}
),
"pachtende": forms.DateInput(
attrs={"class": "form-control", "type": "date"}
),
"verlaengerung_klausel": forms.CheckboxInput(
attrs={"class": "form-check-input"}
),
"zahlungsweise": forms.Select(attrs={"class": "form-select"}),
"pachtzins_pro_ha": forms.NumberInput(
attrs={"class": "form-control", "step": "0.01"}
),
"pachtzins_pauschal": forms.NumberInput(
attrs={"class": "form-control", "step": "0.01"}
),
# Umsatzsteuer
"ust_option": forms.CheckboxInput(attrs={"class": "form-check-input"}),
"ust_satz": forms.NumberInput(
attrs={"class": "form-control", "step": "0.01"}
),
# Umlagen
"grundsteuer_umlage": forms.CheckboxInput(
attrs={"class": "form-check-input"}
),
"versicherungen_umlage": forms.CheckboxInput(
attrs={"class": "form-check-input"}
),
"verbandsbeitraege_umlage": forms.CheckboxInput(
attrs={"class": "form-check-input"}
),
"jagdpacht_anteil_umlage": forms.CheckboxInput(
attrs={"class": "form-check-input"}
),
# Legacy
"anteil_grundsteuer": forms.NumberInput(
attrs={"class": "form-control", "step": "0.01"}
),
"anteil_lwk": forms.NumberInput(
attrs={"class": "form-control", "step": "0.01"}
),
# Status
"aktiv": forms.CheckboxInput(attrs={"class": "form-check-input"}),
"notizen": forms.Textarea(attrs={"class": "form-control", "rows": 3}),
}
class LandVerpachtungForm(forms.ModelForm):
"""Form für das Erstellen und Bearbeiten von Verpachtungen"""
class Meta:
model = LandVerpachtung
fields = [
'land',
'paechter',
'vertragsnummer',
'pachtbeginn',
'pachtende',
'verlaengerung_klausel',
'verpachtete_flaeche',
'pachtzins_pauschal',
'pachtzins_pro_ha',
'zahlungsweise',
'ust_option',
'ust_satz',
'grundsteuer_umlage',
'versicherungen_umlage',
'verbandsbeitraege_umlage',
'jagdpacht_anteil_umlage',
'status',
'bemerkungen'
]
widgets = {
'land': forms.Select(attrs={'class': 'form-select'}),
'paechter': forms.Select(attrs={'class': 'form-select'}),
'vertragsnummer': forms.TextInput(attrs={'class': 'form-control'}),
'pachtbeginn': forms.DateInput(attrs={'class': 'form-control', 'type': 'date'}),
'pachtende': forms.DateInput(attrs={'class': 'form-control', 'type': 'date'}),
'verlaengerung_klausel': forms.CheckboxInput(attrs={'class': 'form-check-input'}),
'verpachtete_flaeche': forms.NumberInput(attrs={'class': 'form-control', 'step': '0.01'}),
'pachtzins_pauschal': forms.NumberInput(attrs={'class': 'form-control', 'step': '0.01'}),
'pachtzins_pro_ha': forms.NumberInput(attrs={'class': 'form-control', 'step': '0.01'}),
'zahlungsweise': forms.Select(attrs={'class': 'form-select'}),
'ust_option': forms.CheckboxInput(attrs={'class': 'form-check-input'}),
'ust_satz': forms.NumberInput(attrs={'class': 'form-control', 'step': '0.01'}),
'grundsteuer_umlage': forms.CheckboxInput(attrs={'class': 'form-check-input'}),
'versicherungen_umlage': forms.CheckboxInput(attrs={'class': 'form-check-input'}),
'verbandsbeitraege_umlage': forms.CheckboxInput(attrs={'class': 'form-check-input'}),
'jagdpacht_anteil_umlage': forms.CheckboxInput(attrs={'class': 'form-check-input'}),
'status': forms.Select(attrs={'class': 'form-select'}),
'bemerkungen': forms.Textarea(attrs={'class': 'form-control', 'rows': 3}),
}
class LandAbrechnungForm(forms.ModelForm):
"""Form für das Erstellen und Bearbeiten von Landabrechnungen"""
class Meta:
model = LandAbrechnung
fields = [
"land",
"abrechnungsjahr",
# Einnahmen
"pacht_vereinnahmt",
"umlagen_vereinnahmt",
"sonstige_einnahmen",
# Ausgaben
"grundsteuer_bescheid_nr",
"grundsteuer_betrag",
"versicherungen_betrag",
"verbandsbeitraege_betrag",
"sonstige_abgaben_betrag",
"instandhaltung_betrag",
"verwaltung_recht_betrag",
# Umsatzsteuer
"vorsteuer_aus_umlagen",
# Sonstiges
"offene_posten",
"bemerkungen",
# Dokumente werden über Paperless verknüpft, nicht hochgeladen
]
widgets = {
"land": forms.Select(attrs={"class": "form-select"}),
"abrechnungsjahr": forms.NumberInput(
attrs={"class": "form-control", "min": "2000", "max": "2050"}
),
# Einnahmen
"pacht_vereinnahmt": forms.NumberInput(
attrs={"class": "form-control", "step": "0.01"}
),
"umlagen_vereinnahmt": forms.NumberInput(
attrs={"class": "form-control", "step": "0.01"}
),
"sonstige_einnahmen": forms.NumberInput(
attrs={"class": "form-control", "step": "0.01"}
),
# Ausgaben
"grundsteuer_bescheid_nr": forms.TextInput(attrs={"class": "form-control"}),
"grundsteuer_betrag": forms.NumberInput(
attrs={"class": "form-control", "step": "0.01"}
),
"versicherungen_betrag": forms.NumberInput(
attrs={"class": "form-control", "step": "0.01"}
),
"verbandsbeitraege_betrag": forms.NumberInput(
attrs={"class": "form-control", "step": "0.01"}
),
"sonstige_abgaben_betrag": forms.NumberInput(
attrs={"class": "form-control", "step": "0.01"}
),
"instandhaltung_betrag": forms.NumberInput(
attrs={"class": "form-control", "step": "0.01"}
),
"verwaltung_recht_betrag": forms.NumberInput(
attrs={"class": "form-control", "step": "0.01"}
),
# Umsatzsteuer
"vorsteuer_aus_umlagen": forms.NumberInput(
attrs={"class": "form-control", "step": "0.01"}
),
# Sonstiges
"offene_posten": forms.NumberInput(
attrs={"class": "form-control", "step": "0.01"}
),
"bemerkungen": forms.Textarea(attrs={"class": "form-control", "rows": 4}),
}
class PaechterForm(forms.ModelForm):
"""Form für das Erstellen und Bearbeiten von Pächtern"""
class Meta:
model = Paechter
fields = "__all__"
widgets = {
"anrede": forms.Select(attrs={"class": "form-select"}),
"vorname": forms.TextInput(attrs={"class": "form-control"}),
"nachname": forms.TextInput(attrs={"class": "form-control"}),
"email": forms.EmailInput(attrs={"class": "form-control"}),
"telefon": forms.TextInput(attrs={"class": "form-control"}),
"mobil": forms.TextInput(attrs={"class": "form-control"}),
"geburtsdatum": forms.DateInput(
attrs={"class": "form-control", "type": "date"}
),
"strasse": forms.TextInput(attrs={"class": "form-control"}),
"plz": forms.TextInput(attrs={"class": "form-control"}),
"ort": forms.TextInput(attrs={"class": "form-control"}),
"aktiv": forms.CheckboxInput(attrs={"class": "form-check-input"}),
"notizen": forms.Textarea(attrs={"class": "form-control", "rows": 3}),
}

View File

@@ -0,0 +1,460 @@
import re
from django import forms
from django.core.exceptions import ValidationError
from ..models import Person
class UserCreationForm(forms.Form):
"""Form für die Erstellung neuer Benutzer"""
username = forms.CharField(
label="Benutzername",
max_length=150,
help_text="Eindeutiger Benutzername für die Anmeldung",
widget=forms.TextInput(attrs={"class": "form-control"}),
)
email = forms.EmailField(
label="E-Mail-Adresse",
help_text="E-Mail-Adresse des Benutzers",
widget=forms.EmailInput(attrs={"class": "form-control"}),
)
first_name = forms.CharField(
label="Vorname",
max_length=30,
required=False,
widget=forms.TextInput(attrs={"class": "form-control"}),
)
last_name = forms.CharField(
label="Nachname",
max_length=150,
required=False,
widget=forms.TextInput(attrs={"class": "form-control"}),
)
password1 = forms.CharField(
label="Passwort",
widget=forms.PasswordInput(attrs={"class": "form-control"}),
help_text="Mindestens 8 Zeichen",
)
password2 = forms.CharField(
label="Passwort bestätigen",
widget=forms.PasswordInput(attrs={"class": "form-control"}),
help_text="Geben Sie das Passwort zur Bestätigung erneut ein",
)
is_active = forms.BooleanField(
label="Aktiv",
required=False,
initial=True,
help_text="Benutzer kann sich anmelden",
widget=forms.CheckboxInput(attrs={"class": "form-check-input"}),
)
is_staff = forms.BooleanField(
label="Staff-Status",
required=False,
help_text="Benutzer kann auf Django Admin zugreifen",
widget=forms.CheckboxInput(attrs={"class": "form-check-input"}),
)
def clean_username(self):
username = self.cleaned_data["username"]
from django.contrib.auth.models import User
if User.objects.filter(username=username).exists():
raise forms.ValidationError(
"Ein Benutzer mit diesem Namen existiert bereits."
)
return username
def clean_email(self):
email = self.cleaned_data["email"]
from django.contrib.auth.models import User
if User.objects.filter(email=email).exists():
raise forms.ValidationError(
"Ein Benutzer mit dieser E-Mail-Adresse existiert bereits."
)
return email
def clean(self):
cleaned_data = super().clean()
password1 = cleaned_data.get("password1")
password2 = cleaned_data.get("password2")
if password1 and password2:
if password1 != password2:
raise forms.ValidationError("Die Passwörter stimmen nicht überein.")
if len(password1) < 8:
raise forms.ValidationError(
"Das Passwort muss mindestens 8 Zeichen lang sein."
)
return cleaned_data
class UserUpdateForm(forms.ModelForm):
"""Form für die Bearbeitung bestehender Benutzer"""
class Meta:
from django.contrib.auth.models import User
model = User
fields = [
"username",
"email",
"first_name",
"last_name",
"is_active",
"is_staff",
]
widgets = {
"username": forms.TextInput(attrs={"class": "form-control"}),
"email": forms.EmailInput(attrs={"class": "form-control"}),
"first_name": forms.TextInput(attrs={"class": "form-control"}),
"last_name": forms.TextInput(attrs={"class": "form-control"}),
"is_active": forms.CheckboxInput(attrs={"class": "form-check-input"}),
"is_staff": forms.CheckboxInput(attrs={"class": "form-check-input"}),
}
labels = {
"username": "Benutzername",
"email": "E-Mail-Adresse",
"first_name": "Vorname",
"last_name": "Nachname",
"is_active": "Aktiv",
"is_staff": "Staff-Status",
}
help_texts = {
"username": "Eindeutiger Benutzername für die Anmeldung",
"email": "E-Mail-Adresse des Benutzers",
"is_active": "Benutzer kann sich anmelden",
"is_staff": "Benutzer kann auf Django Admin zugreifen",
}
class PasswordChangeForm(forms.Form):
"""Form für Passwort-Änderungen"""
new_password1 = forms.CharField(
label="Neues Passwort",
widget=forms.PasswordInput(attrs={"class": "form-control"}),
help_text="Mindestens 8 Zeichen",
)
new_password2 = forms.CharField(
label="Neues Passwort bestätigen",
widget=forms.PasswordInput(attrs={"class": "form-control"}),
help_text="Geben Sie das neue Passwort zur Bestätigung erneut ein",
)
def clean(self):
cleaned_data = super().clean()
password1 = cleaned_data.get("new_password1")
password2 = cleaned_data.get("new_password2")
if password1 and password2:
if password1 != password2:
raise forms.ValidationError("Die Passwörter stimmen nicht überein.")
if len(password1) < 8:
raise forms.ValidationError(
"Das Passwort muss mindestens 8 Zeichen lang sein."
)
return cleaned_data
class UserPermissionForm(forms.Form):
"""Form für die Zuweisung von Berechtigungen"""
def __init__(self, *args, **kwargs):
user = kwargs.pop("user", None)
super().__init__(*args, **kwargs)
from django.contrib.auth.models import Permission
# Get all custom permissions for stiftung app
app_permissions = Permission.objects.filter(
content_type__app_label="stiftung"
).order_by("name")
# Create checkbox fields for each permission
for perm in app_permissions:
field_name = f"perm_{perm.id}"
self.fields[field_name] = forms.BooleanField(
label=perm.name,
required=False,
widget=forms.CheckboxInput(attrs={"class": "form-check-input"}),
)
# Set initial values if user is provided
if user:
self.fields[field_name].initial = user.has_perm(
f"stiftung.{perm.codename}"
)
def get_permission_groups(self):
"""Group permissions by functionality for template rendering"""
from django.contrib.auth.models import Permission
groups = {
"entities": {
"name": "Entitäten verwalten",
"permissions": [],
"icon": "fas fa-users",
},
"documents": {
"name": "Dokumentenverwaltung",
"permissions": [],
"icon": "fas fa-folder-open",
},
"financial": {
"name": "Finanzverwaltung",
"permissions": [],
"icon": "fas fa-euro-sign",
},
"administration": {
"name": "Administration",
"permissions": [],
"icon": "fas fa-cogs",
},
"system": {"name": "System", "permissions": [], "icon": "fas fa-server"},
}
# Get all permissions to properly categorize them
for field_name, field in self.fields.items():
if field_name.startswith("perm_"):
# Extract permission ID from field name
perm_id = field_name.replace("perm_", "")
try:
permission = Permission.objects.get(id=perm_id)
label = permission.name.lower()
codename = permission.codename.lower()
# Get bound field for proper template rendering
bound_field = self[field_name]
# More precise categorization based on both name and codename
if (
any(
word in codename
for word in [
"destinataer",
"land",
"paechter",
"verpachtung",
"foerderung",
]
)
and "manage_" in codename
or "view_" in codename
):
groups["entities"]["permissions"].append(
(field_name, bound_field, permission)
)
elif (
any(
word in codename for word in ["documents", "link_documents"]
)
or "dokument" in label
):
groups["documents"]["permissions"].append(
(field_name, bound_field, permission)
)
elif any(
word in codename
for word in [
"verwaltungskosten",
"konten",
"rentmeister",
"approve_payments",
]
) or any(
word in label
for word in [
"verwaltungskosten",
"konto",
"rentmeister",
"zahlung",
]
):
groups["financial"]["permissions"].append(
(field_name, bound_field, permission)
)
elif any(
word in codename
for word in [
"administration",
"audit",
"backup",
"manage_users",
"manage_permissions",
]
) or any(
word in label
for word in [
"administration",
"audit",
"backup",
"benutzer",
"berechtigung",
]
):
groups["administration"]["permissions"].append(
(field_name, bound_field, permission)
)
else:
groups["system"]["permissions"].append(
(field_name, bound_field, permission)
)
except Permission.DoesNotExist:
# Create a fallback permission-like object with proper display
class FallbackPermission:
def __init__(self, field_name):
self.name = field_name.replace('_', ' ').title()
self.codename = field_name
fallback_perm = FallbackPermission(field_name)
bound_field = self[field_name] # Get bound field for exception case too
groups["system"]["permissions"].append((field_name, bound_field, fallback_perm))
return groups
class TwoFactorSetupForm(forms.Form):
"""Form for setting up 2FA with TOTP verification"""
token = forms.CharField(
max_length=6,
min_length=6,
widget=forms.TextInput(attrs={
'class': 'form-control text-center',
'placeholder': '000000',
'autocomplete': 'off',
'pattern': '[0-9]{6}',
'inputmode': 'numeric'
}),
label='Bestätigungscode',
help_text='6-stelliger Code aus Ihrer Authenticator-App'
)
def clean_token(self):
token = self.cleaned_data.get('token')
if token and not token.isdigit():
raise ValidationError('Der Code darf nur Zahlen enthalten.')
return token
class TwoFactorVerifyForm(forms.Form):
"""Form for verifying 2FA during login"""
otp_token = forms.CharField(
max_length=8,
min_length=6,
widget=forms.TextInput(attrs={
'class': 'form-control form-control-lg text-center',
'placeholder': '000000',
'autocomplete': 'off',
'autofocus': True
}),
label='Authentifizierungscode',
help_text='6-stelliger Code aus der App oder 8-stelliger Backup-Code'
)
def clean_otp_token(self):
token = self.cleaned_data.get('otp_token')
if token:
token = token.strip().lower()
# Allow 6-digit TOTP codes or 8-character backup codes
if len(token) == 6 and token.isdigit():
return token
elif len(token) == 8 and re.match(r'^[a-f0-9]{8}$', token):
return token
else:
raise ValidationError(
'Bitte geben Sie einen 6-stelligen Authenticator-Code oder 8-stelligen Backup-Code ein.'
)
return token
class TwoFactorDisableForm(forms.Form):
"""Form for disabling 2FA with password confirmation"""
password = forms.CharField(
widget=forms.PasswordInput(attrs={
'class': 'form-control',
'autocomplete': 'current-password',
'autofocus': True
}),
label='Passwort',
help_text='Geben Sie Ihr aktuelles Passwort zur Bestätigung ein'
)
class BackupTokenRegenerateForm(forms.Form):
"""Form for regenerating backup tokens"""
password = forms.CharField(
widget=forms.PasswordInput(attrs={
'class': 'form-control',
'autocomplete': 'current-password'
}),
label='Passwort',
help_text='Geben Sie Ihr Passwort ein, um neue Backup-Codes zu generieren'
)
class PersonForm(forms.ModelForm):
"""Form für das Erstellen und Bearbeiten von Personen (Legacy)"""
class Meta:
model = Person
fields = [
"familienzweig",
"vorname",
"nachname",
"geburtsdatum",
"email",
"telefon",
"iban",
"adresse",
"notizen",
"aktiv",
]
widgets = {
"familienzweig": forms.Select(attrs={"class": "form-select"}),
"vorname": forms.TextInput(attrs={"class": "form-control"}),
"nachname": forms.TextInput(attrs={"class": "form-control"}),
"geburtsdatum": forms.DateInput(
attrs={"class": "form-control", "type": "date"}
),
"email": forms.EmailInput(attrs={"class": "form-control"}),
"telefon": forms.TextInput(attrs={"class": "form-control"}),
"iban": forms.TextInput(
attrs={"class": "form-control", "placeholder": "DE89370400440532013000"}
),
"adresse": forms.Textarea(attrs={"class": "form-control", "rows": 3}),
"notizen": forms.Textarea(attrs={"class": "form-control", "rows": 3}),
"aktiv": forms.CheckboxInput(attrs={"class": "form-check-input"}),
}
labels = {
"familienzweig": "Familienzweig",
"vorname": "Vorname *",
"nachname": "Nachname *",
"geburtsdatum": "Geburtsdatum",
"email": "E-Mail",
"telefon": "Telefon",
"iban": "IBAN",
"adresse": "Adresse",
"notizen": "Notizen",
"aktiv": "Aktiv",
}
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
# Markiere Pflichtfelder
self.fields["vorname"].required = True
self.fields["nachname"].required = True

View File

@@ -0,0 +1,59 @@
from django import forms
from ..models import Veranstaltung, Veranstaltungsteilnehmer
class VeranstaltungForm(forms.ModelForm):
"""Form für das Erstellen und Bearbeiten von Veranstaltungen inkl. Serienbrief-Felder"""
class Meta:
model = Veranstaltung
fields = [
"titel", "datum", "uhrzeit", "ort", "adresse",
"beschreibung", "status", "budget_pro_person",
"betreff", "briefvorlage",
"unterschrift_1_name", "unterschrift_1_titel",
"unterschrift_2_name", "unterschrift_2_titel",
]
widgets = {
"titel": forms.TextInput(attrs={"class": "form-control"}),
"datum": forms.DateInput(attrs={"class": "form-control", "type": "date"}),
"uhrzeit": forms.TimeInput(attrs={"class": "form-control", "type": "time"}),
"ort": forms.TextInput(attrs={"class": "form-control"}),
"adresse": forms.Textarea(attrs={"class": "form-control", "rows": 2}),
"beschreibung": forms.Textarea(attrs={"class": "form-control", "rows": 3}),
"status": forms.Select(attrs={"class": "form-select"}),
"budget_pro_person": forms.NumberInput(attrs={"class": "form-control", "step": "0.01"}),
"betreff": forms.TextInput(attrs={"class": "form-control"}),
"briefvorlage": forms.Textarea(attrs={"class": "form-control", "rows": 12}),
"unterschrift_1_name": forms.TextInput(attrs={"class": "form-control"}),
"unterschrift_1_titel": forms.TextInput(attrs={"class": "form-control"}),
"unterschrift_2_name": forms.TextInput(attrs={"class": "form-control"}),
"unterschrift_2_titel": forms.TextInput(attrs={"class": "form-control"}),
}
class VeranstaltungsteilnehmerForm(forms.ModelForm):
"""Form für das Erstellen und Bearbeiten von Veranstaltungsteilnehmern"""
class Meta:
model = Veranstaltungsteilnehmer
fields = [
"anrede", "vorname", "nachname",
"strasse", "plz", "ort", "email",
"rsvp_status", "bemerkungen",
"paechter", "destinataer",
]
widgets = {
"anrede": forms.Select(attrs={"class": "form-select"}),
"vorname": forms.TextInput(attrs={"class": "form-control"}),
"nachname": forms.TextInput(attrs={"class": "form-control"}),
"strasse": forms.TextInput(attrs={"class": "form-control"}),
"plz": forms.TextInput(attrs={"class": "form-control"}),
"ort": forms.TextInput(attrs={"class": "form-control"}),
"email": forms.EmailInput(attrs={"class": "form-control"}),
"rsvp_status": forms.Select(attrs={"class": "form-select"}),
"bemerkungen": forms.Textarea(attrs={"class": "form-control", "rows": 2}),
"paechter": forms.Select(attrs={"class": "form-select"}),
"destinataer": forms.Select(attrs={"class": "form-select"}),
}

View File

@@ -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."))

View File

@@ -7,94 +7,137 @@ class Command(BaseCommand):
help = "Initialize default app configuration settings"
def handle(self, *args, **options):
# Paperless Integration Settings
paperless_settings = [
# E-Mail / IMAP Settings
email_settings = [
{
"key": "paperless_api_url",
"display_name": "Paperless API URL",
"description": "The base URL for your Paperless-NGX API (e.g., http://paperless.example.com:8000)",
"value": "http://192.168.178.167:30070",
"default_value": "http://192.168.178.167:30070",
"setting_type": "url",
"category": "paperless",
"order": 1,
},
{
"key": "paperless_api_token",
"display_name": "Paperless API Token",
"description": "The authentication token for Paperless API access",
"key": "imap_host",
"display_name": "IMAP Server",
"description": "Hostname oder IP-Adresse des IMAP-Servers (z.B. mail.example.com)",
"value": "",
"default_value": "",
"setting_type": "text",
"category": "paperless",
"category": "email",
"order": 1,
},
{
"key": "imap_port",
"display_name": "IMAP Port",
"description": "Port des IMAP-Servers (Standard: 993 für SSL, 143 für unverschlüsselt)",
"value": "993",
"default_value": "993",
"setting_type": "number",
"category": "email",
"order": 2,
},
{
"key": "paperless_destinataere_tag",
"display_name": "Destinatäre Tag Name",
"description": "The tag name used to identify Destinatäre documents in Paperless",
"value": "Stiftung_Destinatäre",
"default_value": "Stiftung_Destinatäre",
"setting_type": "tag",
"category": "paperless",
"key": "imap_user",
"display_name": "IMAP Benutzername",
"description": "Benutzername / E-Mail-Adresse für die IMAP-Anmeldung",
"value": "",
"default_value": "",
"setting_type": "text",
"category": "email",
"order": 3,
},
{
"key": "paperless_destinataere_tag_id",
"display_name": "Destinatäre Tag ID",
"description": "The numeric ID of the Destinatäre tag in Paperless",
"value": "210",
"default_value": "210",
"setting_type": "tag_id",
"category": "paperless",
"key": "imap_password",
"display_name": "IMAP Passwort",
"description": "Passwort für die IMAP-Anmeldung",
"value": "",
"default_value": "",
"setting_type": "password",
"category": "email",
"order": 4,
},
{
"key": "paperless_land_tag",
"display_name": "Land & Pächter Tag Name",
"description": "The tag name used to identify Land and Pächter documents in Paperless",
"value": "Stiftung_Land_und_Pächter",
"default_value": "Stiftung_Land_und_Pächter",
"setting_type": "tag",
"category": "paperless",
"key": "imap_folder",
"display_name": "IMAP Ordner",
"description": "Name des zu überwachenden Postfach-Ordners (Standard: INBOX)",
"value": "INBOX",
"default_value": "INBOX",
"setting_type": "text",
"category": "email",
"order": 5,
},
{
"key": "paperless_land_tag_id",
"display_name": "Land & Pächter Tag ID",
"description": "The numeric ID of the Land & Pächter tag in Paperless",
"value": "204",
"default_value": "204",
"setting_type": "tag_id",
"category": "paperless",
"key": "imap_use_ssl",
"display_name": "SSL/TLS verwenden",
"description": "Sichere Verbindung zum IMAP-Server (empfohlen)",
"value": "True",
"default_value": "True",
"setting_type": "boolean",
"category": "email",
"order": 6,
},
# SMTP Settings
{
"key": "paperless_admin_tag",
"display_name": "Administration Tag Name",
"description": "The tag name used to identify Administration documents in Paperless",
"value": "Stiftung_Administration",
"default_value": "Stiftung_Administration",
"setting_type": "tag",
"category": "paperless",
"order": 7,
"key": "smtp_host",
"display_name": "SMTP Server",
"description": "Hostname des SMTP-Servers (z.B. smtp.ionos.de)",
"value": "smtp.ionos.de",
"default_value": "smtp.ionos.de",
"setting_type": "text",
"category": "email",
"order": 10,
},
{
"key": "paperless_admin_tag_id",
"display_name": "Administration Tag ID",
"description": "The numeric ID of the Administration tag in Paperless",
"value": "216",
"default_value": "216",
"setting_type": "tag_id",
"category": "paperless",
"order": 8,
"key": "smtp_port",
"display_name": "SMTP Port",
"description": "Port des SMTP-Servers (465 für SSL, 587 für STARTTLS)",
"value": "465",
"default_value": "465",
"setting_type": "number",
"category": "email",
"order": 11,
},
{
"key": "smtp_user",
"display_name": "SMTP Benutzername",
"description": "Benutzername / E-Mail-Adresse für die SMTP-Anmeldung",
"value": "",
"default_value": "",
"setting_type": "text",
"category": "email",
"order": 12,
},
{
"key": "smtp_password",
"display_name": "SMTP Passwort",
"description": "Passwort für die SMTP-Anmeldung",
"value": "",
"default_value": "",
"setting_type": "password",
"category": "email",
"order": 13,
},
{
"key": "smtp_use_ssl",
"display_name": "SSL/TLS verwenden (SMTP)",
"description": "Sichere Verbindung zum SMTP-Server (empfohlen für Port 465)",
"value": "True",
"default_value": "True",
"setting_type": "boolean",
"category": "email",
"order": 14,
},
{
"key": "smtp_from_email",
"display_name": "Absenderadresse (SMTP)",
"description": "Absenderadresse für ausgehende E-Mails (z.B. buero@vhtv-stiftung.de)",
"value": "buero@vhtv-stiftung.de",
"default_value": "buero@vhtv-stiftung.de",
"setting_type": "text",
"category": "email",
"order": 15,
},
]
all_settings = email_settings
created_count = 0
updated_count = 0
for setting_data in paperless_settings:
for setting_data in all_settings:
setting, created = AppConfiguration.objects.get_or_create(
key=setting_data["key"], defaults=setting_data
)

View File

@@ -0,0 +1,124 @@
# management/commands/migrate_paperless_dokumente.py
# Phase 3: Migriert DokumentLink-Einträge zu DokumentDatei (falls Paperless-Dateien lokal verfügbar)
#
# Verwendung:
# python manage.py migrate_paperless_dokumente [--dry-run] [--limit N]
#
# Was dieser Befehl tut:
# 1. Alle DokumentLink-Objekte abrufen (Paperless-Verweise)
# 2. Für jeden Link: DokumentDatei erstellen, falls noch keine existiert (paperless_dokument_id)
# 3. Suchvektor aktualisieren
# 4. paperless_dokument_id setzen, damit künftige Läufe Duplikate überspringen
import os
from django.core.management.base import BaseCommand, CommandError
from django.db import transaction
from stiftung.models import DokumentDatei, DokumentLink
class Command(BaseCommand):
help = "Migriert Paperless-DokumentLink-Einträge zu DokumentDatei (Metadaten only)"
def add_arguments(self, parser):
parser.add_argument(
"--dry-run",
action="store_true",
help="Zeigt an, was migriert würde, ohne Änderungen vorzunehmen.",
)
parser.add_argument(
"--limit",
type=int,
default=0,
help="Maximale Anzahl Einträge (0 = alle).",
)
def handle(self, *args, **options):
dry_run = options["dry_run"]
limit = options["limit"]
links = DokumentLink.objects.select_related(
"destinataer", "land", "paechter", "verpachtung"
).order_by("pk")
if limit > 0:
links = links[:limit]
total = links.count()
self.stdout.write(f"Gefundene DokumentLinks: {total}")
if dry_run:
self.stdout.write(self.style.WARNING("DRY-RUN keine Datenbankänderungen."))
created = 0
skipped = 0
for link in links:
# Bereits migriert?
if DokumentDatei.objects.filter(
paperless_dokument_id=link.paperless_document_id
).exists():
skipped += 1
continue
titel = link.titel or f"Paperless #{link.paperless_document_id}"
kontext = link.kontext or _guess_kontext(titel)
if dry_run:
self.stdout.write(
f" [DRY] Würde anlegen: {titel!r} (kontext={kontext}, "
f"paperless_id={link.paperless_document_id})"
)
created += 1
continue
with transaction.atomic():
dok = DokumentDatei(
titel=titel,
beschreibung=link.beschreibung or "",
kontext=kontext,
paperless_dokument_id=link.paperless_document_id,
)
# Assign FKs by ID (DokumentLink stores raw UUIDs, not FK relations)
if link.destinataer_id:
dok.destinataer_id = link.destinataer_id
if link.land_id:
dok.land_id = link.land_id
if link.paechter_id:
dok.paechter_id = link.paechter_id
if link.land_verpachtung_id:
dok.verpachtung_id = link.land_verpachtung_id
dok.save()
dok.update_suchvektor()
created += 1
self.stdout.write(
self.style.SUCCESS(
f"Fertig: {created} angelegt, {skipped} übersprungen (bereits migriert)."
)
)
def _guess_kontext(title_lower: str) -> str:
"""Leitet den Kontext-Code aus dem Titel ab."""
t = title_lower.lower()
if any(kw in t for kw in ["pachtvertrag", "pachtvertr"]):
return "pachtvertrag"
if any(kw in t for kw in ["antrag", "förderantrag"]):
return "antrag"
if any(kw in t for kw in ["nachweis", "verwendungsnachweis"]):
return "verwendungsnachweis"
if any(kw in t for kw in ["rechnung"]):
return "rechnung"
if any(kw in t for kw in ["bericht", "jahresbericht"]):
return "bericht"
if any(kw in t for kw in ["karte", "landkarte", "flurkarte"]):
return "landkarte"
if any(kw in t for kw in ["bescheid"]):
return "bescheid"
if any(kw in t for kw in ["korrespondenz", "brief"]):
return "korrespondenz"
if any(kw in t for kw in ["studium", "immatrikulation", "zeugnis"]):
return "studiennachweis"
return "anderes"

View File

@@ -0,0 +1,90 @@
"""
Migriert Legacy-Pachtdaten von Land-Feldern zu LandVerpachtung-Einträgen.
Die alte Struktur speichert Pachtdaten direkt auf dem Land-Model
(aktueller_paechter, pachtbeginn, pachtende, etc.).
Die neue Struktur nutzt das LandVerpachtung-Model (1:n).
"""
from decimal import Decimal
from django.core.management.base import BaseCommand
from django.db import transaction
from stiftung.models import Land, LandVerpachtung
class Command(BaseCommand):
help = "Migriert Land-Pachtfelder zu LandVerpachtung-Einträgen"
def add_arguments(self, parser):
parser.add_argument(
"--dry-run",
action="store_true",
help="Zeigt nur an, was gemacht würde",
)
def handle(self, *args, **options):
dry_run = options["dry_run"]
lands = Land.objects.filter(
aktueller_paechter__isnull=False,
).select_related("aktueller_paechter")
self.stdout.write(f"Land-Einträge mit aktueller_paechter: {lands.count()}")
created = 0
skipped = 0
with transaction.atomic():
for land in lands:
# Skip if LandVerpachtung already exists for this land+paechter
existing = LandVerpachtung.objects.filter(
land=land, paechter=land.aktueller_paechter
).exists()
if existing:
self.stdout.write(
self.style.WARNING(f" Übersprungen: {land} (bereits migriert)")
)
skipped += 1
continue
vertragsnummer = f"LEGACY-{land.lfd_nr}"
verpachtete_flaeche = land.verp_flaeche_aktuell or land.groesse_qm or Decimal("1.00")
pachtzins = land.pachtzins_pauschal or Decimal("0.00")
self.stdout.write(
f" Migriere: {land} -> {land.aktueller_paechter} "
f"(Beginn={land.pachtbeginn}, Ende={land.pachtende}, "
f"Fläche={verpachtete_flaeche}qm, Pachtzins={pachtzins}€)"
)
if not dry_run:
LandVerpachtung.objects.create(
land=land,
paechter=land.aktueller_paechter,
vertragsnummer=vertragsnummer,
pachtbeginn=land.pachtbeginn or land.erstellt_am.date(),
pachtende=land.pachtende,
verlaengerung_klausel=land.verlaengerung_klausel,
verpachtete_flaeche=verpachtete_flaeche,
pachtzins_pauschal=pachtzins,
pachtzins_pro_ha=land.pachtzins_pro_ha,
zahlungsweise=land.zahlungsweise or "jaehrlich",
ust_option=land.ust_option,
ust_satz=land.ust_satz or Decimal("19.00"),
grundsteuer_umlage=land.grundsteuer_umlage,
versicherungen_umlage=land.versicherungen_umlage,
verbandsbeitraege_umlage=land.verbandsbeitraege_umlage,
jagdpacht_anteil_umlage=land.jagdpacht_anteil_umlage,
status="aktiv",
bemerkungen=f"Automatisch migriert aus Land-Feldern (Lfd.Nr. {land.lfd_nr})",
)
created += 1
action = "würden erstellt" if dry_run else "erstellt"
self.stdout.write(
self.style.SUCCESS(
f"\n{created} LandVerpachtung-Einträge {action}, {skipped} übersprungen."
)
)

View 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.")
)

View File

@@ -0,0 +1,30 @@
# Generated by Django 5.0.6 on 2026-03-10 22:37
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('stiftung', '0045_add_serienbrief_editable_fields'),
]
operations = [
migrations.CreateModel(
name='BriefVorlage',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(max_length=100, verbose_name='Vorlagenname')),
('beschreibung', models.TextField(blank=True, help_text='Kurze Beschreibung des Verwendungszwecks dieser Vorlage.', verbose_name='Beschreibung')),
('briefvorlage', models.TextField(help_text='HTML-Text des Briefs. Verfügbare Platzhalter: {{ anrede }}, {{ vorname }}, {{ nachname }}, {{ strasse }}, {{ plz }}, {{ ort }}, {{ datum }}, {{ uhrzeit }}, {{ veranstaltungsort }}, {{ gasthaus_adresse }}', verbose_name='Brieftext (HTML)')),
('betreff', models.CharField(blank=True, help_text='Wird beim Laden der Vorlage in die Veranstaltung übernommen. Leer = unveränderter Betreff.', max_length=300, verbose_name='Standard-Betreff')),
('erstellt_am', models.DateTimeField(auto_now_add=True)),
('aktualisiert_am', models.DateTimeField(auto_now=True)),
],
options={
'verbose_name': 'Briefvorlage',
'verbose_name_plural': 'Briefvorlagen',
'ordering': ['name'],
},
),
]

View File

@@ -0,0 +1,45 @@
# Generated by Django 5.0.6 on 2026-03-11 10:39
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('stiftung', '0046_briefvorlage_model'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.AlterModelOptions(
name='applicationpermission',
options={'default_permissions': (), 'managed': False, 'permissions': [('manage_destinataere', 'Kann Destinatäre verwalten'), ('view_destinataere', 'Kann Destinatäre anzeigen'), ('manage_land', 'Kann Ländereien verwalten'), ('view_land', 'Kann Ländereien anzeigen'), ('manage_paechter', 'Kann Pächter verwalten'), ('view_paechter', 'Kann Pächter anzeigen'), ('manage_verpachtungen', 'Kann Verpachtungen verwalten'), ('view_verpachtungen', 'Kann Verpachtungen anzeigen'), ('manage_foerderungen', 'Kann Förderungen verwalten'), ('view_foerderungen', 'Kann Förderungen anzeigen'), ('manage_documents', 'Kann Dokumente verwalten'), ('view_documents', 'Kann Dokumente anzeigen'), ('link_documents', 'Kann Dokumente verknüpfen'), ('manage_verwaltungskosten', 'Kann Verwaltungskosten verwalten'), ('view_verwaltungskosten', 'Kann Verwaltungskosten anzeigen'), ('approve_payments', 'Kann Zahlungen genehmigen'), ('manage_konten', 'Kann Stiftungskonten verwalten'), ('view_konten', 'Kann Stiftungskonten anzeigen'), ('manage_rentmeister', 'Kann Rentmeister verwalten'), ('view_rentmeister', 'Kann Rentmeister anzeigen'), ('access_administration', 'Kann Administration aufrufen'), ('view_audit_logs', 'Kann Audit-Logs anzeigen'), ('manage_backups', 'Kann Backups erstellen und verwalten'), ('manage_users', 'Kann Benutzer verwalten'), ('manage_permissions', 'Kann Berechtigungen verwalten'), ('manage_veranstaltungen', 'Kann Veranstaltungen verwalten'), ('view_veranstaltungen', 'Kann Veranstaltungen anzeigen'), ('import_data', 'Kann Daten importieren'), ('export_data', 'Kann Daten exportieren'), ('access_django_admin', 'Kann Django Admin aufrufen'), ('view_system_stats', 'Kann Systemstatistiken anzeigen')]},
),
migrations.AddField(
model_name='destinataerunterstuetzung',
name='erstellt_von',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='erstellte_unterstuetzungen', to=settings.AUTH_USER_MODEL, verbose_name='Erstellt von'),
),
migrations.AddField(
model_name='destinataerunterstuetzung',
name='freigegeben_am',
field=models.DateField(blank=True, null=True, verbose_name='Freigegeben am'),
),
migrations.AddField(
model_name='destinataerunterstuetzung',
name='freigegeben_von',
field=models.ForeignKey(blank=True, help_text='Muss ein anderer Nutzer als der Ersteller sein (Vier-Augen-Prinzip)', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='freigegebene_unterstuetzungen', to=settings.AUTH_USER_MODEL, verbose_name='Freigegeben von (4-Augen)'),
),
migrations.AlterField(
model_name='destinataerunterstuetzung',
name='ausgezahlt_von',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='ausgezahlte_unterstuetzungen', to=settings.AUTH_USER_MODEL, verbose_name='Ausgezahlt von'),
),
migrations.AlterField(
model_name='destinataerunterstuetzung',
name='status',
field=models.CharField(choices=[('geplant', 'Offen'), ('faellig', 'Fällig'), ('nachweis_eingereicht', 'Nachweis eingereicht'), ('freigegeben', 'Freigegeben (4-Augen)'), ('in_bearbeitung', 'In Bearbeitung'), ('ausgezahlt', 'Überwiesen'), ('abgeschlossen', 'Abgeschlossen'), ('storniert', 'Storniert')], default='geplant', max_length=20, verbose_name='Status'),
),
]

View File

@@ -0,0 +1,51 @@
# Generated by Django 5.0.6 on 2026-03-11 11:09
import django.contrib.postgres.indexes
import django.contrib.postgres.search
import django.db.models.deletion
import stiftung.models.dokumente
import uuid
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('stiftung', '0047_phase2_zahlungs_pipeline'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name='DokumentDatei',
fields=[
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
('titel', models.CharField(max_length=255, verbose_name='Titel')),
('beschreibung', models.TextField(blank=True, verbose_name='Beschreibung')),
('kontext', models.CharField(choices=[('pachtvertrag', 'Pachtvertrag'), ('antrag', 'Antrag / Förderantrag'), ('verwendungsnachweis', 'Verwendungsnachweis'), ('studiennachweis', 'Studiennachweis'), ('rechnung', 'Rechnung'), ('vertrag', 'Vertrag'), ('bericht', 'Bericht'), ('landkarte', 'Landkarte / Kataster'), ('korrespondenz', 'Korrespondenz / Brief'), ('bescheid', 'Bescheid / Behörde'), ('anderes', 'Sonstiges')], default='anderes', max_length=30, verbose_name='Dokumententyp')),
('datei', models.FileField(upload_to=stiftung.models.dokumente.dokument_upload_path, verbose_name='Datei')),
('dateiname_original', models.CharField(blank=True, max_length=255, verbose_name='Originaldateiname')),
('dateityp', models.CharField(blank=True, max_length=100, verbose_name='MIME-Typ')),
('dateigroesse', models.PositiveIntegerField(default=0, verbose_name='Dateigröße (Bytes)')),
('inhaltstext', models.TextField(blank=True, help_text='Automatisch befüllt für PDF/TXT-Dateien. Basis für Volltextsuche.', verbose_name='Extrahierter Textinhalt')),
('suchvektor', django.contrib.postgres.search.SearchVectorField(blank=True, null=True, verbose_name='Such-Vektor (FTS)')),
('paperless_dokument_id', models.IntegerField(blank=True, help_text='Wird nach vollständiger Migration entfernt.', null=True, verbose_name='Paperless-ID (Migration)')),
('erstellt_am', models.DateTimeField(auto_now_add=True, verbose_name='Erstellt am')),
('aktualisiert_am', models.DateTimeField(auto_now=True, verbose_name='Aktualisiert am')),
('destinataer', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='dms_dokumente', to='stiftung.destinataer', verbose_name='Destinatär')),
('erstellt_von', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='hochgeladene_dokumente', to=settings.AUTH_USER_MODEL, verbose_name='Erstellt von')),
('foerderung', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='dms_dokumente', to='stiftung.foerderung', verbose_name='Förderung')),
('land', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='dms_dokumente', to='stiftung.land', verbose_name='Länderei')),
('paechter', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='dms_dokumente', to='stiftung.paechter', verbose_name='Pächter')),
('rentmeister', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='dms_dokumente', to='stiftung.rentmeister', verbose_name='Rentmeister')),
('verpachtung', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='dms_dokumente', to='stiftung.landverpachtung', verbose_name='Verpachtung')),
],
options={
'verbose_name': 'Dokument',
'verbose_name_plural': 'Dokumente (DMS)',
'ordering': ['-erstellt_am'],
'indexes': [django.contrib.postgres.indexes.GinIndex(fields=['suchvektor'], name='dms_suchvektor_gin_idx'), models.Index(fields=['kontext'], name='stiftung_do_kontext_c6a21e_idx'), models.Index(fields=['destinataer', 'kontext'], name='stiftung_do_destina_1189f2_idx'), models.Index(fields=['land', 'kontext'], name='stiftung_do_land_id_6668ac_idx'), models.Index(fields=['paechter', 'kontext'], name='stiftung_do_paechte_05586e_idx')],
},
),
]

View File

@@ -0,0 +1,33 @@
# Generated by Django 5.0.6 on 2026-03-12 08:11
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('stiftung', '0048_phase3_dms_dokument_datei'),
]
operations = [
migrations.AddField(
model_name='destinataeremaileingang',
name='dokument_dateien',
field=models.ManyToManyField(blank=True, help_text='Automatisch befüllte Anhänge als Django-DMS-Dateien.', related_name='email_eingaenge', to='stiftung.dokumentdatei', verbose_name='DMS-Dokumente (Anhänge)'),
),
migrations.AlterField(
model_name='appconfiguration',
name='category',
field=models.CharField(choices=[('paperless', 'Paperless Integration'), ('email', 'E-Mail / IMAP'), ('general', 'General Settings'), ('corporate', 'Corporate Identity'), ('notifications', 'Notifications'), ('system', 'System Settings')], default='general', max_length=50, verbose_name='Category'),
),
migrations.AlterField(
model_name='appconfiguration',
name='setting_type',
field=models.CharField(choices=[('text', 'Text'), ('password', 'Password'), ('number', 'Number'), ('boolean', 'Boolean'), ('url', 'URL'), ('tag', 'Tag Name'), ('tag_id', 'Tag ID')], default='text', max_length=20, verbose_name='Type'),
),
migrations.AlterField(
model_name='destinataeremaileingang',
name='paperless_dokument_ids',
field=models.JSONField(blank=True, default=list, help_text='Veraltet wird nach vollständiger Migration entfernt. Neue Anhänge in dokument_dateien.', verbose_name='Paperless Dokument-IDs (Anhänge, veraltet)'),
),
]

View File

@@ -0,0 +1,132 @@
# Phase 4: Generalize EmailEingang + Rechnungsworkflow
# - Rename DestinataerEmailEingang → EmailEingang
# - Add kategorie, verwaltungskosten FK, land FK, verpachtung FK
# - Expand status choices (rechnung_erfasst, zahlung_gebucht)
# - Add verwaltungskosten FK to DokumentDatei
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('stiftung', '0049_phase3_email_dms_m2m'),
]
operations = [
# 1. Rename model (preserves DB table, updates Django state)
migrations.RenameModel(
old_name='DestinataerEmailEingang',
new_name='EmailEingang',
),
# 2. Add kategorie field to EmailEingang
migrations.AddField(
model_name='emaileingang',
name='kategorie',
field=models.CharField(
choices=[
('destinataer', 'Destinataer'),
('rechnung', 'Rechnung'),
('land_pacht', 'Grundstueck / Pacht'),
('allgemein', 'Allgemein'),
],
default='allgemein',
max_length=20,
verbose_name='Kategorie',
),
),
# 3. Add verwaltungskosten FK to EmailEingang
migrations.AddField(
model_name='emaileingang',
name='verwaltungskosten',
field=models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name='email_eingaenge',
to='stiftung.verwaltungskosten',
verbose_name='Verwaltungskosten / Rechnung',
),
),
# 4. Add land FK to EmailEingang
migrations.AddField(
model_name='emaileingang',
name='land',
field=models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name='email_eingaenge',
to='stiftung.land',
verbose_name='Laenderei',
),
),
# 5. Add verpachtung FK to EmailEingang
migrations.AddField(
model_name='emaileingang',
name='verpachtung',
field=models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name='email_eingaenge',
to='stiftung.landverpachtung',
verbose_name='Verpachtung',
),
),
# 6. Update status choices on EmailEingang
migrations.AlterField(
model_name='emaileingang',
name='status',
field=models.CharField(
choices=[
('neu', 'Neu / Unbearbeitet'),
('zugewiesen', 'Destinataer zugewiesen'),
('verarbeitet', 'Verarbeitet'),
('rechnung_erfasst', 'Rechnung erfasst'),
('zahlung_gebucht', 'Zahlung gebucht'),
('unbekannt', 'Unbekannter Absender'),
('fehler', 'Fehler bei Verarbeitung'),
],
default='neu',
max_length=20,
verbose_name='Status',
),
),
# 7. Update Meta on EmailEingang
migrations.AlterModelOptions(
name='emaileingang',
options={
'ordering': ['-eingangsdatum'],
'verbose_name': 'E-Mail-Eingang',
'verbose_name_plural': 'E-Mail-Eingaenge',
},
),
# 8. Set kategorie='destinataer' for existing emails that have a destinataer FK
migrations.RunSQL(
sql="UPDATE stiftung_emaileingang SET kategorie = 'destinataer' WHERE destinataer_id IS NOT NULL;",
reverse_sql=migrations.RunSQL.noop,
),
# 9. Add verwaltungskosten FK to DokumentDatei
migrations.AddField(
model_name='dokumentdatei',
name='verwaltungskosten',
field=models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name='dms_dokumente',
to='stiftung.verwaltungskosten',
verbose_name='Verwaltungskosten / Rechnung',
),
),
]

View File

@@ -0,0 +1,29 @@
# Generated by Django 5.0.6 on 2026-03-12 09:45
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('stiftung', '0050_generalize_email_rechnungsworkflow'),
]
operations = [
migrations.AlterField(
model_name='emaileingang',
name='destinataer',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='email_eingaenge', to='stiftung.destinataer', verbose_name='Destinataer'),
),
migrations.AlterField(
model_name='emaileingang',
name='dokument_dateien',
field=models.ManyToManyField(blank=True, help_text='Automatisch befuellte Anhaenge als Django-DMS-Dateien.', related_name='email_eingaenge', to='stiftung.dokumentdatei', verbose_name='DMS-Dokumente (Anhaenge)'),
),
migrations.AlterField(
model_name='emaileingang',
name='paperless_dokument_ids',
field=models.JSONField(blank=True, default=list, help_text='Veraltet wird nach vollstaendiger Migration entfernt. Neue Anhaenge in dokument_dateien.', verbose_name='Paperless Dokument-IDs (Anhaenge, veraltet)'),
),
]

View File

@@ -0,0 +1,23 @@
# Generated by Django 5.0.6 on 2026-03-12 10:07
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('stiftung', '0051_alter_emaileingang_destinataer_and_more'),
]
operations = [
migrations.AlterField(
model_name='dokumentdatei',
name='kontext',
field=models.CharField(choices=[('pachtvertrag', 'Pachtvertrag'), ('antrag', 'Antrag / Förderantrag'), ('verwendungsnachweis', 'Verwendungsnachweis'), ('studiennachweis', 'Studiennachweis'), ('rechnung', 'Rechnung'), ('vertrag', 'Vertrag'), ('bericht', 'Bericht'), ('landkarte', 'Landkarte / Kataster'), ('korrespondenz', 'Korrespondenz / Brief'), ('bescheid', 'Bescheid / Behörde'), ('stiftungsgeschichte', 'Stiftungsgeschichte / Archiv'), ('anderes', 'Sonstiges')], default='anderes', max_length=30, verbose_name='Dokumententyp'),
),
migrations.AlterField(
model_name='emaileingang',
name='kategorie',
field=models.CharField(choices=[('destinataer', 'Destinataer'), ('rechnung', 'Rechnung'), ('land_pacht', 'Grundstueck / Pacht'), ('stiftungsgeschichte', 'Stiftungsgeschichte'), ('allgemein', 'Allgemein')], default='allgemein', max_length=20, verbose_name='Kategorie'),
),
]

View File

@@ -0,0 +1,18 @@
# Generated by Django 5.0.6 on 2026-03-12 10:40
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('stiftung', '0052_alter_dokumentdatei_kontext_and_more'),
]
operations = [
migrations.AddField(
model_name='geschichteseite',
name='dokumente',
field=models.ManyToManyField(blank=True, related_name='geschichte_seiten', to='stiftung.dokumentdatei', verbose_name='Verknüpfte Dokumente'),
),
]

View File

@@ -0,0 +1,18 @@
# Generated by Django 5.0.6 on 2026-03-13 21:57
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('stiftung', '0053_geschichte_dokumente_m2m'),
]
operations = [
migrations.AddField(
model_name='land',
name='alkis_kennzeichen',
field=models.CharField(blank=True, help_text='z.B. 05300800400030______ — für direkte Verlinkung zum Katasteramt', max_length=30, null=True, verbose_name='ALKIS Flurstückskennzeichen'),
),
]

View File

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

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

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

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,60 @@
# models/ package re-exports all models for backward compatibility
# Phase 0: Vision 2026 Code-Refactoring
from .system import ( # noqa: F401
AppConfiguration,
ApplicationPermission,
AuditLog,
BackupJob,
CSVImport,
HelpBox,
)
from .dokumente import ( # noqa: F401
DokumentDatei,
)
from .land import ( # noqa: F401
DokumentLink,
Land,
LandAbrechnung,
LandVerpachtung,
Paechter,
)
from .finanzen import ( # noqa: F401
BankTransaction,
Rentmeister,
StiftungsKonto,
Verwaltungskosten,
)
from .destinataere import ( # noqa: F401
Destinataer,
DestinataerEmailEingang,
EmailEingang,
DestinataerNotiz,
DestinataerUnterstuetzung,
Foerderung,
OnboardingEinladung,
Person,
UnterstuetzungWiederkehrend,
UploadToken,
VierteljahresNachweis,
)
from .geschichte import ( # noqa: F401
GeschichteBild,
GeschichteSeite,
StiftungsKalenderEintrag,
)
from .veranstaltungen import ( # noqa: F401
BriefVorlage,
Veranstaltung,
Veranstaltungsteilnehmer,
)
from .vorlagen import ( # noqa: F401
DokumentVorlage,
)

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,189 @@
# models/dokumente.py
# Phase 3: Django-natives DMS ersetzt Paperless-NGX-Integration
import uuid
import os
from django.contrib.postgres.indexes import GinIndex
from django.contrib.postgres.search import SearchVector, SearchVectorField
from django.db import models
from django.utils import timezone
def dokument_upload_path(instance, filename):
"""Speichert Dateien in MEDIA_ROOT/dokumente/YYYY/MM/<uuid>/<original_filename>"""
ext = os.path.splitext(filename)[1].lower()
safe_name = os.path.basename(filename)[:100]
return f"dokumente/{timezone.now().strftime('%Y/%m')}/{instance.id}/{safe_name}"
class DokumentDatei(models.Model):
"""Nativ gespeicherte Datei im Django-DMS ersetzt Paperless-Referenzen."""
KONTEXT_CHOICES = [
("pachtvertrag", "Pachtvertrag"),
("antrag", "Antrag / Förderantrag"),
("verwendungsnachweis", "Verwendungsnachweis"),
("studiennachweis", "Studiennachweis"),
("rechnung", "Rechnung"),
("vertrag", "Vertrag"),
("bericht", "Bericht"),
("landkarte", "Landkarte / Kataster"),
("korrespondenz", "Korrespondenz / Brief"),
("bescheid", "Bescheid / Behörde"),
("stiftungsgeschichte", "Stiftungsgeschichte / Archiv"),
("email", "E-Mail-Nachricht"),
("anderes", "Sonstiges"),
]
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
titel = models.CharField(max_length=255, verbose_name="Titel")
beschreibung = models.TextField(blank=True, verbose_name="Beschreibung")
kontext = models.CharField(
max_length=30,
choices=KONTEXT_CHOICES,
default="anderes",
verbose_name="Dokumententyp",
)
datei = models.FileField(
upload_to=dokument_upload_path,
verbose_name="Datei",
)
dateiname_original = models.CharField(
max_length=255, blank=True, verbose_name="Originaldateiname"
)
dateityp = models.CharField(
max_length=100, blank=True, verbose_name="MIME-Typ"
)
dateigroesse = models.PositiveIntegerField(
default=0, verbose_name="Dateigröße (Bytes)"
)
# Volltext-Index (PostgreSQL FTS, befüllt per Signal)
inhaltstext = models.TextField(
blank=True,
verbose_name="Extrahierter Textinhalt",
help_text="Automatisch befüllt für PDF/TXT-Dateien. Basis für Volltextsuche.",
)
suchvektor = SearchVectorField(
null=True, blank=True, verbose_name="Such-Vektor (FTS)"
)
# Zuordnungsfelder optional, ein Dokument kann mehreren Entitäten gehören
land = models.ForeignKey(
"Land",
on_delete=models.SET_NULL,
null=True, blank=True,
related_name="dms_dokumente",
verbose_name="Länderei",
)
paechter = models.ForeignKey(
"Paechter",
on_delete=models.SET_NULL,
null=True, blank=True,
related_name="dms_dokumente",
verbose_name="Pächter",
)
verpachtung = models.ForeignKey(
"LandVerpachtung",
on_delete=models.SET_NULL,
null=True, blank=True,
related_name="dms_dokumente",
verbose_name="Verpachtung",
)
destinataer = models.ForeignKey(
"Destinataer",
on_delete=models.SET_NULL,
null=True, blank=True,
related_name="dms_dokumente",
verbose_name="Destinatär",
)
foerderung = models.ForeignKey(
"Foerderung",
on_delete=models.SET_NULL,
null=True, blank=True,
related_name="dms_dokumente",
verbose_name="Förderung",
)
rentmeister = models.ForeignKey(
"Rentmeister",
on_delete=models.SET_NULL,
null=True, blank=True,
related_name="dms_dokumente",
verbose_name="Rentmeister",
)
verwaltungskosten = models.ForeignKey(
"Verwaltungskosten",
on_delete=models.SET_NULL,
null=True, blank=True,
related_name="dms_dokumente",
verbose_name="Verwaltungskosten / Rechnung",
)
# Herkunft (optional: Verweis auf altes Paperless-Dokument zur Rückverfolgung)
paperless_dokument_id = models.IntegerField(
null=True, blank=True,
verbose_name="Paperless-ID (Migration)",
help_text="Wird nach vollständiger Migration entfernt.",
)
# Audit
erstellt_von = models.ForeignKey(
"auth.User",
on_delete=models.SET_NULL,
null=True, blank=True,
related_name="hochgeladene_dokumente",
verbose_name="Erstellt von",
)
erstellt_am = models.DateTimeField(auto_now_add=True, verbose_name="Erstellt am")
aktualisiert_am = models.DateTimeField(auto_now=True, verbose_name="Aktualisiert am")
class Meta:
verbose_name = "Dokument"
verbose_name_plural = "Dokumente (DMS)"
ordering = ["-erstellt_am"]
indexes = [
# PostgreSQL GIN-Index für Volltextsuche
GinIndex(fields=["suchvektor"], name="dms_suchvektor_gin_idx"),
models.Index(fields=["kontext"]),
models.Index(fields=["destinataer", "kontext"]),
models.Index(fields=["land", "kontext"]),
models.Index(fields=["paechter", "kontext"]),
]
def __str__(self):
return self.titel or self.dateiname_original or str(self.id)
def save(self, *args, **kwargs):
# Originaldateiname aus FileField ableiten
if self.datei and not self.dateiname_original:
self.dateiname_original = os.path.basename(self.datei.name)
super().save(*args, **kwargs)
def update_suchvektor(self):
"""Aktualisiert den Such-Vektor aus Titel, Beschreibung und Inhaltstext."""
DokumentDatei.objects.filter(pk=self.pk).update(
suchvektor=SearchVector("titel", weight="A")
+ SearchVector("beschreibung", weight="B")
+ SearchVector("inhaltstext", weight="C"),
)
def get_datei_url(self):
"""Gibt die Download-URL zurück."""
if self.datei:
return self.datei.url
return None
def is_pdf(self):
return self.dateityp == "application/pdf" or (
self.dateiname_original and self.dateiname_original.lower().endswith(".pdf")
)
def get_human_size(self):
"""Gibt die Dateigröße leserlich zurück."""
size = self.dateigroesse
if size < 1024:
return f"{size} B"
elif size < 1024 * 1024:
return f"{size / 1024:.1f} KB"
else:
return f"{size / (1024 * 1024):.1f} MB"

View File

@@ -0,0 +1,385 @@
import uuid
from django.db import models
class Rentmeister(models.Model):
"""Geschäftsführer der Stiftung (natürliche Personen)"""
ANREDE_CHOICES = [
("herr", "Herr"),
("frau", "Frau"),
("dr", "Dr."),
("prof", "Prof."),
("prof_dr", "Prof. Dr."),
]
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
anrede = models.CharField(
max_length=10, choices=ANREDE_CHOICES, blank=True, verbose_name="Anrede"
)
vorname = models.CharField(max_length=100, verbose_name="Vorname")
nachname = models.CharField(max_length=100, verbose_name="Nachname")
titel = models.CharField(max_length=50, blank=True, verbose_name="Titel")
# Kontaktdaten
email = models.EmailField(blank=True, verbose_name="E-Mail")
telefon = models.CharField(max_length=20, blank=True, verbose_name="Telefon")
mobil = models.CharField(max_length=20, blank=True, verbose_name="Mobil")
# Adresse
strasse = models.CharField(max_length=200, blank=True, verbose_name="Straße")
plz = models.CharField(max_length=10, blank=True, verbose_name="PLZ")
ort = models.CharField(max_length=100, blank=True, verbose_name="Ort")
# Bankdaten für Abrechnungen
iban = models.CharField(max_length=34, blank=True, verbose_name="IBAN")
bic = models.CharField(max_length=11, blank=True, verbose_name="BIC")
bank_name = models.CharField(max_length=100, blank=True, verbose_name="Bank")
# Stiftungs-spezifisch
seit_datum = models.DateField(verbose_name="Rentmeister seit")
bis_datum = models.DateField(null=True, blank=True, verbose_name="Rentmeister bis")
aktiv = models.BooleanField(default=True, verbose_name="Aktiv")
# Vergütung/Aufwandsentschädigung
monatliche_verguetung = models.DecimalField(
max_digits=8,
decimal_places=2,
null=True,
blank=True,
verbose_name="Monatliche Vergütung (€)",
)
km_pauschale = models.DecimalField(
max_digits=4,
decimal_places=2,
default=0.30,
verbose_name="Kilometerpauschale (€/km)",
)
notizen = models.TextField(blank=True, verbose_name="Notizen")
erstellt_am = models.DateTimeField(auto_now_add=True)
aktualisiert_am = models.DateTimeField(auto_now=True)
class Meta:
verbose_name = "Rentmeister"
verbose_name_plural = "Rentmeister"
ordering = ["nachname", "vorname"]
def __str__(self):
name_parts = []
if self.anrede:
name_parts.append(self.get_anrede_display())
if self.vorname:
name_parts.append(self.vorname)
name_parts.append(self.nachname)
if self.titel:
name_parts.append(f"({self.titel})")
return " ".join(name_parts)
def get_full_name(self):
"""Vollständiger Name ohne Anrede"""
if self.vorname:
return f"{self.vorname} {self.nachname}"
return self.nachname
def get_address(self):
"""Vollständige Adresse als String"""
parts = []
if self.strasse:
parts.append(self.strasse)
if self.plz and self.ort:
parts.append(f"{self.plz} {self.ort}")
elif self.ort:
parts.append(self.ort)
return ", ".join(parts)
class StiftungsKonto(models.Model):
"""Bankkonten der Stiftung"""
KONTO_TYP_CHOICES = [
("girokonto", "Girokonto"),
("sparkonto", "Sparkonto"),
("festgeld", "Festgeld"),
("tagesgeld", "Tagesgeld"),
("depot", "Depot"),
("sonstiges", "Sonstiges"),
]
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
kontoname = models.CharField(max_length=200, verbose_name="Kontoname")
bank_name = models.CharField(max_length=200, verbose_name="Bank")
iban = models.CharField(max_length=34, verbose_name="IBAN")
bic = models.CharField(max_length=11, blank=True, verbose_name="BIC")
konto_typ = models.CharField(
max_length=20,
choices=KONTO_TYP_CHOICES,
default="girokonto",
verbose_name="Kontotyp",
)
saldo = models.DecimalField(
max_digits=10, decimal_places=2, default=0.00, verbose_name="Aktueller Saldo"
)
saldo_datum = models.DateField(null=True, blank=True, verbose_name="Saldo-Datum")
zinssatz = models.DecimalField(
max_digits=5,
decimal_places=2,
null=True,
blank=True,
verbose_name="Zinssatz (%)",
)
laufzeit_bis = models.DateField(null=True, blank=True, verbose_name="Laufzeit bis")
aktiv = models.BooleanField(default=True, verbose_name="Aktiv")
notizen = models.TextField(blank=True, verbose_name="Notizen")
erstellt_am = models.DateTimeField(auto_now_add=True)
aktualisiert_am = models.DateTimeField(auto_now=True)
class Meta:
verbose_name = "Stiftungskonto"
verbose_name_plural = "Stiftungskonten"
ordering = ["bank_name", "kontoname"]
def __str__(self):
return f"{self.bank_name} - {self.kontoname}"
class BankTransaction(models.Model):
"""Banktransaktionen aus importierten Kontodaten"""
TRANSACTION_TYPE_CHOICES = [
("eingang", "Eingang"),
("ausgang", "Ausgang"),
("lastschrift", "Lastschrift"),
("ueberweisung", "Überweisung"),
("dauerauftrag", "Dauerauftrag"),
("kartenzahlung", "Kartenzahlung"),
("zinsen", "Zinsen"),
("gebuehren", "Gebühren"),
("sonstiges", "Sonstiges"),
]
STATUS_CHOICES = [
("imported", "Importiert"),
("verified", "Geprüft"),
("assigned", "Zugeordnet"),
("ignored", "Ignoriert"),
]
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
konto = models.ForeignKey(
StiftungsKonto, on_delete=models.CASCADE, verbose_name="Konto"
)
# Transaktionsdaten
datum = models.DateField(verbose_name="Buchungsdatum")
valuta = models.DateField(null=True, blank=True, verbose_name="Valutadatum")
betrag = models.DecimalField(
max_digits=12, decimal_places=2, verbose_name="Betrag (€)"
)
waehrung = models.CharField(max_length=3, default="EUR", verbose_name="Währung")
# Transaktionsdetails
verwendungszweck = models.TextField(verbose_name="Verwendungszweck")
empfaenger_zahlungspflichtiger = models.CharField(
max_length=200, blank=True, verbose_name="Empfänger/Zahlungspflichtiger"
)
iban_gegenpartei = models.CharField(
max_length=34, blank=True, verbose_name="IBAN Gegenpartei"
)
bic_gegenpartei = models.CharField(
max_length=11, blank=True, verbose_name="BIC Gegenpartei"
)
# Bankspezifische Daten
referenz = models.CharField(
max_length=100, blank=True, verbose_name="Referenz/Transaktions-ID"
)
transaction_type = models.CharField(
max_length=20,
choices=TRANSACTION_TYPE_CHOICES,
default="sonstiges",
verbose_name="Transaktionsart",
)
# Verwaltung
status = models.CharField(
max_length=20, choices=STATUS_CHOICES, default="imported", verbose_name="Status"
)
kommentare = models.TextField(blank=True, verbose_name="Kommentare")
verwaltungskosten = models.ForeignKey(
"Verwaltungskosten",
null=True,
blank=True,
on_delete=models.SET_NULL,
verbose_name="Zugeordnete Verwaltungskosten",
)
# Import-Metadaten
import_datei = models.CharField(
max_length=255, blank=True, verbose_name="Import-Datei"
)
importiert_am = models.DateTimeField(
auto_now_add=True, verbose_name="Importiert am"
)
saldo_nach_buchung = models.DecimalField(
max_digits=12,
decimal_places=2,
null=True,
blank=True,
verbose_name="Saldo nach Buchung",
)
class Meta:
verbose_name = "Banktransaktion"
verbose_name_plural = "Banktransaktionen"
ordering = ["-datum", "-importiert_am"]
unique_together = ["konto", "datum", "betrag", "referenz"] # Prevent duplicates
def __str__(self):
return f"{self.datum} - {self.betrag}€ - {self.verwendungszweck[:50]}"
def is_income(self):
"""Prüft ob es sich um einen Geldeingang handelt"""
return self.betrag > 0
def get_absolute_amount(self):
"""Gibt den absoluten Betrag zurück"""
return abs(self.betrag)
class Verwaltungskosten(models.Model):
"""Administrative Kosten und Ausgaben der Stiftung"""
KATEGORIE_CHOICES = [
("rechnung_intern", "Interne Rechnung"),
("bueroausstattung", "Büroausstattung"),
("fahrtkosten", "Fahrtkosten"),
("porto", "Porto & Versand"),
("telefon_internet", "Telefon & Internet"),
("software", "Software & Lizenzen"),
("beratung", "Beratung & Dienstleistungen"),
("versicherung", "Versicherungen"),
("steuerberatung", "Steuerberatung"),
("bankgebuehren", "Bankgebühren"),
("sonstiges", "Sonstiges"),
]
STATUS_CHOICES = [
("geplant", "Geplant"),
("bestellt", "Bestellt"),
("erhalten", "Erhalten"),
("in_bearbeitung", "In Bearbeitung"),
("bezahlt", "Bezahlt"),
("storniert", "Storniert"),
]
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
bezeichnung = models.CharField(max_length=200, verbose_name="Bezeichnung")
kategorie = models.CharField(
max_length=30, choices=KATEGORIE_CHOICES, verbose_name="Kategorie"
)
betrag = models.DecimalField(
max_digits=10, decimal_places=2, verbose_name="Betrag (€)"
)
datum = models.DateField(verbose_name="Datum")
lieferant_firma = models.CharField(
max_length=200, blank=True, verbose_name="Lieferant/Firma"
)
rechnungsnummer = models.CharField(
max_length=100, blank=True, verbose_name="Rechnungsnummer"
)
status = models.CharField(
max_length=20, choices=STATUS_CHOICES, default="geplant", verbose_name="Status"
)
# Zuständigkeit und Zahlung
rentmeister = models.ForeignKey(
Rentmeister,
on_delete=models.SET_NULL,
null=True,
blank=True,
verbose_name="Zuständiger Rentmeister",
)
zahlungskonto = models.ForeignKey(
StiftungsKonto,
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name="zahlungen",
verbose_name="Zahlungskonto",
)
quellkonto = models.ForeignKey(
StiftungsKonto,
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name="ausgaben",
verbose_name="Quellkonto",
)
# Legacy field für Rückwärtskompatibilität
konto = models.ForeignKey(
StiftungsKonto,
on_delete=models.SET_NULL,
null=True,
blank=True,
verbose_name="Konto (Legacy)",
help_text="Veraltet - verwende Zahlungskonto und Quellkonto",
)
# Fahrtkosten spezifisch
km_anzahl = models.DecimalField(
max_digits=8, decimal_places=1, null=True, blank=True, verbose_name="Kilometer"
)
km_satz = models.DecimalField(
max_digits=4, decimal_places=2, null=True, blank=True, verbose_name="€/km"
)
von_ort = models.CharField(max_length=100, blank=True, verbose_name="Von (Ort)")
nach_ort = models.CharField(max_length=100, blank=True, verbose_name="Nach (Ort)")
zweck = models.CharField(max_length=200, blank=True, verbose_name="Zweck der Fahrt")
beschreibung = models.TextField(blank=True, verbose_name="Beschreibung")
notizen = models.TextField(blank=True, verbose_name="Notizen")
erstellt_am = models.DateTimeField(auto_now_add=True)
aktualisiert_am = models.DateTimeField(auto_now=True)
class Meta:
verbose_name = "Verwaltungskosten"
verbose_name_plural = "Verwaltungskosten"
ordering = ["-datum", "-erstellt_am"]
def __str__(self):
return f"{self.bezeichnung} - €{self.betrag} ({self.datum})"
def get_status_color(self):
colors = {
"geplant": "secondary",
"bestellt": "warning",
"erhalten": "info",
"in_bearbeitung": "primary",
"bezahlt": "success",
"storniert": "danger",
}
return colors.get(self.status, "secondary")
def get_effective_zahlungskonto(self):
"""Gibt das Zahlungskonto zurück, fallback auf Legacy-Konto"""
return self.zahlungskonto or self.konto
def get_effective_quellkonto(self):
"""Gibt das Quellkonto zurück, fallback auf Zahlungskonto oder Legacy-Konto"""
return self.quellkonto or self.zahlungskonto or self.konto
def is_fahrtkosten(self):
"""Prüft ob es sich um Fahrtkosten handelt"""
return self.kategorie == "fahrtkosten"
def calculate_fahrtkosten(self):
"""Berechnet Fahrtkosten automatisch wenn km_anzahl und km_satz gesetzt sind"""
if self.km_anzahl and self.km_satz:
return self.km_anzahl * self.km_satz
return None

View File

@@ -0,0 +1,221 @@
import uuid
from django.db import models
from django.utils import timezone
class GeschichteSeite(models.Model):
"""Wiki-style pages for foundation history"""
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
titel = models.CharField(max_length=200, verbose_name="Titel")
slug = models.SlugField(max_length=200, unique=True, verbose_name="URL-Slug")
inhalt = models.TextField(
verbose_name="Inhalt (Markdown)",
blank=True,
help_text="Sie können Markdown verwenden: **fett**, *kursiv*, # Überschriften, [Links](URL), Listen, etc."
)
# Metadata
erstellt_am = models.DateTimeField(auto_now_add=True, verbose_name="Erstellt am")
aktualisiert_am = models.DateTimeField(auto_now=True, verbose_name="Aktualisiert am")
erstellt_von = models.ForeignKey(
'auth.User',
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name='geschichte_seiten_erstellt',
verbose_name="Erstellt von"
)
aktualisiert_von = models.ForeignKey(
'auth.User',
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name='geschichte_seiten_aktualisiert',
verbose_name="Aktualisiert von"
)
# Verknüpfte DMS-Dokumente
dokumente = models.ManyToManyField(
"DokumentDatei",
blank=True,
related_name="geschichte_seiten",
verbose_name="Verknüpfte Dokumente",
)
# Options
ist_veroeffentlicht = models.BooleanField(default=True, verbose_name="Veröffentlicht")
sortierung = models.IntegerField(default=0, verbose_name="Sortierung")
class Meta:
verbose_name = "Geschichte Seite"
verbose_name_plural = "Geschichte Seiten"
ordering = ['sortierung', 'titel']
def __str__(self):
return self.titel
def get_absolute_url(self):
from django.urls import reverse
return reverse('stiftung:geschichte_detail', kwargs={'slug': self.slug})
class GeschichteBild(models.Model):
"""Images for history pages"""
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
seite = models.ForeignKey(
GeschichteSeite,
on_delete=models.CASCADE,
related_name='bilder',
verbose_name="Geschichte Seite"
)
titel = models.CharField(max_length=200, verbose_name="Bildtitel")
bild = models.ImageField(
upload_to='geschichte/bilder/%Y/%m/',
verbose_name="Bild"
)
beschreibung = models.TextField(blank=True, verbose_name="Beschreibung")
alt_text = models.CharField(max_length=200, blank=True, verbose_name="Alt-Text")
# Metadata
hochgeladen_am = models.DateTimeField(auto_now_add=True, verbose_name="Hochgeladen am")
hochgeladen_von = models.ForeignKey(
'auth.User',
on_delete=models.SET_NULL,
null=True,
blank=True,
verbose_name="Hochgeladen von"
)
sortierung = models.IntegerField(default=0, verbose_name="Sortierung")
class Meta:
verbose_name = "Geschichte Bild"
verbose_name_plural = "Geschichte Bilder"
ordering = ['sortierung', 'titel']
def __str__(self):
return f"{self.titel} ({self.seite.titel})"
class StiftungsKalenderEintrag(models.Model):
"""Custom calendar events for foundation management"""
KATEGORIE_CHOICES = [
('termin', 'Termin/Meeting'),
('zahlung', 'Zahlungserinnerung'),
('deadline', 'Frist/Deadline'),
('geburtstag', 'Geburtstag'),
('vertrag', 'Vertrag läuft aus'),
('pruefung', 'Prüfung/Nachweis'),
('sonstiges', 'Sonstiges'),
]
PRIORITAET_CHOICES = [
('niedrig', 'Niedrig'),
('normal', 'Normal'),
('hoch', 'Hoch'),
('kritisch', 'Kritisch'),
]
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
titel = models.CharField(max_length=200, verbose_name="Titel")
beschreibung = models.TextField(blank=True, verbose_name="Beschreibung")
# Date and time
datum = models.DateField(verbose_name="Datum")
uhrzeit = models.TimeField(null=True, blank=True, verbose_name="Uhrzeit")
ganztags = models.BooleanField(default=True, verbose_name="Ganztägig")
# Categorization
kategorie = models.CharField(
max_length=20,
choices=KATEGORIE_CHOICES,
default='termin',
verbose_name="Kategorie"
)
prioritaet = models.CharField(
max_length=20,
choices=PRIORITAET_CHOICES,
default='normal',
verbose_name="Priorität"
)
# Links to related objects
destinataer = models.ForeignKey(
'stiftung.Destinataer',
null=True,
blank=True,
on_delete=models.CASCADE,
verbose_name="Bezogener Destinatär"
)
verpachtung = models.ForeignKey(
'stiftung.LandVerpachtung',
null=True,
blank=True,
on_delete=models.CASCADE,
verbose_name="Bezogene Verpachtung"
)
# Status and completion
erledigt = models.BooleanField(default=False, verbose_name="Erledigt")
erledigt_am = models.DateTimeField(null=True, blank=True, verbose_name="Erledigt am")
# Metadata
erstellt_von = models.CharField(
max_length=100,
null=True,
blank=True,
verbose_name="Erstellt von"
)
erstellt_am = models.DateTimeField(auto_now_add=True, verbose_name="Erstellt am")
aktualisiert_am = models.DateTimeField(auto_now=True, verbose_name="Aktualisiert am")
class Meta:
verbose_name = "Kalender Eintrag"
verbose_name_plural = "Kalender Einträge"
ordering = ['datum', 'uhrzeit']
indexes = [
models.Index(fields=['datum']),
models.Index(fields=['kategorie', 'datum']),
models.Index(fields=['erledigt', 'datum']),
]
def __str__(self):
return f"{self.datum}: {self.titel}"
def get_kategorie_icon(self):
icons = {
'termin': 'fas fa-calendar-alt',
'zahlung': 'fas fa-euro-sign',
'deadline': 'fas fa-exclamation-triangle',
'geburtstag': 'fas fa-birthday-cake',
'vertrag': 'fas fa-file-contract',
'pruefung': 'fas fa-clipboard-check',
'sonstiges': 'fas fa-calendar',
}
return icons.get(self.kategorie, 'fas fa-calendar')
def get_prioritaet_color(self):
colors = {
'niedrig': 'success',
'normal': 'primary',
'hoch': 'warning',
'kritisch': 'danger',
}
return colors.get(self.prioritaet, 'primary')
def is_overdue(self):
"""Check if event is overdue (past due and not completed)"""
if self.erledigt:
return False
return self.datum < timezone.now().date()
def is_upcoming(self, days=7):
"""Check if event is upcoming within specified days"""
if self.erledigt:
return False
today = timezone.now().date()
return today <= self.datum <= (today + timezone.timedelta(days=days))

1089
app/stiftung/models/land.py Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,479 @@
import uuid
from django.db import models
class CSVImport(models.Model):
"""Track CSV import operations for audit purposes"""
IMPORT_TYPE_CHOICES = [
("destinataere", "Destinatäre"),
("paechter", "Pächter"),
("laendereien", "Ländereien"),
("verpachtungen", "Verpachtungen"),
("foerderungen", "Förderungen"),
("konten", "Stiftungskonten"),
("verwaltungskosten", "Verwaltungskosten"),
("rentmeister", "Rentmeister"),
("personen", "Personen (Legacy)"),
]
STATUS_CHOICES = [
("pending", "Ausstehend"),
("processing", "Wird verarbeitet"),
("completed", "Abgeschlossen"),
("failed", "Fehlgeschlagen"),
("partial", "Teilweise erfolgreich"),
]
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
import_type = models.CharField(
max_length=20, choices=IMPORT_TYPE_CHOICES, verbose_name="Import-Typ"
)
filename = models.CharField(max_length=255, verbose_name="Dateiname")
file_size = models.IntegerField(verbose_name="Dateigröße (Bytes)")
status = models.CharField(max_length=20, choices=STATUS_CHOICES, default="pending")
# Results
total_rows = models.IntegerField(default=0, verbose_name="Gesamtzeilen")
imported_rows = models.IntegerField(default=0, verbose_name="Importierte Zeilen")
failed_rows = models.IntegerField(default=0, verbose_name="Fehlgeschlagene Zeilen")
error_log = models.TextField(null=True, blank=True, verbose_name="Fehlerprotokoll")
# Metadata
created_by = models.CharField(
max_length=100, null=True, blank=True, verbose_name="Erstellt von"
)
started_at = models.DateTimeField(auto_now_add=True, verbose_name="Gestartet um")
completed_at = models.DateTimeField(
null=True, blank=True, verbose_name="Abgeschlossen um"
)
class Meta:
verbose_name = "CSV Import"
verbose_name_plural = "CSV Imports"
ordering = ["-started_at"]
def __str__(self):
return f"{self.get_import_type_display()} - {self.filename} ({self.status})"
def get_duration(self):
"""Calculate import duration"""
if self.completed_at and self.started_at:
return self.completed_at - self.started_at
return None
def get_success_rate(self):
"""Calculate success rate percentage"""
if self.total_rows > 0:
return (self.imported_rows / self.total_rows) * 100
return 0
class ApplicationPermission(models.Model):
"""Custom permissions for application functions"""
class Meta:
managed = False # No database table creation
default_permissions = () # Remove default Django permissions
permissions = [
# Entity Management Permissions
("manage_destinataere", "Kann Destinatäre verwalten"),
("view_destinataere", "Kann Destinatäre anzeigen"),
("manage_land", "Kann Ländereien verwalten"),
("view_land", "Kann Ländereien anzeigen"),
("manage_paechter", "Kann Pächter verwalten"),
("view_paechter", "Kann Pächter anzeigen"),
("manage_verpachtungen", "Kann Verpachtungen verwalten"),
("view_verpachtungen", "Kann Verpachtungen anzeigen"),
("manage_foerderungen", "Kann Förderungen verwalten"),
("view_foerderungen", "Kann Förderungen anzeigen"),
# Document Management Permissions
("manage_documents", "Kann Dokumente verwalten"),
("view_documents", "Kann Dokumente anzeigen"),
("link_documents", "Kann Dokumente verknüpfen"),
# Financial Management Permissions
("manage_verwaltungskosten", "Kann Verwaltungskosten verwalten"),
("view_verwaltungskosten", "Kann Verwaltungskosten anzeigen"),
("approve_payments", "Kann Zahlungen genehmigen"),
("manage_konten", "Kann Stiftungskonten verwalten"),
("view_konten", "Kann Stiftungskonten anzeigen"),
("manage_rentmeister", "Kann Rentmeister verwalten"),
("view_rentmeister", "Kann Rentmeister anzeigen"),
# Administration Permissions
("access_administration", "Kann Administration aufrufen"),
("view_audit_logs", "Kann Audit-Logs anzeigen"),
("manage_backups", "Kann Backups erstellen und verwalten"),
("manage_users", "Kann Benutzer verwalten"),
("manage_permissions", "Kann Berechtigungen verwalten"),
# Veranstaltungen Permissions
("manage_veranstaltungen", "Kann Veranstaltungen verwalten"),
("view_veranstaltungen", "Kann Veranstaltungen anzeigen"),
# Import/Export Permissions
("import_data", "Kann Daten importieren"),
("export_data", "Kann Daten exportieren"),
# System Permissions
("access_django_admin", "Kann Django Admin aufrufen"),
("view_system_stats", "Kann Systemstatistiken anzeigen"),
# AI Agent Permissions
("can_use_agent", "Kann AI-Assistenten nutzen"),
]
class AuditLog(models.Model):
"""Audit Log für alle Benutzeraktionen im System"""
ACTION_TYPES = [
("create", "Erstellt"),
("update", "Aktualisiert"),
("delete", "Gelöscht"),
("link", "Verknüpft"),
("unlink", "Verknüpfung entfernt"),
("login", "Anmeldung"),
("logout", "Abmeldung"),
("backup", "Backup erstellt"),
("restore", "Wiederherstellung"),
("export", "Export"),
("import", "Import"),
]
ENTITY_TYPES = [
("destinataer", "Destinatär"),
("land", "Länderei"),
("paechter", "Pächter"),
("verpachtung", "Verpachtung"),
("foerderung", "Förderung"),
("rentmeister", "Rentmeister"),
("stiftungskonto", "Stiftungskonto"),
("verwaltungskosten", "Verwaltungskosten"),
("banktransaction", "Bank-Transaktion"),
("dokumentlink", "Dokument-Verknüpfung"),
("system", "System"),
("user", "Benutzer"),
]
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
# Benutzer und Zeitpunkt
user = models.ForeignKey(
"auth.User", on_delete=models.SET_NULL, null=True, verbose_name="Benutzer"
)
username = models.CharField(
max_length=150, verbose_name="Benutzername"
) # Fallback falls User gelöscht wird
timestamp = models.DateTimeField(auto_now_add=True, verbose_name="Zeitpunkt")
# Aktion
action = models.CharField(
max_length=20, choices=ACTION_TYPES, verbose_name="Aktion"
)
entity_type = models.CharField(
max_length=20, choices=ENTITY_TYPES, verbose_name="Entitätstyp"
)
entity_id = models.CharField(max_length=100, blank=True, verbose_name="Entitäts-ID")
entity_name = models.CharField(max_length=255, verbose_name="Entitätsname")
# Details
description = models.TextField(verbose_name="Beschreibung")
changes = models.JSONField(
null=True, blank=True, verbose_name="Änderungen"
) # Alte und neue Werte
# Request-Informationen
ip_address = models.GenericIPAddressField(
null=True, blank=True, verbose_name="IP-Adresse"
)
user_agent = models.TextField(blank=True, verbose_name="User Agent")
session_key = models.CharField(
max_length=40, blank=True, verbose_name="Session-Key"
)
class Meta:
verbose_name = "Audit Log Eintrag"
verbose_name_plural = "Audit Log Einträge"
ordering = ["-timestamp"]
indexes = [
models.Index(fields=["timestamp"]),
models.Index(fields=["user", "timestamp"]),
models.Index(fields=["entity_type", "timestamp"]),
models.Index(fields=["action", "timestamp"]),
]
def __str__(self):
return f"{self.username} - {self.get_action_display()} {self.get_entity_type_display()} '{self.entity_name}' ({self.timestamp.strftime('%d.%m.%Y %H:%M')})"
def get_changes_summary(self):
"""Erstellt eine lesbare Zusammenfassung der Änderungen"""
if not self.changes:
return "Keine Details verfügbar"
if isinstance(self.changes, dict):
summary = []
for field, values in self.changes.items():
if isinstance(values, dict) and "old" in values and "new" in values:
old_val = values["old"] or "Leer"
new_val = values["new"] or "Leer"
summary.append(f"{field}: '{old_val}''{new_val}'")
return "; ".join(summary) if summary else "Keine Änderungen dokumentiert"
return str(self.changes)
class BackupJob(models.Model):
"""Backup-Jobs und deren Status"""
STATUS_CHOICES = [
("pending", "Wartend"),
("running", "Läuft"),
("completed", "Abgeschlossen"),
("failed", "Fehlgeschlagen"),
("cancelled", "Abgebrochen"),
]
TYPE_CHOICES = [
("full", "Vollständiges Backup"),
("database", "Nur Datenbank"),
("files", "Nur Dateien"),
]
OPERATION_CHOICES = [
("backup", "Backup"),
("restore", "Wiederherstellung"),
]
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
# Job-Details
operation = models.CharField(
max_length=20, choices=OPERATION_CHOICES, default="backup", verbose_name="Vorgang"
)
backup_type = models.CharField(
max_length=20, choices=TYPE_CHOICES, verbose_name="Backup-Typ"
)
status = models.CharField(
max_length=20, choices=STATUS_CHOICES, default="pending", verbose_name="Status"
)
# Ausführung
created_by = models.ForeignKey(
"auth.User", on_delete=models.SET_NULL, null=True, verbose_name="Erstellt von"
)
created_at = models.DateTimeField(auto_now_add=True, verbose_name="Erstellt am")
started_at = models.DateTimeField(
null=True, blank=True, verbose_name="Gestartet am"
)
completed_at = models.DateTimeField(
null=True, blank=True, verbose_name="Abgeschlossen am"
)
# Ergebnis
backup_filename = models.CharField(
max_length=255, blank=True, verbose_name="Backup-Dateiname"
)
backup_size = models.BigIntegerField(
null=True, blank=True, verbose_name="Backup-Größe (Bytes)"
)
error_message = models.TextField(blank=True, verbose_name="Fehlermeldung")
# Metadaten
database_size = models.BigIntegerField(
null=True, blank=True, verbose_name="Datenbankgröße (Bytes)"
)
files_count = models.IntegerField(
null=True, blank=True, verbose_name="Anzahl Dateien"
)
class Meta:
verbose_name = "Backup-Job"
verbose_name_plural = "Backup-Jobs"
ordering = ["-created_at"]
def __str__(self):
return f"{self.get_backup_type_display()} - {self.get_status_display()} ({self.created_at.strftime('%d.%m.%Y %H:%M')})"
def get_duration(self):
"""Berechnet die Dauer des Backup-Jobs"""
if self.started_at and self.completed_at:
return self.completed_at - self.started_at
elif self.started_at:
from django.utils import timezone
return timezone.now() - self.started_at
return None
def get_size_display(self):
"""Formatiert die Backup-Größe für die Anzeige"""
if not self.backup_size:
return "Unbekannt"
size = self.backup_size
for unit in ["B", "KB", "MB", "GB"]:
if size < 1024:
return f"{size:.1f} {unit}"
size /= 1024
return f"{size:.1f} TB"
class AppConfiguration(models.Model):
"""Application configuration settings that can be managed through the admin interface"""
SETTING_TYPE_CHOICES = [
("text", "Text"),
("password", "Password"),
("number", "Number"),
("boolean", "Boolean"),
("url", "URL"),
("tag", "Tag Name"),
("tag_id", "Tag ID"),
]
CATEGORY_CHOICES = [
("paperless", "Paperless Integration"),
("email", "E-Mail / IMAP"),
("general", "General Settings"),
("corporate", "Corporate Identity"),
("notifications", "Notifications"),
("system", "System Settings"),
]
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
key = models.CharField(max_length=100, unique=True, verbose_name="Setting Key")
display_name = models.CharField(max_length=200, verbose_name="Display Name")
description = models.TextField(blank=True, null=True, verbose_name="Description")
value = models.TextField(verbose_name="Value")
default_value = models.TextField(verbose_name="Default Value")
setting_type = models.CharField(
max_length=20, choices=SETTING_TYPE_CHOICES, default="text", verbose_name="Type"
)
category = models.CharField(
max_length=50,
choices=CATEGORY_CHOICES,
default="general",
verbose_name="Category",
)
is_active = models.BooleanField(default=True, verbose_name="Active")
is_system = models.BooleanField(
default=False, verbose_name="System Setting (read-only)"
)
order = models.IntegerField(default=0, verbose_name="Display Order")
# Metadata
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
verbose_name = "App Configuration"
verbose_name_plural = "App Configurations"
ordering = ["category", "order", "display_name"]
def __str__(self):
return f"{self.display_name} ({self.key})"
def get_typed_value(self):
"""Return the value converted to the appropriate type"""
if self.setting_type == "boolean":
return self.value.lower() in ("true", "1", "yes", "on")
elif self.setting_type == "number":
try:
if "." in self.value:
return float(self.value)
return int(self.value)
except (ValueError, TypeError):
return 0
return self.value
@classmethod
def get_setting(cls, key, default=None):
"""Get a setting value by key"""
try:
setting = cls.objects.get(key=key, is_active=True)
return setting.get_typed_value()
except cls.DoesNotExist:
return default
@classmethod
def set_setting(
cls,
key,
value,
display_name=None,
description=None,
setting_type="text",
category="general",
):
"""Set or update a setting value"""
setting, created = cls.objects.get_or_create(
key=key,
defaults={
"display_name": display_name or key,
"description": description,
"value": str(value),
"default_value": str(value),
"setting_type": setting_type,
"category": category,
},
)
if not created:
setting.value = str(value)
setting.save()
return setting
class HelpBox(models.Model):
"""Editierbare Hilfe-Infoboxen für Formulare"""
PAGE_CHOICES = [
("destinataer_new", "Neuer Destinatär"),
("unterstuetzung_new", "Neue Unterstützung"),
("foerderung_new", "Neue Förderung"),
("paechter_new", "Neuer Pächter"),
("laenderei_new", "Neue Länderei"),
("verpachtung_new", "Neue Verpachtung"),
("land_abrechnung_new", "Neue Landabrechnung"),
("person_new", "Neue Person"),
("konto_new", "Neues Konto"),
("verwaltungskosten_new", "Neue Verwaltungskosten"),
("rentmeister_new", "Neuer Rentmeister"),
("dokument_new", "Neues Dokument"),
("user_new", "Neuer Benutzer"),
("csv_import_new", "CSV Import"),
("destinataer_notiz_new", "Destinatär Notiz"),
]
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
page_key = models.CharField(
max_length=50, choices=PAGE_CHOICES, unique=True, verbose_name="Seite"
)
title = models.CharField(max_length=200, verbose_name="Titel der Hilfsbox")
content = models.TextField(
verbose_name="Inhalt (Markdown unterstützt)",
help_text="Sie können Markdown verwenden: **fett**, *kursiv*, `code`, [Link](url), etc.",
)
is_active = models.BooleanField(default=True, verbose_name="Aktiv")
# Metadata
created_at = models.DateTimeField(auto_now_add=True, verbose_name="Erstellt am")
updated_at = models.DateTimeField(auto_now=True, verbose_name="Aktualisiert am")
created_by = models.CharField(
max_length=100, null=True, blank=True, verbose_name="Erstellt von"
)
updated_by = models.CharField(
max_length=100, null=True, blank=True, verbose_name="Aktualisiert von"
)
class Meta:
verbose_name = "Hilfs-Infobox"
verbose_name_plural = "Hilfs-Infoboxen"
ordering = ["page_key"]
def __str__(self):
return f"{self.get_page_key_display()}: {self.title}"
@classmethod
def get_help_for_page(cls, page_key):
"""Hole die aktive Hilfs-Infobox für eine bestimmte Seite"""
try:
return cls.objects.get(page_key=page_key, is_active=True)
except cls.DoesNotExist:
return None

View File

@@ -0,0 +1,215 @@
import uuid
from django.db import models
class BriefVorlage(models.Model):
"""Wiederverwendbare Briefvorlagen für Serienbriefe (Veranstaltungseinladungen u.ä.)"""
name = models.CharField(max_length=100, verbose_name="Vorlagenname")
beschreibung = models.TextField(
blank=True,
verbose_name="Beschreibung",
help_text="Kurze Beschreibung des Verwendungszwecks dieser Vorlage.",
)
briefvorlage = models.TextField(
verbose_name="Brieftext (HTML)",
help_text=(
"HTML-Text des Briefs. Verfügbare Platzhalter: "
"{{ anrede }}, {{ vorname }}, {{ nachname }}, {{ strasse }}, "
"{{ plz }}, {{ ort }}, {{ datum }}, {{ uhrzeit }}, "
"{{ veranstaltungsort }}, {{ gasthaus_adresse }}"
),
)
betreff = models.CharField(
max_length=300,
blank=True,
verbose_name="Standard-Betreff",
help_text="Wird beim Laden der Vorlage in die Veranstaltung übernommen. Leer = unveränderter Betreff.",
)
erstellt_am = models.DateTimeField(auto_now_add=True)
aktualisiert_am = models.DateTimeField(auto_now=True)
class Meta:
verbose_name = "Briefvorlage"
verbose_name_plural = "Briefvorlagen"
ordering = ["name"]
def __str__(self):
return self.name
class Veranstaltung(models.Model):
"""Veranstaltungen der Stiftung, z.B. Stiftungsessen mit Rechnungslegung"""
STATUS_CHOICES = [
("geplant", "Geplant"),
("einladungen_versendet", "Einladungen versendet"),
("abgeschlossen", "Abgeschlossen"),
("abgesagt", "Abgesagt"),
]
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
titel = models.CharField(max_length=200, verbose_name="Titel")
datum = models.DateField(verbose_name="Datum")
uhrzeit = models.TimeField(null=True, blank=True, verbose_name="Uhrzeit")
ort = models.CharField(max_length=200, verbose_name="Ort / Gasthaus")
adresse = models.TextField(blank=True, verbose_name="Adresse Gasthaus")
beschreibung = models.TextField(blank=True, verbose_name="Beschreibung / Zweck")
status = models.CharField(
max_length=30,
choices=STATUS_CHOICES,
default="geplant",
verbose_name="Status",
)
budget_pro_person = models.DecimalField(
max_digits=8,
decimal_places=2,
null=True,
blank=True,
verbose_name="Budget pro Person (€)",
help_text="Geschätztes Budget je Teilnehmer in €",
)
briefvorlage = models.TextField(
blank=True,
verbose_name="Briefvorlage",
help_text=(
"HTML/Text-Template für Serienbrief. Platzhalter: "
"{{ anrede }}, {{ vorname }}, {{ nachname }}, {{ strasse }}, "
"{{ plz }}, {{ ort }}, {{ datum }}, {{ uhrzeit }}, "
"{{ veranstaltungsort }}, {{ gasthaus_adresse }}"
),
)
betreff = models.CharField(
max_length=300,
blank=True,
verbose_name="Betreff",
help_text="Betreffzeile des Serienbriefs. Leer = Standardbetreff.",
)
unterschrift_1_name = models.CharField(
max_length=100,
blank=True,
default="Katrin Kleinpaß",
verbose_name="Unterschrift 1 Name",
)
unterschrift_1_titel = models.CharField(
max_length=100,
blank=True,
default="Rentmeisterin",
verbose_name="Unterschrift 1 Titel",
)
unterschrift_2_name = models.CharField(
max_length=100,
blank=True,
default="Jan Remmer Siebels",
verbose_name="Unterschrift 2 Name",
)
unterschrift_2_titel = models.CharField(
max_length=100,
blank=True,
default="Rentmeister",
verbose_name="Unterschrift 2 Titel",
)
erstellt_am = models.DateTimeField(auto_now_add=True)
aktualisiert_am = models.DateTimeField(auto_now=True)
class Meta:
verbose_name = "Veranstaltung"
verbose_name_plural = "Veranstaltungen"
ordering = ["-datum"]
def __str__(self):
return f"{self.titel} ({self.datum})"
def get_teilnehmer_count(self):
return self.teilnehmer.count()
def get_zugesagte_count(self):
return self.teilnehmer.filter(rsvp_status="zugesagt").count()
def get_abgesagte_count(self):
return self.teilnehmer.filter(rsvp_status="abgesagt").count()
def get_keine_rueckmeldung_count(self):
return self.teilnehmer.filter(rsvp_status="keine_rueckmeldung").count()
class Veranstaltungsteilnehmer(models.Model):
"""Teilnehmer einer Veranstaltung primär freie Eingabe für Familienmitglieder"""
ANREDE_CHOICES = [
("Herr", "Herr"),
("Frau", "Frau"),
("", "Keine Anrede"),
]
RSVP_CHOICES = [
("eingeladen", "Eingeladen"),
("zugesagt", "Zugesagt"),
("abgesagt", "Abgesagt"),
("keine_rueckmeldung", "Keine Rückmeldung"),
]
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
veranstaltung = models.ForeignKey(
Veranstaltung,
on_delete=models.CASCADE,
related_name="teilnehmer",
verbose_name="Veranstaltung",
)
# Optionale Verknüpfung zu bestehenden Datensätzen
paechter = models.ForeignKey(
"stiftung.Paechter",
null=True,
blank=True,
on_delete=models.SET_NULL,
verbose_name="Pächter (optional)",
)
destinataer = models.ForeignKey(
"stiftung.Destinataer",
null=True,
blank=True,
on_delete=models.SET_NULL,
verbose_name="Destinatär (optional)",
)
# Freie Felder (Pflichtfelder für Serienbrief)
anrede = models.CharField(
max_length=10, choices=ANREDE_CHOICES, blank=True, verbose_name="Anrede"
)
vorname = models.CharField(max_length=100, verbose_name="Vorname")
nachname = models.CharField(max_length=100, verbose_name="Nachname")
strasse = models.CharField(max_length=200, blank=True, verbose_name="Straße")
plz = models.CharField(max_length=10, blank=True, verbose_name="PLZ")
ort = models.CharField(max_length=100, blank=True, verbose_name="Ort")
email = models.EmailField(
blank=True, verbose_name="E-Mail", help_text="Optional, für späteren E-Mail-Versand"
)
rsvp_status = models.CharField(
max_length=20,
choices=RSVP_CHOICES,
default="eingeladen",
verbose_name="RSVP-Status",
)
bemerkungen = models.TextField(blank=True, verbose_name="Bemerkungen")
erstellt_am = models.DateTimeField(auto_now_add=True)
class Meta:
verbose_name = "Veranstaltungsteilnehmer"
verbose_name_plural = "Veranstaltungsteilnehmer"
ordering = ["nachname", "vorname"]
def __str__(self):
return f"{self.anrede} {self.vorname} {self.nachname}".strip()
def get_full_name(self):
return f"{self.vorname} {self.nachname}".strip()
def get_full_address(self):
parts = [self.strasse, f"{self.plz} {self.ort}".strip()]
return ", ".join(p for p in parts if p)

View 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})"

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

View File

@@ -1,20 +1,20 @@
"""
Celery-Tasks für die automatische Verarbeitung von Destinatär-E-Mails.
Celery-Tasks fuer die automatische Verarbeitung eingehender E-Mails.
Workflow:
1. `poll_destinataer_emails` läuft alle 15 Minuten (Celery Beat)
2. Er liest ungelesene E-Mails aus dem IMAP-Postfach (paperless@vhtv-stiftung.de)
3. Für jede E-Mail:
a) Absender wird mit Destinatär-Datenbank abgeglichen (E-Mail-Feld)
b) Ein DestinataerEmailEingang-Datensatz wird angelegt
c) Alle Anhänge werden per Paperless-API hochgeladen
d) Für jeden Anhang wird ein DokumentLink erstellt
1. `poll_emails` laeuft alle 15 Minuten (Celery Beat)
2. Er liest ungelesene E-Mails aus dem IMAP-Postfach
3. Fuer jede E-Mail:
a) Absender wird mit Destinataer-Datenbank abgeglichen (E-Mail-Feld)
b) Betreff/Body wird auf Rechnungs-Keywords geprueft
c) Ein EmailEingang-Datensatz wird angelegt (mit Kategorie)
d) Alle Anhaenge werden als DokumentDatei im Django-DMS gespeichert
4. Nicht erkannte Absender werden als "unbekannt" markiert (manuelle Nachbearbeitung)
Konfiguration (Umgebungsvariablen in .env / compose.yml):
IMAP_HOST — IMAP-Server-Adresse (z. B. mail.vhtv-stiftung.de)
IMAP_PORT — Port (Standard: 993 für SSL)
IMAP_USER — Benutzername (z. B. paperless@vhtv-stiftung.de)
IMAP_PORT — Port (Standard: 993 fuer SSL)
IMAP_USER — Benutzername
IMAP_PASSWORD — Passwort
IMAP_FOLDER — Ordner (Standard: INBOX)
"""
@@ -22,20 +22,39 @@ Konfiguration (Umgebungsvariablen in .env / compose.yml):
import email
import email.utils
import imaplib
import io
import logging
import mimetypes
import re
from datetime import datetime, timezone as dt_timezone
from email.header import decode_header, make_header
import requests
from celery import shared_task
from django.conf import settings
from django.utils import timezone
logger = logging.getLogger(__name__)
# Patterns fuer Rechnungserkennung im Betreff/Body
RECHNUNG_PATTERNS = [
re.compile(r"\brechnung\b", re.IGNORECASE),
re.compile(r"\binvoice\b", re.IGNORECASE),
re.compile(r"\brechnungs[-\s]?nr\.?\s*[:\s]?\s*\d+", re.IGNORECASE),
re.compile(r"\bRE[-/]\d{4,}", re.IGNORECASE), # RE-2024001, RE/20240315
]
GESCHICHTE_PATTERNS = [
re.compile(r"\bstiftungsgeschichte\b", re.IGNORECASE),
re.compile(r"\bahnenforschung\b", re.IGNORECASE),
re.compile(r"\bgenealogie\b", re.IGNORECASE),
re.compile(r"\bstammbaum\b", re.IGNORECASE),
re.compile(r"\bhistorisch", re.IGNORECASE),
re.compile(r"\bchronik\b", re.IGNORECASE),
re.compile(r"\barchiv\b", re.IGNORECASE),
re.compile(r"\bfamiliengeschichte\b", re.IGNORECASE),
re.compile(r"\burkunde\b", re.IGNORECASE),
]
# ---------------------------------------------------------------------------
# Hilfsfunktionen
# ---------------------------------------------------------------------------
@@ -52,7 +71,7 @@ def _decode_header_value(raw_value: str) -> str:
def _parse_email_date(date_str: str) -> datetime:
"""Parst das E-Mail-Datum und gibt ein timezone-aware datetime zurück."""
"""Parst das E-Mail-Datum und gibt ein timezone-aware datetime zurueck."""
try:
parsed = email.utils.parsedate_to_datetime(date_str)
if parsed.tzinfo is None:
@@ -84,103 +103,108 @@ def _get_email_body(msg) -> str:
return "\n".join(body_parts).strip()
def _upload_to_paperless(content: bytes, filename: str, destinataer=None, betreff: str = "") -> int | None:
def _detect_kategorie(betreff: str, email_text: str, has_destinataer: bool) -> str:
"""
Lädt einen Anhang in Paperless-NGX hoch.
Gibt die neue Paperless-Dokument-ID zurück, oder None bei Fehler.
Erkennt die Kategorie einer Email anhand von Betreff und Body.
Gibt 'destinataer', 'rechnung', 'stiftungsgeschichte', oder 'allgemein' zurueck.
"""
api_url = getattr(settings, "PAPERLESS_API_URL", None)
api_token = getattr(settings, "PAPERLESS_API_TOKEN", None)
if has_destinataer:
return "destinataer"
if not api_url or not api_token:
logger.warning("Paperless nicht konfiguriert Anhang '%s' wird nicht hochgeladen.", filename)
return None
text_to_check = f"{betreff}\n{email_text[:2000]}"
# Tag-ID für Destinatäre ermitteln
tag_ids = []
dest_tag_id = getattr(settings, "PAPERLESS_DESTINATAERE_TAG_ID", None)
if dest_tag_id:
try:
tag_ids.append(int(dest_tag_id))
except (ValueError, TypeError):
pass
# Rechnungserkennung via Patterns
for pattern in RECHNUNG_PATTERNS:
if pattern.search(text_to_check):
return "rechnung"
# Correspondent: Name des Destinatärs (optional, Paperless sucht/erstellt ihn)
correspondent_name = None
if destinataer:
correspondent_name = f"{destinataer.vorname} {destinataer.nachname}".strip()
# Stiftungsgeschichte-Erkennung
for pattern in GESCHICHTE_PATTERNS:
if pattern.search(text_to_check):
return "stiftungsgeschichte"
# Dateiname bereinigen
safe_filename = filename or "anhang.pdf"
return "allgemein"
# Mime-Type bestimmen
def _save_to_dms(content: bytes, filename: str, destinataer=None, betreff: str = "", kontext: str = "korrespondenz"):
"""
Speichert einen E-Mail-Anhang direkt als DokumentDatei im Django-DMS.
Gibt das DokumentDatei-Objekt zurueck, oder None bei Fehler.
"""
from stiftung.models import DokumentDatei
from django.core.files.base import ContentFile
safe_filename = filename or "anhang.bin"
mime_type, _ = mimetypes.guess_type(safe_filename)
mime_type = mime_type or "application/octet-stream"
upload_url = f"{api_url.rstrip('/')}/api/documents/post_document/"
headers = {"Authorization": f"Token {api_token}"}
form_data = {}
if tag_ids:
form_data["tags"] = tag_ids
if correspondent_name:
form_data["correspondent_name"] = correspondent_name
if betreff:
form_data["title"] = betreff[:128]
files = {"document": (safe_filename, io.BytesIO(content), mime_type)}
titel = f"{betreff[:100]} {safe_filename}" if betreff else safe_filename
beschreibung = ""
if destinataer:
beschreibung = (
f"Automatisch importiert aus E-Mail-Eingang.\n"
f"Absender: {destinataer.vorname} {destinataer.nachname} <{destinataer.email}>"
)
try:
response = requests.post(
upload_url,
headers=headers,
data=form_data,
files=files,
timeout=60,
doc = DokumentDatei(
titel=titel[:255],
beschreibung=beschreibung,
kontext=kontext,
dateiname_original=safe_filename,
dateityp=mime_type,
dateigroesse=len(content),
destinataer=destinataer,
)
response.raise_for_status()
# Paperless gibt die neue Dokument-ID zurück (als Integer oder UUID-String)
result = response.json()
doc_id = result if isinstance(result, int) else result.get("id")
logger.info("Anhang '%s' erfolgreich in Paperless hochgeladen (ID: %s).", safe_filename, doc_id)
return doc_id
except requests.RequestException as exc:
logger.error("Fehler beim Hochladen von '%s' in Paperless: %s", safe_filename, exc)
doc.datei.save(safe_filename, ContentFile(content), save=False)
doc.save()
logger.info("Anhang '%s' als DokumentDatei gespeichert (ID: %s).", safe_filename, doc.pk)
return doc
except Exception as exc:
logger.error("Fehler beim Speichern von '%s' im DMS: %s", safe_filename, exc)
return None
# ---------------------------------------------------------------------------
# Haupttask
# ---------------------------------------------------------------------------
@shared_task(bind=True, max_retries=3, default_retry_delay=300, name="stiftung.tasks.poll_destinataer_emails")
def poll_destinataer_emails(self):
@shared_task(bind=True, max_retries=3, default_retry_delay=300, name="stiftung.tasks.poll_emails")
def poll_emails(self, search_all_recent_days=0):
"""
Liest ungelesene E-Mails aus dem IMAP-Postfach und verarbeitet sie.
Liest E-Mails aus dem IMAP-Postfach und verarbeitet sie.
Wird durch Celery Beat alle 15 Minuten ausgeführt.
Wird durch Celery Beat alle 15 Minuten ausgefuehrt.
Erkennt automatisch Destinataer-Emails, Rechnungen und allgemeine Post.
Args:
search_all_recent_days: Wenn > 0, werden alle E-Mails der letzten N Tage
durchsucht (nicht nur ungelesene). Nuetzlich fuer manuellen Abruf.
"""
from stiftung.models import Destinataer, DestinataerEmailEingang, DokumentLink
from stiftung.models import Destinataer, EmailEingang
# IMAP-Konfiguration aus Settings
imap_host = getattr(settings, "IMAP_HOST", None)
imap_port = int(getattr(settings, "IMAP_PORT", 993))
imap_user = getattr(settings, "IMAP_USER", None)
imap_password = getattr(settings, "IMAP_PASSWORD", None)
imap_folder = getattr(settings, "IMAP_FOLDER", "INBOX")
imap_use_ssl = getattr(settings, "IMAP_USE_SSL", True)
# IMAP-Konfiguration: DB (AppConfiguration) mit Fallback auf env/settings
from stiftung.utils.config import get_config
imap_host = get_config("imap_host")
imap_port = int(get_config("imap_port", 993))
imap_user = get_config("imap_user")
imap_password = get_config("imap_password")
imap_folder = get_config("imap_folder", "INBOX")
imap_use_ssl = get_config("imap_use_ssl", True)
if not all([imap_host, imap_user, imap_password]):
logger.warning(
"IMAP nicht konfiguriert (IMAP_HOST, IMAP_USER, IMAP_PASSWORD fehlen). "
"Task wird übersprungen."
"Task wird uebersprungen."
)
return {"status": "skipped", "reason": "IMAP not configured"}
# Vorab: Destinatär-E-Mail-Index für schnelle Zuordnung
# Nur aktive Destinatäre mit gesetzter E-Mail-Adresse
# Vorab: Destinataer-E-Mail-Index fuer schnelle Zuordnung
# Nur aktive Destinataere mit gesetzter E-Mail-Adresse
destinataer_by_email = {
d.email.lower(): d
for d in Destinataer.objects.filter(aktiv=True, email__isnull=False).exclude(email="")
@@ -190,20 +214,28 @@ def poll_destinataer_emails(self):
errors = 0
try:
# IMAP-Verbindung aufbauen
# IMAP-Verbindung aufbauen (mit Socket-Timeout fuer grosse E-Mails)
imap_timeout = 120 # Sekunden genug fuer grosse Anhaenge
if imap_use_ssl:
mail = imaplib.IMAP4_SSL(imap_host, imap_port)
mail = imaplib.IMAP4_SSL(imap_host, imap_port, timeout=imap_timeout)
else:
mail = imaplib.IMAP4(imap_host, imap_port)
mail = imaplib.IMAP4(imap_host, imap_port, timeout=imap_timeout)
mail.login(imap_user, imap_password)
mail.select(imap_folder)
# Ungelesene Nachrichten suchen
_, message_ids_raw = mail.search(None, "UNSEEN")
# Nachrichten suchen
if search_all_recent_days and search_all_recent_days > 0:
from datetime import timedelta
since_date = (datetime.now(dt_timezone.utc) - timedelta(days=search_all_recent_days)).strftime("%d-%b-%Y")
_, message_ids_raw = mail.search(None, "SINCE", since_date)
search_mode = f"ALL seit {since_date}"
else:
_, message_ids_raw = mail.search(None, "UNSEEN")
search_mode = "UNSEEN"
message_ids = message_ids_raw[0].split()
logger.info("Postfach '%s': %d ungelesene Nachricht(en) gefunden.", imap_folder, len(message_ids))
logger.info("Postfach '%s' (%s): %d Nachricht(en) gefunden.", imap_folder, search_mode, len(message_ids))
for msg_id in message_ids:
try:
@@ -214,7 +246,7 @@ def poll_destinataer_emails(self):
# Absender ermitteln
from_raw = msg.get("From", "")
absender_name_raw, absender_email_raw = email.utils.parseaddr(from_raw)
absender_email = absender_email_raw.lower().strip()
absender_email_addr = absender_email_raw.lower().strip()
absender_name = _decode_header_value(absender_name_raw)
# Betreff
@@ -226,30 +258,48 @@ def poll_destinataer_emails(self):
# E-Mail-Text
email_text = _get_email_body(msg)
# Destinatär zuordnen
destinataer = destinataer_by_email.get(absender_email)
status = "zugewiesen" if destinataer else "unbekannt"
# Destinataer zuordnen
destinataer = destinataer_by_email.get(absender_email_addr)
# Prüfen ob diese E-Mail bereits verarbeitet wurde (Duplikat-Check via
# Kategorie erkennen
kategorie = _detect_kategorie(betreff, email_text, has_destinataer=bool(destinataer))
# Status basierend auf Kategorie
if destinataer:
status = "zugewiesen"
elif kategorie == "rechnung":
status = "neu" # Muss manuell als Rechnung erfasst werden
else:
status = "unbekannt"
# DMS-Kontext fuer Anhaenge basierend auf Kategorie
dms_kontext_map = {
"rechnung": "rechnung",
"stiftungsgeschichte": "stiftungsgeschichte",
}
dms_kontext = dms_kontext_map.get(kategorie, "korrespondenz")
# Pruefen ob diese E-Mail bereits verarbeitet wurde (Duplikat-Check via
# Datum + Absender + Betreff)
already_exists = DestinataerEmailEingang.objects.filter(
absender_email=absender_email,
already_exists = EmailEingang.objects.filter(
absender_email=absender_email_addr,
eingangsdatum=eingangsdatum,
betreff=betreff[:500],
).exists()
if already_exists:
logger.debug(
"E-Mail von %s am %s bereits vorhanden wird übersprungen.",
absender_email, eingangsdatum,
"E-Mail von %s am %s bereits vorhanden wird uebersprungen.",
absender_email_addr, eingangsdatum,
)
# Als gelesen markieren
mail.store(msg_id, "+FLAGS", "\\Seen")
continue
# Datensatz anlegen
eingang = DestinataerEmailEingang(
eingang = EmailEingang(
kategorie=kategorie,
destinataer=destinataer,
absender_email=absender_email,
absender_email=absender_email_addr,
absender_name=absender_name,
betreff=betreff[:500],
eingangsdatum=eingangsdatum,
@@ -257,8 +307,8 @@ def poll_destinataer_emails(self):
status=status,
)
# Anhänge verarbeiten
paperless_ids = []
# Anhaenge verarbeiten und als DokumentDatei im DMS speichern
dms_dokumente = []
if msg.is_multipart():
for part in msg.walk():
disposition = str(part.get_content_disposition() or "")
@@ -266,48 +316,90 @@ def poll_destinataer_emails(self):
filename = _decode_header_value(part.get_filename() or "")
content = part.get_payload(decode=True)
if not content:
logger.warning(
"Anhang '%s' hat keinen Inhalt wird uebersprungen.",
filename,
)
continue
doc_id = _upload_to_paperless(
doc = _save_to_dms(
content=content,
filename=filename,
destinataer=destinataer,
betreff=betreff,
kontext=dms_kontext,
)
if doc_id:
paperless_ids.append(doc_id)
# DokumentLink anlegen
DokumentLink.objects.create(
paperless_document_id=doc_id,
kontext="verwendungsnachweis",
titel=f"{betreff[:100]} {filename}" if filename else betreff[:200],
beschreibung=(
f"Automatisch importiert aus E-Mail-Eingang.\n"
f"Absender: {absender_name} <{absender_email}>\n"
f"Datum: {eingangsdatum.strftime('%d.%m.%Y %H:%M')}"
),
destinataer_id=destinataer.pk if destinataer else None,
)
if doc:
dms_dokumente.append(doc)
eingang.paperless_dokument_ids = paperless_ids
if paperless_ids:
eingang.status = "verarbeitet" if destinataer else "unbekannt"
# Cover-Email als eigenes DMS-Dokument speichern
email_body_doc = None
if email_text.strip():
email_filename = f"Email_{eingangsdatum.strftime('%Y%m%d_%H%M')}_{betreff[:50]}.txt"
# Bereinige Dateinamen
email_filename = re.sub(r'[^\w\s\-._]', '', email_filename)
anhang_count = len(dms_dokumente)
anhang_hinweis = (
f"\n\n--- Anhänge: {anhang_count} ---\n"
+ "\n".join(f"{d.dateiname_original or d.titel}" for d in dms_dokumente)
if dms_dokumente else ""
)
email_body_content = (
f"Von: {absender_name} <{absender_email_addr}>\n"
f"Datum: {eingangsdatum.strftime('%d.%m.%Y %H:%M')}\n"
f"Betreff: {betreff}\n"
f"{'=' * 60}\n\n"
f"{email_text}"
f"{anhang_hinweis}"
)
email_body_doc = _save_to_dms(
content=email_body_content.encode("utf-8"),
filename=email_filename,
destinataer=destinataer,
betreff=betreff,
kontext="email",
)
if email_body_doc:
# Beschreibung mit Anhang-Verweis ergaenzen
if dms_dokumente:
email_body_doc.beschreibung = (
f"E-Mail-Nachricht mit {anhang_count} Anhang/Anhängen.\n"
f"Absender: {absender_name} <{absender_email_addr}>"
)
else:
email_body_doc.beschreibung = (
f"E-Mail-Nachricht (ohne Anhänge).\n"
f"Absender: {absender_name} <{absender_email_addr}>"
)
email_body_doc.save(update_fields=["beschreibung"])
# Alle DMS-Dokumente (Email-Body + Anhaenge) verknuepfen
alle_dms_dokumente = []
if email_body_doc:
alle_dms_dokumente.append(email_body_doc)
alle_dms_dokumente.extend(dms_dokumente)
if dms_dokumente:
eingang.status = "verarbeitet" if destinataer else status
eingang.save()
if alle_dms_dokumente:
eingang.dokument_dateien.set(alle_dms_dokumente)
# Als gelesen markieren
mail.store(msg_id, "+FLAGS", "\\Seen")
processed += 1
logger.info(
"E-Mail verarbeitet: von=%s, Destinatär=%s, Anhänge=%d",
absender_email,
str(destinataer) if destinataer else "unbekannt",
len(paperless_ids),
"E-Mail verarbeitet: von=%s, Kategorie=%s, Destinataer=%s, Anhaenge=%d",
absender_email_addr,
kategorie,
str(destinataer) if destinataer else "",
len(dms_dokumente),
)
except Exception as exc:
errors += 1
logger.exception("Fehler bei Verarbeitung von Nachricht %s: %s", msg_id, exc)
# Nicht als gelesen markieren wird beim nächsten Lauf erneut versucht
# Nicht als gelesen markieren wird beim naechsten Lauf erneut versucht
mail.close()
mail.logout()
@@ -316,9 +408,442 @@ def poll_destinataer_emails(self):
logger.error("IMAP-Fehler: %s", exc)
raise self.retry(exc=exc)
except Exception as exc:
logger.exception("Unerwarteter Fehler im poll_destinataer_emails-Task: %s", exc)
logger.exception("Unerwarteter Fehler im poll_emails-Task: %s", exc)
raise self.retry(exc=exc)
result = {"status": "done", "processed": processed, "errors": errors}
logger.info("poll_destinataer_emails abgeschlossen: %s", result)
logger.info("poll_emails abgeschlossen: %s", result)
return result
# Backward-compatible alias for existing Celery Beat schedules
poll_destinataer_emails = poll_emails
# =============================================================================
# SMTP-Ausgangs-Tasks: Nachweis-Aufforderungen und Token-Erinnerungen
# =============================================================================
import secrets # noqa: E402 (wird hier benötigt)
from datetime import timedelta # noqa: E402
def _get_smtp_connection():
"""
Erstellt eine Django-E-Mail-Verbindung mit SMTP-Einstellungen aus der DB.
"""
from django.core.mail import get_connection
from stiftung.utils.config import get_config
return get_connection(
backend="django.core.mail.backends.smtp.EmailBackend",
host=get_config("smtp_host", "smtp.ionos.de"),
port=int(get_config("smtp_port", 465)),
username=get_config("smtp_user", ""),
password=get_config("smtp_password", ""),
use_ssl=bool(get_config("smtp_use_ssl", True)),
use_tls=False,
fail_silently=False,
)
def _get_smtp_from_email():
"""Gibt die konfigurierte Absenderadresse zurück."""
from stiftung.utils.config import get_config
return get_config("smtp_from_email", "buero@vhtv-stiftung.de")
@shared_task(bind=True, max_retries=3, default_retry_delay=300)
def send_nachweis_aufforderung(self, destinataer_id, nachweis_id, base_url=None):
"""
Erstellt einen UploadToken und sendet eine Nachweis-Aufforderungs-E-Mail
mit Einmallink und QR-Code an den Destinatär.
Args:
destinataer_id: UUID des Destinatärs
nachweis_id: UUID des VierteljahresNachweises
base_url: Basis-URL der Anwendung (z.B. 'https://vhtv-stiftung.de')
"""
from django.conf import settings
from django.core.mail import EmailMultiAlternatives
from django.template.loader import render_to_string
from django.utils import timezone
import io
try:
import qrcode
from PIL import Image
import base64
qr_available = True
except ImportError:
qr_available = False
from stiftung.models import Destinataer, VierteljahresNachweis, UploadToken
try:
destinataer = Destinataer.objects.get(id=destinataer_id)
nachweis = VierteljahresNachweis.objects.get(id=nachweis_id)
except (Destinataer.DoesNotExist, VierteljahresNachweis.DoesNotExist) as exc:
logger.error("send_nachweis_aufforderung: Objekt nicht gefunden: %s", exc)
return {"status": "error", "message": str(exc)}
if not destinataer.email:
logger.warning(
"send_nachweis_aufforderung: Destinatär %s hat keine E-Mail-Adresse",
destinataer_id,
)
return {"status": "skipped", "reason": "no_email"}
# Bestehende aktive Tokens für diesen Nachweis deaktivieren
UploadToken.objects.filter(
destinataer=destinataer,
nachweis=nachweis,
ist_aktiv=True,
).update(ist_aktiv=False)
# Neuen Token erstellen
token_str = secrets.token_urlsafe(48)
gueltig_bis = timezone.now() + timedelta(days=30)
upload_token = UploadToken.objects.create(
token=token_str,
destinataer=destinataer,
nachweis=nachweis,
gueltig_bis=gueltig_bis,
)
if base_url is None:
base_url = getattr(settings, "SITE_URL", "https://vhtv-stiftung.de")
upload_url = f"{base_url}/portal/upload/{token_str}/"
# QR-Code generieren
qr_code_base64 = None
if qr_available:
try:
qr = qrcode.QRCode(
version=1,
error_correction=qrcode.constants.ERROR_CORRECT_M,
box_size=6,
border=4,
)
qr.add_data(upload_url)
qr.make(fit=True)
img = qr.make_image(fill_color="black", back_color="white")
buffer = io.BytesIO()
img.save(buffer, format="PNG")
qr_code_base64 = base64.b64encode(buffer.getvalue()).decode()
except Exception as qr_exc:
logger.warning("QR-Code-Generierung fehlgeschlagen: %s", qr_exc)
# Halbjahr bestimmen (Q1+Q2 = 1. Halbjahr, Q3+Q4 = 2. Halbjahr)
halbjahr = 1 if nachweis.quartal in [1, 2] else 2
halbjahr_label = f"{halbjahr}. Halbjahr {nachweis.jahr}"
quartal_label = f"Q{nachweis.quartal} {nachweis.jahr}"
context = {
"destinataer": destinataer,
"nachweis": nachweis,
"upload_url": upload_url,
"qr_code_base64": qr_code_base64,
"gueltig_bis": gueltig_bis,
"halbjahr_label": halbjahr_label,
"quartal_label": quartal_label,
"datenschutz_url": f"{base_url}/portal/datenschutz/",
}
subject = f"Nachweis-Aufforderung: {quartal_label} ({halbjahr_label}) vHTV-Stiftung"
from_email = _get_smtp_from_email()
to_email = destinataer.email
from stiftung.utils.vorlagen import render_vorlage
text_body = render_vorlage("email/nachweis_aufforderung.txt", context)
html_body = render_vorlage("email/nachweis_aufforderung.html", context)
try:
connection = _get_smtp_connection()
msg = EmailMultiAlternatives(subject, text_body, from_email, [to_email], connection=connection)
msg.attach_alternative(html_body, "text/html")
msg.send()
logger.info(
"Nachweis-Aufforderung gesendet an %s (Token %s)",
to_email,
upload_token.id,
)
return {
"status": "sent",
"destinataer_id": str(destinataer_id),
"nachweis_id": str(nachweis_id),
"token_id": str(upload_token.id),
}
except Exception as exc:
logger.exception("E-Mail-Versand fehlgeschlagen für %s: %s", to_email, exc)
raise self.retry(exc=exc)
@shared_task(bind=True, max_retries=3, default_retry_delay=300)
def send_nachweis_erinnerung(self, token_id, base_url=None):
"""
Sendet eine Erinnerungs-E-Mail für einen bald ablaufenden Upload-Token.
Wird durch Celery Beat ausgelöst (7 Tage vor Ablauf).
"""
from django.conf import settings
from django.core.mail import EmailMultiAlternatives
from django.template.loader import render_to_string
from stiftung.models import UploadToken
try:
upload_token = UploadToken.objects.select_related(
"destinataer", "nachweis"
).get(id=token_id, ist_aktiv=True)
except UploadToken.DoesNotExist:
return {"status": "skipped", "reason": "token_not_found_or_inactive"}
if not upload_token.ist_gueltig():
return {"status": "skipped", "reason": "token_invalid"}
if not upload_token.destinataer.email:
return {"status": "skipped", "reason": "no_email"}
if base_url is None:
base_url = getattr(settings, "SITE_URL", "https://vhtv-stiftung.de")
upload_url = f"{base_url}/portal/upload/{upload_token.token}/"
nachweis = upload_token.nachweis
halbjahr = 1 if nachweis.quartal in [1, 2] else 2
halbjahr_label = f"{halbjahr}. Halbjahr {nachweis.jahr}"
context = {
"destinataer": upload_token.destinataer,
"nachweis": nachweis,
"upload_url": upload_url,
"gueltig_bis": upload_token.gueltig_bis,
"halbjahr_label": halbjahr_label,
"ist_erinnerung": True,
"datenschutz_url": f"{base_url}/portal/datenschutz/",
}
subject = f"Erinnerung: Nachweis-Upload noch ausstehend {halbjahr_label}"
from_email = _get_smtp_from_email()
to_email = upload_token.destinataer.email
from stiftung.utils.vorlagen import render_vorlage
text_body = render_vorlage("email/nachweis_aufforderung.txt", context)
html_body = render_vorlage("email/nachweis_aufforderung.html", context)
try:
connection = _get_smtp_connection()
msg = EmailMultiAlternatives(subject, text_body, from_email, [to_email], connection=connection)
msg.attach_alternative(html_body, "text/html")
msg.send()
upload_token.erinnerung_gesendet = True
upload_token.save(update_fields=["erinnerung_gesendet"])
logger.info("Erinnerung gesendet an %s (Token %s)", to_email, token_id)
return {"status": "sent", "token_id": str(token_id)}
except Exception as exc:
logger.exception("Erinnerungs-E-Mail fehlgeschlagen für %s: %s", to_email, exc)
raise self.retry(exc=exc)
@shared_task(bind=True, max_retries=3, default_retry_delay=300)
def send_onboarding_einladung(self, einladung_id, base_url=None):
"""
Sendet eine Onboarding-Einladungs-E-Mail an eine neue potenzielle Destinatärin/
einen neuen potenziellen Destinatär.
Args:
einladung_id: UUID der OnboardingEinladung
base_url: Basis-URL der Anwendung
"""
from django.conf import settings
from django.core.mail import EmailMultiAlternatives
from django.template.loader import render_to_string
from stiftung.models import OnboardingEinladung
try:
einladung = OnboardingEinladung.objects.get(id=einladung_id)
except OnboardingEinladung.DoesNotExist as exc:
logger.error("send_onboarding_einladung: Einladung %s nicht gefunden", einladung_id)
return {"status": "error", "message": str(exc)}
if not einladung.ist_gueltig():
return {"status": "skipped", "reason": "einladung_ungueltig"}
if base_url is None:
base_url = getattr(settings, "SITE_URL", "https://vhtv-stiftung.de")
onboarding_url = f"{base_url}/portal/onboarding/{einladung.token}/"
context = {
"einladung": einladung,
"onboarding_url": onboarding_url,
"gueltig_bis": einladung.gueltig_bis,
}
subject = "Einladung zum Onboarding van Hees-Theyssen-Vogel'sche Stiftung"
from_email = _get_smtp_from_email()
to_email = einladung.email
from stiftung.utils.vorlagen import render_vorlage
text_body = render_vorlage("email/onboarding_einladung.txt", context)
html_body = render_vorlage("email/onboarding_einladung.html", context)
try:
connection = _get_smtp_connection()
msg = EmailMultiAlternatives(subject, text_body, from_email, [to_email], connection=connection)
msg.attach_alternative(html_body, "text/html")
msg.send()
logger.info(
"Onboarding-Einladung gesendet an %s (Einladung %s)",
to_email,
einladung_id,
)
return {"status": "sent", "einladung_id": str(einladung_id), "email": to_email}
except Exception as exc:
logger.exception("Onboarding-E-Mail-Versand fehlgeschlagen für %s: %s", to_email, exc)
raise self.retry(exc=exc)
def _send_bestaetigung_sync(destinataer_id):
"""
Generiert ein Bestätigungsschreiben (PDF) für einen Destinatär und sendet es
per E-Mail. Das PDF wird zusätzlich im DMS unter Kontext "korrespondenz" abgelegt.
Kann direkt (synchron) oder via Celery-Task aufgerufen werden.
Bei Fehlern wird eine Exception geworfen (kein stilles Verschlucken).
"""
from decimal import Decimal
from django.core.files.base import ContentFile
from django.core.mail import EmailMultiAlternatives
from django.utils import timezone
from stiftung.models import Destinataer, DestinataerUnterstuetzung, DokumentDatei
try:
destinataer = Destinataer.objects.get(id=destinataer_id)
except Destinataer.DoesNotExist as exc:
logger.error("send_bestaetigung: Destinatär %s nicht gefunden", destinataer_id)
return {"status": "error", "message": str(exc)}
if not destinataer.email:
logger.warning("send_bestaetigung: Destinatär %s hat keine E-Mail-Adresse", destinataer_id)
return {"status": "skipped", "reason": "no_email"}
# Alle abgeschlossenen Unterstützungen laden
unterstuetzungen = list(DestinataerUnterstuetzung.objects.filter(
destinataer=destinataer,
status__in=["ausgezahlt", "abgeschlossen"],
).order_by("faellig_am"))
gesamtbetrag = sum(u.betrag for u in unterstuetzungen) if unterstuetzungen else Decimal("0")
zeitraum = None
if unterstuetzungen:
erste = unterstuetzungen[0].faellig_am
letzte = unterstuetzungen[-1].faellig_am
if erste == letzte:
zeitraum = erste.strftime("%d.%m.%Y")
else:
zeitraum = f"{erste.strftime('%d.%m.%Y')} {letzte.strftime('%d.%m.%Y')}"
betrag_quartal = destinataer.vierteljaehrlicher_betrag
betrag_jaehrlich = (betrag_quartal * 4) if betrag_quartal else None
zweck = destinataer.ausbildungsstand or (destinataer.get_berufsgruppe_display() if destinataer.berufsgruppe else None)
datum = timezone.now().date()
context = {
"destinataer": destinataer,
"unterstuetzungen": unterstuetzungen,
"gesamtbetrag": gesamtbetrag,
"datum": datum,
"zeitraum": zeitraum,
"betrag_quartal": betrag_quartal,
"betrag_jaehrlich": betrag_jaehrlich,
"zweck": zweck,
}
# PDF generieren via WeasyPrint
pdf_bytes = None
try:
from weasyprint import HTML
from stiftung.utils.vorlagen import render_vorlage
html_content = render_vorlage("pdf/bestaetigung.html", context)
pdf_bytes = HTML(string=html_content).write_pdf()
except Exception as exc:
logger.error("send_bestaetigung: PDF-Generierung fehlgeschlagen: %s", exc)
raise
# PDF im DMS ablegen
filename = (
f"bestaetigung_{destinataer.nachname}_{destinataer.vorname}"
f"_{datum.strftime('%Y%m%d')}.pdf"
)
try:
doc = DokumentDatei(
titel=f"Bestätigungsschreiben {datum.strftime('%d.%m.%Y')} {destinataer.get_full_name()}",
beschreibung="Automatisch generiertes Bestätigungsschreiben über Förderleistungen.",
kontext="korrespondenz",
dateiname_original=filename,
dateityp="application/pdf",
dateigroesse=len(pdf_bytes),
destinataer=destinataer,
)
doc.datei.save(filename, ContentFile(pdf_bytes), save=False)
doc.save()
logger.info("Bestätigung im DMS gespeichert (ID: %s).", doc.pk)
except Exception as exc:
logger.error("send_bestaetigung: DMS-Speicherung fehlgeschlagen: %s", exc)
# Weiter mit E-Mail-Versand auch wenn DMS-Speicherung schlägt fehl
# E-Mail senden
from stiftung.utils.vorlagen import render_vorlage
html_body = render_vorlage("email/bestaetigung.html", context)
subject = "Bestätigung Ihrer Stiftungsförderung van Hees-Theyssen-Vogel'sche Stiftung"
from_email = _get_smtp_from_email()
to_email = destinataer.email
connection = _get_smtp_connection()
msg = EmailMultiAlternatives(subject, "", from_email, [to_email], connection=connection)
msg.attach_alternative(html_body, "text/html")
if pdf_bytes:
msg.attach(filename, pdf_bytes, "application/pdf")
msg.send()
logger.info("Bestätigung gesendet an %s (Destinatär %s)", to_email, destinataer_id)
return {"status": "sent", "destinataer_id": str(destinataer_id), "email": to_email}
@shared_task(bind=True, max_retries=3, default_retry_delay=300)
def send_bestaetigung(self, destinataer_id, base_url=None):
"""Celery-Wrapper für _send_bestaetigung_sync (für asynchronen Aufruf)."""
try:
return _send_bestaetigung_sync(destinataer_id)
except Exception as exc:
logger.exception("send_bestaetigung task fehlgeschlagen: %s", exc)
raise self.retry(exc=exc)
@shared_task
def check_ablaufende_tokens():
"""
Prüft täglich Upload-Tokens, die in 7 Tagen ablaufen,
und sendet Erinnerungs-E-Mails (falls noch nicht gesendet).
Wird durch Celery Beat aufgerufen.
"""
from django.utils import timezone
from stiftung.models import UploadToken
grenze = timezone.now() + timedelta(days=7)
tokens = UploadToken.objects.filter(
ist_aktiv=True,
eingeloest_am__isnull=True,
erinnerung_gesendet=False,
gueltig_bis__lte=grenze,
gueltig_bis__gt=timezone.now(),
)
count = 0
for token in tokens:
send_nachweis_erinnerung.delay(str(token.id))
count += 1
logger.info("check_ablaufende_tokens: %d Erinnerungen angestoßen", count)
return {"triggered": count}

View File

@@ -34,6 +34,17 @@ def help_box_exists(page_key):
return HelpBox.get_help_for_page(page_key) is not None
@register.filter
def get_item(dictionary, key):
"""Lookup a key in a dictionary, trying int conversion for numeric keys."""
if dictionary is None:
return None
result = dictionary.get(key)
if result is None and isinstance(key, str) and key.isdigit():
result = dictionary.get(int(key))
return result
@register.filter
def markdown_to_html(text):
"""Konvertiere Markdown-Text zu HTML"""

View File

@@ -1,15 +1,23 @@
from django.urls import path
from django.urls import include, path
from . import views
app_name = "stiftung"
urlpatterns = [
# AI Agent
path("agent/", include("stiftung.agent.urls")),
# Home - Main landing page after login
path("", views.home, name="home"),
# CSV Import URLs
# CSV Import URLs (legacy)
path("import/", views.csv_import_list, name="csv_import_list"),
path("import/neu/", views.csv_import_create, name="csv_import_create"),
# Unified Import/Export Hub
path("daten/", views.import_export_hub, name="import_export_hub"),
path("daten/export/", views.csv_export, name="csv_export"),
path("daten/import/upload/", views.csv_import_upload, name="csv_import_upload"),
path("daten/import/ausfuehren/", views.csv_import_execute, name="csv_import_execute"),
# Destinatär URLs (Förderungsempfänger)
path("destinataere/", views.destinataer_list, name="destinataer_list"),
path(
@@ -26,6 +34,11 @@ urlpatterns = [
views.destinataer_delete,
name="destinataer_delete",
),
path(
"destinataere/<uuid:pk>/archivieren/",
views.destinataer_toggle_archiv,
name="destinataer_toggle_archiv",
),
path(
"destinataere/<uuid:pk>/notiz/",
views.destinataer_notiz_create,
@@ -135,46 +148,7 @@ urlpatterns = [
views.foerderung_delete,
name="foerderung_delete",
),
# Dokumente URLs
path("dokumente/", views.dokument_list, name="dokument_list"),
path("dokumente/<uuid:pk>/", views.dokument_detail, name="dokument_detail"),
path("dokumente/neu/", views.dokument_create, name="dokument_create"),
path(
"dokumente/<uuid:pk>/bearbeiten/", views.dokument_update, name="dokument_update"
),
path(
"dokumente/<uuid:pk>/loeschen/", views.dokument_delete, name="dokument_delete"
),
# Dokumentenverwaltung (Paperless-Integration, Verwaltung & Verknüpfung)
path(
"dokumente/verwaltung/", views.dokument_management, name="dokument_management"
),
# Legacy document URLs removed - use dokument_management instead
# Dokument-Verknüpfung
path(
"api/link-document/search/",
views.link_document_search,
name="link_document_search",
),
path(
"api/link-document/create/",
views.link_document_create,
name="link_document_create",
),
path(
"api/link-document/list/", views.link_document_list, name="link_document_list"
),
path(
"api/link-document/update/",
views.link_document_update,
name="link_document_update",
),
path(
"api/link-document/delete/<uuid:link_id>/",
views.link_document_delete,
name="link_document_delete",
),
# Legacy dokument_verknuepfung URL removed - use dokument_management instead
# Dokumente-URLs (DMS) Legacy-Paperless-URLs entfernt (Phase 3)
# Jahresbericht URLs
path("berichte/", views.bericht_list, name="bericht_list"),
path(
@@ -192,6 +166,16 @@ urlpatterns = [
views.jahresbericht_pdf,
name="jahresbericht_pdf",
),
path(
"berichte/zusammenstellen/",
views.bericht_zusammenstellen,
name="bericht_zusammenstellen",
),
path(
"berichte/<str:vorlage_key>/",
views.bericht_vorlage,
name="bericht_vorlage",
),
# Geschäftsführung URLs
path("geschaeftsfuehrung/", views.geschaeftsfuehrung, name="geschaeftsfuehrung"),
path("geschaeftsfuehrung/konten/", views.konto_list, name="konto_list"),
@@ -214,6 +198,11 @@ urlpatterns = [
views.verwaltungskosten_create,
name="verwaltungskosten_create",
),
path(
"geschaeftsfuehrung/verwaltungskosten/<uuid:pk>/",
views.verwaltungskosten_detail,
name="verwaltungskosten_detail",
),
path(
"geschaeftsfuehrung/verwaltungskosten/<uuid:pk>/bearbeiten/",
views.verwaltungskosten_edit,
@@ -257,6 +246,7 @@ urlpatterns = [
# Administration URLs
path("administration/", views.administration, name="administration"),
path("administration/settings/", views.app_settings, name="app_settings"),
path("administration/email/", views.email_settings, name="email_settings"),
path("administration/audit-log/", views.audit_log_list, name="audit_log_list"),
path("administration/backup/", views.backup_management, name="backup_management"),
path(
@@ -343,30 +333,44 @@ urlpatterns = [
# Hilfsbox URLs
path("help-box/edit/", views.edit_help_box, name="edit_help_box"),
path("help-box/admin/", views.edit_help_box, name="help_boxes_admin"),
# Phase 4: Globale Suche (Cmd+K)
path("api/suche/", views.globale_suche_api, name="globale_suche_api"),
# API URLs
path("api/land-stats/", views.land_stats_api, name="land_stats_api"),
path("api/health/", views.health_check, name="health_check"),
path("api/paperless/ping/", views.paperless_ping, name="paperless_ping"),
path(
"api/paperless/documents/",
views.paperless_documents,
name="paperless_documents",
),
path("api/paperless/tags/", views.paperless_tags_only, name="paperless_tags_only"),
path("api/paperless/debug/", views.paperless_debug, name="paperless_debug"),
path(
"api/paperless/documents/<int:doc_id>/",
views.paperless_document_redirect,
name="paperless_document_redirect",
),
# Veranstaltungsmodul
path("veranstaltungen/", views.veranstaltung_list, name="veranstaltung_list"),
path("veranstaltungen/neu/", views.veranstaltung_create, name="veranstaltung_create"),
path("veranstaltungen/<uuid:pk>/", views.veranstaltung_detail, name="veranstaltung_detail"),
path("veranstaltungen/<uuid:pk>/bearbeiten/", views.veranstaltung_update, name="veranstaltung_update"),
path("veranstaltungen/<uuid:pk>/loeschen/", views.veranstaltung_delete, name="veranstaltung_delete"),
path(
"veranstaltungen/<uuid:pk>/serienbrief/",
views.veranstaltung_serienbrief_pdf,
name="veranstaltung_serienbrief_pdf",
),
path(
"veranstaltungen/<uuid:pk>/serienbrief-vorschau/",
views.veranstaltung_serienbrief_vorschau,
name="veranstaltung_serienbrief_vorschau",
),
# Teilnehmer CRUD
path(
"veranstaltungen/<uuid:veranstaltung_pk>/teilnehmer/neu/",
views.teilnehmer_create,
name="teilnehmer_create",
),
path(
"veranstaltungen/<uuid:veranstaltung_pk>/teilnehmer/<uuid:pk>/bearbeiten/",
views.teilnehmer_update,
name="teilnehmer_update",
),
path(
"veranstaltungen/<uuid:veranstaltung_pk>/teilnehmer/<uuid:pk>/loeschen/",
views.teilnehmer_delete,
name="teilnehmer_delete",
),
# Gramps integration (probe)
path("api/gramps/search/", views.gramps_search_api, name="gramps_search_api"),
path("api/gramps/debug/", views.gramps_debug_api, name="gramps_debug_api"),
@@ -407,6 +411,7 @@ urlpatterns = [
# E-Mail-Eingang Destinatäre
path("email-eingang/", views.email_eingang_list, name="email_eingang_list"),
path("email-eingang/<uuid:pk>/", views.email_eingang_detail, name="email_eingang_detail"),
path("email-eingang/<uuid:pk>/loeschen/", views.email_eingang_delete, name="email_eingang_delete"),
path("email-eingang/poll/", views.email_eingang_poll_trigger, name="email_eingang_poll_trigger"),
# Kalender URLs
path("kalender/", views.kalender_view, name="kalender"),
@@ -416,4 +421,97 @@ urlpatterns = [
path("kalender/<uuid:pk>/bearbeiten/", views.kalender_edit, name="kalender_edit"),
path("kalender/<uuid:pk>/loeschen/", views.kalender_delete, name="kalender_delete"),
path("kalender/api/events/", views.kalender_api_events, name="kalender_api_events"),
# Phase 2: Destinatär-Timeline (2a)
path(
"destinataere/<uuid:pk>/timeline/",
views.destinataer_timeline,
name="destinataer_timeline",
),
# Phase 2: Nachweis-Board (2b)
path("nachweis-board/", views.nachweis_board, name="nachweis_board"),
path(
"nachweis-board/erinnerung/",
views.batch_erinnerung_senden,
name="batch_erinnerung_senden",
),
# Phase 2: Zahlungs-Pipeline (2c)
path("zahlungs-pipeline/", views.zahlungs_pipeline, name="zahlungs_pipeline"),
path(
"unterstuetzungen/<uuid:pk>/freigeben/",
views.unterstuetzung_freigeben,
name="unterstuetzung_freigeben",
),
path(
"unterstuetzungen/<uuid:pk>/nachweis-eingereicht/",
views.unterstuetzung_nachweis_eingereicht,
name="unterstuetzung_nachweis_eingereicht",
),
path(
"unterstuetzungen/<uuid:pk>/abschliessen/",
views.unterstuetzung_abschliessen,
name="unterstuetzung_abschliessen",
),
path("sepa-export/", views.sepa_xml_export, name="sepa_xml_export"),
# Phase 2: Pächter-Workflow (2d)
path("paechter/workflow/", views.paechter_workflow, name="paechter_workflow"),
# Phase 4: Upload-Portal Admin-seitige Auslöser
path(
"quarterly-confirmations/<uuid:nachweis_pk>/aufforderung-senden/",
views.nachweis_aufforderung_senden,
name="nachweis_aufforderung_senden",
),
path(
"nachweis-board/batch-aufforderung-senden/",
views.batch_nachweis_aufforderung_senden,
name="batch_nachweis_aufforderung_senden",
),
# Phase 5: Onboarding Admin-Seite
path(
"destinataere/onboarding/einladen/",
views.onboarding_einladung_senden,
name="onboarding_einladung_senden",
),
path(
"destinataere/onboarding/einladungen/",
views.onboarding_einladung_liste,
name="onboarding_einladung_liste",
),
path(
"destinataere/onboarding/einladungen/<uuid:pk>/widerrufen/",
views.onboarding_einladung_widerrufen,
name="onboarding_einladung_widerrufen",
),
# Bestätigungsschreiben
path(
"destinataere/<uuid:pk>/bestaetigung/",
views.bestaetigung_vorschau,
name="bestaetigung_vorschau",
),
path(
"destinataere/<uuid:pk>/bestaetigung/versenden/",
views.bestaetigung_versenden,
name="bestaetigung_versenden",
),
# Dokument-Vorlagen-Editor
path("administration/vorlagen/", views.vorlagen_liste, name="vorlagen_liste"),
path("administration/vorlagen/<uuid:pk>/", views.vorlage_editor, name="vorlage_editor"),
path("administration/vorlagen/<uuid:pk>/zuruecksetzen/", views.vorlage_zuruecksetzen, name="vorlage_zuruecksetzen"),
path("administration/vorlagen/<uuid:pk>/vorschau/", views.vorlage_vorschau, name="vorlage_vorschau"),
path("administration/vorlagen/alle-zuruecksetzen/", views.vorlagen_alle_zuruecksetzen, name="vorlagen_alle_zuruecksetzen"),
# Phase 3: DMS Django-natives Dokumentenmanagement
path("dms/", views.dms_list, name="dms_list"),
path("dms/hochladen/", views.dms_upload, name="dms_upload"),
path("dms/suche/", views.dms_search_api, name="dms_search_api"),
path("dms/<uuid:pk>/", views.dms_detail, name="dms_detail"),
path("dms/<uuid:pk>/herunterladen/", views.dms_download, name="dms_download"),
path("dms/<uuid:pk>/bearbeiten/", views.dms_edit, name="dms_edit"),
path("dms/<uuid:pk>/loeschen/", views.dms_delete, name="dms_delete"),
]

View File

@@ -30,25 +30,6 @@ def get_config(key, default=None, fallback_to_settings=True):
return value if value is not None else default
def get_paperless_config():
"""
Get all Paperless-related configuration values
Returns:
dict: Dictionary containing all Paperless configuration
"""
return {
"api_url": get_config("paperless_api_url"),
"api_token": get_config("paperless_api_token"),
"destinataere_tag": get_config("paperless_destinataere_tag"),
"destinataere_tag_id": get_config("paperless_destinataere_tag_id"),
"land_tag": get_config("paperless_land_tag"),
"land_tag_id": get_config("paperless_land_tag_id"),
"admin_tag": get_config("paperless_admin_tag"),
"admin_tag_id": get_config("paperless_admin_tag_id"),
}
def set_config(key, value, **kwargs):
"""
Set a configuration value
@@ -63,13 +44,3 @@ def set_config(key, value, **kwargs):
"""
return AppConfiguration.set_setting(key, value, **kwargs)
def is_paperless_configured():
"""
Check if Paperless is properly configured
Returns:
bool: True if API URL and token are configured
"""
config = get_paperless_config()
return bool(config["api_url"] and config["api_token"])

View File

@@ -0,0 +1,59 @@
"""Utility für das Rendering von Dokument-Vorlagen.
Prüft zuerst die Datenbank (DokumentVorlage), fällt dann auf die Datei-Vorlage zurück.
"""
from django.template import Context, Engine, Template
from django.template.loader import render_to_string
def render_vorlage(template_name: str, context: dict, request=None) -> str:
"""Rendert eine Vorlage.
Schaut zuerst in der DB nach (DokumentVorlage), fällt auf die Datei zurück.
Args:
template_name: Template-Pfad, z.B. "pdf/bestaetigung.html"
context: Template-Kontext-Dictionary
request: Optionaler Request (für RequestContext)
Returns:
Gerenderter HTML-String
"""
from stiftung.models import DokumentVorlage
try:
vorlage = DokumentVorlage.objects.get(schluessel=template_name)
# Eigene Engine mit den Standard-Builtins, aber ohne Dateisystem-Loader
engine = Engine.get_default()
t = engine.from_string(vorlage.html_inhalt)
return t.render(Context(context))
except DokumentVorlage.DoesNotExist:
pass
# Fallback: Datei-Template
return render_to_string(template_name, context, request=request)
def get_vorlage_original(template_name: str) -> str:
"""Liest den Original-Dateiinhalt einer Vorlage (für Reset-Funktion)."""
from django.template.loaders.filesystem import Loader
from django.template import Engine
engine = Engine.get_default()
for loader in engine.template_loaders:
try:
source, _ = loader.get_contents_and_origin(template_name)
return source
except Exception:
# Try get_template_sources
try:
for origin in loader.get_template_sources(template_name):
try:
with open(origin.name, "r", encoding="utf-8") as f:
return f.read()
except OSError:
continue
except Exception:
continue
raise FileNotFoundError(f"Template-Datei nicht gefunden: {template_name}")

Some files were not shown because too many files have changed in this diff Show More