Compare commits
29 Commits
28621d2774
...
f1358d0131
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f1358d0131 | ||
|
|
7e42b50d5b | ||
|
|
7a9dc533c3 | ||
|
|
781d410f88 | ||
|
|
d84421ea38 | ||
|
|
5c9db56158 | ||
|
|
e6f4c5ba1b | ||
|
|
f4fc512ad3 | ||
|
|
8c528308bd | ||
|
|
8ae7bff38c | ||
|
|
65e025d8c4 | ||
|
|
83cf2798b1 | ||
|
|
2a7c9d8529 | ||
|
|
96204c04dd | ||
|
|
c3c6755027 | ||
|
|
8e1db11f8d | ||
|
|
b47ffd4a3c | ||
|
|
cf127b043d | ||
|
|
113bd53a3a | ||
|
|
502fab31fc | ||
|
|
905aa879ee | ||
|
|
2be72c3990 | ||
|
|
a79a0989d6 | ||
|
|
ee2c827d85 | ||
|
|
bf47ba11c9 | ||
|
|
3ca2706e5d | ||
|
|
7e9e4fddf1 | ||
|
|
b4bad7bc83 | ||
|
|
709903e627 |
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
250
app/static/stiftung/js/briefvorlage_editor.js
Normal file
250
app/static/stiftung/js/briefvorlage_editor.js
Normal 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
14
app/stiftung/admin/__init__.py
Normal file
14
app/stiftung/admin/__init__.py
Normal 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"
|
||||
178
app/stiftung/admin/destinataere.py
Normal file
178
app/stiftung/admin/destinataere.py
Normal 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"
|
||||
20
app/stiftung/admin/dokumente.py
Normal file
20
app/stiftung/admin/dokumente.py
Normal 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",)}),
|
||||
)
|
||||
191
app/stiftung/admin/finanzen.py
Normal file
191
app/stiftung/admin/finanzen.py
Normal 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")
|
||||
)
|
||||
69
app/stiftung/admin/foerderung.py
Normal file
69
app/stiftung/admin/foerderung.py
Normal 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
206
app/stiftung/admin/land.py
Normal 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"
|
||||
579
app/stiftung/admin/system.py
Normal file
579
app/stiftung/admin/system.py
Normal 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"
|
||||
190
app/stiftung/admin/veranstaltung.py
Normal file
190
app/stiftung/admin/veranstaltung.py
Normal 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",)}),
|
||||
)
|
||||
@@ -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
58
app/stiftung/forms/__init__.py
Normal file
58
app/stiftung/forms/__init__.py
Normal 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",
|
||||
]
|
||||
428
app/stiftung/forms/destinataere.py
Normal file
428
app/stiftung/forms/destinataere.py
Normal 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
|
||||
19
app/stiftung/forms/dokumente.py
Normal file
19
app/stiftung/forms/dokumente.py
Normal 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"}
|
||||
),
|
||||
}
|
||||
351
app/stiftung/forms/finanzen.py
Normal file
351
app/stiftung/forms/finanzen.py
Normal 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)",
|
||||
)
|
||||
73
app/stiftung/forms/foerderung.py
Normal file
73
app/stiftung/forms/foerderung.py
Normal 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",
|
||||
}
|
||||
107
app/stiftung/forms/geschichte.py
Normal file
107
app/stiftung/forms/geschichte.py
Normal 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
293
app/stiftung/forms/land.py
Normal 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}),
|
||||
}
|
||||
460
app/stiftung/forms/system.py
Normal file
460
app/stiftung/forms/system.py
Normal 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
|
||||
59
app/stiftung/forms/veranstaltung.py
Normal file
59
app/stiftung/forms/veranstaltung.py
Normal 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"}),
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
|
||||
124
app/stiftung/management/commands/migrate_paperless_dokumente.py
Normal file
124
app/stiftung/management/commands/migrate_paperless_dokumente.py
Normal 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"
|
||||
@@ -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."
|
||||
)
|
||||
)
|
||||
30
app/stiftung/migrations/0046_briefvorlage_model.py
Normal file
30
app/stiftung/migrations/0046_briefvorlage_model.py
Normal 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'],
|
||||
},
|
||||
),
|
||||
]
|
||||
45
app/stiftung/migrations/0047_phase2_zahlungs_pipeline.py
Normal file
45
app/stiftung/migrations/0047_phase2_zahlungs_pipeline.py
Normal 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'),
|
||||
),
|
||||
]
|
||||
51
app/stiftung/migrations/0048_phase3_dms_dokument_datei.py
Normal file
51
app/stiftung/migrations/0048_phase3_dms_dokument_datei.py
Normal 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')],
|
||||
},
|
||||
),
|
||||
]
|
||||
33
app/stiftung/migrations/0049_phase3_email_dms_m2m.py
Normal file
33
app/stiftung/migrations/0049_phase3_email_dms_m2m.py
Normal 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)'),
|
||||
),
|
||||
]
|
||||
@@ -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',
|
||||
),
|
||||
),
|
||||
]
|
||||
@@ -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)'),
|
||||
),
|
||||
]
|
||||
@@ -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'),
|
||||
),
|
||||
]
|
||||
18
app/stiftung/migrations/0053_geschichte_dokumente_m2m.py
Normal file
18
app/stiftung/migrations/0053_geschichte_dokumente_m2m.py
Normal 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
54
app/stiftung/models/__init__.py
Normal file
54
app/stiftung/models/__init__.py
Normal 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,
|
||||
)
|
||||
1262
app/stiftung/models/destinataere.py
Normal file
1262
app/stiftung/models/destinataere.py
Normal file
File diff suppressed because it is too large
Load Diff
188
app/stiftung/models/dokumente.py
Normal file
188
app/stiftung/models/dokumente.py
Normal 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"
|
||||
385
app/stiftung/models/finanzen.py
Normal file
385
app/stiftung/models/finanzen.py
Normal 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
|
||||
221
app/stiftung/models/geschichte.py
Normal file
221
app/stiftung/models/geschichte.py
Normal 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
1082
app/stiftung/models/land.py
Normal file
File diff suppressed because it is too large
Load Diff
473
app/stiftung/models/system.py
Normal file
473
app/stiftung/models/system.py
Normal 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
|
||||
|
||||
215
app/stiftung/models/veranstaltungen.py
Normal file
215
app/stiftung/models/veranstaltungen.py
Normal 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)
|
||||
@@ -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
|
||||
|
||||
@@ -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"""
|
||||
|
||||
@@ -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"),
|
||||
]
|
||||
|
||||
@@ -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
206
app/stiftung/views/__init__.py
Normal file
206
app/stiftung/views/__init__.py
Normal 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
|
||||
160
app/stiftung/views/dashboard.py
Normal file
160
app/stiftung/views/dashboard.py
Normal 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"})
|
||||
762
app/stiftung/views/destinataere.py
Normal file
762
app/stiftung/views/destinataere.py
Normal 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
280
app/stiftung/views/dms.py
Normal 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)
|
||||
5
app/stiftung/views/dokumente.py
Normal file
5
app/stiftung/views/dokumente.py
Normal 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
|
||||
818
app/stiftung/views/finanzen.py
Normal file
818
app/stiftung/views/finanzen.py
Normal 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
|
||||
# =============================================================================
|
||||
|
||||
|
||||
236
app/stiftung/views/foerderung.py
Normal file
236
app/stiftung/views/foerderung.py
Normal 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
|
||||
811
app/stiftung/views/geschichte.py
Normal file
811
app/stiftung/views/geschichte.py
Normal 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
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
2348
app/stiftung/views/system.py
Normal file
File diff suppressed because it is too large
Load Diff
1887
app/stiftung/views/unterstuetzungen.py
Normal file
1887
app/stiftung/views/unterstuetzungen.py
Normal file
File diff suppressed because it is too large
Load Diff
254
app/stiftung/views/veranstaltung.py
Normal file
254
app/stiftung/views/veranstaltung.py
Normal 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
@@ -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>
|
||||
|
||||
@@ -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
@@ -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 %}
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
151
app/templates/stiftung/destinataer_timeline.html
Normal file
151
app/templates/stiftung/destinataer_timeline.html
Normal 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 %}
|
||||
119
app/templates/stiftung/dms/detail.html
Normal file
119
app/templates/stiftung/dms/detail.html
Normal 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 %}
|
||||
116
app/templates/stiftung/dms/edit.html
Normal file
116
app/templates/stiftung/dms/edit.html
Normal 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 %}
|
||||
153
app/templates/stiftung/dms/list.html
Normal file
153
app/templates/stiftung/dms/list.html
Normal 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 %}
|
||||
39
app/templates/stiftung/dms/partials/search_results.html
Normal file
39
app/templates/stiftung/dms/partials/search_results.html
Normal 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 %}
|
||||
10
app/templates/stiftung/dms/partials/upload_success.html
Normal file
10
app/templates/stiftung/dms/partials/upload_success.html
Normal 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>
|
||||
166
app/templates/stiftung/dms/upload.html
Normal file
166
app/templates/stiftung/dms/upload.html
Normal 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 %}
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
@@ -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, "'")}')" 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 %}
|
||||
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
@@ -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 }}">
|
||||
«
|
||||
</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 }}">
|
||||
»
|
||||
</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>
|
||||
|
||||
142
app/templates/stiftung/email_settings.html
Normal file
142
app/templates/stiftung/email_settings.html
Normal 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 %}
|
||||
@@ -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 %}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 }} · {{ 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>
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
@@ -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 €</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" }} · {{ payment.betrag|floatformat:2 }} €</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 →</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 }} · {{ entry.timestamp|timesince }} her</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -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;">← 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">€{{ total_einnahmen|floatformat:2 }}</div>
|
||||
<div class="label">Einnahmen (Pacht)</div>
|
||||
</div>
|
||||
<div class="bilanz-card ausgaben">
|
||||
<div class="value">€{{ total_ausgaben|floatformat:2 }}</div>
|
||||
<div class="label">Ausgaben gesamt</div>
|
||||
</div>
|
||||
<div class="bilanz-card {% if netto >= 0 %}netto-positiv{% else %}netto-negativ{% endif %}">
|
||||
<div class="value">{% if netto >= 0 %}+{% endif %}€{{ netto|floatformat:2 }}</div>
|
||||
<div class="label">Nettosaldo</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="stats-grid">
|
||||
<div class="stat-card">
|
||||
<div class="value">€{{ total_foerderungen|floatformat:2 }}</div>
|
||||
<div class="label">Gesamtförderungen</div>
|
||||
<div class="value">€{{ 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">€{{ 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">€{{ 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">€{{ 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 ·
|
||||
{{ unterstuetzungen_ausgezahlt.count }} überwiesen (€{{ total_unterstuetzungen|floatformat:2 }})
|
||||
</p>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Destinatär</th>
|
||||
<th>Betrag</th>
|
||||
<th>Fällig am</th>
|
||||
<th>Status</th>
|
||||
<th>Verwendungszweck</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for u in unterstuetzungen %}
|
||||
<tr>
|
||||
<td>{{ u.destinataer.get_full_name }}</td>
|
||||
<td class="amount">€{{ u.betrag|floatformat:2 }}</td>
|
||||
<td>{{ u.faellig_am|date:"d.m.Y" }}</td>
|
||||
<td>
|
||||
<span class="status-badge status-{{ u.status }}">{{ u.get_status_display }}</span>
|
||||
</td>
|
||||
<td>{{ u.beschreibung|default:"-" }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
<tfoot>
|
||||
<tr style="font-weight: bold; background: #f0f7f4;">
|
||||
<td>Summe ausgezahlt</td>
|
||||
<td class="amount">€{{ total_unterstuetzungen|floatformat:2 }}</td>
|
||||
<td colspan="3"></td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- 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">€{{ f.betrag|floatformat:2 }}</td>
|
||||
<td>
|
||||
<span class="status-badge status-{{ f.status }}">{{ f.get_status_display }}</span>
|
||||
</td>
|
||||
<td>{{ f.antragsdatum|date:"d.m.Y" }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
<tfoot>
|
||||
<tr style="font-weight: bold; background: #f0f7f4;">
|
||||
<td colspan="2">Summe</td>
|
||||
<td class="amount">€{{ total_foerderungen_legacy|floatformat:2 }}</td>
|
||||
<td colspan="2"></td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- 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">€{{ v.pachtzins_pauschal|floatformat:2 }}</td>
|
||||
<td>{% if v.pachtende %}{{ v.pachtende|date:"d.m.Y" }}{% else %}unbefristet{% endif %}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
<tfoot>
|
||||
<tr style="font-weight: bold; background: #f0f7f4;">
|
||||
<td colspan="3">Gesamtpachtzins (kalkuliert)</td>
|
||||
<td class="amount">€{{ total_pachtzins|floatformat:2 }}</td>
|
||||
<td></td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
{% endif %}
|
||||
|
||||
{% if landabrechnungen %}
|
||||
<h3>Landabrechnungen {{ jahr }}</h3>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Länderei</th>
|
||||
<th>Pacht vereinnahmt</th>
|
||||
<th>Umlagen</th>
|
||||
<th>Grundsteuer</th>
|
||||
<th>Sonstige Einnahmen</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for a in landabrechnungen %}
|
||||
<tr>
|
||||
<td>{{ a.land }}</td>
|
||||
<td class="amount">€{{ a.pacht_vereinnahmt|floatformat:2 }}</td>
|
||||
<td class="amount">€{{ a.umlagen_vereinnahmt|floatformat:2 }}</td>
|
||||
<td class="amount">€{{ a.grundsteuer_betrag|floatformat:2 }}</td>
|
||||
<td class="amount">€{{ a.sonstige_einnahmen|floatformat:2 }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
<tfoot>
|
||||
<tr style="font-weight: bold; background: #f0f7f4;">
|
||||
<td>Summe</td>
|
||||
<td class="amount">€{{ pacht_vereinnahmt|floatformat:2 }}</td>
|
||||
<td></td>
|
||||
<td class="amount">€{{ grundsteuer_gesamt|floatformat:2 }}</td>
|
||||
<td></td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
{% endif %}
|
||||
|
||||
{% if not verpachtungen and not landabrechnungen %}
|
||||
<p style="color: #999;">Keine Verpachtungs- oder Abrechnungsdaten für {{ jahr }} vorhanden.</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<!-- 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">€{{ k.summe|floatformat:2 }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
<tfoot>
|
||||
<tr style="font-weight: bold; background: #f0f7f4;">
|
||||
<td colspan="2">Gesamt</td>
|
||||
<td class="amount">€{{ total_verwaltungskosten|floatformat:2 }}</td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- 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 }} — automatisch generiert von der Stiftungsverwaltung</p>
|
||||
<p>van Hees-Theyssen-Vogel'sche Familienstiftung · Vertraulich</p>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
</html>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
@@ -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>
|
||||
|
||||
170
app/templates/stiftung/nachweis_board.html
Normal file
170
app/templates/stiftung/nachweis_board.html
Normal 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 (Jan–Mär)</th>
|
||||
<th class="text-center">Q2 (Apr–Jun)</th>
|
||||
<th class="text-center">Q3 (Jul–Sep)</th>
|
||||
<th class="text-center">Q4 (Okt–Dez)</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 %}
|
||||
@@ -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 %}
|
||||
|
||||
227
app/templates/stiftung/paechter_workflow.html
Normal file
227
app/templates/stiftung/paechter_workflow.html
Normal 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 %}
|
||||
@@ -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 %}
|
||||
|
||||
36
app/templates/stiftung/veranstaltung/delete.html
Normal file
36
app/templates/stiftung/veranstaltung/delete.html
Normal 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 %}
|
||||
@@ -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>
|
||||
|
||||
200
app/templates/stiftung/veranstaltung/form.html
Normal file
200
app/templates/stiftung/veranstaltung/form.html
Normal 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 %}
|
||||
@@ -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>
|
||||
|
||||
134
app/templates/stiftung/veranstaltung/serienbrief_vorschau.html
Normal file
134
app/templates/stiftung/veranstaltung/serienbrief_vorschau.html
Normal 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 · 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
Reference in New Issue
Block a user