Phase 0: forms.py, admin.py und views.py in Domain-Packages aufteilen

- forms.py → forms/ Package (8 Domänen: destinataere, land, finanzen,
  foerderung, dokumente, veranstaltung, system, geschichte)
- admin.py → admin/ Package (7 Domänen, alle 22 @admin.register dekoriert)
- views.py (8845 Zeilen) → views/ Package (10 Domänen: dashboard, destinataere,
  land, paechter, finanzen, foerderung, dokumente, unterstuetzungen,
  veranstaltung, geschichte, system)
- __init__.py in jedem Package re-exportiert alle Symbole für Rückwärtskompatibilität
- urls.py bleibt unverändert (funktioniert durch Re-Exports)
- Django system check: 0 Fehler, alle URL-Auflösungen funktionieren

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

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
SysAdmin Agent
2026-03-11 09:55:15 +00:00
parent 7e9e4fddf1
commit 3ca2706e5d
31 changed files with 12891 additions and 12042 deletions

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,199 @@
# 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_notiz_create,
destinataer_export,
)
from .dokumente import ( # noqa: F401
dokument_management,
paperless_document_redirect,
dokument_list,
dokument_detail,
dokument_create,
dokument_update,
dokument_delete,
paperless_ping,
paperless_documents,
paperless_debug,
paperless_tags_only,
link_document_search,
create_paechter_link_for_verpachtung,
link_document_create,
link_document_list,
link_document_update,
link_document_delete,
)
from .finanzen import ( # noqa: F401
bericht_list,
jahresbericht_generate,
jahresbericht_generate_redirect,
jahresbericht_pdf,
geschaeftsfuehrung,
konto_list,
verwaltungskosten_list,
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_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,
)
from .system import ( # noqa: F401
get_pdf_generator,
GrampsClient,
get_gramps_client,
gramps_debug_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,
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,
)
from .veranstaltung import ( # noqa: F401
veranstaltung_list,
veranstaltung_detail,
veranstaltung_serienbrief_pdf,
veranstaltung_serienbrief_vorschau,
veranstaltung_create,
veranstaltung_update,
veranstaltung_delete,
teilnehmer_create,
teilnehmer_update,
teilnehmer_delete,
)
# Non-view exports (helpers used elsewhere)
from .system import GrampsClient, get_gramps_client, get_pdf_generator # noqa: F401

View File

@@ -0,0 +1,117 @@
# views/dashboard.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 home(request):
"""Home page for the Stiftungsverwaltung application"""
from stiftung.services.calendar_service import StiftungsKalenderService
# Get upcoming events for the calendar widget
calendar_service = StiftungsKalenderService()
# Get all events for the next 14 days
from datetime import timedelta
today = timezone.now().date()
end_date = today + timedelta(days=14)
all_events = calendar_service.get_all_events(today, end_date)
# Filter for upcoming and overdue
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)]
# Get current month events for mini calendar
from calendar import monthrange
_, last_day = monthrange(today.year, today.month)
month_start = today.replace(day=1)
month_end = today.replace(day=last_day)
current_month_events = calendar_service.get_all_events(month_start, month_end)
context = {
"title": "Stiftungsverwaltung",
"description": "Foundation Management System",
"upcoming_events": upcoming_events[:5], # Show only 5 upcoming events
"overdue_events": overdue_events[:3], # Show only 3 overdue events
"current_month_events": current_month_events,
"today": today,
}
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",
}
)
## Removed duplicate paperless_ping referencing non-existent PAPERLESS_URL
# CSV Import Views
@api_view(["GET"])
def health(_request):
return Response({"status": "ok"})

View File

@@ -0,0 +1,697 @@
# 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,
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", "")
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 = DokumentLink.objects.filter(
destinataer_id=destinataer.pk
).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")
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,
}
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)
# 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

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,743 @@
# 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)
@login_required
def jahresbericht_generate(request, jahr):
"""Generate annual report for a specific year"""
# Get data for the year
foerderungen = Foerderung.objects.filter(jahr=jahr).select_related("person")
verpachtungen = LandVerpachtung.objects.filter(
pachtbeginn__year__lte=jahr, pachtende__year__gte=jahr
).select_related("land", "paechter")
# Calculate statistics
total_foerderungen = foerderungen.aggregate(total=Sum("betrag"))["total"] or 0
total_pachtzins = (
verpachtungen.aggregate(total=Sum("pachtzins_pauschal"))["total"] or 0
)
context = {
"jahr": jahr,
"foerderungen": foerderungen,
"verpachtungen": verpachtungen,
"total_foerderungen": total_foerderungen,
"total_pachtzins": total_pachtzins,
"title": f"Jahresbericht {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):
"""Generate PDF version of annual report"""
from django.http import HttpResponse
from django.template.loader import render_to_string
from weasyprint import HTML
# Get data for the year
foerderungen = Foerderung.objects.filter(jahr=jahr).select_related("person")
verpachtungen = LandVerpachtung.objects.filter(
pachtbeginn__year__lte=jahr, pachtende__year__gte=jahr
).select_related("land", "paechter")
# Calculate statistics
total_foerderungen = foerderungen.aggregate(total=Sum("betrag"))["total"] or 0
total_pachtzins = (
verpachtungen.aggregate(total=Sum("pachtzins_pauschal"))["total"] or 0
)
context = {
"jahr": jahr,
"foerderungen": foerderungen,
"verpachtungen": verpachtungen,
"total_foerderungen": total_foerderungen,
"total_pachtzins": total_pachtzins,
}
# 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 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 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_list")
else:
form = VerwaltungskostenForm(instance=verwaltungskosten)
context = {
"form": form,
"verwaltungskosten": verwaltungskosten,
"title": f"Verwaltungskosten bearbeiten: {verwaltungskosten.bezeichnung}",
"submit_text": "Änderungen speichern",
}
return render(request, "stiftung/verwaltungskosten_form.html", context)
@login_required
def verwaltungskosten_delete(request, pk):
"""Lösche Verwaltungskosten"""
from stiftung.models import Verwaltungskosten
verwaltungskosten = get_object_or_404(Verwaltungskosten, pk=pk)
if request.method == "POST":
bezeichnung = verwaltungskosten.bezeichnung
# Log the deletion
from stiftung.audit import log_action
log_action(
request=request,
action="delete",
entity_type="verwaltungskosten",
entity_id=str(verwaltungskosten.pk),
entity_name=bezeichnung,
description=f'Verwaltungskosten "{bezeichnung}" wurden gelöscht',
)
verwaltungskosten.delete()
messages.success(
request,
f'Verwaltungskosten "{bezeichnung}" wurden erfolgreich gelöscht.',
)
return redirect("stiftung:verwaltungskosten_list")
context = {
"verwaltungskosten": verwaltungskosten,
"title": f"Verwaltungskosten löschen: {verwaltungskosten.bezeichnung}",
}
return render(request, "stiftung/verwaltungskosten_delete.html", context)
@login_required
def mark_expense_paid(request):
"""Markiere eine Ausgabe als bezahlt"""
if request.method == "POST":
expense_id = request.POST.get("expense_id")
if expense_id:
try:
from stiftung.models import Verwaltungskosten
expense = Verwaltungskosten.objects.get(pk=expense_id)
old_status = expense.status
expense.status = "bezahlt"
expense.save()
# Log the status change
from stiftung.audit import log_action
log_action(
request=request,
action="update",
entity_type="verwaltungskosten",
entity_id=str(expense.pk),
entity_name=expense.bezeichnung,
description=f'Ausgaben-Status geändert von "{old_status}" zu "bezahlt"',
changes={"status": {"old": old_status, "new": "bezahlt"}},
)
messages.success(
request,
f'Ausgabe "{expense.bezeichnung}" wurde als bezahlt markiert.',
)
return redirect(
"stiftung:rentmeister_ausgaben", pk=expense.rentmeister.pk
)
except Verwaltungskosten.DoesNotExist:
messages.error(request, "Ausgabe nicht gefunden.")
return redirect("stiftung:verwaltungskosten_list")
# =============================================================================
# ADMINISTRATION VIEWS
# =============================================================================

View File

@@ -0,0 +1,236 @@
# views/foerderung.py
# Phase 0: Vision 2026 Code-Refactoring
import csv
import io
import json
import os
import time
from datetime import datetime, timedelta, date
from decimal import Decimal
import qrcode
import qrcode.image.svg
import requests
from django.conf import settings
from django.contrib import messages
from django.contrib.auth.decorators import login_required
from django.core.paginator import Paginator
from django.db.models import (Avg, Count, DecimalField, F, IntegerField, Q,
Sum, Value)
from django.db.models.functions import Cast, Coalesce, NullIf, Replace
from django.http import HttpResponse, JsonResponse
from django.shortcuts import get_object_or_404, redirect, render
from django.urls import reverse
from django.utils import timezone
from django.views.decorators.csrf import csrf_exempt
from django_otp.decorators import otp_required
from django_otp.plugins.otp_totp.models import TOTPDevice
from django_otp.plugins.otp_static.models import StaticDevice, StaticToken
from django_otp.util import random_hex
from rest_framework.decorators import api_view
from rest_framework.response import Response
from stiftung.models import (AppConfiguration, AuditLog, BackupJob, BankTransaction,
BriefVorlage, CSVImport, Destinataer,
DestinataerEmailEingang, DestinataerNotiz,
DestinataerUnterstuetzung,
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 = DokumentLink.objects.filter(
foerderung_id=foerderung.pk
).order_by("kontext", "titel")
context = {
"foerderung": foerderung,
"verknuepfte_dokumente": verknuepfte_dokumente,
"title": f"Förderung: {foerderung}",
}
return render(request, "stiftung/foerderung_detail.html", context)
@login_required
def foerderung_create(request):
"""Create a new funding grant"""
# Get destinataer from URL parameter if provided
destinataer_id = request.GET.get("destinataer")
initial = {}
if destinataer_id:
initial["destinataer"] = destinataer_id
if request.method == "POST":
form = FoerderungForm(request.POST)
if form.is_valid():
foerderung = form.save()
messages.success(
request,
f"Förderung für {foerderung.destinataer} wurde erfolgreich erstellt.",
)
return redirect("stiftung:foerderung_detail", pk=foerderung.pk)
else:
form = FoerderungForm(initial=initial)
context = {
"form": form,
"title": "Neue Förderung erstellen",
}
return render(request, "stiftung/foerderung_form.html", context)
@login_required
def foerderung_update(request, pk):
"""Update an existing funding grant"""
foerderung = get_object_or_404(Foerderung, pk=pk)
if request.method == "POST":
form = FoerderungForm(request.POST, instance=foerderung)
if form.is_valid():
form.save()
messages.success(
request,
f"Förderung für {foerderung.person} wurde erfolgreich aktualisiert.",
)
return redirect("stiftung:foerderung_detail", pk=foerderung.pk)
else:
form = FoerderungForm(instance=foerderung)
context = {
"form": form,
"foerderung": foerderung,
"title": f"Förderung bearbeiten: {foerderung}",
}
return render(request, "stiftung/foerderung_form.html", context)
@login_required
def foerderung_delete(request, pk):
"""Delete a funding grant"""
foerderung = get_object_or_404(Foerderung, pk=pk)
if request.method == "POST":
# Get the recipient name before deletion
recipient_name = (
foerderung.destinataer.get_full_name()
if foerderung.destinataer
else (
foerderung.person.get_full_name()
if foerderung.person
else "Unbekannter Empfänger"
)
)
foerderung.delete()
messages.success(
request, f"Förderung für {recipient_name} wurde erfolgreich gelöscht."
)
return redirect("stiftung:foerderung_list")
context = {
"foerderung": foerderung,
"title": f"Förderung löschen: {foerderung}",
}
return render(request, "stiftung/foerderung_confirm_delete.html", context)
# DokumentLink Views

View File

@@ -0,0 +1,710 @@
# 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, 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()
messages.success(request, f'Geschichtsseite "{seite.titel}" wurde erfolgreich erstellt.')
return redirect('stiftung:geschichte_detail', slug=seite.slug)
else:
form = GeschichteSeiteForm()
context = {
'form': form,
'title': 'Neue Geschichtsseite'
}
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()
messages.success(request, f'Geschichtsseite "{seite.titel}" wurde erfolgreich aktualisiert.')
return redirect('stiftung:geschichte_detail', slug=seite.slug)
else:
form = GeschichteSeiteForm(instance=seite)
context = {
'form': form,
'seite': seite,
'title': f'Bearbeiten: {seite.titel}'
}
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):
"""
Übersicht aller eingegangenen E-Mails von Destinatären.
Zeigt ungeklärte Absender zuerst, dann chronologisch absteigend.
"""
status_filter = request.GET.get("status", "")
search = request.GET.get("q", "").strip()
qs = DestinataerEmailEingang.objects.select_related("destinataer", "quartalsnachweis")
if status_filter:
qs = qs.filter(status=status_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", # "unbekannt" kommt alphabetisch vor "verarbeitet" / "zugewiesen"
"-eingangsdatum",
)
paginator = Paginator(qs, 30)
page_obj = paginator.get_page(request.GET.get("page"))
context = {
"title": "E-Mail-Eingang (Destinatäre)",
"page_obj": page_obj,
"status_filter": status_filter,
"search": search,
"status_choices": DestinataerEmailEingang.STATUS_CHOICES,
"counts": {
"gesamt": DestinataerEmailEingang.objects.count(),
"neu": DestinataerEmailEingang.objects.filter(status="neu").count(),
"unbekannt": DestinataerEmailEingang.objects.filter(status="unbekannt").count(),
"fehler": DestinataerEmailEingang.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 Möglichkeit zur manuellen Zuordnung."""
eingang = get_object_or_404(DestinataerEmailEingang, 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.status = "zugewiesen"
eingang.save()
messages.success(
request,
f"E-Mail wurde {destinataer} zugeordnet.",
)
except Destinataer.DoesNotExist:
messages.error(request, "Destinatär nicht gefunden.")
return redirect("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("email_eingang_list")
elif action == "save_notizen":
eingang.notizen = request.POST.get("notizen", "")
eingang.save()
messages.success(request, "Notizen gespeichert.")
return redirect("email_eingang_detail", pk=pk)
# Paperless-Links zusammenstellen
paperless_links = eingang.get_paperless_links()
# DokumentLinks für diese E-Mail (über paperless_dokument_ids)
dokument_links = []
if eingang.paperless_dokument_ids:
dokument_links = DokumentLink.objects.filter(
paperless_document_id__in=eingang.paperless_dokument_ids
)
# Alle aktiven Destinatäre für manuelle Zuordnung
alle_destinataere = Destinataer.objects.filter(aktiv=True).order_by("nachname", "vorname")
context = {
"title": f"E-Mail-Eingang: {eingang}",
"eingang": eingang,
"paperless_links": paperless_links,
"dokument_links": dokument_links,
"alle_destinataere": alle_destinataere,
}
return render(request, "stiftung/email_eingang/detail.html", context)
@login_required
def email_eingang_poll_trigger(request):
"""Löst den IMAP-Poll-Task manuell aus (für Tests und manuelle Verarbeitung)."""
if request.method == "POST":
from stiftung.tasks import poll_destinataer_emails
try:
task = poll_destinataer_emails.delay()
messages.success(
request,
f"E-Mail-Abruf wurde gestartet (Task-ID: {task.id}). "
"Bitte Seite in ca. 30 Sekunden neu laden.",
)
except Exception as exc:
messages.error(request, f"Fehler beim Starten des Tasks: {exc}")
return redirect("email_eingang_list")
# ============================================================
# Veranstaltungsmodul
# ============================================================

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

File diff suppressed because it is too large Load Diff

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

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

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