Compare commits

...

29 Commits

Author SHA1 Message Date
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
110 changed files with 22929 additions and 19768 deletions

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",
@@ -121,9 +124,9 @@ CELERY_RESULT_BACKEND = os.getenv("REDIS_URL", "redis://redis:6379/0")
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"),
},
}
@@ -131,21 +134,12 @@ CELERY_BEAT_SCHEDULE = {
# 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")
# Authentication
LOGIN_URL = "/login/"
@@ -177,7 +171,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

@@ -11,4 +11,6 @@ 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

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 it is too large Load Diff

View File

@@ -0,0 +1,14 @@
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
# 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": ("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

@@ -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,428 @@
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 and berufsgruppe 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)
# Set choices for standard_konto to allow blank
self.fields["standard_konto"].empty_label = "---"
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
for field_name, field in self.fields.items():
if field_name not in ["vorname", "nachname"]:
field.required = False
class DestinataerUnterstuetzungForm(forms.ModelForm):
"""Form für geplante/ausgeführte Destinatärunterstützungen"""
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')
if einkommenssituation_bestaetigt and not einkommenssituation_text and not einkommenssituation_datei:
raise ValidationError(
'Wenn die Einkommenssituation bestätigt wird, muss entweder ein Text oder eine Datei 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')
if vermogenssituation_bestaetigt and not vermogenssituation_text and not vermogenssituation_datei:
raise ValidationError(
'Wenn die Vermögenssituation bestätigt wird, muss entweder ein Text oder eine Datei 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:
if not studiennachweis_datei and not studiennachweis_bemerkung:
raise ValidationError(
'Wenn der Studiennachweis als eingereicht markiert wird, muss entweder eine Datei oder eine Bemerkung 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'
}

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

@@ -0,0 +1,293 @@
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",
# 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

@@ -7,94 +7,76 @@ 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,
},
{
"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": "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,
},
]
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,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'),
),
]

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,54 @@
# 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,
Person,
UnterstuetzungWiederkehrend,
VierteljahresNachweis,
)
from .geschichte import ( # noqa: F401
GeschichteBild,
GeschichteSeite,
StiftungsKalenderEintrag,
)
from .veranstaltungen import ( # noqa: F401
BriefVorlage,
Veranstaltung,
Veranstaltungsteilnehmer,
)

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,188 @@
# 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"),
("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))

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

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,473 @@
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"),
("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"),
]
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

@@ -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,43 @@ 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"
if dms_dokumente:
eingang.status = "verarbeitet" if destinataer else status
eingang.save()
if dms_dokumente:
eingang.dokument_dateien.set(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 +361,13 @@ 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

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

@@ -26,6 +26,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 +140,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(
@@ -214,6 +180,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 +228,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 +315,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 +393,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 +403,50 @@ 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 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"])

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,206 @@
# views/__init__.py
# Phase 0: Vision 2026 Re-exportiert alle View-Funktionen für Rückwärtskompatibilität
from .dashboard import ( # noqa: F401
home,
health_check,
health,
)
from .destinataere import ( # noqa: F401
person_list,
person_detail,
person_create,
person_update,
person_delete,
destinataer_list,
destinataer_detail,
destinataer_create,
destinataer_update,
destinataer_delete,
destinataer_toggle_archiv,
destinataer_notiz_create,
destinataer_export,
)
from .finanzen import ( # noqa: F401
bericht_list,
jahresbericht_generate,
jahresbericht_generate_redirect,
jahresbericht_pdf,
geschaeftsfuehrung,
konto_list,
verwaltungskosten_list,
verwaltungskosten_detail,
rentmeister_list,
rentmeister_detail,
rentmeister_ausgaben,
rentmeister_create,
rentmeister_edit,
konto_create,
konto_edit,
konto_detail,
verwaltungskosten_create,
verwaltungskosten_edit,
verwaltungskosten_delete,
mark_expense_paid,
)
from .foerderung import ( # noqa: F401
foerderung_list,
foerderung_detail,
foerderung_create,
foerderung_update,
foerderung_delete,
)
from .geschichte import ( # noqa: F401
geschichte_list,
geschichte_detail,
geschichte_create,
geschichte_edit,
geschichte_bild_upload,
geschichte_bild_delete,
kalender_view,
kalender_create,
kalender_detail,
kalender_edit,
kalender_delete,
kalender_admin,
kalender_api_events,
email_eingang_list,
email_eingang_detail,
email_eingang_delete,
email_eingang_poll_trigger,
)
from .land import ( # noqa: F401
paechter_list,
paechter_detail,
paechter_create,
paechter_update,
paechter_delete,
land_list,
land_detail,
land_create,
land_update,
land_delete,
verpachtung_list,
land_verpachtung_detail,
land_verpachtung_update,
land_verpachtung_end_direct,
land_stats_api,
paechter_export,
land_export,
verpachtung_export,
land_abrechnung_list,
land_abrechnung_detail,
land_abrechnung_create,
land_abrechnung_update,
land_abrechnung_delete,
land_verpachtung_create,
land_verpachtung_end,
land_verpachtung_edit,
verpachtung_detail,
verpachtung_create,
verpachtung_update,
verpachtung_delete,
# Phase 2d
paechter_workflow,
)
from .system import ( # noqa: F401
get_pdf_generator,
GrampsClient,
get_gramps_client,
gramps_debug_api,
globale_suche_api,
csv_import_list,
csv_import_create,
process_personen_csv,
process_destinataere_csv,
process_paechter_csv,
process_laendereien_csv,
gramps_search_api,
administration,
audit_log_list,
backup_management,
backup_download,
backup_restore,
backup_cancel,
user_management,
user_create,
user_detail,
user_edit,
user_change_password,
user_permissions,
user_delete,
user_login,
user_logout,
app_settings,
email_settings,
edit_help_box,
two_factor_setup,
two_factor_qr,
two_factor_verify,
two_factor_disable,
backup_tokens,
)
from .unterstuetzungen import ( # noqa: F401
unterstuetzungen_list,
export_unterstuetzungen_csv,
export_unterstuetzungen_pdf,
export_foerderungen_csv,
export_foerderungen_pdf,
unterstuetzung_edit,
unterstuetzung_delete,
unterstuetzungen_all,
unterstuetzung_create,
get_destinataer_info,
unterstuetzung_detail,
unterstuetzung_mark_paid,
wiederkehrende_unterstuetzungen,
quarterly_confirmation_update,
create_quarterly_support_payment,
quarterly_confirmation_create,
quarterly_confirmation_edit,
quarterly_confirmation_approve,
quarterly_confirmation_reset,
# Phase 2
destinataer_timeline,
nachweis_board,
batch_erinnerung_senden,
zahlungs_pipeline,
unterstuetzung_freigeben,
unterstuetzung_nachweis_eingereicht,
unterstuetzung_abschliessen,
sepa_xml_export,
)
from .dms import ( # noqa: F401
dms_list,
dms_detail,
dms_download,
dms_upload,
dms_delete,
dms_search_api,
dms_edit,
)
from .veranstaltung import ( # noqa: F401
veranstaltung_list,
veranstaltung_detail,
veranstaltung_serienbrief_pdf,
veranstaltung_serienbrief_vorschau,
veranstaltung_create,
veranstaltung_update,
veranstaltung_delete,
teilnehmer_create,
teilnehmer_update,
teilnehmer_delete,
)
# Non-view exports (helpers used elsewhere)
from .system import GrampsClient, get_gramps_client, get_pdf_generator # noqa: F401

View File

@@ -0,0 +1,160 @@
# views/dashboard.py
# Vision 2026 Phase 1: Dashboard Cockpit
import csv
import io
import json
import os
import time
from datetime import datetime, timedelta, date
from decimal import Decimal
import qrcode
import qrcode.image.svg
import requests
from django.conf import settings
from django.contrib import messages
from django.contrib.auth.decorators import login_required
from django.core.paginator import Paginator
from django.db.models import (Avg, Count, DecimalField, F, IntegerField, Q,
Sum, Value)
from django.db.models.functions import Cast, Coalesce, NullIf, Replace
from django.http import HttpResponse, JsonResponse
from django.shortcuts import get_object_or_404, redirect, render
from django.urls import reverse
from django.utils import timezone
from django.views.decorators.csrf import csrf_exempt
from django_otp.decorators import otp_required
from django_otp.plugins.otp_totp.models import TOTPDevice
from django_otp.plugins.otp_static.models import StaticDevice, StaticToken
from django_otp.util import random_hex
from rest_framework.decorators import api_view
from rest_framework.response import Response
from stiftung.models import (AppConfiguration, AuditLog, BackupJob, BankTransaction,
BriefVorlage, CSVImport, Destinataer,
DestinataerEmailEingang, DestinataerNotiz,
DestinataerUnterstuetzung,
DokumentLink, Foerderung, GeschichteBild, GeschichteSeite,
Land, LandAbrechnung, LandVerpachtung, Paechter, Person,
Rentmeister, StiftungsKalenderEintrag, StiftungsKonto,
UnterstuetzungWiederkehrend, Veranstaltung,
Veranstaltungsteilnehmer, Verwaltungskosten,
VierteljahresNachweis)
from stiftung.forms import (
DestinataerForm, DestinataerUnterstuetzungForm, DestinataerNotizForm,
FoerderungForm, GeschichteBildForm, GeschichteSeiteForm,
LandForm, LandVerpachtungForm, LandAbrechnungForm,
PaechterForm, DokumentLinkForm,
RentmeisterForm, StiftungsKontoForm, VerwaltungskostenForm,
BankTransactionForm, BankImportForm,
UnterstuetzungForm, UnterstuetzungWiederkehrendForm,
UnterstuetzungMarkAsPaidForm, VierteljahresNachweisForm,
UserCreationForm, UserUpdateForm, PasswordChangeForm, UserPermissionForm,
TwoFactorSetupForm, TwoFactorVerifyForm, TwoFactorDisableForm,
BackupTokenRegenerateForm, PersonForm,
VeranstaltungForm, VeranstaltungsteilnehmerForm,
)
@login_required
def home(request):
"""Vision 2026 Dashboard Cockpit"""
from stiftung.services.calendar_service import StiftungsKalenderService
today = timezone.now().date()
current_quarter = (today.month - 1) // 3 + 1
current_year = today.year
# ── Calendar events ──
calendar_service = StiftungsKalenderService()
end_date = today + timedelta(days=14)
all_events = calendar_service.get_all_events(today, end_date)
upcoming_events = [e for e in all_events if not getattr(e, 'overdue', False)]
overdue_events = [e for e in all_events if getattr(e, 'overdue', False)]
# ── Stats ──
destinataer_count = Destinataer.objects.count()
paechter_count = Paechter.objects.count()
land_count = Land.objects.count()
# Active Foerderungen (not rejected/cancelled, current or future year)
foerderung_active = Foerderung.objects.filter(
jahr__gte=current_year,
status__in=['beantragt', 'genehmigt'],
).count()
# ── Overdue Nachweise (current quarter) ──
overdue_nachweise = VierteljahresNachweis.objects.filter(
quartal=current_quarter,
jahr=current_year,
status__in=['offen', 'teilweise', 'nachbesserung'],
).select_related('destinataer').order_by('jahr', 'quartal')[:10]
# ── Pending payments (not yet paid out) ──
pending_payments = DestinataerUnterstuetzung.objects.filter(
status__in=['geplant', 'faellig', 'in_bearbeitung'],
).select_related('destinataer').order_by('faellig_am')[:10]
pending_payment_total = DestinataerUnterstuetzung.objects.filter(
status__in=['geplant', 'faellig', 'in_bearbeitung'],
).aggregate(total=Coalesce(Sum('betrag'), Decimal('0')))['total']
# ── New emails ──
new_emails = DestinataerEmailEingang.objects.filter(
status='neu',
).order_by('-eingangsdatum')[:5]
new_email_count = DestinataerEmailEingang.objects.filter(status='neu').count()
# ── Expiring leases (next 90 days) ──
lease_cutoff = today + timedelta(days=90)
expiring_leases = LandVerpachtung.objects.filter(
pachtende__lte=lease_cutoff,
pachtende__gte=today,
).select_related('paechter', 'land').order_by('pachtende')[:5]
# ── Recent audit log ──
recent_audit = AuditLog.objects.order_by('-timestamp')[:5]
context = {
"title": "Dashboard",
# Stats
"destinataer_count": destinataer_count,
"paechter_count": paechter_count,
"land_count": land_count,
"foerderung_active": foerderung_active,
# Calendar
"upcoming_events": upcoming_events[:5],
"overdue_events": overdue_events[:3],
"today": today,
# Action items
"overdue_nachweise": overdue_nachweise,
"pending_payments": pending_payments,
"pending_payment_total": pending_payment_total,
"new_emails": new_emails,
"new_email_count": new_email_count,
"expiring_leases": expiring_leases,
"recent_audit": recent_audit,
"current_quarter": current_quarter,
"current_year": current_year,
}
return render(request, "stiftung/home.html", context)
@api_view(["GET"])
def health_check(request):
"""Simple health check endpoint for deployment monitoring"""
return JsonResponse(
{
"status": "healthy",
"timestamp": timezone.now().isoformat(),
"service": "stiftung-web",
}
)
# CSV Import Views
@api_view(["GET"])
def health(_request):
return Response({"status": "ok"})

View File

@@ -0,0 +1,762 @@
# views/destinataere.py
# Phase 0: Vision 2026 Code-Refactoring
import csv
import io
import json
import os
import time
from datetime import datetime, timedelta, date
from decimal import Decimal
import qrcode
import qrcode.image.svg
import requests
from django.conf import settings
from django.contrib import messages
from django.contrib.auth.decorators import login_required
from django.core.paginator import Paginator
from django.db.models import (Avg, Count, DecimalField, F, IntegerField, Q,
Sum, Value)
from django.db.models.functions import Cast, Coalesce, NullIf, Replace
from django.http import HttpResponse, JsonResponse
from django.shortcuts import get_object_or_404, redirect, render
from django.urls import reverse
from django.utils import timezone
from django.views.decorators.csrf import csrf_exempt
from django_otp.decorators import otp_required
from django_otp.plugins.otp_totp.models import TOTPDevice
from django_otp.plugins.otp_static.models import StaticDevice, StaticToken
from django_otp.util import random_hex
from rest_framework.decorators import api_view
from rest_framework.response import Response
from stiftung.models import (AppConfiguration, AuditLog, BackupJob, BankTransaction,
BriefVorlage, CSVImport, Destinataer,
DestinataerEmailEingang, DestinataerNotiz,
DestinataerUnterstuetzung,
DokumentDatei, DokumentLink, Foerderung, GeschichteBild, GeschichteSeite,
Land, LandAbrechnung, LandVerpachtung, Paechter, Person,
Rentmeister, StiftungsKalenderEintrag, StiftungsKonto,
UnterstuetzungWiederkehrend, Veranstaltung,
Veranstaltungsteilnehmer, Verwaltungskosten,
VierteljahresNachweis)
from stiftung.forms import (
DestinataerForm, DestinataerUnterstuetzungForm, DestinataerNotizForm,
FoerderungForm, GeschichteBildForm, GeschichteSeiteForm,
LandForm, LandVerpachtungForm, LandAbrechnungForm,
PaechterForm, DokumentLinkForm,
RentmeisterForm, StiftungsKontoForm, VerwaltungskostenForm,
BankTransactionForm, BankImportForm,
UnterstuetzungForm, UnterstuetzungWiederkehrendForm,
UnterstuetzungMarkAsPaidForm, VierteljahresNachweisForm,
UserCreationForm, UserUpdateForm, PasswordChangeForm, UserPermissionForm,
TwoFactorSetupForm, TwoFactorVerifyForm, TwoFactorDisableForm,
BackupTokenRegenerateForm, PersonForm,
VeranstaltungForm, VeranstaltungsteilnehmerForm,
)
@login_required
def person_list(request):
search_query = request.GET.get("search", "")
familienzweig_filter = request.GET.get("familienzweig", "")
aktiv_filter = request.GET.get("aktiv", "")
persons = Person.objects.all()
if search_query:
persons = persons.filter(
Q(nachname__icontains=search_query)
| Q(vorname__icontains=search_query)
| Q(email__icontains=search_query)
| Q(familienzweig__icontains=search_query)
)
if familienzweig_filter:
persons = persons.filter(familienzweig=familienzweig_filter)
if aktiv_filter == "true":
persons = persons.filter(aktiv=True)
elif aktiv_filter == "false":
persons = persons.filter(aktiv=False)
# Annotate with total funding
persons = persons.annotate(total_foerderungen=Sum("foerderung__betrag"))
paginator = Paginator(persons, 20)
page_number = request.GET.get("page")
page_obj = paginator.get_page(page_number)
context = {
"page_obj": page_obj,
"search_query": search_query,
"familienzweig_filter": familienzweig_filter,
"aktiv_filter": aktiv_filter,
"familienzweig_choices": Person.FAMILIENZWIG_CHOICES,
}
return render(request, "stiftung/person_list.html", context)
@login_required
def person_detail(request, pk):
person = get_object_or_404(Person, pk=pk)
foerderungen = person.foerderung_set.all().order_by("-jahr", "-betrag")
# Get new LandVerpachtungen for this person's Paechter instances
verpachtungen = LandVerpachtung.objects.filter(paechter__person=person).order_by(
"-pachtbeginn"
)
context = {
"person": person,
"foerderungen": foerderungen,
"verpachtungen": verpachtungen,
}
return render(request, "stiftung/person_detail.html", context)
@login_required
def person_create(request):
if request.method == "POST":
form = PersonForm(request.POST)
if form.is_valid():
person = form.save()
messages.success(
request,
f'Person "{person.get_full_name()}" wurde erfolgreich erstellt.',
)
return redirect("stiftung:person_detail", pk=person.pk)
else:
form = PersonForm()
context = {"form": form, "title": "Neue Person erstellen"}
return render(request, "stiftung/person_form.html", context)
@login_required
def person_update(request, pk):
person = get_object_or_404(Person, pk=pk)
if request.method == "POST":
form = PersonForm(request.POST, instance=person)
if form.is_valid():
person = form.save()
messages.success(
request,
f'Person "{person.get_full_name()}" wurde erfolgreich aktualisiert.',
)
return redirect("stiftung:person_detail", pk=person.pk)
else:
form = PersonForm(instance=person)
context = {
"form": form,
"person": person,
"title": f"Person bearbeiten: {person.get_full_name()}",
}
return render(request, "stiftung/person_form.html", context)
@login_required
def person_delete(request, pk):
person = get_object_or_404(Person, pk=pk)
if request.method == "POST":
person.delete()
messages.success(
request, f'Person "{person.get_full_name()}" wurde erfolgreich gelöscht.'
)
return redirect("stiftung:person_list")
context = {"person": person}
return render(request, "stiftung/person_confirm_delete.html", context)
# Destinatär Views (Förderungsempfänger)
@login_required
def destinataer_list(request):
search_query = request.GET.get("search", "")
familienzweig_filter = request.GET.get("familienzweig", "")
berufsgruppe_filter = request.GET.get("berufsgruppe", "")
aktiv_filter = request.GET.get("aktiv", "true")
sort = request.GET.get("sort", "")
direction = request.GET.get("dir", "asc")
destinataere = Destinataer.objects.all()
if search_query:
destinataere = destinataere.filter(
Q(nachname__icontains=search_query)
| Q(vorname__icontains=search_query)
| Q(email__icontains=search_query)
| Q(institution__icontains=search_query)
| Q(familienzweig__icontains=search_query)
)
if familienzweig_filter:
destinataere = destinataere.filter(familienzweig=familienzweig_filter)
if berufsgruppe_filter:
destinataere = destinataere.filter(berufsgruppe=berufsgruppe_filter)
if aktiv_filter == "true":
destinataere = destinataere.filter(aktiv=True)
elif aktiv_filter == "false":
destinataere = destinataere.filter(aktiv=False)
# Annotate with total funding (coalesce nulls to Decimal for stable sorting)
destinataere = destinataere.annotate(
total_foerderungen=Coalesce(
Sum("foerderung__betrag"),
Value(
Decimal("0.00"),
output_field=DecimalField(max_digits=12, decimal_places=2),
),
output_field=DecimalField(max_digits=12, decimal_places=2),
)
)
# Sorting
sort_map = {
"vorname": ["vorname"],
"nachname": ["nachname"],
"email": ["email"],
"vierteljaehrlicher_betrag": ["vierteljaehrlicher_betrag"],
"letzter_studiennachweis": ["letzter_studiennachweis"],
"unterstuetzung_bestaetigt": ["unterstuetzung_bestaetigt"],
# Keep old mappings for backward compatibility
"name": ["nachname", "vorname"],
"familienzweig": ["familienzweig"],
"berufsgruppe": ["berufsgruppe"],
"institution": ["institution"],
"foerderungen": ["total_foerderungen"],
"status": ["aktiv"],
}
if sort in sort_map:
fields = sort_map[sort]
if direction == "desc":
order_fields = [f"-{f}" for f in fields]
else:
order_fields = fields
destinataere = destinataere.order_by(*order_fields)
else:
# Default sorting by last name (nachname) ascending
destinataere = destinataere.order_by("nachname", "vorname")
paginator = Paginator(destinataere, 50) # Increased from 20 to 50 entries per page
page_number = request.GET.get("page")
page_obj = paginator.get_page(page_number)
# Set default sort to nachname if no sort is specified
effective_sort = sort if sort else "nachname"
effective_direction = direction if sort else "asc"
context = {
"page_obj": page_obj,
"search_query": search_query,
"familienzweig_filter": familienzweig_filter,
"berufsgruppe_filter": berufsgruppe_filter,
"aktiv_filter": aktiv_filter,
"familienzweig_choices": Destinataer.FAMILIENZWIG_CHOICES,
"berufsgruppe_choices": Destinataer.BERUFSGRUPPE_CHOICES,
"sort": effective_sort,
"dir": effective_direction,
}
return render(request, "stiftung/destinataer_list.html", context)
@login_required
def destinataer_detail(request, pk):
destinataer = get_object_or_404(Destinataer, pk=pk)
# Alle mit diesem Destinatär verknüpften Dokumente laden
verknuepfte_dokumente = DokumentDatei.objects.filter(
destinataer=destinataer
).order_by("kontext", "titel")
# Förderungen für diesen Destinatär laden
foerderungen = Foerderung.objects.filter(destinataer=destinataer).order_by(
"-jahr", "-betrag"
)
# Unterstützungen für diesen Destinatär laden
unterstuetzungen = DestinataerUnterstuetzung.objects.filter(
destinataer=destinataer
).order_by("-faellig_am")
# Notizen laden
notizen_eintraege = DestinataerNotiz.objects.filter(
destinataer=destinataer
).order_by("-erstellt_am")
# Quarterly confirmations - load for current and next year
from datetime import date
current_year = date.today().year
quarterly_confirmations = VierteljahresNachweis.objects.filter(
destinataer=destinataer,
jahr__in=[current_year, current_year + 1]
).order_by('-jahr', '-quartal')
# Create missing quarterly confirmations for current year
# Quarterly tracking is now always available regardless of study proof requirements
for quartal in range(1, 5): # Q1-Q4
nachweis, created = VierteljahresNachweis.get_or_create_for_period(
destinataer, current_year, quartal
)
# Reload to get any newly created confirmations
quarterly_confirmations = VierteljahresNachweis.objects.filter(
destinataer=destinataer,
jahr__in=[current_year, current_year + 1]
).order_by('-jahr', '-quartal')
# Modal forms removed - only using full-screen editor now
# Generate available years for the add quarter dropdown (current year + next 5 years)
available_years = list(range(current_year, current_year + 6))
# Alle verfügbaren StiftungsKonten für das Select-Feld laden
stiftungskonten = StiftungsKonto.objects.all().order_by("kontoname")
# Timeline events (merged from destinataer_timeline view)
timeline_events = []
for u in destinataer.unterstuetzungen.select_related("konto", "ausgezahlt_von", "freigegeben_von").order_by("-faellig_am"):
timeline_events.append({
"datum": u.faellig_am,
"typ": "zahlung",
"icon": "fa-money-bill-wave",
"farbe": "success" if u.status == "ausgezahlt" else ("danger" if u.is_overdue() else "primary"),
"titel": f"Zahlung \u20ac{u.betrag}",
"beschreibung": u.beschreibung or u.get_status_display(),
"status": u.get_status_display(),
})
for n in destinataer.quartalseinreichungen.order_by("-jahr", "-quartal"):
datum = n.zahlung_faelligkeitsdatum or n.faelligkeitsdatum
if datum:
timeline_events.append({
"datum": datum,
"typ": "nachweis",
"icon": "fa-file-alt",
"farbe": "success" if n.status in ("geprueft", "auto_geprueft") else ("danger" if n.is_overdue() else "warning"),
"titel": f"Nachweis {n.jahr} Q{n.quartal}",
"beschreibung": n.get_status_display(),
"status": n.get_status_display(),
})
for e in destinataer.email_eingaenge.order_by("-eingangsdatum"):
timeline_events.append({
"datum": e.eingangsdatum.date() if hasattr(e.eingangsdatum, "date") else e.eingangsdatum,
"typ": "email",
"icon": "fa-envelope",
"farbe": "info",
"titel": e.betreff or "(kein Betreff)",
"beschreibung": e.absender_email,
"status": e.get_status_display(),
})
for n in destinataer.notizen_eintraege.order_by("-erstellt_am"):
timeline_events.append({
"datum": n.erstellt_am.date() if hasattr(n.erstellt_am, "date") else n.erstellt_am,
"typ": "notiz",
"icon": "fa-sticky-note",
"farbe": "secondary",
"titel": n.titel or "Notiz",
"beschreibung": (n.text[:100] + "\u2026") if n.text and len(n.text) > 100 else n.text,
"status": f"von {n.erstellt_von.get_full_name() or n.erstellt_von.username}" if n.erstellt_von else "",
})
timeline_events.sort(key=lambda e: e["datum"] if e["datum"] else date.min, reverse=True)
context = {
"destinataer": destinataer,
"verknuepfte_dokumente": verknuepfte_dokumente,
"foerderungen": foerderungen,
"unterstuetzungen": unterstuetzungen,
"notizen_eintraege": notizen_eintraege,
"stiftungskonten": stiftungskonten,
"quarterly_confirmations": quarterly_confirmations,
"available_years": available_years,
"current_year": current_year,
"timeline_events": timeline_events,
}
return render(request, "stiftung/destinataer_detail.html", context)
@login_required
def destinataer_create(request):
if request.method == "POST":
form = DestinataerForm(request.POST)
if form.is_valid():
destinataer = form.save()
messages.success(
request,
f'Destinatär "{destinataer.get_full_name()}" wurde erfolgreich erstellt.',
)
return redirect("stiftung:destinataer_detail", pk=destinataer.pk)
else:
form = DestinataerForm()
context = {"form": form, "title": "Neuen Destinatär erstellen"}
return render(request, "stiftung/destinataer_form.html", context)
@login_required
def destinataer_update(request, pk):
destinataer = get_object_or_404(Destinataer, pk=pk)
if request.method == "POST":
form = DestinataerForm(request.POST, instance=destinataer)
# Handle AJAX requests
if request.headers.get('X-Requested-With') == 'XMLHttpRequest':
if form.is_valid():
try:
destinataer = form.save()
# Note: Support payments are now only created through quarterly confirmations
# No automatic creation when unterstuetzung_bestaetigt is checked
return JsonResponse({
'success': True,
'message': f'Destinatär "{destinataer.get_full_name()}" wurde erfolgreich aktualisiert.'
})
except Exception as e:
return JsonResponse({
'success': False,
'error': f'Fehler beim Speichern: {str(e)}'
})
else:
# Return form errors for AJAX requests
errors = []
for field, field_errors in form.errors.items():
for error in field_errors:
errors.append(f'{form[field].label}: {error}')
return JsonResponse({
'success': False,
'error': 'Formular enthält Fehler: ' + '; '.join(errors)
})
# Handle regular form submission
if form.is_valid():
destinataer = form.save()
# Note: Support payments are now only created through quarterly confirmations
# No automatic creation when unterstuetzung_bestaetigt is checked
messages.success(
request,
f'Destinatär "{destinataer.get_full_name()}" wurde erfolgreich aktualisiert.',
)
return redirect("stiftung:destinataer_detail", pk=destinataer.pk)
else:
form = DestinataerForm(instance=destinataer)
context = {
"form": form,
"destinataer": destinataer,
"title": f"Destinatär bearbeiten: {destinataer.get_full_name()}",
}
return render(request, "stiftung/destinataer_form.html", context)
@login_required
def destinataer_delete(request, pk):
destinataer = get_object_or_404(Destinataer, pk=pk)
if request.method == "POST":
destinataer.delete()
messages.success(
request,
f'Destinatär "{destinataer.get_full_name()}" wurde erfolgreich gelöscht.',
)
return redirect("stiftung:destinataer_list")
context = {"destinataer": destinataer}
return render(request, "stiftung/destinataer_confirm_delete.html", context)
@login_required
def destinataer_toggle_archiv(request, pk):
"""Destinatär aktivieren/deaktivieren (archivieren)."""
destinataer = get_object_or_404(Destinataer, pk=pk)
if request.method == "POST":
destinataer.aktiv = not destinataer.aktiv
destinataer.save(update_fields=["aktiv"])
status_text = "aktiviert" if destinataer.aktiv else "archiviert (inaktiv gesetzt)"
AuditLog.objects.create(
user=request.user,
action=f"Destinatär {destinataer.get_full_name()} wurde {status_text}.",
model_name="Destinataer",
object_id=str(destinataer.pk),
)
messages.success(request, f'Destinatär "{destinataer.get_full_name()}" wurde {status_text}.')
return redirect("stiftung:destinataer_detail", pk=destinataer.pk)
# Paechter Views (Landpächter)
@login_required
def destinataer_notiz_create(request, pk):
destinataer = get_object_or_404(Destinataer, pk=pk)
if request.method == "POST":
form = DestinataerNotizForm(request.POST, request.FILES)
if form.is_valid():
note = form.save(commit=False)
note.destinataer = destinataer
note.erstellt_von = request.user
note.save()
messages.success(request, "Notiz wurde gespeichert.")
return redirect("stiftung:destinataer_detail", pk=destinataer.pk)
else:
# Debug: show what validation failed
for field, errors in form.errors.items():
messages.error(request, f'Fehler in {field}: {", ".join(errors)}')
else:
form = DestinataerNotizForm()
return render(
request,
"stiftung/destinataer_notiz_form.html",
{"form": form, "destinataer": destinataer, "title": "Notiz hinzufügen"},
)
@login_required
def destinataer_export(request, pk):
"""Export complete Destinatär data as ZIP with documents"""
import json
import os
import tempfile
import zipfile
from django.http import HttpResponse
destinataer = get_object_or_404(Destinataer, pk=pk)
# Create a temporary file for the ZIP
temp_file = tempfile.NamedTemporaryFile(delete=False, suffix=".zip")
try:
with zipfile.ZipFile(temp_file.name, "w", zipfile.ZIP_DEFLATED) as zipf:
# 1. Entity data as JSON
entity_data = {
"id": str(destinataer.id),
"anrede": destinataer.get_anrede_display() if hasattr(destinataer, 'anrede') and destinataer.anrede else None,
"titel": destinataer.titel if hasattr(destinataer, 'titel') else None,
"vorname": destinataer.vorname,
"nachname": destinataer.nachname,
"geburtsdatum": (
destinataer.geburtsdatum.isoformat()
if destinataer.geburtsdatum
else None
),
"email": destinataer.email,
"telefon": destinataer.telefon,
"mobil": destinataer.mobil if hasattr(destinataer, 'mobil') else None,
"iban": destinataer.iban,
"strasse": destinataer.strasse,
"plz": destinataer.plz,
"ort": destinataer.ort,
"familienzweig": destinataer.get_familienzweig_display(),
"berufsgruppe": destinataer.get_berufsgruppe_display(),
"ausbildungsstand": destinataer.ausbildungsstand,
"institution": destinataer.institution,
"projekt_beschreibung": destinataer.projekt_beschreibung,
"jaehrliches_einkommen": (
str(destinataer.jaehrliches_einkommen)
if destinataer.jaehrliches_einkommen
else None
),
"finanzielle_notlage": destinataer.finanzielle_notlage,
"ist_abkoemmling": destinataer.ist_abkoemmling,
"haushaltsgroesse": destinataer.haushaltsgroesse,
"monatliche_bezuege": (
str(destinataer.monatliche_bezuege)
if destinataer.monatliche_bezuege
else None
),
"vermoegen": (
str(destinataer.vermoegen) if destinataer.vermoegen else None
),
"unterstuetzung_bestaetigt": destinataer.unterstuetzung_bestaetigt,
"vierteljaehrlicher_betrag": (
str(destinataer.vierteljaehrlicher_betrag)
if destinataer.vierteljaehrlicher_betrag
else None
),
"standard_konto": (
str(destinataer.standard_konto)
if destinataer.standard_konto
else None
),
"studiennachweis_erforderlich": destinataer.studiennachweis_erforderlich,
"letzter_studiennachweis": (
destinataer.letzter_studiennachweis.isoformat()
if destinataer.letzter_studiennachweis
else None
),
"notizen": destinataer.notizen,
"aktiv": destinataer.aktiv,
"export_datum": timezone.now().isoformat(),
"export_user": request.user.username,
}
zipf.writestr(
"destinataer_data.json",
json.dumps(entity_data, indent=2, ensure_ascii=False),
)
# 2. Notes with attachments
notizen = DestinataerNotiz.objects.filter(destinataer=destinataer).order_by(
"-erstellt_am"
)
notes_data = []
for note in notizen:
note_data = {
"titel": note.titel,
"text": note.text,
"erstellt_am": note.erstellt_am.isoformat(),
"erstellt_von": (
note.erstellt_von.username if note.erstellt_von else None
),
"datei_name": note.datei.name if note.datei else None,
}
notes_data.append(note_data)
# Add attachment file if exists
if note.datei and os.path.exists(note.datei.path):
zipf.write(
note.datei.path,
f"notizen_anhaenge/{os.path.basename(note.datei.name)}",
)
if notes_data:
zipf.writestr(
"notizen.json", json.dumps(notes_data, indent=2, ensure_ascii=False)
)
# 3. Linked documents from Paperless
dokumente = DokumentLink.objects.filter(destinataer_id=destinataer.pk)
docs_data = []
for doc in dokumente:
doc_data = {
"paperless_id": doc.paperless_document_id,
"titel": doc.titel,
"kontext": doc.get_kontext_display(),
"beschreibung": doc.beschreibung,
}
docs_data.append(doc_data)
# Try to download document from Paperless
try:
if (
hasattr(settings, "PAPERLESS_API_URL")
and settings.PAPERLESS_API_URL
):
doc_url = f"{settings.PAPERLESS_API_URL}/api/documents/{doc.paperless_document_id}/download/"
headers = {}
if (
hasattr(settings, "PAPERLESS_API_TOKEN")
and settings.PAPERLESS_API_TOKEN
):
headers["Authorization"] = (
f"Token {settings.PAPERLESS_API_TOKEN}"
)
response = requests.get(doc_url, headers=headers, timeout=30)
if response.status_code == 200:
# Determine file extension from Content-Type or use .pdf as fallback
content_type = response.headers.get("content-type", "")
if "pdf" in content_type:
ext = ".pdf"
elif "jpeg" in content_type or "jpg" in content_type:
ext = ".jpg"
elif "png" in content_type:
ext = ".png"
else:
ext = ".pdf" # fallback
safe_filename = f"dokument_{doc.paperless_document_id}_{doc.titel.replace('/', '_')[:50]}{ext}"
zipf.writestr(
f"dokumente/{safe_filename}", response.content
)
doc_data["downloaded"] = True
else:
doc_data["download_error"] = f"HTTP {response.status_code}"
except Exception as e:
doc_data["download_error"] = str(e)
if docs_data:
zipf.writestr(
"dokumente.json",
json.dumps(docs_data, indent=2, ensure_ascii=False),
)
# 4. Quarterly Confirmations with documents
quarterly_confirmations = destinataer.quartalseinreichungen.all().order_by("-jahr", "-quartal")
quarterly_data = []
for confirmation in quarterly_confirmations:
confirmation_data = {
"id": str(confirmation.id),
"jahr": confirmation.jahr,
"quartal": confirmation.quartal,
"quartal_display": confirmation.get_quartal_display(),
"status": confirmation.status,
"status_display": confirmation.get_status_display(),
"studiennachweis_erforderlich": confirmation.studiennachweis_erforderlich,
"studiennachweis_eingereicht": confirmation.studiennachweis_eingereicht,
"studiennachweis_bemerkung": confirmation.studiennachweis_bemerkung,
"einkommenssituation_bestaetigt": confirmation.einkommenssituation_bestaetigt,
"einkommenssituation_text": confirmation.einkommenssituation_text,
"vermogenssituation_bestaetigt": confirmation.vermogenssituation_bestaetigt,
"vermogenssituation_text": confirmation.vermogenssituation_text,
"weitere_dokumente_beschreibung": confirmation.weitere_dokumente_beschreibung,
"interne_notizen": confirmation.interne_notizen,
"erstellt_am": confirmation.erstellt_am.isoformat(),
"aktualisiert_am": confirmation.aktualisiert_am.isoformat(),
"eingereicht_am": confirmation.eingereicht_am.isoformat() if confirmation.eingereicht_am else None,
"geprueft_am": confirmation.geprueft_am.isoformat() if confirmation.geprueft_am else None,
"geprueft_von": confirmation.geprueft_von.username if confirmation.geprueft_von else None,
"faelligkeitsdatum": confirmation.faelligkeitsdatum.isoformat() if confirmation.faelligkeitsdatum else None,
"completion_percentage": confirmation.get_completion_percentage(),
"uploaded_files": []
}
# Add uploaded files from quarterly confirmation
quarterly_files = [
("studiennachweis", confirmation.studiennachweis_datei),
("einkommenssituation", confirmation.einkommenssituation_datei),
("vermogenssituation", confirmation.vermogenssituation_datei),
("weitere_dokumente", confirmation.weitere_dokumente),
]
for file_type, file_field in quarterly_files:
if file_field and os.path.exists(file_field.path):
file_info = {
"type": file_type,
"name": os.path.basename(file_field.name),
"path": file_field.name
}
confirmation_data["uploaded_files"].append(file_info)
# Add file to ZIP
safe_filename = f"quarterly_{confirmation.jahr}_Q{confirmation.quartal}_{file_type}_{os.path.basename(file_field.name)}"
zipf.write(
file_field.path,
f"vierteljahresnachweis/{safe_filename}"
)
quarterly_data.append(confirmation_data)
if quarterly_data:
zipf.writestr(
"vierteljahresnachweis.json",
json.dumps(quarterly_data, indent=2, ensure_ascii=False),
)
# Prepare response
with open(temp_file.name, "rb") as f:
response = HttpResponse(f.read(), content_type="application/zip")
filename = f"destinataer_{destinataer.nachname}_{destinataer.vorname}_{timezone.now().strftime('%Y%m%d_%H%M%S')}.zip"
response["Content-Disposition"] = f'attachment; filename="{filename}"'
return response
finally:
# Clean up temp file
try:
os.unlink(temp_file.name)
except:
pass

280
app/stiftung/views/dms.py Normal file
View File

@@ -0,0 +1,280 @@
# views/dms.py
# Phase 3: Django-natives DMS Dokumentenverwaltung ohne Paperless-NGX
import os
from datetime import date
from django.contrib import messages
from django.contrib.auth.decorators import login_required
from django.contrib.postgres.search import SearchQuery, SearchRank
from django.core.paginator import Paginator
from django.http import FileResponse, Http404, JsonResponse
from django.shortcuts import get_object_or_404, redirect, render
from django.utils import timezone
from django.views.decorators.http import require_POST
from stiftung.models import (
Destinataer, DokumentDatei, Foerderung, Land, LandVerpachtung, Paechter, Rentmeister
)
# ---------------------------------------------------------------------------
# Hilfsfunktionen
# ---------------------------------------------------------------------------
def _save_upload(request, instance: DokumentDatei):
"""Speichert Upload-Metadaten (Dateityp, Größe, FTS)."""
if instance.datei:
f = instance.datei
instance.dateiname_original = os.path.basename(f.name)
instance.dateityp = getattr(f, "content_type", "") or ""
instance.dateigroesse = f.size if hasattr(f, "size") else 0
instance.erstellt_von = request.user
instance.save()
instance.update_suchvektor()
# ---------------------------------------------------------------------------
# DMS Hauptseiten
# ---------------------------------------------------------------------------
@login_required
def dms_list(request):
"""Dokumenten-Übersicht mit Filter und Suche."""
q = request.GET.get("q", "").strip()
kontext_filter = request.GET.get("kontext", "")
entity_filter = request.GET.get("entity", "") # z.B. "destinataer"
entity_id = request.GET.get("entity_id", "")
qs = DokumentDatei.objects.select_related(
"destinataer", "land", "paechter", "verpachtung", "erstellt_von"
)
# Volltextsuche
if q:
search_query = SearchQuery(q, config="german")
qs = qs.annotate(rank=SearchRank("suchvektor", search_query)).filter(
rank__gt=0.01
).order_by("-rank")
else:
qs = qs.order_by("-erstellt_am")
if kontext_filter:
qs = qs.filter(kontext=kontext_filter)
if entity_filter == "destinataer" and entity_id:
qs = qs.filter(destinataer_id=entity_id)
elif entity_filter == "land" and entity_id:
qs = qs.filter(land_id=entity_id)
elif entity_filter == "paechter" and entity_id:
qs = qs.filter(paechter_id=entity_id)
elif entity_filter == "verpachtung" and entity_id:
qs = qs.filter(verpachtung_id=entity_id)
paginator = Paginator(qs, 25)
page_obj = paginator.get_page(request.GET.get("page"))
context = {
"page_obj": page_obj,
"q": q,
"kontext_filter": kontext_filter,
"kontext_choices": DokumentDatei.KONTEXT_CHOICES,
"gesamt": qs.count() if not q else paginator.count,
}
return render(request, "stiftung/dms/list.html", context)
@login_required
def dms_detail(request, pk):
"""Dokument-Detailseite mit Download-Link."""
dok = get_object_or_404(DokumentDatei, pk=pk)
context = {"dok": dok}
return render(request, "stiftung/dms/detail.html", context)
@login_required
def dms_download(request, pk):
"""Direkter Datei-Download."""
dok = get_object_or_404(DokumentDatei, pk=pk)
if not dok.datei or not dok.datei.storage.exists(dok.datei.name):
raise Http404("Datei nicht gefunden.")
response = FileResponse(
dok.datei.open("rb"),
as_attachment=True,
filename=dok.dateiname_original or os.path.basename(dok.datei.name),
)
return response
@login_required
def dms_upload(request):
"""HTMX-Drag&Drop-Upload unterstützt normale POST-Anfragen und HTMX-Requests."""
# Pre-fill entity links from GET params
initial = {
"destinataer_id": request.GET.get("destinataer", ""),
"land_id": request.GET.get("land", ""),
"paechter_id": request.GET.get("paechter", ""),
"verpachtung_id": request.GET.get("verpachtung", ""),
"foerderung_id": request.GET.get("foerderung", ""),
"kontext": request.GET.get("kontext", "anderes"),
}
if request.method == "POST":
datei = request.FILES.get("datei")
titel = request.POST.get("titel", "").strip()
beschreibung = request.POST.get("beschreibung", "").strip()
kontext = request.POST.get("kontext", "anderes")
if not datei:
if request.htmx:
return JsonResponse({"error": "Keine Datei übermittelt."}, status=400)
messages.error(request, "Bitte eine Datei auswählen.")
else:
if not titel:
titel = os.path.splitext(datei.name)[0][:255]
dok = DokumentDatei(
titel=titel,
beschreibung=beschreibung,
kontext=kontext,
datei=datei,
)
# Entity links
dest_id = request.POST.get("destinataer_id", "").strip()
land_id = request.POST.get("land_id", "").strip()
paechter_id = request.POST.get("paechter_id", "").strip()
verp_id = request.POST.get("verpachtung_id", "").strip()
foerd_id = request.POST.get("foerderung_id", "").strip()
if dest_id:
try:
dok.destinataer_id = dest_id
except Exception:
pass
if land_id:
try:
dok.land_id = land_id
except Exception:
pass
if paechter_id:
try:
dok.paechter_id = paechter_id
except Exception:
pass
if verp_id:
try:
dok.verpachtung_id = verp_id
except Exception:
pass
if foerd_id:
try:
dok.foerderung_id = foerd_id
except Exception:
pass
_save_upload(request, dok)
if request.htmx:
return render(request, "stiftung/dms/partials/upload_success.html", {"dok": dok})
messages.success(request, f'Dokument \u201e{dok.titel}\u201c erfolgreich hochgeladen.')
return redirect("stiftung:dms_detail", pk=dok.pk)
# GET: zeige Upload-Formular
destinataere = Destinataer.objects.filter(aktiv=True).order_by("nachname", "vorname")
laendereien = Land.objects.filter(aktiv=True).order_by("lfd_nr")
paechter_qs = Paechter.objects.filter(aktiv=True).order_by("nachname")
context = {
"initial": initial,
"kontext_choices": DokumentDatei.KONTEXT_CHOICES,
"destinataere": destinataere,
"laendereien": laendereien,
"paechter_qs": paechter_qs,
}
return render(request, "stiftung/dms/upload.html", context)
@login_required
@require_POST
def dms_delete(request, pk):
"""Löscht ein Dokument inkl. Datei."""
dok = get_object_or_404(DokumentDatei, pk=pk)
titel = dok.titel
# Datei physisch löschen
if dok.datei:
try:
dok.datei.delete(save=False)
except Exception:
pass
dok.delete()
messages.success(request, f'Dokument \u201e{titel}\u201c gel\u00f6scht.')
next_url = request.POST.get("next") or "stiftung:dms_list"
if next_url.startswith("/"):
return redirect(next_url)
return redirect(next_url)
@login_required
def dms_search_api(request):
"""HTMX-Suche: gibt gerendertes Partial mit Suchergebnissen zurück."""
q = request.GET.get("q", "").strip()
if not q:
return render(request, "stiftung/dms/partials/search_results.html", {"results": []})
search_query = SearchQuery(q, config="german")
results = (
DokumentDatei.objects.annotate(rank=SearchRank("suchvektor", search_query))
.filter(rank__gt=0.01)
.select_related("destinataer", "land")
.order_by("-rank")[:20]
)
return render(
request,
"stiftung/dms/partials/search_results.html",
{"results": results, "q": q},
)
@login_required
def dms_edit(request, pk):
"""Bearbeite Metadaten eines Dokuments (kein Datei-Austausch)."""
dok = get_object_or_404(DokumentDatei, pk=pk)
if request.method == "POST":
dok.titel = request.POST.get("titel", dok.titel).strip()[:255]
dok.beschreibung = request.POST.get("beschreibung", "").strip()
dok.kontext = request.POST.get("kontext", dok.kontext)
# Entity assignments
dest_id = request.POST.get("destinataer_id", "").strip()
land_id = request.POST.get("land_id", "").strip()
paechter_id = request.POST.get("paechter_id", "").strip()
verp_id = request.POST.get("verpachtung_id", "").strip()
dok.destinataer_id = int(dest_id) if dest_id else None
dok.land_id = int(land_id) if land_id else None
dok.paechter_id = int(paechter_id) if paechter_id else None
dok.verpachtung_id = verp_id if verp_id else None
dok.save()
dok.update_suchvektor()
messages.success(request, "Metadaten gespeichert.")
return redirect("stiftung:dms_detail", pk=dok.pk)
destinataere = Destinataer.objects.filter(aktiv=True).order_by("nachname", "vorname")
laendereien = Land.objects.filter(aktiv=True).order_by("lfd_nr")
paechter_qs = Paechter.objects.filter(aktiv=True).order_by("nachname")
verpachtungen = LandVerpachtung.objects.select_related("land", "paechter").order_by("-pachtbeginn")[:50]
context = {
"dok": dok,
"kontext_choices": DokumentDatei.KONTEXT_CHOICES,
"destinataere": destinataere,
"laendereien": laendereien,
"paechter_qs": paechter_qs,
"verpachtungen": verpachtungen,
}
return render(request, "stiftung/dms/edit.html", context)

View File

@@ -0,0 +1,5 @@
# views/dokumente.py
# Phase 3: Vision 2026 Paperless-Code entfernt
# Alle DokumentLink/Paperless-Views wurden im Rahmen der DMS-Migration entfernt.
# Dokumente werden jetzt über das Django-DMS (DokumentDatei) verwaltet.
# DMS-Views: stiftung/views/dms.py

View File

@@ -0,0 +1,818 @@
# views/finanzen.py
# Phase 0: Vision 2026 Code-Refactoring
import csv
import io
import json
import os
import time
from datetime import datetime, timedelta, date
from decimal import Decimal
import qrcode
import qrcode.image.svg
import requests
from django.conf import settings
from django.contrib import messages
from django.contrib.auth.decorators import login_required
from django.core.paginator import Paginator
from django.db.models import (Avg, Count, DecimalField, F, IntegerField, Q,
Sum, Value)
from django.db.models.functions import Cast, Coalesce, NullIf, Replace
from django.http import HttpResponse, JsonResponse
from django.shortcuts import get_object_or_404, redirect, render
from django.urls import reverse
from django.utils import timezone
from django.views.decorators.csrf import csrf_exempt
from django_otp.decorators import otp_required
from django_otp.plugins.otp_totp.models import TOTPDevice
from django_otp.plugins.otp_static.models import StaticDevice, StaticToken
from django_otp.util import random_hex
from rest_framework.decorators import api_view
from rest_framework.response import Response
from stiftung.models import (AppConfiguration, AuditLog, BackupJob, BankTransaction,
BriefVorlage, CSVImport, Destinataer,
DestinataerEmailEingang, DestinataerNotiz,
DestinataerUnterstuetzung,
DokumentLink, Foerderung, GeschichteBild, GeschichteSeite,
Land, LandAbrechnung, LandVerpachtung, Paechter, Person,
Rentmeister, StiftungsKalenderEintrag, StiftungsKonto,
UnterstuetzungWiederkehrend, Veranstaltung,
Veranstaltungsteilnehmer, Verwaltungskosten,
VierteljahresNachweis)
from stiftung.forms import (
DestinataerForm, DestinataerUnterstuetzungForm, DestinataerNotizForm,
FoerderungForm, GeschichteBildForm, GeschichteSeiteForm,
LandForm, LandVerpachtungForm, LandAbrechnungForm,
PaechterForm, DokumentLinkForm,
RentmeisterForm, StiftungsKontoForm, VerwaltungskostenForm,
BankTransactionForm, BankImportForm,
UnterstuetzungForm, UnterstuetzungWiederkehrendForm,
UnterstuetzungMarkAsPaidForm, VierteljahresNachweisForm,
UserCreationForm, UserUpdateForm, PasswordChangeForm, UserPermissionForm,
TwoFactorSetupForm, TwoFactorVerifyForm, TwoFactorDisableForm,
BackupTokenRegenerateForm, PersonForm,
VeranstaltungForm, VeranstaltungsteilnehmerForm,
)
@login_required
def bericht_list(request):
"""List available reports"""
# Get available years from data
jahre = sorted(
set(
list(Foerderung.objects.values_list("jahr", flat=True))
+ list(LandVerpachtung.objects.values_list("pachtbeginn__year", flat=True))
),
reverse=True,
)
# Statistics for overview tiles (removed legacy Person and Verpachtung)
total_destinataere = Destinataer.objects.count()
total_laendereien = Land.objects.count()
total_verpachtungen = LandVerpachtung.objects.count()
total_foerderungen = Foerderung.objects.count()
context = {
"jahre": jahre,
"title": "Berichte",
"total_destinataere": total_destinataere,
"total_laendereien": total_laendereien,
"total_verpachtungen": total_verpachtungen,
"total_foerderungen": total_foerderungen,
}
return render(request, "stiftung/bericht_list.html", context)
def _jahresbericht_context(jahr):
"""Phase 4: Aggregiert alle Daten für den Jahresbericht."""
from stiftung.models import (
DestinataerUnterstuetzung, LandAbrechnung, Verwaltungskosten,
)
# Förderungen (legacy)
foerderungen = Foerderung.objects.filter(jahr=jahr).select_related("destinataer", "person")
total_foerderungen_legacy = foerderungen.aggregate(total=Sum("betrag"))["total"] or 0
# Unterstützungen (Phase 2 neue Pipeline)
unterstuetzungen = DestinataerUnterstuetzung.objects.filter(
faellig_am__year=jahr
).exclude(status="storniert").select_related("destinataer", "konto")
unterstuetzungen_ausgezahlt = unterstuetzungen.filter(
status__in=["ausgezahlt", "abgeschlossen"]
)
total_unterstuetzungen = unterstuetzungen_ausgezahlt.aggregate(total=Sum("betrag"))["total"] or 0
# Gesamtausgaben Förderung
total_ausgaben_foerderung = total_foerderungen_legacy + total_unterstuetzungen
# Verpachtungen
verpachtungen = LandVerpachtung.objects.filter(
pachtbeginn__year__lte=jahr
).filter(
Q(pachtende__isnull=True) | Q(pachtende__year__gte=jahr)
).select_related("land", "paechter")
total_pachtzins = verpachtungen.aggregate(total=Sum("pachtzins_pauschal"))["total"] or 0
# Landabrechnungen für das Jahr
landabrechnungen = LandAbrechnung.objects.filter(
abrechnungsjahr=jahr
).select_related("land")
pacht_vereinnahmt = landabrechnungen.aggregate(total=Sum("pacht_vereinnahmt"))["total"] or 0
grundsteuer_gesamt = landabrechnungen.aggregate(total=Sum("grundsteuer_betrag"))["total"] or 0
# Verwaltungskosten
verwaltungskosten_qs = Verwaltungskosten.objects.filter(datum__year=jahr)
total_verwaltungskosten = verwaltungskosten_qs.aggregate(total=Sum("betrag"))["total"] or 0
verwaltungskosten_nach_kategorie = (
verwaltungskosten_qs
.values("kategorie")
.annotate(summe=Sum("betrag"), anzahl=Count("id"))
.order_by("-summe")
)
# Gesamtbilanz
total_einnahmen = pacht_vereinnahmt if pacht_vereinnahmt else total_pachtzins
total_ausgaben = total_ausgaben_foerderung + total_verwaltungskosten
netto = total_einnahmen - total_ausgaben
return {
"jahr": jahr,
"title": f"Jahresbericht {jahr}",
"foerderungen": foerderungen,
"total_foerderungen_legacy": total_foerderungen_legacy,
"unterstuetzungen": unterstuetzungen,
"unterstuetzungen_ausgezahlt": unterstuetzungen_ausgezahlt,
"total_unterstuetzungen": total_unterstuetzungen,
"total_ausgaben_foerderung": total_ausgaben_foerderung,
"verpachtungen": verpachtungen,
"total_pachtzins": total_pachtzins,
"landabrechnungen": landabrechnungen,
"pacht_vereinnahmt": pacht_vereinnahmt,
"grundsteuer_gesamt": grundsteuer_gesamt,
"verwaltungskosten_nach_kategorie": verwaltungskosten_nach_kategorie,
"total_verwaltungskosten": total_verwaltungskosten,
"total_einnahmen": total_einnahmen,
"total_ausgaben": total_ausgaben,
"netto": netto,
# Rückwärtskompatibilität
"total_foerderungen": total_ausgaben_foerderung,
}
@login_required
def jahresbericht_generate(request, jahr):
"""Phase 4: Jahresbericht mit aggregierten Finanzdaten."""
context = _jahresbericht_context(jahr)
return render(request, "stiftung/jahresbericht.html", context)
@login_required
def jahresbericht_generate_redirect(request):
"""Redirects the GET form without path param to the proper URL using the provided query param 'jahr'."""
jahr = request.GET.get("jahr")
if jahr and str(jahr).isdigit():
return redirect("stiftung:jahresbericht_generate", jahr=int(jahr))
messages.error(request, "Bitte wählen Sie ein gültiges Jahr aus.")
return redirect("stiftung:bericht_list")
@login_required
def jahresbericht_pdf(request, jahr):
"""Phase 4: PDF-Export des Jahresberichts."""
from django.http import HttpResponse
from django.template.loader import render_to_string
from weasyprint import HTML
context = _jahresbericht_context(jahr)
# Render HTML
html_string = render_to_string("stiftung/jahresbericht.html", context)
# Generate PDF
pdf = HTML(string=html_string).write_pdf()
# Create response
response = HttpResponse(pdf, content_type="application/pdf")
response["Content-Disposition"] = f'attachment; filename="jahresbericht_{jahr}.pdf"'
return response
# API Views for AJAX
@login_required
def geschaeftsfuehrung(request):
"""Hauptansicht für die Geschäftsführung mit Übersicht"""
from datetime import datetime, timedelta
from django.db.models import Count, Sum
from stiftung.models import Rentmeister, StiftungsKonto, Verwaltungskosten
# Rentmeister-Übersicht
rentmeister = Rentmeister.objects.filter(aktiv=True).order_by("nachname", "vorname")
# Konten-Übersicht
konten = StiftungsKonto.objects.filter(aktiv=True).order_by(
"bank_name", "kontoname"
)
gesamtsaldo = konten.aggregate(total=Sum("saldo"))["total"] or 0
# Aktuelle Kosten (letzten 30 Tage)
heute = datetime.now().date()
vor_30_tagen = heute - timedelta(days=30)
aktuelle_kosten = Verwaltungskosten.objects.filter(
datum__gte=vor_30_tagen
).order_by("-datum")[:10]
# Statistiken
kosten_summe_monat = (
Verwaltungskosten.objects.filter(datum__gte=vor_30_tagen).aggregate(
total=Sum("betrag")
)["total"]
or 0
)
kosten_statistik = (
Verwaltungskosten.objects.filter(datum__gte=vor_30_tagen)
.values("kategorie")
.annotate(summe=Sum("betrag"), anzahl=Count("id"))
.order_by("-summe")
)
context = {
"rentmeister": rentmeister,
"konten": konten,
"gesamtsaldo": gesamtsaldo,
"aktuelle_kosten": aktuelle_kosten,
"kosten_summe_monat": kosten_summe_monat,
"kosten_statistik": kosten_statistik,
}
return render(request, "stiftung/geschaeftsfuehrung.html", context)
@login_required
def konto_list(request):
"""Liste aller Stiftungskonten"""
from django.db.models import Sum
from stiftung.models import StiftungsKonto
konten = StiftungsKonto.objects.all().order_by("bank_name", "kontoname")
gesamtsaldo = konten.aggregate(total=Sum("saldo"))["total"] or 0
context = {
"konten": konten,
"gesamtsaldo": gesamtsaldo,
}
return render(request, "stiftung/konto_list.html", context)
@login_required
def verwaltungskosten_list(request):
"""Liste aller Verwaltungskosten"""
from django.core.paginator import Paginator
from stiftung.models import Verwaltungskosten
kosten = Verwaltungskosten.objects.all().order_by("-datum", "-erstellt_am")
# Filter nach Kategorie
kategorie_filter = request.GET.get("kategorie")
if kategorie_filter:
kosten = kosten.filter(kategorie=kategorie_filter)
# Filter nach Status
status_filter = request.GET.get("status")
if status_filter:
kosten = kosten.filter(status=status_filter)
# Pagination
paginator = Paginator(kosten, 25)
page_number = request.GET.get("page")
page_obj = paginator.get_page(page_number)
# Für Filter-Dropdowns
kategorien = Verwaltungskosten.KATEGORIE_CHOICES
status_choices = Verwaltungskosten.STATUS_CHOICES
context = {
"page_obj": page_obj,
"kategorien": kategorien,
"status_choices": status_choices,
"kategorie_filter": kategorie_filter,
"status_filter": status_filter,
}
return render(request, "stiftung/verwaltungskosten_list.html", context)
@login_required
def verwaltungskosten_detail(request, pk):
"""Detailansicht einer Verwaltungskosten-Position mit verknüpften Dokumenten und E-Mails."""
from stiftung.models import DokumentDatei, EmailEingang, Verwaltungskosten
vk = get_object_or_404(Verwaltungskosten, pk=pk)
if request.method == "POST":
action = request.POST.get("action")
if action == "set_status":
new_status = request.POST.get("status", "")
if new_status in dict(Verwaltungskosten.STATUS_CHOICES):
vk.status = new_status
vk.save()
messages.success(request, f"Status auf '{vk.get_status_display()}' gesetzt.")
return redirect("stiftung:verwaltungskosten_detail", pk=pk)
# Verknüpfte DMS-Dokumente
dms_dokumente = DokumentDatei.objects.filter(verwaltungskosten=vk).order_by("erstellt_am")
# Verknüpfte E-Mails
email_eingaenge = EmailEingang.objects.filter(verwaltungskosten=vk).order_by("-eingangsdatum")
context = {
"vk": vk,
"dms_dokumente": dms_dokumente,
"email_eingaenge": email_eingaenge,
"status_choices": Verwaltungskosten.STATUS_CHOICES,
}
return render(request, "stiftung/verwaltungskosten_detail.html", context)
@login_required
def rentmeister_list(request):
"""Liste aller Rentmeister"""
from stiftung.models import Rentmeister
rentmeister = Rentmeister.objects.all().order_by("nachname", "vorname")
# Aktive/Inaktive aufteilen
aktive_rentmeister = rentmeister.filter(aktiv=True)
ehemalige_rentmeister = rentmeister.filter(aktiv=False)
context = {
"aktive_rentmeister": aktive_rentmeister,
"ehemalige_rentmeister": ehemalige_rentmeister,
"total_count": rentmeister.count(),
}
return render(request, "stiftung/rentmeister_list.html", context)
@login_required
def rentmeister_detail(request, pk):
"""Detailansicht eines Rentmeisters mit seinen Ausgaben"""
from datetime import datetime, timedelta
from django.db.models import Count, Q, Sum
from stiftung.models import Rentmeister, Verwaltungskosten
rentmeister = get_object_or_404(Rentmeister, pk=pk)
# Ausgaben des Rentmeisters
ausgaben = Verwaltungskosten.objects.filter(rentmeister=rentmeister).order_by(
"-datum"
)
# Statistiken
heute = datetime.now().date()
aktueller_monat = heute.replace(day=1)
aktuelles_jahr = heute.replace(month=1, day=1)
stats = {
"gesamt_ausgaben": ausgaben.aggregate(total=Sum("betrag"))["total"] or 0,
"monat_ausgaben": ausgaben.filter(datum__gte=aktueller_monat).aggregate(
total=Sum("betrag")
)["total"]
or 0,
"jahr_ausgaben": ausgaben.filter(datum__gte=aktuelles_jahr).aggregate(
total=Sum("betrag")
)["total"]
or 0,
"anzahl_ausgaben": ausgaben.count(),
"offene_ausgaben": ausgaben.exclude(status="bezahlt").count(),
}
# Kategorie-Aufschlüsselung
kategorie_stats = (
ausgaben.values("kategorie")
.annotate(summe=Sum("betrag"), anzahl=Count("id"))
.order_by("-summe")
)
# Aktuelle Ausgaben (letzten 30 Tage)
vor_30_tagen = heute - timedelta(days=30)
aktuelle_ausgaben = ausgaben.filter(datum__gte=vor_30_tagen)[:10]
# Verknüpfte Dokumente laden
from stiftung.models import DokumentLink
verknuepfte_dokumente = DokumentLink.objects.filter(
rentmeister_id=rentmeister.id
).order_by("-id")[
:10
] # Neueste 10 Dokumente
context = {
"rentmeister": rentmeister,
"ausgaben": ausgaben[:20], # Nur erste 20 für Übersicht
"stats": stats,
"kategorie_stats": kategorie_stats,
"aktuelle_ausgaben": aktuelle_ausgaben,
"verknuepfte_dokumente": verknuepfte_dokumente,
}
return render(request, "stiftung/rentmeister_detail.html", context)
@login_required
def rentmeister_ausgaben(request, pk):
"""Vollständige Ausgabenliste eines Rentmeisters mit PDF Export"""
from django.core.paginator import Paginator
from django.db import models
from django.db.models import Count, Q, Sum
from stiftung.models import Rentmeister, Verwaltungskosten
rentmeister = get_object_or_404(Rentmeister, pk=pk)
# Handle PDF export request
if request.method == "POST" and "export_pdf" in request.POST:
selected_ids = request.POST.getlist("selected_expenses")
if selected_ids:
# Update status to 'in_bearbeitung' and log each change
from stiftung.audit import log_action
expenses_to_update = Verwaltungskosten.objects.filter(
id__in=selected_ids, rentmeister=rentmeister
)
updated_count = 0
for expense in expenses_to_update:
old_status = expense.status
expense.status = "in_bearbeitung"
expense.save()
updated_count += 1
# Log the status change
log_action(
request=request,
action="update",
entity_type="verwaltungskosten",
entity_id=str(expense.pk),
entity_name=expense.bezeichnung,
description=f'Ausgaben-Status für PDF-Export geändert von "{old_status}" zu "in_bearbeitung"',
changes={"status": {"old": old_status, "new": "in_bearbeitung"}},
)
messages.success(
request,
f"{updated_count} Ausgaben wurden zur Bearbeitung markiert und sind bereit für PDF Export.",
)
return redirect(
"stiftung:rentmeister_ausgaben_pdf",
pk=pk,
expense_ids=",".join(selected_ids),
)
# Get expenses grouped by status
ausgaben_by_status = {}
for status_code, status_name in Verwaltungskosten.STATUS_CHOICES:
ausgaben_by_status[status_code] = {
"name": status_name,
"ausgaben": Verwaltungskosten.objects.filter(
rentmeister=rentmeister, status=status_code
).order_by("-datum", "-erstellt_am"),
"total": Verwaltungskosten.objects.filter(
rentmeister=rentmeister, status=status_code
).aggregate(total=Sum("betrag"))["total"]
or 0,
}
# Get statistics
stats = Verwaltungskosten.objects.filter(rentmeister=rentmeister).aggregate(
total_count=Count("id"),
total_amount=Sum("betrag"),
geplant_count=Count("id", filter=Q(status="geplant")),
geplant_amount=Sum("betrag", filter=Q(status="geplant")),
in_bearbeitung_count=Count("id", filter=Q(status="in_bearbeitung")),
in_bearbeitung_amount=Sum("betrag", filter=Q(status="in_bearbeitung")),
bezahlt_count=Count("id", filter=Q(status="bezahlt")),
bezahlt_amount=Sum("betrag", filter=Q(status="bezahlt")),
)
context = {
"rentmeister": rentmeister,
"ausgaben_by_status": ausgaben_by_status,
"stats": stats,
"kategorien": Verwaltungskosten.KATEGORIE_CHOICES,
"status_choices": Verwaltungskosten.STATUS_CHOICES,
}
return render(request, "stiftung/rentmeister_ausgaben.html", context)
@login_required
def rentmeister_create(request):
"""Erstelle einen neuen Rentmeister"""
from stiftung.forms import RentmeisterForm
if request.method == "POST":
form = RentmeisterForm(request.POST)
if form.is_valid():
rentmeister = form.save()
messages.success(
request,
f"Rentmeister {rentmeister.get_full_name()} wurde erfolgreich angelegt.",
)
return redirect("stiftung:rentmeister_detail", pk=rentmeister.pk)
else:
form = RentmeisterForm()
context = {
"form": form,
"title": "Neuen Rentmeister anlegen",
"submit_text": "Rentmeister anlegen",
}
return render(request, "stiftung/rentmeister_form.html", context)
@login_required
def rentmeister_edit(request, pk):
"""Bearbeite einen bestehenden Rentmeister"""
from stiftung.forms import RentmeisterForm
from stiftung.models import Rentmeister
rentmeister = get_object_or_404(Rentmeister, pk=pk)
if request.method == "POST":
form = RentmeisterForm(request.POST, instance=rentmeister)
if form.is_valid():
rentmeister = form.save()
messages.success(
request,
f"Rentmeister {rentmeister.get_full_name()} wurde erfolgreich aktualisiert.",
)
return redirect("stiftung:rentmeister_detail", pk=rentmeister.pk)
else:
form = RentmeisterForm(instance=rentmeister)
context = {
"form": form,
"rentmeister": rentmeister,
"title": f"{rentmeister.get_full_name()} bearbeiten",
"submit_text": "Änderungen speichern",
}
return render(request, "stiftung/rentmeister_form.html", context)
@login_required
def konto_create(request):
"""Erstelle ein neues Stiftungskonto"""
from stiftung.forms import StiftungsKontoForm
if request.method == "POST":
form = StiftungsKontoForm(request.POST)
if form.is_valid():
konto = form.save()
messages.success(
request, f"Konto {konto.kontoname} wurde erfolgreich angelegt."
)
return redirect("stiftung:konto_list")
else:
form = StiftungsKontoForm()
context = {
"form": form,
"title": "Neues Konto anlegen",
"submit_text": "Konto anlegen",
}
return render(request, "stiftung/konto_form.html", context)
@login_required
def konto_edit(request, pk):
"""Bearbeite ein bestehendes Stiftungskonto"""
from stiftung.forms import StiftungsKontoForm
from stiftung.models import StiftungsKonto
konto = get_object_or_404(StiftungsKonto, pk=pk)
if request.method == "POST":
form = StiftungsKontoForm(request.POST, instance=konto)
if form.is_valid():
konto = form.save()
messages.success(
request, f"Konto {konto.kontoname} wurde erfolgreich aktualisiert."
)
return redirect("stiftung:konto_list")
else:
form = StiftungsKontoForm(instance=konto)
context = {
"form": form,
"konto": konto,
"title": f"Konto {konto.kontoname} bearbeiten",
"submit_text": "Änderungen speichern",
}
return render(request, "stiftung/konto_form.html", context)
@login_required
def konto_detail(request, pk):
"""Zeige Details eines Stiftungskontos"""
from django.db import models
from django.db.models import Count, Max, Q, Sum
from stiftung.models import BankTransaction, StiftungsKonto
konto = get_object_or_404(StiftungsKonto, pk=pk)
# Get transaction statistics
transactions = BankTransaction.objects.filter(konto=konto)
transaction_stats = transactions.aggregate(
total_count=Count("id"),
total_eingang=Sum("betrag", filter=Q(betrag__gt=0)),
total_ausgang=Sum("betrag", filter=Q(betrag__lt=0)),
last_transaction_date=Max("datum"),
)
# Recent transactions
recent_transactions = transactions.order_by("-datum", "-importiert_am")[:10]
context = {
"konto": konto,
"transaction_stats": transaction_stats,
"recent_transactions": recent_transactions,
}
return render(request, "stiftung/konto_detail.html", context)
@login_required
def verwaltungskosten_create(request):
"""Erstelle neue Verwaltungskosten"""
from stiftung.forms import VerwaltungskostenForm
from stiftung.models import Rentmeister
# Check if we're coming from a specific Rentmeister
rentmeister_id = request.GET.get("rentmeister")
initial_data = {}
redirect_url = "stiftung:verwaltungskosten_list"
if rentmeister_id:
try:
rentmeister = Rentmeister.objects.get(pk=rentmeister_id)
initial_data["rentmeister"] = rentmeister
redirect_url = "stiftung:rentmeister_detail"
except Rentmeister.DoesNotExist:
pass
if request.method == "POST":
form = VerwaltungskostenForm(request.POST)
if form.is_valid():
kosten = form.save()
messages.success(
request,
f'Verwaltungskosten "{kosten.bezeichnung}" wurden erfolgreich angelegt.',
)
if rentmeister_id:
return redirect(redirect_url, pk=rentmeister_id)
return redirect("stiftung:verwaltungskosten_list")
else:
form = VerwaltungskostenForm(initial=initial_data)
context = {
"form": form,
"title": "Neue Verwaltungskosten anlegen",
"submit_text": "Kosten anlegen",
}
return render(request, "stiftung/verwaltungskosten_form.html", context)
@login_required
def verwaltungskosten_edit(request, pk):
"""Bearbeite bestehende Verwaltungskosten"""
from stiftung.forms import VerwaltungskostenForm
from stiftung.models import DokumentDatei, Verwaltungskosten
verwaltungskosten = get_object_or_404(Verwaltungskosten, pk=pk)
if request.method == "POST":
form = VerwaltungskostenForm(request.POST, instance=verwaltungskosten)
if form.is_valid():
verwaltungskosten = form.save()
messages.success(
request,
f'Verwaltungskosten "{verwaltungskosten.bezeichnung}" wurden erfolgreich aktualisiert.',
)
return redirect("stiftung:verwaltungskosten_detail", pk=pk)
else:
form = VerwaltungskostenForm(instance=verwaltungskosten)
# Verknüpfte DMS-Dokumente
dms_dokumente = DokumentDatei.objects.filter(verwaltungskosten=verwaltungskosten).order_by("erstellt_am")
context = {
"form": form,
"verwaltungskosten": verwaltungskosten,
"dms_dokumente": dms_dokumente,
"title": f"Verwaltungskosten bearbeiten: {verwaltungskosten.bezeichnung}",
"submit_text": "Änderungen speichern",
}
return render(request, "stiftung/verwaltungskosten_form.html", context)
@login_required
def verwaltungskosten_delete(request, pk):
"""Lösche Verwaltungskosten"""
from stiftung.models import Verwaltungskosten
verwaltungskosten = get_object_or_404(Verwaltungskosten, pk=pk)
if request.method == "POST":
bezeichnung = verwaltungskosten.bezeichnung
# Log the deletion
from stiftung.audit import log_action
log_action(
request=request,
action="delete",
entity_type="verwaltungskosten",
entity_id=str(verwaltungskosten.pk),
entity_name=bezeichnung,
description=f'Verwaltungskosten "{bezeichnung}" wurden gelöscht',
)
verwaltungskosten.delete()
messages.success(
request,
f'Verwaltungskosten "{bezeichnung}" wurden erfolgreich gelöscht.',
)
return redirect("stiftung:verwaltungskosten_list")
context = {
"verwaltungskosten": verwaltungskosten,
"title": f"Verwaltungskosten löschen: {verwaltungskosten.bezeichnung}",
}
return render(request, "stiftung/verwaltungskosten_delete.html", context)
@login_required
def mark_expense_paid(request):
"""Markiere eine Ausgabe als bezahlt"""
if request.method == "POST":
expense_id = request.POST.get("expense_id")
if expense_id:
try:
from stiftung.models import Verwaltungskosten
expense = Verwaltungskosten.objects.get(pk=expense_id)
old_status = expense.status
expense.status = "bezahlt"
expense.save()
# Log the status change
from stiftung.audit import log_action
log_action(
request=request,
action="update",
entity_type="verwaltungskosten",
entity_id=str(expense.pk),
entity_name=expense.bezeichnung,
description=f'Ausgaben-Status geändert von "{old_status}" zu "bezahlt"',
changes={"status": {"old": old_status, "new": "bezahlt"}},
)
messages.success(
request,
f'Ausgabe "{expense.bezeichnung}" wurde als bezahlt markiert.',
)
return redirect(
"stiftung:rentmeister_ausgaben", pk=expense.rentmeister.pk
)
except Verwaltungskosten.DoesNotExist:
messages.error(request, "Ausgabe nicht gefunden.")
return redirect("stiftung:verwaltungskosten_list")
# =============================================================================
# ADMINISTRATION VIEWS
# =============================================================================

View File

@@ -0,0 +1,236 @@
# views/foerderung.py
# Phase 0: Vision 2026 Code-Refactoring
import csv
import io
import json
import os
import time
from datetime import datetime, timedelta, date
from decimal import Decimal
import qrcode
import qrcode.image.svg
import requests
from django.conf import settings
from django.contrib import messages
from django.contrib.auth.decorators import login_required
from django.core.paginator import Paginator
from django.db.models import (Avg, Count, DecimalField, F, IntegerField, Q,
Sum, Value)
from django.db.models.functions import Cast, Coalesce, NullIf, Replace
from django.http import HttpResponse, JsonResponse
from django.shortcuts import get_object_or_404, redirect, render
from django.urls import reverse
from django.utils import timezone
from django.views.decorators.csrf import csrf_exempt
from django_otp.decorators import otp_required
from django_otp.plugins.otp_totp.models import TOTPDevice
from django_otp.plugins.otp_static.models import StaticDevice, StaticToken
from django_otp.util import random_hex
from rest_framework.decorators import api_view
from rest_framework.response import Response
from stiftung.models import (AppConfiguration, AuditLog, BackupJob, BankTransaction,
BriefVorlage, CSVImport, Destinataer,
DestinataerEmailEingang, DestinataerNotiz,
DestinataerUnterstuetzung,
DokumentDatei, DokumentLink, Foerderung, GeschichteBild, GeschichteSeite,
Land, LandAbrechnung, LandVerpachtung, Paechter, Person,
Rentmeister, StiftungsKalenderEintrag, StiftungsKonto,
UnterstuetzungWiederkehrend, Veranstaltung,
Veranstaltungsteilnehmer, Verwaltungskosten,
VierteljahresNachweis)
from stiftung.forms import (
DestinataerForm, DestinataerUnterstuetzungForm, DestinataerNotizForm,
FoerderungForm, GeschichteBildForm, GeschichteSeiteForm,
LandForm, LandVerpachtungForm, LandAbrechnungForm,
PaechterForm, DokumentLinkForm,
RentmeisterForm, StiftungsKontoForm, VerwaltungskostenForm,
BankTransactionForm, BankImportForm,
UnterstuetzungForm, UnterstuetzungWiederkehrendForm,
UnterstuetzungMarkAsPaidForm, VierteljahresNachweisForm,
UserCreationForm, UserUpdateForm, PasswordChangeForm, UserPermissionForm,
TwoFactorSetupForm, TwoFactorVerifyForm, TwoFactorDisableForm,
BackupTokenRegenerateForm, PersonForm,
VeranstaltungForm, VeranstaltungsteilnehmerForm,
)
@login_required
def foerderung_list(request):
"""List all funding grants with filtering and pagination"""
foerderungen = Foerderung.objects.select_related(
"destinataer", "verwendungsnachweis"
).all()
# Check for export request - handle both GET and POST
export_format = (
request.POST.get("format")
if request.method == "POST"
else request.GET.get("format", "")
)
selected_ids_param = (
request.POST.get("selected_entries", "")
if request.method == "POST"
else request.GET.get("selected_entries", "")
)
selected_ids = (
[id for id in selected_ids_param.split(",") if id] if selected_ids_param else []
)
# Filtering
jahr = request.GET.get("jahr")
kategorie = request.GET.get("kategorie")
status = request.GET.get("status")
destinataer = request.GET.get("destinataer")
if jahr:
foerderungen = foerderungen.filter(jahr=int(jahr))
if kategorie:
foerderungen = foerderungen.filter(kategorie=kategorie)
if status:
foerderungen = foerderungen.filter(status=status)
if destinataer:
foerderungen = foerderungen.filter(destinataer__nachname__icontains=destinataer)
# Handle exports
if export_format == "csv":
return export_foerderungen_csv(request, foerderungen, selected_ids)
elif export_format == "pdf":
return export_foerderungen_pdf(request, foerderungen, selected_ids)
# Pagination
paginator = Paginator(foerderungen, 25)
page_number = request.GET.get("page")
page_obj = paginator.get_page(page_number)
# Statistics
total_betrag = foerderungen.aggregate(total=Sum("betrag"))["total"] or 0
avg_betrag = foerderungen.aggregate(avg=Avg("betrag"))["avg"] or 0
# Year choices for filters
jahre = sorted(
set(list(Foerderung.objects.values_list("jahr", flat=True))), reverse=True
)
context = {
"page_obj": page_obj,
"foerderungen": foerderungen, # Add for counting
"total_betrag": total_betrag,
"avg_betrag": avg_betrag,
"kategorien": Foerderung.KATEGORIE_CHOICES,
"status_choices": Foerderung.STATUS_CHOICES,
"filter_jahr": jahr,
"filter_kategorie": kategorie,
"filter_status": status,
"filter_person": destinataer,
"jahre": jahre,
}
return render(request, "stiftung/foerderung_list.html", context)
@login_required
def foerderung_detail(request, pk):
"""Show details of a specific funding grant"""
foerderung = get_object_or_404(
Foerderung.objects.select_related("person", "verwendungsnachweis"), pk=pk
)
# Alle mit dieser Förderung verknüpften Dokumente laden
verknuepfte_dokumente = DokumentDatei.objects.filter(
foerderung=foerderung
).order_by("kontext", "titel")
context = {
"foerderung": foerderung,
"verknuepfte_dokumente": verknuepfte_dokumente,
"title": f"Förderung: {foerderung}",
}
return render(request, "stiftung/foerderung_detail.html", context)
@login_required
def foerderung_create(request):
"""Create a new funding grant"""
# Get destinataer from URL parameter if provided
destinataer_id = request.GET.get("destinataer")
initial = {}
if destinataer_id:
initial["destinataer"] = destinataer_id
if request.method == "POST":
form = FoerderungForm(request.POST)
if form.is_valid():
foerderung = form.save()
messages.success(
request,
f"Förderung für {foerderung.destinataer} wurde erfolgreich erstellt.",
)
return redirect("stiftung:foerderung_detail", pk=foerderung.pk)
else:
form = FoerderungForm(initial=initial)
context = {
"form": form,
"title": "Neue Förderung erstellen",
}
return render(request, "stiftung/foerderung_form.html", context)
@login_required
def foerderung_update(request, pk):
"""Update an existing funding grant"""
foerderung = get_object_or_404(Foerderung, pk=pk)
if request.method == "POST":
form = FoerderungForm(request.POST, instance=foerderung)
if form.is_valid():
form.save()
messages.success(
request,
f"Förderung für {foerderung.person} wurde erfolgreich aktualisiert.",
)
return redirect("stiftung:foerderung_detail", pk=foerderung.pk)
else:
form = FoerderungForm(instance=foerderung)
context = {
"form": form,
"foerderung": foerderung,
"title": f"Förderung bearbeiten: {foerderung}",
}
return render(request, "stiftung/foerderung_form.html", context)
@login_required
def foerderung_delete(request, pk):
"""Delete a funding grant"""
foerderung = get_object_or_404(Foerderung, pk=pk)
if request.method == "POST":
# Get the recipient name before deletion
recipient_name = (
foerderung.destinataer.get_full_name()
if foerderung.destinataer
else (
foerderung.person.get_full_name()
if foerderung.person
else "Unbekannter Empfänger"
)
)
foerderung.delete()
messages.success(
request, f"Förderung für {recipient_name} wurde erfolgreich gelöscht."
)
return redirect("stiftung:foerderung_list")
context = {
"foerderung": foerderung,
"title": f"Förderung löschen: {foerderung}",
}
return render(request, "stiftung/foerderung_confirm_delete.html", context)
# DokumentLink Views

View File

@@ -0,0 +1,811 @@
# views/geschichte.py
# Phase 0: Vision 2026 Code-Refactoring
import csv
import io
import json
import os
import time
from datetime import datetime, timedelta, date
from decimal import Decimal
import qrcode
import qrcode.image.svg
import requests
from django.conf import settings
from django.contrib import messages
from django.contrib.auth.decorators import login_required
from django.core.paginator import Paginator
from django.db.models import (Avg, Count, DecimalField, F, IntegerField, Q,
Sum, Value)
from django.db.models.functions import Cast, Coalesce, NullIf, Replace
from django.http import HttpResponse, JsonResponse
from django.shortcuts import get_object_or_404, redirect, render
from django.urls import reverse
from django.utils import timezone
from django.views.decorators.csrf import csrf_exempt
from django_otp.decorators import otp_required
from django_otp.plugins.otp_totp.models import TOTPDevice
from django_otp.plugins.otp_static.models import StaticDevice, StaticToken
from django_otp.util import random_hex
from rest_framework.decorators import api_view
from rest_framework.response import Response
from stiftung.models import (AppConfiguration, AuditLog, BackupJob, BankTransaction,
BriefVorlage, CSVImport, Destinataer,
DestinataerEmailEingang, EmailEingang, DestinataerNotiz,
DestinataerUnterstuetzung,
DokumentLink, Foerderung, GeschichteBild, GeschichteSeite,
Land, LandAbrechnung, LandVerpachtung, Paechter, Person,
Rentmeister, StiftungsKalenderEintrag, StiftungsKonto,
UnterstuetzungWiederkehrend, Veranstaltung,
Veranstaltungsteilnehmer, Verwaltungskosten,
VierteljahresNachweis)
from stiftung.forms import (
DestinataerForm, DestinataerUnterstuetzungForm, DestinataerNotizForm,
FoerderungForm, GeschichteBildForm, GeschichteSeiteForm,
LandForm, LandVerpachtungForm, LandAbrechnungForm,
PaechterForm, DokumentLinkForm,
RentmeisterForm, StiftungsKontoForm, VerwaltungskostenForm,
BankTransactionForm, BankImportForm,
UnterstuetzungForm, UnterstuetzungWiederkehrendForm,
UnterstuetzungMarkAsPaidForm, VierteljahresNachweisForm,
UserCreationForm, UserUpdateForm, PasswordChangeForm, UserPermissionForm,
TwoFactorSetupForm, TwoFactorVerifyForm, TwoFactorDisableForm,
BackupTokenRegenerateForm, PersonForm,
VeranstaltungForm, VeranstaltungsteilnehmerForm,
)
@login_required
def geschichte_list(request):
"""List all published history pages"""
seiten = GeschichteSeite.objects.filter(ist_veroeffentlicht=True).order_by('sortierung', 'titel')
context = {
'seiten': seiten,
'title': 'Geschichte der Stiftung'
}
return render(request, 'stiftung/geschichte/liste.html', context)
@login_required
def geschichte_detail(request, slug):
"""Display a specific history page"""
seite = get_object_or_404(GeschichteSeite, slug=slug, ist_veroeffentlicht=True)
bilder = seite.bilder.all().order_by('sortierung', 'titel')
context = {
'seite': seite,
'bilder': bilder,
'title': seite.titel
}
return render(request, 'stiftung/geschichte/detail.html', context)
@login_required
def geschichte_create(request):
"""Create a new history page"""
if not request.user.has_perm('stiftung.add_geschichteseite'):
messages.error(request, 'Sie haben keine Berechtigung, neue Geschichtsseiten zu erstellen.')
return redirect('stiftung:geschichte_list')
if request.method == 'POST':
form = GeschichteSeiteForm(request.POST)
if form.is_valid():
seite = form.save(commit=False)
seite.erstellt_von = request.user
seite.aktualisiert_von = request.user
seite.save()
form.save_m2m()
# Link selected DMS documents
dok_ids = request.POST.getlist("dokument_ids")
if dok_ids:
from stiftung.models import DokumentDatei
seite.dokumente.set(DokumentDatei.objects.filter(pk__in=dok_ids))
messages.success(request, f'Geschichtsseite "{seite.titel}" wurde erfolgreich erstellt.')
return redirect('stiftung:geschichte_detail', slug=seite.slug)
else:
form = GeschichteSeiteForm()
# Verfuegbare Stiftungsgeschichte-Dokumente aus DMS
from stiftung.models import DokumentDatei
geschichte_dokumente = DokumentDatei.objects.filter(
kontext="stiftungsgeschichte"
).order_by("-erstellt_am")[:20]
context = {
'form': form,
'title': 'Neue Geschichtsseite',
'geschichte_dokumente': geschichte_dokumente,
'selected_dok_ids': [],
}
return render(request, 'stiftung/geschichte/form.html', context)
@login_required
def geschichte_edit(request, slug):
"""Edit an existing history page"""
seite = get_object_or_404(GeschichteSeite, slug=slug)
if not request.user.has_perm('stiftung.change_geschichteseite'):
messages.error(request, 'Sie haben keine Berechtigung, diese Geschichtsseite zu bearbeiten.')
return redirect('stiftung:geschichte_detail', slug=slug)
if request.method == 'POST':
form = GeschichteSeiteForm(request.POST, instance=seite)
if form.is_valid():
seite = form.save(commit=False)
seite.aktualisiert_von = request.user
seite.save()
form.save_m2m()
# Update linked DMS documents
dok_ids = request.POST.getlist("dokument_ids")
from stiftung.models import DokumentDatei
seite.dokumente.set(DokumentDatei.objects.filter(pk__in=dok_ids))
messages.success(request, f'Geschichtsseite "{seite.titel}" wurde erfolgreich aktualisiert.')
return redirect('stiftung:geschichte_detail', slug=seite.slug)
else:
form = GeschichteSeiteForm(instance=seite)
# Verfuegbare Stiftungsgeschichte-Dokumente aus DMS
from stiftung.models import DokumentDatei
geschichte_dokumente = DokumentDatei.objects.filter(
kontext="stiftungsgeschichte"
).order_by("-erstellt_am")[:20]
# IDs der bereits verknuepften Dokumente
selected_dok_ids = list(seite.dokumente.values_list("pk", flat=True))
context = {
'form': form,
'seite': seite,
'title': f'Bearbeiten: {seite.titel}',
'geschichte_dokumente': geschichte_dokumente,
'selected_dok_ids': selected_dok_ids,
}
return render(request, 'stiftung/geschichte/form.html', context)
@login_required
def geschichte_bild_upload(request, slug):
"""Upload images to a history page"""
seite = get_object_or_404(GeschichteSeite, slug=slug)
if not request.user.has_perm('stiftung.add_geschichtebild'):
messages.error(request, 'Sie haben keine Berechtigung, Bilder hochzuladen.')
return redirect('stiftung:geschichte_detail', slug=slug)
if request.method == 'POST':
form = GeschichteBildForm(request.POST, request.FILES)
if form.is_valid():
bild = form.save(commit=False)
bild.seite = seite
bild.hochgeladen_von = request.user
bild.save()
messages.success(request, f'Bild "{bild.titel}" wurde erfolgreich hochgeladen.')
return redirect('stiftung:geschichte_detail', slug=slug)
else:
form = GeschichteBildForm()
context = {
'form': form,
'seite': seite,
'title': f'Bild hochladen: {seite.titel}'
}
return render(request, 'stiftung/geschichte/bild_form.html', context)
@login_required
def geschichte_bild_delete(request, slug, bild_id):
"""Delete an image from a history page"""
seite = get_object_or_404(GeschichteSeite, slug=slug)
bild = get_object_or_404(GeschichteBild, id=bild_id, seite=seite)
if not request.user.has_perm('stiftung.delete_geschichtebild'):
messages.error(request, 'Sie haben keine Berechtigung, Bilder zu löschen.')
return redirect('stiftung:geschichte_detail', slug=slug)
if request.method == 'POST':
bild_titel = bild.titel
bild.delete()
messages.success(request, f'Bild "{bild_titel}" wurde erfolgreich gelöscht.')
return redirect('stiftung:geschichte_detail', slug=slug)
context = {
'bild': bild,
'seite': seite,
'title': f'Bild löschen: {bild.titel}'
}
return render(request, 'stiftung/geschichte/bild_delete.html', context)
# Calendar Views
@login_required
def kalender_view(request):
"""Main calendar view with different view types"""
from stiftung.services.calendar_service import StiftungsKalenderService
import calendar as cal
calendar_service = StiftungsKalenderService()
# Get current date and view parameters
today = timezone.now().date()
view_type = request.GET.get('view', 'month') # month, week, list, agenda
year = int(request.GET.get('year', today.year))
month = int(request.GET.get('month', today.month))
# Calculate date ranges based on view type
if view_type == 'month':
# Get events for the entire month
start_date = date(year, month, 1)
_, last_day = cal.monthrange(year, month)
end_date = date(year, month, last_day)
title_suffix = f"{cal.month_name[month]} {year}"
elif view_type == 'week':
# Get current week
week_start = today - timedelta(days=today.weekday())
start_date = week_start
end_date = week_start + timedelta(days=6)
title_suffix = f"Woche vom {start_date.strftime('%d.%m')} - {end_date.strftime('%d.%m.%Y')}"
elif view_type == 'agenda':
# Next 30 days
start_date = today
end_date = today + timedelta(days=30)
title_suffix = "Nächste 30 Tage"
else: # list view
# Next 90 days
start_date = today
end_date = today + timedelta(days=90)
title_suffix = "Liste (nächste 90 Tage)"
# Get events for the date range
events = calendar_service.get_all_events(start_date, end_date)
# Generate calendar grid for month view
calendar_grid = None
if view_type == 'month':
calendar_grid = []
first_day = date(year, month, 1)
month_cal = cal.monthcalendar(year, month)
for week in month_cal:
week_data = []
for day in week:
if day == 0:
week_data.append(None)
else:
day_date = date(year, month, day)
day_events = [e for e in events if e.date == day_date]
week_data.append({
'day': day,
'date': day_date,
'is_today': day_date == today,
'events': day_events[:3], # Show max 3 events per day
'event_count': len(day_events)
})
calendar_grid.append(week_data)
# Navigation dates for month view
if month > 1:
prev_month = month - 1
prev_year = year
else:
prev_month = 12
prev_year = year - 1
if month < 12:
next_month = month + 1
next_year = year
else:
next_month = 1
next_year = year + 1
context = {
'title': f'Kalender - {title_suffix}',
'events': events,
'calendar_grid': calendar_grid,
'view_type': view_type,
'year': year,
'month': month,
'today': today,
'start_date': start_date,
'end_date': end_date,
'prev_year': prev_year,
'prev_month': prev_month,
'next_year': next_year,
'next_month': next_month,
'month_name': cal.month_name[month],
'weekdays': ['Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa', 'So'],
}
# Choose template based on view type
if view_type == 'month':
template = 'stiftung/kalender/month_view.html'
elif view_type == 'week':
template = 'stiftung/kalender/week_view.html'
elif view_type == 'agenda':
template = 'stiftung/kalender/agenda_view.html'
else:
template = 'stiftung/kalender/list_view.html'
return render(request, template, context)
@login_required
def kalender_create(request):
"""Create new calendar event"""
from stiftung.models import StiftungsKalenderEintrag
if request.method == 'POST':
# Simple form handling - you can enhance this with Django forms
titel = request.POST.get('titel')
beschreibung = request.POST.get('beschreibung', '')
datum = request.POST.get('datum')
kategorie = request.POST.get('kategorie', 'termin')
prioritaet = request.POST.get('prioritaet', 'normal')
if titel and datum:
zeit_str = request.POST.get('zeit')
uhrzeit = zeit_str if zeit_str else None
ganztags = not bool(zeit_str)
StiftungsKalenderEintrag.objects.create(
titel=titel,
beschreibung=beschreibung,
datum=datum,
uhrzeit=uhrzeit,
ganztags=ganztags,
kategorie=kategorie,
prioritaet=prioritaet,
erstellt_von=request.user.username
)
messages.success(request, 'Kalendereintrag wurde erfolgreich erstellt.')
return redirect('stiftung:kalender')
else:
messages.error(request, 'Titel und Datum sind erforderlich.')
context = {
'title': 'Neuer Kalendereintrag',
}
return render(request, 'stiftung/kalender/create.html', context)
@login_required
def kalender_detail(request, pk):
"""Calendar event detail view"""
from stiftung.models import StiftungsKalenderEintrag
event = get_object_or_404(StiftungsKalenderEintrag, pk=pk)
context = {
'title': f'Kalendereintrag: {event.titel}',
'event': event,
}
return render(request, 'stiftung/kalender/detail.html', context)
@login_required
def kalender_edit(request, pk):
"""Edit calendar event"""
from stiftung.models import StiftungsKalenderEintrag
event = get_object_or_404(StiftungsKalenderEintrag, pk=pk)
if request.method == 'POST':
event.titel = request.POST.get('titel', event.titel)
event.beschreibung = request.POST.get('beschreibung', event.beschreibung)
event.datum = request.POST.get('datum', event.datum)
zeit_str = request.POST.get('zeit')
if zeit_str:
event.uhrzeit = zeit_str
event.ganztags = False
else:
event.uhrzeit = None
event.ganztags = True
event.kategorie = request.POST.get('kategorie', event.kategorie)
event.prioritaet = request.POST.get('prioritaet', event.prioritaet)
event.erledigt = 'erledigt' in request.POST
event.save()
messages.success(request, 'Kalendereintrag wurde aktualisiert.')
return redirect('stiftung:kalender_detail', pk=pk)
context = {
'title': f'Bearbeiten: {event.titel}',
'event': event,
}
return render(request, 'stiftung/kalender/edit.html', context)
@login_required
def kalender_delete(request, pk):
"""Delete calendar event"""
from stiftung.models import StiftungsKalenderEintrag
event = get_object_or_404(StiftungsKalenderEintrag, pk=pk)
if request.method == 'POST':
event_titel = event.titel
event.delete()
messages.success(request, f'Kalendereintrag "{event_titel}" wurde gelöscht.')
return redirect('stiftung:kalender')
context = {
'title': f'Löschen: {event.titel}',
'event': event,
}
return render(request, 'stiftung/kalender/delete_confirm.html', context)
@login_required
def kalender_admin(request):
"""Calendar administration with event sources and management"""
from stiftung.models import StiftungsKalenderEintrag
from stiftung.services.calendar_service import StiftungsKalenderService
# Get filter parameters
show_custom = request.GET.get('show_custom', 'true') == 'true'
show_payments = request.GET.get('show_payments', 'true') == 'true'
show_leases = request.GET.get('show_leases', 'true') == 'true'
show_birthdays = request.GET.get('show_birthdays', 'true') == 'true'
category_filter = request.GET.get('category', '')
priority_filter = request.GET.get('priority', '')
# Initialize calendar service
calendar_service = StiftungsKalenderService()
# Get events based on filters
from datetime import date, timedelta
start_date = date.today() - timedelta(days=30)
end_date = date.today() + timedelta(days=90)
all_events = []
# Custom calendar entries
if show_custom:
custom_events = calendar_service.get_calendar_events(start_date, end_date)
all_events.extend(custom_events)
# Payment events
if show_payments:
payment_events = calendar_service.get_support_payment_events(start_date, end_date)
all_events.extend(payment_events)
# Lease events
if show_leases:
lease_events = calendar_service.get_lease_events(start_date, end_date)
all_events.extend(lease_events)
# Birthday events
if show_birthdays:
birthday_events = calendar_service.get_birthday_events(start_date, end_date)
all_events.extend(birthday_events)
# Filter by category and priority if specified
if category_filter:
all_events = [e for e in all_events if getattr(e, 'category', '') == category_filter]
if priority_filter:
all_events = [e for e in all_events if getattr(e, 'priority', '') == priority_filter]
# Sort events by date
all_events.sort(key=lambda x: x.date)
# Get statistics
custom_count = StiftungsKalenderEintrag.objects.count()
total_events = len(all_events)
# Event source statistics
stats = {
'custom_events': len([e for e in all_events if getattr(e, 'source', '') == 'custom']),
'payment_events': len([e for e in all_events if getattr(e, 'source', '') == 'payment']),
'lease_events': len([e for e in all_events if getattr(e, 'source', '') == 'lease']),
'birthday_events': len([e for e in all_events if getattr(e, 'source', '') == 'birthday']),
'total_events': total_events,
'custom_count': custom_count,
}
context = {
'title': 'Kalender Administration',
'events': all_events,
'stats': stats,
'show_custom': show_custom,
'show_payments': show_payments,
'show_leases': show_leases,
'show_birthdays': show_birthdays,
'category_filter': category_filter,
'priority_filter': priority_filter,
'categories': StiftungsKalenderEintrag.KATEGORIE_CHOICES,
'priorities': StiftungsKalenderEintrag.PRIORITAET_CHOICES,
}
return render(request, 'stiftung/kalender/admin.html', context)
@login_required
def kalender_api_events(request):
"""API endpoint for calendar events (JSON)"""
from django.http import JsonResponse
from stiftung.services.calendar_service import StiftungsKalenderService
from datetime import datetime
calendar_service = StiftungsKalenderService()
# Get date range from request
start_date = request.GET.get('start')
end_date = request.GET.get('end')
if start_date and end_date:
try:
start_date = datetime.strptime(start_date, '%Y-%m-%d').date()
end_date = datetime.strptime(end_date, '%Y-%m-%d').date()
except ValueError:
return JsonResponse({'error': 'Invalid date format'}, status=400)
events = calendar_service.get_all_events(start_date, end_date)
else:
events = calendar_service.get_all_events()
# Convert to FullCalendar format
calendar_events = []
for event in events:
calendar_events.append({
'id': getattr(event, 'id', str(event.title)),
'title': event.title,
'start': event.date.strftime('%Y-%m-%d'),
'description': getattr(event, 'description', ''),
'className': f"event-{event.category}",
'backgroundColor': f"var(--bs-{event.color})",
'borderColor': f"var(--bs-{event.color})",
})
return JsonResponse(calendar_events, safe=False)
# Calendar Views
@login_required
def kalender_view(request):
"""Full calendar view with all events"""
from stiftung.services.calendar_service import StiftungsKalenderService
calendar_service = StiftungsKalenderService()
# Get current month events by default
today = timezone.now().date()
events = calendar_service.get_events_for_month(today.year, today.month)
context = {
'events': events,
'title': 'Stiftungskalender',
'current_month': today.strftime('%B %Y'),
}
return render(request, 'stiftung/kalender/kalender.html', context)
context = {
'title': 'Kalendereintrag löschen'
}
return render(request, 'stiftung/kalender/delete.html', context)
# =============================================================================
# E-Mail-Eingang Destinatäre
# =============================================================================
@login_required
def email_eingang_list(request):
"""
Uebersicht aller eingegangenen E-Mails.
Filtert nach Status und Kategorie, zeigt ungeklaerte Absender zuerst.
"""
status_filter = request.GET.get("status", "")
kategorie_filter = request.GET.get("kategorie", "")
search = request.GET.get("q", "").strip()
qs = EmailEingang.objects.select_related("destinataer", "quartalsnachweis", "verwaltungskosten")
if status_filter:
qs = qs.filter(status=status_filter)
if kategorie_filter:
qs = qs.filter(kategorie=kategorie_filter)
if search:
qs = qs.filter(
Q(absender_email__icontains=search)
| Q(absender_name__icontains=search)
| Q(betreff__icontains=search)
| Q(destinataer__vorname__icontains=search)
| Q(destinataer__nachname__icontains=search)
)
# Unbekannte Absender zuerst, dann nach Datum absteigend
qs = qs.order_by(
"status",
"-eingangsdatum",
)
paginator = Paginator(qs, 30)
page_obj = paginator.get_page(request.GET.get("page"))
context = {
"title": "E-Mail-Eingang",
"page_obj": page_obj,
"status_filter": status_filter,
"kategorie_filter": kategorie_filter,
"search": search,
"status_choices": EmailEingang.STATUS_CHOICES,
"kategorie_choices": EmailEingang.KATEGORIE_CHOICES,
"counts": {
"gesamt": EmailEingang.objects.count(),
"neu": EmailEingang.objects.filter(status="neu").count(),
"unbekannt": EmailEingang.objects.filter(status="unbekannt").count(),
"rechnung": EmailEingang.objects.filter(kategorie="rechnung").count(),
"fehler": EmailEingang.objects.filter(status="fehler").count(),
},
}
return render(request, "stiftung/email_eingang/list.html", context)
@login_required
def email_eingang_detail(request, pk):
"""Detailansicht einer eingegangenen E-Mail mit Zuordnung und Rechnungserfassung."""
eingang = get_object_or_404(EmailEingang, pk=pk)
if request.method == "POST":
action = request.POST.get("action")
if action == "assign_destinataer":
dest_id = request.POST.get("destinataer_id")
if dest_id:
try:
destinataer = Destinataer.objects.get(pk=dest_id)
eingang.destinataer = destinataer
eingang.kategorie = "destinataer"
eingang.status = "zugewiesen"
eingang.save()
eingang.dokument_dateien.filter(destinataer__isnull=True).update(
destinataer=destinataer
)
messages.success(request, f"E-Mail wurde {destinataer} zugeordnet.")
except Destinataer.DoesNotExist:
messages.error(request, "Destinataer nicht gefunden.")
return redirect("stiftung:email_eingang_detail", pk=pk)
elif action == "erfasse_rechnung":
# Erstelle Verwaltungskosten-Eintrag aus Email
bezeichnung = request.POST.get("bezeichnung", eingang.betreff[:200]).strip()
betrag = request.POST.get("betrag", "0").strip().replace(",", ".")
kategorie = request.POST.get("vk_kategorie", "rechnung_intern")
lieferant = request.POST.get("lieferant", eingang.absender_name or eingang.absender_email).strip()
rechnungsnummer = request.POST.get("rechnungsnummer", "").strip()
try:
from decimal import Decimal
vk = Verwaltungskosten(
bezeichnung=bezeichnung[:200],
kategorie=kategorie,
betrag=Decimal(betrag) if betrag else Decimal("0"),
datum=eingang.eingangsdatum.date(),
lieferant_firma=lieferant[:200],
rechnungsnummer=rechnungsnummer[:100],
status="erhalten",
beschreibung=f"Automatisch erfasst aus E-Mail-Eingang.\nBetreff: {eingang.betreff}\nAbsender: {eingang.absender_email}",
)
vk.save()
# Verknuepfe Email mit Verwaltungskosten
eingang.verwaltungskosten = vk
eingang.kategorie = "rechnung"
eingang.status = "rechnung_erfasst"
eingang.save()
# Verknuepfe angehaengte Dokumente mit Verwaltungskosten
for dok in eingang.dokument_dateien.all():
dok.verwaltungskosten = vk
dok.kontext = "rechnung"
dok.save()
messages.success(request, f'Rechnung "{bezeichnung}" erfasst (€{betrag}).')
except Exception as exc:
messages.error(request, f"Fehler beim Erfassen der Rechnung: {exc}")
return redirect("stiftung:email_eingang_detail", pk=pk)
elif action == "set_kategorie":
new_kategorie = request.POST.get("kategorie", "")
if new_kategorie in dict(EmailEingang.KATEGORIE_CHOICES):
eingang.kategorie = new_kategorie
eingang.save()
messages.success(request, f"Kategorie auf '{dict(EmailEingang.KATEGORIE_CHOICES)[new_kategorie]}' gesetzt.")
return redirect("stiftung:email_eingang_detail", pk=pk)
elif action == "mark_verarbeitet":
eingang.status = "verarbeitet"
eingang.notizen = request.POST.get("notizen", eingang.notizen)
eingang.save()
messages.success(request, "E-Mail als verarbeitet markiert.")
return redirect("stiftung:email_eingang_list")
elif action == "save_notizen":
eingang.notizen = request.POST.get("notizen", "")
eingang.save()
messages.success(request, "Notizen gespeichert.")
return redirect("stiftung:email_eingang_detail", pk=pk)
# DMS-Dokumente
dms_dokumente = eingang.dokument_dateien.all().order_by("erstellt_am")
# Alle aktiven Destinataere fuer manuelle Zuordnung
alle_destinataere = Destinataer.objects.filter(aktiv=True).order_by("nachname", "vorname")
context = {
"title": f"E-Mail-Eingang: {eingang}",
"eingang": eingang,
"dms_dokumente": dms_dokumente,
"alle_destinataere": alle_destinataere,
"vk_kategorie_choices": Verwaltungskosten.KATEGORIE_CHOICES,
}
return render(request, "stiftung/email_eingang/detail.html", context)
@login_required
def email_eingang_poll_trigger(request):
"""Loest den IMAP-Poll manuell aus sucht alle E-Mails der letzten 30 Tage."""
if request.method == "POST":
from stiftung.tasks import poll_emails
try:
result = poll_emails.apply(kwargs={"search_all_recent_days": 30}).get(timeout=300)
processed = result.get("processed", 0) if isinstance(result, dict) else 0
if result and result.get("status") == "skipped":
messages.warning(request, "IMAP ist nicht konfiguriert. Bitte Einstellungen unter Administration → E-Mail / IMAP prüfen.")
elif processed > 0:
error_count = result.get("errors", 0) if isinstance(result, dict) else 0
if error_count > 0:
messages.warning(request, f"{processed} E-Mail(s) importiert, aber {error_count} Fehler aufgetreten. Bitte Logs prüfen.")
else:
messages.success(request, f"{processed} neue E-Mail(s) importiert.")
else:
error_count = result.get("errors", 0) if isinstance(result, dict) else 0
if error_count > 0:
messages.warning(request, f"Keine neuen E-Mails importiert, aber {error_count} Fehler aufgetreten. Bitte Logs prüfen.")
else:
messages.info(request, "Keine neuen E-Mails gefunden.")
except Exception as exc:
messages.error(request, f"Fehler beim E-Mail-Abruf: {exc}")
return redirect("stiftung:email_eingang_list")
@login_required
def email_eingang_delete(request, pk):
"""Loescht eine eingegangene E-Mail."""
eingang = get_object_or_404(EmailEingang, pk=pk)
if request.method == "POST":
betreff = eingang.betreff or "(kein Betreff)"
eingang.delete()
messages.success(request, f'E-Mail "{betreff}" wurde gelöscht.')
return redirect("stiftung:email_eingang_list")
return redirect("stiftung:email_eingang_detail", pk=pk)
# ============================================================
# Veranstaltungsmodul
# ============================================================

1575
app/stiftung/views/land.py Normal file

File diff suppressed because it is too large Load Diff

2348
app/stiftung/views/system.py Normal file

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,254 @@
# views/veranstaltung.py
# Phase 0: Vision 2026 Code-Refactoring
import csv
import io
import json
import os
import time
from datetime import datetime, timedelta, date
from decimal import Decimal
import qrcode
import qrcode.image.svg
import requests
from django.conf import settings
from django.contrib import messages
from django.contrib.auth.decorators import login_required
from django.core.paginator import Paginator
from django.db.models import (Avg, Count, DecimalField, F, IntegerField, Q,
Sum, Value)
from django.db.models.functions import Cast, Coalesce, NullIf, Replace
from django.http import HttpResponse, JsonResponse
from django.shortcuts import get_object_or_404, redirect, render
from django.urls import reverse
from django.utils import timezone
from django.views.decorators.csrf import csrf_exempt
from django_otp.decorators import otp_required
from django_otp.plugins.otp_totp.models import TOTPDevice
from django_otp.plugins.otp_static.models import StaticDevice, StaticToken
from django_otp.util import random_hex
from rest_framework.decorators import api_view
from rest_framework.response import Response
from stiftung.models import (AppConfiguration, AuditLog, BackupJob, BankTransaction,
BriefVorlage, CSVImport, Destinataer,
DestinataerEmailEingang, DestinataerNotiz,
DestinataerUnterstuetzung,
DokumentLink, Foerderung, GeschichteBild, GeschichteSeite,
Land, LandAbrechnung, LandVerpachtung, Paechter, Person,
Rentmeister, StiftungsKalenderEintrag, StiftungsKonto,
UnterstuetzungWiederkehrend, Veranstaltung,
Veranstaltungsteilnehmer, Verwaltungskosten,
VierteljahresNachweis)
from stiftung.forms import (
DestinataerForm, DestinataerUnterstuetzungForm, DestinataerNotizForm,
FoerderungForm, GeschichteBildForm, GeschichteSeiteForm,
LandForm, LandVerpachtungForm, LandAbrechnungForm,
PaechterForm, DokumentLinkForm,
RentmeisterForm, StiftungsKontoForm, VerwaltungskostenForm,
BankTransactionForm, BankImportForm,
UnterstuetzungForm, UnterstuetzungWiederkehrendForm,
UnterstuetzungMarkAsPaidForm, VierteljahresNachweisForm,
UserCreationForm, UserUpdateForm, PasswordChangeForm, UserPermissionForm,
TwoFactorSetupForm, TwoFactorVerifyForm, TwoFactorDisableForm,
BackupTokenRegenerateForm, PersonForm,
VeranstaltungForm, VeranstaltungsteilnehmerForm,
)
@login_required
def veranstaltung_list(request):
"""Liste aller Veranstaltungen"""
veranstaltungen = Veranstaltung.objects.all()
return render(request, "stiftung/veranstaltung/list.html", {"veranstaltungen": veranstaltungen})
@login_required
def veranstaltung_detail(request, pk):
"""Detail-Ansicht einer Veranstaltung mit RSVP-Übersicht"""
veranstaltung = get_object_or_404(Veranstaltung, pk=pk)
teilnehmer = veranstaltung.teilnehmer.all()
context = {
"veranstaltung": veranstaltung,
"teilnehmer": teilnehmer,
"zugesagte": teilnehmer.filter(rsvp_status="zugesagt"),
"abgesagte": teilnehmer.filter(rsvp_status="abgesagt"),
"keine_rueckmeldung": teilnehmer.filter(rsvp_status="keine_rueckmeldung"),
"eingeladen": teilnehmer.filter(rsvp_status="eingeladen"),
}
return render(request, "stiftung/veranstaltung/detail.html", context)
@login_required
def veranstaltung_serienbrief_pdf(request, pk):
"""Generiert Serienbrief-PDF für alle Teilnehmer einer Veranstaltung"""
from weasyprint import HTML
from django.template.loader import render_to_string
veranstaltung = get_object_or_404(Veranstaltung, pk=pk)
teilnehmer = veranstaltung.teilnehmer.all().order_by("nachname", "vorname")
# Render HTML for all letters
html_string = render_to_string(
"stiftung/veranstaltung/serienbrief_pdf.html",
{
"veranstaltung": veranstaltung,
"teilnehmer": teilnehmer,
},
)
pdf = HTML(string=html_string).write_pdf()
filename = f"einladungen_{veranstaltung.datum}_{veranstaltung.titel[:30].replace(' ', '_')}.pdf"
response = HttpResponse(pdf, content_type="application/pdf")
response["Content-Disposition"] = f'attachment; filename="{filename}"'
return response
@login_required
def veranstaltung_serienbrief_vorschau(request, pk):
"""HTML-Vorschau des Serienbriefs im Browser (kein PDF-Download)"""
veranstaltung = get_object_or_404(Veranstaltung, pk=pk)
teilnehmer = veranstaltung.teilnehmer.all().order_by("nachname", "vorname")
return render(
request,
"stiftung/veranstaltung/serienbrief_vorschau.html",
{
"veranstaltung": veranstaltung,
"teilnehmer": teilnehmer,
},
)
@login_required
def veranstaltung_create(request):
"""Neue Veranstaltung erstellen"""
from stiftung.forms import VeranstaltungForm
if request.method == "POST":
form = VeranstaltungForm(request.POST)
if form.is_valid():
veranstaltung = form.save()
messages.success(request, f'Veranstaltung "{veranstaltung.titel}" wurde erstellt.')
return redirect("stiftung:veranstaltung_detail", pk=veranstaltung.pk)
else:
messages.error(request, "Bitte korrigieren Sie die Fehler im Formular.")
else:
form = VeranstaltungForm()
return render(request, "stiftung/veranstaltung/form.html", {
"form": form,
"title": "Neue Veranstaltung erstellen",
})
@login_required
def veranstaltung_update(request, pk):
"""Veranstaltung bearbeiten (inkl. Serienbrief-Felder)"""
from stiftung.forms import VeranstaltungForm
veranstaltung = get_object_or_404(Veranstaltung, pk=pk)
if request.method == "POST":
form = VeranstaltungForm(request.POST, instance=veranstaltung)
if form.is_valid():
form.save()
messages.success(request, f'Veranstaltung "{veranstaltung.titel}" wurde aktualisiert.')
return redirect("stiftung:veranstaltung_detail", pk=veranstaltung.pk)
else:
messages.error(request, "Bitte korrigieren Sie die Fehler im Formular.")
else:
form = VeranstaltungForm(instance=veranstaltung)
return render(request, "stiftung/veranstaltung/form.html", {
"form": form,
"veranstaltung": veranstaltung,
"title": f"Veranstaltung bearbeiten: {veranstaltung.titel}",
})
@login_required
def veranstaltung_delete(request, pk):
"""Veranstaltung löschen"""
veranstaltung = get_object_or_404(Veranstaltung, pk=pk)
if request.method == "POST":
titel = veranstaltung.titel
veranstaltung.delete()
messages.success(request, f'Veranstaltung "{titel}" wurde gelöscht.')
return redirect("stiftung:veranstaltung_list")
return render(request, "stiftung/veranstaltung/delete.html", {
"veranstaltung": veranstaltung,
})
@login_required
def teilnehmer_create(request, veranstaltung_pk):
"""Teilnehmer zu einer Veranstaltung hinzufügen"""
from stiftung.forms import VeranstaltungsteilnehmerForm
veranstaltung = get_object_or_404(Veranstaltung, pk=veranstaltung_pk)
if request.method == "POST":
form = VeranstaltungsteilnehmerForm(request.POST)
if form.is_valid():
teilnehmer = form.save(commit=False)
teilnehmer.veranstaltung = veranstaltung
teilnehmer.save()
messages.success(request, f"{teilnehmer.vorname} {teilnehmer.nachname} wurde hinzugefügt.")
return redirect("stiftung:veranstaltung_detail", pk=veranstaltung.pk)
else:
messages.error(request, "Bitte korrigieren Sie die Fehler im Formular.")
else:
form = VeranstaltungsteilnehmerForm()
return render(request, "stiftung/veranstaltung/teilnehmer_form.html", {
"form": form,
"veranstaltung": veranstaltung,
"title": "Teilnehmer hinzufügen",
})
@login_required
def teilnehmer_update(request, veranstaltung_pk, pk):
"""Teilnehmer bearbeiten"""
from stiftung.forms import VeranstaltungsteilnehmerForm
veranstaltung = get_object_or_404(Veranstaltung, pk=veranstaltung_pk)
teilnehmer = get_object_or_404(Veranstaltungsteilnehmer, pk=pk, veranstaltung=veranstaltung)
if request.method == "POST":
form = VeranstaltungsteilnehmerForm(request.POST, instance=teilnehmer)
if form.is_valid():
form.save()
messages.success(request, f"{teilnehmer.vorname} {teilnehmer.nachname} wurde aktualisiert.")
return redirect("stiftung:veranstaltung_detail", pk=veranstaltung.pk)
else:
messages.error(request, "Bitte korrigieren Sie die Fehler im Formular.")
else:
form = VeranstaltungsteilnehmerForm(instance=teilnehmer)
return render(request, "stiftung/veranstaltung/teilnehmer_form.html", {
"form": form,
"veranstaltung": veranstaltung,
"teilnehmer": teilnehmer,
"title": f"Teilnehmer bearbeiten: {teilnehmer.vorname} {teilnehmer.nachname}",
})
@login_required
def teilnehmer_delete(request, veranstaltung_pk, pk):
"""Teilnehmer aus Veranstaltung entfernen"""
veranstaltung = get_object_or_404(Veranstaltung, pk=veranstaltung_pk)
teilnehmer = get_object_or_404(Veranstaltungsteilnehmer, pk=pk, veranstaltung=veranstaltung)
if request.method == "POST":
name = f"{teilnehmer.vorname} {teilnehmer.nachname}"
teilnehmer.delete()
messages.success(request, f"{name} wurde aus der Teilnehmerliste entfernt.")
return redirect("stiftung:veranstaltung_detail", pk=veranstaltung.pk)
return render(request, "stiftung/veranstaltung/teilnehmer_delete.html", {
"veranstaltung": veranstaltung,
"teilnehmer": teilnehmer,
})

File diff suppressed because it is too large Load Diff

View File

@@ -217,6 +217,12 @@
<span>App Settings</span>
</a>
</div>
<div class="col-md-3 mb-3">
<a href="{% url 'stiftung:email_settings' %}" class="btn btn-outline-primary w-100">
<i class="fas fa-envelope d-block mb-2 fa-2x"></i>
<span>E-Mail / IMAP</span>
</a>
</div>
<div class="col-md-3 mb-3">
<a href="{% url 'stiftung:audit_log_list' %}" class="btn btn-outline-primary w-100">
<i class="fas fa-history d-block mb-2 fa-2x"></i>
@@ -254,7 +260,7 @@
</a>
</div>
<div class="col-md-3 mb-3">
<a href="{% url 'stiftung:dokument_management' %}" class="btn btn-outline-secondary w-100">
<a href="{% url 'stiftung:dms_list' %}" class="btn btn-outline-secondary w-100">
<i class="fas fa-folder-open d-block mb-2 fa-2x"></i>
<span>Dokumentenverwaltung</span>
</a>

View File

@@ -27,7 +27,7 @@
<div class="card mb-4">
<div class="card-header">
<h4 class="mb-0">
<i class="fas fa-{% if category_name == 'Paperless Integration' %}file-alt{% elif category_name == 'System' %}cog{% elif category_name == 'Database' %}database{% else %}folder{% endif %}"></i>
<i class="fas fa-{% if category_name == 'Paperless Integration' %}file-alt{% elif category_name == 'E-Mail / IMAP' %}envelope{% elif category_name == 'System' %}cog{% elif category_name == 'Database' %}database{% else %}folder{% endif %}"></i>
{{ category_name }}
</h4>
</div>
@@ -49,9 +49,9 @@
{% if setting.setting_type == 'boolean' %}
<div class="form-check">
<input type="checkbox"
class="form-check-input"
id="setting_{{ setting.key }}"
<input type="checkbox"
class="form-check-input"
id="setting_{{ setting.key }}"
name="setting_{{ setting.key }}"
value="True"
{% if setting.get_typed_value %}checked{% endif %}
@@ -63,17 +63,24 @@
{% if not setting.get_typed_value %}
<input type="hidden" name="setting_{{ setting.key }}" value="False">
{% endif %}
{% elif setting.setting_type == 'integer' %}
<input type="number"
class="form-control"
id="setting_{{ setting.key }}"
{% elif setting.setting_type == 'password' %}
<input type="password"
class="form-control"
id="setting_{{ setting.key }}"
name="setting_{{ setting.key }}"
value="{{ setting.value }}"
{% if setting.is_system %}readonly{% endif %}>
{% elif setting.setting_type == 'integer' or setting.setting_type == 'number' %}
<input type="number"
class="form-control"
id="setting_{{ setting.key }}"
name="setting_{{ setting.key }}"
value="{{ setting.get_typed_value }}"
{% if setting.is_system %}readonly{% endif %}>
{% elif setting.setting_type == 'text' or setting.setting_type == 'url' %}
<input type="{% if setting.setting_type == 'url' %}url{% else %}text{% endif %}"
class="form-control"
id="setting_{{ setting.key }}"
<input type="{% if setting.setting_type == 'url' %}url{% else %}text{% endif %}"
class="form-control"
id="setting_{{ setting.key }}"
name="setting_{{ setting.key }}"
value="{{ setting.value }}"
{% if setting.is_system %}readonly{% endif %}>

File diff suppressed because it is too large Load Diff

View File

@@ -5,474 +5,256 @@
{% block title %}{{ title }} - Stiftungsverwaltung{% endblock %}
{% block content %}
<div class="container-fluid px-4">
<div class="row">
<div class="col-12">
<div class="d-flex justify-content-between align-items-center mb-4">
<h1 class="h3">
<i class="fas fa-users text-primary me-2"></i>
{{ title }}
</h1>
<div>
<a href="{% url 'stiftung:destinataer_list' %}" class="btn btn-outline-secondary">
<i class="fas fa-arrow-left me-2"></i>Zurück zur Liste
</a>
</div>
</div>
<div class="row">
<div class="col-lg-8">
<div class="card shadow">
<div class="card-header bg-primary text-white">
<h5 class="card-title mb-0">
<i class="fas fa-edit me-2"></i>Destinatär-Daten
</h5>
</div>
<div class="card-body p-4">
<!-- Elegant Two-Column Form Design -->
<style>
.form-section {
margin-bottom: 4rem;
padding: 2.5rem;
background: linear-gradient(135deg, #f8f9fb 0%, #f1f3f7 100%);
border-radius: 16px;
border: 1px solid #e3e6f0;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.05);
}
.form-section h4 {
font-size: 1.75rem;
font-weight: 700;
color: var(--racing-green);
margin-bottom: 2rem;
padding-bottom: 1rem;
border-bottom: 3px solid var(--racing-green);
display: flex;
align-items: center;
gap: 0.75rem;
}
.form-section h4 i {
font-size: 1.5rem;
}
.two-column-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 2.5rem;
align-items: start;
}
.form-field {
margin-bottom: 1.75rem;
}
.form-field .form-label {
font-weight: 600;
color: #2d3748;
margin-bottom: 0.75rem;
font-size: 0.95rem;
display: block;
}
.form-field .form-control,
.form-field .form-select,
.form-field textarea {
width: 100%;
padding: 0.875rem 1.125rem;
border: 2px solid #e2e8f0;
border-radius: 10px;
font-size: 1rem;
transition: all 0.2s ease;
background: white;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
}
.form-field .form-control:focus,
.form-field .form-select:focus,
.form-field textarea:focus {
border-color: var(--racing-green);
box-shadow: 0 0 0 3px rgba(0, 66, 37, 0.1);
outline: none;
transform: translateY(-1px);
}
.form-field .form-check {
padding: 1rem;
background: white;
border: 2px solid #e2e8f0;
border-radius: 10px;
margin-bottom: 0.5rem;
transition: all 0.2s ease;
}
.form-field .form-check:hover {
border-color: var(--racing-green-light);
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
.form-field .form-check-input {
margin-right: 0.75rem;
transform: scale(1.2);
}
.form-field .form-check-label {
font-weight: 500;
color: #2d3748;
}
.full-width-field {
grid-column: 1 / -1;
}
@media (max-width: 768px) {
.two-column-grid {
grid-template-columns: 1fr;
gap: 1.5rem;
}
.form-section {
padding: 1.5rem;
}
}
</style>
<form method="post" novalidate>
{% csrf_token %}
<!-- Persönliche Informationen -->
<div class="form-section">
<h4>
<i class="fas fa-user"></i>
Persönliche Informationen
</h4>
<div class="two-column-grid">
<div class="form-field">
<label for="{{ form.vorname.id_for_label }}" class="form-label">
{{ form.vorname.label }} *
</label>
{{ form.vorname }}
{% if form.vorname.errors %}
<div class="invalid-feedback d-block">
{% for error in form.vorname.errors %}
{{ error }}
{% endfor %}
</div>
{% endif %}
</div>
<div class="form-field">
<label for="{{ form.nachname.id_for_label }}" class="form-label">
{{ form.nachname.label }} *
</label>
{{ form.nachname }}
{% if form.nachname.errors %}
<div class="invalid-feedback d-block">
{% for error in form.nachname.errors %}
{{ error }}
{% endfor %}
</div>
{% endif %}
</div>
<div class="form-field">
<label for="{{ form.geburtsdatum.id_for_label }}" class="form-label">
{{ form.geburtsdatum.label }}
</label>
{{ form.geburtsdatum }}
{% if form.geburtsdatum.errors %}
<div class="invalid-feedback d-block">
{% for error in form.geburtsdatum.errors %}
{{ error }}
{% endfor %}
</div>
{% endif %}
</div>
<div class="form-field">
<label for="{{ form.familienzweig.id_for_label }}" class="form-label">
{{ form.familienzweig.label }}
</label>
{{ form.familienzweig }}
{% if form.familienzweig.errors %}
<div class="invalid-feedback d-block">
{% for error in form.familienzweig.errors %}
{{ error }}
{% endfor %}
</div>
{% endif %}
</div>
</div>
<!-- Kontaktinformationen -->
<div class="form-section">
<h4>
<i class="fas fa-address-book"></i>
Kontaktinformationen
</h4>
<div class="two-column-grid">
<div class="form-field">
<label for="{{ form.email.id_for_label }}" class="form-label">
{{ form.email.label }}
</label>
{{ form.email }}
{% if form.email.errors %}
<div class="invalid-feedback d-block">
{% for error in form.email.errors %}
{{ error }}
{% endfor %}
</div>
{% endif %}
</div>
<div class="form-field">
<label for="{{ form.telefon.id_for_label }}" class="form-label">
{{ form.telefon.label }}
</label>
{{ form.telefon }}
{% if form.telefon.errors %}
<div class="invalid-feedback d-block">
{% for error in form.telefon.errors %}
{{ error }}
{% endfor %}
</div>
{% endif %}
</div>
<div class="form-field">
<label for="{{ form.iban.id_for_label }}" class="form-label">
{{ form.iban.label }}
</label>
{{ form.iban }}
{% if form.iban.errors %}
<div class="invalid-feedback d-block">
{% for error in form.iban.errors %}
{{ error }}
{% endfor %}
</div>
{% endif %}
</div>
<div class="form-field" style="grid-column: 1 / -1;">
<label for="{{ form.adresse.id_for_label }}" class="form-label">
{{ form.adresse.label }}
</label>
{{ form.adresse }}
{% if form.adresse.errors %}
<div class="invalid-feedback d-block">
{% for error in form.adresse.errors %}
{{ error }}
{% endfor %}
</div>
{% endif %}
</div>
</div>
</div>
<!-- Professional Information Section -->
<div class="form-section">
<h4>
<i class="fas fa-briefcase"></i>
Berufliche Informationen
</h4>
<div class="two-column-grid">
<div class="form-field">
<label for="{{ form.berufsgruppe.id_for_label }}" class="form-label">
{{ form.berufsgruppe.label }}
</label>
{{ form.berufsgruppe }}
{% if form.berufsgruppe.errors %}
<div class="invalid-feedback d-block">
{% for error in form.berufsgruppe.errors %}
{{ error }}
{% endfor %}
</div>
{% endif %}
</div>
<div class="form-field">
<label for="{{ form.ausbildungsstand.id_for_label }}" class="form-label">
{{ form.ausbildungsstand.label }}
</label>
{{ form.ausbildungsstand }}
{% if form.ausbildungsstand.errors %}
<div class="invalid-feedback d-block">
{% for error in form.ausbildungsstand.errors %}
{{ error }}
{% endfor %}
</div>
{% endif %}
</div>
<div class="form-field" style="grid-column: 1 / -1;">
<label for="{{ form.institution.id_for_label }}" class="form-label">
{{ form.institution.label }}
</label>
{{ form.institution }}
{% if form.institution.errors %}
<div class="invalid-feedback d-block">
{% for error in form.institution.errors %}
{{ error }}
{% endfor %}
</div>
{% endif %}
</div>
<div class="form-field" style="grid-column: 1 / -1;">
<label for="{{ form.projekt_beschreibung.id_for_label }}" class="form-label">
{{ form.projekt_beschreibung.label }}
</label>
{{ form.projekt_beschreibung }}
{% if form.projekt_beschreibung.errors %}
<div class="invalid-feedback d-block">
{% for error in form.projekt_beschreibung.errors %}
{{ error }}
{% endfor %}
</div>
{% endif %}
</div>
</div>
</div>
<!-- Financial Information Section -->
<div class="form-section">
<h4>
<i class="fas fa-euro-sign"></i>
Finanzielle Informationen
</h4>
<div class="two-column-grid">
<div class="form-field">
<label for="{{ form.jaehrliches_einkommen.id_for_label }}" class="form-label">
{{ form.jaehrliches_einkommen.label }}
</label>
{{ form.jaehrliches_einkommen }}
{% if form.jaehrliches_einkommen.errors %}
<div class="invalid-feedback d-block">
{% for error in form.jaehrliches_einkommen.errors %}
{{ error }}
{% endfor %}
</div>
{% endif %}
</div>
<div class="form-field" style="grid-column: 1 / -1;">
<div class="form-check">
{{ form.finanzielle_notlage }}
<label class="form-check-label" for="{{ form.finanzielle_notlage.id_for_label }}">
{{ form.finanzielle_notlage.label }}
</label>
</div>
{% if form.finanzielle_notlage.errors %}
<div class="invalid-feedback d-block">
{% for error in form.finanzielle_notlage.errors %}
{{ error }}
{% endfor %}
</div>
{% endif %}
</div>
</div>
</div>
<!-- Support & Payout Section -->
<div class="form-section">
<h4>
<i class="fas fa-check-circle"></i>
Unterstützung & Auszahlung
</h4>
<div class="two-column-grid">
<div class="form-field">
<div class="form-check mb-2">
{{ form.ist_abkoemmling }}
<label class="form-check-label" for="{{ form.ist_abkoemmling.id_for_label }}">{{ form.ist_abkoemmling.label }}</label>
</div>
</div>
<div class="form-field">
<div class="form-check mb-3">
{{ form.unterstuetzung_bestaetigt }}
<label class="form-check-label" for="{{ form.unterstuetzung_bestaetigt.id_for_label }}">{{ form.unterstuetzung_bestaetigt.label }}</label>
</div>
</div>
<div class="form-field">
<label for="{{ form.haushaltsgroesse.id_for_label }}" class="form-label">{{ form.haushaltsgroesse.label }}</label>
{{ form.haushaltsgroesse }}
</div>
<div class="form-field">
<label for="{{ form.vierteljaehrlicher_betrag.id_for_label }}" class="form-label">Vierteljährliche Bezüge (€)</label>
{{ form.vierteljaehrlicher_betrag }}
</div>
<div class="form-field">
<label for="{{ form.vermoegen.id_for_label }}" class="form-label">{{ form.vermoegen.label }}</label>
{{ form.vermoegen }}
</div>
<div class="form-field" style="grid-column: 1 / -1;">
<label for="{{ form.standard_konto.id_for_label }}" class="form-label">{{ form.standard_konto.label }}</label>
{{ form.standard_konto }}
<div class="form-text">Standardkonto für vierteljährliche Vorauszahlungen</div>
</div>
</div>
</div>
<!-- Study Proof Section -->
<div class="form-section">
<h4>
<i class="fas fa-graduation-cap"></i>
Studiennachweis
</h4>
<div class="two-column-grid">
<div class="form-field">
<div class="form-check">
{{ form.studiennachweis_erforderlich }}
<label class="form-check-label" for="{{ form.studiennachweis_erforderlich.id_for_label }}">{{ form.studiennachweis_erforderlich.label }}</label>
</div>
</div>
<div class="form-field">
<label for="{{ form.letzter_studiennachweis.id_for_label }}" class="form-label">{{ form.letzter_studiennachweis.label }}</label>
{{ form.letzter_studiennachweis }}
<div class="form-text">Stichtage: 15.03 und 15.09</div>
</div>
</div>
</div>
<!-- Status & Notes Section -->
<div class="form-section">
<h4>
<i class="fas fa-cog"></i>
Status und Notizen
</h4>
<div class="two-column-grid">
<div class="form-field">
<div class="form-check">
{{ form.aktiv }}
<label class="form-check-label" for="{{ form.aktiv.id_for_label }}">
{{ form.aktiv.label }}
</label>
</div>
{% if form.aktiv.errors %}
<div class="invalid-feedback d-block">
{% for error in form.aktiv.errors %}
{{ error }}
{% endfor %}
</div>
{% endif %}
</div>
<div class="form-field" style="grid-column: 1 / -1;">
<label for="{{ form.notizen.id_for_label }}" class="form-label">
{{ form.notizen.label }}
</label>
{{ form.notizen }}
{% if form.notizen.errors %}
<div class="invalid-feedback d-block">
{% for error in form.notizen.errors %}
{{ error }}
{% endfor %}
</div>
{% endif %}
</div>
</div>
</div>
<!-- Form Actions -->
<div class="d-flex justify-content-between">
<a href="{% url 'stiftung:destinataer_list' %}" class="btn btn-outline-secondary">
<i class="fas fa-times me-2"></i>Abbrechen
</a>
<button type="submit" class="btn btn-primary">
<i class="fas fa-save me-2"></i>Speichern
</button>
</div>
</form>
</div>
{# ── Header (mirrors detail page style) ── #}
<div class="card shadow-sm mb-4 border-0" style="background: linear-gradient(135deg, #f8f9fa 0%, #e9ecef 100%);">
<div class="card-body py-3">
<div class="row align-items-center">
<div class="col-auto">
<div class="rounded-circle bg-primary text-white d-flex align-items-center justify-content-center" style="width:56px;height:56px;font-size:1.4rem;">
<i class="fas fa-user-plus"></i>
</div>
</div>
<!-- Sidebar mit Hilfe -->
<div class="col-lg-4">
{% help_box 'destinataer_new' user %}
<div class="col">
<h4 class="mb-1">{{ title }}</h4>
<div class="text-muted small">Alle Felder ausfuellen und speichern</div>
</div>
<div class="col-auto">
<a href="{% url 'stiftung:destinataer_list' %}" class="btn btn-outline-secondary btn-sm">
<i class="fas fa-arrow-left me-1"></i>Zurueck
</a>
</div>
</div>
</div>
</div>
{% endblock %}
<form method="post" novalidate>
{% csrf_token %}
{% if form.errors %}
<div class="alert alert-danger alert-dismissible fade show mb-3" role="alert">
<i class="fas fa-exclamation-triangle me-2"></i>Bitte korrigieren Sie die markierten Felder.
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
</div>
{% endif %}
<div class="row g-3">
{# ── Left Column ── #}
<div class="col-lg-6">
{# Personal Data #}
<div class="card shadow-sm mb-3">
<div class="card-header py-2"><i class="fas fa-user me-2 text-primary"></i><strong>Persoenliche Daten</strong></div>
<div class="card-body py-2">
<table class="table table-sm table-borderless mb-0">
<tbody>
<tr>
<td class="text-muted" style="width:140px;">Vorname *</td>
<td>
{{ form.vorname }}
{% if form.vorname.errors %}<div class="invalid-feedback d-block">{{ form.vorname.errors.0 }}</div>{% endif %}
</td>
</tr>
<tr>
<td class="text-muted">Nachname *</td>
<td>
{{ form.nachname }}
{% if form.nachname.errors %}<div class="invalid-feedback d-block">{{ form.nachname.errors.0 }}</div>{% endif %}
</td>
</tr>
<tr>
<td class="text-muted">Geburtsdatum</td>
<td>
{{ form.geburtsdatum }}
{% if form.geburtsdatum.errors %}<div class="invalid-feedback d-block">{{ form.geburtsdatum.errors.0 }}</div>{% endif %}
</td>
</tr>
<tr>
<td class="text-muted">Familienzweig</td>
<td>
{{ form.familienzweig }}
{% if form.familienzweig.errors %}<div class="invalid-feedback d-block">{{ form.familienzweig.errors.0 }}</div>{% endif %}
</td>
</tr>
<tr>
<td class="text-muted">Berufsgruppe</td>
<td>
{{ form.berufsgruppe }}
{% if form.berufsgruppe.errors %}<div class="invalid-feedback d-block">{{ form.berufsgruppe.errors.0 }}</div>{% endif %}
</td>
</tr>
</tbody>
</table>
</div>
</div>
{# Contact & Address #}
<div class="card shadow-sm mb-3">
<div class="card-header py-2"><i class="fas fa-address-book me-2 text-info"></i><strong>Kontakt & Adresse</strong></div>
<div class="card-body py-2">
<table class="table table-sm table-borderless mb-0">
<tbody>
<tr>
<td class="text-muted" style="width:140px;">E-Mail</td>
<td>
{{ form.email }}
{% if form.email.errors %}<div class="invalid-feedback d-block">{{ form.email.errors.0 }}</div>{% endif %}
</td>
</tr>
<tr>
<td class="text-muted">Telefon</td>
<td>
{{ form.telefon }}
{% if form.telefon.errors %}<div class="invalid-feedback d-block">{{ form.telefon.errors.0 }}</div>{% endif %}
</td>
</tr>
<tr>
<td class="text-muted">IBAN</td>
<td>
{{ form.iban }}
{% if form.iban.errors %}<div class="invalid-feedback d-block">{{ form.iban.errors.0 }}</div>{% endif %}
</td>
</tr>
<tr>
<td class="text-muted">Strasse</td>
<td>
{{ form.strasse }}
{% if form.strasse.errors %}<div class="invalid-feedback d-block">{{ form.strasse.errors.0 }}</div>{% endif %}
</td>
</tr>
<tr>
<td class="text-muted">PLZ / Ort</td>
<td>
<div class="row g-2">
<div class="col-4">{{ form.plz }}</div>
<div class="col-8">{{ form.ort }}</div>
</div>
</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
{# ── Right Column ── #}
<div class="col-lg-6">
{# Financial #}
<div class="card shadow-sm mb-3">
<div class="card-header py-2"><i class="fas fa-euro-sign me-2 text-warning"></i><strong>Finanzen & Foerderung</strong></div>
<div class="card-body py-2">
<table class="table table-sm table-borderless mb-0">
<tbody>
<tr>
<td class="text-muted" style="width:180px;">Quartalsbetrag</td>
<td>{{ form.vierteljaehrlicher_betrag }}</td>
</tr>
<tr>
<td class="text-muted">Monatl. Bezuege</td>
<td>{{ form.monatliche_bezuege }}</td>
</tr>
<tr>
<td class="text-muted">Jaehrl. Einkommen</td>
<td>{{ form.jaehrliches_einkommen }}</td>
</tr>
<tr>
<td class="text-muted">Vermoegen</td>
<td>{{ form.vermoegen }}</td>
</tr>
<tr>
<td class="text-muted">Haushaltsgroesse</td>
<td>{{ form.haushaltsgroesse }}</td>
</tr>
<tr>
<td class="text-muted">Standardkonto</td>
<td>{{ form.standard_konto }}</td>
</tr>
<tr>
<td class="text-muted">Finanz. Notlage</td>
<td>
<div class="form-check">{{ form.finanzielle_notlage }}<label class="form-check-label" for="{{ form.finanzielle_notlage.id_for_label }}">Ja</label></div>
</td>
</tr>
<tr>
<td class="text-muted">Unterstuetzung best.</td>
<td>
<div class="form-check">{{ form.unterstuetzung_bestaetigt }}<label class="form-check-label" for="{{ form.unterstuetzung_bestaetigt.id_for_label }}">Ja</label></div>
</td>
</tr>
</tbody>
</table>
</div>
</div>
{# Study & Prerequisites #}
<div class="card shadow-sm mb-3">
<div class="card-header py-2"><i class="fas fa-graduation-cap me-2 text-secondary"></i><strong>Studium & Voraussetzungen</strong></div>
<div class="card-body py-2">
<table class="table table-sm table-borderless mb-0">
<tbody>
<tr>
<td class="text-muted" style="width:180px;">Abkoemmling gem. Satzung</td>
<td>
<div class="form-check">{{ form.ist_abkoemmling }}<label class="form-check-label" for="{{ form.ist_abkoemmling.id_for_label }}">Ja</label></div>
</td>
</tr>
<tr>
<td class="text-muted">Ausbildungsstand</td>
<td>{{ form.ausbildungsstand }}</td>
</tr>
<tr>
<td class="text-muted">Institution</td>
<td>{{ form.institution }}</td>
</tr>
<tr>
<td class="text-muted">Studiennachweis erf.</td>
<td>
<div class="form-check">{{ form.studiennachweis_erforderlich }}<label class="form-check-label" for="{{ form.studiennachweis_erforderlich.id_for_label }}">Ja</label></div>
</td>
</tr>
<tr>
<td class="text-muted">Letzter Nachweis</td>
<td>{{ form.letzter_studiennachweis }}</td>
</tr>
</tbody>
</table>
</div>
</div>
{# Description & Notes #}
<div class="card shadow-sm mb-3">
<div class="card-header py-2"><i class="fas fa-sticky-note me-2 text-secondary"></i><strong>Beschreibung & Notizen</strong></div>
<div class="card-body py-2">
<div class="mb-2">
<small class="text-muted">Projektbeschreibung</small>
{{ form.projekt_beschreibung }}
</div>
<div class="mb-2">
<small class="text-muted">Notizen</small>
{{ form.notizen }}
</div>
<div class="form-check">
{{ form.aktiv }}
<label class="form-check-label" for="{{ form.aktiv.id_for_label }}">{{ form.aktiv.label }}</label>
</div>
</div>
</div>
</div>
</div>
{# ── Save bar ── #}
<div class="card shadow-sm mt-2 mb-4 border-0" style="background: linear-gradient(135deg, #f8f9fa 0%, #e9ecef 100%);">
<div class="card-body py-3">
<div class="d-flex justify-content-between align-items-center">
<a href="{% url 'stiftung:destinataer_list' %}" class="btn btn-outline-secondary btn-sm">
<i class="fas fa-times me-1"></i>Abbrechen
</a>
<button type="submit" class="btn btn-success">
<i class="fas fa-save me-1"></i>Speichern
</button>
</div>
</div>
</div>
</form>
</div>
{% endblock %}

View File

@@ -130,10 +130,10 @@
{% for destinataer in page_obj %}
<tr>
<td>
<strong>{{ destinataer.vorname|default:"-" }}</strong>
<a href="{% url 'stiftung:destinataer_detail' pk=destinataer.pk %}" class="text-decoration-none fw-bold">{{ destinataer.vorname|default:"-" }}</a>
</td>
<td>
<strong>{{ destinataer.nachname }}</strong>
<a href="{% url 'stiftung:destinataer_detail' pk=destinataer.pk %}" class="text-decoration-none fw-bold">{{ destinataer.nachname }}</a>
{% if destinataer.geburtsdatum %}
<br><small class="text-muted">{{ destinataer.geburtsdatum|date:"d.m.Y" }}</small>
{% endif %}

View File

@@ -0,0 +1,151 @@
{% extends 'base.html' %}
{% load static %}
{% block title %}Timeline {{ destinataer.get_full_name }} Stiftungsverwaltung{% endblock %}
{% block content %}
<div class="row">
<div class="col-12">
<!-- Header -->
<div class="d-flex justify-content-between align-items-center mb-4">
<h1 class="h3">
<i class="fas fa-stream text-primary me-2"></i>
Timeline: {{ destinataer.get_full_name }}
</h1>
<div>
<a href="{% url 'stiftung:destinataer_detail' pk=destinataer.pk %}" class="btn btn-outline-secondary">
<i class="fas fa-arrow-left me-2"></i>Zum Destinatär
</a>
</div>
</div>
<!-- Filter Bar -->
<div class="card shadow mb-4">
<div class="card-body py-2">
<div class="d-flex gap-2 flex-wrap align-items-center">
<span class="text-muted small fw-bold me-2">Filter:</span>
<a href="?typ=" class="btn btn-sm {% if not typ_filter %}btn-primary{% else %}btn-outline-primary{% endif %}">
<i class="fas fa-list me-1"></i>Alle
</a>
<a href="?typ=zahlung" class="btn btn-sm {% if typ_filter == 'zahlung' %}btn-success{% else %}btn-outline-success{% endif %}">
<i class="fas fa-money-bill-wave me-1"></i>Zahlungen
</a>
<a href="?typ=nachweis" class="btn btn-sm {% if typ_filter == 'nachweis' %}btn-warning{% else %}btn-outline-warning{% endif %}">
<i class="fas fa-file-alt me-1"></i>Nachweise
</a>
<a href="?typ=email" class="btn btn-sm {% if typ_filter == 'email' %}btn-info{% else %}btn-outline-info{% endif %}">
<i class="fas fa-envelope me-1"></i>E-Mails
</a>
<a href="?typ=notiz" class="btn btn-sm {% if typ_filter == 'notiz' %}btn-secondary{% else %}btn-outline-secondary{% endif %}">
<i class="fas fa-sticky-note me-1"></i>Notizen
</a>
<span class="ms-auto text-muted small">{{ events|length }} Einträge</span>
</div>
</div>
</div>
<!-- Timeline -->
{% if events %}
<div class="timeline-wrapper">
{% for event in events %}
<div class="timeline-item mb-3">
<div class="card shadow-sm border-start border-4 border-{{ event.farbe }}">
<div class="card-body py-2 px-3">
<div class="d-flex align-items-start gap-3">
<!-- Icon -->
<div class="timeline-icon text-{{ event.farbe }} pt-1">
<i class="fas {{ event.icon }} fa-lg"></i>
</div>
<!-- Content -->
<div class="flex-grow-1">
<div class="d-flex justify-content-between align-items-start">
<div>
<span class="fw-semibold">{{ event.titel }}</span>
{% if event.beschreibung %}
<span class="text-muted ms-2 small">{{ event.beschreibung }}</span>
{% endif %}
</div>
<div class="text-end ms-3 text-nowrap">
<div class="small text-muted">{{ event.datum|date:"d.m.Y" }}</div>
{% if event.status %}
<span class="badge bg-{{ event.farbe }} text-white small">{{ event.status }}</span>
{% endif %}
</div>
</div>
<!-- Extra info per type -->
{% if event.typ == 'zahlung' %}
<div class="small mt-1">
{% with u=event.objekt %}
Konto: {{ u.konto.kontoname }}
{% if u.empfaenger_iban %} · IBAN: {{ u.empfaenger_iban|slice:":4" }}…{% endif %}
{% if u.ausgezahlt_von %} · Ausgezahlt von: {{ u.ausgezahlt_von.get_full_name|default:u.ausgezahlt_von.username }}{% endif %}
{% if u.freigegeben_von %} · Freigegeben: {{ u.freigegeben_von.get_full_name|default:u.freigegeben_von.username }} ({{ u.freigegeben_am|date:"d.m.Y" }}){% endif %}
{% endwith %}
</div>
{% elif event.typ == 'nachweis' %}
<div class="small mt-1">
{% with n=event.objekt %}
Fortschritt: {{ n.get_completion_percentage }}%
{% if n.geprueft_von %} · Geprüft von: {{ n.geprueft_von.get_full_name|default:n.geprueft_von.username }}{% endif %}
{% endwith %}
</div>
{% elif event.typ == 'email' %}
<div class="small mt-1">
{% with e=event.objekt %}
Von: {{ e.absender_name|default:e.absender_email }}
{% if e.quartalsnachweis %} · Zugeordnet: Q{{ e.quartalsnachweis.quartal }}/{{ e.quartalsnachweis.jahr }}{% endif %}
{% endwith %}
</div>
{% endif %}
</div>
</div>
</div>
</div>
</div>
{% endfor %}
</div>
{% else %}
<div class="alert alert-info">
<i class="fas fa-info-circle me-2"></i>
Keine Timeline-Einträge vorhanden{% if typ_filter %} für den gewählten Filter{% endif %}.
</div>
{% endif %}
</div>
</div>
<style>
.timeline-wrapper {
position: relative;
}
.timeline-wrapper::before {
content: '';
position: absolute;
left: 20px;
top: 0;
bottom: 0;
width: 2px;
background: #dee2e6;
z-index: 0;
}
.timeline-item {
position: relative;
padding-left: 48px;
}
.timeline-icon {
position: absolute;
left: -28px;
top: 50%;
transform: translateY(-50%);
width: 32px;
height: 32px;
display: flex;
align-items: center;
justify-content: center;
background: white;
border-radius: 50%;
border: 2px solid #dee2e6;
z-index: 1;
}
</style>
{% endblock %}

View File

@@ -0,0 +1,119 @@
{% extends 'base.html' %}
{% load static %}
{% block title %}{{ dok.titel }} DMS Stiftungsverwaltung{% endblock %}
{% block content %}
<div class="row">
<div class="col-lg-8">
<div class="d-flex justify-content-between align-items-center mb-4">
<h1 class="h3">
{% if dok.is_pdf %}
<i class="fas fa-file-pdf text-danger me-2"></i>
{% else %}
<i class="fas fa-file text-primary me-2"></i>
{% endif %}
{{ dok.titel }}
</h1>
<div class="d-flex gap-2">
<a href="{% url 'stiftung:dms_download' pk=dok.pk %}" class="btn btn-outline-success">
<i class="fas fa-download me-2"></i>Herunterladen
</a>
<a href="{% url 'stiftung:dms_edit' pk=dok.pk %}" class="btn btn-outline-secondary">
<i class="fas fa-edit me-2"></i>Bearbeiten
</a>
<a href="{% url 'stiftung:dms_list' %}" class="btn btn-outline-secondary">
<i class="fas fa-arrow-left me-2"></i>Zurück
</a>
</div>
</div>
<!-- Metadaten -->
<div class="card shadow mb-4">
<div class="card-header bg-dark text-white py-2">
<span class="small fw-bold"><i class="fas fa-info-circle me-2"></i>Dokument-Informationen</span>
</div>
<div class="card-body">
<dl class="row mb-0">
<dt class="col-sm-4 text-muted small">Typ</dt>
<dd class="col-sm-8"><span class="badge bg-secondary">{{ dok.get_kontext_display }}</span></dd>
{% if dok.beschreibung %}
<dt class="col-sm-4 text-muted small">Beschreibung</dt>
<dd class="col-sm-8">{{ dok.beschreibung }}</dd>
{% endif %}
<dt class="col-sm-4 text-muted small">Dateiname</dt>
<dd class="col-sm-8 font-monospace small">{{ dok.dateiname_original|default:dok.datei.name }}</dd>
<dt class="col-sm-4 text-muted small">Dateigröße</dt>
<dd class="col-sm-8">{{ dok.get_human_size }}</dd>
<dt class="col-sm-4 text-muted small">Hochgeladen am</dt>
<dd class="col-sm-8">{{ dok.erstellt_am|date:"d.m.Y H:i" }} Uhr</dd>
{% if dok.erstellt_von %}
<dt class="col-sm-4 text-muted small">Hochgeladen von</dt>
<dd class="col-sm-8">{{ dok.erstellt_von.get_full_name|default:dok.erstellt_von.username }}</dd>
{% endif %}
</dl>
</div>
</div>
<!-- Zuordnungen -->
{% if dok.destinataer or dok.land or dok.paechter or dok.verpachtung %}
<div class="card shadow mb-4">
<div class="card-header bg-dark text-white py-2">
<span class="small fw-bold"><i class="fas fa-link me-2"></i>Zuordnungen</span>
</div>
<div class="card-body">
{% if dok.destinataer %}
<div class="mb-2">
<span class="text-muted small"><i class="fas fa-user me-1"></i>Destinatär:</span>
<a href="{% url 'stiftung:destinataer_detail' pk=dok.destinataer.pk %}" class="ms-2">
{{ dok.destinataer.get_full_name }}
</a>
</div>
{% endif %}
{% if dok.land %}
<div class="mb-2">
<span class="text-muted small"><i class="fas fa-map me-1"></i>Länderei:</span>
<a href="{% url 'stiftung:land_detail' pk=dok.land.pk %}" class="ms-2">
{{ dok.land.lfd_nr }}{% if dok.land.gemeinde %} {{ dok.land.gemeinde }}{% endif %}
</a>
</div>
{% endif %}
{% if dok.paechter %}
<div class="mb-2">
<span class="text-muted small"><i class="fas fa-user-tie me-1"></i>Pächter:</span>
<a href="{% url 'stiftung:paechter_detail' pk=dok.paechter.pk %}" class="ms-2">
{{ dok.paechter.get_full_name }}
</a>
</div>
{% endif %}
{% if dok.verpachtung %}
<div class="mb-2">
<span class="text-muted small"><i class="fas fa-handshake me-1"></i>Verpachtung:</span>
<a href="{% url 'stiftung:land_verpachtung_detail' pk=dok.verpachtung.pk %}" class="ms-2">
Vertrag #{{ dok.verpachtung.pk|stringformat:'s'|slice:':8' }}
</a>
</div>
{% endif %}
</div>
</div>
{% endif %}
<!-- Aktionen -->
<div class="d-flex gap-2 mt-2">
<form method="post" action="{% url 'stiftung:dms_delete' pk=dok.pk %}"
class="d-inline" onsubmit="return confirm('Dokument „{{ dok.titel }}" unwiderruflich löschen?')">
{% csrf_token %}
<input type="hidden" name="next" value="{% url 'stiftung:dms_list' %}">
<button type="submit" class="btn btn-outline-danger btn-sm">
<i class="fas fa-trash me-2"></i>Dokument löschen
</button>
</form>
</div>
</div>
</div>
{% endblock %}

View File

@@ -0,0 +1,116 @@
{% extends 'base.html' %}
{% load static %}
{% block title %}{{ dok.titel }} bearbeiten DMS Stiftungsverwaltung{% endblock %}
{% block content %}
<div class="row justify-content-center">
<div class="col-lg-7">
<div class="d-flex justify-content-between align-items-center mb-4">
<h1 class="h3">
<i class="fas fa-edit text-primary me-2"></i>
Metadaten bearbeiten
</h1>
<a href="{% url 'stiftung:dms_detail' pk=dok.pk %}" class="btn btn-outline-secondary">
<i class="fas fa-arrow-left me-2"></i>Zurück
</a>
</div>
<div class="card shadow">
<div class="card-body">
<form method="post">
{% csrf_token %}
<div class="mb-3">
<label class="form-label fw-semibold">Titel</label>
<input type="text" name="titel" class="form-control"
value="{{ dok.titel }}" required maxlength="255">
</div>
<div class="mb-3">
<label class="form-label fw-semibold">Typ / Kontext</label>
<select name="kontext" class="form-select">
{% for code, label in kontext_choices %}
<option value="{{ code }}" {% if code == dok.kontext %}selected{% endif %}>{{ label }}</option>
{% endfor %}
</select>
</div>
<div class="mb-4">
<label class="form-label fw-semibold">Beschreibung</label>
<textarea name="beschreibung" class="form-control" rows="3">{{ dok.beschreibung }}</textarea>
</div>
<!-- Zuordnung -->
<hr>
<h6 class="fw-semibold mb-3"><i class="fas fa-link me-2"></i>Zuordnung</h6>
<div class="row g-3 mb-4">
<div class="col-md-6">
<label class="form-label small fw-semibold">Destinatär</label>
<select name="destinataer_id" class="form-select form-select-sm">
<option value=""> keine </option>
{% for d in destinataere %}
<option value="{{ d.pk }}" {% if dok.destinataer_id == d.pk %}selected{% endif %}>
{{ d.get_full_name }}
</option>
{% endfor %}
</select>
</div>
<div class="col-md-6">
<label class="form-label small fw-semibold">Länderei</label>
<select name="land_id" class="form-select form-select-sm">
<option value=""> keine </option>
{% for l in laendereien %}
<option value="{{ l.pk }}" {% if dok.land_id == l.pk %}selected{% endif %}>
{{ l.lfd_nr }}{% if l.gemeinde %} {{ l.gemeinde }}{% endif %}
</option>
{% endfor %}
</select>
</div>
<div class="col-md-6">
<label class="form-label small fw-semibold">Pächter</label>
<select name="paechter_id" class="form-select form-select-sm">
<option value=""> keine </option>
{% for p in paechter_qs %}
<option value="{{ p.pk }}" {% if dok.paechter_id == p.pk %}selected{% endif %}>
{{ p.get_full_name }}
</option>
{% endfor %}
</select>
</div>
<div class="col-md-6">
<label class="form-label small fw-semibold">Verpachtung</label>
<select name="verpachtung_id" class="form-select form-select-sm">
<option value=""> keine </option>
{% for v in verpachtungen %}
<option value="{{ v.pk }}" {% if dok.verpachtung_id == v.pk %}selected{% endif %}>
{{ v.land.lfd_nr }} {{ v.paechter.get_full_name }} ({{ v.vertragsbeginn|date:"Y" }})
</option>
{% endfor %}
</select>
</div>
</div>
<div class="d-flex gap-2 justify-content-end">
<a href="{% url 'stiftung:dms_detail' pk=dok.pk %}" class="btn btn-outline-secondary">Abbrechen</a>
<button type="submit" class="btn btn-primary">
<i class="fas fa-save me-2"></i>Speichern
</button>
</div>
</form>
</div>
</div>
<!-- Datei-Info (read-only) -->
<div class="card shadow mt-4">
<div class="card-header bg-light py-2">
<span class="small text-muted"><i class="fas fa-file me-2"></i>Datei (nicht änderbar)</span>
</div>
<div class="card-body py-2">
<span class="font-monospace small text-muted">{{ dok.dateiname_original|default:dok.datei.name }}</span>
<span class="text-muted small ms-3">{{ dok.get_human_size }}</span>
</div>
</div>
</div>
</div>
{% endblock %}

View File

@@ -0,0 +1,153 @@
{% extends 'base.html' %}
{% load static %}
{% block title %}DMS Dokumente Stiftungsverwaltung{% endblock %}
{% block content %}
<div class="row">
<div class="col-12">
<div class="d-flex justify-content-between align-items-center mb-4">
<h1 class="h3">
<i class="fas fa-folder-open text-primary me-2"></i>
Dokumentenverwaltung (DMS)
</h1>
<a href="{% url 'stiftung:dms_upload' %}" class="btn btn-primary">
<i class="fas fa-upload me-2"></i>Dokument hochladen
</a>
</div>
<!-- Suche & Filter -->
<div class="card shadow mb-4">
<div class="card-body py-2">
<form method="get" class="row g-2 align-items-center">
<div class="col-md-5">
<div class="input-group input-group-sm">
<span class="input-group-text"><i class="fas fa-search"></i></span>
<input type="text" name="q" value="{{ q }}" class="form-control"
placeholder="Volltextsuche (Titel, Beschreibung, Inhalt)"
hx-get="{% url 'stiftung:dms_search_api' %}"
hx-target="#search-results"
hx-trigger="keyup changed delay:400ms"
hx-include="[name='q']">
</div>
</div>
<div class="col-md-3">
<select name="kontext" class="form-select form-select-sm" onchange="this.form.submit()">
<option value="">Alle Typen</option>
{% for code, label in kontext_choices %}
<option value="{{ code }}" {% if code == kontext_filter %}selected{% endif %}>{{ label }}</option>
{% endfor %}
</select>
</div>
<div class="col-md-2">
<button type="submit" class="btn btn-sm btn-outline-primary w-100">Suchen</button>
</div>
{% if q or kontext_filter %}
<div class="col-md-2">
<a href="{% url 'stiftung:dms_list' %}" class="btn btn-sm btn-outline-secondary w-100">Reset</a>
</div>
{% endif %}
</form>
</div>
</div>
<!-- HTMX Live-Suchergebnisse -->
<div id="search-results"></div>
<!-- Dokument-Liste -->
<div class="card shadow">
<div class="card-header bg-dark text-white py-2 d-flex justify-content-between">
<span class="small fw-bold"><i class="fas fa-file me-2"></i>{{ gesamt }} Dokument(e)</span>
{% if q %}<span class="small text-warning">Suche: „{{ q }}"</span>{% endif %}
</div>
<div class="card-body p-0">
{% if page_obj.object_list %}
<div class="table-responsive">
<table class="table table-sm table-hover mb-0">
<thead class="table-light">
<tr>
<th>Titel</th>
<th>Typ</th>
<th>Zuordnung</th>
<th>Größe</th>
<th>Hochgeladen</th>
<th></th>
</tr>
</thead>
<tbody>
{% for dok in page_obj %}
<tr>
<td class="align-middle">
<a href="{% url 'stiftung:dms_detail' pk=dok.pk %}" class="text-decoration-none fw-semibold">
{% if dok.is_pdf %}<i class="fas fa-file-pdf text-danger me-1"></i>{% else %}<i class="fas fa-file text-muted me-1"></i>{% endif %}
{{ dok.titel|truncatechars:60 }}
</a>
{% if dok.beschreibung %}
<div class="small text-muted">{{ dok.beschreibung|truncatechars:80 }}</div>
{% endif %}
</td>
<td class="align-middle">
<span class="badge bg-secondary small">{{ dok.get_kontext_display }}</span>
</td>
<td class="align-middle small text-muted">
{% if dok.destinataer %}<div><i class="fas fa-user me-1"></i>{{ dok.destinataer.get_full_name }}</div>{% endif %}
{% if dok.land %}<div><i class="fas fa-map me-1"></i>{{ dok.land.lfd_nr }}</div>{% endif %}
{% if dok.paechter %}<div><i class="fas fa-user-tie me-1"></i>{{ dok.paechter.get_full_name }}</div>{% endif %}
</td>
<td class="align-middle small text-muted text-nowrap">{{ dok.get_human_size }}</td>
<td class="align-middle small text-muted text-nowrap">
{{ dok.erstellt_am|date:"d.m.Y" }}
{% if dok.erstellt_von %}<br>{{ dok.erstellt_von.get_full_name|default:dok.erstellt_von.username }}{% endif %}
</td>
<td class="align-middle text-end">
<a href="{% url 'stiftung:dms_download' pk=dok.pk %}" class="btn btn-xs btn-outline-success me-1" style="font-size:0.7rem;padding:2px 6px;" title="Herunterladen">
<i class="fas fa-download"></i>
</a>
<a href="{% url 'stiftung:dms_edit' pk=dok.pk %}" class="btn btn-xs btn-outline-secondary me-1" style="font-size:0.7rem;padding:2px 6px;" title="Bearbeiten">
<i class="fas fa-edit"></i>
</a>
<form method="post" action="{% url 'stiftung:dms_delete' pk=dok.pk %}" class="d-inline" onsubmit="return confirm('Dokument löschen?')">
{% csrf_token %}
<input type="hidden" name="next" value="{% url 'stiftung:dms_list' %}">
<button type="submit" class="btn btn-xs btn-outline-danger" style="font-size:0.7rem;padding:2px 6px;" title="Löschen">
<i class="fas fa-trash"></i>
</button>
</form>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
<!-- Pagination -->
{% if page_obj.has_other_pages %}
<div class="d-flex justify-content-center py-3">
<nav>
<ul class="pagination pagination-sm mb-0">
{% if page_obj.has_previous %}
<li class="page-item"><a class="page-link" href="?page={{ page_obj.previous_page_number }}&q={{ q }}&kontext={{ kontext_filter }}"></a></li>
{% endif %}
<li class="page-item active"><a class="page-link">{{ page_obj.number }}/{{ page_obj.paginator.num_pages }}</a></li>
{% if page_obj.has_next %}
<li class="page-item"><a class="page-link" href="?page={{ page_obj.next_page_number }}&q={{ q }}&kontext={{ kontext_filter }}"></a></li>
{% endif %}
</ul>
</nav>
</div>
{% endif %}
{% else %}
<div class="text-muted text-center py-5">
<i class="fas fa-folder-open fa-3x mb-3 d-block opacity-25"></i>
{% if q %}Keine Dokumente für „{{ q }}" gefunden.{% else %}Noch keine Dokumente vorhanden.{% endif %}
<div class="mt-3">
<a href="{% url 'stiftung:dms_upload' %}" class="btn btn-primary btn-sm">
<i class="fas fa-upload me-1"></i>Erstes Dokument hochladen
</a>
</div>
</div>
{% endif %}
</div>
</div>
</div>
</div>
{% endblock %}

View File

@@ -0,0 +1,39 @@
{% if results %}
<div class="card shadow mb-4 border-primary">
<div class="card-header bg-primary text-white py-2">
<span class="small fw-bold">
<i class="fas fa-search me-2"></i>Live-Suche: „{{ q }}" {{ results|length }} Treffer
</span>
</div>
<div class="card-body p-0">
<div class="table-responsive">
<table class="table table-sm table-hover mb-0">
<tbody>
{% for dok in results %}
<tr>
<td class="align-middle">
<a href="{% url 'stiftung:dms_detail' pk=dok.pk %}" class="text-decoration-none fw-semibold">
{% if dok.is_pdf %}<i class="fas fa-file-pdf text-danger me-1"></i>{% else %}<i class="fas fa-file text-muted me-1"></i>{% endif %}
{{ dok.titel|truncatechars:60 }}
</a>
{% if dok.beschreibung %}
<div class="small text-muted">{{ dok.beschreibung|truncatechars:80 }}</div>
{% endif %}
</td>
<td class="align-middle">
<span class="badge bg-secondary small">{{ dok.get_kontext_display }}</span>
</td>
<td class="align-middle small text-muted text-nowrap">{{ dok.erstellt_am|date:"d.m.Y" }}</td>
<td class="align-middle text-end">
<a href="{% url 'stiftung:dms_download' pk=dok.pk %}" class="btn btn-xs btn-outline-success" style="font-size:0.7rem;padding:2px 6px;">
<i class="fas fa-download"></i>
</a>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
{% endif %}

View File

@@ -0,0 +1,10 @@
<div class="alert alert-success d-flex align-items-center gap-3 mt-3">
<i class="fas fa-check-circle fa-2x"></i>
<div>
<strong>Erfolgreich hochgeladen!</strong>
<div class="small">
„{{ dok.titel }}" — {{ dok.get_human_size }}
<a href="{% url 'stiftung:dms_detail' pk=dok.pk %}" class="ms-2">Details ansehen</a>
</div>
</div>
</div>

View File

@@ -0,0 +1,166 @@
{% extends 'base.html' %}
{% load static %}
{% block title %}Dokument hochladen Stiftungsverwaltung{% endblock %}
{% block content %}
<div class="row justify-content-center">
<div class="col-lg-8">
<div class="d-flex justify-content-between align-items-center mb-4">
<h1 class="h3">
<i class="fas fa-upload text-primary me-2"></i>
Dokument hochladen
</h1>
<a href="{% url 'stiftung:dms_list' %}" class="btn btn-outline-secondary">
<i class="fas fa-arrow-left me-2"></i>Zurück
</a>
</div>
<form method="post" enctype="multipart/form-data" id="upload-form">
{% csrf_token %}
{% if initial.foerderung_id %}<input type="hidden" name="foerderung_id" value="{{ initial.foerderung_id }}">{% endif %}
{% if initial.verpachtung_id %}<input type="hidden" name="verpachtung_id" value="{{ initial.verpachtung_id }}">{% endif %}
<!-- Drag & Drop Zone -->
<div class="card shadow mb-4">
<div class="card-body">
<div id="drop-zone"
class="border border-2 border-dashed rounded p-5 text-center"
style="border-color: #ccc !important; cursor: pointer; transition: all 0.2s;"
onclick="document.getElementById('datei-input').click()">
<i class="fas fa-cloud-upload-alt fa-3x text-muted mb-3 d-block"></i>
<p class="mb-1 fw-semibold">Datei hierher ziehen oder klicken zum Auswählen</p>
<p class="small text-muted mb-0">PDF, Word, Excel, Bilder — max. 50 MB</p>
<div id="file-preview" class="mt-3 d-none">
<span class="badge bg-success fs-6 px-3 py-2">
<i class="fas fa-file me-2"></i><span id="file-name"></span>
</span>
</div>
</div>
<input type="file" name="datei" id="datei-input" class="d-none" required>
</div>
</div>
<!-- Metadaten -->
<div class="card shadow mb-4">
<div class="card-header bg-dark text-white py-2">
<span class="small fw-bold"><i class="fas fa-tag me-2"></i>Metadaten</span>
</div>
<div class="card-body">
<div class="mb-3">
<label class="form-label fw-semibold">Titel</label>
<input type="text" name="titel" class="form-control"
placeholder="Wird automatisch aus Dateiname abgeleitet wenn leer">
</div>
<div class="mb-3">
<label class="form-label fw-semibold">Typ / Kontext</label>
<select name="kontext" class="form-select">
{% for code, label in kontext_choices %}
<option value="{{ code }}" {% if code == initial.kontext %}selected{% endif %}>{{ label }}</option>
{% endfor %}
</select>
</div>
<div class="mb-3">
<label class="form-label fw-semibold">Beschreibung <span class="text-muted fw-normal">(optional)</span></label>
<textarea name="beschreibung" class="form-control" rows="2"
placeholder="Kurze Beschreibung des Dokuments"></textarea>
</div>
</div>
</div>
<!-- Zuordnung -->
<div class="card shadow mb-4">
<div class="card-header bg-dark text-white py-2">
<span class="small fw-bold"><i class="fas fa-link me-2"></i>Zuordnung <span class="fw-normal opacity-75">(optional)</span></span>
</div>
<div class="card-body">
<div class="row g-3">
<div class="col-md-6">
<label class="form-label small fw-semibold">Destinatär</label>
<select name="destinataer_id" class="form-select form-select-sm">
<option value=""> keine </option>
{% for d in destinataere %}
<option value="{{ d.pk }}" {% if d.pk|stringformat:'s' == initial.destinataer_id %}selected{% endif %}>
{{ d.get_full_name }}
</option>
{% endfor %}
</select>
</div>
<div class="col-md-6">
<label class="form-label small fw-semibold">Länderei</label>
<select name="land_id" class="form-select form-select-sm">
<option value=""> keine </option>
{% for l in laendereien %}
<option value="{{ l.pk }}" {% if l.pk|stringformat:'s' == initial.land_id %}selected{% endif %}>
{{ l.lfd_nr }}{% if l.gemeinde %} {{ l.gemeinde }}{% endif %}
</option>
{% endfor %}
</select>
</div>
<div class="col-md-6">
<label class="form-label small fw-semibold">Pächter</label>
<select name="paechter_id" class="form-select form-select-sm">
<option value=""> keine </option>
{% for p in paechter_qs %}
<option value="{{ p.pk }}" {% if p.pk|stringformat:'s' == initial.paechter_id %}selected{% endif %}>
{{ p.get_full_name }}
</option>
{% endfor %}
</select>
</div>
</div>
</div>
</div>
<div class="d-flex gap-2 justify-content-end">
<a href="{% url 'stiftung:dms_list' %}" class="btn btn-outline-secondary">Abbrechen</a>
<button type="submit" class="btn btn-primary">
<i class="fas fa-upload me-2"></i>Hochladen
</button>
</div>
</form>
</div>
</div>
<script>
(function () {
const dropZone = document.getElementById('drop-zone');
const fileInput = document.getElementById('datei-input');
const filePreview = document.getElementById('file-preview');
const fileName = document.getElementById('file-name');
function showFile(file) {
fileName.textContent = file.name;
filePreview.classList.remove('d-none');
dropZone.style.borderColor = '#198754 !important';
dropZone.classList.add('border-success');
}
fileInput.addEventListener('change', function () {
if (this.files[0]) showFile(this.files[0]);
});
dropZone.addEventListener('dragover', function (e) {
e.preventDefault();
this.style.backgroundColor = '#f0f7ff';
this.style.borderColor = '#0d6efd';
});
dropZone.addEventListener('dragleave', function () {
this.style.backgroundColor = '';
this.style.borderColor = '#ccc';
});
dropZone.addEventListener('drop', function (e) {
e.preventDefault();
this.style.backgroundColor = '';
this.style.borderColor = '#ccc';
const files = e.dataTransfer.files;
if (files.length > 0) {
fileInput.files = files;
showFile(files[0]);
}
});
})();
</script>
{% endblock %}

View File

@@ -1,65 +0,0 @@
{% extends 'base.html' %}
{% load static %}
{% block title %}Dokument löschen - Stiftungsverwaltung{% endblock %}
{% block content %}
<div class="row">
<div class="col-lg-6 mx-auto">
<div class="card shadow">
<div class="card-header bg-danger text-white">
<h4 class="mb-0">
<i class="fas fa-exclamation-triangle me-2"></i>Dokument löschen
</h4>
</div>
<div class="card-body">
<div class="alert alert-warning">
<h5 class="alert-heading">
<i class="fas fa-exclamation-triangle me-2"></i>Warnung!
</h5>
<p class="mb-0">
Sind Sie sicher, dass Sie das Dokument <strong>{{ dokument.titel }}</strong> löschen möchten?
</p>
</div>
<div class="card mb-3">
<div class="card-body">
<h6 class="card-title">Dokumentdetails:</h6>
<p class="card-text">
<strong>Titel:</strong> {{ dokument.titel }}<br>
<strong>Kontext:</strong> {{ dokument.get_kontext_display }}<br>
<strong>Paperless ID:</strong> {{ dokument.paperless_document_id }}<br>
{% if dokument.beschreibung %}
<strong>Beschreibung:</strong> {{ dokument.beschreibung }}
{% endif %}
</p>
</div>
</div>
<div class="alert alert-info">
<h6 class="alert-heading">
<i class="fas fa-info-circle me-2"></i>Wichtiger Hinweis
</h6>
<p class="mb-0">
Diese Aktion kann nicht rückgängig gemacht werden. Alle zugehörigen Verknüpfungen werden ebenfalls gelöscht.
</p>
</div>
<form method="post">
{% csrf_token %}
<div class="d-flex justify-content-between">
<a href="{% url 'stiftung:dokument_detail' dokument.pk %}" class="btn btn-secondary">
<i class="fas fa-arrow-left me-2"></i>Abbrechen
</a>
<button type="submit" class="btn btn-danger">
<i class="fas fa-trash me-2"></i>Endgültig löschen
</button>
</div>
</form>
</div>
</div>
</div>
</div>
{% endblock %}

View File

@@ -1,168 +0,0 @@
{% extends 'base.html' %}
{% load static %}
{% block title %}{{ title }} - Stiftungsverwaltung{% endblock %}
{% block content %}
<div class="row">
<div class="col-12">
<!-- Header -->
<div class="d-flex justify-content-between align-items-center mb-4">
<h1 class="h3">
<i class="fas fa-file-alt text-primary me-2"></i>{{ title }}
</h1>
<div class="btn-group" role="group">
<a href="{% url 'stiftung:dokument_update' dokument.pk %}" class="btn btn-warning">
<i class="fas fa-edit me-2"></i>Bearbeiten
</a>
<a href="{% url 'stiftung:dokument_delete' dokument.pk %}" class="btn btn-danger">
<i class="fas fa-trash me-2"></i>Löschen
</a>
</div>
</div>
<div class="row">
<!-- Main Information -->
<div class="col-lg-8">
<!-- Dokument Details -->
<div class="card shadow mb-4">
<div class="card-header py-3">
<h6 class="m-0 font-weight-bold text-primary">
<i class="fas fa-info-circle me-2"></i>Dokumentdetails
</h6>
</div>
<div class="card-body">
<div class="row">
<div class="col-md-6">
<h6 class="text-primary">Titel</h6>
<p class="mb-3">{{ dokument.titel }}</p>
<h6 class="text-primary">Kontext</h6>
<p class="mb-3">
<span class="badge bg-secondary">{{ dokument.get_kontext_display }}</span>
</p>
</div>
<div class="col-md-6">
<h6 class="text-primary">Paperless ID</h6>
<p class="mb-3">
<code>{{ dokument.paperless_document_id }}</code>
</p>
<h6 class="text-primary">Erstellt</h6>
<p class="mb-3">{{ dokument.id }}</p>
</div>
</div>
{% if dokument.beschreibung %}
<hr class="my-3">
<h6 class="text-primary">Beschreibung</h6>
<p class="mb-0">{{ dokument.beschreibung }}</p>
{% endif %}
</div>
</div>
<!-- Verknüpfungen -->
<div class="card shadow mb-4">
<div class="card-header py-3">
<h6 class="m-0 font-weight-bold text-primary">
<i class="fas fa-link me-2"></i>Verknüpfungen
</h6>
</div>
<div class="card-body">
{% if dokument.foerderung_set.exists or dokument.verpachtung_set.exists %}
{% if dokument.foerderung_set.exists %}
<h6 class="text-primary">Förderungen</h6>
<div class="list-group list-group-flush mb-3">
{% for foerderung in dokument.foerderung_set.all %}
<div class="list-group-item d-flex justify-content-between align-items-center">
<div>
<strong>{{ foerderung.person.get_full_name }}</strong> - {{ foerderung.jahr }}
<br>
<small class="text-muted">€{{ foerderung.betrag|floatformat:2 }}</small>
</div>
<a href="{% url 'stiftung:foerderung_detail' foerderung.pk %}" class="btn btn-sm btn-outline-primary">
<i class="fas fa-eye"></i>
</a>
</div>
{% endfor %}
</div>
{% endif %}
{% if dokument.verpachtung_set.exists %}
<h6 class="text-primary">Verpachtungen</h6>
<div class="list-group list-group-flush">
{% for verpachtung in dokument.verpachtung_set.all %}
<div class="list-group-item d-flex justify-content-between align-items-center">
<div>
<strong>{{ verpachtung.vertragsnummer }}</strong> - {{ verpachtung.land.gemeinde }}
<br>
<small class="text-muted">{{ verpachtung.paechter.get_full_name }}</small>
</div>
<a href="{% url 'stiftung:verpachtung_detail' verpachtung.pk %}" class="btn btn-sm btn-outline-primary">
<i class="fas fa-eye"></i>
</a>
</div>
{% endfor %}
</div>
{% endif %}
{% else %}
<div class="text-center py-4">
<i class="fas fa-link fa-3x text-muted mb-3"></i>
<h5 class="text-muted">Keine Verknüpfungen</h5>
<p class="text-muted">Dieses Dokument ist noch nicht mit Förderungen oder Verpachtungen verknüpft.</p>
</div>
{% endif %}
</div>
</div>
</div>
<!-- Sidebar -->
<div class="col-lg-4">
<!-- Quick Stats -->
<div class="card shadow mb-4">
<div class="card-header py-3">
<h6 class="m-0 font-weight-bold text-primary">
<i class="fas fa-chart-pie me-2"></i>Übersicht
</h6>
</div>
<div class="card-body">
<div class="row text-center">
<div class="col-6">
<div class="border-end">
<h4 class="text-primary">{{ dokument.foerderung_set.count }}</h4>
<small class="text-muted">Förderungen</small>
</div>
</div>
<div class="col-6">
<h4 class="text-success">{{ dokument.verpachtung_set.count }}</h4>
<small class="text-muted">Verpachtungen</small>
</div>
</div>
</div>
</div>
<!-- Actions -->
<div class="card shadow">
<div class="card-header py-3">
<h6 class="m-0 font-weight-bold text-primary">
<i class="fas fa-bolt me-2"></i>Schnellzugriff
</h6>
</div>
<div class="card-body">
<div class="d-grid gap-2">
<a href="{% url 'stiftung:dokument_update' dokument.pk %}" class="btn btn-warning">
<i class="fas fa-edit me-2"></i>Dokument bearbeiten
</a>
<a href="{% url 'stiftung:dokument_list' %}" class="btn btn-outline-secondary">
<i class="fas fa-arrow-left me-2"></i>Zurück zur Liste
</a>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
{% endblock %}

View File

@@ -1,139 +0,0 @@
{% extends 'base.html' %}
{% load static %}
{% block title %}{{ title }} - Stiftung Management{% endblock %}
{% block content %}
<div class="row">
<div class="col-lg-8 mx-auto">
<div class="card shadow">
<div class="card-header">
<h4 class="mb-0">
<i class="fas fa-file-alt text-primary me-2"></i>{{ title }}
</h4>
</div>
<div class="card-body">
<form method="post">
{% csrf_token %}
<!-- Verknüpfungsanzeigen -->
{% if form.land_verpachtung_id.value %}
<div class="alert alert-info">
<i class="fas fa-info-circle me-2"></i>
<strong>Verknüpfung:</strong> Dieses Dokument wird mit einer Verpachtung verknüpft.
</div>
{% elif form.verpachtung_id.value %}
<div class="alert alert-info">
<i class="fas fa-info-circle me-2"></i>
<strong>Verknüpfung:</strong> Dieses Dokument wird mit einer Verpachtung (Legacy) verknüpft.
</div>
{% elif form.land_id.value %}
<div class="alert alert-info">
<i class="fas fa-info-circle me-2"></i>
<strong>Verknüpfung:</strong> Dieses Dokument wird mit einer Länderei verknüpft.
</div>
{% elif form.paechter_id.value %}
<div class="alert alert-info">
<i class="fas fa-info-circle me-2"></i>
<strong>Verknüpfung:</strong> Dieses Dokument wird mit einem Pächter verknüpft.
</div>
{% elif form.destinataer_id.value %}
<div class="alert alert-info">
<i class="fas fa-info-circle me-2"></i>
<strong>Verknüpfung:</strong> Dieses Dokument wird mit einem Destinatär verknüpft.
</div>
{% elif form.foerderung_id.value %}
<div class="alert alert-info">
<i class="fas fa-info-circle me-2"></i>
<strong>Verknüpfung:</strong> Dieses Dokument wird mit einer Förderung verknüpft.
</div>
{% endif %}
<div class="row">
<div class="col-md-6 mb-3">
<label for="{{ form.paperless_document_id.id_for_label }}" class="form-label">
{{ form.paperless_document_id.label }} *
</label>
{{ form.paperless_document_id }}
{% if form.paperless_document_id.errors %}
<div class="invalid-feedback d-block">
{{ form.paperless_document_id.errors.0 }}
</div>
{% endif %}
<small class="form-text text-muted">
Die Dokument-ID aus Paperless (z.B. aus der URL: /documents/12345/)
</small>
</div>
<div class="col-md-6 mb-3">
<label for="{{ form.kontext.id_for_label }}" class="form-label">
{{ form.kontext.label }} *
</label>
{{ form.kontext }}
{% if form.kontext.errors %}
<div class="invalid-feedback d-block">
{{ form.kontext.errors.0 }}
</div>
{% endif %}
</div>
</div>
<div class="mb-3">
<label for="{{ form.titel.id_for_label }}" class="form-label">
{{ form.titel.label }} *
</label>
{{ form.titel }}
{% if form.titel.errors %}
<div class="invalid-feedback d-block">
{{ form.titel.errors.0 }}
</div>
{% endif %}
</div>
<div class="mb-3">
<label for="{{ form.beschreibung.id_for_label }}" class="form-label">
{{ form.beschreibung.label }}
</label>
{{ form.beschreibung }}
{% if form.beschreibung.errors %}
<div class="invalid-feedback d-block">
{{ form.beschreibung.errors.0 }}
</div>
{% endif %}
</div>
<!-- Versteckte Verknüpfungsfelder -->
{{ form.land_verpachtung_id }}
{{ form.verpachtung_id }}
{{ form.land_id }}
{{ form.paechter_id }}
{{ form.destinataer_id }}
{{ form.foerderung_id }}
<div class="d-flex justify-content-between">
<a href="{% url 'stiftung:dokument_list' %}" class="btn btn-outline-secondary">
<i class="fas fa-arrow-left me-1"></i>Zurück zur Liste
</a>
<button type="submit" class="btn btn-primary">
<i class="fas fa-save me-1"></i>
{% if dokument %}Aktualisieren{% else %}Verknüpfen{% endif %}
</button>
</div>
</form>
</div>
</div>
</div>
</div>
{% endblock %}
{% block extra_css %}
<style>
.form-control, .form-select {
border-radius: 0.375rem;
}
.form-control:focus, .form-select:focus {
border-color: #86b7fe;
box-shadow: 0 0 0 0.25rem rgba(13, 110, 253, 0.25);
}
</style>
{% endblock %}

View File

@@ -1,146 +0,0 @@
{% extends 'base.html' %}
{% load static %}
{% block title %}Alle Dokumente - Stiftungsverwaltung{% endblock %}
{% block content %}
<div class="row">
<div class="col-12">
<div class="d-flex justify-content-between align-items-center mb-4">
<h1 class="h3 mb-0">
<i class="fas fa-file-alt text-primary me-2"></i>
Alle Dokumente
</h1>
<div>
<a href="{% url 'stiftung:dokument_management' %}" class="btn btn-info me-2">
<i class="fas fa-external-link-alt me-1"></i>Dokumentenverwaltung
</a>
</div>
</div>
</div>
</div>
<!-- Verknüpfte Dokumente -->
<div class="row mb-4">
<div class="col-12">
<div class="card shadow">
<div class="card-header py-3">
<h6 class="m-0 font-weight-bold text-success">
<i class="fas fa-link me-2"></i>Verknüpfte Dokumente ({{ dokumente|length }})
</h6>
</div>
<div class="card-body">
{% if dokumente %}
<div class="table-responsive">
<table class="table table-hover">
<thead class="table-light">
<tr>
<th>Dokument</th>
<th>Kontext</th>
<th>Verknüpft mit</th>
<th>Aktionen</th>
</tr>
</thead>
<tbody>
{% for dokument in dokumente %}
<tr>
<td>
<strong>{{ dokument.titel }}</strong>
<br>
<small class="text-muted">ID: {{ dokument.paperless_document_id }}</small>
</td>
<td>
<span class="badge bg-secondary">{{ dokument.get_kontext_display }}</span>
</td>
<td>
{% if dokument.verpachtung_id %}
<span class="badge bg-info">Verpachtung</span>
{% elif dokument.land_id %}
<span class="badge bg-success">Länderei</span>
{% elif dokument.paechter_id %}
<span class="badge bg-primary">Pächter</span>
{% elif dokument.destinataer_id %}
<span class="badge bg-warning">Destinatär</span>
{% elif dokument.foerderung_id %}
<span class="badge bg-secondary">Förderung</span>
{% else %}
<span class="text-muted">Keine Verknüpfung</span>
{% endif %}
</td>
<td>
<div class="btn-group" role="group">
<a href="{{ dokument.get_paperless_url }}" target="_blank" class="btn btn-sm btn-outline-primary" title="In Paperless öffnen">
<i class="fas fa-external-link-alt"></i>
</a>
<a href="{% url 'stiftung:dokument_detail' dokument.pk %}" class="btn btn-sm btn-outline-info" title="Details">
<i class="fas fa-eye"></i>
</a>
<a href="{% url 'stiftung:dokument_update' dokument.pk %}" class="btn btn-sm btn-outline-warning" title="Bearbeiten">
<i class="fas fa-edit"></i>
</a>
</div>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<div class="text-center py-4">
<i class="fas fa-link fa-3x text-muted mb-3"></i>
<h5 class="text-muted">Keine Dokumente verknüpft</h5>
<p class="text-muted">Verknüpfen Sie Dokumente aus Paperless mit Ihren Entitäten.</p>
</div>
{% endif %}
</div>
</div>
</div>
</div>
<!-- Verfügbare Paperless-Dokumente -->
{% if available_dokumente %}
<div class="row mb-4">
<div class="col-12">
<div class="card shadow">
<div class="card-header py-3">
<h6 class="m-0 font-weight-bold text-info">
<i class="fas fa-plus-circle me-2"></i>Verfügbare Paperless-Dokumente ({{ available_dokumente|length }})
</h6>
</div>
<div class="card-body">
<div class="row">
{% for doc in available_dokumente %}
<div class="col-md-6 col-lg-4 mb-3">
<div class="card h-100 border-info">
<div class="card-body">
<h6 class="card-title">{{ doc.title }}</h6>
<div class="mb-2">
{% for tag in doc.tags %}
{% if tag == 'Stiftung_Destinatäre' or tag == 'Stiftung_Land_und_Pächter' or tag == 'Stiftung_Administration' %}
<span class="badge bg-primary me-1">{{ tag }}</span>
{% else %}
<span class="badge bg-light text-dark me-1">{{ tag }}</span>
{% endif %}
{% endfor %}
</div>
<div class="d-flex justify-content-between align-items-center">
<a href="{{ doc.document_url }}" target="_blank" class="btn btn-sm btn-outline-info">
<i class="fas fa-external-link-alt me-1"></i>In Paperless öffnen
</a>
<a href="{% url 'stiftung:dokument_management' %}" class="btn btn-sm btn-success">
<i class="fas fa-link me-1"></i>Verknüpfen
</a>
</div>
</div>
</div>
</div>
{% endfor %}
</div>
</div>
</div>
</div>
</div>
{% endif %}
{% endblock %}

View File

@@ -1,557 +0,0 @@
{% extends 'base.html' %}
{% load static %}
{% block title %}Dokumentenverwaltung - Stiftung{% endblock %}
{% block content %}
<div class="d-flex justify-content-between align-items-center mb-4">
<h1><i class="fas fa-folder-open me-2"></i>Dokumentenverwaltung</h1>
<div>
<a href="{% url 'stiftung:dokument_list' %}" class="btn btn-outline-primary">
<i class="fas fa-list me-1"></i>Alle Dokumente anzeigen
</a>
</div>
</div>
<div id="statusMessages"></div>
<!-- Filter -->
<div class="card mb-3">
<div class="card-header">
<i class="fas fa-filter me-2"></i>Filter
</div>
<div class="card-body">
<div class="row g-3">
<div class="col-md-3">
<label class="form-label">Kategorie</label>
<select id="filterCategory" class="form-select">
<option value="all">Alle</option>
<option value="destinaere">Destinatäre</option>
<option value="land">Ländereien</option>
<option value="admin">Administration</option>
</select>
</div>
<div class="col-md-6">
<label class="form-label">Suche im Titel</label>
<input id="filterQuery" class="form-control" placeholder="Titel enthält..." />
</div>
<div class="col-md-3 d-flex align-items-end">
<button id="refreshDocuments" class="btn btn-primary w-100">
<i class="fas fa-sync me-1"></i>Aktualisieren
</button>
</div>
</div>
</div>
</div>
<!-- Dokumente-Liste -->
<div class="card">
<div class="card-header d-flex justify-content-between align-items-center">
<div><i class="fas fa-file-alt me-2"></i>Dokumente</div>
<small class="text-muted" id="counts"></small>
</div>
<div class="card-body" id="documentsContainer">
<div class="text-center py-5 text-muted" id="loadingState">
<div class="spinner-border" role="status"></div>
<p class="mt-2">Lade Dokumente...</p>
</div>
</div>
</div>
<!-- Re-Link Modal -->
<div class="modal fade" id="relinkModal" tabindex="-1">
<div class="modal-dialog modal-lg">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">Dokument neu verknüpfen</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<div class="mb-3"><strong id="relinkDocTitle"></strong></div>
<div id="currentLinks" class="mb-3"></div>
<div class="row g-3">
<div class="col-md-4">
<label class="form-label">Kategorie</label>
<select id="relinkCategory" class="form-select">
<option value="destinataer">Destinatäre</option>
<option value="land">Ländereien</option>
<option value="paechter">Pächter</option>
<option value="verpachtung">Verpachtungen</option>
<option value="foerderung">Förderungen</option>
<option value="abrechnung">Abrechnungen</option>
<option value="rentmeister">Rentmeister</option>
</select>
</div>
<div class="col-md-8">
<label class="form-label">Suche</label>
<div class="input-group">
<input id="relinkQuery" class="form-control" placeholder="Name, Ort, E-Mail, Telefon, Adresse..." />
<button id="relinkSearch" class="btn btn-outline-secondary"><i class="fas fa-search"></i></button>
</div>
<small class="form-text text-muted">Durchsucht Name, Adresse, E-Mail, Telefon und weitere Felder</small>
</div>
</div>
<div class="mt-3" id="relinkResults" style="max-height: 400px; overflow-y: auto; border: 1px solid #dee2e6; border-radius: 0.375rem; padding: 0.5rem;"></div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Schließen</button>
</div>
</div>
</div>
</div>
{% endblock %}
{% block extra_css %}
<style>
.search-result-item:hover {
background-color: #f8f9fa !important;
border-color: #0d6efd !important;
}
#relinkResults::-webkit-scrollbar {
width: 8px;
}
#relinkResults::-webkit-scrollbar-track {
background: #f1f1f1;
border-radius: 4px;
}
#relinkResults::-webkit-scrollbar-thumb {
background: #c1c1c1;
border-radius: 4px;
}
#relinkResults::-webkit-scrollbar-thumb:hover {
background: #a8a8a8;
}
</style>
{% endblock %}
{% block javascript %}
<script>
let allDocuments = [];
let linksByPaperlessId = new Map();
let currentRelink = { linkId: null, paperlessId: null };
function showMessage(message, type) {
const statusDiv = document.getElementById('statusMessages');
const alert = document.createElement('div');
alert.className = `alert alert-${type} alert-dismissible fade show`;
alert.innerHTML = `${message}<button type="button" class="btn-close" data-bs-dismiss="alert"></button>`;
statusDiv.appendChild(alert);
setTimeout(() => alert.remove(), 5000);
}
function fetchData() {
console.log('Fetching updated document data...'); // Debug log
const loadingState = document.getElementById('loadingState');
if (loadingState) {
loadingState.style.display = 'block';
}
console.log('Making API calls to:', [
'/api/paperless/documents/?poll=1',
'/api/link-document/list/'
]);
Promise.all([
fetch('/api/paperless/documents/?poll=1').then(r => {
console.log('Paperless API response status:', r.status, r.ok);
if (!r.ok) {
throw new Error(`Paperless API failed: ${r.status} ${r.statusText}`);
}
return r.json();
}),
fetch('/api/link-document/list/').then(r => {
console.log('Link API response status:', r.status, r.ok);
if (!r.ok) {
throw new Error(`Link API failed: ${r.status} ${r.statusText}`);
}
return r.json();
})
]).then(([docs, linksResp]) => {
console.log('Data fetched successfully:', { docs: docs.documents?.length, links: linksResp.links?.length }); // Debug log
console.log('Full docs response:', docs); // Debug the full response
console.log('Full links response:', linksResp); // Debug the full response
allDocuments = docs.documents || [];
linksByPaperlessId = new Map();
// Handle new grouped links format
(linksResp.links || []).forEach(docLinks => {
console.log(`Setting linksByPaperlessId for document ${docLinks.paperless_id}:`, docLinks);
linksByPaperlessId.set(docLinks.paperless_id, docLinks);
});
console.log('Final linksByPaperlessId Map:', linksByPaperlessId);
renderDocuments();
const countsElement = document.getElementById('counts');
if (countsElement) {
countsElement.textContent = `Gesamt: ${docs.total_all} | Destinatäre: ${docs.total_destinaere} | Land: ${docs.total_land} | Admin: ${docs.total_admin}`;
}
}).catch(err => {
console.error('Error fetching data:', err);
console.error('Error details:', {
message: err.message,
stack: err.stack,
name: err.name
});
showMessage('Fehler beim Laden der Daten: ' + err.message, 'danger');
// Show error in the container
const container = document.getElementById('documentsContainer');
if (container) {
container.innerHTML = `
<div class="alert alert-danger">
<h6><i class="fas fa-exclamation-triangle me-2"></i>Fehler beim Laden der Dokumente</h6>
<p class="mb-0">${err.message}</p>
<button class="btn btn-primary mt-2" onclick="fetchData()">
<i class="fas fa-sync me-1"></i>Erneut versuchen
</button>
</div>
`;
}
}).finally(() => {
const loadingState = document.getElementById('loadingState');
if (loadingState) {
loadingState.style.display = 'none';
}
});
}
function renderDocuments() {
const container = document.getElementById('documentsContainer');
const category = document.getElementById('filterCategory').value;
const query = document.getElementById('filterQuery').value.toLowerCase();
let filtered = allDocuments.slice();
if (category !== 'all') {
filtered = filtered.filter(d => d.tag_category === category);
}
if (query) {
filtered = filtered.filter(d => (d.title || '').toLowerCase().includes(query));
}
if (!filtered.length) {
container.innerHTML = '<p class="text-muted">Keine Dokumente gefunden.</p>';
return;
}
let html = '<div class="table-responsive"><table class="table table-striped align-middle"><thead><tr><th>Titel</th><th>Kategorie</th><th>Verknüpft mit</th><th>Aktionen</th></tr></thead><tbody>';
filtered.forEach(doc => {
const linkData = linksByPaperlessId.get(doc.id);
let linkedTo = '<span class="text-muted">nicht verknüpft</span>';
let hasLinks = false;
if (linkData && linkData.links && linkData.links.length > 0) {
console.log(`Document ${doc.id} (${doc.title}) has ${linkData.links.length} links:`, linkData.links);
hasLinks = true;
const linkItems = linkData.links.map(link => {
const obj = link.linked_object;
if (!obj) {
console.warn('Link missing linked_object:', link);
return `<div class="mb-1"><span class="badge bg-warning me-1">Fehler</span> Fehlerhafter Link</div>`;
}
// Generate the appropriate detail URL based on link type
let detailUrl = '#';
if (link.link_type === 'destinataer') {
detailUrl = `/destinataere/${obj.id}/`;
} else if (link.link_type === 'land') {
detailUrl = `/laendereien/${obj.id}/`;
} else if (link.link_type === 'paechter') {
detailUrl = `/paechter/${obj.id}/`;
} else if (link.link_type === 'verpachtung') {
detailUrl = `/laendereien/verpachtungen/${obj.id}/`;
} else if (link.link_type === 'foerderung') {
detailUrl = `/foerderungen/${obj.id}/`;
} else if (link.link_type === 'abrechnung') {
detailUrl = `/laendereien/abrechnungen/${obj.id}/`;
} else if (link.link_type === 'rentmeister') {
detailUrl = `/geschaeftsfuehrung/rentmeister/${obj.id}/`;
}
return `<div class="mb-1 d-flex align-items-center justify-content-between">
<div class="flex-grow-1">
<span class="badge bg-info me-1">${obj?.type || 'Unbekannt'}</span>
<a href="${detailUrl}" class="text-decoration-none small text-primary" title="Zu ${obj?.type || 'Entität'} navigieren">
${obj?.name || 'Unbekannt'}
<i class="fas fa-external-link-alt ms-1" style="font-size: 0.7em;"></i>
</a>
</div>
<button class="btn btn-sm btn-outline-danger ms-2" onclick="onDeleteLink('${link.id}')" title="Diese Verknüpfung löschen">
<i class="fas fa-times" style="font-size: 0.7em;"></i>
</button>
</div>`;
}).join('');
linkedTo = `<div class="small">${linkItems}</div>`;
console.log(`Final linkedTo HTML for doc ${doc.id}:`, linkedTo);
}
const openUrl = `/api/paperless/documents/${doc.id}/`;
html += `
<tr>
<td><strong>${doc.title || 'Ohne Titel'}</strong><br><small class="text-muted">Paperless-ID: ${doc.id}</small></td>
<td><span class="badge bg-secondary">${doc.tag_category}</span></td>
<td>${linkedTo}</td>
<td>
<a class="btn btn-sm btn-outline-primary" href="${openUrl}" target="_blank" title="In Paperless öffnen"><i class="fas fa-external-link-alt"></i></a>
${hasLinks ? `<button class="btn btn-sm btn-outline-danger" onclick="onDeleteAllLinks(${doc.id})" title="Alle Verknüpfungen löschen"><i class="fas fa-trash"></i></button>` : ''}
<button class="btn btn-sm btn-outline-success" onclick="onRelink('', ${doc.id}, '${(doc.title||'').replace(/'/g, "&#39;")}')" title="Neu verknüpfen"><i class="fas fa-link"></i></button>
</td>
</tr>`;
});
html += '</tbody></table></div>';
container.innerHTML = html;
}
function onRelink(linkId, paperlessId, title) {
currentRelink = { linkId, paperlessId };
document.getElementById('relinkDocTitle').textContent = title;
// Show current links in modal
const linkData = linksByPaperlessId.get(paperlessId);
const currentLinksDiv = document.getElementById('currentLinks');
if (linkData && linkData.links && linkData.links.length > 0) {
let currentLinksHtml = '<div class="alert alert-info"><strong>Aktuell verknüpft mit:</strong><ul class="mb-0 mt-2">';
linkData.links.forEach(link => {
const obj = link.linked_object;
currentLinksHtml += `<li><span class="badge bg-secondary me-1">${obj?.type || 'Unbekannt'}</span> ${obj?.name || 'Unbekannt'}</li>`;
});
currentLinksHtml += '</ul></div>';
currentLinksDiv.innerHTML = currentLinksHtml;
} else {
currentLinksDiv.innerHTML = '<div class="alert alert-warning">Dieses Dokument ist noch nicht verknüpft.</div>';
}
document.getElementById('relinkResults').innerHTML = '';
const modal = new bootstrap.Modal(document.getElementById('relinkModal'));
modal.show();
}
document.getElementById('relinkSearch').addEventListener('click', function() {
const category = document.getElementById('relinkCategory').value;
const q = document.getElementById('relinkQuery').value || 'all';
const target = document.getElementById('relinkResults');
target.innerHTML = '<div class="d-flex align-items-center"><div class="spinner-border spinner-border-sm me-2" role="status"></div>Suche...</div>';
fetch(`/api/link-document/search/?q=${encodeURIComponent(q)}&category=${category}`)
.then(r => r.json())
.then(data => {
let items = data[category] || [];
if (!items.length) {
target.innerHTML = '<div class="text-center py-3 text-muted"><i class="fas fa-search me-2"></i>Keine Treffer gefunden.</div>';
return;
}
let html = `<div class="mb-2"><small class="text-muted">${items.length} Treffer gefunden</small></div>`;
// Get current links for this document to mark already linked entities
const linkData = linksByPaperlessId.get(currentRelink.paperlessId);
const currentlyLinkedIds = new Set();
if (linkData && linkData.links) {
linkData.links.forEach(link => {
if (link.linked_object && link.linked_object.id) {
currentlyLinkedIds.add(link.linked_object.id);
}
});
}
items.forEach(it => {
const isLinked = currentlyLinkedIds.has(it.id);
const linkClass = isLinked ? 'border-success bg-light' : 'border';
const buttonClass = isLinked ? 'btn-success' : 'btn-outline-primary';
const buttonIcon = isLinked ? 'fas fa-check-circle' : 'fas fa-plus';
const buttonText = isLinked ? 'Bereits verknüpft' : 'Verknüpfen';
html += `<div class="d-flex justify-content-between align-items-start ${linkClass} rounded p-3 mb-2 search-result-item" style="transition: all 0.2s;">
<div class="flex-grow-1">
<div class="fw-bold mb-1">${it.name}</div>
<div class="text-muted small mb-1">${it.details || ''}</div>
${isLinked ? '<span class="badge bg-success mt-1"><i class="fas fa-check-circle me-1"></i>Aktuell verknüpft</span>' : ''}
</div>
<button class="btn btn-sm ${buttonClass} ms-3" onclick="confirmRelink('${it.id}', '${category}')" title="${buttonText}" ${isLinked ? 'disabled' : ''}>
<i class="${buttonIcon} me-1"></i>${isLinked ? 'Verknüpft' : 'Auswählen'}
</button>
</div>`;
});
target.innerHTML = html;
})
.catch(() => { target.innerHTML = '<div class="text-center py-3 text-danger"><i class="fas fa-exclamation-triangle me-2"></i>Fehler bei der Suche</div>'; });
});
function onDeleteAllLinks(paperlessId) {
const linkData = linksByPaperlessId.get(paperlessId);
if (!linkData || !linkData.links || linkData.links.length === 0) {
showMessage('Keine Verknüpfungen zum Löschen gefunden', 'warning');
return;
}
if (!confirm(`Möchten Sie wirklich alle ${linkData.links.length} Verknüpfung(en) für dieses Dokument löschen?`)) {
return;
}
console.log(`Deleting all ${linkData.links.length} links for document ${paperlessId}:`, linkData.links);
// Delete all links for this document with proper CSRF token
const deletePromises = linkData.links.map(link => {
console.log(`Deleting link ${link.id}`);
return fetch(`/api/link-document/delete/${link.id}/`, {
method: 'DELETE',
headers: { 'X-CSRFToken': getCookie('csrftoken') }
});
});
Promise.all(deletePromises).then(responses => {
console.log('All delete responses:', responses.map(r => ({ status: r.status, ok: r.ok })));
const allSuccessful = responses.every(r => r.ok);
const failedCount = responses.filter(r => !r.ok).length;
if (allSuccessful) {
showMessage('Alle Verknüpfungen erfolgreich gelöscht', 'success');
setTimeout(() => {
fetchData();
}, 300);
} else {
console.error(`${failedCount} of ${responses.length} delete requests failed`);
showMessage(`Fehler beim Löschen von ${failedCount} Verknüpfung(en)`, 'danger');
}
}).catch(err => {
console.error('Error during bulk delete:', err);
showMessage('Fehler beim Löschen der Verknüpfungen', 'danger');
});
}
// Add Enter key support for search
document.getElementById('relinkQuery').addEventListener('keypress', function(e) {
if (e.key === 'Enter') {
document.getElementById('relinkSearch').click();
}
});
function confirmRelink(targetId, category) {
console.log('confirmRelink called:', { targetId, category, currentRelink });
const isUpdate = !!currentRelink.linkId;
const url = isUpdate ? '/api/link-document/update/' : '/api/link-document/create/';
const payload = isUpdate ? {
link_id: currentRelink.linkId,
link_type: category,
link_id_target: targetId
} : {
paperless_id: currentRelink.paperlessId,
paperless_title: document.getElementById('relinkDocTitle').textContent,
paperless_url: `/api/paperless/documents/${currentRelink.paperlessId}/`,
link_type: category,
link_id: targetId
};
console.log('Sending request:', { url, payload });
fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-CSRFToken': getCookie('csrftoken') },
body: JSON.stringify(payload)
}).then(async r => {
console.log('Response status:', r.status, r.ok);
let resp = {};
try {
resp = await r.json();
console.log('Response data:', resp);
} catch (e) {
console.log('No JSON response, treating as success if status OK');
if (r.ok) {
resp = { success: true };
} else {
throw new Error('Network response was not ok');
}
}
if (r.ok && (resp.success || resp.message)) {
console.log('Success! Showing message and refreshing...');
showMessage(resp.message || 'Verknüpfung gespeichert', 'success');
// Close modal first, then refresh data
const modal = bootstrap.Modal.getInstance(document.getElementById('relinkModal'));
if (modal) {
console.log('Closing modal...');
modal.hide();
}
// Clear search results to prevent confusion
document.getElementById('relinkResults').innerHTML = '';
document.getElementById('relinkQuery').value = '';
// Try immediate refresh first
console.log('Calling fetchData() immediately...');
fetchData();
// Also schedule a backup refresh to be sure
console.log('Scheduling backup refresh in 500ms...');
setTimeout(() => {
console.log('Backup fetchData() call...');
fetchData();
}, 500);
} else {
console.log('Error response:', resp);
showMessage(resp.error || 'Fehler beim Speichern', 'danger');
}
}).catch(err => {
console.error('Relink error:', err);
showMessage('Fehler beim Speichern', 'danger');
});
}
function onDeleteLink(linkId) {
if (!confirm('Diese Verknüpfung wirklich löschen?')) return;
console.log('Deleting individual link:', linkId);
fetch(`/api/link-document/delete/${linkId}/`, {
method: 'DELETE',
headers: { 'X-CSRFToken': getCookie('csrftoken') }
})
.then(async r => {
console.log('Delete response status:', r.status, r.ok);
let data = {};
try {
data = await r.json();
console.log('Delete response data:', data);
} catch (_) {
console.log('No JSON response, treating as success if status OK');
}
if (r.ok && (data.success === undefined || data.success === true)) {
showMessage('Verknüpfung gelöscht', 'success');
setTimeout(() => {
fetchData();
}, 300);
} else {
showMessage((data && data.error) || 'Fehler beim Löschen', 'danger');
}
})
.catch(err => {
console.error('Delete error:', err);
showMessage('Fehler beim Löschen', 'danger');
});
}
function getCookie(name) {
let cookieValue = null;
if (document.cookie && document.cookie !== '') {
const cookies = document.cookie.split(';');
for (let i = 0; i < cookies.length; i++) {
const cookie = cookies[i].trim();
if (cookie.substring(0, name.length + 1) === (name + '=')) {
cookieValue = decodeURIComponent(cookie.substring(name.length + 1));
break;
}
}
}
return cookieValue;
}
document.getElementById('refreshDocuments').addEventListener('click', fetchData);
document.getElementById('filterCategory').addEventListener('change', renderDocuments);
document.getElementById('filterQuery').addEventListener('input', () => { renderDocuments(); });
document.addEventListener('DOMContentLoaded', function() {
console.log('Dokumentenverwaltung page loaded, calling fetchData()...');
fetchData();
});
</script>
{% endblock %}

View File

@@ -1,7 +1,7 @@
{% extends 'base.html' %}
{% load humanize %}
{% block title %}E-Mail-Eingang Detail - van Hees-Theyssen-Vogel'sche Stiftung{% endblock %}
{% block title %}E-Mail-Eingang Detail - Stiftungsverwaltung{% endblock %}
{% block content %}
<div class="row">
@@ -11,22 +11,31 @@
<i class="fas fa-envelope me-2"></i>E-Mail-Eingang
</h1>
<a href="{% url 'stiftung:email_eingang_list' %}" class="btn btn-outline-secondary btn-sm">
<i class="fas fa-arrow-left me-1"></i>Zurück zur Übersicht
<i class="fas fa-arrow-left me-1"></i>Zurueck zur Uebersicht
</a>
</div>
</div>
</div>
<div class="row">
<!-- Linke Spalte: E-Mail-Details -->
{# Linke Spalte: E-Mail-Details #}
<div class="col-lg-8">
<div class="card mb-4">
<div class="card-header d-flex justify-content-between align-items-center">
<span><i class="fas fa-envelope-open me-2"></i>E-Mail-Details</span>
<span>
{# Kategorie-Badge #}
{% if eingang.kategorie == "rechnung" %}<span class="badge bg-warning text-dark me-1"><i class="fas fa-file-invoice me-1"></i>Rechnung</span>
{% elif eingang.kategorie == "destinataer" %}<span class="badge bg-info me-1"><i class="fas fa-user me-1"></i>Destinataer</span>
{% elif eingang.kategorie == "land_pacht" %}<span class="badge bg-success me-1"><i class="fas fa-map me-1"></i>Land/Pacht</span>
{% elif eingang.kategorie == "stiftungsgeschichte" %}<span class="badge bg-dark me-1"><i class="fas fa-landmark me-1"></i>Geschichte</span>
{% endif %}
{# Status-Badge #}
{% if eingang.status == "neu" %}<span class="badge bg-warning text-dark">Neu</span>
{% elif eingang.status == "zugewiesen" %}<span class="badge bg-primary">Zugewiesen</span>
{% elif eingang.status == "verarbeitet" %}<span class="badge bg-success">Verarbeitet</span>
{% elif eingang.status == "rechnung_erfasst" %}<span class="badge bg-info">Rechnung erfasst</span>
{% elif eingang.status == "zahlung_gebucht" %}<span class="badge bg-success">Zahlung gebucht</span>
{% elif eingang.status == "unbekannt" %}<span class="badge bg-danger">Unbekannter Absender</span>
{% elif eingang.status == "fehler" %}<span class="badge bg-secondary">Fehler</span>
{% endif %}
@@ -47,17 +56,27 @@
<dt class="col-sm-3">Betreff</dt>
<dd class="col-sm-9">{{ eingang.betreff|default:"(kein Betreff)" }}</dd>
<dt class="col-sm-3">Destinatär</dt>
<dt class="col-sm-3">Destinataer</dt>
<dd class="col-sm-9">
{% if eingang.destinataer %}
<a href="{% url 'stiftung:destinataer_detail' eingang.destinataer.pk %}">
{{ eingang.destinataer }}
</a>
{% else %}
<span class="text-danger"><i class="fas fa-exclamation-circle me-1"></i>Nicht zugeordnet</span>
<span class="text-muted">Nicht zugeordnet</span>
{% endif %}
</dd>
{% if eingang.verwaltungskosten %}
<dt class="col-sm-3">Rechnung</dt>
<dd class="col-sm-9">
<a href="{% url 'stiftung:verwaltungskosten_detail' eingang.verwaltungskosten.pk %}">
{{ eingang.verwaltungskosten.bezeichnung }} ({{ eingang.verwaltungskosten.betrag }} EUR)
</a>
<span class="badge bg-{{ eingang.verwaltungskosten.get_status_color }}">{{ eingang.verwaltungskosten.get_status_display }}</span>
</dd>
{% endif %}
{% if eingang.quartalsnachweis %}
<dt class="col-sm-3">Quartalsnachweis</dt>
<dd class="col-sm-9">
@@ -82,32 +101,34 @@
</div>
</div>
<!-- Anhänge / Paperless-Dokumente -->
{% if dokument_links %}
{# Anhaenge / DMS-Dokumente #}
{% if dms_dokumente %}
<div class="card mb-4">
<div class="card-header">
<i class="fas fa-paperclip me-2"></i>Anhänge in Paperless-NGX
<i class="fas fa-paperclip me-2"></i>Anhaenge ({{ dms_dokumente|length }})
</div>
<div class="card-body p-0">
<table class="table mb-0">
<thead class="table-light">
<tr>
<th>Titel</th>
<th>Kontext</th>
<th>Paperless-ID</th>
<th>Dateiname</th>
<th>Typ</th>
<th>Groesse</th>
<th></th>
</tr>
</thead>
<tbody>
{% for link in dokument_links %}
{% for dok in dms_dokumente %}
<tr>
<td>{{ link.titel }}</td>
<td>{{ link.get_kontext_display }}</td>
<td><code>{{ link.paperless_document_id }}</code></td>
<td>{{ dok.dateiname_original|default:dok.titel }}</td>
<td><span class="text-muted small">{{ dok.dateityp|default:"" }}</span></td>
<td><span class="text-muted small">{{ dok.get_human_size }}</span></td>
<td>
<a href="{{ link.get_paperless_url }}" target="_blank" class="btn btn-sm btn-outline-info">
<i class="fas fa-external-link-alt me-1"></i>Öffnen
{% if dok.datei %}
<a href="{% url 'stiftung:dms_download' dok.pk %}" class="btn btn-sm btn-outline-primary">
<i class="fas fa-download me-1"></i>Herunterladen
</a>
{% endif %}
</td>
</tr>
{% endfor %}
@@ -115,43 +136,109 @@
</table>
</div>
</div>
{% elif eingang.paperless_dokument_ids %}
<div class="alert alert-info">
<i class="fas fa-info-circle me-1"></i>
{{ eingang.paperless_dokument_ids|length }} Anhang/-hänge in Paperless hochgeladen
(IDs: {{ eingang.paperless_dokument_ids|join:", " }}), aber noch kein DokumentLink erstellt.
</div>
{% else %}
<div class="card mb-4">
<div class="card-body text-muted text-center py-3">
<i class="fas fa-paperclip me-1"></i>Keine Anhänge in dieser E-Mail.
<i class="fas fa-paperclip me-1"></i>Keine Anhaenge in dieser E-Mail.
</div>
</div>
{% endif %}
</div>
<!-- Rechte Spalte: Aktionen -->
{# Rechte Spalte: Aktionen #}
<div class="col-lg-4">
<!-- Manuelle Destinatär-Zuordnung -->
{% if not eingang.destinataer or eingang.status == "unbekannt" %}
{# Kategorie aendern #}
<div class="card mb-4">
<div class="card-header">
<i class="fas fa-tag me-2"></i>Kategorie
</div>
<div class="card-body">
<form method="post">
{% csrf_token %}
<input type="hidden" name="action" value="set_kategorie">
<div class="mb-2">
<select class="form-select form-select-sm" name="kategorie">
<option value="allgemein" {% if eingang.kategorie == "allgemein" %}selected{% endif %}>Allgemein</option>
<option value="destinataer" {% if eingang.kategorie == "destinataer" %}selected{% endif %}>Destinataer</option>
<option value="rechnung" {% if eingang.kategorie == "rechnung" %}selected{% endif %}>Rechnung</option>
<option value="land_pacht" {% if eingang.kategorie == "land_pacht" %}selected{% endif %}>Grundstueck / Pacht</option>
<option value="stiftungsgeschichte" {% if eingang.kategorie == "stiftungsgeschichte" %}selected{% endif %}>Stiftungsgeschichte</option>
</select>
</div>
<button type="submit" class="btn btn-outline-primary btn-sm w-100">
<i class="fas fa-save me-1"></i>Kategorie setzen
</button>
</form>
</div>
</div>
{# Rechnung erfassen (nur wenn noch keine zugeordnet) #}
{% if not eingang.verwaltungskosten and eingang.status != "zahlung_gebucht" %}
<div class="card mb-4 border-warning">
<div class="card-header bg-warning text-dark">
<i class="fas fa-user-plus me-2"></i>Destinatär manuell zuordnen
<i class="fas fa-file-invoice-dollar me-2"></i>Als Rechnung erfassen
</div>
<div class="card-body">
<p class="small text-muted">
Die E-Mail-Adresse <strong>{{ eingang.absender_email }}</strong>
konnte keinem Destinatär automatisch zugeordnet werden.
Bitte wählen Sie den passenden Destinatär aus.
Erstellt einen Verwaltungskosten-Eintrag und verknuepft die Anhaenge als Rechnungsdokumente.
</p>
<form method="post">
{% csrf_token %}
<input type="hidden" name="action" value="erfasse_rechnung">
<div class="mb-2">
<label class="form-label small">Bezeichnung</label>
<input type="text" class="form-control form-control-sm" name="bezeichnung"
value="{{ eingang.betreff }}" required>
</div>
<div class="mb-2">
<label class="form-label small">Betrag (EUR)</label>
<input type="number" step="0.01" class="form-control form-control-sm" name="betrag"
placeholder="0.00" required>
</div>
<div class="mb-2">
<label class="form-label small">Lieferant / Firma</label>
<input type="text" class="form-control form-control-sm" name="lieferant"
value="{{ eingang.absender_name|default:eingang.absender_email }}">
</div>
<div class="mb-2">
<label class="form-label small">Rechnungsnummer</label>
<input type="text" class="form-control form-control-sm" name="rechnungsnummer"
placeholder="z.B. RE-2026001">
</div>
<div class="mb-2">
<label class="form-label small">Kategorie</label>
<select class="form-select form-select-sm" name="vk_kategorie">
{% for key, label in vk_kategorie_choices %}
<option value="{{ key }}" {% if key == "rechnung_intern" %}selected{% endif %}>{{ label }}</option>
{% endfor %}
</select>
</div>
<button type="submit" class="btn btn-warning w-100">
<i class="fas fa-file-invoice me-1"></i>Rechnung erfassen
</button>
</form>
</div>
</div>
{% endif %}
{# Manuelle Destinataer-Zuordnung #}
{% if not eingang.destinataer or eingang.status == "unbekannt" %}
<div class="card mb-4 border-info">
<div class="card-header bg-info text-white">
<i class="fas fa-user-plus me-2"></i>Destinataer zuordnen
</div>
<div class="card-body">
<p class="small text-muted">
Absender <strong>{{ eingang.absender_email }}</strong>
konnte nicht automatisch zugeordnet werden.
</p>
<form method="post">
{% csrf_token %}
<input type="hidden" name="action" value="assign_destinataer">
<div class="mb-3">
<label class="form-label">Destinatär</label>
<select class="form-select" name="destinataer_id" required>
<option value=""> Bitte wählen </option>
<select class="form-select form-select-sm" name="destinataer_id" required>
<option value=""> Bitte waehlen </option>
{% for d in alle_destinataere %}
<option value="{{ d.pk }}">{{ d.nachname }}, {{ d.vorname }}
{% if d.email %} ({{ d.email }}){% endif %}
@@ -159,16 +246,16 @@
{% endfor %}
</select>
</div>
<button type="submit" class="btn btn-warning w-100">
<i class="fas fa-link me-1"></i>Zuordnen & Speichern
<button type="submit" class="btn btn-info w-100">
<i class="fas fa-link me-1"></i>Zuordnen
</button>
</form>
</div>
</div>
{% endif %}
<!-- Als verarbeitet markieren -->
{% if eingang.status != "verarbeitet" %}
{# Als verarbeitet markieren #}
{% if eingang.status != "verarbeitet" and eingang.status != "zahlung_gebucht" %}
<div class="card mb-4">
<div class="card-header">
<i class="fas fa-check-circle me-2"></i>Als verarbeitet markieren
@@ -178,9 +265,8 @@
{% csrf_token %}
<input type="hidden" name="action" value="mark_verarbeitet">
<div class="mb-3">
<label class="form-label">Interne Notiz (optional)</label>
<textarea class="form-control" name="notizen" rows="3"
placeholder="Z. B. 'Studiennachweis für WS 2025/26 eingegangen und geprüft.'">{{ eingang.notizen }}</textarea>
<textarea class="form-control form-control-sm" name="notizen" rows="3"
placeholder="Optionale Notiz...">{{ eingang.notizen }}</textarea>
</div>
<button type="submit" class="btn btn-success w-100">
<i class="fas fa-check me-1"></i>Verarbeitet
@@ -190,7 +276,7 @@
</div>
{% endif %}
<!-- Notizen bearbeiten -->
{# Notizen #}
<div class="card mb-4">
<div class="card-header">
<i class="fas fa-sticky-note me-2"></i>Interne Notizen
@@ -200,28 +286,46 @@
{% csrf_token %}
<input type="hidden" name="action" value="save_notizen">
<div class="mb-3">
<textarea class="form-control" name="notizen" rows="5"
placeholder="Interne Notizen zur E-Mail...">{{ eingang.notizen }}</textarea>
<textarea class="form-control form-control-sm" name="notizen" rows="4"
placeholder="Interne Notizen...">{{ eingang.notizen }}</textarea>
</div>
<button type="submit" class="btn btn-outline-secondary w-100">
<button type="submit" class="btn btn-outline-secondary btn-sm w-100">
<i class="fas fa-save me-1"></i>Notizen speichern
</button>
</form>
</div>
</div>
<!-- Metadaten -->
<div class="card">
{# Metadaten #}
<div class="card mb-4">
<div class="card-header"><i class="fas fa-info-circle me-2"></i>Metadaten</div>
<div class="card-body">
<dl class="row mb-0 small">
<dt class="col-6">Erfasst am</dt>
<dd class="col-6">{{ eingang.created_at|date:"d.m.Y H:i" }}</dd>
<dt class="col-6">Kategorie</dt>
<dd class="col-6">{{ eingang.get_kategorie_display }}</dd>
<dt class="col-6">Datensatz-ID</dt>
<dd class="col-6 text-muted"><code>{{ eingang.pk|stringformat:"s"|slice:":8" }}</code></dd>
<dd class="col-6 text-muted"><code>{{ eingang.pk|stringformat:"s"|slice:":8" }}...</code></dd>
</dl>
</div>
</div>
{# Loeschen #}
<div class="card border-danger">
<div class="card-header text-danger">
<i class="fas fa-trash-alt me-2"></i>E-Mail loeschen
</div>
<div class="card-body">
<form method="post" action="{% url 'stiftung:email_eingang_delete' eingang.pk %}"
onsubmit="return confirm('E-Mail wirklich loeschen?');">
{% csrf_token %}
<button type="submit" class="btn btn-outline-danger btn-sm w-100">
<i class="fas fa-trash-alt me-1"></i>Loeschen
</button>
</form>
</div>
</div>
</div>
</div>
{% endblock %}

View File

@@ -1,14 +1,14 @@
{% extends 'base.html' %}
{% load humanize %}
{% block title %}E-Mail-Eingang (Destinatäre) - van Hees-Theyssen-Vogel'sche Stiftung{% endblock %}
{% block title %}E-Mail-Eingang - Stiftungsverwaltung{% endblock %}
{% block content %}
<div class="row">
<div class="col-12">
<div class="d-sm-flex align-items-center justify-content-between mb-4">
<h1 class="h3 mb-0 text-gray-800">
<i class="fas fa-envelope-open-text me-2"></i>E-Mail-Eingang (Destinatäre)
<i class="fas fa-envelope-open-text me-2"></i>E-Mail-Eingang
</h1>
<div class="d-flex gap-2">
<form method="post" action="{% url 'stiftung:email_eingang_poll_trigger' %}" class="d-inline">
@@ -17,79 +17,65 @@
<i class="fas fa-sync-alt me-1"></i>Jetzt abrufen
</button>
</form>
<a href="{% url 'stiftung:destinataer_list' %}" class="btn btn-outline-secondary btn-sm">
<i class="fas fa-arrow-left me-1"></i>Destinatäre
</a>
</div>
</div>
</div>
</div>
<!-- Statuskarten -->
{# Statuskarten #}
<div class="row mb-4">
<div class="col-md-3">
<div class="col-md-3 col-6 mb-2">
<div class="card border-left-primary h-100 py-2">
<div class="card-body">
<div class="row no-gutters align-items-center">
<div class="col mr-2">
<div class="text-xs font-weight-bold text-primary text-uppercase mb-1">Gesamt</div>
<div class="h5 mb-0 font-weight-bold text-gray-800">{{ counts.gesamt }}</div>
</div>
<div class="col-auto"><i class="fas fa-envelope fa-2x text-gray-300"></i></div>
</div>
<div class="text-xs font-weight-bold text-primary text-uppercase mb-1">Gesamt</div>
<div class="h5 mb-0 font-weight-bold">{{ counts.gesamt }}</div>
</div>
</div>
</div>
<div class="col-md-3">
<div class="col-md-3 col-6 mb-2">
<div class="card border-left-warning h-100 py-2">
<div class="card-body">
<div class="row no-gutters align-items-center">
<div class="col mr-2">
<div class="text-xs font-weight-bold text-warning text-uppercase mb-1">Neu / Unbearbeitet</div>
<div class="h5 mb-0 font-weight-bold text-gray-800">{{ counts.neu }}</div>
</div>
<div class="col-auto"><i class="fas fa-exclamation-circle fa-2x text-gray-300"></i></div>
</div>
<div class="text-xs font-weight-bold text-warning text-uppercase mb-1">Neu</div>
<div class="h5 mb-0 font-weight-bold">{{ counts.neu }}</div>
</div>
</div>
</div>
<div class="col-md-3">
<div class="col-md-3 col-6 mb-2">
<div class="card border-left-info h-100 py-2">
<div class="card-body">
<div class="text-xs font-weight-bold text-info text-uppercase mb-1">Rechnungen</div>
<div class="h5 mb-0 font-weight-bold">{{ counts.rechnung }}</div>
</div>
</div>
</div>
<div class="col-md-3 col-6 mb-2">
<div class="card border-left-danger h-100 py-2">
<div class="card-body">
<div class="row no-gutters align-items-center">
<div class="col mr-2">
<div class="text-xs font-weight-bold text-danger text-uppercase mb-1">Unbekannter Absender</div>
<div class="h5 mb-0 font-weight-bold text-gray-800">{{ counts.unbekannt }}</div>
</div>
<div class="col-auto"><i class="fas fa-user-times fa-2x text-gray-300"></i></div>
</div>
</div>
</div>
</div>
<div class="col-md-3">
<div class="card border-left-secondary h-100 py-2">
<div class="card-body">
<div class="row no-gutters align-items-center">
<div class="col mr-2">
<div class="text-xs font-weight-bold text-secondary text-uppercase mb-1">Fehler</div>
<div class="h5 mb-0 font-weight-bold text-gray-800">{{ counts.fehler }}</div>
</div>
<div class="col-auto"><i class="fas fa-exclamation-triangle fa-2x text-gray-300"></i></div>
</div>
<div class="text-xs font-weight-bold text-danger text-uppercase mb-1">Unbekannt</div>
<div class="h5 mb-0 font-weight-bold">{{ counts.unbekannt }}</div>
</div>
</div>
</div>
</div>
<!-- Filter -->
{# Filter #}
<div class="card mb-4">
<div class="card-header"><i class="fas fa-filter me-2"></i>Filter</div>
<div class="card-body">
<form method="get" class="row g-3">
<div class="col-md-4">
<div class="col-md-3">
<label class="form-label">Suche</label>
<input type="text" class="form-control" name="q" value="{{ search }}"
placeholder="Absender, Betreff, Destinatär...">
placeholder="Absender, Betreff...">
</div>
<div class="col-md-3">
<label class="form-label">Kategorie</label>
<select class="form-select" name="kategorie">
<option value="">Alle</option>
{% for value, label in kategorie_choices %}
<option value="{{ value }}" {% if kategorie_filter == value %}selected{% endif %}>{{ label }}</option>
{% endfor %}
</select>
</div>
<div class="col-md-3">
<label class="form-label">Status</label>
@@ -100,15 +86,15 @@
{% endfor %}
</select>
</div>
<div class="col-md-2 d-flex align-items-end">
<div class="col-md-1 d-flex align-items-end">
<button type="submit" class="btn btn-primary w-100">
<i class="fas fa-search me-1"></i>Filtern
<i class="fas fa-search"></i>
</button>
</div>
{% if search or status_filter %}
{% if search or status_filter or kategorie_filter %}
<div class="col-md-2 d-flex align-items-end">
<a href="{% url 'stiftung:email_eingang_list' %}" class="btn btn-outline-secondary w-100">
<i class="fas fa-times me-1"></i>Zurücksetzen
<i class="fas fa-times me-1"></i>Reset
</a>
</div>
{% endif %}
@@ -116,11 +102,11 @@
</div>
</div>
<!-- Tabelle -->
{# Tabelle #}
<div class="card">
<div class="card-header d-flex justify-content-between align-items-center">
<span><i class="fas fa-inbox me-2"></i>Eingegangene E-Mails</span>
<span class="text-muted small">{{ page_obj.paginator.count }} Einträge</span>
<span class="text-muted small">{{ page_obj.paginator.count }} Eintraege</span>
</div>
<div class="card-body p-0">
{% if page_obj %}
@@ -130,9 +116,9 @@
<tr>
<th>Datum</th>
<th>Absender</th>
<th>Destinatär</th>
<th>Betreff</th>
<th>Anhänge</th>
<th>Kategorie</th>
<th>Zuordnung</th>
<th>Status</th>
<th></th>
</tr>
@@ -149,19 +135,27 @@
<small class="text-muted">{{ e.absender_email }}</small>
{% endif %}
</td>
<td>{{ e.betreff|truncatechars:50 }}</td>
<td>
{% if e.destinataer %}
<a href="{% url 'stiftung:destinataer_detail' e.destinataer.pk %}">
{{ e.destinataer }}
</a>
{% if e.kategorie == "rechnung" %}
<span class="badge bg-warning text-dark"><i class="fas fa-file-invoice me-1"></i>Rechnung</span>
{% elif e.kategorie == "destinataer" %}
<span class="badge bg-info"><i class="fas fa-user me-1"></i>Destinataer</span>
{% elif e.kategorie == "land_pacht" %}
<span class="badge bg-success"><i class="fas fa-map me-1"></i>Land/Pacht</span>
{% elif e.kategorie == "stiftungsgeschichte" %}
<span class="badge bg-dark"><i class="fas fa-landmark me-1"></i>Geschichte</span>
{% else %}
<span class="text-danger"><i class="fas fa-question-circle me-1"></i>Unbekannt</span>
<span class="badge bg-secondary">Allgemein</span>
{% endif %}
</td>
<td>{{ e.betreff|truncatechars:60 }}</td>
<td class="text-center">
{% if e.paperless_dokument_ids %}
<span class="badge bg-info">{{ e.paperless_dokument_ids|length }}</span>
<td>
{% if e.destinataer %}
<a href="{% url 'stiftung:destinataer_detail' e.destinataer.pk %}" class="text-decoration-none">
{{ e.destinataer }}
</a>
{% elif e.verwaltungskosten %}
<span class="text-info"><i class="fas fa-file-invoice-dollar me-1"></i>{{ e.verwaltungskosten.bezeichnung|truncatechars:30 }}</span>
{% else %}
<span class="text-muted"></span>
{% endif %}
@@ -173,6 +167,10 @@
<span class="badge bg-primary">Zugewiesen</span>
{% elif e.status == "verarbeitet" %}
<span class="badge bg-success">Verarbeitet</span>
{% elif e.status == "rechnung_erfasst" %}
<span class="badge bg-info">Rechnung erfasst</span>
{% elif e.status == "zahlung_gebucht" %}
<span class="badge bg-success">Bezahlt</span>
{% elif e.status == "unbekannt" %}
<span class="badge bg-danger">Unbekannt</span>
{% elif e.status == "fehler" %}
@@ -180,7 +178,7 @@
{% endif %}
</td>
<td>
<a href="{% url 'stiftung:email_eingang_detail' e.pk %}" class="btn btn-sm btn-outline-primary">
<a href="{% url 'stiftung:email_eingang_detail' e.pk %}" class="btn btn-sm btn-outline-primary" title="Details">
<i class="fas fa-eye"></i>
</a>
</td>
@@ -190,14 +188,14 @@
</table>
</div>
<!-- Pagination -->
{# Pagination #}
{% if page_obj.has_other_pages %}
<div class="d-flex justify-content-center py-3">
<nav>
<ul class="pagination mb-0">
{% if page_obj.has_previous %}
<li class="page-item">
<a class="page-link" href="?page={{ page_obj.previous_page_number }}&q={{ search }}&status={{ status_filter }}">
<a class="page-link" href="?page={{ page_obj.previous_page_number }}&q={{ search }}&status={{ status_filter }}&kategorie={{ kategorie_filter }}">
&laquo;
</a>
</li>
@@ -207,7 +205,7 @@
</li>
{% if page_obj.has_next %}
<li class="page-item">
<a class="page-link" href="?page={{ page_obj.next_page_number }}&q={{ search }}&status={{ status_filter }}">
<a class="page-link" href="?page={{ page_obj.next_page_number }}&q={{ search }}&status={{ status_filter }}&kategorie={{ kategorie_filter }}">
&raquo;
</a>
</li>
@@ -221,7 +219,7 @@
<div class="text-center py-5 text-muted">
<i class="fas fa-inbox fa-3x mb-3"></i>
<p>Keine E-Mails gefunden.</p>
<small>Der automatische Abruf erfolgt alle 15 Minuten. Über den Button "Jetzt abrufen" kann der Vorgang manuell ausgelöst werden.</small>
<small>Der automatische Abruf erfolgt alle 15 Minuten.</small>
</div>
{% endif %}
</div>

View File

@@ -0,0 +1,142 @@
{% extends 'base.html' %}
{% block title %}{{ title }} - Stiftung{% endblock %}
{% block breadcrumb %}
<a href="{% url 'stiftung:home' %}">Stiftungsverwaltung</a>
<span class="mx-1">/</span>
<a href="{% url 'stiftung:administration' %}">Administration</a>
<span class="mx-1">/</span>
{{ title }}
{% endblock %}
{% block content %}
<div class="container-fluid">
<div class="row">
<div class="col-lg-8">
<div class="card">
<div class="card-header d-flex justify-content-between align-items-center">
<h3 class="card-title mb-0">
<i class="fas fa-envelope"></i>
{{ title }}
</h3>
<a href="{% url 'stiftung:administration' %}" class="btn btn-sm btn-secondary">
<i class="fas fa-arrow-left"></i> Zurück
</a>
</div>
<div class="card-body">
{% if test_result %}
<div class="alert alert-{% if test_result.success %}success{% else %}danger{% endif %} alert-dismissible fade show">
<i class="fas fa-{% if test_result.success %}check-circle{% else %}exclamation-triangle{% endif %} me-1"></i>
{{ test_result.message }}
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
</div>
{% endif %}
<form method="post">
{% csrf_token %}
{% for setting in imap_settings %}
<div class="mb-3">
<label for="setting_{{ setting.key }}" class="form-label">
<strong>{{ setting.display_name }}</strong>
</label>
{% if setting.description %}
<div class="form-text mb-1">{{ setting.description }}</div>
{% endif %}
{% if setting.setting_type == 'boolean' %}
<div class="form-check form-switch">
<input type="checkbox"
class="form-check-input"
id="setting_{{ setting.key }}"
name="setting_{{ setting.key }}"
value="True"
{% if setting.get_typed_value %}checked{% endif %}>
<label class="form-check-label" for="setting_{{ setting.key }}">Aktiviert</label>
</div>
{% elif setting.setting_type == 'password' %}
<div class="input-group">
<input type="password"
class="form-control"
id="setting_{{ setting.key }}"
name="setting_{{ setting.key }}"
value="{{ setting.value }}"
placeholder="{% if setting.value %}••••••••{% else %}Passwort eingeben{% endif %}">
<button type="button" class="btn btn-outline-secondary" onclick="togglePassword(this)" title="Passwort anzeigen">
<i class="fas fa-eye"></i>
</button>
</div>
{% elif setting.setting_type == 'number' %}
<input type="number"
class="form-control"
id="setting_{{ setting.key }}"
name="setting_{{ setting.key }}"
value="{{ setting.value }}">
{% else %}
<input type="text"
class="form-control"
id="setting_{{ setting.key }}"
name="setting_{{ setting.key }}"
value="{{ setting.value }}">
{% endif %}
</div>
{% endfor %}
<hr>
<div class="d-flex gap-2">
<button type="submit" name="action" value="save" class="btn btn-primary">
<i class="fas fa-save me-1"></i> Speichern
</button>
<button type="submit" name="action" value="test" class="btn btn-outline-primary">
<i class="fas fa-plug me-1"></i> Verbindung testen
</button>
<a href="{% url 'stiftung:email_eingang_list' %}" class="btn btn-outline-secondary ms-auto">
<i class="fas fa-inbox me-1"></i> Zum Posteingang
</a>
</div>
</form>
</div>
</div>
</div>
<!-- Info sidebar -->
<div class="col-lg-4">
<div class="card">
<div class="card-header">
<i class="fas fa-info-circle"></i> Hinweise
</div>
<div class="card-body" style="font-size: 0.85rem;">
<p>Konfigurieren Sie hier die IMAP-Verbindung zum E-Mail-Server. Eingehende E-Mails werden automatisch alle <strong>15 Minuten</strong> abgerufen und den Destinatären zugeordnet.</p>
<hr>
<p class="mb-1"><strong>Typische Einstellungen:</strong></p>
<ul class="mb-0" style="font-size: 0.8rem;">
<li>SSL/TLS: Port <code>993</code></li>
<li>Unverschlüsselt: Port <code>143</code></li>
</ul>
<hr>
<p class="mb-0"><i class="fas fa-shield-alt text-success me-1"></i> Das Passwort wird in der Datenbank gespeichert. Umgebungsvariablen (<code>IMAP_HOST</code>, etc.) werden als Fallback verwendet, wenn hier keine Werte gesetzt sind.</p>
</div>
</div>
</div>
</div>
</div>
<script>
function togglePassword(btn) {
const input = btn.parentElement.querySelector('input');
const icon = btn.querySelector('i');
if (input.type === 'password') {
input.type = 'text';
icon.classList.replace('fa-eye', 'fa-eye-slash');
} else {
input.type = 'password';
icon.classList.replace('fa-eye-slash', 'fa-eye');
}
}
</script>
{% endblock %}

View File

@@ -72,18 +72,21 @@
<div class="col-12">
<h6 class="text-primary">Verwendungsnachweis</h6>
<p class="mb-3">
<a href="{% url 'stiftung:dokument_detail' foerderung.verwendungsnachweis.pk %}">
{{ foerderung.verwendungsnachweis.titel }}
</a>
{{ foerderung.verwendungsnachweis.titel }}
</p>
</div>
</div>
{% endif %}
<!-- Verknüpfte Dokumente -->
<!-- Dokumente (DMS) -->
<div class="row">
<div class="col-12">
<h6 class="text-primary">Verknüpfte Dokumente</h6>
<div class="d-flex justify-content-between align-items-center mb-2">
<h6 class="text-primary mb-0">Dokumente</h6>
<a href="{% url 'stiftung:dms_upload' %}?foerderung={{ foerderung.pk }}" class="btn btn-sm btn-success">
<i class="fas fa-upload me-1"></i>Dokument hochladen
</a>
</div>
{% if verknuepfte_dokumente %}
<div class="table-responsive">
<table class="table table-sm table-hover">
@@ -100,8 +103,7 @@
<tr>
<td>
<strong>{{ dokument.titel }}</strong>
<br>
<small class="text-muted">ID: {{ dokument.paperless_document_id }}</small>
{% if dokument.dateiname_original %}<br><small class="text-muted">{{ dokument.dateiname_original }} ({{ dokument.get_human_size }})</small>{% endif %}
</td>
<td>
<span class="badge bg-secondary">{{ dokument.get_kontext_display }}</span>
@@ -115,12 +117,15 @@
</td>
<td>
<div class="btn-group" role="group">
<a href="{{ dokument.get_paperless_url }}" target="_blank" class="btn btn-sm btn-outline-primary" title="In Paperless öffnen">
<i class="fas fa-external-link-alt"></i>
<a href="{% url 'stiftung:dms_download' dokument.pk %}" class="btn btn-sm btn-outline-primary" title="Herunterladen">
<i class="fas fa-download"></i>
</a>
<a href="{% url 'stiftung:dokument_update' dokument.pk %}" class="btn btn-sm btn-outline-warning" title="Bearbeiten">
<a href="{% url 'stiftung:dms_edit' dokument.pk %}" class="btn btn-sm btn-outline-warning" title="Bearbeiten">
<i class="fas fa-edit"></i>
</a>
<a href="{% url 'stiftung:dms_delete' dokument.pk %}" class="btn btn-sm btn-outline-danger" title="Löschen">
<i class="fas fa-trash"></i>
</a>
</div>
</td>
</tr>
@@ -128,17 +133,12 @@
</tbody>
</table>
</div>
<div class="mt-2">
<a href="{% url 'stiftung:dokument_management' %}" class="btn btn-sm btn-success">
<i class="fas fa-plus me-1"></i>Weiteres Dokument verknüpfen
</a>
</div>
{% else %}
<div class="text-center py-3">
<i class="fas fa-file-alt fa-2x text-muted mb-2"></i>
<p class="text-muted mb-2">Keine Dokumente verknüpft</p>
<a href="{% url 'stiftung:dokument_management' %}" class="btn btn-success btn-sm">
<i class="fas fa-plus me-1"></i>Erstes Dokument verknüpfen
<p class="text-muted mb-2">Keine Dokumente vorhanden</p>
<a href="{% url 'stiftung:dms_upload' %}?foerderung={{ foerderung.pk }}" class="btn btn-success btn-sm">
<i class="fas fa-upload me-1"></i>Erstes Dokument hochladen
</a>
</div>
{% endif %}

View File

@@ -123,7 +123,7 @@
</div>
{% endif %}
<div class="form-text">
Optionale Verknüpfung zu einem Dokument aus dem Paperless-System
Optionale Verknüpfung zu einem Verwendungsnachweis (Legacy)
</div>
</div>
</div>

View File

@@ -120,12 +120,39 @@
</div>
<div class="col-lg-4">
{% if seite.dokumente.exists %}
<div class="card mb-3">
<div class="card-header bg-dark text-white">
<h6 class="mb-0"><i class="fas fa-landmark me-2"></i>Verknüpfte Dokumente</h6>
</div>
<div class="card-body p-0">
<div class="list-group list-group-flush">
{% for dok in seite.dokumente.all %}
<a href="{% url 'stiftung:dms_detail' pk=dok.pk %}" class="list-group-item list-group-item-action">
<div class="d-flex justify-content-between align-items-center">
<div>
<div class="small fw-bold">
{% if dok.is_pdf %}<i class="fas fa-file-pdf text-danger me-1"></i>{% else %}<i class="fas fa-file text-primary me-1"></i>{% endif %}
{{ dok.titel|truncatechars:35 }}
</div>
<small class="text-muted">{{ dok.dateiname_original }} &middot; {{ dok.get_human_size }}</small>
</div>
<span class="btn btn-sm btn-outline-success" title="Herunterladen" onclick="event.preventDefault(); window.location='{% url 'stiftung:dms_download' dok.pk %}';">
<i class="fas fa-download"></i>
</span>
</div>
</a>
{% endfor %}
</div>
</div>
</div>
{% endif %}
<div class="card">
<div class="card-header bg-secondary text-white">
<h6 class="mb-0"><i class="fas fa-list me-2"></i>Weitere Seiten</h6>
</div>
<div class="card-body">
<!-- This could be populated with other history pages -->
<p class="text-muted small">Navigation zu anderen Geschichtsseiten wird hier angezeigt.</p>
</div>
</div>

View File

@@ -177,6 +177,48 @@
</p>
</div>
</div>
{% if geschichte_dokumente %}
<div class="card mt-3">
<div class="card-header bg-dark text-white">
<h6 class="mb-0"><i class="fas fa-landmark me-2"></i>Dokumente verknüpfen</h6>
</div>
<div class="card-body p-0">
<div class="list-group list-group-flush">
{% for dok in geschichte_dokumente %}
<label class="list-group-item list-group-item-action" style="cursor: pointer;">
<div class="d-flex align-items-start">
<input type="checkbox" name="dokument_ids" value="{{ dok.pk }}"
class="form-check-input me-2 mt-1"
form="geschichteForm"
{% if dok.pk in selected_dok_ids %}checked{% endif %}>
<div class="flex-grow-1">
<div class="small fw-bold">{{ dok.titel|truncatechars:40 }}</div>
<small class="text-muted">{{ dok.dateiname_original }} ({{ dok.get_human_size }})</small>
<br><small class="text-muted">{{ dok.erstellt_am|date:"d.m.Y" }}</small>
</div>
<a href="{% url 'stiftung:dms_download' dok.pk %}" class="btn btn-sm btn-outline-primary ms-1" title="Herunterladen" onclick="event.stopPropagation();">
<i class="fas fa-download"></i>
</a>
</div>
</label>
{% endfor %}
</div>
</div>
<div class="card-footer small text-muted">
Dokumente aus dem DMS mit Kontext "Stiftungsgeschichte" auswählen, um sie mit diesem Beitrag zu verknüpfen.
</div>
</div>
{% else %}
<div class="card mt-3">
<div class="card-header bg-dark text-white">
<h6 class="mb-0"><i class="fas fa-landmark me-2"></i>Dokumente verknüpfen</h6>
</div>
<div class="card-body text-muted small">
Keine Stiftungsgeschichte-Dokumente im DMS vorhanden. Laden Sie Dokumente mit dem Kontext "Stiftungsgeschichte" im <a href="{% url 'stiftung:dms_upload' %}?kontext=stiftungsgeschichte">DMS hoch</a>.
</div>
</div>
{% endif %}
</div>
</div>
{% endblock %}

View File

@@ -1,455 +1,301 @@
{% extends "base.html" %}
{% block title %}{{ title }} - Foundation Management System{% endblock %}
{% block title %}Dashboard - Stiftungsverwaltung{% endblock %}
{% block content %}
<!-- KPI Cards Row -->
<div class="row g-3 mb-4">
<div class="col-xl-3 col-md-6">
<div class="card stat-card border-left-primary">
<div class="card-body d-flex align-items-center">
<div class="stat-icon me-3" style="background: var(--racing-green);">
<i class="fas fa-users"></i>
</div>
<div>
<div class="stat-value">{{ destinataer_count }}</div>
<div class="stat-label">Destinataere</div>
</div>
<a href="{% url 'stiftung:destinataer_list' %}" class="stretched-link"></a>
</div>
</div>
</div>
<div class="col-xl-3 col-md-6">
<div class="card stat-card border-left-success">
<div class="card-body d-flex align-items-center">
<div class="stat-icon me-3" style="background: var(--racing-green-light);">
<i class="fas fa-gift"></i>
</div>
<div>
<div class="stat-value">{{ foerderung_active }}</div>
<div class="stat-label">Aktive Foerderungen</div>
</div>
<a href="{% url 'stiftung:foerderung_list' %}" class="stretched-link"></a>
</div>
</div>
</div>
<div class="col-xl-3 col-md-6">
<div class="card stat-card border-left-warning">
<div class="card-body d-flex align-items-center">
<div class="stat-icon me-3" style="background: var(--orange-accent);">
<i class="fas fa-euro-sign"></i>
</div>
<div>
<div class="stat-value">{{ pending_payment_total|floatformat:0 }}</div>
<div class="stat-label">Offene Zahlungen &euro;</div>
</div>
<a href="{% url 'stiftung:unterstuetzungen_all' %}" class="stretched-link"></a>
</div>
</div>
</div>
<div class="col-xl-3 col-md-6">
<div class="card stat-card border-left-info">
<div class="card-body d-flex align-items-center">
<div class="stat-icon me-3" style="background: var(--grey-medium);">
<i class="fas fa-map"></i>
</div>
<div>
<div class="stat-value">{{ land_count }}</div>
<div class="stat-label">Laendereien</div>
</div>
<a href="{% url 'stiftung:land_list' %}" class="stretched-link"></a>
</div>
</div>
</div>
</div>
<div class="row">
<div class="col-12">
<div class="card shadow">
<div class="card-body text-center py-5">
<!-- Logo Placeholder -->
<div class="mb-4">
<div class="logo-placeholder mx-auto mb-3" style="width: 150px; height: 150px; border: 3px dashed #dee2e6; border-radius: 50%; display: flex; align-items: center; justify-content: center; background-color: #f8f9fa;">
<div class="text-center text-muted">
<i class="fas fa-image fa-2x mb-2"></i>
<div style="font-size: 0.8rem;">Logo hier<br>einfügen</div>
</div>
<!-- Main Action Row -->
<div class="row g-3">
<!-- Left Column: Action Items -->
<div class="col-lg-8">
{% if overdue_events %}
<!-- Overdue Events Alert -->
<div class="card border-left-danger mb-3">
<div class="card-header bg-danger text-white">
<i class="fas fa-exclamation-triangle me-2"></i>Ueberfaellige Termine ({{ overdue_events|length }})
</div>
<div class="card-body p-0">
<div class="action-list">
{% for event in overdue_events %}
<div class="action-item">
<div class="action-icon bg-danger text-white">
<i class="{{ event.icon }}"></i>
</div>
<!-- Alternative: Replace above div with actual logo when available -->
<!-- <img src="{% load static %}{% static 'images/stiftung-logo.png' %}" alt="Stiftung Logo" class="img-fluid mb-3" style="max-height: 150px;"> -->
</div>
<h1 class="display-4 mb-4">
<i class="fas fa-landmark text-primary me-3"></i>van Hees-Theyssen-Vogel'sche Stiftung
</h1>
<p class="lead text-muted mb-5">Stiftungsverwaltung - Modern Foundation Management System</p>
<div class="row justify-content-center mb-4">
<div class="col-md-8">
<div class="card border-0 bg-light">
<div class="card-body text-center py-3">
<p class="mb-1"><i class="fas fa-map-marker-alt text-primary me-2"></i>Raesfelder Str. 3, 46499 Hamminkeln</p>
<p class="mb-0"><i class="fas fa-phone text-primary me-2"></i>+49 (0) 2852 12345</p>
</div>
</div>
</div>
</div>
<div class="row g-4 mb-5">
<div class="col-md-4">
<div class="card h-100 border-0 shadow-sm">
<div class="card-body text-center p-4">
<i class="fas fa-users fa-3x text-primary mb-3"></i>
<h5 class="card-title">👥 Destinatäre</h5>
<p class="card-text">Verwalten Sie Stiftungsmitglieder, Familienzweige und Kontaktdaten zentral und übersichtlich.</p>
<a href="{% url 'stiftung:destinataer_list' %}" class="btn btn-outline-primary btn-sm mt-2">
<i class="fas fa-external-link-alt me-1"></i>Öffnen
</a>
</div>
</div>
</div>
<div class="col-md-4">
<div class="card h-100 border-0 shadow-sm">
<div class="card-body text-center p-4">
<i class="fas fa-hand-holding-usd fa-3x text-success mb-3"></i>
<h5 class="card-title">💰 Unterstützungsverwaltung</h5>
<p class="card-text">Erfassen und verfolgen Sie Unterstützungen, Beträge und Verwendungsnachweise systematisch.</p>
<a href="{% url 'stiftung:unterstuetzungen_all' %}" class="btn btn-outline-success btn-sm mt-2">
<i class="fas fa-external-link-alt me-1"></i>Öffnen
</a>
</div>
</div>
</div>
<div class="col-md-4">
<div class="card h-100 border-0 shadow-sm">
<div class="card-body text-center p-4">
<i class="fas fa-file-alt fa-3x text-info mb-3"></i>
<h5 class="card-title">📄 Dokumentenverwaltung</h5>
<p class="card-text">Stiftungsdokumente und Verträge</p>
<a href="{% url 'stiftung:dokument_management' %}" class="btn btn-outline-info btn-sm mt-2">
<i class="fas fa-external-link-alt me-1"></i>Öffnen
</a>
</div>
</div>
</div>
</div>
<!-- Calendar and Events Section -->
<div class="row g-4 mb-5">
<div class="col-lg-8">
<div class="card h-100 border-0 shadow-sm">
<div class="card-header bg-primary text-white">
<h5 class="mb-0">
<i class="fas fa-calendar-alt me-2"></i>Anstehende Termine & Ereignisse
</h5>
</div>
<div class="card-body">
{% if overdue_events %}
<div class="alert alert-danger" role="alert">
<h6><i class="fas fa-exclamation-triangle me-2"></i>Überfällige Termine ({{ overdue_events|length }})</h6>
{% for event in overdue_events %}
<div class="d-flex justify-content-between align-items-center border-bottom py-2">
<div>
<i class="{{ event.icon }} me-2"></i>
<strong>{{ event.title }}</strong>
<small class="text-muted d-block">{{ event.description }}</small>
</div>
<span class="badge bg-danger">{{ event.date }}</span>
</div>
{% endfor %}
</div>
{% endif %}
{% if upcoming_events %}
<h6 class="text-muted mb-3">Nächste {{ upcoming_events|length }} Termine</h6>
{% for event in upcoming_events %}
<div class="d-flex justify-content-between align-items-center border-bottom py-2">
<div class="flex-grow-1">
<i class="{{ event.icon }} me-2 text-{{ event.color }}"></i>
<strong>{{ event.title }}</strong>
{% if event.description %}
<small class="text-muted d-block">{{ event.description }}</small>
{% endif %}
</div>
<div class="text-end">
<span class="badge bg-{{ event.color }}">{{ event.date }}</span>
{% if event.time %}
<small class="d-block text-muted">{{ event.time }}</small>
{% endif %}
</div>
</div>
{% empty %}
<div class="text-center py-4 text-muted">
<i class="fas fa-calendar-check fa-3x mb-3"></i>
<p>Keine anstehenden Termine in den nächsten 14 Tagen.</p>
</div>
{% endfor %}
<div class="text-center mt-3">
<a href="{% url 'stiftung:kalender' %}" class="btn btn-outline-primary btn-sm">
<i class="fas fa-calendar me-1"></i>Vollständigen Kalender anzeigen
</a>
</div>
{% endif %}
</div>
</div>
</div>
<div class="col-lg-4">
<!-- Mini Calendar -->
<div class="card border-0 shadow-sm mb-3">
<div class="card-header bg-info text-white">
<h6 class="mb-0">
<i class="fas fa-calendar me-2"></i>{{ today|date:"F Y" }}
</h6>
</div>
<div class="card-body p-2">
<div class="mini-calendar">
<!-- Calendar will be generated by JavaScript -->
<div id="mini-calendar" data-events="{{ current_month_events|length }}">
<!-- Mini calendar grid will be inserted here -->
</div>
</div>
</div>
</div>
<!-- Quick Actions -->
<div class="card border-0 shadow-sm">
<div class="card-header bg-success text-white">
<h6 class="mb-0">
<i class="fas fa-plus me-2"></i>Schnellzugriff
</h6>
</div>
<div class="card-body">
<div class="d-grid gap-2">
<a href="{% url 'stiftung:kalender_create' %}" class="btn btn-outline-primary btn-sm">
<i class="fas fa-calendar-plus me-1"></i>Termin hinzufügen
</a>
<a href="{% url 'stiftung:unterstuetzung_create' %}" class="btn btn-outline-success btn-sm">
<i class="fas fa-euro-sign me-1"></i>Zahlung planen
</a>
<a href="{% url 'stiftung:destinataer_create' %}" class="btn btn-outline-info btn-sm">
<i class="fas fa-user-plus me-1"></i>Destinatär anlegen
</a>
</div>
</div>
</div>
</div>
</div>
<div class="row g-4 mb-5">
<div class="col-md-4">
<div class="card h-100 border-0 shadow-sm">
<div class="card-body text-center p-4">
<i class="fas fa-chart-bar fa-3x text-warning mb-3"></i>
<h5 class="card-title">📊 Berichte & Auswertungen</h5>
<p class="card-text">Generieren Sie detaillierte Berichte und Auswertungen für Ihre Stiftungsarbeit.</p>
<a href="{% url 'stiftung:bericht_list' %}" class="btn btn-outline-warning btn-sm mt-2">
<i class="fas fa-external-link-alt me-1"></i>Öffnen
</a>
</div>
</div>
</div>
<div class="col-md-4">
<div class="card h-100 border-0 shadow-sm">
<div class="card-body text-center p-4">
<i class="fas fa-map fa-3x text-danger mb-3"></i>
<h5 class="card-title">🗺️ Ländereiverwaltung</h5>
<p class="card-text">Verwalten Sie Grundstücke, Flächen und Verpachtungen professionell.</p>
<a href="{% url 'stiftung:land_list' %}" class="btn btn-outline-danger btn-sm mt-2">
<i class="fas fa-external-link-alt me-1"></i>Öffnen
</a>
</div>
</div>
</div>
<div class="col-md-4">
<div class="card h-100 border-0 shadow-sm">
<div class="card-body text-center p-4">
<i class="fas fa-handshake fa-3x text-secondary mb-3"></i>
<h5 class="card-title">🤝 Verpachtungsverwaltung</h5>
<p class="card-text">Organisieren Sie Pachtverträge und deren Verwaltung effizient.</p>
<a href="{% url 'stiftung:verpachtung_list' %}" class="btn btn-outline-secondary btn-sm mt-2">
<i class="fas fa-external-link-alt me-1"></i>Öffnen
</a>
</div>
</div>
</div>
</div>
<div class="row g-4 mb-5">
<div class="col-md-6">
<div class="card h-100 border-0 shadow-sm">
<div class="card-body text-center p-4">
<i class="fas fa-link fa-3x text-purple mb-3"></i>
<h5 class="card-title">🔗 Dokumentenverknüpfung</h5>
<p class="card-text">Verknüpfen Sie Paperless-Dokumente direkt mit Destinatären, Ländereien und Verpachtungen.</p>
<a href="{% url 'stiftung:dokument_management' %}" class="btn btn-outline-purple btn-sm mt-2">
<i class="fas fa-external-link-alt me-1"></i>Öffnen
</a>
</div>
</div>
</div>
<div class="col-md-6">
<div class="card h-100 border-0 shadow-sm">
<div class="card-body text-center p-4">
<i class="fas fa-database fa-3x text-dark mb-3"></i>
<h5 class="card-title">🗃️ Dokumentenarchiv</h5>
<p class="card-text">Zentraler Zugriff auf alle verknüpften Dokumente und deren Metadaten.</p>
<a href="{% url 'stiftung:dokument_list' %}" class="btn btn-outline-dark btn-sm mt-2">
<i class="fas fa-external-link-alt me-1"></i>Öffnen
</a>
</div>
</div>
</div>
</div>
<div class="d-flex justify-content-center gap-3 flex-wrap">
<a href="{% url 'stiftung:destinataer_list' %}" class="btn btn-primary btn-lg">
<i class="fas fa-users me-2"></i>Destinatäre
</a>
<a href="{% url 'stiftung:land_list' %}" class="btn btn-success btn-lg">
<i class="fas fa-map me-2"></i>Ländereien
</a>
<a href="{% url 'stiftung:paechter_list' %}" class="btn btn-info btn-lg">
<i class="fas fa-user-tie me-2"></i>Pächter
</a>
<a href="{% url 'stiftung:land_list' %}" class="btn btn-secondary btn-lg">
<i class="fas fa-handshake me-2"></i>Verpachtungen
</a>
<a href="{% url 'stiftung:foerderung_list' %}" class="btn btn-warning btn-lg">
<i class="fas fa-gift me-2"></i>Förderungen
</a>
</div>
<div class="d-flex justify-content-center gap-3 flex-wrap mt-3">
<a href="{% url 'stiftung:dokument_management' %}" class="btn btn-info btn-lg">
<i class="fas fa-file-alt me-2"></i>Paperless Integration
</a>
<a href="{% url 'stiftung:dokument_management' %}" class="btn btn-purple btn-lg">
<i class="fas fa-link me-2"></i>Dokumente verknüpfen
</a>
<a href="{% url 'stiftung:dokument_list' %}" class="btn btn-dark btn-lg">
<i class="fas fa-database me-2"></i>Dokumentenarchiv
</a>
<div class="action-text">
<div class="action-title">{{ event.title }}</div>
<div class="action-desc">{{ event.description }}</div>
</div>
<span class="badge bg-danger">{{ event.date }}</span>
</div>
{% endfor %}
</div>
</div>
<div class="text-center mt-4">
<div class="alert alert-success d-inline-block">
<i class="fas fa-check-circle me-2"></i>
<span class="fw-bold">System läuft erfolgreich</span>
<br>
<small class="text-muted">Entwickelt mit Django & Docker</small>
</div>
{% endif %}
{% if overdue_nachweise %}
<!-- Overdue Nachweise -->
<div class="card border-left-warning mb-3">
<div class="card-header">
<i class="fas fa-file-alt me-2 text-warning"></i>Ausstehende Nachweise Q{{ current_quarter }}/{{ current_year }}
</div>
<div class="card-body p-0">
<div class="action-list">
{% for nachweis in overdue_nachweise %}
<a class="action-item" href="{% url 'stiftung:destinataer_detail' nachweis.destinataer.pk %}">
<div class="action-icon" style="background: rgba(253,126,20,0.15); color: var(--orange-accent);">
<i class="fas fa-clock"></i>
</div>
<div class="action-text">
<div class="action-title">{{ nachweis.destinataer.vorname }} {{ nachweis.destinataer.nachname }}</div>
<div class="action-desc">{{ nachweis.get_status_display }}</div>
</div>
<span class="badge bg-warning">{{ nachweis.get_status_display }}</span>
</a>
{% endfor %}
</div>
</div>
</div>
{% endif %}
{% if pending_payments %}
<!-- Pending Payments -->
<div class="card border-left-primary mb-3">
<div class="card-header">
<i class="fas fa-hand-holding-usd me-2 text-primary"></i>Offene Zahlungen
</div>
<div class="card-body p-0">
<div class="action-list">
{% for payment in pending_payments %}
<a class="action-item" href="{% url 'stiftung:destinataer_detail' payment.destinataer.pk %}">
<div class="action-icon" style="background: rgba(0,66,37,0.1); color: var(--racing-green);">
<i class="fas fa-euro-sign"></i>
</div>
<div class="action-text">
<div class="action-title">{{ payment.destinataer.vorname }} {{ payment.destinataer.nachname }}</div>
<div class="action-desc">{{ payment.beschreibung|default:"Unterstuetzung" }} &middot; {{ payment.betrag|floatformat:2 }} &euro;</div>
</div>
<span class="badge bg-{% if payment.faellig_am < today %}danger{% else %}primary{% endif %}">{{ payment.faellig_am|date:"d.m.Y" }}</span>
</a>
{% endfor %}
</div>
</div>
</div>
{% endif %}
<!-- Upcoming Events -->
<div class="card mb-3">
<div class="card-header">
<i class="fas fa-calendar-alt me-2 text-primary"></i>Anstehende Termine
<a href="{% url 'stiftung:kalender' %}" class="float-end text-decoration-none" style="font-size: 0.75rem;">Alle anzeigen &rarr;</a>
</div>
<div class="card-body p-0">
<div class="action-list">
{% for event in upcoming_events %}
<div class="action-item">
<div class="action-icon" style="background: rgba(0,66,37,0.1); color: var(--racing-green);">
<i class="{{ event.icon }}"></i>
</div>
<div class="action-text">
<div class="action-title">{{ event.title }}</div>
{% if event.description %}
<div class="action-desc">{{ event.description }}</div>
{% endif %}
</div>
<span class="badge bg-{{ event.color }}">{{ event.date }}</span>
</div>
{% empty %}
<div class="text-center py-4 text-muted">
<i class="fas fa-calendar-check fa-2x mb-2 d-block"></i>
Keine Termine in den naechsten 14 Tagen.
</div>
{% endfor %}
</div>
</div>
</div>
</div>
{% endblock %}
<!-- Right Column: Quick Actions & Info -->
<div class="col-lg-4">
{% block extra_css %}
<style>
.mini-calendar {
font-size: 0.8rem;
}
.mini-calendar .calendar-day {
width: 32px;
height: 32px;
display: inline-flex;
align-items: center;
justify-content: center;
margin: 1px;
border-radius: 4px;
cursor: pointer;
}
.mini-calendar .calendar-day.today {
background-color: var(--bs-primary);
color: white;
font-weight: bold;
}
.mini-calendar .calendar-day.has-events {
background-color: var(--bs-success);
color: white;
}
.mini-calendar .calendar-day:hover {
background-color: var(--bs-light);
}
.mini-calendar .calendar-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 10px;
}
.mini-calendar .calendar-grid {
display: grid;
grid-template-columns: repeat(7, 1fr);
gap: 1px;
text-align: center;
}
.mini-calendar .calendar-weekday {
font-weight: bold;
color: var(--bs-secondary);
padding: 4px;
font-size: 0.7rem;
}
</style>
{% endblock %}
<!-- Quick Actions -->
<div class="card mb-3">
<div class="card-header bg-success text-white">
<i class="fas fa-bolt me-2"></i>Schnellzugriff
</div>
<div class="card-body">
<div class="d-grid gap-2">
<a href="{% url 'stiftung:destinataer_create' %}" class="btn btn-outline-primary btn-sm">
<i class="fas fa-user-plus me-1"></i>Neuer Destinataer
</a>
<a href="{% url 'stiftung:unterstuetzung_create' %}" class="btn btn-outline-primary btn-sm">
<i class="fas fa-euro-sign me-1"></i>Neue Zahlung
</a>
<a href="{% url 'stiftung:kalender_create' %}" class="btn btn-outline-primary btn-sm">
<i class="fas fa-calendar-plus me-1"></i>Neuer Termin
</a>
<a href="{% url 'stiftung:foerderung_create' %}" class="btn btn-outline-primary btn-sm">
<i class="fas fa-gift me-1"></i>Neue Foerderung
</a>
</div>
</div>
</div>
{% block extra_js %}
<script>
document.addEventListener('DOMContentLoaded', function() {
// Generate mini calendar for current month
const today = new Date();
const currentMonth = today.getMonth();
const currentYear = today.getFullYear();
function generateMiniCalendar(year, month) {
const firstDay = new Date(year, month, 1);
const lastDay = new Date(year, month + 1, 0);
const startDate = new Date(firstDay);
startDate.setDate(startDate.getDate() - firstDay.getDay()); // Start from Sunday
const calendarEl = document.getElementById('mini-calendar');
let html = '<div class="calendar-grid">';
// Weekday headers
const weekdays = ['So', 'Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa'];
weekdays.forEach(day => {
html += `<div class="calendar-weekday">${day}</div>`;
});
// Generate calendar days
const currentDate = new Date(startDate);
for (let i = 0; i < 42; i++) { // 6 weeks
const isCurrentMonth = currentDate.getMonth() === month;
const isToday = currentDate.toDateString() === today.toDateString();
let classes = ['calendar-day'];
if (isToday) classes.push('today');
if (!isCurrentMonth) classes.push('text-muted');
html += `<div class="${classes.join(' ')}">${currentDate.getDate()}</div>`;
currentDate.setDate(currentDate.getDate() + 1);
if (currentDate > lastDay && currentDate.getDay() === 0) {
break; // Stop at end of month on Sunday
}
}
html += '</div>';
calendarEl.innerHTML = html;
}
// Generate the current month calendar
generateMiniCalendar(currentYear, currentMonth);
});
</script>
{% if new_emails %}
<!-- New Emails -->
<div class="card mb-3">
<div class="card-header">
<i class="fas fa-envelope me-2 text-warning"></i>Neue E-Mails
<span class="badge bg-warning float-end">{{ new_email_count }}</span>
</div>
<div class="card-body p-0">
<div class="action-list">
{% for email in new_emails %}
<div class="action-item">
<div class="action-icon" style="background: rgba(253,126,20,0.15); color: var(--orange-accent);">
<i class="fas fa-envelope"></i>
</div>
<div class="action-text">
<div class="action-title">{{ email.absender_name|default:email.absender_email|truncatechars:30 }}</div>
<div class="action-desc">{{ email.betreff|truncatechars:40 }}</div>
</div>
</div>
{% endfor %}
</div>
</div>
</div>
{% endif %}
<style>
/* Mini Calendar Styles */
.mini-calendar .calendar-day {
width: 32px;
height: 32px;
display: flex;
align-items: center;
justify-content: center;
font-size: 0.8rem;
border-radius: 3px;
cursor: pointer;
transition: background-color 0.2s;
}
{% if expiring_leases %}
<!-- Expiring Leases -->
<div class="card mb-3">
<div class="card-header">
<i class="fas fa-handshake me-2 text-info"></i>Auslaufende Pachtvertraege
</div>
<div class="card-body p-0">
<div class="action-list">
{% for lease in expiring_leases %}
<a class="action-item" href="{% url 'stiftung:verpachtung_detail' lease.pk %}">
<div class="action-icon" style="background: rgba(108,117,125,0.15); color: var(--grey-medium);">
<i class="fas fa-file-contract"></i>
</div>
<div class="action-text">
<div class="action-title">{{ lease.paechter.vorname }} {{ lease.paechter.nachname }}</div>
<div class="action-desc">{{ lease.land.bezeichnung|truncatechars:30 }}</div>
</div>
<span class="badge bg-info">{{ lease.pachtende|date:"d.m.Y" }}</span>
</a>
{% endfor %}
</div>
</div>
</div>
{% endif %}
.mini-calendar .calendar-day:hover {
background-color: var(--bs-primary-bg-subtle);
}
<!-- Stats Overview -->
<div class="card mb-3">
<div class="card-header">
<i class="fas fa-chart-pie me-2 text-primary"></i>Uebersicht
</div>
<div class="card-body">
<div class="d-flex justify-content-between mb-2">
<span class="text-muted" style="font-size: 0.8rem;">Destinataere</span>
<strong>{{ destinataer_count }}</strong>
</div>
<div class="d-flex justify-content-between mb-2">
<span class="text-muted" style="font-size: 0.8rem;">Paechter</span>
<strong>{{ paechter_count }}</strong>
</div>
<div class="d-flex justify-content-between mb-2">
<span class="text-muted" style="font-size: 0.8rem;">Laendereien</span>
<strong>{{ land_count }}</strong>
</div>
<div class="d-flex justify-content-between">
<span class="text-muted" style="font-size: 0.8rem;">Aktive Foerderungen</span>
<strong>{{ foerderung_active }}</strong>
</div>
</div>
</div>
.mini-calendar .calendar-day.today {
background-color: var(--bs-primary);
color: white;
font-weight: bold;
}
.mini-calendar .calendar-header {
font-weight: 600;
color: var(--bs-secondary);
font-size: 0.75rem;
}
/* Calendar event indicators */
.calendar-events-indicator {
position: absolute;
bottom: 2px;
right: 2px;
width: 6px;
height: 6px;
background-color: var(--bs-warning);
border-radius: 50%;
}
/* Overdue events styling */
.alert-danger .border-bottom:last-child {
border-bottom: none !important;
}
/* Calendar card hover effects */
.card.border-left-primary { border-left-color: var(--bs-primary) !important; }
.card.border-left-success { border-left-color: var(--bs-success) !important; }
.card.border-left-warning { border-left-color: var(--bs-warning) !important; }
.card.border-left-danger { border-left-color: var(--bs-danger) !important; }
.card.border-left-info { border-left-color: var(--bs-info) !important; }
</style>
{% endblock %}
{% if recent_audit %}
<!-- Recent Activity -->
<div class="card">
<div class="card-header">
<i class="fas fa-history me-2"></i>Letzte Aktivitaeten
</div>
<div class="card-body p-0">
<div class="action-list">
{% for entry in recent_audit %}
<div class="action-item" style="padding: 0.4rem 0.75rem;">
<div class="action-text">
<div class="action-title">{{ entry.get_action_display }} - {{ entry.get_entity_type_display }}</div>
<div class="action-desc">{{ entry.username }} &middot; {{ entry.timestamp|timesince }} her</div>
</div>
</div>
{% endfor %}
</div>
</div>
</div>
{% endif %}
</div>
</div>
{% endblock %}

View File

@@ -4,12 +4,13 @@
<meta charset="utf-8">
<title>Stiftung Jahresbericht {{ jahr }}</title>
<style>
body {
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
body {
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
line-height: 1.6;
margin: 0;
padding: 20px;
color: #333;
font-size: 14px;
}
.header {
text-align: center;
@@ -17,129 +18,200 @@
padding-bottom: 20px;
margin-bottom: 30px;
}
.header h1 {
color: #2c3e50;
margin: 0;
font-size: 2.5em;
}
.header .subtitle {
color: #7f8c8d;
font-size: 1.2em;
margin-top: 10px;
}
.section {
margin-bottom: 30px;
page-break-inside: avoid;
}
.header h1 { color: #2c3e50; margin: 0; font-size: 2.2em; }
.header .subtitle { color: #7f8c8d; font-size: 1.1em; margin-top: 8px; }
.section { margin-bottom: 30px; page-break-inside: avoid; }
.section h2 {
color: #34495e;
border-bottom: 2px solid #3498db;
padding-bottom: 10px;
margin-bottom: 20px;
color: #1a4a2e;
border-bottom: 2px solid #2c7a4b;
padding-bottom: 8px;
margin-bottom: 16px;
font-size: 1.2em;
}
.section h3 { color: #34495e; font-size: 1em; margin-top: 16px; margin-bottom: 8px; }
.stats-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 20px;
grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
gap: 16px;
margin-bottom: 20px;
}
.stat-card {
background: #f8f9fa;
border: 1px solid #dee2e6;
border-radius: 8px;
padding: 20px;
padding: 16px;
text-align: center;
}
.stat-card .value {
font-size: 2em;
font-weight: bold;
color: #2c3e50;
}
.stat-card .label {
color: #7f8c8d;
margin-top: 5px;
}
table {
border-collapse: collapse;
width: 100%;
.stat-card .value { font-size: 1.6em; font-weight: bold; color: #1a4a2e; }
.stat-card .label { color: #7f8c8d; margin-top: 4px; font-size: 0.85em; }
.bilanz-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 16px;
margin-bottom: 20px;
}
th, td {
border: 1px solid #dee2e6;
padding: 12px;
text-align: left;
.bilanz-card {
border-radius: 8px;
padding: 16px;
text-align: center;
}
th {
background-color: #f8f9fa;
font-weight: 600;
color: #2c3e50;
}
tr:nth-child(even) {
background-color: #f8f9fa;
}
.amount {
text-align: right;
font-family: 'Courier New', monospace;
}
.status-badge {
padding: 4px 8px;
border-radius: 4px;
.bilanz-card.einnahmen { background: #d4edda; border: 1px solid #c3e6cb; }
.bilanz-card.ausgaben { background: #f8d7da; border: 1px solid #f5c6cb; }
.bilanz-card.netto-positiv { background: #d1ecf1; border: 1px solid #bee5eb; }
.bilanz-card.netto-negativ { background: #fff3cd; border: 1px solid #ffeeba; }
.bilanz-card .value { font-size: 1.5em; font-weight: bold; }
.bilanz-card .label { font-size: 0.85em; margin-top: 4px; color: #555; }
table {
border-collapse: collapse;
width: 100%;
margin-bottom: 16px;
font-size: 0.9em;
}
th, td { border: 1px solid #dee2e6; padding: 8px 10px; text-align: left; }
th { background-color: #f0f7f4; font-weight: 600; color: #1a4a2e; }
tr:nth-child(even) { background-color: #f8f9fa; }
.amount { text-align: right; font-family: 'Courier New', monospace; }
.status-badge {
padding: 3px 7px;
border-radius: 4px;
font-size: 0.8em;
font-weight: 500;
}
.status-beantragt { background-color: #fff3cd; color: #856404; }
.status-genehmigt { background-color: #d1ecf1; color: #0c5460; }
.status-ausgezahlt { background-color: #d4edda; color: #155724; }
.status-abgelehnt { background-color: #f8d7da; color: #721c24; }
.status-storniert { background-color: #e2e3e5; color: #383d41; }
.status-ausgezahlt, .status-abgeschlossen { background-color: #d4edda; color: #155724; }
.status-abgelehnt, .status-storniert { background-color: #f8d7da; color: #721c24; }
.status-geplant, .status-faellig { background-color: #e2e3e5; color: #383d41; }
.footer {
margin-top: 40px;
padding-top: 20px;
padding-top: 16px;
border-top: 1px solid #dee2e6;
text-align: center;
color: #7f8c8d;
font-size: 0.9em;
font-size: 0.85em;
}
@media print {
body { margin: 0; padding: 15px; }
body { margin: 0; padding: 10px; }
.section { page-break-inside: avoid; }
.no-print { display: none; }
}
.print-btn {
display: inline-block;
padding: 8px 20px;
background: #1a4a2e;
color: white;
border: none;
border-radius: 6px;
cursor: pointer;
font-size: 0.9em;
text-decoration: none;
margin-right: 8px;
}
</style>
</head>
<body>
<div class="header">
<h1>Stiftung Jahresbericht {{ jahr }}</h1>
<div class="subtitle">Jahresübersicht über Förderungen und Verpachtungen</div>
<div class="subtitle">Erstellt am {{ "now"|date:"d.m.Y" }}</div>
<!-- Aktionsleiste (nur Bildschirm, nicht Druck) -->
<div class="no-print" style="margin-bottom: 20px; display: flex; align-items: center; gap: 12px;">
<a href="{% url 'stiftung:bericht_list' %}" style="color: #1a4a2e;">&#8592; Berichte</a>
<span style="color: #dee2e6;">|</span>
<a href="{% url 'stiftung:jahresbericht_pdf' jahr=jahr %}" class="print-btn">
PDF herunterladen
</a>
<button onclick="window.print()" class="print-btn" style="background: #34495e;">
Drucken
</button>
</div>
<!-- Executive Summary -->
<!-- Kopfzeile -->
<div class="header">
<h1>Jahresbericht {{ jahr }}</h1>
<div class="subtitle">van Hees-Theyssen-Vogel'sche Familienstiftung</div>
<div class="subtitle">Erstellt am {% now "d.m.Y" %}</div>
</div>
<!-- 1. Gesamtübersicht / Bilanz -->
<div class="section">
<h2>Zusammenfassung</h2>
<h2>1. Jahresbilanz {{ jahr }}</h2>
<div class="bilanz-grid">
<div class="bilanz-card einnahmen">
<div class="value">&#8364;{{ total_einnahmen|floatformat:2 }}</div>
<div class="label">Einnahmen (Pacht)</div>
</div>
<div class="bilanz-card ausgaben">
<div class="value">&#8364;{{ total_ausgaben|floatformat:2 }}</div>
<div class="label">Ausgaben gesamt</div>
</div>
<div class="bilanz-card {% if netto >= 0 %}netto-positiv{% else %}netto-negativ{% endif %}">
<div class="value">{% if netto >= 0 %}+{% endif %}&#8364;{{ netto|floatformat:2 }}</div>
<div class="label">Nettosaldo</div>
</div>
</div>
<div class="stats-grid">
<div class="stat-card">
<div class="value">€{{ total_foerderungen|floatformat:2 }}</div>
<div class="label">Gesamtförderungen</div>
<div class="value">&#8364;{{ total_ausgaben_foerderung|floatformat:2 }}</div>
<div class="label">Förderausgaben</div>
</div>
<div class="stat-card">
<div class="value">€{{ total_pachtzins|floatformat:2 }}</div>
<div class="label">Gesamtpachtzins</div>
<div class="value">&#8364;{{ total_verwaltungskosten|floatformat:2 }}</div>
<div class="label">Verwaltungskosten</div>
</div>
<div class="stat-card">
<div class="value">{{ foerderungen.count }}</div>
<div class="label">Förderungen</div>
<div class="value">&#8364;{{ pacht_vereinnahmt|floatformat:2 }}</div>
<div class="label">Pacht vereinnahmt</div>
</div>
<div class="stat-card">
<div class="value">{{ verpachtungen.count }}</div>
<div class="label">Aktive Verpachtungen</div>
<div class="value">&#8364;{{ grundsteuer_gesamt|floatformat:2 }}</div>
<div class="label">Grundsteuer</div>
</div>
</div>
</div>
<!-- Förderungen Section -->
<!-- 2. Unterstützungen (Zahlungs-Pipeline) -->
{% if unterstuetzungen %}
<div class="section">
<h2>2. Unterstützungszahlungen {{ jahr }}</h2>
<p style="color: #666; margin-bottom: 12px;">
{{ unterstuetzungen.count }} Unterstützung(en) geplant/ausgezahlt &middot;
{{ unterstuetzungen_ausgezahlt.count }} überwiesen (&#8364;{{ total_unterstuetzungen|floatformat:2 }})
</p>
<table>
<thead>
<tr>
<th>Destinatär</th>
<th>Betrag</th>
<th>Fällig am</th>
<th>Status</th>
<th>Verwendungszweck</th>
</tr>
</thead>
<tbody>
{% for u in unterstuetzungen %}
<tr>
<td>{{ u.destinataer.get_full_name }}</td>
<td class="amount">&#8364;{{ u.betrag|floatformat:2 }}</td>
<td>{{ u.faellig_am|date:"d.m.Y" }}</td>
<td>
<span class="status-badge status-{{ u.status }}">{{ u.get_status_display }}</span>
</td>
<td>{{ u.beschreibung|default:"-" }}</td>
</tr>
{% endfor %}
</tbody>
<tfoot>
<tr style="font-weight: bold; background: #f0f7f4;">
<td>Summe ausgezahlt</td>
<td class="amount">&#8364;{{ total_unterstuetzungen|floatformat:2 }}</td>
<td colspan="3"></td>
</tr>
</tfoot>
</table>
</div>
{% endif %}
<!-- 3. Förderungen (legacy Foerderung-Modell) -->
{% if foerderungen %}
<div class="section">
<h2>Förderungen im Jahr {{ jahr }}</h2>
<h2>3. Förderungen {{ jahr }}</h2>
<table>
<thead>
<tr>
@@ -151,77 +223,144 @@
</tr>
</thead>
<tbody>
{% for foerderung in foerderungen %}
{% for f in foerderungen %}
<tr>
<td>{{ foerderung.person.get_full_name }}</td>
<td>{{ foerderung.get_kategorie_display }}</td>
<td class="amount">€{{ foerderung.betrag|floatformat:2 }}</td>
<td>
<span class="status-badge status-{{ foerderung.status }}">
{{ foerderung.get_status_display }}
</span>
{% if f.destinataer %}{{ f.destinataer.get_full_name }}
{% elif f.person %}{{ f.person.get_full_name }}
{% else %}{% endif %}
</td>
<td>{{ foerderung.antragsdatum|date:"d.m.Y" }}</td>
<td>{{ f.get_kategorie_display }}</td>
<td class="amount">&#8364;{{ f.betrag|floatformat:2 }}</td>
<td>
<span class="status-badge status-{{ f.status }}">{{ f.get_status_display }}</span>
</td>
<td>{{ f.antragsdatum|date:"d.m.Y" }}</td>
</tr>
{% endfor %}
</tbody>
<tfoot>
<tr style="font-weight: bold; background: #f0f7f4;">
<td colspan="2">Summe</td>
<td class="amount">&#8364;{{ total_foerderungen_legacy|floatformat:2 }}</td>
<td colspan="2"></td>
</tr>
</tfoot>
</table>
</div>
{% endif %}
<!-- Verpachtungen Section -->
{% if verpachtungen %}
<!-- 4. Grundstücksverwaltung -->
<div class="section">
<h2>Aktive Verpachtungen im Jahr {{ jahr }}</h2>
<h2>4. Grundstücksverwaltung</h2>
{% if verpachtungen %}
<h3>Aktive Verpachtungen</h3>
<table>
<thead>
<tr>
<th>Länderei</th>
<th>Pächter</th>
<th>Vertragsnummer</th>
<th>Verpachtete Fläche</th>
<th>Jährlicher Pachtzins</th>
<th>Jahrespachtzins</th>
<th>Pachtende</th>
</tr>
</thead>
<tbody>
{% for verpachtung in verpachtungen %}
{% for v in verpachtungen %}
<tr>
<td>{{ verpachtung.land }}</td>
<td>{{ verpachtung.paechter.get_full_name }}</td>
<td>{{ verpachtung.vertragsnummer }}</td>
<td>{{ verpachtung.verpachtete_flaeche|floatformat:2 }} qm</td>
<td class="amount">€{{ verpachtung.pachtzins_jaehrlich|floatformat:2 }}</td>
<td>{{ verpachtung.pachtende|date:"d.m.Y" }}</td>
<td>{{ v.land }}</td>
<td>{{ v.paechter.get_full_name }}</td>
<td class="amount">{{ v.verpachtete_flaeche|floatformat:0 }} qm</td>
<td class="amount">&#8364;{{ v.pachtzins_pauschal|floatformat:2 }}</td>
<td>{% if v.pachtende %}{{ v.pachtende|date:"d.m.Y" }}{% else %}unbefristet{% endif %}</td>
</tr>
{% endfor %}
</tbody>
<tfoot>
<tr style="font-weight: bold; background: #f0f7f4;">
<td colspan="3">Gesamtpachtzins (kalkuliert)</td>
<td class="amount">&#8364;{{ total_pachtzins|floatformat:2 }}</td>
<td></td>
</tr>
</tfoot>
</table>
{% endif %}
{% if landabrechnungen %}
<h3>Landabrechnungen {{ jahr }}</h3>
<table>
<thead>
<tr>
<th>Länderei</th>
<th>Pacht vereinnahmt</th>
<th>Umlagen</th>
<th>Grundsteuer</th>
<th>Sonstige Einnahmen</th>
</tr>
</thead>
<tbody>
{% for a in landabrechnungen %}
<tr>
<td>{{ a.land }}</td>
<td class="amount">&#8364;{{ a.pacht_vereinnahmt|floatformat:2 }}</td>
<td class="amount">&#8364;{{ a.umlagen_vereinnahmt|floatformat:2 }}</td>
<td class="amount">&#8364;{{ a.grundsteuer_betrag|floatformat:2 }}</td>
<td class="amount">&#8364;{{ a.sonstige_einnahmen|floatformat:2 }}</td>
</tr>
{% endfor %}
</tbody>
<tfoot>
<tr style="font-weight: bold; background: #f0f7f4;">
<td>Summe</td>
<td class="amount">&#8364;{{ pacht_vereinnahmt|floatformat:2 }}</td>
<td></td>
<td class="amount">&#8364;{{ grundsteuer_gesamt|floatformat:2 }}</td>
<td></td>
</tr>
</tfoot>
</table>
{% endif %}
{% if not verpachtungen and not landabrechnungen %}
<p style="color: #999;">Keine Verpachtungs- oder Abrechnungsdaten für {{ jahr }} vorhanden.</p>
{% endif %}
</div>
<!-- 5. Verwaltungskosten -->
{% if verwaltungskosten_nach_kategorie %}
<div class="section">
<h2>5. Verwaltungskosten {{ jahr }}</h2>
<table>
<thead>
<tr>
<th>Kategorie</th>
<th>Anzahl</th>
<th>Betrag</th>
</tr>
</thead>
<tbody>
{% for k in verwaltungskosten_nach_kategorie %}
<tr>
<td>{{ k.kategorie|capfirst }}</td>
<td>{{ k.anzahl }}</td>
<td class="amount">&#8364;{{ k.summe|floatformat:2 }}</td>
</tr>
{% endfor %}
</tbody>
<tfoot>
<tr style="font-weight: bold; background: #f0f7f4;">
<td colspan="2">Gesamt</td>
<td class="amount">&#8364;{{ total_verwaltungskosten|floatformat:2 }}</td>
</tr>
</tfoot>
</table>
</div>
{% endif %}
<!-- Financial Summary -->
<div class="section">
<h2>Finanzielle Übersicht</h2>
<div class="stats-grid">
<div class="stat-card">
<div class="value">€{{ total_foerderungen|floatformat:2 }}</div>
<div class="label">Ausgaben (Förderungen)</div>
</div>
<div class="stat-card">
<div class="value">€{{ total_pachtzins|floatformat:2 }}</div>
<div class="label">Einnahmen (Pachtzins)</div>
</div>
<div class="stat-card">
<div class="value">€{{ total_pachtzins|add:total_foerderungen|floatformat:2 }}</div>
<div class="label">Netto-Position</div>
</div>
</div>
</div>
<div class="footer">
<p>Dieser Bericht wurde automatisch generiert von der Stiftungsverwaltung.</p>
<p>Bei Fragen wenden Sie sich bitte an die Verwaltung.</p>
<p>Jahresbericht {{ jahr }} &mdash; automatisch generiert von der Stiftungsverwaltung</p>
<p>van Hees-Theyssen-Vogel'sche Familienstiftung &middot; Vertraulich</p>
</div>
</body>
</html>
</html>

View File

@@ -303,11 +303,8 @@
{% endif %}
<div class="d-grid gap-2">
<a href="{% url 'stiftung:dokument_management' %}" class="btn btn-outline-primary btn-sm">
<i class="fas fa-link me-2"></i>Dokumente verknüpfen
</a>
<a href="mailto:paperless@vhtv-stiftung.de?subject=Dokumente für {{ land }}" class="btn btn-outline-info btn-sm">
<i class="fas fa-envelope me-2"></i>E-Mail an Paperless
<a href="{% url 'stiftung:dms_list' %}" class="btn btn-outline-primary btn-sm">
<i class="fas fa-folder-open me-2"></i>Zum DMS
</a>
</div>
</div>

View File

@@ -234,14 +234,11 @@
</div>
<div class="card-body">
<p class="text-muted mb-3">
Dokumente werden über Paperless verwaltet und verknüpft.
Dokumente werden im DMS verwaltet.
</p>
<div class="d-grid gap-2">
<a href="{% url 'stiftung:dokument_management' %}" class="btn btn-outline-primary btn-sm">
<i class="fas fa-link me-2"></i>Dokumente verknüpfen
</a>
<a href="mailto:paperless@vhtv-stiftung.de" class="btn btn-outline-info btn-sm">
<i class="fas fa-envelope me-2"></i>E-Mail an Paperless
<a href="{% url 'stiftung:dms_list' %}" class="btn btn-outline-primary btn-sm">
<i class="fas fa-folder-open me-2"></i>Zum DMS
</a>
</div>

View File

@@ -479,14 +479,14 @@
</div>
{% endif %}
<!-- Verknüpfte Dokumente -->
<!-- Dokumente (DMS) -->
<div class="card shadow mb-4">
<div class="card-header py-3 d-flex justify-content-between align-items-center">
<h6 class="m-0 font-weight-bold text-success">
<i class="fas fa-file-alt me-2"></i>Verknüpfte Dokumente
<i class="fas fa-file-alt me-2"></i>Dokumente
</h6>
<a href="{% url 'stiftung:dokument_management' %}" class="btn btn-sm btn-success">
<i class="fas fa-plus me-2"></i>Dokument verknüpfen
<a href="{% url 'stiftung:dms_upload' %}?land={{ land.pk }}" class="btn btn-sm btn-success">
<i class="fas fa-upload me-2"></i>Dokument hochladen
</a>
</div>
<div class="card-body">
@@ -506,8 +506,7 @@
<tr>
<td>
<strong>{{ dokument.titel }}</strong>
<br>
<small class="text-muted">ID: {{ dokument.paperless_document_id }}</small>
{% if dokument.dateiname_original %}<br><small class="text-muted">{{ dokument.dateiname_original }} ({{ dokument.get_human_size }})</small>{% endif %}
</td>
<td>
<span class="badge bg-secondary">{{ dokument.get_kontext_display }}</span>
@@ -521,17 +520,14 @@
</td>
<td>
<div class="btn-group" role="group">
<a href="{{ dokument.get_paperless_url }}" target="_blank" class="btn btn-sm btn-outline-primary" title="In Paperless öffnen">
<i class="fas fa-external-link-alt"></i>
<a href="{% url 'stiftung:dms_download' dokument.pk %}" class="btn btn-sm btn-outline-primary" title="Herunterladen">
<i class="fas fa-download"></i>
</a>
<a href="{{ dokument.get_paperless_thumbnail_url }}" target="_blank" class="btn btn-sm btn-outline-info" title="Thumbnail anzeigen">
<i class="fas fa-image"></i>
</a>
<a href="{% url 'stiftung:dokument_update' dokument.pk %}" class="btn btn-sm btn-outline-warning" title="Bearbeiten">
<a href="{% url 'stiftung:dms_edit' dokument.pk %}" class="btn btn-sm btn-outline-warning" title="Bearbeiten">
<i class="fas fa-edit"></i>
</a>
<a href="{% url 'stiftung:dokument_delete' dokument.pk %}" class="btn btn-sm btn-outline-danger" title="Verknüpfung löschen">
<i class="fas fa-unlink"></i>
<a href="{% url 'stiftung:dms_delete' dokument.pk %}" class="btn btn-sm btn-outline-danger" title="Löschen">
<i class="fas fa-trash"></i>
</a>
</div>
</td>
@@ -543,10 +539,10 @@
{% else %}
<div class="text-center py-4">
<i class="fas fa-file-alt fa-3x text-muted mb-3"></i>
<h5 class="text-muted">Keine Dokumente verknüpft</h5>
<p class="text-muted">Verknüpfen Sie Dokumente aus Paperless mit dieser Länderei.</p>
<a href="{% url 'stiftung:dokument_management' %}" class="btn btn-success">
<i class="fas fa-plus me-2"></i>Erstes Dokument verknüpfen
<h5 class="text-muted">Keine Dokumente vorhanden</h5>
<p class="text-muted">Laden Sie Dokumente direkt hoch und verknüpfen Sie sie mit dieser Länderei.</p>
<a href="{% url 'stiftung:dms_upload' %}?land={{ land.pk }}" class="btn btn-success">
<i class="fas fa-upload me-2"></i>Erstes Dokument hochladen
</a>
</div>
{% endif %}

View File

@@ -188,12 +188,12 @@
</div>
</div>
<!-- Verknüpfte Dokumente -->
<!-- Dokumente (DMS) -->
<div class="card mb-4">
<div class="card-header d-flex justify-content-between align-items-center">
<h5 class="mb-0"><i class="fas fa-folder-open me-2"></i>Verknüpfte Dokumente</h5>
<a href="/dokumente/verwaltung/" class="btn btn-sm btn-outline-primary">
<i class="fas fa-link me-1"></i>Dokument verknüpfen
<h5 class="mb-0"><i class="fas fa-folder-open me-2"></i>Dokumente</h5>
<a href="{% url 'stiftung:dms_upload' %}?verpachtung={{ landverpachtung.pk }}" class="btn btn-sm btn-outline-primary">
<i class="fas fa-upload me-1"></i>Dokument hochladen
</a>
</div>
<div class="card-body">
@@ -212,14 +212,21 @@
<tr>
<td>
<strong>{{ doc.titel|default:"Ohne Titel" }}</strong>
<br>
<small class="text-muted">Paperless-ID: {{ doc.paperless_document_id }}</small>
{% if doc.dateiname_original %}<br><small class="text-muted">{{ doc.dateiname_original }} ({{ doc.get_human_size }})</small>{% endif %}
</td>
<td>{{ doc.get_kontext_display }}</td>
<td>
<a href="{{ doc.get_paperless_url }}" target="_blank" class="btn btn-sm btn-outline-primary" title="In Paperless öffnen">
<i class="fas fa-external-link-alt"></i>
</a>
<div class="btn-group" role="group">
<a href="{% url 'stiftung:dms_download' doc.pk %}" class="btn btn-sm btn-outline-primary" title="Herunterladen">
<i class="fas fa-download"></i>
</a>
<a href="{% url 'stiftung:dms_edit' doc.pk %}" class="btn btn-sm btn-outline-warning" title="Bearbeiten">
<i class="fas fa-edit"></i>
</a>
<a href="{% url 'stiftung:dms_delete' doc.pk %}" class="btn btn-sm btn-outline-danger" title="Löschen">
<i class="fas fa-trash"></i>
</a>
</div>
</td>
</tr>
{% endfor %}
@@ -227,7 +234,7 @@
</table>
</div>
{% else %}
<p class="text-muted">Keine Dokumente verknüpft.</p>
<p class="text-muted">Keine Dokumente vorhanden.</p>
{% endif %}
</div>
</div>

View File

@@ -0,0 +1,170 @@
{% extends 'base.html' %}
{% load static help_tags %}
{% block title %}Nachweis-Board Stiftungsverwaltung{% endblock %}
{% block content %}
<div class="row">
<div class="col-12">
<!-- Header -->
<div class="d-flex justify-content-between align-items-center mb-4">
<h1 class="h3">
<i class="fas fa-th text-primary me-2"></i>
Nachweis-Board {{ jahr_filter }}
</h1>
<div class="d-flex gap-2">
{% if overdue_count > 0 %}
<form method="post" action="{% url 'stiftung:batch_erinnerung_senden' %}">
{% csrf_token %}
<input type="hidden" name="jahr" value="{{ jahr_filter }}">
<button type="submit" class="btn btn-danger" onclick="return confirm('{{ overdue_count }} säumige Destinatäre markieren?')">
<i class="fas fa-bell me-2"></i>{{ overdue_count }} Erinnerung(en)
</button>
</form>
{% endif %}
</div>
</div>
<!-- Filter Bar -->
<div class="card shadow mb-4">
<div class="card-body py-2">
<form method="get" class="d-flex gap-3 flex-wrap align-items-center">
<div class="d-flex align-items-center gap-2">
<label class="text-muted small fw-bold mb-0">Jahr:</label>
<select name="jahr" class="form-select form-select-sm" style="width: auto;" onchange="this.form.submit()">
{% for j in verfuegbare_jahre %}
<option value="{{ j }}" {% if j == jahr_filter %}selected{% endif %}>{{ j }}</option>
{% endfor %}
</select>
</div>
<div class="d-flex align-items-center gap-2">
<label class="text-muted small fw-bold mb-0">Status:</label>
<select name="status" class="form-select form-select-sm" style="width: auto;" onchange="this.form.submit()">
<option value="">Alle</option>
{% for code, label in status_choices %}
<option value="{{ code }}" {% if code == status_filter %}selected{% endif %}>{{ label }}</option>
{% endfor %}
</select>
</div>
<span class="ms-auto text-muted small">{{ board|length }} Destinatäre</span>
</form>
</div>
</div>
<!-- Semester-Hinweis -->
<div class="alert alert-info py-2 mb-4 small">
<i class="fas fa-info-circle me-2"></i>
<strong>Semester-Logik:</strong>
Studiennachweis-Frist Q1/Q2 → 15. März · Q3/Q4 → 15. September.
Zahlungsfrist Q1 → 15. Dez (Vorjahr) · Q2 → 15. Mär · Q3 → 15. Jun · Q4 → 15. Sep.
</div>
<!-- Board Table -->
{% if board %}
<div class="card shadow">
<div class="card-body p-0">
<div class="table-responsive">
<table class="table table-hover table-sm mb-0">
<thead class="table-dark">
<tr>
<th style="min-width:180px">Destinatär</th>
<th class="text-center">Q1 (JanMär)</th>
<th class="text-center">Q2 (AprJun)</th>
<th class="text-center">Q3 (JulSep)</th>
<th class="text-center">Q4 (OktDez)</th>
<th class="text-center">Gesamt</th>
</tr>
</thead>
<tbody>
{% for row in board %}
<tr>
<td class="align-middle">
<a href="{% url 'stiftung:destinataer_detail' pk=row.destinataer.pk %}" class="text-decoration-none fw-semibold">
{{ row.destinataer.get_full_name }}
</a>
<div class="small text-muted">
<a href="{% url 'stiftung:destinataer_timeline' pk=row.destinataer.pk %}" class="text-muted">
<i class="fas fa-stream"></i> Timeline
</a>
</div>
</td>
{% for q in "1234" %}
{% with nachweis=row.quartale|get_item:q|default:None %}
<td class="text-center align-middle">
{% if nachweis %}
{% if nachweis.status == 'geprueft' or nachweis.status == 'auto_geprueft' %}
<span class="badge bg-success" title="{{ nachweis.get_status_display }}">
<i class="fas fa-check"></i>
</span>
{% elif nachweis.status == 'eingereicht' %}
<span class="badge bg-info" title="{{ nachweis.get_status_display }}">
<i class="fas fa-paper-plane"></i>
</span>
{% elif nachweis.status == 'teilweise' %}
<span class="badge bg-warning text-dark" title="{{ nachweis.get_status_display }}">
<i class="fas fa-circle-half-stroke"></i>
</span>
{% elif nachweis.status == 'nachbesserung' %}
<span class="badge bg-orange" title="{{ nachweis.get_status_display }}" style="background:#fd7e14">
<i class="fas fa-redo"></i>
</span>
{% elif nachweis.status == 'abgelehnt' %}
<span class="badge bg-danger" title="{{ nachweis.get_status_display }}">
<i class="fas fa-times"></i>
</span>
{% elif nachweis.is_overdue %}
<span class="badge bg-danger" title="Überfällig">
<i class="fas fa-exclamation-triangle"></i>
</span>
{% else %}
<span class="badge bg-secondary" title="{{ nachweis.get_status_display }}">
<i class="fas fa-clock"></i>
</span>
{% endif %}
<div class="small mt-1">
<a href="{% url 'stiftung:quarterly_confirmation_edit' pk=nachweis.pk %}" class="text-muted">
{{ nachweis.get_completion_percentage }}%
</a>
</div>
{% else %}
<span class="text-muted small"></span>
{% endif %}
</td>
{% endwith %}
{% endfor %}
<td class="text-center align-middle">
{% with total=0 done=0 %}
{% for q, nachweis in row.quartale.items %}
{% if nachweis %}
{% if nachweis.status == 'geprueft' or nachweis.status == 'auto_geprueft' %}
<i class="fas fa-check-circle text-success"></i>
{% endif %}
{% endif %}
{% endfor %}
{% endwith %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
<!-- Legend -->
<div class="mt-3 d-flex flex-wrap gap-3 small text-muted">
<span><span class="badge bg-success"><i class="fas fa-check"></i></span> Geprüft</span>
<span><span class="badge bg-info"><i class="fas fa-paper-plane"></i></span> Eingereicht</span>
<span><span class="badge bg-warning text-dark"><i class="fas fa-circle-half-stroke"></i></span> Teilweise</span>
<span><span class="badge bg-danger"><i class="fas fa-exclamation-triangle"></i></span> Überfällig</span>
<span><span class="badge bg-secondary"><i class="fas fa-clock"></i></span> Offen</span>
</div>
{% else %}
<div class="alert alert-info">
<i class="fas fa-info-circle me-2"></i>
Keine aktiven Destinatäre gefunden.
</div>
{% endif %}
</div>
</div>
{% endblock %}

View File

@@ -279,14 +279,14 @@
<!-- Legacy Verpachtungen entfernt für saubere UI -->
<!-- Verknüpfte Dokumente -->
<!-- Dokumente (DMS) -->
<div class="card shadow mb-4">
<div class="card-header bg-success text-white d-flex justify-content-between align-items-center">
<h5 class="card-title mb-0">
<i class="fas fa-file-alt me-2"></i>Verknüpfte Dokumente
<i class="fas fa-file-alt me-2"></i>Dokumente
</h5>
<a href="{% url 'stiftung:dokument_management' %}" class="btn btn-light btn-sm">
<i class="fas fa-plus me-1"></i>Dokument verknüpfen
<a href="{% url 'stiftung:dms_upload' %}?paechter={{ paechter.pk }}" class="btn btn-light btn-sm">
<i class="fas fa-upload me-1"></i>Dokument hochladen
</a>
</div>
<div class="card-body">
@@ -306,8 +306,7 @@
<tr>
<td>
<strong>{{ dokument.titel }}</strong>
<br>
<small class="text-muted">ID: {{ dokument.paperless_document_id }}</small>
{% if dokument.dateiname_original %}<br><small class="text-muted">{{ dokument.dateiname_original }} ({{ dokument.get_human_size }})</small>{% endif %}
</td>
<td>
<span class="badge bg-secondary">{{ dokument.get_kontext_display }}</span>
@@ -321,17 +320,14 @@
</td>
<td>
<div class="btn-group" role="group">
<a href="{{ dokument.get_paperless_url }}" target="_blank" class="btn btn-sm btn-outline-primary" title="In Paperless öffnen">
<i class="fas fa-external-link-alt"></i>
<a href="{% url 'stiftung:dms_download' dokument.pk %}" class="btn btn-sm btn-outline-primary" title="Herunterladen">
<i class="fas fa-download"></i>
</a>
<a href="{{ dokument.get_paperless_thumbnail_url }}" target="_blank" class="btn btn-sm btn-outline-info" title="Thumbnail anzeigen">
<i class="fas fa-image"></i>
</a>
<a href="{% url 'stiftung:dokument_update' dokument.pk %}" class="btn btn-sm btn-outline-warning" title="Bearbeiten">
<a href="{% url 'stiftung:dms_edit' dokument.pk %}" class="btn btn-sm btn-outline-warning" title="Bearbeiten">
<i class="fas fa-edit"></i>
</a>
<a href="{% url 'stiftung:dokument_delete' dokument.pk %}" class="btn btn-sm btn-outline-danger" title="Verknüpfung löschen">
<i class="fas fa-unlink"></i>
<a href="{% url 'stiftung:dms_delete' dokument.pk %}" class="btn btn-sm btn-outline-danger" title="Löschen">
<i class="fas fa-trash"></i>
</a>
</div>
</td>
@@ -343,10 +339,10 @@
{% else %}
<div class="text-center py-4">
<i class="fas fa-file-alt fa-3x text-muted mb-3"></i>
<h5 class="text-muted">Keine Dokumente verknüpft</h5>
<p class="text-muted">Verknüpfen Sie Dokumente aus Paperless mit diesem Pächter.</p>
<a href="{% url 'stiftung:dokument_management' %}" class="btn btn-success">
<i class="fas fa-plus me-2"></i>Erstes Dokument verknüpfen
<h5 class="text-muted">Keine Dokumente vorhanden</h5>
<p class="text-muted">Laden Sie Dokumente direkt hoch und verknüpfen Sie sie mit diesem Pächter.</p>
<a href="{% url 'stiftung:dms_upload' %}?paechter={{ paechter.pk }}" class="btn btn-success">
<i class="fas fa-upload me-2"></i>Erstes Dokument hochladen
</a>
</div>
{% endif %}

View File

@@ -0,0 +1,227 @@
{% extends 'base.html' %}
{% load static %}
{% block title %}Pächter-Workflow Stiftungsverwaltung{% endblock %}
{% block content %}
<div class="row">
<div class="col-12">
<!-- Header -->
<div class="d-flex justify-content-between align-items-center mb-4">
<h1 class="h3">
<i class="fas fa-tractor text-primary me-2"></i>
Pächter-Workflow
</h1>
<div class="d-flex gap-2">
<a href="{% url 'stiftung:land_abrechnung_create' %}" class="btn btn-outline-primary">
<i class="fas fa-plus me-2"></i>Neue Abrechnung
</a>
<a href="{% url 'stiftung:paechter_list' %}" class="btn btn-outline-secondary">
<i class="fas fa-list me-2"></i>Alle Pächter
</a>
</div>
</div>
<!-- Pipeline: Vertragsfristen -->
<div class="card shadow mb-4">
<div class="card-header bg-dark text-white py-2">
<h5 class="mb-0 small fw-bold">
<i class="fas fa-calendar-alt me-2"></i>Pipeline: Vertragsfristen
</h5>
</div>
<div class="card-body p-0">
<div class="row g-0">
{% for stage in pipeline_stages %}
<div class="col-xl col-lg-4 col-md-6 border-end">
<div class="p-3">
<div class="d-flex justify-content-between align-items-center mb-3">
<h6 class="mb-0 text-{{ stage.farbe }} fw-bold small">
<i class="fas {{ stage.icon }} me-1"></i>{{ stage.label }}
</h6>
<span class="badge bg-{{ stage.farbe }} {% if stage.farbe == 'warning' %}text-dark{% else %}text-white{% endif %}">
{{ stage.count }}
</span>
</div>
{% if stage.verpachtungen %}
{% for v in stage.verpachtungen %}
<div class="card mb-2 border-start border-3 border-{{ stage.farbe }}" style="font-size:0.78rem;">
<div class="card-body py-2 px-2">
<div class="fw-semibold">
<a href="{% url 'stiftung:land_verpachtung_detail' pk=v.pk %}" class="text-decoration-none text-dark">
{{ v.land.lfd_nr }} {{ v.paechter.get_full_name }}
</a>
</div>
{% if v.pachtende %}
<div class="text-muted mt-1">
Ende: {{ v.pachtende|date:"d.m.Y" }}
{% if v.pachtende >= heute %}
<span class="ms-1">({{ v.pachtende|timeuntil:heute }})</span>
{% else %}
<span class="badge bg-danger ms-1">abgelaufen</span>
{% endif %}
</div>
{% endif %}
<div class="text-muted">
{% if v.verpachtete_flaeche %}{{ v.verpachtete_flaeche|floatformat:0 }} m²{% endif %}
{% if v.pachtzins_pauschal %} · €{{ v.pachtzins_pauschal|floatformat:2 }}/J{% endif %}
</div>
</div>
</div>
{% endfor %}
{% else %}
<div class="text-muted text-center small py-3">
<i class="fas fa-check-circle fa-lg d-block mb-1 opacity-25"></i>
Keine
</div>
{% endif %}
</div>
</div>
{% endfor %}
</div>
</div>
</div>
<div class="row g-4">
<!-- Ausstehende Jahresabrechnungen -->
<div class="col-lg-6">
<div class="card shadow h-100">
<div class="card-header bg-warning text-dark py-2">
<h5 class="mb-0 small fw-bold">
<i class="fas fa-file-invoice me-2"></i>
Ausstehende Jahresabrechnungen {{ letztes_jahr }}
</h5>
</div>
<div class="card-body p-0">
{% if laender_ohne_abrechnung %}
<div class="table-responsive">
<table class="table table-sm table-hover mb-0">
<thead class="table-light">
<tr>
<th>Lfd. Nr.</th>
<th>Gemeinde</th>
<th>Pächter</th>
<th></th>
</tr>
</thead>
<tbody>
{% for land in laender_ohne_abrechnung %}
<tr>
<td>{{ land.lfd_nr }}</td>
<td>{{ land.gemeinde|default:"" }}</td>
<td>{{ land.paechter_name|default:"" }}</td>
<td>
<a href="{% url 'stiftung:land_abrechnung_create' %}?land={{ land.pk }}&jahr={{ letztes_jahr }}" class="btn btn-xs btn-outline-primary" style="font-size:0.7rem;padding:2px 6px;">
<i class="fas fa-plus"></i>
</a>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<div class="text-muted text-center py-4">
<i class="fas fa-check-circle fa-2x mb-2 d-block text-success opacity-50"></i>
Alle Jahresabrechnungen {{ letztes_jahr }} vorhanden.
</div>
{% endif %}
</div>
</div>
</div>
<!-- Pachtanpassungen fällig (> 5 Jahre) -->
<div class="col-lg-6">
<div class="card shadow h-100">
<div class="card-header bg-info text-white py-2">
<h5 class="mb-0 small fw-bold">
<i class="fas fa-chart-line me-2"></i>
Pachtanpassung fällig (> 5 Jahre)
</h5>
</div>
<div class="card-body p-0">
{% if lang_laufend %}
<div class="table-responsive">
<table class="table table-sm table-hover mb-0">
<thead class="table-light">
<tr>
<th>Land</th>
<th>Pächter</th>
<th>Beginn</th>
<th>Dauer</th>
</tr>
</thead>
<tbody>
{% for v in lang_laufend %}
<tr>
<td>
<a href="{% url 'stiftung:land_verpachtung_detail' pk=v.pk %}">
{{ v.land.lfd_nr }}
</a>
</td>
<td>{{ v.paechter.get_full_name }}</td>
<td>{{ v.pachtbeginn|date:"d.m.Y" }}</td>
<td>
<span class="badge bg-warning text-dark">
{{ v.pachtbeginn|timesince:heute }}
</span>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<div class="text-muted text-center py-4">
<i class="fas fa-check-circle fa-2x mb-2 d-block text-success opacity-50"></i>
Keine Pachtanpassungen fällig.
</div>
{% endif %}
</div>
</div>
</div>
</div>
<!-- Top-Pächter -->
<div class="card shadow mt-4">
<div class="card-header bg-primary text-white py-2">
<h5 class="mb-0 small fw-bold">
<i class="fas fa-crown me-2"></i>Top-Pächter nach Fläche
</h5>
</div>
<div class="card-body p-0">
{% if top_paechter %}
<div class="table-responsive">
<table class="table table-sm table-hover mb-0">
<thead class="table-light">
<tr>
<th>#</th>
<th>Pächter</th>
<th>Verträge</th>
<th>Gesamtfläche (m²)</th>
</tr>
</thead>
<tbody>
{% for p in top_paechter %}
<tr>
<td class="text-muted">{{ forloop.counter }}</td>
<td>
<a href="{% url 'stiftung:paechter_detail' pk=p.pk %}">
{{ p.get_full_name }}
</a>
</td>
<td>{{ p.anzahl_vertraege }}</td>
<td>{{ p.flaeche|floatformat:0|default:"" }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<div class="text-muted text-center py-4">Keine Pächter gefunden.</div>
{% endif %}
</div>
</div>
</div>
</div>
{% endblock %}

View File

@@ -296,8 +296,8 @@
<span class="badge bg-primary ms-2">{{ verknuepfte_dokumente.count }}</span>
{% endif %}
</h6>
<a href="/dokumente/verwaltung/" class="btn btn-outline-primary btn-sm">
<i class="fas fa-link me-1"></i>Dokumentenverwaltung
<a href="{% url 'stiftung:dms_list' %}" class="btn btn-outline-primary btn-sm">
<i class="fas fa-folder-open me-1"></i>Dokumentenverwaltung
</a>
</div>
<div class="card-body">
@@ -318,10 +318,7 @@
</small>
</div>
<div class="d-flex gap-2">
<a href="/api/paperless/documents/{{ dokument.paperless_document_id }}/"
class="btn btn-outline-primary btn-sm" target="_blank" title="In Paperless öffnen">
<i class="fas fa-external-link-alt"></i>
</a>
<span class="badge bg-secondary">Legacy</span>
</div>
</div>
{% endfor %}
@@ -332,7 +329,7 @@
<small class="text-muted">
<i class="fas fa-info-circle me-1"></i>
Es werden nur die neuesten 10 Dokumente angezeigt.
<a href="/dokumente/verwaltung/" class="text-decoration-none">Alle Dokumente anzeigen</a>
<a href="{% url 'stiftung:dms_list' %}" class="text-decoration-none">Alle Dokumente anzeigen</a>
</small>
</div>
{% endif %}
@@ -342,7 +339,7 @@
<h5 class="text-muted">Keine Dokumente verknüpft</h5>
<p class="text-muted">
Verknüpfen Sie Dokumente über die
<a href="/dokumente/verwaltung/" class="text-decoration-none">Dokumentenverwaltung</a>.
<a href="{% url 'stiftung:dms_list' %}" class="text-decoration-none">Dokumentenverwaltung</a>.
</p>
</div>
{% endif %}

View File

@@ -0,0 +1,36 @@
{% extends 'base.html' %}
{% block title %}Veranstaltung löschen Stiftungsverwaltung{% endblock %}
{% block content %}
<div class="container-fluid py-4">
<div class="row justify-content-center">
<div class="col-lg-6">
<div class="card shadow-sm">
<div class="card-header bg-danger text-white">
<i class="fas fa-exclamation-triangle me-2"></i>Veranstaltung löschen
</div>
<div class="card-body">
<p>Möchten Sie die folgende Veranstaltung wirklich löschen?</p>
<div class="alert alert-warning">
<strong>{{ veranstaltung.titel }}</strong><br>
{{ veranstaltung.datum|date:"d.m.Y" }} {{ veranstaltung.ort }}<br>
<small class="text-muted">{{ veranstaltung.get_teilnehmer_count }} Teilnehmer werden ebenfalls entfernt.</small>
</div>
<form method="post">
{% csrf_token %}
<div class="d-flex justify-content-between">
<a href="{% url 'stiftung:veranstaltung_detail' veranstaltung.pk %}" class="btn btn-secondary">
<i class="fas fa-arrow-left me-2"></i>Abbrechen
</a>
<button type="submit" class="btn btn-danger">
<i class="fas fa-trash me-2"></i>Endgültig löschen
</button>
</div>
</form>
</div>
</div>
</div>
</div>
</div>
{% endblock %}

View File

@@ -20,7 +20,7 @@
</p>
</div>
<div class="d-flex gap-2">
<a href="{% url 'admin:stiftung_veranstaltung_change' veranstaltung.pk %}" class="btn btn-outline-secondary">
<a href="{% url 'stiftung:veranstaltung_update' veranstaltung.pk %}" class="btn btn-outline-secondary">
<i class="fas fa-edit me-1"></i>Bearbeiten
</a>
<a href="{% url 'stiftung:veranstaltung_serienbrief_pdf' veranstaltung.pk %}" class="btn btn-success">
@@ -125,11 +125,15 @@
class="btn btn-success w-100">
<i class="fas fa-file-pdf me-2"></i>Serienbrief-PDF (alle Teilnehmer)
</a>
<a href="{% url 'admin:stiftung_veranstaltungsteilnehmer_add' %}?veranstaltung={{ veranstaltung.pk }}"
<a href="{% url 'stiftung:veranstaltung_serienbrief_vorschau' veranstaltung.pk %}"
class="btn btn-outline-primary w-100" target="_blank">
<i class="fas fa-eye me-2"></i>Serienbrief-Vorschau
</a>
<a href="{% url 'stiftung:teilnehmer_create' veranstaltung.pk %}"
class="btn btn-outline-primary w-100">
<i class="fas fa-user-plus me-2"></i>Teilnehmer hinzufügen
</a>
<a href="{% url 'admin:stiftung_veranstaltung_change' veranstaltung.pk %}"
<a href="{% url 'stiftung:veranstaltung_update' veranstaltung.pk %}"
class="btn btn-outline-secondary w-100">
<i class="fas fa-edit me-2"></i>Veranstaltung bearbeiten
</a>
@@ -142,6 +146,9 @@
<div class="card shadow-sm mt-4">
<div class="card-header bg-dark text-white d-flex justify-content-between align-items-center">
<span><i class="fas fa-users me-2"></i>Teilnehmerliste ({{ teilnehmer.count }})</span>
<a href="{% url 'stiftung:teilnehmer_create' veranstaltung.pk %}" class="btn btn-sm btn-outline-light">
<i class="fas fa-user-plus me-1"></i>Hinzufügen
</a>
</div>
<div class="card-body p-0">
{% if teilnehmer %}
@@ -153,6 +160,7 @@
<th>E-Mail</th>
<th>RSVP</th>
<th>Bemerkungen</th>
<th>Aktionen</th>
</tr>
</thead>
<tbody>
@@ -179,6 +187,14 @@
{% endif %}
</td>
<td>{{ t.bemerkungen|default:"" }}</td>
<td>
<a href="{% url 'stiftung:teilnehmer_update' veranstaltung.pk t.pk %}" class="btn btn-sm btn-outline-secondary me-1" title="Bearbeiten">
<i class="fas fa-edit"></i>
</a>
<a href="{% url 'stiftung:teilnehmer_delete' veranstaltung.pk t.pk %}" class="btn btn-sm btn-outline-danger" title="Entfernen">
<i class="fas fa-trash"></i>
</a>
</td>
</tr>
{% endfor %}
</tbody>
@@ -187,7 +203,7 @@
<div class="p-4 text-center text-muted">
<i class="fas fa-users fa-2x mb-2"></i>
<p>Noch keine Teilnehmer eingetragen.</p>
<a href="{% url 'admin:stiftung_veranstaltungsteilnehmer_add' %}?veranstaltung={{ veranstaltung.pk }}"
<a href="{% url 'stiftung:teilnehmer_create' veranstaltung.pk %}"
class="btn btn-primary">
<i class="fas fa-user-plus me-1"></i>Ersten Teilnehmer hinzufügen
</a>

View File

@@ -0,0 +1,200 @@
{% extends 'base.html' %}
{% load static %}
{% block title %}{{ title }} - Stiftungsverwaltung{% endblock %}
{% block content %}
<div class="container-fluid py-4">
<div class="row">
<div class="col-12">
<div class="d-flex justify-content-between align-items-center mb-4">
<h1 class="h3">
<i class="fas fa-calendar-alt text-primary me-2"></i>
{{ title }}
</h1>
<a href="{% url 'stiftung:veranstaltung_list' %}" class="btn btn-outline-secondary">
<i class="fas fa-arrow-left me-2"></i>Zurück zur Liste
</a>
</div>
</div>
</div>
<form method="post" novalidate>
{% csrf_token %}
{% if form.non_field_errors %}
<div class="alert alert-danger">
<ul class="mb-0">
{% for error in form.non_field_errors %}
<li>{{ error }}</li>
{% endfor %}
</ul>
</div>
{% endif %}
<div class="row g-4">
<div class="col-lg-8">
<!-- Grunddaten -->
<div class="card shadow-sm mb-4">
<div class="card-header bg-dark text-white">
<i class="fas fa-info-circle me-2"></i>Grunddaten
</div>
<div class="card-body">
<div class="row">
<div class="col-12 mb-3">
<label for="{{ form.titel.id_for_label }}" class="form-label">{{ form.titel.label }} *</label>
{{ form.titel }}
{% if form.titel.errors %}<div class="invalid-feedback d-block">{{ form.titel.errors.0 }}</div>{% endif %}
</div>
<div class="col-md-4 mb-3">
<label for="{{ form.datum.id_for_label }}" class="form-label">{{ form.datum.label }} *</label>
{{ form.datum }}
{% if form.datum.errors %}<div class="invalid-feedback d-block">{{ form.datum.errors.0 }}</div>{% endif %}
</div>
<div class="col-md-4 mb-3">
<label for="{{ form.uhrzeit.id_for_label }}" class="form-label">{{ form.uhrzeit.label }}</label>
{{ form.uhrzeit }}
{% if form.uhrzeit.errors %}<div class="invalid-feedback d-block">{{ form.uhrzeit.errors.0 }}</div>{% endif %}
</div>
<div class="col-md-4 mb-3">
<label for="{{ form.status.id_for_label }}" class="form-label">{{ form.status.label }}</label>
{{ form.status }}
</div>
<div class="col-md-6 mb-3">
<label for="{{ form.ort.id_for_label }}" class="form-label">{{ form.ort.label }} *</label>
{{ form.ort }}
{% if form.ort.errors %}<div class="invalid-feedback d-block">{{ form.ort.errors.0 }}</div>{% endif %}
</div>
<div class="col-md-6 mb-3">
<label for="{{ form.budget_pro_person.id_for_label }}" class="form-label">{{ form.budget_pro_person.label }}</label>
{{ form.budget_pro_person }}
</div>
<div class="col-12 mb-3">
<label for="{{ form.adresse.id_for_label }}" class="form-label">{{ form.adresse.label }}</label>
{{ form.adresse }}
</div>
<div class="col-12 mb-3">
<label for="{{ form.beschreibung.id_for_label }}" class="form-label">{{ form.beschreibung.label }}</label>
{{ form.beschreibung }}
</div>
</div>
</div>
</div>
<!-- Serienbrief-Vorlage -->
<div class="card shadow-sm mb-4">
<div class="card-header bg-dark text-white">
<i class="fas fa-envelope-open-text me-2"></i>Serienbrief Vorlage
</div>
<div class="card-body">
<div class="col-12 mb-3">
<label for="{{ form.betreff.id_for_label }}" class="form-label">{{ form.betreff.label }}</label>
{{ form.betreff }}
<div class="form-text">{{ form.betreff.help_text }}</div>
</div>
<div class="col-12 mb-3">
<label for="{{ form.briefvorlage.id_for_label }}" class="form-label">{{ form.briefvorlage.label }}</label>
{{ form.briefvorlage }}
{% if form.briefvorlage.errors %}<div class="invalid-feedback d-block">{{ form.briefvorlage.errors.0 }}</div>{% endif %}
<div class="form-text">{{ form.briefvorlage.help_text }}</div>
</div>
</div>
</div>
<!-- Unterschriften -->
<div class="card shadow-sm mb-4">
<div class="card-header bg-dark text-white">
<i class="fas fa-signature me-2"></i>Serienbrief Unterschriften
</div>
<div class="card-body">
<div class="row">
<div class="col-md-6 mb-3">
<label for="{{ form.unterschrift_1_name.id_for_label }}" class="form-label">{{ form.unterschrift_1_name.label }}</label>
{{ form.unterschrift_1_name }}
</div>
<div class="col-md-6 mb-3">
<label for="{{ form.unterschrift_1_titel.id_for_label }}" class="form-label">{{ form.unterschrift_1_titel.label }}</label>
{{ form.unterschrift_1_titel }}
</div>
<div class="col-md-6 mb-3">
<label for="{{ form.unterschrift_2_name.id_for_label }}" class="form-label">{{ form.unterschrift_2_name.label }}</label>
{{ form.unterschrift_2_name }}
</div>
<div class="col-md-6 mb-3">
<label for="{{ form.unterschrift_2_titel.id_for_label }}" class="form-label">{{ form.unterschrift_2_titel.label }}</label>
{{ form.unterschrift_2_titel }}
</div>
</div>
</div>
</div>
</div>
<!-- Sidebar -->
<div class="col-lg-4">
<div class="card shadow-sm mb-4">
<div class="card-header bg-dark text-white">
<i class="fas fa-question-circle me-2"></i>Platzhalter-Hilfe
</div>
<div class="card-body">
<p class="small text-muted mb-2">Verfügbare Platzhalter für die Briefvorlage:</p>
<table class="table table-sm small">
<tbody>
<tr><td class="font-monospace text-danger">{{ anrede }}</td><td>Herr / Frau</td></tr>
<tr><td class="font-monospace text-danger">{{ vorname }}</td><td>Vorname</td></tr>
<tr><td class="font-monospace text-danger">{{ nachname }}</td><td>Nachname</td></tr>
<tr><td class="font-monospace text-danger">{{ strasse }}</td><td>Straße + Nr.</td></tr>
<tr><td class="font-monospace text-danger">{{ plz }}</td><td>PLZ</td></tr>
<tr><td class="font-monospace text-danger">{{ ort }}</td><td>Wohnort</td></tr>
<tr><td class="font-monospace text-danger">{{ datum }}</td><td>Veranstaltungsdatum</td></tr>
<tr><td class="font-monospace text-danger">{{ uhrzeit }}</td><td>Uhrzeit</td></tr>
<tr><td class="font-monospace text-danger">{{ veranstaltungsort }}</td><td>Gasthaus / Ort</td></tr>
<tr><td class="font-monospace text-danger">{{ gasthaus_adresse }}</td><td>Adresse Gasthaus</td></tr>
</tbody>
</table>
<p class="small text-muted mb-0">Platzhalter werden beim PDF-Export automatisch befüllt.</p>
</div>
</div>
{% if veranstaltung %}
<div class="card shadow-sm">
<div class="card-header bg-dark text-white">
<i class="fas fa-tools me-2"></i>Aktionen
</div>
<div class="card-body d-flex flex-column gap-2">
<a href="{% url 'stiftung:veranstaltung_serienbrief_vorschau' veranstaltung.pk %}"
class="btn btn-outline-primary w-100" target="_blank">
<i class="fas fa-eye me-2"></i>Serienbrief-Vorschau
</a>
<a href="{% url 'stiftung:veranstaltung_serienbrief_pdf' veranstaltung.pk %}"
class="btn btn-outline-success w-100">
<i class="fas fa-file-pdf me-2"></i>Serienbrief-PDF
</a>
<hr>
<a href="{% url 'stiftung:veranstaltung_delete' veranstaltung.pk %}"
class="btn btn-outline-danger w-100">
<i class="fas fa-trash me-2"></i>Veranstaltung löschen
</a>
</div>
</div>
{% endif %}
</div>
</div>
<!-- Submit -->
<div class="row mt-3">
<div class="col-lg-8">
<hr>
<div class="d-flex justify-content-between">
<a href="{% url 'stiftung:veranstaltung_list' %}" class="btn btn-secondary">
<i class="fas fa-times me-2"></i>Abbrechen
</a>
<button type="submit" class="btn btn-success">
<i class="fas fa-save me-2"></i>
{% if veranstaltung %}Aktualisieren{% else %}Erstellen{% endif %}
</button>
</div>
</div>
</div>
</form>
</div>
{% endblock %}

View File

@@ -8,7 +8,7 @@
<h1 class="h3 mb-0">
<i class="fas fa-calendar-alt me-2"></i>Veranstaltungen
</h1>
<a href="{% url 'admin:stiftung_veranstaltung_add' %}" class="btn btn-primary">
<a href="{% url 'stiftung:veranstaltung_create' %}" class="btn btn-primary">
<i class="fas fa-plus me-1"></i>Neue Veranstaltung
</a>
</div>
@@ -68,7 +68,7 @@
{% else %}
<div class="alert alert-info">
<i class="fas fa-info-circle me-2"></i>Noch keine Veranstaltungen angelegt.
<a href="{% url 'admin:stiftung_veranstaltung_add' %}">Jetzt erste Veranstaltung erstellen.</a>
<a href="{% url 'stiftung:veranstaltung_create' %}">Jetzt erste Veranstaltung erstellen.</a>
</div>
{% endif %}
</div>

View File

@@ -0,0 +1,134 @@
{% extends "base.html" %}
{% block title %}Serienbrief-Vorschau {{ veranstaltung.titel }}{% endblock %}
{% block content %}
<div class="container-fluid mt-3">
<div class="d-flex justify-content-between align-items-center mb-3">
<h2>
Serienbrief-Vorschau
<small class="text-muted fs-6">{{ veranstaltung.titel }} ({{ veranstaltung.datum|date:"j. F Y" }})</small>
</h2>
<div class="d-flex gap-2">
<a href="{% url 'stiftung:veranstaltung_serienbrief_pdf' veranstaltung.pk %}"
class="btn btn-primary">
PDF generieren
</a>
<a href="{% url 'stiftung:veranstaltung_detail' veranstaltung.pk %}"
class="btn btn-outline-secondary">
Zurück zur Veranstaltung
</a>
</div>
</div>
{% if not teilnehmer %}
<div class="alert alert-warning">
Diese Veranstaltung hat noch keine Teilnehmer. Bitte zuerst Teilnehmer anlegen.
</div>
{% else %}
<div class="alert alert-info d-flex align-items-start gap-2 mb-3">
<span></span>
<div>
<strong>{{ teilnehmer.count }} Brief{% if teilnehmer.count != 1 %}e{% endif %}</strong> werden generiert.
Die Vorschau zeigt jeden Brief auf einer separaten Seite.
Platzhalter wie <code>{% verbatim %}{{ vorname }}{% endverbatim %}</code> sind hier bereits durch Beispieldaten ersetzt.
</div>
</div>
<!-- Navigation zwischen Briefen -->
<div class="mb-3 d-flex gap-2 align-items-center flex-wrap">
<strong>Empfänger:</strong>
{% for t in teilnehmer %}
<a href="#brief-{{ forloop.counter }}"
class="btn btn-sm btn-outline-secondary">
{{ t.nachname }}, {{ t.vorname }}
</a>
{% endfor %}
</div>
<!-- Einzelne Briefe -->
{% for t in teilnehmer %}
<div id="brief-{{ forloop.counter }}"
style="max-width:210mm;margin:0 auto 40px;padding:20mm 25mm;border:1px solid #dee2e6;background:#fff;box-shadow:0 2px 8px rgba(0,0,0,.1);font-family:'Times New Roman',Times,serif;font-size:11pt;line-height:1.4;">
<!-- Stiftungskopf -->
<div style="font-size:12pt;font-weight:bold;margin-bottom:2mm;">van Hees-Theyssen-Vogel'sche Stiftung</div>
<div style="font-size:8.5pt;color:#444;margin-bottom:5mm;">
Raesfelder Str. 3 &nbsp;·&nbsp; 46499 Hamminkeln
</div>
<!-- Empfänger -->
<div style="min-height:35mm;margin-bottom:5mm;">
<div style="font-size:7.5pt;border-bottom:1px solid #000;margin-bottom:3pt;padding-bottom:1pt;color:#444;">
van Hees-Theyssen-Vogel'sche Stiftung · Raesfelder Str. 3 · 46499 Hamminkeln
</div>
<p style="margin:0;line-height:1.3;">{{ t.anrede }} {{ t.vorname }} {{ t.nachname }}</p>
{% if t.strasse %}<p style="margin:0;line-height:1.3;">{{ t.strasse }}</p>{% endif %}
{% if t.plz or t.ort %}<p style="margin:0;line-height:1.3;">{{ t.plz }} {{ t.ort }}</p>{% endif %}
</div>
<!-- Datum -->
<div style="text-align:right;margin-bottom:4mm;">
Hamminkeln, den {{ veranstaltung.datum|date:"j. F Y" }}
</div>
<!-- Betreff -->
<div style="font-weight:bold;margin-bottom:4mm;">
{% if veranstaltung.betreff %}{{ veranstaltung.betreff }}{% else %}Einladung zum {{ veranstaltung.titel }}{% endif %}
</div>
<!-- Anrede -->
<div style="margin-bottom:3mm;">
Sehr geehrte{% if t.anrede == "Herr" %}r Herr{% elif t.anrede == "Frau" %} Frau{% else %}
{{ t.anrede }}{% endif %} {{ t.nachname }},
</div>
<!-- Brieftext -->
<div class="brieftext">
{% if veranstaltung.briefvorlage %}
{{ veranstaltung.briefvorlage|safe }}
{% else %}
<p>wir laden Sie herzlich ein, an der jährlichen Vorstellung der Rechnungslegung
der van Hees-Theyssen-Vogel'schen Stiftung teilzunehmen.</p>
<p>Die Veranstaltung findet statt am:</p>
<div style="margin:4mm 0 4mm 10mm;font-weight:bold;">
{{ veranstaltung.datum|date:"l, j. F Y" }}{% if veranstaltung.uhrzeit %}, {{ veranstaltung.uhrzeit|time:"H:i" }} Uhr{% endif %}<br>
{{ veranstaltung.ort }}<br>
{% if veranstaltung.adresse %}{{ veranstaltung.adresse }}{% endif %}
</div>
<p>Bitte teilen Sie uns Ihre Teilnahme bis zum <strong>4. April 2026</strong> mit.</p>
<p>Wir freuen uns auf Ihr Kommen.</p>
{% endif %}
<p>Mit freundlichen Grüßen</p>
</div>
<!-- Unterschriften -->
<div style="margin-top:10mm;">
<div style="display:inline-block;width:45%;vertical-align:top;">
{% if veranstaltung.unterschrift_1_name %}
<div style="border-top:1px solid #000;margin-bottom:2mm;width:80%;"></div>
{{ veranstaltung.unterschrift_1_name }}<br>
{{ veranstaltung.unterschrift_1_titel }}<br>
van Hees-Theyssen-Vogel'sche Stiftung
{% endif %}
</div>
<div style="display:inline-block;width:45%;vertical-align:top;">
{% if veranstaltung.unterschrift_2_name %}
<div style="border-top:1px solid #000;margin-bottom:2mm;width:80%;"></div>
{{ veranstaltung.unterschrift_2_name }}<br>
{{ veranstaltung.unterschrift_2_titel }}<br>
van Hees-Theyssen-Vogel'sche Stiftung
{% endif %}
</div>
</div>
<div style="text-align:right;margin-top:12mm;font-size:9pt;color:#999;">
Brief {{ forloop.counter }} von {{ teilnehmer.count }}
</div>
</div>
{% endfor %}
{% endif %}
</div>
{% endblock %}

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