diff --git a/app/stiftung/admin.py b/app/stiftung/admin.py deleted file mode 100644 index 91921a4..0000000 --- a/app/stiftung/admin.py +++ /dev/null @@ -1,1421 +0,0 @@ -from django import forms -from django.contrib import admin -from django.db.models import Count, Sum -from django.urls import reverse -from django.utils import timezone -from django.utils.html import format_html -from django.utils.safestring import mark_safe - -from . import models -from .models import (AppConfiguration, AuditLog, BackupJob, BankTransaction, - BriefVorlage, CSVImport, Destinataer, DestinataerEmailEingang, - DestinataerUnterstuetzung, - DokumentLink, Foerderung, Land, LandVerpachtung, Paechter, Person, - Rentmeister, StiftungsKonto, UnterstuetzungWiederkehrend, - Veranstaltung, Veranstaltungsteilnehmer, - Verwaltungskosten, 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('{:.1f}%', 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( - '{}', 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(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( - '{}', obj.iban - ) - return "-" - - iban_display.short_description = "IBAN" - - -@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( - '{}', 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('{:.1f}%', 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( - '{}', - color, - obj.get_status_display() - ) - - status_display.short_description = "Status" - - -@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",)}), - ) - - -@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( - '{}', - 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" - - -@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") - ) - - -@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( - '{}', - 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(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(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(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(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( - '{} %', - 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( - ' Ja' - ) - return format_html( - ' Nein' - ) - 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" - - -@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('{}', url, obj.destinataer) - return format_html('–') - 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" - - -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( - """
| {{{{ anrede }}}} | Anredetitel (Herr / Frau) |
| {{{{ vorname }}}} | Vorname des Empfängers |
| {{{{ nachname }}}} | Nachname des Empfängers |
| {{{{ strasse }}}} | Straße und Hausnummer |
| {{{{ plz }}}} | Postleitzahl |
| {{{{ ort }}}} | Wohnort des Empfängers |
| {{{{ datum }}}} | Datum der Veranstaltung (z.B. Freitag, 17. April 2026) |
| {{{{ uhrzeit }}}} | Uhrzeit der Veranstaltung (z.B. 19:00 Uhr) |
| {{{{ veranstaltungsort }}}} | Name des Veranstaltungsorts / Gasthaus |
| {{{{ gasthaus_adresse }}}} | Adresse des Gasthauses |
| {{{{ anrede }}}} | Anredetitel (Herr / Frau) |
| {{{{ vorname }}}} | Vorname des Empfängers |
| {{{{ nachname }}}} | Nachname des Empfängers |
| {{{{ strasse }}}} | Straße und Hausnummer |
| {{{{ plz }}}} | Postleitzahl |
| {{{{ ort }}}} | Wohnort des Empfängers |
| {{{{ datum }}}} | Datum der Veranstaltung (z.B. Freitag, 17. April 2026) |
| {{{{ uhrzeit }}}} | Uhrzeit der Veranstaltung (z.B. 19:00 Uhr) |
| {{{{ veranstaltungsort }}}} | Name des Veranstaltungsorts / Gasthaus |
| {{{{ gasthaus_adresse }}}} | Adresse des Gasthauses |
PDF generation requires additional system dependencies that are not installed.
-Error: {error_message}
-Please install WeasyPrint dependencies or use CSV export instead.
- - - """ - response = HttpResponse(error_html, content_type="text/html") - response["Content-Disposition"] = ( - 'inline; filename="pdf_not_available.html"' - ) - return response - - return MockPDFGenerator() - - -class GrampsClient: - """Lightweight client for Gramps Web API.""" - - def __init__( - self, base_url: str, token: str = "", username: str = "", password: str = "" - ): - self.base_url = base_url.rstrip("/") - self.session = requests.Session() - if token: - self.session.headers.update({"Authorization": f"Bearer {token}"}) - self.username = username - self.password = password - self._cached_token = token - - def search_people(self, query: str, limit: int = 5): - try: - r = self.session.get( - f"{self.base_url}/api/people/", - params={"q": query, "limit": limit}, - timeout=10, - ) - r.raise_for_status() - return r.json() - except Exception as e: - # try login-once if unauthorized and we have credentials - if self.username and self.password and "401" in str(e): - if self._login(): - return self.search_people(query, limit) - return {"error": str(e)} - - def get_person(self, handle_or_id: str): - try: - r = self.session.get( - f"{self.base_url}/api/people/{handle_or_id}", timeout=10 - ) - r.raise_for_status() - return r.json() - except Exception as e: - if self.username and self.password and "401" in str(e): - if self._login(): - return self.get_person(handle_or_id) - return {"error": str(e)} - - def _login(self) -> bool: - try: - # try common endpoints - endpoints = [ - ( - "/api/auth/login", - {"username": self.username, "password": self.password}, - "json", - ), - ( - "/auth/login", - {"username": self.username, "password": self.password}, - "json", - ), - ( - "/api/token", - {"username": self.username, "password": self.password}, - "form", - ), - ( - "/login", - {"username": self.username, "password": self.password}, - "form", - ), - ( - "/token", - {"username": self.username, "password": self.password}, - "form", - ), - ( - "/api/login", - {"username": self.username, "password": self.password}, - "json", - ), - ] - for path, payload, mode in endpoints: - url = f"{self.base_url}{path}" - if mode == "json": - r = self.session.post( - url, json=payload, timeout=10, allow_redirects=False - ) - else: - r = self.session.post( - url, data=payload, timeout=10, allow_redirects=False - ) - # Success with token body - if r.status_code in (200, 201) and "application/json" in r.headers.get( - "Content-Type", "" - ): - data = r.json() - token = ( - data.get("access_token") - or data.get("token") - or data.get("access") - or data.get("jwt") - ) - if token: - self._cached_token = token - self.session.headers.update( - {"Authorization": f"Bearer {token}"} - ) - return True - # Success via session cookie and redirect - if r.status_code in (200, 302) and ( - "set-cookie" in {k.lower(): v for k, v in r.headers.items()} - ): - return True - # Basic Auth fallback (some setups protect API with Basic) - try: - self.session.auth = (self.username, self.password) - r = self.session.get(f"{self.base_url}/api/people/?limit=1", timeout=10) - if r.status_code == 200: - return True - except Exception: - pass - return False - except Exception: - return False - - -def get_gramps_client() -> GrampsClient: - return GrampsClient( - getattr(settings, "GRAMPS_URL", ""), - getattr(settings, "GRAMPS_API_TOKEN", ""), - getattr(settings, "GRAMPS_USERNAME", ""), - getattr(settings, "GRAMPS_PASSWORD", ""), - ) - - -@api_view(["GET"]) -def gramps_debug_api(_request): - return Response( - { - "GRAMPS_URL": getattr(settings, "GRAMPS_URL", ""), - "has_username": bool(getattr(settings, "GRAMPS_USERNAME", "")), - "has_password": bool(getattr(settings, "GRAMPS_PASSWORD", "")), - } - ) - - -from stiftung.models import DestinataerNotiz, DestinataerUnterstuetzung - -from .forms import (DestinataerForm, DestinataerNotizForm, - DestinataerUnterstuetzungForm, DokumentLinkForm, - FoerderungForm, LandForm, PaechterForm, PersonForm, - UnterstuetzungForm, UnterstuetzungMarkAsPaidForm, VierteljahresNachweisForm) - -@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) - - -@login_required -def dokument_management(request): - """Dokumentenverwaltung: zeigt Paperless-Dokumente (mit Stiftung-Tags) und bestehende Verknüpfungen. - Bietet Filter und ermöglicht Re-Linking. - """ - return render(request, "stiftung/dokument_management.html") - - -@api_view(["GET"]) -def paperless_document_redirect(_request, doc_id: int): - """Redirects to the Paperless UI document URL and supports thumbnails if needed later.""" - from stiftung.utils.config import get_paperless_config - - config = get_paperless_config() - url = config["api_url"] - if not url: - return Response({"error": "Paperless API not configured"}, status=400) - - # Remove /api suffix if present, then construct the document URL - base_url = url[:-4] if url.endswith("/api") else url - - # For external Paperless (already includes /paperless/ in base URL) - return redirect(f"{base_url}/documents/{doc_id}/details/") - - -@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 -@login_required -def csv_import_list(request): - """List all CSV import operations""" - imports = CSVImport.objects.all().order_by("-started_at") - - paginator = Paginator(imports, 20) - page_number = request.GET.get("page") - page_obj = paginator.get_page(page_number) - - context = { - "page_obj": page_obj, - "import_types": CSVImport.IMPORT_TYPE_CHOICES, - "status_choices": CSVImport.STATUS_CHOICES, - } - return render(request, "stiftung/csv_import_list.html", context) - - -@login_required -def csv_import_create(request): - """Show CSV import form and handle file upload""" - if request.method == "POST": - import_type = request.POST.get("import_type") - csv_file = request.FILES.get("csv_file") - - if not csv_file or not import_type: - messages.error( - request, "Bitte wählen Sie einen Import-Typ und eine CSV-Datei aus." - ) - return redirect("stiftung:csv_import_create") - - if not csv_file.name.endswith(".csv"): - messages.error(request, "Bitte wählen Sie eine gültige CSV-Datei aus.") - return redirect("stiftung:csv_import_create") - - try: - # Create import record - csv_import = CSVImport.objects.create( - import_type=import_type, - filename=csv_file.name, - file_size=csv_file.size, - created_by=( - request.user.username - if request.user.is_authenticated - else "Unknown" - ), - status="processing", - ) - - # Process the CSV file - if import_type == "destinataere": - result = process_destinataere_csv(csv_file, csv_import) - elif import_type == "paechter": - result = process_paechter_csv(csv_file, csv_import) - elif import_type == "personen": - result = process_personen_csv(csv_file, csv_import) - elif import_type == "laendereien": - result = process_laendereien_csv(csv_file, csv_import) - else: - messages.error(request, "Unbekannter Import-Typ.") - csv_import.status = "failed" - csv_import.save() - return redirect("stiftung:csv_import_create") - - # Update import record - csv_import.total_rows = result["total_rows"] - csv_import.imported_rows = result["imported_rows"] - csv_import.failed_rows = result["failed_rows"] - csv_import.error_log = result["error_log"] - csv_import.status = result["status"] - csv_import.completed_at = timezone.now() - csv_import.save() - - if result["status"] == "completed": - messages.success( - request, - f'CSV-Import erfolgreich! {result["imported_rows"]} Datensätze importiert.', - ) - elif result["status"] == "partial": - messages.warning( - request, - f'CSV-Import teilweise erfolgreich. {result["imported_rows"]} importiert, {result["failed_rows"]} fehlgeschlagen.', - ) - else: - messages.error( - request, f'CSV-Import fehlgeschlagen. {result["error_log"]}' - ) - - return redirect("stiftung:csv_import_list") - - except Exception as e: - messages.error(request, f"Fehler beim CSV-Import: {str(e)}") - return redirect("stiftung:csv_import_create") - - context = { - "import_types": CSVImport.IMPORT_TYPE_CHOICES, - } - return render(request, "stiftung/csv_import_form.html", context) - - -def process_personen_csv(csv_file, csv_import): - """Process CSV file for Personen import""" - decoded_file = csv_file.read().decode("utf-8") - # Handle both comma and semicolon separated files - if ";" in decoded_file.split("\n")[0]: - csv_data = csv.DictReader(io.StringIO(decoded_file), delimiter=";") - else: - csv_data = csv.DictReader(io.StringIO(decoded_file)) - - total_rows = 0 - imported_rows = 0 - failed_rows = 0 - error_log = [] - - for row_num, row in enumerate( - csv_data, start=2 - ): # Start at 2 because row 1 is header - total_rows += 1 - - try: - # Map CSV columns to model fields - person_data = { - "vorname": row.get("Vorname", "").strip(), - "nachname": row.get("Nachname", "").strip(), - "familienzweig": row.get("Familienzweig", "hauptzweig").strip(), - "email": row.get("E-Mail", "").strip() or None, - "telefon": row.get("Telefon", "").strip() or None, - "iban": row.get("IBAN", "").strip() or None, - "adresse": row.get("Adresse", "").strip() or None, - "notizen": row.get("Notizen", "").strip() or None, - "aktiv": row.get("Aktiv", "true").lower() == "true", - } - - # Handle date fields - if row.get("Geburtsdatum"): - try: - person_data["geburtsdatum"] = datetime.strptime( - row["Geburtsdatum"], "%d.%m.%Y" - ).date() - except ValueError: - try: - person_data["geburtsdatum"] = datetime.strptime( - row["Geburtsdatum"], "%Y-%m-%d" - ).date() - except ValueError: - person_data["geburtsdatum"] = None - - # Validate required fields - if not person_data["vorname"] or not person_data["nachname"]: - error_log.append( - f"Zeile {row_num}: Vorname und Nachname sind erforderlich" - ) - failed_rows += 1 - continue - - # Check if person already exists - existing_person = Person.objects.filter( - vorname__iexact=person_data["vorname"], - nachname__iexact=person_data["nachname"], - ).first() - - if existing_person: - # Update existing person - for field, value in person_data.items(): - if value is not None: - setattr(existing_person, field, value) - existing_person.save() - else: - # Create new person - Person.objects.create(**person_data) - - imported_rows += 1 - - except Exception as e: - error_log.append(f"Zeile {row_num}: {str(e)}") - failed_rows += 1 - - # Determine status - if failed_rows == 0: - status = "completed" - elif imported_rows > 0: - status = "partial" - else: - status = "failed" - - return { - "total_rows": total_rows, - "imported_rows": imported_rows, - "failed_rows": failed_rows, - "error_log": "\n".join(error_log) if error_log else None, - "status": status, - } - - -def process_destinataere_csv(csv_file, csv_import): - """Process CSV file for Destinatäre import""" - decoded_file = csv_file.read().decode("utf-8") - # Handle both comma and semicolon separated files - if ";" in decoded_file.split("\n")[0]: - csv_data = csv.DictReader(io.StringIO(decoded_file), delimiter=";") - else: - csv_data = csv.DictReader(io.StringIO(decoded_file)) - - total_rows = 0 - imported_rows = 0 - failed_rows = 0 - error_log = [] - - for row_num, row in enumerate( - csv_data, start=2 - ): # Start at 2 because row 1 is header - total_rows += 1 - - try: - # Helper function to parse boolean values from CSV - def parse_boolean(value, default=False): - """Parse boolean values from CSV with multiple accepted formats""" - if not value: - return default - value_str = str(value).strip().lower() - # Accept various true values - true_values = ['true', 'ja', 'yes', '1', 'wahr', 'x'] - # Accept various false values - false_values = ['false', 'nein', 'no', '0', 'falsch', ''] - - if value_str in true_values: - return True - elif value_str in false_values: - return False - else: - # If unclear, return default - return default - - # Map CSV columns to model fields - destinataer_data = { - "vorname": row.get("Vorname", "").strip(), - "nachname": row.get("Nachname", "").strip(), - "familienzweig": row.get("Familienzweig", "hauptzweig").strip(), - "email": row.get("E-Mail", "").strip() or None, - "telefon": row.get("Telefon", "").strip() or None, - "iban": row.get("IBAN", "").strip() or None, - "strasse": row.get("Straße", "").strip() or None, - "plz": row.get("PLZ", "").strip() or None, - "ort": row.get("Ort", "").strip() or None, - "berufsgruppe": row.get("Berufsgruppe", "andere").strip(), - "ausbildungsstand": row.get("Ausbildungsstand", "").strip() or None, - "institution": row.get("Institution", "").strip() or None, - "projekt_beschreibung": row.get("Projektbeschreibung", "").strip() - or None, - "jaehrliches_einkommen": ( - float(row.get("Jährliches_Einkommen", 0)) - if row.get("Jährliches_Einkommen") - else None - ), - "notizen": row.get("Notizen", "").strip() or None, - # Boolean fields with improved parsing - "finanzielle_notlage": parse_boolean(row.get("Finanzielle_Notlage"), False), - "aktiv": parse_boolean(row.get("Aktiv"), True), - "ist_abkoemmling": parse_boolean(row.get("Ist_Abkömmling"), False), - "unterstuetzung_bestaetigt": parse_boolean(row.get("Unterstützung_bestätigt"), False), - "studiennachweis_erforderlich": parse_boolean(row.get("Studiennachweis_erforderlich"), False), - } - - # Handle numeric fields - if row.get("Haushaltsgröße"): - try: - destinataer_data["haushaltsgroesse"] = int(row["Haushaltsgröße"]) - except ValueError: - pass - - if row.get("Monatliche_Bezüge"): - try: - destinataer_data["monatliche_bezuege"] = float(row["Monatliche_Bezüge"]) - except ValueError: - pass - - if row.get("Vermögen"): - try: - destinataer_data["vermoegen"] = float(row["Vermögen"]) - except ValueError: - pass - - if row.get("Vierteljährlicher_Betrag"): - try: - destinataer_data["vierteljaehrlicher_betrag"] = float(row["Vierteljährlicher_Betrag"]) - except ValueError: - pass - - # Handle date fields - if row.get("Geburtsdatum"): - try: - destinataer_data["geburtsdatum"] = datetime.strptime( - row["Geburtsdatum"], "%d.%m.%Y" - ).date() - except ValueError: - try: - destinataer_data["geburtsdatum"] = datetime.strptime( - row["Geburtsdatum"], "%Y-%m-%d" - ).date() - except ValueError: - destinataer_data["geburtsdatum"] = None - - if row.get("Letzter_Studiennachweis"): - try: - destinataer_data["letzter_studiennachweis"] = datetime.strptime( - row["Letzter_Studiennachweis"], "%d.%m.%Y" - ).date() - except ValueError: - try: - destinataer_data["letzter_studiennachweis"] = datetime.strptime( - row["Letzter_Studiennachweis"], "%Y-%m-%d" - ).date() - except ValueError: - destinataer_data["letzter_studiennachweis"] = None - - # Validate required fields - if not destinataer_data["vorname"] or not destinataer_data["nachname"]: - error_log.append( - f"Zeile {row_num}: Vorname und Nachname sind erforderlich" - ) - failed_rows += 1 - continue - - # Check if destinataer already exists - existing_destinataer = Destinataer.objects.filter( - vorname__iexact=destinataer_data["vorname"], - nachname__iexact=destinataer_data["nachname"], - ).first() - - if existing_destinataer: - # Update existing destinataer - for field, value in destinataer_data.items(): - if value is not None: - setattr(existing_destinataer, field, value) - existing_destinataer.save() - else: - # Create new destinataer - Destinataer.objects.create(**destinataer_data) - - imported_rows += 1 - - except Exception as e: - error_log.append(f"Zeile {row_num}: {str(e)}") - failed_rows += 1 - - # Determine status - if failed_rows == 0: - status = "completed" - elif imported_rows > 0: - status = "partial" - else: - status = "failed" - - return { - "total_rows": total_rows, - "imported_rows": imported_rows, - "failed_rows": failed_rows, - "error_log": "\n".join(error_log) if error_log else None, - "status": status, - } - - -def process_paechter_csv(csv_file, csv_import): - """Process CSV file for Paechter import""" - decoded_file = csv_file.read().decode("utf-8") - - # Handle both comma and semicolon separated files - if ";" in decoded_file.split("\n")[0]: - csv_data = csv.DictReader(io.StringIO(decoded_file), delimiter=";") - else: - csv_data = csv.DictReader(io.StringIO(decoded_file)) - - total_rows = 0 - imported_rows = 0 - failed_rows = 0 - error_log = [] - - for row_num, row in enumerate( - csv_data, start=2 - ): # Start at 2 because row 1 is header - total_rows += 1 - - try: - # Get raw values from CSV - handle both semicolon and comma separated - # Handle BOM in column names - vorname_raw = row.get("Vorname", "") or row.get("\ufeffVorname", "") - nachname_raw = row.get("Nachname", "") - personentyp_raw = row.get("Personentyp", "") - - # Clean up the values (remove extra whitespace but keep empty strings) - vorname_raw = vorname_raw.strip() if vorname_raw else "" - nachname_raw = nachname_raw.strip() if nachname_raw else "" - personentyp_raw = personentyp_raw.strip() if personentyp_raw else "" - - # Debug: Log raw values and available columns - error_log.append(f"Zeile {row_num}: Available columns: {list(row.keys())}") - error_log.append( - f"Zeile {row_num}: RAW Vorname='{vorname_raw}', Nachname='{nachname_raw}', Personentyp='{personentyp_raw}'" - ) - - # Determine personentyp based on the data - if personentyp_raw in ["Gesellschaft", "KG", "GbR", "GmbH"]: - personentyp = "gesellschaft" - elif personentyp_raw in ["Herrn", "Frau"]: - personentyp = "natuerlich" - else: - # Fallback: analyze the Nachname to detect companies - nachname_lower = nachname_raw.lower() - if any( - keyword in nachname_lower - for keyword in [ - "kg", - "gbr", - "gmbh", - "ag", - "ohg", - "e.v.", - "stiftung", - "genossenschaft", - ] - ): - personentyp = "gesellschaft" - else: - personentyp = "natuerlich" - - # Handle Vorname - keep original value unless it's 'N/A' - vorname = vorname_raw if vorname_raw and vorname_raw != "N/A" else "" - - # Debug: Log processed values - error_log.append( - f"Zeile {row_num}: PROCESSED Vorname='{vorname}', Nachname='{nachname_raw}', Personentyp='{personentyp}'" - ) - - paechter_data = { - "vorname": vorname, - "nachname": nachname_raw, - "email": row.get("E-Mail", "").strip() or None, - "telefon": row.get("Telefon", "").strip() or None, - "iban": row.get("IBAN", "").strip() or None, - "strasse": row.get("Straße", "").strip() or None, - "plz": row.get("PLZ", "").strip() or None, - "ort": row.get("Ort", "").strip() or None, - "personentyp": personentyp, - "pachtnummer": row.get("Pachtnummer", "").strip() or None, - "landwirtschaftliche_ausbildung": row.get( - "Landwirtschaftliche_Ausbildung", "false" - ).lower() - == "true", - "berufserfahrung_jahre": ( - int(row.get("Berufserfahrung_Jahre", 0)) - if row.get("Berufserfahrung_Jahre") - else None - ), - "spezialisierung": row.get("Spezialisierung", "").strip() or None, - "notizen": row.get("Notizen", "").strip() or None, - "aktiv": row.get("Aktiv", "true").lower() - in ["true", "wahr", "ja", "1"], - } - - # Handle date fields - if row.get("Geburtsdatum"): - try: - paechter_data["geburtsdatum"] = datetime.strptime( - row["Geburtsdatum"], "%d.%m.%Y" - ).date() - except ValueError: - try: - paechter_data["geburtsdatum"] = datetime.strptime( - row["Geburtsdatum"], "%Y-%m-%d" - ).date() - except ValueError: - paechter_data["geburtsdatum"] = None - - if row.get("Pachtbeginn_Erste"): - try: - paechter_data["pachtbeginn_erste"] = datetime.strptime( - row["Pachtbeginn_Erste"], "%d.%m.%Y" - ).date() - except ValueError: - try: - paechter_data["pachtbeginn_erste"] = datetime.strptime( - row["Pachtbeginn_Erste"], "%Y-%m-%d" - ).date() - except ValueError: - paechter_data["pachtbeginn_erste"] = None - - if row.get("Pachtende_Letzte"): - try: - paechter_data["pachtende_letzte"] = datetime.strptime( - row["Pachtende_Letzte"], "%d.%m.%Y" - ).date() - except ValueError: - try: - paechter_data["pachtende_letzte"] = datetime.strptime( - row["Pachtende_Letzte"], "%Y-%m-%d" - ).date() - except ValueError: - paechter_data["pachtende_letzte"] = None - - # Handle decimal fields - if row.get("Pachtzins_Aktuell"): - try: - paechter_data["pachtzins_aktuell"] = float(row["Pachtzins_Aktuell"]) - except ValueError: - paechter_data["pachtzins_aktuell"] = None - - # Validate required fields - if personentyp == "gesellschaft": - # For companies, only Nachname is required - if not paechter_data["nachname"]: - error_log.append( - f"Zeile {row_num}: Nachname ist für Gesellschaften erforderlich" - ) - failed_rows += 1 - continue - else: - # For natural persons, only Nachname is required - if not paechter_data["nachname"]: - error_log.append( - f"Zeile {row_num}: Nachname ist für natürliche Personen erforderlich" - ) - failed_rows += 1 - continue - - # Check if paechter already exists - if personentyp == "gesellschaft": - # For companies, search by Nachname only - existing_paechter = Paechter.objects.filter( - nachname__iexact=paechter_data["nachname"], - personentyp="gesellschaft", - ).first() - else: - # For natural persons, search by Nachname only (since Vorname can be empty) - existing_paechter = Paechter.objects.filter( - nachname__iexact=paechter_data["nachname"], personentyp="natuerlich" - ).first() - - if existing_paechter: - # Update existing paechter - for field, value in paechter_data.items(): - if value is not None: - setattr(existing_paechter, field, value) - existing_paechter.save() - else: - # Create new paechter - Paechter.objects.create(**paechter_data) - - imported_rows += 1 - - except Exception as e: - error_log.append(f"Zeile {row_num}: {str(e)}") - failed_rows += 1 - - # Determine status - if failed_rows == 0: - status = "completed" - elif imported_rows > 0: - status = "partial" - else: - status = "failed" - - return { - "total_rows": total_rows, - "imported_rows": imported_rows, - "failed_rows": failed_rows, - "error_log": "\n".join(error_log) if error_log else None, - "status": status, - } - - -def process_laendereien_csv(csv_file, csv_import): - """Process CSV file for Ländereien import""" - decoded_file = csv_file.read().decode("utf-8") - # Handle both comma and semicolon separated files - if ";" in decoded_file.split("\n")[0]: - csv_data = csv.DictReader(io.StringIO(decoded_file), delimiter=";") - else: - csv_data = csv.DictReader(io.StringIO(decoded_file)) - - total_rows = 0 - imported_rows = 0 - failed_rows = 0 - error_log = [] - - last_gemeinde = None - for row_num, row in enumerate(csv_data, start=2): - total_rows += 1 - - try: - # Build case-insensitive access helpers (strip BOM, normalize separators) - def clean_key(key: str) -> str: - return (key or "").replace("\ufeff", "").replace("\ufeff", "").strip() - - normalized_row = {clean_key(k): (v or "").strip() for k, v in row.items()} - lower_row = { - clean_key(k).lower(): (v or "").strip() for k, v in row.items() - } - sanitized_row = { - clean_key(k) - .lower() - .replace("-", "_") - .replace(" ", "_"): (v or "") - .strip() - for k, v in row.items() - } - - def get_val(*keys): - # Try exact keys first, then case-insensitive - for key in keys: - if key in normalized_row: - return normalized_row[key] - for key in keys: - lk = key.lower() - if lk in lower_row: - return lower_row[lk] - sk = lk.replace("-", "_").replace(" ", "_") - if sk in sanitized_row: - return sanitized_row[sk] - return "" - - def parse_float(value): - if not value: - return 0 - # replace comma decimal if present - v = ( - value.replace(".", "").replace(",", ".") - if value.count(",") == 1 and value.count(".") > 1 - else value.replace(",", ".") - ) - try: - return float(v) - except ValueError: - return 0 - - # Map CSV columns to model fields (robust to header variants) - lfd_nr_val = get_val( - "Lfd_Nr", - "lfd_nr", - "LfdNr", - "lfdnr", - "laufende_nummer", - "laufende-nummer", - ) - land_data = { - "lfd_nr": lfd_nr_val, - "ew_nummer": get_val("EW_Nummer", "ew_nummer") or None, - "amtsgericht": get_val("Amtsgericht", "amtsgericht"), - "gemeinde": get_val("Gemeinde", "gemeinde"), - "gemarkung": get_val("Gemarkung", "gemarkung"), - "flur": get_val("Flur", "flur"), - "flurstueck": get_val( - "Flurstück", "Flurstueck", "flurstück", "flurstueck" - ), - "groesse_qm": parse_float( - get_val("Größe_qm", "Groesse_qm", "groesse_qm", "größe_qm") - ), - "gruenland_qm": parse_float( - get_val( - "Grünland_qm", "Gruenland_qm", "gruenland_qm", "grünland_qm" - ) - ), - "acker_qm": parse_float(get_val("Acker_qm", "acker_qm")), - "wald_qm": parse_float(get_val("Wald_qm", "wald_qm")), - "sonstiges_qm": parse_float(get_val("Sonstiges_qm", "sonstiges_qm")), - "verpachtete_gesamtflaeche": parse_float( - get_val( - "Verpachtete_Gesamtfläche_qm", - "Verpachtete_Gesamtflaeche_qm", - "verpachtete_gesamtfläche_qm", - "verpachtete_gesamtflaeche_qm", - ) - ), - "verp_flaeche_aktuell": parse_float( - get_val( - "Verp_Fläche_aktuell_qm", - "Verp_Flaeche_aktuell_qm", - "verp_flaeche_aktuell_qm", - "verp_fläche_aktuell_qm", - ) - ), - "aktiv": get_val("Aktiv", "aktiv").lower() - in ["true", "wahr", "ja", "1"], - "notizen": get_val("Notizen", "notizen") or None, - } - - # Fallback for missing 'Gemeinde' -> set explicit placeholder - if not land_data["gemeinde"]: - land_data["gemeinde"] = "FEHLT" - - # Validate required fields - required_fields = ["lfd_nr", "gemeinde", "gemarkung", "flur", "flurstueck"] - missing_fields = [ - field for field in required_fields if not land_data[field] - ] - - if missing_fields: - # Log header diagnostics on first failure only to help debugging - if row_num == 2: - error_log.append(f"Erkannte Spalten: {list(normalized_row.keys())}") - error_log.append( - f"Zeile {row_num}: Fehlende Pflichtfelder: {', '.join(missing_fields)}" - ) - failed_rows += 1 - continue - - # Check if land already exists - existing_land = Land.objects.filter(lfd_nr=land_data["lfd_nr"]).first() - - if existing_land: - # Update existing land - for field, value in land_data.items(): - if value is not None: - setattr(existing_land, field, value) - existing_land.save() - else: - # Create new land - Land.objects.create(**land_data) - - imported_rows += 1 - if land_data["gemeinde"]: - last_gemeinde = land_data["gemeinde"] - - except Exception as e: - error_log.append(f"Zeile {row_num}: {str(e)}") - failed_rows += 1 - - # Determine status - if failed_rows == 0: - status = "completed" - elif imported_rows > 0: - status = "partial" - else: - status = "failed" - - return { - "total_rows": total_rows, - "imported_rows": imported_rows, - "failed_rows": failed_rows, - "error_log": "\n".join(error_log) if error_log else None, - "status": status, - } - - -# Person Views -@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 paechter_list(request): - search_query = request.GET.get("search", "") - ausbildung_filter = request.GET.get("ausbildung", "") - aktiv_filter = request.GET.get("aktiv", "") - sort = request.GET.get("sort", "") - direction = request.GET.get("dir", "asc") - - paechter = Paechter.objects.all() - - if search_query: - paechter = paechter.filter( - Q(nachname__icontains=search_query) - | Q(vorname__icontains=search_query) - | Q(email__icontains=search_query) - | Q(pachtnummer__icontains=search_query) - ) - - if ausbildung_filter == "true": - paechter = paechter.filter(landwirtschaftliche_ausbildung=True) - elif ausbildung_filter == "false": - paechter = paechter.filter(landwirtschaftliche_ausbildung=False) - - if aktiv_filter == "true": - paechter = paechter.filter(aktiv=True) - elif aktiv_filter == "false": - paechter = paechter.filter(aktiv=False) - - # Annotate with total leased area and rent (coalesce nulls to Decimal for stable sorting) - paechter = paechter.annotate( - gesamt_flaeche=Coalesce( - Sum("neue_verpachtungen__verpachtete_flaeche"), - Value( - Decimal("0.00"), - output_field=DecimalField(max_digits=12, decimal_places=2), - ), - output_field=DecimalField(max_digits=12, decimal_places=2), - ), - gesamt_pachtzins=Coalesce( - Sum("neue_verpachtungen__pachtzins_pauschal"), - 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 = { - "name": ["nachname", "vorname"], - "pachtnummer": ["pachtnummer"], - "ausbildung": ["landwirtschaftliche_ausbildung"], - "spezialisierung": ["spezialisierung"], - "flaeche": ["gesamt_flaeche"], - "pachtzins": ["gesamt_pachtzins"], - "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 - paechter = paechter.order_by(*order_fields) - - paginator = Paginator(paechter, 20) - page_number = request.GET.get("page") - page_obj = paginator.get_page(page_number) - - context = { - "page_obj": page_obj, - "search_query": search_query, - "ausbildung_filter": ausbildung_filter, - "aktiv_filter": aktiv_filter, - "sort": sort, - "dir": direction, - } - return render(request, "stiftung/paechter_list.html", context) - - -@login_required -def paechter_detail(request, pk): - paechter = get_object_or_404(Paechter, pk=pk) - - # Alle mit diesem Pächter verknüpften Dokumente laden - verknuepfte_dokumente = DokumentLink.objects.filter( - paechter_id=paechter.pk - ).order_by("kontext", "titel") - - # Neue LandVerpachtungen für diesen Pächter laden - verpachtungen = LandVerpachtung.objects.filter(paechter=paechter).order_by( - "-pachtbeginn" - ) - - # Neue gepachtete Ländereien (über aktueller_paechter) - gepachtete_laendereien = paechter.gepachtete_laendereien.filter( - aktiv=True - ).order_by("gemeinde", "gemarkung") - - # Statistiken berechnen - total_flaeche_neu = sum( - land.verp_flaeche_aktuell or 0 for land in gepachtete_laendereien - ) - total_pachtzins_neu = sum( - land.pachtzins_pauschal or 0 for land in gepachtete_laendereien - ) - - context = { - "paechter": paechter, - "verknuepfte_dokumente": verknuepfte_dokumente, - "verpachtungen": verpachtungen, # Now using LandVerpachtung - "gepachtete_laendereien": gepachtete_laendereien, # Neu - "total_flaeche_neu": total_flaeche_neu, - "total_pachtzins_neu": total_pachtzins_neu, - } - return render(request, "stiftung/paechter_detail.html", context) - - -@login_required -def paechter_create(request): - if request.method == "POST": - form = PaechterForm(request.POST) - if form.is_valid(): - paechter = form.save() - messages.success( - request, - f'Pächter "{paechter.get_full_name()}" wurde erfolgreich erstellt.', - ) - return redirect("stiftung:paechter_detail", pk=paechter.pk) - else: - # Debug: Log form errors and show them to user - print(f"Form errors: {form.errors}") - print(f"Form data: {request.POST}") - messages.error(request, f"Formular-Fehler: {form.errors}") - for field, errors in form.errors.items(): - for error in errors: - messages.error(request, f"{field}: {error}") - else: - form = PaechterForm() - - context = {"form": form, "title": "Neuen Pächter erstellen"} - return render(request, "stiftung/paechter_form.html", context) - - -@login_required -def paechter_update(request, pk): - paechter = get_object_or_404(Paechter, pk=pk) - if request.method == "POST": - form = PaechterForm(request.POST, instance=paechter) - if form.is_valid(): - paechter = form.save() - messages.success( - request, - f'Pächter "{paechter.get_full_name()}" wurde erfolgreich aktualisiert.', - ) - return redirect("stiftung:paechter_detail", pk=paechter.pk) - else: - # Debug: Log form errors and show them to user - print(f"Form errors: {form.errors}") - print(f"Form data: {request.POST}") - messages.error(request, f"Formular-Fehler: {form.errors}") - for field, errors in form.errors.items(): - for error in errors: - messages.error(request, f"{field}: {error}") - else: - form = PaechterForm(instance=paechter) - - context = { - "form": form, - "paechter": paechter, - "title": f"Pächter bearbeiten: {paechter.get_full_name()}", - } - return render(request, "stiftung/paechter_form.html", context) - - -@login_required -def paechter_delete(request, pk): - paechter = get_object_or_404(Paechter, pk=pk) - if request.method == "POST": - paechter.delete() - messages.success( - request, f'Pächter "{paechter.get_full_name()}" wurde erfolgreich gelöscht.' - ) - return redirect("stiftung:paechter_list") - - context = {"paechter": paechter} - return render(request, "stiftung/paechter_confirm_delete.html", context) - - -# Land Views -@login_required -def land_list(request): - search_query = request.GET.get("search", "") - gemeinde_filter = request.GET.get("gemeinde", "") - aktiv_filter = request.GET.get("aktiv", "") - sort = request.GET.get("sort", "") - direction = request.GET.get("dir", "asc") - - lands = Land.objects.all() - - if search_query: - lands = lands.filter( - Q(lfd_nr__icontains=search_query) - | Q(gemeinde__icontains=search_query) - | Q(gemarkung__icontains=search_query) - | Q(flur__icontains=search_query) - | Q(flurstueck__icontains=search_query) - ) - - if gemeinde_filter: - lands = lands.filter(gemeinde=gemeinde_filter) - - if aktiv_filter == "true": - lands = lands.filter(aktiv=True) - elif aktiv_filter == "false": - lands = lands.filter(aktiv=False) - - # Annotate with verpachtungsgrad and numeric casts for natural sorting - # Prepare numeric versions of textual fields by stripping common non-digits - def digits_only(field_expr): - expr = Replace(field_expr, Value(" "), Value("")) - expr = Replace(expr, Value("-"), Value("")) - expr = Replace(expr, Value("."), Value("")) - expr = Replace(expr, Value("/"), Value("")) - expr = Replace(expr, Value("L"), Value("")) - return expr - - lands = lands.extra( - select={ - "verpachtungsgrad": "CASE WHEN groesse_qm > 0 THEN (verp_flaeche_aktuell / groesse_qm) * 100 ELSE 0 END" - } - ).annotate( - lfd_nr_num=Cast(NullIf(digits_only(F("lfd_nr")), Value("")), IntegerField()), - flur_num=Cast(NullIf(digits_only(F("flur")), Value("")), IntegerField()), - flurstueck_num=Cast( - NullIf(digits_only(F("flurstueck")), Value("")), IntegerField() - ), - ) - - # Sorting - sort_map = { - "lfd_nr": ["lfd_nr_num", "lfd_nr"], - "gemeinde": ["gemeinde"], - "gemarkung": ["gemarkung"], - "flur": ["flur_num", "flur"], - "flurstueck": ["flurstueck_num", "flurstueck"], - "groesse": ["groesse_qm"], - "verp": ["verp_flaeche_aktuell"], - "grad": ["verpachtungsgrad"], - } - if sort in sort_map: - fields = sort_map[sort] - if direction == "desc": - order_fields = [f"-{f}" for f in fields] - else: - order_fields = fields - lands = lands.order_by(*order_fields) - - # Aggregated statistics for current filter set - aggregates = lands.aggregate( - sum_groesse_qm=Sum("groesse_qm"), - sum_gruenland_qm=Sum("gruenland_qm"), - sum_acker_qm=Sum("acker_qm"), - sum_wald_qm=Sum("wald_qm"), - sum_sonstiges_qm=Sum("sonstiges_qm"), - ) - sum_groesse_qm = float(aggregates.get("sum_groesse_qm") or 0) - sum_gruenland_qm = float(aggregates.get("sum_gruenland_qm") or 0) - sum_acker_qm = float(aggregates.get("sum_acker_qm") or 0) - sum_wald_qm = float(aggregates.get("sum_wald_qm") or 0) - sum_sonstiges_qm = float(aggregates.get("sum_sonstiges_qm") or 0) - sum_total_use_qm = sum_gruenland_qm + sum_acker_qm + sum_wald_qm + sum_sonstiges_qm - - # Calculate verpachtung statistics - total_plots = lands.count() - verpachtete_plots = lands.filter(verp_flaeche_aktuell__gt=0).count() - unveerpachtete_plots = total_plots - verpachtete_plots - - def pct(part, total): - return round((part / total) * 100, 1) if total and part is not None else 0.0 - - stats = { - "sum_groesse_qm": sum_groesse_qm, - "sum_gruenland_qm": sum_gruenland_qm, - "sum_acker_qm": sum_acker_qm, - "sum_wald_qm": sum_wald_qm, - "sum_sonstiges_qm": sum_sonstiges_qm, - "sum_total_use_qm": sum_total_use_qm, - "pct_gruenland": pct(sum_gruenland_qm, sum_total_use_qm), - "pct_acker": pct(sum_acker_qm, sum_total_use_qm), - "pct_wald": pct(sum_wald_qm, sum_total_use_qm), - "total_plots": total_plots, - "verpachtete_plots": verpachtete_plots, - "unveerpachtete_plots": unveerpachtete_plots, - "pct_verpachtet": pct(verpachtete_plots, total_plots), - "pct_unveerpachtet": pct(unveerpachtete_plots, total_plots), - } - - # Prepare size chart data (top 30 by size) - top_sizes = list( - lands.order_by("-groesse_qm").values_list("lfd_nr", "groesse_qm")[:30] - ) - size_chart_labels = [label or "" for label, _ in top_sizes] - size_chart_values = [float(val or 0) for _, val in top_sizes] - - paginator = Paginator(lands, 20) - page_number = request.GET.get("page") - page_obj = paginator.get_page(page_number) - - # Get unique gemeinden for filter - gemeinden = ( - Land.objects.values_list("gemeinde", flat=True).distinct().order_by("gemeinde") - ) - - context = { - "page_obj": page_obj, - "search_query": search_query, - "gemeinde_filter": gemeinde_filter, - "aktiv_filter": aktiv_filter, - "gemeinden": gemeinden, - "stats": stats, - "size_chart_labels_json": json.dumps(size_chart_labels), - "size_chart_values_json": json.dumps(size_chart_values), - "sort": sort, - "dir": direction, - } - return render(request, "stiftung/land_list.html", context) - - -@login_required -def land_detail(request, pk): - land = get_object_or_404(Land, pk=pk) - - # Alle mit dieser Länderei verknüpften Dokumente laden - verknuepfte_dokumente = DokumentLink.objects.filter(land_id=land.pk).order_by( - "kontext", "titel" - ) - - # Neue LandVerpachtungen laden (mit related data) - neue_verpachtungen = land.neue_verpachtungen.select_related("paechter").order_by( - "-pachtbeginn" - ) - - context = { - "land": land, - "verknuepfte_dokumente": verknuepfte_dokumente, - "verpachtungen": neue_verpachtungen, # Using only new system now - "neue_verpachtungen": neue_verpachtungen, - } - return render(request, "stiftung/land_detail.html", context) - - -@login_required -def land_create(request): - if request.method == "POST": - form = LandForm(request.POST) - - # Debug: Print form data - print("=== LAND CREATE DEBUG ===") - print(f"POST data: {dict(request.POST)}") - print(f"Form is valid: {form.is_valid()}") - - if not form.is_valid(): - print(f"Form errors: {form.errors}") - print(f"Form non-field errors: {form.non_field_errors()}") - # Add error messages for debugging - for field, errors in form.errors.items(): - for error in errors: - messages.error(request, f"{field}: {error}") - - if form.is_valid(): - try: - land = form.save() - messages.success(request, f'Länderei "{land}" wurde erfolgreich erstellt.') - print(f"Successfully created land: {land}") - return redirect("stiftung:land_detail", pk=land.pk) - except Exception as e: - print(f"Error saving land: {e}") - messages.error(request, f"Fehler beim Speichern: {str(e)}") - else: - messages.error(request, "Bitte korrigieren Sie die Fehler im Formular.") - else: - form = LandForm() - - context = {"form": form, "title": "Neue Länderei erstellen"} - return render(request, "stiftung/land_form.html", context) - - -@login_required -def land_update(request, pk): - land = get_object_or_404(Land, pk=pk) - if request.method == "POST": - form = LandForm(request.POST, instance=land) - if form.is_valid(): - land = form.save() - messages.success( - request, f'Länderei "{land}" wurde erfolgreich aktualisiert.' - ) - return redirect("stiftung:land_detail", pk=land.pk) - else: - form = LandForm(instance=land) - - context = {"form": form, "land": land, "title": f"Länderei bearbeiten: {land}"} - return render(request, "stiftung/land_form.html", context) - - -@login_required -def land_delete(request, pk): - land = get_object_or_404(Land, pk=pk) - if request.method == "POST": - land.delete() - messages.success(request, f'Länderei "{land}" wurde erfolgreich gelöscht.') - return redirect("stiftung:land_list") - - context = {"land": land} - return render(request, "stiftung/land_confirm_delete.html", context) - - -# Verpachtung Views -@login_required -def verpachtung_list(request): - search_query = request.GET.get("search", "") - status_filter = request.GET.get("status", "") - gemeinde_filter = request.GET.get("gemeinde", "") - sort = request.GET.get("sort", "") - direction = request.GET.get("dir", "asc") - - verpachtungen = LandVerpachtung.objects.select_related("land", "paechter").all() - - if search_query: - verpachtungen = verpachtungen.filter( - Q(vertragsnummer__icontains=search_query) - | Q(land__gemeinde__icontains=search_query) - | Q(paechter__nachname__icontains=search_query) - | Q(paechter__vorname__icontains=search_query) - ) - - if status_filter: - verpachtungen = verpachtungen.filter(status=status_filter) - - if gemeinde_filter: - verpachtungen = verpachtungen.filter(land__gemeinde=gemeinde_filter) - - # Sorting - sort_map = { - "vertragsnummer": ["vertragsnummer"], - "land": ["land__gemeinde"], - "paechter": ["paechter__nachname", "paechter__vorname"], - "beginn": ["pachtbeginn"], - "ende": ["pachtende"], - "flaeche": ["verpachtete_flaeche"], - "pachtzins": ["pachtzins_pauschal"], - "status": ["status"], - } - if sort in sort_map: - fields = sort_map[sort] - if direction == "desc": - order_fields = [f"-{f}" for f in fields] - else: - order_fields = fields - verpachtungen = verpachtungen.order_by(*order_fields) - - paginator = Paginator(verpachtungen, 20) - page_number = request.GET.get("page") - page_obj = paginator.get_page(page_number) - - # Calculate statistics for the summary cards - # Get ALL verpachtungen (not filtered) for accurate statistics - all_verpachtungen = LandVerpachtung.objects.select_related("land", "paechter").all() - - # Active verpachtungen count - aktive_verpachtungen = all_verpachtungen.filter(status="aktiv").count() - - # Total leased area (only active verpachtungen) - gesamt_flaeche_result = all_verpachtungen.filter(status="aktiv").aggregate( - total=Sum("verpachtete_flaeche") - ) - gesamt_flaeche = ( - gesamt_flaeche_result["total"] - if gesamt_flaeche_result["total"] is not None - else 0 - ) - - # Total annual rent (only active verpachtungen) - jaehrlicher_pachtzins_result = all_verpachtungen.filter(status="aktiv").aggregate( - total=Sum("pachtzins_pauschal") - ) - jaehrlicher_pachtzins = ( - jaehrlicher_pachtzins_result["total"] - if jaehrlicher_pachtzins_result["total"] is not None - else 0 - ) - - # Total count of all verpachtungen - anzahl_verpachtungen = all_verpachtungen.count() - - # Get unique gemeinden and statuses for filters - gemeinden = ( - Land.objects.values_list("gemeinde", flat=True).distinct().order_by("gemeinde") - ) - status_choices = LandVerpachtung.STATUS_CHOICES - - context = { - "page_obj": page_obj, - "search_query": search_query, - "status_filter": status_filter, - "gemeinde_filter": gemeinde_filter, - "gemeinden": gemeinden, - "status_choices": status_choices, - # Statistics for summary cards - "aktive_verpachtungen": aktive_verpachtungen, - "gesamt_flaeche": gesamt_flaeche, - "jaehrlicher_pachtzins": jaehrlicher_pachtzins, - "anzahl_verpachtungen": anzahl_verpachtungen, - "sort": sort, - "dir": direction, - } - return render(request, "stiftung/verpachtung_list.html", context) - - -@login_required -def land_verpachtung_detail(request, pk): - """Detail view for LandVerpachtung""" - verpachtung = get_object_or_404(LandVerpachtung, pk=pk) - - # Alle mit dieser Verpachtung verknüpften Dokumente laden - verknuepfte_dokumente = DokumentLink.objects.filter( - land_verpachtung_id=verpachtung.pk - ).order_by("kontext", "titel") - - context = { - "verpachtung": verpachtung, - "landverpachtung": verpachtung, # Template expects this variable name - "verknuepfte_dokumente": verknuepfte_dokumente, - } - return render(request, "stiftung/land_verpachtung_detail.html", context) - - -@login_required -def land_verpachtung_update(request, pk): - """Update an existing LandVerpachtung by its primary key""" - verpachtung = get_object_or_404(LandVerpachtung, pk=pk) - - if request.method == "POST": - # Handle the update form submission - vertragsnummer = request.POST.get("vertragsnummer") - pachtbeginn = request.POST.get("pachtbeginn") - pachtende = request.POST.get("pachtende") - pachtzins_pauschal = request.POST.get("pachtzins_pauschal") - - if vertragsnummer: - verpachtung.vertragsnummer = vertragsnummer - if pachtbeginn: - verpachtung.pachtbeginn = pachtbeginn - if pachtende: - verpachtung.pachtende = pachtende - if pachtzins_pauschal: - verpachtung.pachtzins_pauschal = pachtzins_pauschal - - verpachtung.save() - messages.success(request, "Verpachtung wurde erfolgreich aktualisiert.") - return redirect("stiftung:land_verpachtung_detail", pk=verpachtung.pk) - - context = { - "verpachtung": verpachtung, - "landverpachtung": verpachtung, # Template expects this variable name - "is_edit": True, - "is_update": True, # Form template uses this flag - } - return render(request, "stiftung/land_verpachtung_form.html", context) - - -@login_required -def land_verpachtung_end_direct(request, pk): - """End a LandVerpachtung directly by its primary key""" - verpachtung = get_object_or_404(LandVerpachtung, pk=pk) - - if request.method == "POST": - verpachtung.status = "beendet" - verpachtung.pachtende = timezone.now().date() - verpachtung.save() - messages.success(request, "Verpachtung wurde erfolgreich beendet.") - return redirect("stiftung:land_detail", pk=verpachtung.land.pk) - - context = { - "verpachtung": verpachtung, - } - return render(request, "stiftung/land_verpachtung_end_confirm.html", context) - - -# Förderung Views -@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 -@login_required -def dokument_list(request): - """Zeigt alle verknüpften Dokumente an""" - # Alle verknüpften Dokumente laden - dokumente = DokumentLink.objects.all().order_by("-id") - - # Paperless-API-Konfiguration für verfügbare Dokumente - import requests - - from stiftung.utils.config import get_paperless_config - - config = get_paperless_config() - url = config["api_url"] - token = config["api_token"] - - available_dokumente = [] - if url and token: - try: - base_url = url[:-4] if url.endswith("/api") else url - headers = {"Authorization": f"Token {token}"} - - # Alle verfügbaren Dokumente abrufen (mit Paginierung) - all_dokumente = [] - page = 1 - page_size = 100 - - while True: - response = requests.get( - f"{base_url}/api/documents/?page={page}&page_size={page_size}", - headers=headers, - timeout=10, - ) - response.raise_for_status() - data = response.json() - - all_dokumente.extend(data.get("results", [])) - - if not data.get("next"): - break - page += 1 - - # Stiftung-Dokumente filtern - for doc in all_dokumente: - try: - tags = [] - doc_tags = doc.get("tags", []) - - if isinstance(doc_tags, list): - for tag in doc_tags: - if isinstance(tag, dict) and "name" in tag: - tags.append(tag["name"]) - elif isinstance(tag, str): - tags.append(tag) - elif isinstance(tag, int): - tags.append(f"Tag_{tag}") - elif isinstance(doc_tags, str): - tags = [tag.strip() for tag in doc_tags.split(",")] - - if any( - tag - in [ - config["destinataere_tag"], - config["land_tag"], - config["admin_tag"], - ] - for tag in tags - ): - bereits_verknuepft = DokumentLink.objects.filter( - paperless_document_id=doc["id"] - ).exists() - - if not bereits_verknuepft: - available_dokumente.append( - { - "id": doc["id"], - "title": doc.get("title", f'Dokument {doc["id"]}'), - "created_date": doc.get("created_date", ""), - "tags": tags, - "thumbnail_url": f"{base_url}/api/documents/{doc['id']}/thumb/", - "document_url": f"{base_url}/documents/{doc['id']}/", - } - ) - except Exception: - continue - - # Nach Erstellungsdatum sortieren - available_dokumente.sort(key=lambda x: x["created_date"], reverse=True) - - except Exception: - pass - - context = { - "dokumente": dokumente, - "available_dokumente": available_dokumente, - "title": "Alle verknüpften Dokumente", - } - return render(request, "stiftung/dokument_list.html", context) - - -@login_required -def dokument_detail(request, pk): - """Show details of a specific document link""" - dokument = get_object_or_404(DokumentLink, pk=pk) - - context = { - "dokument": dokument, - "title": f"Dokument: {dokument}", - } - return render(request, "stiftung/dokument_detail.html", context) - - -@login_required -def dokument_create(request): - """Create a new document link""" - if request.method == "POST": - form = DokumentLinkForm(request.POST) - if form.is_valid(): - dokument = form.save() - messages.success( - request, f'Dokument "{dokument.titel}" wurde erfolgreich verknüpft.' - ) - - # Zurück zur verknüpften Entität leiten - if dokument.land_verpachtung_id: - return redirect( - "stiftung:verpachtung_detail", pk=dokument.land_verpachtung_id - ) - elif dokument.verpachtung_id: - return redirect( - "stiftung:verpachtung_detail", pk=dokument.verpachtung_id - ) - elif dokument.land_id: - return redirect("stiftung:land_detail", pk=dokument.land_id) - elif dokument.paechter_id: - return redirect("stiftung:paechter_detail", pk=dokument.paechter_id) - elif dokument.destinataer_id: - return redirect( - "stiftung:destinataer_detail", pk=dokument.destinataer_id - ) - elif dokument.foerderung_id: - return redirect("stiftung:foerderung_detail", pk=dokument.foerderung_id) - else: - return redirect("stiftung:dokument_detail", pk=dokument.pk) - else: - # Initial-Werte aus GET-Parametern setzen - initial_data = {} - if request.GET.get("land_verpachtung_id"): - initial_data["land_verpachtung_id"] = request.GET.get("land_verpachtung_id") - if request.GET.get("verpachtung"): - initial_data["verpachtung_id"] = request.GET.get("verpachtung") - if request.GET.get("land"): - initial_data["land_id"] = request.GET.get("land") - if request.GET.get("paechter"): - initial_data["paechter_id"] = request.GET.get("paechter") - if request.GET.get("destinataer"): - initial_data["destinataer_id"] = request.GET.get("destinataer") - if request.GET.get("foerderung"): - initial_data["foerderung_id"] = request.GET.get("foerderung") - - form = DokumentLinkForm(initial=initial_data) - - context = { - "form": form, - "title": "Neues Dokument verknüpfen", - } - return render(request, "stiftung/dokument_form.html", context) - - -@login_required -def dokument_update(request, pk): - """Update an existing document link""" - dokument = get_object_or_404(DokumentLink, pk=pk) - - if request.method == "POST": - form = DokumentLinkForm(request.POST, instance=dokument) - if form.is_valid(): - form.save() - messages.success( - request, f'Dokument "{dokument.titel}" wurde erfolgreich aktualisiert.' - ) - return redirect("stiftung:dokument_detail", pk=dokument.pk) - else: - form = DokumentLinkForm(instance=dokument) - - context = { - "form": form, - "dokument": dokument, - "title": f"Dokument bearbeiten: {dokument}", - } - return render(request, "stiftung/dokument_form.html", context) - - -@login_required -def dokument_delete(request, pk): - """Delete a document link""" - dokument = get_object_or_404(DokumentLink, pk=pk) - - if request.method == "POST": - dokument.delete() - messages.success( - request, f'Dokument "{dokument.titel}" wurde erfolgreich gelöscht.' - ) - return redirect("stiftung:dokument_list") - - context = { - "dokument": dokument, - "title": f"Dokument löschen: {dokument}", - } - return render(request, "stiftung/dokument_confirm_delete.html", context) - - -# Legacy document views removed - use dokument_management instead - - -# Jahresbericht Views -@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 land_stats_api(request): - """API endpoint for land statistics""" - if request.method == "GET": - gemeinde = request.GET.get("gemeinde", "") - - if gemeinde: - lands = Land.objects.filter(gemeinde=gemeinde) - else: - lands = Land.objects.all() - - stats = { - "total_count": lands.count(), - "total_flaeche": float( - lands.aggregate(total=Sum("groesse_qm"))["total"] or 0 - ), - "total_verpachtet": float( - LandVerpachtung.objects.filter( - status="aktiv", land__in=lands - ).aggregate(total=Sum("verpachtete_flaeche"))["total"] - or 0 - ), - "avg_verpachtungsgrad": 0, - } - - if stats["total_flaeche"] > 0: - stats["avg_verpachtungsgrad"] = ( - stats["total_verpachtet"] / stats["total_flaeche"] - ) * 100 - - return JsonResponse(stats) - - return JsonResponse({"error": "Invalid request method"}, status=400) - - -@api_view(["GET"]) -def health(_request): - return Response({"status": "ok"}) - - -@api_view(["GET"]) -def paperless_ping(_request): - from stiftung.utils.config import get_paperless_config - - config = get_paperless_config() - url = config["api_url"] - token = config["api_token"] - if not url or not token: - return Response( - {"ok": False, "reason": "Paperless API not configured"}, status=400 - ) - try: - # Entferne /api vom Ende der URL falls vorhanden - base_url = url[:-4] if url.endswith("/api") else url - r = requests.get( - f"{base_url}/api/tags/", - headers={"Authorization": f"Token {token}"}, - timeout=5, - ) - return Response({"ok": r.ok, "status_code": r.status_code}) - except Exception as e: - return Response({"ok": False, "error": str(e)}, status=500) - - -@api_view(["GET"]) -def paperless_documents(request): - """Holt Dokumente aus Paperless mit den erforderlichen Tags. - Optionales Polling: ?poll=1 wartet kurz und versucht erneut, damit frisch ingestete - Dokumente in Paperless erscheinen, bevor die Liste zurückgegeben wird. - """ - from stiftung.utils.config import get_paperless_config - - config = get_paperless_config() - url = config["api_url"] - token = config["api_token"] - required_tag = config["destinataere_tag"] - land_tag = config["land_tag"] - admin_tag = config["admin_tag"] - destinaere_tag_id = config["destinataere_tag_id"] - land_tag_id = config["land_tag_id"] - admin_tag_id = config["admin_tag_id"] - - if not url or not token: - return Response( - { - "error": "Paperless API not configured", - "message": "Please set PAPERLESS_API_URL and PAPERLESS_API_TOKEN in your environment variables", - "documents": [], - "total_destinaere": 0, - "total_land": 0, - "total_admin": 0, - "total_all": 0, - }, - status=400, - ) - - try: - # Entferne /api vom Ende der URL falls vorhanden - base_url = url[:-4] if url.endswith("/api") else url - headers = {"Authorization": f"Token {token}"} - - def fetch_tagged(): - # mit ordering=-created neueste zuerst - dest_resp = requests.get( - f"{base_url}/api/documents/?tags__id={destinaere_tag_id}&ordering=-created", - headers=headers, - timeout=10, - ) - dest_resp.raise_for_status() - dest_docs = dest_resp.json() - - land_resp = requests.get( - f"{base_url}/api/documents/?tags__id={land_tag_id}&ordering=-created", - headers=headers, - timeout=10, - ) - land_resp.raise_for_status() - land_docs = land_resp.json() - - admin_resp = requests.get( - f"{base_url}/api/documents/?tags__id={admin_tag_id}&ordering=-created", - headers=headers, - timeout=10, - ) - admin_resp.raise_for_status() - admin_docs = admin_resp.json() - - return dest_docs, land_docs, admin_docs - - dest_docs, land_docs, admin_docs = fetch_tagged() - - # Optionales kurzes Polling, wenn angefordert - if request.GET.get("poll") in ("1", "true", "yes"): - start_total = sum( - [ - dest_docs.get("count", 0), - land_docs.get("count", 0), - admin_docs.get("count", 0), - ] - ) - deadline = time.time() + 6.0 # bis zu 6 Sekunden warten - while time.time() < deadline: - time.sleep(1.0) - d2, l2, a2 = fetch_tagged() - new_total = sum( - [d2.get("count", 0), l2.get("count", 0), a2.get("count", 0)] - ) - if new_total > start_total: - dest_docs, land_docs, admin_docs = d2, l2, a2 - break - - # Alle Dokumente zusammenfassen - all_documents = [] - for doc in dest_docs.get("results", []): - doc["tag_category"] = "destinaere" - all_documents.append(doc) - for doc in land_docs.get("results", []): - doc["tag_category"] = "land" - all_documents.append(doc) - for doc in admin_docs.get("results", []): - doc["tag_category"] = "admin" - all_documents.append(doc) - - return Response( - { - "documents": all_documents, - "total_destinaere": dest_docs.get("count", 0), - "total_land": land_docs.get("count", 0), - "total_admin": admin_docs.get("count", 0), - "total_all": len(all_documents), - } - ) - - except requests.exceptions.RequestException as e: - import logging - logger = logging.getLogger(__name__) - logger.error(f"Paperless API request failed: {e}") - logger.error(f"Paperless API URL: {base_url}") - logger.error(f"Token configured: {'Yes' if token else 'No'}") - - return Response( - { - "error": f"API-Fehler: {e}", - "message": f"Could not connect to Paperless API at {base_url}. Please check your configuration.", - "debug_info": { - "api_url": base_url, - "has_token": bool(token), - "error_type": type(e).__name__ - }, - "documents": [], - "total_destinaere": 0, - "total_land": 0, - "total_admin": 0, - "total_all": 0, - }, - status=500, - ) - except Exception as e: - return Response( - { - "error": f"Unerwarteter Fehler: {e}", - "message": "An unexpected error occurred while fetching documents.", - "documents": [], - "total_destinaere": 0, - "total_land": 0, - "total_admin": 0, - "total_all": 0, - }, - status=500, - ) - - -# Legacy dokument_integration view removed - use dokument_management instead - - -@api_view(["GET"]) -def paperless_debug(request): - """Debug-View für Paperless-Integration""" - from stiftung.utils.config import get_paperless_config - - config = get_paperless_config() - url = config["api_url"] - token = config["api_token"] - required_tag = config["destinataere_tag"] - land_tag = config["land_tag"] - admin_tag = config["admin_tag"] - destinaere_tag_id = config["destinataere_tag_id"] - land_tag_id = config["land_tag_id"] - admin_tag_id = config["admin_tag_id"] - - if not url or not token: - return Response({"error": "Paperless API not configured"}, status=400) - - try: - # Entferne /api vom Ende der URL falls vorhanden - base_url = url[:-4] if url.endswith("/api") else url - - headers = {"Authorization": f"Token {token}"} - - # Alle Tags abrufen - tags_response = requests.get( - f"{base_url}/api/tags/?page_size=1000", headers=headers, timeout=10 - ) - tags_response.raise_for_status() - tags_data = tags_response.json() - - # Alle Tags durchsuchen - all_tags = tags_data.get("results", []) - exact_match_destinaere = None - exact_match_land = None - exact_match_admin = None - similar_tags = [] - - # Nach den neuen Tag-Namen suchen (mit Unterstrichen) - for tag in all_tags: - tag_name = tag.get("name", "") - tag_id = tag.get("id") - - # Suche nach den neuen Tag-Namen - if tag_name == "Stiftung_Destinatäre": - exact_match_destinaere = {"id": tag_id, "name": tag_name} - elif tag_name == "Stiftung_Land_und_Pächter": - exact_match_land = {"id": tag_id, "name": tag_name} - elif tag_name == "Stiftung_Administration": - exact_match_admin = {"id": tag_id, "name": tag_name} - - # Ähnliche Tags finden - if ( - "stiftung" in tag_name.lower() - or "destinat" in tag_name.lower() - or "land" in tag_name.lower() - or "admin" in tag_name.lower() - ): - similar_tags.append({"id": tag_id, "name": tag_name}) - - # Alle Tag-Namen sammeln - all_tag_names = [tag.get("name", "") for tag in all_tags] - - # Dokumente abrufen - documents_response = requests.get( - f"{base_url}/api/documents/?page_size=10", headers=headers, timeout=10 - ) - documents_response.raise_for_status() - documents_data = documents_response.json() - - # Stiftung-Dokumente finden (mit Tag 21 "Stiftung") - stiftung_documents = [] - for doc in documents_data.get("results", []): - doc_tags = doc.get("tags", []) - if 21 in doc_tags: # Tag 21 ist "Stiftung" - stiftung_documents.append(doc) - - # Sample-Dokumente mit Tag-Namen anreichern - sample_documents = documents_data.get("results", [])[:5] - enriched_documents = [] - - for doc in sample_documents: - doc_copy = doc.copy() - tag_names = [] - for tag_id in doc.get("tags", []): - # Tag-Namen aus der Tag-Liste finden - tag_name = next( - ( - tag.get("name", f"Unknown({tag_id})") - for tag in all_tags - if tag.get("id") == tag_id - ), - f"Unknown({tag_id})", - ) - tag_names.append(tag_name) - doc_copy["tag_names"] = tag_names - enriched_documents.append(doc_copy) - - return Response( - { - "paperless_url": url, - "base_url": base_url, - "required_tag": required_tag, - "land_tag": land_tag, - "admin_tag": admin_tag, - "destinaere_tag_id": destinaere_tag_id, - "land_tag_id": land_tag_id, - "admin_tag_id": admin_tag_id, - "exact_match_destinaere": exact_match_destinaere, - "exact_match_land": exact_match_land, - "exact_match_admin": exact_match_admin, - "similar_tags": similar_tags, - "all_tag_names": all_tag_names, - "total_tags": len(all_tags), - "total_documents": documents_data.get("count", 0), - "sample_documents": sample_documents, - "api_token_length": len(token) if token else 0, - "enriched_documents": enriched_documents, - "stiftung_documents": stiftung_documents, - } - ) - - except requests.exceptions.RequestException as e: - return Response({"error": f"API-Fehler: {e}"}, status=500) - except Exception as e: - return Response({"error": f"Unerwarteter Fehler: {e}"}, status=500) - - -@api_view(["GET"]) -def paperless_tags_only(request): - """Holt nur die Tag-Liste aus Paperless - ohne Dokumente""" - from stiftung.utils.config import get_paperless_config - - config = get_paperless_config() - url = config["api_url"] - token = config["api_token"] - - if not url or not token: - return Response({"error": "Paperless API not configured"}, status=400) - - try: - # Entferne /api vom Ende der URL falls vorhanden - base_url = url[:-4] if url.endswith("/api") else url - - # Alle Tags abrufen (mit großer page_size) - headers = {"Authorization": f"Token {token}"} - - # Erste Anfrage mit großer page_size - tags_response = requests.get( - f"{base_url}/api/tags/?page_size=1000", headers=headers, timeout=10 - ) - tags_response.raise_for_status() - tags_data = tags_response.json() - - all_tags = [] - - # Erste Seite verarbeiten - for tag in tags_data.get("results", []): - tag_detail = { - "id": tag.get("id"), - "name": tag.get("name", ""), - "slug": tag.get("slug", ""), - "color": tag.get("color", ""), - "text_color": tag.get("text_color", ""), - "match": tag.get("match", ""), - "matching_algorithm": tag.get("matching_algorithm"), - "is_inbox_tag": tag.get("is_inbox_tag"), - "document_count": tag.get("document_count", 0), - } - all_tags.append(tag_detail) - - # Weitere Seiten abrufen falls vorhanden - next_url = tags_data.get("next") - while next_url: - next_response = requests.get(next_url, headers=headers, timeout=10) - next_response.raise_for_status() - next_data = next_response.json() - - for tag in next_data.get("results", []): - tag_detail = { - "id": tag.get("id"), - "name": tag.get("name", ""), - "slug": tag.get("slug", ""), - "color": tag.get("color", ""), - "text_color": tag.get("text_color", ""), - "match": tag.get("match", ""), - "matching_algorithm": tag.get("matching_algorithm"), - "is_inbox_tag": tag.get("is_inbox_tag"), - "document_count": tag.get("document_count", 0), - } - all_tags.append(tag_detail) - - next_url = next_data.get("next") - - # Nach ID sortieren - all_tags.sort(key=lambda x: x["id"]) - - return Response( - { - "total_tags": len(all_tags), - "tags": all_tags, - "tag_ids": [tag["id"] for tag in all_tags], - "tag_names": [tag["name"] for tag in all_tags], - "api_info": { - "page_size_used": 1000, - "total_count_from_api": tags_data.get("count", 0), - }, - } - ) - - except requests.exceptions.RequestException as e: - return Response({"error": f"API-Fehler: {e}"}, status=500) - except Exception as e: - return Response({"error": f"Unerwarteter Fehler: {e}"}, status=500) - - -@api_view(["GET"]) -def link_document_search(request): - """Sucht nach Datensätzen für die Dokument-Verknüpfung""" - from django.db.models import Q - - query = request.GET.get("q", "") - category = request.GET.get("category", "all") - - results = {} - - if category in ["all", "destinataer"]: - # Suche nach Destinatären - destinataer_query = Q() - if query and query != "all": - destinataer_query = ( - Q(nachname__icontains=query) - | Q(vorname__icontains=query) - | Q(email__icontains=query) - | Q(telefon__icontains=query) - | Q(strasse__icontains=query) - | Q(ort__icontains=query) - | Q(plz__icontains=query) - | Q(institution__icontains=query) - | Q(familienzweig__icontains=query) - | Q(notizen__icontains=query) - ) - - destinataer_results = Destinataer.objects.filter(destinataer_query)[:25] - results["destinataer"] = [ - { - "id": d.id, - "name": ( - f"{d.vorname} {d.nachname}".strip() - if d.vorname - else (d.institution or d.nachname) - ), - "type": "Destinatär", - "details": f"{d.strasse or ''}{(', ' + d.plz + ' ' + d.ort) if d.plz and d.ort else (' ' + d.ort) if d.ort else ''} {('• ' + d.email) if d.email else ''} {('• ' + d.institution) if d.institution else ''} {('• ' + d.familienzweig) if d.familienzweig else ''}".strip(), - } - for d in destinataer_results - ] - - if category in ["all", "land"]: - # Suche nach Ländereien - land_query = Q() - if query and query != "all": - # Extract numbers from search terms like "Flur 9" or "Flurstück 11" - import re - - flur_match = re.search(r"flur\s*(\d+)", query, re.IGNORECASE) - flurstuck_match = re.search(r"flurstück\s*(\d+)", query, re.IGNORECASE) - - land_query = ( - Q(gemarkung__icontains=query) - | Q(gemeinde__icontains=query) - | Q(flur__icontains=query) - | Q(flurstueck__icontains=query) - | Q(lfd_nr__icontains=query) - | Q(ew_nummer__icontains=query) - | Q(notizen__icontains=query) - ) - - # Add specific searches for extracted numbers - if flur_match: - land_query |= Q(flur__exact=flur_match.group(1)) - if flurstuck_match: - land_query |= Q(flurstueck__exact=flurstuck_match.group(1)) - - land_results = Land.objects.filter(land_query)[:25] - results["land"] = [ - { - "id": l.id, - "name": f"{l.gemarkung} - {l.gemeinde}, Flur {l.flur}, Flurstück {l.flurstueck or 'N/A'}", - "type": "Land", - "details": f"Lfd.Nr: {l.lfd_nr or 'N/A'} • EW-Nr: {l.ew_nummer or 'N/A'} • Größe: {l.groesse_qm or 0} m²", - } - for l in land_results - ] - - if category in ["all", "verpachtung"]: - # Suche nach Verpachtungen (using new LandVerpachtung model) - verpachtung_query = Q() - if query and query != "all": - verpachtung_query = ( - Q(paechter__nachname__icontains=query) - | Q(paechter__vorname__icontains=query) - | Q(paechter__ort__icontains=query) - | Q(paechter__email__icontains=query) - | Q(paechter__pachtnummer__icontains=query) - | Q(land__gemarkung__icontains=query) - | Q(land__gemeinde__icontains=query) - | Q(land__flur__icontains=query) - | Q(land__flurstueck__icontains=query) - | Q(land__lfd_nr__icontains=query) - | Q(vertragsnummer__icontains=query) - | Q(pachtzins_pauschal__icontains=query) - | Q(bemerkungen__icontains=query) - ) - - verpachtung_results = LandVerpachtung.objects.filter( - verpachtung_query - ).select_related("paechter", "land")[:25] - results["verpachtung"] = [ - { - "id": v.id, - "name": f"{(v.paechter.vorname + ' ') if v.paechter and v.paechter.vorname else ''}{v.paechter.nachname if v.paechter else 'Unbekannt'} → {v.land.gemarkung}, Flur {v.land.flur}", - "type": "Verpachtung", - "details": f"Vertrag: {v.vertragsnummer} • Flurstück: {v.land.flurstueck or 'N/A'} • Pachtzins: {v.pachtzins_pauschal or 'N/A'} €/Jahr • Zeitraum: {v.pachtbeginn.strftime('%Y') if v.pachtbeginn else '?'}-{v.pachtende.strftime('%Y') if v.pachtende else 'laufend'}", - } - for v in verpachtung_results - ] - - if category in ["all", "paechter"]: - # Suche nach Pächtern - paechter_query = Q() - if query and query != "all": - paechter_query = ( - Q(nachname__icontains=query) - | Q(vorname__icontains=query) - | Q(ort__icontains=query) - | Q(email__icontains=query) - | Q(telefon__icontains=query) - | Q(strasse__icontains=query) - | Q(pachtnummer__icontains=query) - | Q(plz__icontains=query) - | Q(notizen__icontains=query) - ) - paechter_results = Paechter.objects.filter(paechter_query)[:25] - results["paechter"] = [ - { - "id": p.id, - "name": f"{(p.vorname + ' ') if p.vorname else ''}{p.nachname}" - + (f" (#{p.pachtnummer})" if p.pachtnummer else ""), - "type": "Pächter", - "details": f"{p.strasse or ''}{(', ' + p.plz + ' ' + p.ort) if p.plz and p.ort else (' ' + p.ort) if p.ort else ''} {('• ' + p.email) if p.email else ''} {('• Tel: ' + p.telefon) if p.telefon else ''}".strip(), - } - for p in paechter_results - ] - - if category in ["all", "rentmeister"]: - # Suche nach Rentmeistern - from stiftung.models import Rentmeister - - rentmeister_query = Q() - if query and query != "all": - rentmeister_query = ( - Q(nachname__icontains=query) - | Q(vorname__icontains=query) - | Q(ort__icontains=query) - | Q(email__icontains=query) - | Q(telefon__icontains=query) - | Q(strasse__icontains=query) - | Q(plz__icontains=query) - | Q(notizen__icontains=query) - | Q(titel__icontains=query) - | Q(mobil__icontains=query) - ) - rentmeister_results = Rentmeister.objects.filter(rentmeister_query)[:25] - results["rentmeister"] = [ - { - "id": r.id, - "name": f"{(r.anrede + ' ') if r.anrede else ''}{(r.vorname + ' ') if r.vorname else ''}{r.nachname}" - + (f" ({r.titel})" if r.titel else ""), - "type": "Rentmeister", - "details": f"{r.strasse or ''}{(', ' + r.plz + ' ' + r.ort) if r.plz and r.ort else (' ' + r.ort) if r.ort else ''} {('• ' + r.email) if r.email else ''} {('• Tel: ' + r.telefon) if r.telefon else ''} {('• Mobil: ' + r.mobil) if r.mobil else ''}".strip(), - } - for r in rentmeister_results - ] - - if category in ["all", "abrechnung"]: - # Suche nach Abrechnungen - abrechnung_query = Q() - if query and query != "all": - abrechnung_query = ( - Q(land__gemarkung__icontains=query) - | Q(land__gemeinde__icontains=query) - | Q(land__flur__icontains=query) - | Q(land__flurstueck__icontains=query) - | Q(land__lfd_nr__icontains=query) - | Q(abrechnungsjahr__icontains=query) - | Q(bemerkungen__icontains=query) - ) - - abrechnung_results = LandAbrechnung.objects.filter( - abrechnung_query - ).select_related("land")[:25] - results["abrechnung"] = [ - { - "id": a.id, - "name": f"Abrechnung {a.abrechnungsjahr} - {a.land.gemarkung}, Flur {a.land.flur}", - "type": "Abrechnung", - "details": f"Flurstück: {a.land.flurstueck or 'N/A'} • Jahr: {a.abrechnungsjahr} • Grundsteuer: {a.grundsteuer_betrag or 0} € • Versicherung: {a.versicherungen_betrag or 0} €", - } - for a in abrechnung_results - ] - - if category in ["all", "foerderung"]: - # Suche nach Förderungen - foerderung_query = Q() - if query and query != "all": - foerderung_query = ( - Q(destinataer__nachname__icontains=query) - | Q(destinataer__vorname__icontains=query) - | Q(destinataer__institution__icontains=query) - | Q(destinataer__email__icontains=query) - | Q(jahr__icontains=query) - | Q(betrag__icontains=query) - | Q(kategorie__icontains=query) - | Q(status__icontains=query) - | Q(bemerkungen__icontains=query) - ) - - foerderung_results = Foerderung.objects.filter(foerderung_query).select_related( - "destinataer" - )[:25] - results["foerderung"] = [ - { - "id": str(f.id), # Convert UUID to string for JSON serialization - "name": f"{f.destinataer.get_full_name() if f.destinataer else 'Unbekannt'} - {f.jahr}", - "type": "Förderung", - "details": f"Betrag: {f.betrag} € • Kategorie: {f.get_kategorie_display()} • Status: {f.get_status_display()} • Jahr: {f.jahr}", - } - for f in foerderung_results - ] - - return Response(results) - - -def create_paechter_link_for_verpachtung(paperless_id, paperless_title, verpachtung_id): - """Hilfsfunktion: Erstellt automatisch eine Pächter-Verknüpfung für eine Verpachtung""" - try: - # Hole die LandVerpachtung und den zugehörigen Pächter - verpachtung = LandVerpachtung.objects.select_related("paechter").get( - id=verpachtung_id - ) - if verpachtung.paechter: - # Prüfe, ob bereits eine Verknüpfung für dieses Dokument und diesen Pächter existiert - existing_link = DokumentLink.objects.filter( - paperless_document_id=paperless_id, paechter_id=verpachtung.paechter.id - ).first() - - if not existing_link: - # Erstelle automatische Pächter-Verknüpfung - DokumentLink.objects.create( - paperless_document_id=paperless_id, - titel=paperless_title, - kontext="paechter", - paechter_id=verpachtung.paechter.id, - ) - return True - except (LandVerpachtung.DoesNotExist, Exception): - pass - return False - - -@csrf_exempt -@api_view(["POST"]) -def link_document_create(request): - """Erstellt eine Verknüpfung zwischen einem Paperless-Dokument und einem Datensatz""" - from django.db import transaction - - try: - # Robust JSON parsing to tolerate Latin-1 encoded payloads (e.g., umlauts) - try: - payload = request.data - except Exception: - raw = request.body - try: - payload = json.loads(raw.decode("utf-8")) - except UnicodeDecodeError: - payload = json.loads(raw.decode("latin-1")) - - paperless_id = payload.get("paperless_id") - paperless_title = payload.get("paperless_title") - paperless_url = payload.get("paperless_url") - link_type = payload.get( - "link_type" - ) # 'destinataer', 'land', 'verpachtung', 'paechter', 'abrechnung' - link_id = payload.get("link_id") - - if not all([paperless_id, paperless_title, paperless_url, link_type, link_id]): - return Response({"error": "Alle Felder sind erforderlich"}, status=400) - - with transaction.atomic(): - # Erstelle den DokumentLink - dokument_link = DokumentLink.objects.create( - paperless_document_id=paperless_id, # Korrigiert: 'paperless_document_id' statt 'paperless_id' - titel=paperless_title, # Korrigiert: 'titel' statt 'title' - kontext="anderes", - ) - - # Setze die entsprechenden UUID-Felder basierend auf dem Link-Typ - if link_type == "destinataer": - dokument_link.destinataer_id = link_id - elif link_type == "land": - dokument_link.land_id = link_id - elif link_type == "verpachtung": - # Use new LandVerpachtung field instead of legacy - dokument_link.land_verpachtung_id = link_id - elif link_type == "paechter": - dokument_link.paechter_id = link_id - elif link_type == "foerderung": - dokument_link.foerderung_id = link_id - elif link_type == "rentmeister": - dokument_link.rentmeister_id = link_id - elif link_type == "abrechnung": - dokument_link.abrechnung_id = link_id - - dokument_link.save() - - # Log the document linking action - from stiftung.audit import log_link - - try: - # Get the linked entity name for logging - entity_name = paperless_title - if link_type == "destinataer": - from stiftung.models import Destinataer - - entity = Destinataer.objects.get(id=link_id) - target_name = entity.get_full_name() - elif link_type == "land": - from stiftung.models import Land - - entity = Land.objects.get(id=link_id) - target_name = str(entity) - elif link_type == "paechter": - from stiftung.models import Paechter - - entity = Paechter.objects.get(id=link_id) - target_name = f"{entity.vorname} {entity.nachname}".strip() - elif link_type == "foerderung": - from stiftung.models import Foerderung - - entity = Foerderung.objects.get(id=link_id) - target_name = f"{entity.destinataer.get_full_name() if entity.destinataer else 'Unbekannt'} - {entity.jahr}" - elif link_type == "verpachtung": - entity = LandVerpachtung.objects.get(id=link_id) - target_name = str(entity) - elif link_type == "rentmeister": - from stiftung.models import Rentmeister - - entity = Rentmeister.objects.get(id=link_id) - target_name = entity.get_full_name() - else: - target_name = f"ID {link_id}" - - log_link( - request=request, - entity_type="dokumentlink", - entity_id=str(dokument_link.id), - entity_name=entity_name, - target_type=link_type, - target_name=target_name, - ) - except Exception as e: - # Don't fail the main operation if logging fails - print(f"Audit logging failed: {e}") - - # Automatische Pächter-Verknüpfung NACH der Haupttransaktion - paechter_linked = False - if link_type == "verpachtung": - paechter_linked = create_paechter_link_for_verpachtung( - paperless_id, paperless_title, link_id - ) - - message = f"Dokument erfolgreich mit {link_type} verknüpft" - if paechter_linked: - message += " (automatisch auch mit Pächter verknüpft)" - - return Response( - {"success": True, "message": message, "dokument_id": dokument_link.id} - ) - - except Exception as e: - return Response( - {"error": f"Fehler beim Erstellen der Verknüpfung: {str(e)}"}, status=500 - ) - - -# Legacy dokument_verknuepfung view removed - use dokument_management instead - - -@api_view(["GET"]) -def link_document_list(request): - """Listet alle bestehenden Dokument-Verknüpfungen auf, gruppiert nach Paperless-Document-ID""" - try: - dokument_links = DokumentLink.objects.all().order_by("-id") - - # Group links by paperless_document_id to show multiple links per document - links_by_document = {} - - for link in dokument_links: - paperless_id = link.paperless_document_id - - if paperless_id not in links_by_document: - links_by_document[paperless_id] = { - "paperless_id": paperless_id, - "title": link.titel, - "paperless_url": f"/api/paperless/documents/{paperless_id}/", - "links": [], - } - - # Create link info - link_info = { - "id": str(link.id), # Ensure UUID is stringified - "kontext": link.kontext, - "link_type": None, - "linked_object": None, - } - - # Determine link type and get linked object details - if link.destinataer_id: - link_info["link_type"] = "destinataer" - try: - dest = Destinataer.objects.get(id=link.destinataer_id) - link_info["linked_object"] = { - "id": str(dest.id), - "type": "Destinatär", - "name": ( - f"{dest.vorname} {dest.nachname}".strip() - if dest.vorname - else dest.institution - ), - "details": ( - f"Institution: {dest.institution}" - if dest.institution - else f"Person: {dest.vorname} {dest.nachname}".strip() - ), - } - except Destinataer.DoesNotExist: - link_info["linked_object"] = { - "type": "Destinatär", - "name": "Gelöscht", - "details": "Datensatz nicht mehr verfügbar", - } - - elif link.land_id: - link_info["link_type"] = "land" - try: - land = Land.objects.get(id=link.land_id) - link_info["linked_object"] = { - "id": str(land.id), - "type": "Land", - "name": f"{land.gemarkung} - {land.gemeinde}", - "details": f"Flur: {land.flur}, Größe: {land.groesse_qm or 0} m²", - } - except Land.DoesNotExist: - link_info["linked_object"] = { - "type": "Land", - "name": "Gelöscht", - "details": "Datensatz nicht mehr verfügbar", - } - - elif link.paechter_id: - link_info["link_type"] = "paechter" - try: - p = Paechter.objects.get(id=link.paechter_id) - link_info["linked_object"] = { - "id": str(p.id), - "type": "Pächter", - "name": f"{(p.vorname + ' ') if p.vorname else ''}{p.nachname}", - "details": f"{p.ort or ''}", - } - except Paechter.DoesNotExist: - link_info["linked_object"] = { - "type": "Pächter", - "name": "Gelöscht", - "details": "Datensatz nicht mehr verfügbar", - } - - elif link.land_verpachtung_id: - link_info["link_type"] = "verpachtung" - try: - from stiftung.models import LandVerpachtung - - verp = LandVerpachtung.objects.select_related( - "paechter", "land" - ).get(id=link.land_verpachtung_id) - link_info["linked_object"] = { - "id": str(verp.id), - "type": "Verpachtung", - "name": f"Verpachtung {(verp.paechter.vorname + ' ') if verp.paechter and verp.paechter.vorname else ''}{verp.paechter.nachname if verp.paechter else 'Unbekannt'}", - "details": f"Land: {verp.land.gemarkung} - {verp.land.gemeinde}, Pächter: {(verp.paechter.vorname + ' ') if verp.paechter and verp.paechter.vorname else ''}{verp.paechter.nachname if verp.paechter else 'Unbekannt'}, Vertrag: {verp.vertragsnummer}", - } - except LandVerpachtung.DoesNotExist: - link_info["linked_object"] = { - "type": "Verpachtung", - "name": "Gelöscht", - "details": "Datensatz nicht mehr verfügbar", - } - - elif link.rentmeister_id: - link_info["link_type"] = "rentmeister" - try: - from stiftung.models import Rentmeister - - rentmeister = Rentmeister.objects.get(id=link.rentmeister_id) - link_info["linked_object"] = { - "id": str(rentmeister.id), - "type": "Rentmeister", - "name": f"{(rentmeister.anrede + ' ') if rentmeister.anrede else ''}{(rentmeister.vorname + ' ') if rentmeister.vorname else ''}{rentmeister.nachname}" - + (f" ({rentmeister.titel})" if rentmeister.titel else ""), - "details": f"Seit: {rentmeister.seit_datum.strftime('%d.%m.%Y') if rentmeister.seit_datum else 'Unbekannt'}" - + ( - f", Tel: {rentmeister.telefon}" - if rentmeister.telefon - else "" - ) - + (f", {rentmeister.email}" if rentmeister.email else ""), - "url": f"/geschaeftsfuehrung/rentmeister/{rentmeister.id}/", - } - except Rentmeister.DoesNotExist: - link_info["linked_object"] = { - "type": "Rentmeister", - "name": "Gelöscht", - "details": "Datensatz nicht mehr verfügbar", - } - - elif link.abrechnung_id: - link_info["link_type"] = "abrechnung" - try: - abrechnung = LandAbrechnung.objects.select_related("land").get( - id=link.abrechnung_id - ) - link_info["linked_object"] = { - "id": str(abrechnung.id), - "type": "Abrechnung", - "name": f"Abrechnung {abrechnung.abrechnungsjahr} - {abrechnung.land.gemarkung}", - "details": f"Land: {abrechnung.land.gemarkung} - {abrechnung.land.gemeinde}, Flur {abrechnung.land.flur}, Jahr {abrechnung.abrechnungsjahr}", - "url": f"/laendereien/abrechnungen/{abrechnung.id}/", - } - except LandAbrechnung.DoesNotExist: - link_info["linked_object"] = { - "type": "Abrechnung", - "name": "Gelöscht", - "details": "Datensatz nicht mehr verfügbar", - } - - links_by_document[paperless_id]["links"].append(link_info) - - # Convert to list format for frontend - results = list(links_by_document.values()) - - return Response( - { - "total_documents": len(results), - "total_links": sum(len(doc["links"]) for doc in results), - "links": results, - } - ) - - except Exception as e: - return Response( - {"error": f"Fehler beim Abrufen der Verknüpfungen: {str(e)}"}, status=500 - ) - - -@csrf_exempt -@api_view(["POST"]) -def link_document_update(request): - """Aktualisiert die Verknüpfung eines Dokuments (Re-Linking auf anderen Datensatz/Kontext).""" - from django.db import transaction - - try: - # Robust JSON parsing to tolerate Latin-1 encoded payloads (e.g., umlauts) - try: - payload = request.data - except Exception: - raw = request.body - try: - payload = json.loads(raw.decode("utf-8")) - except UnicodeDecodeError: - payload = json.loads(raw.decode("latin-1")) - - link_id = payload.get("link_id") - link_type = payload.get( - "link_type" - ) # 'destinataer' | 'land' | 'verpachtung' | 'paechter' | 'rentmeister' - link_target_id = payload.get("link_id_target") - if not all([link_id, link_type, link_target_id]): - return Response( - {"error": "link_id, link_type und link_id_target sind erforderlich"}, - status=400, - ) - - with transaction.atomic(): - link = DokumentLink.objects.get(id=link_id) - old_verpachtung_id = ( - link.verpachtung_id - ) # Merke alte Verpachtung für Cleanup - paperless_id_for_cleanup = link.paperless_document_id - titel_for_new_link = link.titel - - # Reset all associations first - link.destinataer_id = None - link.land_id = None - link.verpachtung_id = None - link.paechter_id = None - link.foerderung_id = None - link.rentmeister_id = None - link.kontext = link_type - - if link_type == "destinataer": - link.destinataer_id = link_target_id - elif link_type == "land": - link.land_id = link_target_id - elif link_type == "verpachtung": - link.verpachtung_id = link_target_id - elif link_type == "paechter": - link.paechter_id = link_target_id - elif link_type == "foerderung": - link.foerderung_id = link_target_id - elif link_type == "rentmeister": - link.rentmeister_id = link_target_id - else: - return Response({"error": "Ungültiger link_type"}, status=400) - - link.save() - - # Automatische Pächter-Verknüpfung und Cleanup NACH der Haupttransaktion - paechter_linked = False - if link_type == "verpachtung": - paechter_linked = create_paechter_link_for_verpachtung( - paperless_id_for_cleanup, titel_for_new_link, link_target_id - ) - - # Cleanup: Entferne verwaiste Pächter-Verknüpfungen wenn von Verpachtung weg geändert - if old_verpachtung_id and link_type != "verpachtung": - try: - old_verpachtung = LandVerpachtung.objects.select_related( - "paechter" - ).get(id=old_verpachtung_id) - if old_verpachtung.paechter: - # Prüfe ob noch andere Verpachtungs-Links für diesen Pächter existieren - other_verpachtung_links = DokumentLink.objects.filter( - paperless_document_id=paperless_id_for_cleanup, - verpachtung__paechter_id=old_verpachtung.paechter.id, - ).exists() - - if not other_verpachtung_links: - # Entferne automatisch erstellte Pächter-Verknüpfung - DokumentLink.objects.filter( - paperless_document_id=paperless_id_for_cleanup, - paechter_id=old_verpachtung.paechter.id, - kontext="paechter", - ).delete() - except (LandVerpachtung.DoesNotExist, Exception): - pass - - message = "Verknüpfung aktualisiert" - if paechter_linked: - message += " (automatisch auch mit Pächter verknüpft)" - - return Response({"success": True, "message": message}) - except DokumentLink.DoesNotExist: - return Response({"error": "Verknüpfung nicht gefunden"}, status=404) - except Exception as e: - return Response({"error": f"Unerwarteter Fehler: {e}"}, status=500) - - -@csrf_exempt -@api_view(["DELETE"]) -def link_document_delete(request, link_id): - """Löscht eine bestehende Verknüpfung.""" - from django.db import transaction - - try: - with transaction.atomic(): - link = DokumentLink.objects.get(id=link_id) - verpachtung_id_for_cleanup = link.verpachtung_id - paperless_id_for_cleanup = link.paperless_document_id - - # Log the unlinking action before deletion - from stiftung.audit import log_unlink - - try: - # Determine what entity this was linked to - target_type = "unknown" - target_name = "Unknown" - - if link.destinataer_id: - target_type = "destinataer" - try: - entity = Destinataer.objects.get(id=link.destinataer_id) - target_name = entity.get_full_name() - except Destinataer.DoesNotExist: - target_name = f"Destinatär ID {link.destinataer_id}" - elif link.land_id: - target_type = "land" - try: - entity = Land.objects.get(id=link.land_id) - target_name = str(entity) - except Land.DoesNotExist: - target_name = f"Land ID {link.land_id}" - elif link.paechter_id: - target_type = "paechter" - try: - entity = Paechter.objects.get(id=link.paechter_id) - target_name = f"{entity.vorname} {entity.nachname}".strip() - except Paechter.DoesNotExist: - target_name = f"Pächter ID {link.paechter_id}" - elif link.verpachtung_id: - target_type = "verpachtung" - try: - entity = LandVerpachtung.objects.get(id=link.verpachtung_id) - target_name = str(entity) - except LandVerpachtung.DoesNotExist: - target_name = f"Verpachtung ID {link.verpachtung_id}" - elif link.rentmeister_id: - target_type = "rentmeister" - try: - from stiftung.models import Rentmeister - - entity = Rentmeister.objects.get(id=link.rentmeister_id) - target_name = entity.get_full_name() - except Rentmeister.DoesNotExist: - target_name = f"Rentmeister ID {link.rentmeister_id}" - - log_unlink( - request=request, - entity_type="dokumentlink", - entity_id=str(link.id), - entity_name=link.titel, - target_type=target_type, - target_name=target_name, - ) - except Exception as e: - # Don't fail the main operation if logging fails - print(f"Audit logging failed: {e}") - - link.delete() - - # Cleanup NACH der Haupttransaktion: Wenn eine Verpachtungs-Verknüpfung gelöscht wurde, prüfe Pächter-Links - if verpachtung_id_for_cleanup: - try: - verpachtung = LandVerpachtung.objects.select_related("paechter").get( - id=verpachtung_id_for_cleanup - ) - if verpachtung.paechter: - # Prüfe ob noch andere Verpachtungs-Links für diesen Pächter existieren - other_verpachtung_links = DokumentLink.objects.filter( - paperless_document_id=paperless_id_for_cleanup, - verpachtung__paechter_id=verpachtung.paechter.id, - ).exists() - - if not other_verpachtung_links: - # Entferne automatisch erstellte Pächter-Verknüpfung - DokumentLink.objects.filter( - paperless_document_id=paperless_id_for_cleanup, - paechter_id=verpachtung.paechter.id, - kontext="paechter", - ).delete() - except (LandVerpachtung.DoesNotExist, Exception): - pass - - return Response({"success": True}) - except DokumentLink.DoesNotExist: - return Response({"error": "Verknüpfung nicht gefunden"}, status=404) - except Exception as e: - return Response({"error": f"Unerwarteter Fehler: {e}"}, status=500) - - -@api_view(["GET"]) -def gramps_search_api(request): - """Probe-Endpoint: Suche Personen in Gramps Web nach q (Nachname, Vorname).""" - q = request.GET.get("q", "") - if not q: - return Response({"error": "Parameter q erforderlich"}, status=400) - client = get_gramps_client() - result = client.search_people(q) - return Response(result) - - -# Geschäftsführung Views -@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 -# ============================================================================= - - -@login_required -def administration(request): - """Administration Dashboard""" - from datetime import datetime, timedelta - - from django.db.models import Count - - from stiftung.models import AuditLog, BackupJob - - # Recent audit activity - recent_audit = AuditLog.objects.all()[:10] - - # Audit statistics - heute = datetime.now().date() - stats = { - "total_logs": AuditLog.objects.count(), - "logs_today": AuditLog.objects.filter(timestamp__date=heute).count(), - "logs_week": AuditLog.objects.filter( - timestamp__gte=heute - timedelta(days=7) - ).count(), - "recent_backups": BackupJob.objects.all()[:5], - "last_backup": BackupJob.objects.filter(status="completed").first(), - } - - # User activity summary - user_activity = ( - AuditLog.objects.values("username") - .annotate(count=Count("id")) - .order_by("-count")[:10] - ) - - context = { - "recent_audit": recent_audit, - "stats": stats, - "user_activity": user_activity, - } - - return render(request, "stiftung/administration.html", context) - - -@login_required -def unterstuetzungen_list(request): - """Liste der Destinatärunterstützungen (Administration).""" - status = request.GET.get("status", "") - 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 [] - ) - - qs = DestinataerUnterstuetzung.objects.select_related( - "destinataer", "konto", "ausgezahlt_von", "wiederkehrend_von" - ).order_by("-faellig_am", "destinataer__nachname") - - if status: - qs = qs.filter(status=status) - - # Enhanced CSV export with field selection - if export_format == "csv": - return export_unterstuetzungen_csv(request, qs, selected_ids) - - # Enhanced PDF export with corporate identity - elif export_format == "pdf": - return export_unterstuetzungen_pdf(request, qs, selected_ids) - - # Get quarterly confirmation statistics - quarterly_stats = {} - total_quarterly = VierteljahresNachweis.objects.count() - for status_code, status_name in VierteljahresNachweis.STATUS_CHOICES: - count = VierteljahresNachweis.objects.filter(status=status_code).count() - quarterly_stats[status_code] = { - 'name': status_name, - 'count': count - } - - context = { - "unterstuetzungen": qs, - "status_filter": status, - "quarterly_stats": quarterly_stats, - "total_quarterly": total_quarterly, - } - return render(request, "stiftung/unterstuetzungen_list.html", context) - - -def export_unterstuetzungen_csv(request, queryset, selected_ids=None): - """Enhanced CSV export with field selection""" - import csv - from datetime import datetime - - from django.http import HttpResponse - - # If specific entries are selected, filter to only those - if selected_ids: - queryset = queryset.filter(id__in=selected_ids) - - # Get selected fields from request (handle both 'fields' and 'selected_fields' parameter names) - selected_fields_param = "" - if request.method == "POST": - # Try 'fields' first (new format), then 'selected_fields' (legacy) - fields_list = request.POST.getlist("fields") - if fields_list: - selected_fields_param = ",".join(fields_list) - else: - selected_fields_param = request.POST.get("selected_fields", "") - else: - # Try 'fields' first (new format), then 'selected_fields' (legacy) - fields_list = request.GET.getlist("fields") - if fields_list: - selected_fields_param = ",".join(fields_list) - else: - selected_fields_param = request.GET.get("selected_fields", "") - - selected_fields = selected_fields_param.split(",") if selected_fields_param else [] - - if not selected_fields: - # Default field set - selected_fields = [ - "destinataer_name", - "betrag", - "faellig_am", - "empfaenger_iban", - "verwendungszweck", - "status", - "empfaenger_name", - "beschreibung", - ] - - # Field definitions with headers and data extraction - field_definitions = { - # Core payment fields - "id": ("ID", lambda u: str(u.id)), - "betrag": ("Betrag (€)", lambda u: f"{u.betrag:.2f}"), - "faellig_am": ( - "Fällig am", - lambda u: u.faellig_am.strftime("%d.%m.%Y") if u.faellig_am else "", - ), - "status": ("Status", lambda u: u.get_status_display()), - "beschreibung": ("Beschreibung", lambda u: u.beschreibung or ""), - "ausgezahlt_am": ( - "Ausgezahlt am", - lambda u: u.ausgezahlt_am.strftime("%d.%m.%Y") if u.ausgezahlt_am else "", - ), - "erstellt_am": ( - "Erstellt am", - lambda u: u.erstellt_am.strftime("%d.%m.%Y %H:%M") if u.erstellt_am else "", - ), - "aktualisiert_am": ( - "Aktualisiert am", - lambda u: ( - u.aktualisiert_am.strftime("%d.%m.%Y %H:%M") - if u.aktualisiert_am - else "" - ), - ), - # Destinataer fields - "destinataer_name": ( - "Destinatär Name", - lambda u: u.destinataer.get_full_name() if u.destinataer else "", - ), - "destinataer_vorname": ( - "Vorname", - lambda u: u.destinataer.vorname if u.destinataer else "", - ), - "destinataer_nachname": ( - "Nachname", - lambda u: u.destinataer.nachname if u.destinataer else "", - ), - "familienzweig": ( - "Familienzweig", - lambda u: u.destinataer.familienzweig if u.destinataer else "", - ), - "geburtsdatum": ( - "Geburtsdatum", - lambda u: ( - u.destinataer.geburtsdatum.strftime("%d.%m.%Y") - if u.destinataer and u.destinataer.geburtsdatum - else "" - ), - ), - "email": ("E-Mail", lambda u: u.destinataer.email if u.destinataer else ""), - "telefon": ( - "Telefon", - lambda u: u.destinataer.telefon if u.destinataer else "", - ), - "destinataer_iban": ( - "Destinatär IBAN", - lambda u: u.destinataer.iban if u.destinataer else "", - ), - "strasse": ("Straße", lambda u: u.destinataer.strasse if u.destinataer else ""), - "plz": ("PLZ", lambda u: u.destinataer.plz if u.destinataer else ""), - "ort": ("Ort", lambda u: u.destinataer.ort if u.destinataer else ""), - "adresse": ( - "Adresse", - lambda u: ( - f"{u.destinataer.strasse}, {u.destinataer.plz} {u.destinataer.ort}".strip( - ", " - ) - if u.destinataer - else "" - ), - ), - "berufsgruppe": ( - "Berufsgruppe", - lambda u: u.destinataer.berufsgruppe if u.destinataer else "", - ), - "ausbildungsstand": ( - "Ausbildungsstand", - lambda u: u.destinataer.ausbildungsstand if u.destinataer else "", - ), - "institution": ( - "Institution", - lambda u: u.destinataer.institution if u.destinataer else "", - ), - "jaehrliches_einkommen": ( - "Jährliches Einkommen (€)", - lambda u: ( - f"{u.destinataer.jaehrliches_einkommen:.2f}" - if u.destinataer and u.destinataer.jaehrliches_einkommen - else "" - ), - ), - "haushaltsgroesse": ( - "Haushaltsgröße", - lambda u: ( - str(u.destinataer.haushaltsgroesse) - if u.destinataer and u.destinataer.haushaltsgroesse - else "" - ), - ), - "monatliche_bezuege": ( - "Monatliche Bezüge (€)", - lambda u: ( - f"{u.destinataer.monatliche_bezuege:.2f}" - if u.destinataer and u.destinataer.monatliche_bezuege - else "" - ), - ), - "vermoegen": ( - "Vermögen (€)", - lambda u: ( - f"{u.destinataer.vermoegen:.2f}" - if u.destinataer and u.destinataer.vermoegen - else "" - ), - ), - # Payment details - "empfaenger_iban": ("Empfänger IBAN", lambda u: u.empfaenger_iban or ""), - "empfaenger_name": ("Empfänger Name", lambda u: u.empfaenger_name or ""), - "verwendungszweck": ("Verwendungszweck", lambda u: u.verwendungszweck or ""), - # Account fields - "konto_name": ("Konto", lambda u: str(u.konto) if u.konto else ""), - "konto_bank": ("Bank", lambda u: u.konto.bank_name if u.konto else ""), - "konto_iban": ("Konto IBAN", lambda u: u.konto.iban if u.konto else ""), - # System fields - "ausgezahlt_von": ( - "Ausgezahlt von", - lambda u: u.ausgezahlt_von.get_full_name() if u.ausgezahlt_von else "", - ), - "ist_wiederkehrend": ( - "Wiederkehrend", - lambda u: "Ja" if u.wiederkehrend_von else "Nein", - ), - } - - # Create CSV response - timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") - filename = f"unterstuetzungen_{timestamp}.csv" - - response = HttpResponse(content_type="text/csv; charset=utf-8") - response["Content-Disposition"] = f'attachment; filename="{filename}"' - - writer = csv.writer(response, delimiter=";", quoting=csv.QUOTE_ALL) - - # Write headers - headers = [ - field_definitions[field][0] - for field in selected_fields - if field in field_definitions - ] - writer.writerow(headers) - - # Write data rows - for u in queryset: - row = [] - for field in selected_fields: - if field in field_definitions: - try: - value = field_definitions[field][1](u) - row.append(value) - except Exception: - row.append("") # Fallback for any errors - else: - row.append("") # Unknown field - writer.writerow(row) - - return response - - -def export_unterstuetzungen_pdf(request, queryset, selected_ids=None): - """Enhanced PDF export with corporate identity and field selection""" - # If specific entries are selected, filter to only those - if selected_ids: - queryset = queryset.filter(id__in=selected_ids) - - # Get selected fields from request (handle both 'fields' and 'selected_fields' parameter names) - selected_fields_param = "" - if request.method == "POST": - # Try 'fields' first (new format), then 'selected_fields' (legacy) - fields_list = request.POST.getlist("fields") - if fields_list: - selected_fields_param = ",".join(fields_list) - else: - selected_fields_param = request.POST.get("selected_fields", "") - else: - # Try 'fields' first (new format), then 'selected_fields' (legacy) - fields_list = request.GET.getlist("fields") - if fields_list: - selected_fields_param = ",".join(fields_list) - else: - selected_fields_param = request.GET.get("selected_fields", "") - - selected_fields = selected_fields_param.split(",") if selected_fields_param else [] - - if not selected_fields: - # Default field set for PDF (fewer fields than CSV for better readability) - selected_fields = [ - "destinataer_name", - "betrag", - "faellig_am", - "empfaenger_iban", - "verwendungszweck", - "status", - "beschreibung", - "ausgezahlt_am", - ] - - # Field definitions with display names (reuse from CSV but select PDF-appropriate subset) - field_definitions = { - # Core payment fields - "destinataer_name": "Destinatär", - "betrag": "Betrag (€)", - "faellig_am": "Fällig am", - "status": "Status", - "beschreibung": "Beschreibung", - "ausgezahlt_am": "Ausgezahlt am", - "erstellt_am": "Erstellt am", - "empfaenger_iban": "Empfänger IBAN", - "empfaenger_name": "Empfänger", - "verwendungszweck": "Verwendungszweck", - "konto_name": "Konto", - "ist_wiederkehrend": "Wiederkehrend", - } - - # Filter to only include fields that are both selected and defined - filtered_fields = { - k: v for k, v in field_definitions.items() if k in selected_fields - } - - # Prepare data with field extraction logic - data_for_pdf = [] - for item in queryset: - row_data = {} - for field_key in filtered_fields.keys(): - try: - if field_key == "destinataer_name": - row_data[field_key] = ( - item.destinataer.get_full_name() if item.destinataer else "" - ) - elif field_key == "betrag": - row_data[field_key] = f"€{item.betrag:.2f}" if item.betrag else "" - elif field_key == "faellig_am": - row_data[field_key] = ( - item.faellig_am.strftime("%d.%m.%Y") if item.faellig_am else "" - ) - elif field_key == "status": - row_data[field_key] = item.get_status_display() - elif field_key == "beschreibung": - row_data[field_key] = item.beschreibung or "" - elif field_key == "ausgezahlt_am": - row_data[field_key] = ( - item.ausgezahlt_am.strftime("%d.%m.%Y") - if item.ausgezahlt_am - else "" - ) - elif field_key == "erstellt_am": - row_data[field_key] = ( - item.erstellt_am.strftime("%d.%m.%Y") - if item.erstellt_am - else "" - ) - elif field_key == "empfaenger_iban": - row_data[field_key] = item.empfaenger_iban or "" - elif field_key == "empfaenger_name": - row_data[field_key] = item.empfaenger_name or "" - elif field_key == "verwendungszweck": - row_data[field_key] = item.verwendungszweck or "" - elif field_key == "konto_name": - row_data[field_key] = str(item.konto) if item.konto else "" - elif field_key == "ist_wiederkehrend": - row_data[field_key] = "Ja" if item.wiederkehrend_von else "Nein" - else: - # Generic field access - row_data[field_key] = getattr(item, field_key, "") or "" - except Exception: - row_data[field_key] = "" # Fallback for any errors - - data_for_pdf.append(row_data) - - # Use PDF generator - pdf_gen = get_pdf_generator() - return pdf_gen.export_data_list_pdf( - data=data_for_pdf, - fields_config=filtered_fields, - title="Unterstützungen Export", - filename_prefix="unterstuetzungen", - request_user=request.user, - ) - - -def export_foerderungen_csv(request, queryset, selected_ids=None): - """Enhanced CSV export for Förderungen with field selection""" - import csv - from datetime import datetime - - from django.http import HttpResponse - - # If specific entries are selected, filter to only those - if selected_ids: - queryset = queryset.filter(id__in=selected_ids) - - # Get selected fields from request (default to all if none specified) - selected_fields_param = ( - request.POST.get("selected_fields", "") - if request.method == "POST" - else request.GET.get("selected_fields", "") - ) - selected_fields = selected_fields_param.split(",") if selected_fields_param else [] - - if not selected_fields: - # Default field set - selected_fields = [ - "destinataer_name", - "jahr", - "betrag", - "kategorie", - "status", - "antragsdatum", - "beschreibung", - ] - - # Field definitions with headers and data extraction - field_definitions = { - # Core fields - "id": ("ID", lambda f: str(f.id)), - "destinataer_name": ( - "Destinatär Name", - lambda f: f.destinataer.get_full_name() if f.destinataer else "", - ), - "jahr": ("Jahr", lambda f: str(f.jahr)), - "betrag": ("Betrag (€)", lambda f: f"{f.betrag:.2f}"), - "kategorie": ("Kategorie", lambda f: f.get_kategorie_display()), - "status": ("Status", lambda f: f.get_status_display()), - "antragsdatum": ( - "Antragsdatum", - lambda f: f.antragsdatum.strftime("%d.%m.%Y") if f.antragsdatum else "", - ), - "bewilligungsdatum": ( - "Bewilligungsdatum", - lambda f: ( - f.bewilligungsdatum.strftime("%d.%m.%Y") if f.bewilligungsdatum else "" - ), - ), - "auszahlungsdatum": ( - "Auszahlungsdatum", - lambda f: ( - f.auszahlungsdatum.strftime("%d.%m.%Y") if f.auszahlungsdatum else "" - ), - ), - "beschreibung": ("Beschreibung", lambda f: f.beschreibung or ""), - "begruendung": ("Begründung", lambda f: f.begruendung or ""), - "verwendungsnachweis_datum": ( - "Verwendungsnachweis Datum", - lambda f: ( - f.verwendungsnachweis_datum.strftime("%d.%m.%Y") - if f.verwendungsnachweis_datum - else "" - ), - ), - "verwendungsnachweis_status": ( - "Verwendungsnachweis Status", - lambda f: ( - f.get_verwendungsnachweis_status_display() - if f.verwendungsnachweis_status - else "" - ), - ), - # Destinataer fields - "destinataer_vorname": ( - "Vorname", - lambda f: f.destinataer.vorname if f.destinataer else "", - ), - "destinataer_nachname": ( - "Nachname", - lambda f: f.destinataer.nachname if f.destinataer else "", - ), - "familienzweig": ( - "Familienzweig", - lambda f: f.destinataer.familienzweig if f.destinataer else "", - ), - "email": ("E-Mail", lambda f: f.destinataer.email if f.destinataer else ""), - "telefon": ( - "Telefon", - lambda f: f.destinataer.telefon if f.destinataer else "", - ), - "adresse": ( - "Adresse", - lambda f: ( - f"{f.destinataer.strasse}, {f.destinataer.plz} {f.destinataer.ort}".strip( - ", " - ) - if f.destinataer - else "" - ), - ), - "berufsgruppe": ( - "Berufsgruppe", - lambda f: f.destinataer.berufsgruppe if f.destinataer else "", - ), - "ausbildungsstand": ( - "Ausbildungsstand", - lambda f: f.destinataer.ausbildungsstand if f.destinataer else "", - ), - "institution": ( - "Institution", - lambda f: f.destinataer.institution if f.destinataer else "", - ), - # System fields - "erstellt_am": ( - "Erstellt am", - lambda f: f.erstellt_am.strftime("%d.%m.%Y %H:%M") if f.erstellt_am else "", - ), - "aktualisiert_am": ( - "Aktualisiert am", - lambda f: ( - f.aktualisiert_am.strftime("%d.%m.%Y %H:%M") - if f.aktualisiert_am - else "" - ), - ), - } - - # Create CSV response - timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") - filename = f"foerderungen_{timestamp}.csv" - - response = HttpResponse(content_type="text/csv; charset=utf-8") - response["Content-Disposition"] = f'attachment; filename="{filename}"' - - writer = csv.writer(response, delimiter=";", quoting=csv.QUOTE_ALL) - - # Write headers - headers = [ - field_definitions[field][0] - for field in selected_fields - if field in field_definitions - ] - writer.writerow(headers) - - # Write data rows - for f in queryset: - row = [] - for field in selected_fields: - if field in field_definitions: - try: - value = field_definitions[field][1](f) - row.append(value) - except Exception: - row.append("") # Fallback for any errors - else: - row.append("") # Unknown field - writer.writerow(row) - - return response - - -def export_foerderungen_pdf(request, queryset, selected_ids=None): - """Enhanced PDF export for Förderungen with corporate identity and field selection""" - # If specific entries are selected, filter to only those - if selected_ids: - queryset = queryset.filter(id__in=selected_ids) - - # Get selected fields from request (default to key fields if none specified) - selected_fields_param = ( - request.POST.get("selected_fields", "") - if request.method == "POST" - else request.GET.get("selected_fields", "") - ) - selected_fields = selected_fields_param.split(",") if selected_fields_param else [] - - if not selected_fields: - # Default field set for PDF (fewer fields than CSV for better readability) - selected_fields = [ - "destinataer_name", - "jahr", - "betrag", - "kategorie", - "status", - "antragsdatum", - ] - - # Field definitions with display names - field_definitions = { - "destinataer_name": "Destinatär", - "jahr": "Jahr", - "betrag": "Betrag (€)", - "kategorie": "Kategorie", - "status": "Status", - "antragsdatum": "Antragsdatum", - "bewilligungsdatum": "Bewilligungsdatum", - "auszahlungsdatum": "Auszahlungsdatum", - "beschreibung": "Beschreibung", - "begruendung": "Begründung", - "verwendungsnachweis_status": "Verwendungsnachweis", - } - - # Filter to only include fields that are both selected and defined - filtered_fields = { - k: v for k, v in field_definitions.items() if k in selected_fields - } - - # Prepare data with field extraction logic - data_for_pdf = [] - for item in queryset: - row_data = {} - for field_key in filtered_fields.keys(): - try: - if field_key == "destinataer_name": - row_data[field_key] = ( - item.destinataer.get_full_name() if item.destinataer else "" - ) - elif field_key == "jahr": - row_data[field_key] = str(item.jahr) - elif field_key == "betrag": - row_data[field_key] = f"€{item.betrag:.2f}" if item.betrag else "" - elif field_key == "kategorie": - row_data[field_key] = item.get_kategorie_display() - elif field_key == "status": - row_data[field_key] = item.get_status_display() - elif field_key == "antragsdatum": - row_data[field_key] = ( - item.antragsdatum.strftime("%d.%m.%Y") - if item.antragsdatum - else "" - ) - elif field_key == "bewilligungsdatum": - row_data[field_key] = ( - item.bewilligungsdatum.strftime("%d.%m.%Y") - if item.bewilligungsdatum - else "" - ) - elif field_key == "auszahlungsdatum": - row_data[field_key] = ( - item.auszahlungsdatum.strftime("%d.%m.%Y") - if item.auszahlungsdatum - else "" - ) - elif field_key == "beschreibung": - row_data[field_key] = (item.beschreibung or "")[:100] + ( - "..." if len(item.beschreibung or "") > 100 else "" - ) - elif field_key == "begruendung": - row_data[field_key] = (item.begruendung or "")[:100] + ( - "..." if len(item.begruendung or "") > 100 else "" - ) - elif field_key == "verwendungsnachweis_status": - row_data[field_key] = ( - item.get_verwendungsnachweis_status_display() - if item.verwendungsnachweis_status - else "" - ) - else: - # Generic field access - row_data[field_key] = getattr(item, field_key, "") or "" - except Exception: - row_data[field_key] = "" # Fallback for any errors - - data_for_pdf.append(row_data) - - # Use PDF generator - pdf_gen = get_pdf_generator() - return pdf_gen.export_data_list_pdf( - data=data_for_pdf, - fields_config=filtered_fields, - title="Förderungen Export", - filename_prefix="foerderungen", - request_user=request.user, - ) - - -@login_required -def unterstuetzung_edit(request, pk): - obj = get_object_or_404(DestinataerUnterstuetzung, pk=pk) - if request.method == "POST": - form = DestinataerUnterstuetzungForm(request.POST, instance=obj) - if form.is_valid(): - form.save() - messages.success(request, "Unterstützung aktualisiert.") - return redirect("stiftung:unterstuetzungen_list") - else: - form = DestinataerUnterstuetzungForm(instance=obj) - return render( - request, - "stiftung/unterstuetzung_form.html", - {"form": form, "title": "Unterstützung bearbeiten"}, - ) - - -@login_required -def unterstuetzung_delete(request, pk): - obj = get_object_or_404(DestinataerUnterstuetzung, pk=pk) - - # Check if this will also delete the recurring template - will_delete_template = False - if obj.wiederkehrend_von: - andere_zahlungen = ( - DestinataerUnterstuetzung.objects.filter( - wiederkehrend_von=obj.wiederkehrend_von - ) - .exclude(pk=pk) - .exists() - ) - will_delete_template = not andere_zahlungen - - if request.method == "POST": - # Check if this support payment is linked to a recurring payment template - wiederkehrend_template = obj.wiederkehrend_von - - # Delete the support payment - obj.delete() - - # If this was generated from a recurring template and there are no other - # payments from this template, delete the template too - if wiederkehrend_template: - # Check if there are other payments from this recurring template - andere_zahlungen = DestinataerUnterstuetzung.objects.filter( - wiederkehrend_von=wiederkehrend_template - ).exists() - - # If no other payments exist from this template, delete the template too - if not andere_zahlungen: - wiederkehrend_template.delete() - messages.success( - request, - "Unterstützung und wiederkehrende Zahlungsvorlage gelöscht.", - ) - else: - messages.success(request, "Unterstützung gelöscht.") - else: - messages.success(request, "Unterstützung gelöscht.") - - return redirect("stiftung:unterstuetzungen_list") - - context = { - "obj": obj, - "will_delete_template": will_delete_template, - } - return render(request, "stiftung/unterstuetzung_confirm_delete.html", context) - - -@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 - - -@login_required -def paechter_export(request, pk): - """Export complete Pächter data as ZIP with documents""" - import json - import os - import tempfile - import zipfile - - from django.http import HttpResponse - - paechter = get_object_or_404(Paechter, 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(paechter.id), - "vorname": paechter.vorname, - "nachname": paechter.nachname, - "geburtsdatum": ( - paechter.geburtsdatum.isoformat() if paechter.geburtsdatum else None - ), - "email": paechter.email, - "telefon": paechter.telefon, - "iban": paechter.iban, - "strasse": paechter.strasse, - "plz": paechter.plz, - "ort": paechter.ort, - "personentyp": paechter.get_personentyp_display(), - "pachtnummer": paechter.pachtnummer, - "pachtbeginn_erste": ( - paechter.pachtbeginn_erste.isoformat() - if paechter.pachtbeginn_erste - else None - ), - "pachtende_letzte": ( - paechter.pachtende_letzte.isoformat() - if paechter.pachtende_letzte - else None - ), - "pachtzins_aktuell": ( - str(paechter.pachtzins_aktuell) - if paechter.pachtzins_aktuell - else None - ), - "landwirtschaftliche_ausbildung": paechter.landwirtschaftliche_ausbildung, - "berufserfahrung_jahre": paechter.berufserfahrung_jahre, - "spezialisierung": paechter.spezialisierung, - "notizen": paechter.notizen, - "aktiv": paechter.aktiv, - "gesamt_pachtflaeche": float(paechter.get_gesamt_pachtflaeche()), - "gesamt_pachtzins": float(paechter.get_gesamt_pachtzins()), - "export_datum": timezone.now().isoformat(), - "export_user": request.user.username, - } - zipf.writestr( - "paechter_data.json", - json.dumps(entity_data, indent=2, ensure_ascii=False), - ) - - # 2. Linked documents from Paperless - dokumente = DokumentLink.objects.filter(paechter_id=paechter.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: - 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" - - 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), - ) - - # Prepare response - with open(temp_file.name, "rb") as f: - response = HttpResponse(f.read(), content_type="application/zip") - filename = f"paechter_{paechter.nachname}_{paechter.vorname}_{timezone.now().strftime('%Y%m%d_%H%M%S')}.zip" - response["Content-Disposition"] = f'attachment; filename="{filename}"' - return response - - finally: - try: - os.unlink(temp_file.name) - except: - pass - - -@login_required -def land_export(request, pk): - """Export complete Land data as ZIP with documents""" - import json - import os - import tempfile - import zipfile - - from django.http import HttpResponse - - land = get_object_or_404(Land, 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(land.id), - "lfd_nr": land.lfd_nr, - "ew_nummer": land.ew_nummer, - "amtsgericht": land.amtsgericht, - "gemeinde": land.gemeinde, - "gemarkung": land.gemarkung, - "flur": land.flur, - "flurstueck": land.flurstueck, - "groesse_qm": str(land.groesse_qm), - "gruenland_qm": str(land.gruenland_qm), - "acker_qm": str(land.acker_qm), - "wald_qm": str(land.wald_qm), - "sonstiges_qm": str(land.sonstiges_qm), - "verpachtete_gesamtflaeche": str(land.verpachtete_gesamtflaeche), - "flaeche_alte_liste": ( - str(land.flaeche_alte_liste) if land.flaeche_alte_liste else None - ), - "verp_flaeche_aktuell": str(land.verp_flaeche_aktuell), - "anteil_grundsteuer": ( - str(land.anteil_grundsteuer) if land.anteil_grundsteuer else None - ), - "anteil_lwk": str(land.anteil_lwk) if land.anteil_lwk else None, - "aktiv": land.aktiv, - "notizen": land.notizen, - "erstellt_am": land.erstellt_am.isoformat(), - "aktualisiert_am": land.aktualisiert_am.isoformat(), - "gesamtflaeche_berechnet": float(land.get_gesamtflaeche()), - "verpachtungsgrad": float(land.get_verpachtungsgrad()), - "export_datum": timezone.now().isoformat(), - "export_user": request.user.username, - } - zipf.writestr( - "land_data.json", json.dumps(entity_data, indent=2, ensure_ascii=False) - ) - - # 2. Linked documents from Paperless - dokumente = DokumentLink.objects.filter(land_id=land.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: - 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" - - 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), - ) - - # Prepare response - with open(temp_file.name, "rb") as f: - response = HttpResponse(f.read(), content_type="application/zip") - filename = f"land_{land.gemeinde}_{land.gemarkung}_flur{land.flur}_{timezone.now().strftime('%Y%m%d_%H%M%S')}.zip" - response["Content-Disposition"] = f'attachment; filename="{filename}"' - return response - - finally: - try: - os.unlink(temp_file.name) - except: - pass - - -@login_required -def verpachtung_export(request, pk): - """Export complete Verpachtung data as ZIP with documents""" - import json - import os - import tempfile - import zipfile - - from django.http import HttpResponse - - verpachtung = get_object_or_404(LandVerpachtung, 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(verpachtung.id), - "vertragsnummer": verpachtung.vertragsnummer, - "land": str(verpachtung.land), - "land_id": str(verpachtung.land.id), - "paechter": str(verpachtung.paechter), - "paechter_id": str(verpachtung.paechter.id), - "pachtbeginn": verpachtung.pachtbeginn.isoformat(), - "pachtende": verpachtung.pachtende.isoformat(), - "verlaengerung": ( - verpachtung.verlaengerung.isoformat() - if verpachtung.verlaengerung - else None - ), - "pachtzins_pro_qm": str(verpachtung.pachtzins_pro_qm), - "pachtzins_jaehrlich": str(verpachtung.pachtzins_pauschal), - "verpachtete_flaeche": str(verpachtung.verpachtete_flaeche), - "status": verpachtung.get_status_display(), - "verwendungsnachweis": ( - str(verpachtung.verwendungsnachweis) - if verpachtung.verwendungsnachweis - else None - ), - "bemerkungen": verpachtung.bemerkungen, - "erstellt_am": verpachtung.erstellt_am.isoformat(), - "aktualisiert_am": verpachtung.aktualisiert_am.isoformat(), - "vertragsdauer_tage": verpachtung.get_vertragsdauer_tage(), - "restlaufzeit_tage": verpachtung.get_restlaufzeit_tage(), - "ist_aktiv": verpachtung.is_aktiv(), - "export_datum": timezone.now().isoformat(), - "export_user": request.user.username, - } - zipf.writestr( - "verpachtung_data.json", - json.dumps(entity_data, indent=2, ensure_ascii=False), - ) - - # 2. Linked documents from Paperless - dokumente = DokumentLink.objects.filter(verpachtung_id=verpachtung.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: - 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" - - 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), - ) - - # Prepare response - with open(temp_file.name, "rb") as f: - response = HttpResponse(f.read(), content_type="application/zip") - filename = f"verpachtung_{verpachtung.vertragsnummer}_{timezone.now().strftime('%Y%m%d_%H%M%S')}.zip" - response["Content-Disposition"] = f'attachment; filename="{filename}"' - return response - - finally: - try: - os.unlink(temp_file.name) - except: - pass - - -@login_required -def audit_log_list(request): - """Liste aller Audit Log Einträge""" - from django.core.paginator import Paginator - - from stiftung.models import AuditLog - - logs = AuditLog.objects.all() - - # Filter - user_filter = request.GET.get("user") - if user_filter: - logs = logs.filter(username__icontains=user_filter) - - action_filter = request.GET.get("action") - if action_filter: - logs = logs.filter(action=action_filter) - - entity_filter = request.GET.get("entity_type") - if entity_filter: - logs = logs.filter(entity_type=entity_filter) - - date_from = request.GET.get("date_from") - if date_from: - logs = logs.filter(timestamp__date__gte=date_from) - - date_to = request.GET.get("date_to") - if date_to: - logs = logs.filter(timestamp__date__lte=date_to) - - # Pagination - paginator = Paginator(logs, 50) - page_number = request.GET.get("page") - page_obj = paginator.get_page(page_number) - - context = { - "page_obj": page_obj, - "action_choices": AuditLog.ACTION_TYPES, - "entity_choices": AuditLog.ENTITY_TYPES, - "user_filter": user_filter, - "action_filter": action_filter, - "entity_filter": entity_filter, - "date_from": date_from, - "date_to": date_to, - } - - return render(request, "stiftung/audit_log_list.html", context) - - -@login_required -def backup_management(request): - """Backup Management Interface""" - from django.core.paginator import Paginator - - from stiftung.models import BackupJob - - # Handle backup creation - if request.method == "POST": - backup_type = request.POST.get("backup_type", "full") - - # Create backup job - backup_job = BackupJob.objects.create( - backup_type=backup_type, created_by=request.user - ) - - # Log the backup initiation - from stiftung.audit import log_system_action - - log_system_action( - request=request, - action="backup", - description=f"Backup-Job erstellt: {backup_job.get_backup_type_display()}", - details={"backup_job_id": str(backup_job.id), "backup_type": backup_type}, - ) - - # Start backup process asynchronously (we'll create a simple version for now) - import threading - - from stiftung.backup_utils import run_backup - - backup_thread = threading.Thread(target=run_backup, args=(str(backup_job.id),)) - backup_thread.start() - - messages.success( - request, - f'Backup-Job "{backup_job.get_backup_type_display()}" wurde gestartet.', - ) - return redirect("stiftung:backup_management") - - # List backup jobs - backup_jobs = BackupJob.objects.all() - - # Pagination - paginator = Paginator(backup_jobs, 20) - page_number = request.GET.get("page") - page_obj = paginator.get_page(page_number) - - context = { - "page_obj": page_obj, - "backup_types": BackupJob.TYPE_CHOICES, - } - - return render(request, "stiftung/backup_management.html", context) - - -@login_required -def backup_download(request, backup_id): - """Download a backup file""" - import os - - from django.http import FileResponse, Http404 - - from stiftung.models import BackupJob - - try: - backup_job = BackupJob.objects.get(id=backup_id, status="completed") - except BackupJob.DoesNotExist: - raise Http404("Backup nicht gefunden oder nicht vollständig") - - backup_path = os.path.join("/app/backups", backup_job.backup_filename) - if not os.path.exists(backup_path): - raise Http404("Backup-Datei nicht gefunden") - - # Log download - from stiftung.audit import log_system_action - - log_system_action( - request=request, - action="export", - description=f"Backup heruntergeladen: {backup_job.backup_filename}", - details={"backup_job_id": str(backup_job.id)}, - ) - - response = FileResponse( - open(backup_path, "rb"), as_attachment=True, filename=backup_job.backup_filename - ) - return response - - -@login_required -def backup_restore(request): - """Restore from backup""" - if request.method == "POST": - from stiftung.models import BackupJob - - backup_file = request.FILES.get("backup_file") - - if not backup_file: - messages.error(request, "Bitte wählen Sie eine Backup-Datei aus.") - return redirect("stiftung:backup_management") - - # Validate file format - if not backup_file.name.endswith(".tar.gz"): - messages.error( - request, "Ungültiges Backup-Format. Nur .tar.gz Dateien sind erlaubt." - ) - return redirect("stiftung:backup_management") - - # Save uploaded file to temporary location - import os - import tempfile - - temp_dir = tempfile.mkdtemp() - backup_path = os.path.join(temp_dir, backup_file.name) - - try: - with open(backup_path, "wb+") as destination: - for chunk in backup_file.chunks(): - destination.write(chunk) - - # Validate the backup file - from stiftung.backup_utils import validate_backup_file - - is_valid, message = validate_backup_file(backup_path) - if not is_valid: - messages.error(request, f"Ungültiges Backup: {message}") - return redirect("stiftung:backup_management") - - # Show validation success - messages.info(request, f"Backup validiert: {message}") - - # Create restore job - restore_job = BackupJob.objects.create( - operation="restore", - backup_type="full", - created_by=request.user, - backup_filename=backup_file.name, - ) - - # Log restore initiation - from stiftung.audit import log_system_action - - log_system_action( - request=request, - action="restore", - description=f"Wiederherstellung gestartet von: {backup_file.name}", - details={ - "restore_job_id": str(restore_job.id), - "filename": backup_file.name, - }, - ) - - # Start restore process - import threading - - from stiftung.backup_utils import run_restore - - restore_thread = threading.Thread( - target=run_restore, args=(str(restore_job.id), backup_path) - ) - restore_thread.start() - - messages.success( - request, f'Wiederherstellung von "{backup_file.name}" wurde gestartet. ' - f'Überwachen Sie den Fortschritt in der Backup-Historie.' - ) - return redirect("stiftung:backup_management") - - except Exception as e: - messages.error(request, f"Fehler beim Verarbeiten der Backup-Datei: {e}") - return redirect("stiftung:backup_management") - - return redirect("stiftung:backup_management") - - -@login_required -def backup_cancel(request, backup_id): - """Cancel a running backup job""" - from stiftung.models import BackupJob - import traceback - - try: - print(f"DEBUG: Attempting to cancel backup job {backup_id}") - backup_job = BackupJob.objects.get(id=backup_id) - print(f"DEBUG: Found backup job - ID: {backup_job.id}, Status: {backup_job.status}") - - # Use created_by_id instead of created_by to avoid triggering the foreign key lookup - print(f"DEBUG: Created by ID: {backup_job.created_by_id}, Current user ID: {request.user.id}") - - # Only allow cancelling running or pending jobs - if backup_job.status not in ['running', 'pending']: - messages.error(request, "Nur laufende oder wartende Backups können abgebrochen werden.") - return redirect("stiftung:backup_management") - - # Check if user has permission to cancel (either own job or admin) - # Use created_by_id to avoid database lookup for potentially non-existent user - print(f"DEBUG: Checking permissions - created_by_id: {backup_job.created_by_id}, is_staff: {request.user.is_staff}") - if backup_job.created_by_id is not None and backup_job.created_by_id != request.user.id and not request.user.is_staff: - messages.error(request, "Sie können nur Ihre eigenen Backup-Jobs abbrechen.") - return redirect("stiftung:backup_management") - - # Mark as cancelled - print("DEBUG: About to mark job as cancelled") - from django.utils import timezone - backup_job.status = "cancelled" - backup_job.completed_at = timezone.now() - - print(f"DEBUG: About to set error message with username: {request.user.username}") - backup_job.error_message = f"Abgebrochen von {request.user.username}" - - print("DEBUG: About to save backup job") - backup_job.save() - print("DEBUG: Backup job saved successfully") - - # Log the cancellation (with error handling) - try: - print("DEBUG: About to log system action") - from stiftung.audit import log_system_action - - print(f"DEBUG: About to call get_backup_type_display") - backup_type_display = backup_job.get_backup_type_display() - print(f"DEBUG: Backup type display: {backup_type_display}") - - log_system_action( - request=request, - action="backup_cancel", - description=f"Backup-Job abgebrochen: {backup_type_display}", - details={"backup_job_id": str(backup_job.id)}, - ) - print("DEBUG: System action logged successfully") - except Exception as audit_error: - print(f"ERROR in audit logging: {audit_error}") - print(f"ERROR traceback: {traceback.format_exc()}") - # Don't fail the cancellation if logging fails - - messages.success(request, f"Backup-Job wurde abgebrochen.") - - except BackupJob.DoesNotExist: - print(f"ERROR: Backup job {backup_id} not found") - messages.error(request, "Backup-Job nicht gefunden.") - except Exception as e: - print(f"ERROR: Unexpected error in backup_cancel: {e}") - print(f"ERROR traceback: {traceback.format_exc()}") - messages.error(request, f"Fehler beim Abbrechen des Backup-Jobs: {e}") - - return redirect("stiftung:backup_management") - - -# ============================================================================= -# USER MANAGEMENT VIEWS -# ============================================================================= - - -@login_required -def user_management(request): - """User Management Dashboard""" - from django.contrib.auth.models import User - from django.core.paginator import Paginator - from django.db.models import Q - - # Check permission - if not request.user.has_perm("stiftung.manage_users"): - messages.error( - request, "Sie haben keine Berechtigung für die Benutzerverwaltung." - ) - return redirect("stiftung:administration") - - users = User.objects.all().order_by("username") - - # Search functionality - search = request.GET.get("search") - if search: - users = users.filter( - Q(username__icontains=search) - | Q(email__icontains=search) - | Q(first_name__icontains=search) - | Q(last_name__icontains=search) - ) - - # Filter by status - status_filter = request.GET.get("status") - if status_filter == "active": - users = users.filter(is_active=True) - elif status_filter == "inactive": - users = users.filter(is_active=False) - elif status_filter == "staff": - users = users.filter(is_staff=True) - - # Pagination - paginator = Paginator(users, 20) - page_number = request.GET.get("page") - page_obj = paginator.get_page(page_number) - - # Statistics - stats = { - "total_users": User.objects.count(), - "active_users": User.objects.filter(is_active=True).count(), - "staff_users": User.objects.filter(is_staff=True).count(), - "inactive_users": User.objects.filter(is_active=False).count(), - } - - context = { - "page_obj": page_obj, - "stats": stats, - "search": search, - "status_filter": status_filter, - } - - return render(request, "stiftung/user_management.html", context) - - -@login_required -def user_create(request): - """Create a new user""" - from django.contrib.auth.models import User - - from stiftung.forms import UserCreationForm - - # Check permission - if not request.user.has_perm("stiftung.manage_users"): - messages.error( - request, "Sie haben keine Berechtigung für die Benutzerverwaltung." - ) - return redirect("stiftung:administration") - - if request.method == "POST": - form = UserCreationForm(request.POST) - if form.is_valid(): - # Create user - user = User.objects.create_user( - username=form.cleaned_data["username"], - email=form.cleaned_data["email"], - password=form.cleaned_data["password1"], - first_name=form.cleaned_data["first_name"], - last_name=form.cleaned_data["last_name"], - is_active=form.cleaned_data["is_active"], - is_staff=form.cleaned_data["is_staff"], - ) - - # Log user creation - from stiftung.audit import log_action - - log_action( - request=request, - action="create", - entity_type="user", - entity_id=str(user.pk), - entity_name=user.username, - description=f'Neuer Benutzer "{user.username}" wurde erstellt', - ) - - messages.success( - request, f'Benutzer "{user.username}" wurde erfolgreich erstellt.' - ) - return redirect("stiftung:user_detail", pk=user.pk) - else: - form = UserCreationForm() - - context = {"form": form, "title": "Neuen Benutzer erstellen"} - - return render(request, "stiftung/user_form.html", context) - - -@login_required -def user_detail(request, pk): - """User detail view""" - from django.contrib.auth.models import User - - # Check permission - if not request.user.has_perm("stiftung.manage_users"): - messages.error( - request, "Sie haben keine Berechtigung für die Benutzerverwaltung." - ) - return redirect("stiftung:administration") - - user = get_object_or_404(User, pk=pk) - - # Get user's permissions - user_permissions = user.get_all_permissions() - stiftung_permissions = [ - perm for perm in user_permissions if perm.startswith("stiftung.") - ] - - # Get recent audit activity - from stiftung.models import AuditLog - - recent_activity = AuditLog.objects.filter(user=user).order_by("-timestamp")[:10] - - context = { - "user_obj": user, # Use user_obj to avoid conflict with request.user - "stiftung_permissions": stiftung_permissions, - "recent_activity": recent_activity, - } - - return render(request, "stiftung/user_detail.html", context) - - -@login_required -def user_edit(request, pk): - """Edit user""" - from django.contrib.auth.models import User - - from stiftung.forms import UserUpdateForm - - # Check permission - if not request.user.has_perm("stiftung.manage_users"): - messages.error( - request, "Sie haben keine Berechtigung für die Benutzerverwaltung." - ) - return redirect("stiftung:administration") - - user = get_object_or_404(User, pk=pk) - - if request.method == "POST": - form = UserUpdateForm(request.POST, instance=user) - if form.is_valid(): - # Track changes - from stiftung.audit import log_action, track_model_changes - - old_user = User.objects.get(pk=user.pk) - - updated_user = form.save() - - # Log changes - changes = track_model_changes(old_user, updated_user) - if changes: - log_action( - request=request, - action="update", - entity_type="user", - entity_id=str(updated_user.pk), - entity_name=updated_user.username, - description=f'Benutzer "{updated_user.username}" wurde aktualisiert', - changes=changes, - ) - - messages.success( - request, - f'Benutzer "{updated_user.username}" wurde erfolgreich aktualisiert.', - ) - return redirect("stiftung:user_detail", pk=updated_user.pk) - else: - form = UserUpdateForm(instance=user) - - context = { - "form": form, - "user_obj": user, - "title": f'Benutzer "{user.username}" bearbeiten', - } - - return render(request, "stiftung/user_form.html", context) - - -@login_required -def user_change_password(request, pk): - """Change user password""" - from django.contrib.auth.models import User - - from stiftung.forms import PasswordChangeForm - - # Check permission - if not request.user.has_perm("stiftung.manage_users"): - messages.error( - request, "Sie haben keine Berechtigung für die Benutzerverwaltung." - ) - return redirect("stiftung:administration") - - user = get_object_or_404(User, pk=pk) - - if request.method == "POST": - form = PasswordChangeForm(request.POST) - if form.is_valid(): - user.set_password(form.cleaned_data["new_password1"]) - user.save() - - # Log password change - from stiftung.audit import log_action - - log_action( - request=request, - action="update", - entity_type="user", - entity_id=str(user.pk), - entity_name=user.username, - description=f'Passwort für Benutzer "{user.username}" wurde geändert', - ) - - messages.success( - request, - f'Passwort für Benutzer "{user.username}" wurde erfolgreich geändert.', - ) - return redirect("stiftung:user_detail", pk=user.pk) - else: - form = PasswordChangeForm() - - context = { - "form": form, - "user_obj": user, - "title": f'Passwort für "{user.username}" ändern', - } - - return render(request, "stiftung/user_change_password.html", context) - - -@login_required -def user_permissions(request, pk): - """Manage user permissions""" - from django.contrib.auth.models import Permission, User - - from stiftung.forms import UserPermissionForm - - # Check permission - if not request.user.has_perm("stiftung.manage_permissions"): - messages.error( - request, "Sie haben keine Berechtigung für die Berechtigungsverwaltung." - ) - return redirect("stiftung:administration") - - user = get_object_or_404(User, pk=pk) - - if request.method == "POST": - form = UserPermissionForm(request.POST, user=user) - if form.is_valid(): - # Get selected permissions - selected_perms = [] - for field_name, value in form.cleaned_data.items(): - if field_name.startswith("perm_") and value: - perm_id = field_name.replace("perm_", "") - selected_perms.append(int(perm_id)) - - # Get current stiftung permissions - current_perms = user.user_permissions.filter( - content_type__app_label="stiftung" - ) - current_perm_ids = set(current_perms.values_list("id", flat=True)) - selected_perm_ids = set(selected_perms) - - # Remove permissions that are no longer selected - to_remove = current_perm_ids - selected_perm_ids - if to_remove: - user.user_permissions.remove( - *Permission.objects.filter(id__in=to_remove) - ) - - # Add new permissions - to_add = selected_perm_ids - current_perm_ids - if to_add: - user.user_permissions.add(*Permission.objects.filter(id__in=to_add)) - - # Log permission changes - from stiftung.audit import log_action - - if to_remove or to_add: - changes = { - "removed_permissions": list( - Permission.objects.filter(id__in=to_remove).values_list( - "name", flat=True - ) - ), - "added_permissions": list( - Permission.objects.filter(id__in=to_add).values_list( - "name", flat=True - ) - ), - } - log_action( - request=request, - action="update", - entity_type="user", - entity_id=str(user.pk), - entity_name=user.username, - description=f'Berechtigungen für Benutzer "{user.username}" wurden aktualisiert', - changes=changes, - ) - - messages.success( - request, - f'Berechtigungen für Benutzer "{user.username}" wurden erfolgreich aktualisiert.', - ) - return redirect("stiftung:user_detail", pk=user.pk) - else: - form = UserPermissionForm(user=user) - - context = { - "form": form, - "user_obj": user, - "permission_groups": form.get_permission_groups(), - "title": f'Berechtigungen für "{user.username}"', - } - - return render(request, "stiftung/user_permissions.html", context) - - -@login_required -def user_delete(request, pk): - """Delete user""" - from django.contrib.auth.models import User - - # Check permission - if not request.user.has_perm("stiftung.manage_users"): - messages.error( - request, "Sie haben keine Berechtigung für die Benutzerverwaltung." - ) - return redirect("stiftung:administration") - - user = get_object_or_404(User, pk=pk) - - # Prevent deletion of current user - if user == request.user: - messages.error(request, "Sie können sich nicht selbst löschen.") - return redirect("stiftung:user_detail", pk=pk) - - if request.method == "POST": - username = user.username - - # Log deletion before deleting - from stiftung.audit import log_action - - log_action( - request=request, - action="delete", - entity_type="user", - entity_id=str(user.pk), - entity_name=username, - description=f'Benutzer "{username}" wurde gelöscht', - ) - - user.delete() - - messages.success(request, f'Benutzer "{username}" wurde erfolgreich gelöscht.') - return redirect("stiftung:user_management") - - context = {"user_obj": user, "title": f'Benutzer "{user.username}" löschen'} - - return render(request, "stiftung/user_delete.html", context) - - -# ============================================================================= -# AUTHENTICATION VIEWS -# ============================================================================= - - -def user_login(request): - """User login view""" - from django.contrib.auth import authenticate, login - from django.contrib.auth.forms import AuthenticationForm - - if request.user.is_authenticated: - return redirect("stiftung:home") - - if request.method == "POST": - form = AuthenticationForm(request, data=request.POST) - if form.is_valid(): - username = form.cleaned_data.get("username") - password = form.cleaned_data.get("password") - user = authenticate(username=username, password=password) - if user is not None: - login(request, user) - - # Log the login - from stiftung.audit import log_login - - log_login(request, user) - - # Determine redirect target - next_param = request.GET.get("next") or request.POST.get("next") - if not next_param or not next_param.startswith("/"): - next_param = reverse("stiftung:home") - - # Check if user has 2FA enabled - redirect to verification first - has_2fa = TOTPDevice.objects.filter(user=user, confirmed=True).exists() - if has_2fa: - from urllib.parse import urlencode - verify_url = reverse("stiftung:two_factor_verify") + "?" + urlencode({"next": next_param}) - return redirect(verify_url) - - messages.success(request, f"Willkommen zurück, {user.username}!") - return redirect(next_param) - else: - messages.error(request, "Ungültige Anmeldedaten.") - else: - messages.error(request, "Bitte korrigieren Sie die Fehler im Formular.") - else: - form = AuthenticationForm() - - context = {"form": form, "next": request.GET.get("next", "")} - - return render(request, "stiftung/login.html", context) - - -@login_required -def user_logout(request): - """User logout view""" - from django.contrib.auth import logout - - # Log the logout before actually logging out - from stiftung.audit import log_logout - - log_logout(request, request.user) - - username = request.user.username - logout(request) - - messages.success(request, f"Sie wurden erfolgreich abgemeldet, {username}.") - return redirect("stiftung:login") - - -# ============================================================================ -# LANDABRECHNUNGS VIEWS -# ============================================================================ - - -@login_required -def land_abrechnung_list(request): - """Liste aller Landabrechnungen""" - abrechnungen = LandAbrechnung.objects.select_related("land").all() - - # Filter - jahr_filter = request.GET.get("jahr") - land_filter = request.GET.get("land") - - if jahr_filter: - abrechnungen = abrechnungen.filter(abrechnungsjahr=jahr_filter) - if land_filter: - abrechnungen = abrechnungen.filter(land__pk=land_filter) - - # Pagination - paginator = Paginator(abrechnungen, 20) - page_number = request.GET.get("page") - abrechnungen = paginator.get_page(page_number) - - # Statistiken - stats = LandAbrechnung.objects.aggregate( - total_einnahmen=Sum("pacht_vereinnahmt"), - total_ausgaben=Sum("grundsteuer_betrag"), - anzahl_abrechnungen=Count("id"), - ) - - context = { - "abrechnungen": abrechnungen, - "stats": stats, - "jahre": LandAbrechnung.objects.values_list("abrechnungsjahr", flat=True) - .distinct() - .order_by("-abrechnungsjahr"), - "laendereien": Land.objects.filter(aktiv=True).order_by( - "gemeinde", "gemarkung" - ), - "jahr_filter": jahr_filter, - "land_filter": land_filter, - } - - return render(request, "stiftung/land_abrechnung_list.html", context) - - -@login_required -def land_abrechnung_detail(request, pk): - """Detail-Ansicht einer Landabrechnung""" - abrechnung = get_object_or_404(LandAbrechnung, pk=pk) - - context = { - "abrechnung": abrechnung, - "land": abrechnung.land, - } - - return render(request, "stiftung/land_abrechnung_detail.html", context) - - -@login_required -def land_abrechnung_create(request): - """Neue Landabrechnung erstellen""" - from .forms import LandAbrechnungForm - - land_pk = request.GET.get("land") - initial = {} - land = None - - if land_pk: - land = get_object_or_404(Land, pk=land_pk) - initial["land"] = land - initial["abrechnungsjahr"] = datetime.now().year - - # Automatische Vorausfüllung aus Verpachtungsdaten - if land.pachtzins_pauschal: - initial["pacht_vereinnahmt"] = land.pachtzins_pauschal - - if request.method == "POST": - form = LandAbrechnungForm(request.POST, request.FILES) - if form.is_valid(): - abrechnung = form.save() - messages.success( - request, - f"Landabrechnung für {abrechnung.land} ({abrechnung.abrechnungsjahr}) wurde erfolgreich erstellt.", - ) - return redirect("stiftung:land_abrechnung_detail", pk=abrechnung.pk) - else: - form = LandAbrechnungForm(initial=initial) - - context = { - "form": form, - "title": "Neue Landabrechnung", - "land": land, - } - - return render(request, "stiftung/land_abrechnung_form.html", context) - - -@login_required -def land_abrechnung_update(request, pk): - """Landabrechnung bearbeiten""" - from .forms import LandAbrechnungForm - - abrechnung = get_object_or_404(LandAbrechnung, pk=pk) - - if request.method == "POST": - form = LandAbrechnungForm(request.POST, request.FILES, instance=abrechnung) - if form.is_valid(): - abrechnung = form.save() - messages.success( - request, - f"Landabrechnung für {abrechnung.land} ({abrechnung.abrechnungsjahr}) wurde erfolgreich aktualisiert.", - ) - return redirect("stiftung:land_abrechnung_detail", pk=abrechnung.pk) - else: - form = LandAbrechnungForm(instance=abrechnung) - - context = { - "form": form, - "abrechnung": abrechnung, - "title": f"Abrechnung bearbeiten - {abrechnung.land} ({abrechnung.abrechnungsjahr})", - } - - return render(request, "stiftung/land_abrechnung_form.html", context) - - -@login_required -def land_abrechnung_delete(request, pk): - """Landabrechnung löschen""" - abrechnung = get_object_or_404(LandAbrechnung, pk=pk) - land = abrechnung.land - - if request.method == "POST": - abrechnung.delete() - messages.success( - request, - f"Landabrechnung für {land} ({abrechnung.abrechnungsjahr}) wurde erfolgreich gelöscht.", - ) - return redirect("stiftung:land_detail", pk=land.pk) - - context = { - "abrechnung": abrechnung, - "land": land, - } - - return render(request, "stiftung/land_abrechnung_confirm_delete.html", context) - - -# ============================================================================ -# VEREINHEITLICHTE VERPACHTUNGS VIEWS -# ============================================================================ - - -@login_required -def land_verpachtung_create(request, land_pk): - """Erstelle eine neue Verpachtung direkt im Land-Model""" - from datetime import datetime as dt - - land = get_object_or_404(Land, pk=land_pk) - - if request.method == "POST": - # Einfaches Formular für die wichtigsten Verpachtungsfelder - aktueller_paechter_id = request.POST.get("aktueller_paechter") - pachtbeginn = request.POST.get("pachtbeginn") - pachtende = request.POST.get("pachtende") - pachtzins_pauschal = request.POST.get("pachtzins_pauschal") - zahlungsweise = request.POST.get("zahlungsweise") - ust_option = request.POST.get("ust_option") == "on" - - if aktueller_paechter_id and pachtbeginn: - paechter = get_object_or_404(Paechter, pk=aktueller_paechter_id) - verpachtete_flaeche = request.POST.get("verpachtete_flaeche") - - # Validiere verpachtete Fläche - if not verpachtete_flaeche: - verpachtete_flaeche = land.groesse_qm # Standard: gesamte Fläche - else: - verpachtete_flaeche = float(verpachtete_flaeche) - if verpachtete_flaeche > land.groesse_qm: - messages.error( - request, - f"Die verpachtete Fläche ({verpachtete_flaeche} qm) kann nicht größer als die Gesamtfläche ({land.groesse_qm} qm) sein.", - ) - # Erstelle context für Fehlerfall - paechter_list = Paechter.objects.filter(aktiv=True).order_by( - "nachname", "vorname" - ) - verfuegbare_flaeche = land.groesse_qm - if land.verp_flaeche_aktuell and land.verp_flaeche_aktuell > 0: - verfuegbare_flaeche = ( - land.groesse_qm - land.verp_flaeche_aktuell - ) - - context = { - "land": land, - "paechter_list": paechter_list, - "current_year": dt.now().year, - "is_edit": False, - "verfuegbare_flaeche": verfuegbare_flaeche, - } - return render( - request, "stiftung/land_verpachtung_form.html", context - ) - - # Land aktualisieren - land.aktueller_paechter = paechter - land.paechter_name = paechter.get_full_name() - land.paechter_anschrift = f"{paechter.strasse or ''}\n{paechter.plz or ''} {paechter.ort or ''}".strip() - land.pachtbeginn = pachtbeginn - land.pachtende = pachtende if pachtende else None - land.pachtzins_pauschal = pachtzins_pauschal if pachtzins_pauschal else None - land.zahlungsweise = zahlungsweise - land.ust_option = ust_option - land.verp_flaeche_aktuell = verpachtete_flaeche - land.verpachtete_gesamtflaeche = verpachtete_flaeche - land.save() - - # Erstelle LandVerpachtung-Objekt für bessere Nachverfolgung - land_verpachtung = LandVerpachtung.objects.create( - land=land, - paechter=paechter, - vertragsnummer=f"V-{land.lfd_nr}-{dt.now().year}", - pachtbeginn=pachtbeginn, - pachtende=pachtende if pachtende else None, - verpachtete_flaeche=verpachtete_flaeche, - pachtzins_pauschal=pachtzins_pauschal if pachtzins_pauschal else 0, - zahlungsweise=zahlungsweise, - ust_option=ust_option, - status="aktiv", - ) - - # Erstelle automatisch eine Abrechnung für das aktuelle Jahr - current_year = dt.now().year - - # Berechne erwartete jährliche Pacht basierend auf Zahlungsweise - expected_annual_rent = pachtzins_pauschal if pachtzins_pauschal else 0 - - abrechnung, created = LandAbrechnung.objects.get_or_create( - land=land, - abrechnungsjahr=current_year, - defaults={ - "pacht_vereinnahmt": expected_annual_rent, # Setze erwartete Jahrespacht - "umlagen_vereinnahmt": 0, - "grundsteuer_betrag": 0, - "versicherungen_betrag": 0, - }, - ) - - # Falls Abrechnung bereits existiert, aktualisiere die Pacht wenn höher - if not created and expected_annual_rent > abrechnung.pacht_vereinnahmt: - abrechnung.pacht_vereinnahmt = expected_annual_rent - abrechnung.save() - - success_msg = f"Verpachtung von {land} an {paechter.get_full_name()} wurde erfolgreich erstellt." - if created: - success_msg += ( - f" Abrechnung für {current_year} wurde automatisch angelegt" - ) - if expected_annual_rent > 0: - success_msg += f" (Erwartete Jahrespacht: {expected_annual_rent}€)" - success_msg += "." - elif expected_annual_rent > 0: - success_msg += f" Erwartete Jahrespacht in Abrechnung {current_year} wurde aktualisiert ({expected_annual_rent}€)." - - messages.success(request, success_msg) - return redirect("stiftung:land_detail", pk=land.pk) - else: - messages.error(request, "Bitte füllen Sie alle Pflichtfelder aus.") - - # Verfügbare Pächter - paechter_list = Paechter.objects.filter(aktiv=True).order_by("nachname", "vorname") - - # Berechne verfügbare Fläche (Gesamtfläche minus bereits verpachtete Fläche) - verfuegbare_flaeche = land.groesse_qm - if land.verp_flaeche_aktuell and land.verp_flaeche_aktuell > 0: - verfuegbare_flaeche = land.groesse_qm - land.verp_flaeche_aktuell - - context = { - "land": land, - "paechter_list": paechter_list, - "current_year": dt.now().year, - "is_edit": False, - "verfuegbare_flaeche": verfuegbare_flaeche, - } - - return render(request, "stiftung/land_verpachtung_form.html", context) - - -@login_required -def land_verpachtung_end(request, land_pk): - """Beende die aktuelle Verpachtung eines Landes""" - land = get_object_or_404(Land, pk=land_pk) - - if request.method == "POST": - # Verpachtung beenden - land.aktueller_paechter = None - land.paechter_name = None - land.paechter_anschrift = None - land.pachtende = datetime.now().date() - land.save() - - messages.success(request, f"Verpachtung von {land} wurde beendet.") - return redirect("stiftung:land_detail", pk=land.pk) - - context = { - "land": land, - } - - return render(request, "stiftung/land_verpachtung_end.html", context) - - -@login_required -def land_verpachtung_edit(request, land_pk): - """Bearbeite eine bestehende Verpachtung direkt im Land-Model""" - land = get_object_or_404(Land, pk=land_pk) - - if request.method == "POST": - # Einfaches Formular für die wichtigsten Verpachtungsfelder - aktueller_paechter_id = request.POST.get("aktueller_paechter") - pachtbeginn = request.POST.get("pachtbeginn") - pachtende = request.POST.get("pachtende") - pachtzins_pauschal = request.POST.get("pachtzins_pauschal") - zahlungsweise = request.POST.get("zahlungsweise") - ust_option = request.POST.get("ust_option") == "on" - verpachtete_flaeche = request.POST.get("verpachtete_flaeche") - - if aktueller_paechter_id and pachtbeginn: - paechter = get_object_or_404(Paechter, pk=aktueller_paechter_id) - - # Land aktualisieren - land.aktueller_paechter = paechter - land.paechter_name = paechter.get_full_name() - land.paechter_anschrift = f"{paechter.strasse or ''}\n{paechter.plz or ''} {paechter.ort or ''}".strip() - land.pachtbeginn = pachtbeginn - land.pachtende = pachtende if pachtende else None - land.pachtzins_pauschal = pachtzins_pauschal if pachtzins_pauschal else None - land.zahlungsweise = zahlungsweise - land.ust_option = ust_option - if verpachtete_flaeche: - land.verp_flaeche_aktuell = verpachtete_flaeche - land.save() - - messages.success( - request, - f"Verpachtung von {land} an {paechter.get_full_name()} wurde erfolgreich aktualisiert.", - ) - return redirect("stiftung:land_detail", pk=land.pk) - else: - messages.error(request, "Bitte füllen Sie alle Pflichtfelder aus.") - - # Verfügbare Pächter - paechter_list = Paechter.objects.filter(aktiv=True).order_by("nachname", "vorname") - - # Berechne verfügbare Fläche (Gesamtfläche minus bereits verpachtete Fläche) - verfuegbare_flaeche = land.groesse_qm - if land.verp_flaeche_aktuell and land.verp_flaeche_aktuell > 0: - verfuegbare_flaeche = land.groesse_qm - land.verp_flaeche_aktuell - - context = { - "land": land, - "paechter_list": paechter_list, - "current_year": datetime.now().year, - "is_edit": True, - "verfuegbare_flaeche": verfuegbare_flaeche, - } - - return render(request, "stiftung/land_verpachtung_form.html", context) - - -# Settings Management Views -@login_required -def app_settings(request): - """Application settings management interface""" - - # Group settings by category - categories = {} - for setting in AppConfiguration.objects.filter(is_active=True).order_by( - "category", "order", "display_name" - ): - if setting.category not in categories: - categories[setting.category] = [] - categories[setting.category].append(setting) - - if request.method == "POST": - # Handle form submission - updated_count = 0 - for key, value in request.POST.items(): - if key.startswith("setting_"): - setting_key = key.replace("setting_", "") - try: - setting = AppConfiguration.objects.get( - key=setting_key, is_active=True - ) - if not setting.is_system and setting.value != value: - setting.value = value - setting.save() - updated_count += 1 - except AppConfiguration.DoesNotExist: - continue - - if updated_count > 0: - messages.success(request, f"Successfully updated {updated_count} settings!") - else: - messages.info(request, "No changes were made.") - - return redirect("stiftung:app_settings") - - context = { - "categories": categories, - "title": "Application Settings", - } - return render(request, "stiftung/app_settings.html", context) - - -# Unterstützungen Views (Destinataer-focused) -@login_required -def unterstuetzungen_all(request): - """List all support payments - destinataer-focused view""" - status = request.GET.get("status") - destinataer_id = request.GET.get("destinataer") - export = request.GET.get("format", "") - selected_ids = ( - request.POST.getlist("selected_entries") if request.method == "POST" else [] - ) - - unterstuetzungen = DestinataerUnterstuetzung.objects.select_related( - "destinataer", "konto", "ausgezahlt_von", "wiederkehrend_von" - ).order_by("-faellig_am") - - # Filtering - if status: - unterstuetzungen = unterstuetzungen.filter(status=status) - if destinataer_id: - unterstuetzungen = unterstuetzungen.filter(destinataer_id=destinataer_id) - - # Enhanced CSV export with field selection - if export == "csv": - return export_unterstuetzungen_csv(request, unterstuetzungen, selected_ids) - - # PDF export (simple table via WeasyPrint; graceful fallback if missing) - if export == "pdf": - try: - from django.template.loader import render_to_string - from weasyprint import HTML - - html = render_to_string( - "stiftung/unterstuetzungen_pdf.html", - {"unterstuetzungen": unterstuetzungen}, - ) - from django.http import HttpResponse - - pdf = HTML(string=html).write_pdf() - resp = HttpResponse(pdf, content_type="application/pdf") - resp["Content-Disposition"] = "inline; filename=unterstuetzungen.pdf" - return resp - except Exception: - pass - - # Statistics - total_betrag = unterstuetzungen.aggregate(total=Sum("betrag"))["total"] or 0 - - # Get quarterly confirmation statistics - quarterly_stats = {} - total_quarterly = VierteljahresNachweis.objects.count() - for status_code, status_name in VierteljahresNachweis.STATUS_CHOICES: - count = VierteljahresNachweis.objects.filter(status=status_code).count() - quarterly_stats[status_code] = { - 'name': status_name, - 'count': count - } - - # Available destinataer for filter - destinataer = Destinataer.objects.all().order_by("nachname", "vorname") - - context = { - "page_obj": unterstuetzungen, # Use directly for now (pagination can be added later) - "unterstuetzungen": unterstuetzungen, - "title": "Alle Unterstützungen", - "status_filter": status, - "total_betrag": total_betrag, - "quarterly_stats": quarterly_stats, - "total_quarterly": total_quarterly, - "status_choices": DestinataerUnterstuetzung.STATUS_CHOICES, - "destinataer": destinataer, - } - return render(request, "stiftung/unterstuetzungen_all.html", context) - - -@login_required -def unterstuetzung_create(request): - """Create a new support payment""" - # Get destinataer from URL parameter if provided - destinataer_id = request.GET.get("destinataer") - initial = {} - if destinataer_id: - initial["destinataer"] = destinataer_id - # Pre-populate IBAN and name if destinataer is specified - try: - destinataer = Destinataer.objects.get(pk=destinataer_id) - if hasattr(destinataer, "iban") and destinataer.iban: - initial["empfaenger_iban"] = destinataer.iban - initial["empfaenger_name"] = destinataer.get_full_name() - except Destinataer.DoesNotExist: - pass - - if request.method == "POST": - form = UnterstuetzungForm(request.POST) - if form.is_valid(): - ist_wiederkehrend = form.cleaned_data.get("ist_wiederkehrend", False) - - if ist_wiederkehrend: - # Create recurring payment template - wiederkehrend = UnterstuetzungWiederkehrend.objects.create( - destinataer=form.cleaned_data["destinataer"], - konto=form.cleaned_data["konto"], - betrag=form.cleaned_data["betrag"], - intervall=form.cleaned_data["intervall"], - beschreibung=form.cleaned_data["beschreibung"], - empfaenger_iban=form.cleaned_data["empfaenger_iban"], - empfaenger_name=form.cleaned_data["empfaenger_name"], - verwendungszweck=form.cleaned_data["verwendungszweck"], - erste_zahlung_am=form.cleaned_data["faellig_am"], - letzte_zahlung_am=form.cleaned_data.get("letzte_zahlung_am"), - naechste_generierung=form.cleaned_data["faellig_am"], - erstellt_von=request.user, - ) - - # Create the first payment - unterstuetzung = form.save(commit=False) - unterstuetzung.wiederkehrend_von = wiederkehrend - unterstuetzung.save() - - messages.success( - request, - f"Wiederkehrende Unterstützung für {unterstuetzung.destinataer} wurde erfolgreich erstellt. Die erste Zahlung ist am {unterstuetzung.faellig_am} fällig.", - ) - else: - # Create single payment - unterstuetzung = form.save() - messages.success( - request, - f"Unterstützung für {unterstuetzung.destinataer} wurde erfolgreich erstellt.", - ) - - return redirect("stiftung:unterstuetzung_detail", pk=unterstuetzung.pk) - else: - form = UnterstuetzungForm(initial=initial) - - context = { - "form": form, - "title": "Neue Unterstützung erstellen", - } - return render(request, "stiftung/unterstuetzung_form.html", context) - - -@login_required -def get_destinataer_info(request, destinataer_id): - """AJAX endpoint to get Destinataer IBAN and name information""" - try: - destinataer = Destinataer.objects.get(pk=destinataer_id) - data = { - "success": True, - "name": destinataer.get_full_name(), - "iban": getattr(destinataer, "iban", "") or "", - } - except Destinataer.DoesNotExist: - data = {"success": False, "error": "Destinataer not found"} - - return JsonResponse(data) - - -@login_required -def unterstuetzung_detail(request, pk): - """View support payment details""" - unterstuetzung = get_object_or_404(DestinataerUnterstuetzung, pk=pk) - - # Check if this payment can be marked as paid - can_mark_paid = unterstuetzung.can_be_marked_paid() - - context = { - "unterstuetzung": unterstuetzung, - "title": f"Unterstützung für {unterstuetzung.destinataer.get_full_name()}", - "can_mark_paid": can_mark_paid, - } - return render(request, "stiftung/unterstuetzung_detail.html", context) - - -@login_required -def unterstuetzung_mark_paid(request, pk): - """Mark a support payment as paid""" - unterstuetzung = get_object_or_404(DestinataerUnterstuetzung, pk=pk) - - if not unterstuetzung.can_be_marked_paid(): - messages.error( - request, "Diese Unterstützung kann nicht als bezahlt markiert werden." - ) - return redirect("stiftung:unterstuetzung_detail", pk=pk) - - if request.method == "POST": - form = UnterstuetzungMarkAsPaidForm(request.POST) - if form.is_valid(): - unterstuetzung.status = "ausgezahlt" - unterstuetzung.ausgezahlt_am = form.cleaned_data["ausgezahlt_am"] - unterstuetzung.ausgezahlt_von = request.user - - # Add optional note to description - bemerkung = form.cleaned_data.get("bemerkung") - if bemerkung: - if unterstuetzung.beschreibung: - unterstuetzung.beschreibung += f" | Zahlung: {bemerkung}" - else: - unterstuetzung.beschreibung = f"Zahlung: {bemerkung}" - - unterstuetzung.save() - messages.success(request, f"Unterstützung wurde als bezahlt markiert.") - return redirect("stiftung:unterstuetzung_detail", pk=pk) - else: - form = UnterstuetzungMarkAsPaidForm() - - context = { - "form": form, - "unterstuetzung": unterstuetzung, - "title": f"Zahlung markieren - {unterstuetzung.destinataer.get_full_name()}", - } - return render(request, "stiftung/unterstuetzung_mark_paid.html", context) - - -@login_required -def wiederkehrende_unterstuetzungen(request): - """List all recurring support payment templates""" - from django.db.models import Count - - # Check for cleanup request - if request.GET.get("cleanup") == "1": - # Find templates with no associated payments - verwaiste_templates = UnterstuetzungWiederkehrend.objects.annotate( - zahlung_count=Count("destinataerunterstuetzung") - ).filter(zahlung_count=0) - - if verwaiste_templates.exists(): - anzahl_geloescht = verwaiste_templates.count() - template_namen = list( - verwaiste_templates.values_list("destinataer__nachname", flat=True) - ) - verwaiste_templates.delete() - messages.success( - request, - f'{anzahl_geloescht} verwaiste Zahlungsvorlagen bereinigt: {", ".join(template_namen[:5])}{"..." if len(template_namen) > 5 else ""}', - ) - else: - messages.info(request, "Keine verwaisten Zahlungsvorlagen gefunden.") - - return redirect("stiftung:wiederkehrende_unterstuetzungen") - - # Get all templates with payment counts - templates = ( - UnterstuetzungWiederkehrend.objects.select_related("destinataer", "konto") - .annotate(aktive_zahlungen=Count("destinataerunterstuetzung")) - .all() - ) - - context = { - "templates": templates, - "title": "Wiederkehrende Unterstützungen", - } - return render(request, "stiftung/wiederkehrende_unterstuetzungen.html", context) - - -@login_required -def edit_help_box(request): - """Bearbeite oder erstelle eine Hilfs-Infobox""" - from .models import HelpBox - - # Nur root oder Superuser dürfen bearbeiten - if request.user.username != "root" and not request.user.is_superuser: - messages.error( - request, "Sie haben keine Berechtigung, Hilfsboxen zu bearbeiten." - ) - return redirect("stiftung:home") - - if request.method == "POST": - page_key = request.POST.get("page_key") - title = request.POST.get("title") - content = request.POST.get("content") - is_active = request.POST.get("is_active") == "on" - - if not page_key or not title or not content: - messages.error(request, "Alle Felder sind erforderlich.") - return redirect(request.META.get("HTTP_REFERER", "stiftung:home")) - - # Hilfsbox erstellen oder aktualisieren - help_box, created = HelpBox.objects.get_or_create( - page_key=page_key, - defaults={ - "title": title, - "content": content, - "is_active": is_active, - "created_by": request.user.username, - "updated_by": request.user.username, - }, - ) - - if not created: - # Existierende Hilfsbox aktualisieren - help_box.title = title - help_box.content = content - help_box.is_active = is_active - help_box.updated_by = request.user.username - help_box.save() - - messages.success(request, f'Hilfsbox "{title}" wurde aktualisiert.') - else: - messages.success(request, f'Hilfsbox "{title}" wurde erstellt.') - - # Zurück zur vorherigen Seite - return redirect(request.META.get("HTTP_REFERER", "stiftung:home")) - - # GET Request - Zeige Admin-Übersicht der Hilfsboxen - help_boxes = HelpBox.objects.all().order_by("page_key", "-updated_at") - - # Statistiken berechnen - active_count = help_boxes.filter(is_active=True).count() - inactive_count = help_boxes.filter(is_active=False).count() - existing_pages = set(help_boxes.values_list("page_key", flat=True)) - - # Verfügbare Seiten aus dem Model holen - available_pages = HelpBox.PAGE_CHOICES - - context = { - "help_boxes": help_boxes, - "active_count": active_count, - "inactive_count": inactive_count, - "existing_pages": existing_pages, - "available_pages": available_pages, - "title": "Hilfs-Infoboxen verwalten", - } - return render(request, "stiftung/help_boxes_admin.html", context) - - -# ============================================================================= -# Verpachtung Management Views (Standalone CRUD) -# ============================================================================= - -@login_required -def verpachtung_detail(request, pk): - """Standalone detail view for verpachtung""" - verpachtung = get_object_or_404(LandVerpachtung, pk=pk) - - # Alle mit dieser Verpachtung verknüpften Dokumente laden - verknuepfte_dokumente = DokumentLink.objects.filter( - land_verpachtung_id=verpachtung.pk - ).order_by("kontext", "titel") - - context = { - "verpachtung": verpachtung, - "landverpachtung": verpachtung, # Template compatibility - "verknuepfte_dokumente": verknuepfte_dokumente, - "title": f"Verpachtung {verpachtung.vertragsnummer}", - } - return render(request, "stiftung/verpachtung_detail.html", context) - - -@login_required -def verpachtung_create(request): - """Standalone create view for verpachtung""" - from .forms import LandVerpachtungForm - from datetime import datetime as dt - - if request.method == 'POST': - form = LandVerpachtungForm(request.POST, request.FILES) - if form.is_valid(): - verpachtung = form.save() - - # Update the Land model to reflect this verpachtung - land = verpachtung.land - land.aktueller_paechter = verpachtung.paechter - land.paechter_name = verpachtung.paechter.get_full_name() - land.paechter_anschrift = f"{verpachtung.paechter.strasse or ''}\n{verpachtung.paechter.plz or ''} {verpachtung.paechter.ort or ''}".strip() - land.pachtbeginn = verpachtung.pachtbeginn - land.pachtende = verpachtung.pachtende - land.pachtzins_pauschal = verpachtung.pachtzins_pauschal - land.zahlungsweise = verpachtung.zahlungsweise - land.ust_option = verpachtung.ust_option - land.verpachtete_gesamtflaeche = verpachtung.verpachtete_flaeche - land.verp_flaeche_aktuell = verpachtung.verpachtete_flaeche - land.save() - - # Create automatic abrechnung - current_year = dt.now().year - expected_annual_rent = verpachtung.pachtzins_pauschal if verpachtung.pachtzins_pauschal else 0 - - abrechnung, created = LandAbrechnung.objects.get_or_create( - land=land, - abrechnungsjahr=current_year, - defaults={ - "pacht_vereinnahmt": expected_annual_rent, - "umlagen_vereinnahmt": 0, - "grundsteuer_betrag": 0, - "versicherungen_betrag": 0, - }, - ) - - if not created and expected_annual_rent > abrechnung.pacht_vereinnahmt: - abrechnung.pacht_vereinnahmt = expected_annual_rent - abrechnung.save() - - success_msg = f'Verpachtung "{verpachtung.vertragsnummer}" wurde erfolgreich erstellt.' - if created: - success_msg += f" Abrechnung für {current_year} wurde automatisch angelegt" - if expected_annual_rent > 0: - success_msg += f" (Erwartete Jahrespacht: {expected_annual_rent}€)" - success_msg += "." - elif expected_annual_rent > 0: - success_msg += f" Erwartete Jahrespacht in Abrechnung {current_year} wurde aktualisiert ({expected_annual_rent}€)." - - messages.success(request, success_msg) - return redirect('stiftung:verpachtung_detail', pk=verpachtung.pk) - else: - form = LandVerpachtungForm() - - # Get available Länder and Pächter for the template - laender_list = Land.objects.all().order_by('lfd_nr') - paechter_list = Paechter.objects.filter(aktiv=True).order_by('nachname', 'vorname') - - context = { - 'form': form, - 'title': 'Neue Verpachtung erstellen', - 'laender_list': laender_list, - 'paechter_list': paechter_list, - 'current_year': dt.now().year, - 'is_edit': False, - } - return render(request, 'stiftung/verpachtung_form.html', context) - - -@login_required -def verpachtung_update(request, pk): - """Standalone update view for verpachtung""" - return land_verpachtung_update(request, pk) - - -@login_required -def verpachtung_delete(request, pk): - """Standalone delete view for verpachtung""" - verpachtung = get_object_or_404(LandVerpachtung, pk=pk) - - if request.method == 'POST': - vertragsnummer = verpachtung.vertragsnummer - verpachtung.delete() - messages.success( - request, - f'Verpachtung "{vertragsnummer}" wurde erfolgreich gelöscht.' - ) - return redirect('stiftung:verpachtung_list') - - context = { - 'verpachtung': verpachtung, - 'title': f'Verpachtung {verpachtung.vertragsnummer} löschen', - } - return render(request, 'stiftung/verpachtung_confirm_delete.html', context) - - -@login_required -def quarterly_confirmation_update(request, pk): - """Update quarterly confirmation for destinataer""" - nachweis = get_object_or_404(VierteljahresNachweis, pk=pk) - - if request.method == "POST": - form = VierteljahresNachweisForm(request.POST, request.FILES, instance=nachweis) - if form.is_valid(): - quarterly_proof = form.save(commit=False) - - # Calculate current status before saving - old_status = nachweis.status - - # Auto-update status based on completion - if quarterly_proof.is_complete(): - if quarterly_proof.status in ['offen', 'teilweise']: - quarterly_proof.status = 'eingereicht' - quarterly_proof.eingereicht_am = timezone.now() - else: - # If not complete, set to teilweise if some fields are filled - has_partial_data = ( - quarterly_proof.einkommenssituation_bestaetigt or - quarterly_proof.vermogenssituation_bestaetigt or - quarterly_proof.studiennachweis_eingereicht - ) - if has_partial_data and quarterly_proof.status == 'offen': - quarterly_proof.status = 'teilweise' - - quarterly_proof.save() - - # Try to create automatic support payment if complete - if quarterly_proof.is_complete() and quarterly_proof.status == 'eingereicht': - support_payment = create_quarterly_support_payment(quarterly_proof) - if support_payment: - messages.success( - request, - f"Automatische Unterstützung über {support_payment.betrag}€ für {support_payment.destinataer.get_full_name()} wurde erstellt (fällig am {support_payment.faellig_am.strftime('%d.%m.%Y')})." - ) - else: - # Log why payment wasn't created - reasons = [] - if not quarterly_proof.destinataer.vierteljaehrlicher_betrag: - reasons.append("kein vierteljährlicher Betrag hinterlegt") - if not quarterly_proof.destinataer.iban: - reasons.append("keine IBAN hinterlegt") - if not quarterly_proof.destinataer.standard_konto and not StiftungsKonto.objects.exists(): - reasons.append("kein Auszahlungskonto verfügbar") - - if reasons: - messages.warning( - request, - f"Automatische Unterstützung konnte nicht erstellt werden: {', '.join(reasons)}" - ) - - # Debug message to see what happened - status_changed = old_status != quarterly_proof.status - status_msg = f" (Status: {old_status} → {quarterly_proof.status})" if status_changed else f" (Status: {quarterly_proof.status})" - - messages.success( - request, - f"Vierteljahresnachweis für {nachweis.destinataer.get_full_name()} " - f"({nachweis.jahr} Q{nachweis.quartal}) wurde erfolgreich aktualisiert{status_msg}." - ) - return redirect("stiftung:destinataer_detail", pk=nachweis.destinataer.pk) - else: - # Add form errors to messages - for field, errors in form.errors.items(): - for error in errors: - messages.error(request, f"Fehler in {field}: {error}") - - # If GET request or form errors, redirect back to destinataer detail - return redirect("stiftung:destinataer_detail", pk=nachweis.destinataer.pk) - - -def create_quarterly_support_payment(nachweis): - """ - Get or create a single support payment for this quarterly confirmation - Ensures only one payment exists per destinataer per quarter - """ - from datetime import date - destinataer = nachweis.destinataer - - # Check if all requirements are met - if not nachweis.is_complete(): - return None - - # Check if destinataer has required payment info - if not destinataer.vierteljaehrlicher_betrag or destinataer.vierteljaehrlicher_betrag <= 0: - return None - - if not destinataer.iban: - return None - - # Search for existing payment using payment due date from quarterly confirmation - # This is more accurate than using quarter date range, especially for Q1 (Dec 15 prev year) - payment_due_date = nachweis.zahlung_faelligkeitsdatum - if not payment_due_date: - # Fallback: calculate if not set - if nachweis.quartal == 1: - payment_due_date = date(nachweis.jahr - 1, 12, 15) - elif nachweis.quartal == 2: - payment_due_date = date(nachweis.jahr, 3, 15) - elif nachweis.quartal == 3: - payment_due_date = date(nachweis.jahr, 6, 15) - else: # Q4 - payment_due_date = date(nachweis.jahr, 9, 15) - - # Search for existing payment - match by payment due date and description - # Use a date range around the due date (±30 days) to catch any variations - from datetime import timedelta - date_start = payment_due_date - timedelta(days=30) - date_end = payment_due_date + timedelta(days=30) - - existing_payment = DestinataerUnterstuetzung.objects.filter( - destinataer=destinataer, - faellig_am__gte=date_start, - faellig_am__lte=date_end - ).filter( - Q(beschreibung__contains=f"Q{nachweis.quartal}/{nachweis.jahr}") | - Q(beschreibung__contains=f"Vierteljährliche Unterstützung Q{nachweis.quartal}/{nachweis.jahr}") - ).first() - - if existing_payment: - # Update existing payment to ensure it matches current requirements - existing_payment.betrag = destinataer.vierteljaehrlicher_betrag - existing_payment.empfaenger_iban = destinataer.iban - existing_payment.empfaenger_name = destinataer.get_full_name() - existing_payment.verwendungszweck = f"Vierteljährliche Unterstützung Q{nachweis.quartal}/{nachweis.jahr} - {destinataer.get_full_name()}" - existing_payment.beschreibung = f"Vierteljährliche Unterstützung Q{nachweis.quartal}/{nachweis.jahr} (automatisch erstellt)" - existing_payment.save() - return existing_payment - - # Get default payment account - default_konto = destinataer.standard_konto - if not default_konto: - # Try to get any StiftungsKonto - default_konto = StiftungsKonto.objects.first() - if not default_konto: - return None - - # Use payment due date from quarterly confirmation (already calculated by model) - # This ensures consistency with zahlung_faelligkeitsdatum - payment_due_date = nachweis.zahlung_faelligkeitsdatum - if not payment_due_date: - # Fallback: calculate if not set (should not happen, but safety check) - if nachweis.quartal == 1: # Q1 payment due December 15 of previous year - payment_due_date = date(nachweis.jahr - 1, 12, 15) - elif nachweis.quartal == 2: # Q2 payment due March 15 - payment_due_date = date(nachweis.jahr, 3, 15) - elif nachweis.quartal == 3: # Q3 payment due June 15 - payment_due_date = date(nachweis.jahr, 6, 15) - else: # Q4 payment due September 15 - payment_due_date = date(nachweis.jahr, 9, 15) - - # Create the support payment - payment = DestinataerUnterstuetzung.objects.create( - destinataer=destinataer, - konto=default_konto, - betrag=destinataer.vierteljaehrlicher_betrag, - faellig_am=payment_due_date, - status='geplant', - beschreibung=f"Vierteljährliche Unterstützung Q{nachweis.quartal}/{nachweis.jahr} (automatisch erstellt)", - empfaenger_iban=destinataer.iban, - empfaenger_name=destinataer.get_full_name(), - verwendungszweck=f"Vierteljährliche Unterstützung Q{nachweis.quartal}/{nachweis.jahr} - {destinataer.get_full_name()}", - erstellt_am=timezone.now(), - aktualisiert_am=timezone.now() - ) - - return payment - - -@login_required -def quarterly_confirmation_create(request, destinataer_id): - """Create a new quarterly confirmation for a destinataer""" - import logging - logger = logging.getLogger(__name__) - logger.info(f"quarterly_confirmation_create called: method={request.method}, destinataer_id={destinataer_id}") - - destinataer = get_object_or_404(Destinataer, pk=destinataer_id) - - if request.method == "POST": - logger.info(f"POST data: {request.POST}") - jahr = request.POST.get('jahr') - quartal = request.POST.get('quartal') - - if jahr and quartal: - try: - jahr = int(jahr) - quartal = int(quartal) - - # Check if this quarter already exists - existing = VierteljahresNachweis.objects.filter( - destinataer=destinataer, - jahr=jahr, - quartal=quartal - ).exists() - - if existing: - messages.warning( - request, - f"Quartal {jahr} Q{quartal} existiert bereits für {destinataer.get_full_name()}." - ) - else: - # Create new quarterly confirmation - try: - nachweis = VierteljahresNachweis.objects.create( - destinataer=destinataer, - jahr=jahr, - quartal=quartal, - studiennachweis_erforderlich=True, # Always required now - ) - # Deadlines are automatically set by the model's save() method - # studiennachweis_faelligkeitsdatum: semester-based (Q1/Q2→Mar 15, Q3/Q4→Sep 15) - # zahlung_faelligkeitsdatum: quarterly advance (Q1→Dec 15 prev year, Q2→Mar 15, Q3→Jun 15, Q4→Sep 15) - - # Refresh from database to ensure deadlines are set - nachweis.refresh_from_db() - - studiennachweis_str = nachweis.studiennachweis_faelligkeitsdatum.strftime('%d.%m.%Y') if nachweis.studiennachweis_faelligkeitsdatum else "Nicht gesetzt" - zahlung_str = nachweis.zahlung_faelligkeitsdatum.strftime('%d.%m.%Y') if nachweis.zahlung_faelligkeitsdatum else "Nicht gesetzt" - - messages.success( - request, - f"Quartal {jahr} Q{quartal} wurde erfolgreich für {destinataer.get_full_name()} erstellt. " - f"Studiennachweis fällig: {studiennachweis_str}, " - f"Zahlung fällig: {zahlung_str}." - ) - except Exception as e: - from django.db import IntegrityError - if isinstance(e, IntegrityError): - messages.error( - request, - f"Quartal {jahr} Q{quartal} existiert bereits für {destinataer.get_full_name()}." - ) - else: - messages.error( - request, - f"Fehler beim Erstellen des Quartals: {str(e)}" - ) - - except (ValueError, TypeError): - messages.error(request, "Ungültige Jahr- oder Quartalswerte.") - else: - messages.error(request, "Jahr und Quartal müssen angegeben werden.") - - return redirect("stiftung:destinataer_detail", pk=destinataer.pk) - - -@login_required -def quarterly_confirmation_edit(request, pk): - """Standalone edit view for quarterly confirmation""" - nachweis = get_object_or_404(VierteljahresNachweis, pk=pk) - - if request.method == "POST": - form = VierteljahresNachweisForm(request.POST, request.FILES, instance=nachweis) - if form.is_valid(): - quarterly_proof = form.save(commit=False) - - # Calculate current status before saving - old_status = nachweis.status - - # Auto-update status based on completion - if quarterly_proof.is_complete(): - if quarterly_proof.status in ['offen', 'teilweise']: - quarterly_proof.status = 'eingereicht' - quarterly_proof.eingereicht_am = timezone.now() - else: - # If not complete, set to teilweise if some fields are filled - has_partial_data = ( - quarterly_proof.einkommenssituation_bestaetigt or - quarterly_proof.vermogenssituation_bestaetigt or - quarterly_proof.studiennachweis_eingereicht - ) - if has_partial_data and quarterly_proof.status == 'offen': - quarterly_proof.status = 'teilweise' - - quarterly_proof.save() - - # Try to create automatic support payment if complete - if quarterly_proof.is_complete() and quarterly_proof.status == 'eingereicht': - support_payment = create_quarterly_support_payment(quarterly_proof) - if support_payment: - messages.success( - request, - f"Automatische Unterstützung über {support_payment.betrag}€ für {support_payment.destinataer.get_full_name()} wurde erstellt (fällig am {support_payment.faellig_am.strftime('%d.%m.%Y')})." - ) - else: - # Log why payment wasn't created - reasons = [] - if not quarterly_proof.destinataer.vierteljaehrlicher_betrag: - reasons.append("kein vierteljährlicher Betrag hinterlegt") - if not quarterly_proof.destinataer.iban: - reasons.append("keine IBAN hinterlegt") - if not quarterly_proof.destinataer.standard_konto and not StiftungsKonto.objects.exists(): - reasons.append("kein Auszahlungskonto verfügbar") - - if reasons: - messages.warning( - request, - f"Automatische Unterstützung konnte nicht erstellt werden: {', '.join(reasons)}" - ) - - # Debug message to see what happened - status_changed = old_status != quarterly_proof.status - status_msg = f" (Status: {old_status} → {quarterly_proof.status})" if status_changed else f" (Status: {quarterly_proof.status})" - - messages.success( - request, - f"Vierteljahresnachweis für {nachweis.destinataer.get_full_name()} " - f"({nachweis.jahr} Q{nachweis.quartal}) wurde erfolgreich aktualisiert{status_msg}." - ) - return redirect("stiftung:destinataer_detail", pk=nachweis.destinataer.pk) - else: - # Add form errors to messages - for field, errors in form.errors.items(): - for error in errors: - messages.error(request, f"Fehler in {field}: {error}") - else: - form = VierteljahresNachweisForm(instance=nachweis) - - context = { - 'form': form, - 'nachweis': nachweis, - 'destinataer': nachweis.destinataer, - 'title': f'Vierteljahresnachweis bearbeiten - {nachweis.destinataer.get_full_name()} {nachweis.jahr} Q{nachweis.quartal}', - } - return render(request, 'stiftung/quarterly_confirmation_edit.html', context) - - -@login_required -def quarterly_confirmation_approve(request, pk): - """Approve quarterly confirmation (staff only)""" - if not request.user.is_staff: - messages.error(request, "Sie haben keine Berechtigung für diese Aktion.") - return redirect("stiftung:destinataer_list") - - nachweis = get_object_or_404(VierteljahresNachweis, pk=pk) - - if request.method == "POST": - if nachweis.status in ['eingereicht', 'geprueft']: - # Check if we need to create or update support payment - related_payment = nachweis.get_related_support_payment() - - if nachweis.status == 'eingereicht' or (nachweis.status == 'geprueft' and not related_payment): - # Approve the quarterly confirmation - nachweis.status = 'geprueft' - nachweis.geprueft_am = timezone.now() - nachweis.geprueft_von = request.user - nachweis.save() - - # Auto-approve next quarter for semester-based tracking (Q1→Q2, Q3→Q4) - auto_approved_next = nachweis.auto_approve_next_quarter() - if auto_approved_next: - messages.info( - request, - f"Q{auto_approved_next.quartal} wurde automatisch auf Basis der Q{nachweis.quartal}-Nachweise freigegeben." - ) - - # Handle support payment - create if missing, update if exists - # Check if payment already exists before calling create_quarterly_support_payment() - payment_existed_before = related_payment is not None - - # Use create_quarterly_support_payment() which handles both cases (find existing or create new) - related_payment = create_quarterly_support_payment(nachweis) - if related_payment: - # Update status to 'in_bearbeitung' for both new and existing payments - old_status = related_payment.status - related_payment.status = 'in_bearbeitung' - related_payment.aktualisiert_am = timezone.now() - related_payment.save() - - if payment_existed_before: - messages.success( - request, - f"Vierteljahresnachweis freigegeben und bestehende Unterstützung für {nachweis.destinataer.get_full_name()} " - f"({nachweis.jahr} Q{nachweis.quartal}) wurde von '{old_status}' auf 'in Bearbeitung' aktualisiert." - ) - else: - messages.success( - request, - f"Vierteljahresnachweis freigegeben und neue Unterstützung über {related_payment.betrag}€ für {nachweis.destinataer.get_full_name()} " - f"({nachweis.jahr} Q{nachweis.quartal}) wurde erstellt." - ) - else: - messages.warning( - request, - f"Vierteljahresnachweis freigegeben, aber Unterstützung konnte nicht erstellt werden. " - f"Bitte prüfen Sie die Einstellungen für {nachweis.destinataer.get_full_name()}." - ) - else: - messages.error( - request, - "Nur eingereichte oder bereits genehmigte Nachweise können verarbeitet werden." - ) - - return redirect("stiftung:destinataer_detail", pk=nachweis.destinataer.pk) - - -@login_required -def quarterly_confirmation_reset(request, pk): - """Reset quarterly confirmation status (staff only)""" - if not request.user.is_staff: - messages.error(request, "Sie haben keine Berechtigung für diese Aktion.") - return redirect("stiftung:destinataer_list") - - nachweis = get_object_or_404(VierteljahresNachweis, pk=pk) - - if request.method == "POST": - if nachweis.status in ['geprueft', 'eingereicht']: - # Reset the quarterly confirmation status - nachweis.status = 'eingereicht' if nachweis.is_complete() else 'teilweise' - nachweis.geprueft_am = None - nachweis.geprueft_von = None - nachweis.aktualisiert_am = timezone.now() - nachweis.save() - - # Reset related support payment status if it exists - related_payment = nachweis.get_related_support_payment() - if related_payment and related_payment.status == 'in_bearbeitung': - related_payment.status = 'geplant' - related_payment.aktualisiert_am = timezone.now() - related_payment.save() - - messages.success( - request, - f"Vierteljahresnachweis und zugehörige Unterstützung für {nachweis.destinataer.get_full_name()} " - f"({nachweis.jahr} Q{nachweis.quartal}) wurden zurückgesetzt." - ) - else: - messages.success( - request, - f"Vierteljahresnachweis für {nachweis.destinataer.get_full_name()} " - f"({nachweis.jahr} Q{nachweis.quartal}) wurde zurückgesetzt." - ) - else: - messages.error( - request, - "Nur genehmigte oder eingereichte Nachweise können zurückgesetzt werden." - ) - - return redirect("stiftung:destinataer_detail", pk=nachweis.destinataer.pk) - - -# Two-Factor Authentication Views - -@login_required -def two_factor_setup(request): - """Setup or manage TOTP 2FA for the current user""" - - # Check if user already has TOTP device - device = TOTPDevice.objects.filter(user=request.user, confirmed=True).first() - static_device = StaticDevice.objects.filter(user=request.user).first() - - if device: - # User has 2FA enabled - show management options - context = { - 'has_2fa': True, - 'device': device, - 'backup_token_count': static_device.token_set.count() if static_device else 0, - 'title': 'Zwei-Faktor-Authentifizierung verwalten' - } - return render(request, 'stiftung/auth/two_factor_manage.html', context) - - # User doesn't have 2FA - show setup - # Get or create unconfirmed TOTP device - device, created = TOTPDevice.objects.get_or_create( - user=request.user, - name='default', - defaults={'confirmed': False} - ) - - if request.method == "POST": - token = request.POST.get('token', '').strip() - if device.verify_token(token): - device.confirmed = True - device.save() - - # Generate backup tokens - static_device = StaticDevice.objects.create( - user=request.user, - name='backup' - ) - - backup_tokens = [] - for _ in range(10): # Generate 10 backup codes - token_value = random_hex()[:8] # 8 character backup codes - StaticToken.objects.create( - device=static_device, - token=token_value - ) - backup_tokens.append(token_value) - - messages.success( - request, - "Zwei-Faktor-Authentifizierung wurde erfolgreich aktiviert! " - "Bitte speichern Sie Ihre Backup-Codes sicher." - ) - - return render(request, 'stiftung/auth/backup_tokens.html', { - 'backup_tokens': backup_tokens, - 'title': 'Backup-Codes' - }) - else: - messages.error(request, "Ungültiger Bestätigungscode. Bitte versuchen Sie es erneut.") - - # Generate QR code URL - qr_url = device.config_url - - context = { - 'device': device, - 'qr_url': qr_url, - 'title': 'Zwei-Faktor-Authentifizierung einrichten' - } - - return render(request, 'stiftung/auth/two_factor_setup.html', context) - - -@login_required -def two_factor_qr(request): - """Generate QR code for TOTP setup""" - device = TOTPDevice.objects.filter(user=request.user, confirmed=False).first() - - if not device: - return HttpResponse("Kein Setup-Device gefunden", status=404) - - # Generate QR code - qr = qrcode.QRCode(version=1, box_size=10, border=5) - qr.add_data(device.config_url) - qr.make(fit=True) - - img = qr.make_image(fill_color="black", back_color="white") - - response = HttpResponse(content_type="image/png") - img.save(response, "PNG") - - return response - - -@login_required -def two_factor_verify(request): - """Verify TOTP token during login process""" - if request.method == "POST": - token = request.POST.get('otp_token', '').strip() - - # Check TOTP devices - devices = TOTPDevice.objects.filter(user=request.user, confirmed=True) - for device in devices: - if device.verify_token(token): - request.session['2fa_verified'] = True - messages.success(request, "Zwei-Faktor-Authentifizierung erfolgreich.") - return redirect(request.GET.get('next', 'stiftung:home')) - - # Check static backup tokens - static_devices = StaticDevice.objects.filter(user=request.user) - for device in static_devices: - if device.verify_token(token): - request.session['2fa_verified'] = True - messages.success(request, "Backup-Code erfolgreich verwendet.") - return redirect(request.GET.get('next', 'stiftung:home')) - - messages.error(request, "Ungültiger Code. Bitte versuchen Sie es erneut.") - - context = { - 'title': 'Zwei-Faktor-Authentifizierung', - 'next': request.GET.get('next', '') - } - - return render(request, 'stiftung/auth/two_factor_verify.html', context) - - -@login_required -def two_factor_disable(request): - """Disable TOTP 2FA for the current user""" - if request.method == "POST": - password = request.POST.get('password', '') - - if request.user.check_password(password): - # Remove all TOTP devices - TOTPDevice.objects.filter(user=request.user).delete() - - # Remove all static backup token devices - StaticDevice.objects.filter(user=request.user).delete() - - messages.success( - request, - "Zwei-Faktor-Authentifizierung wurde erfolgreich deaktiviert." - ) - return redirect("stiftung:home") - else: - messages.error(request, "Ungültiges Passwort.") - - context = { - 'title': 'Zwei-Faktor-Authentifizierung deaktivieren' - } - - return render(request, 'stiftung/auth/two_factor_disable.html', context) - - -@login_required -def backup_tokens(request): - """Display or regenerate backup tokens""" - static_device = StaticDevice.objects.filter(user=request.user).first() - - if request.method == "POST" and 'regenerate' in request.POST: - password = request.POST.get('password', '') - - if request.user.check_password(password): - # Delete old tokens - if static_device: - static_device.delete() - - # Generate new backup tokens - static_device = StaticDevice.objects.create( - user=request.user, - name='backup' - ) - - backup_tokens = [] - for _ in range(10): # Generate 10 backup codes - token_value = random_hex()[:8] # 8 character backup codes - StaticToken.objects.create( - device=static_device, - token=token_value - ) - backup_tokens.append(token_value) - - messages.success( - request, - "Neue Backup-Codes wurden generiert. Bitte speichern Sie diese sicher." - ) - - context = { - 'backup_tokens': backup_tokens, - 'title': 'Neue Backup-Codes' - } - - return render(request, 'stiftung/auth/backup_tokens.html', context) - else: - messages.error(request, "Ungültiges Passwort.") - - # Show existing tokens (count only for security) - token_count = 0 - if static_device: - token_count = static_device.token_set.count() - - context = { - 'token_count': token_count, - 'has_tokens': token_count > 0, - 'title': 'Backup-Codes' - } - - return render(request, 'stiftung/auth/backup_tokens_manage.html', context) - - -# Geschichte (History) Views -from .models import GeschichteSeite, GeschichteBild -from .forms import GeschichteSeiteForm, GeschichteBildForm - - -@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 -# ============================================================ - -@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 .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 .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 .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 .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, - }) diff --git a/app/stiftung/views/__init__.py b/app/stiftung/views/__init__.py new file mode 100644 index 0000000..6fc9c27 --- /dev/null +++ b/app/stiftung/views/__init__.py @@ -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 diff --git a/app/stiftung/views/dashboard.py b/app/stiftung/views/dashboard.py new file mode 100644 index 0000000..aa50cf9 --- /dev/null +++ b/app/stiftung/views/dashboard.py @@ -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"}) + + diff --git a/app/stiftung/views/destinataere.py b/app/stiftung/views/destinataere.py new file mode 100644 index 0000000..24f4fdb --- /dev/null +++ b/app/stiftung/views/destinataere.py @@ -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 + + diff --git a/app/stiftung/views/dokumente.py b/app/stiftung/views/dokumente.py new file mode 100644 index 0000000..6769dcb --- /dev/null +++ b/app/stiftung/views/dokumente.py @@ -0,0 +1,1453 @@ +# views/dokumente.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 dokument_management(request): + """Dokumentenverwaltung: zeigt Paperless-Dokumente (mit Stiftung-Tags) und bestehende Verknüpfungen. + Bietet Filter und ermöglicht Re-Linking. + """ + return render(request, "stiftung/dokument_management.html") + + +@api_view(["GET"]) +def paperless_document_redirect(_request, doc_id: int): + """Redirects to the Paperless UI document URL and supports thumbnails if needed later.""" + from stiftung.utils.config import get_paperless_config + + config = get_paperless_config() + url = config["api_url"] + if not url: + return Response({"error": "Paperless API not configured"}, status=400) + + # Remove /api suffix if present, then construct the document URL + base_url = url[:-4] if url.endswith("/api") else url + + # For external Paperless (already includes /paperless/ in base URL) + return redirect(f"{base_url}/documents/{doc_id}/details/") + + +@login_required +def dokument_list(request): + """Zeigt alle verknüpften Dokumente an""" + # Alle verknüpften Dokumente laden + dokumente = DokumentLink.objects.all().order_by("-id") + + # Paperless-API-Konfiguration für verfügbare Dokumente + import requests + + from stiftung.utils.config import get_paperless_config + + config = get_paperless_config() + url = config["api_url"] + token = config["api_token"] + + available_dokumente = [] + if url and token: + try: + base_url = url[:-4] if url.endswith("/api") else url + headers = {"Authorization": f"Token {token}"} + + # Alle verfügbaren Dokumente abrufen (mit Paginierung) + all_dokumente = [] + page = 1 + page_size = 100 + + while True: + response = requests.get( + f"{base_url}/api/documents/?page={page}&page_size={page_size}", + headers=headers, + timeout=10, + ) + response.raise_for_status() + data = response.json() + + all_dokumente.extend(data.get("results", [])) + + if not data.get("next"): + break + page += 1 + + # Stiftung-Dokumente filtern + for doc in all_dokumente: + try: + tags = [] + doc_tags = doc.get("tags", []) + + if isinstance(doc_tags, list): + for tag in doc_tags: + if isinstance(tag, dict) and "name" in tag: + tags.append(tag["name"]) + elif isinstance(tag, str): + tags.append(tag) + elif isinstance(tag, int): + tags.append(f"Tag_{tag}") + elif isinstance(doc_tags, str): + tags = [tag.strip() for tag in doc_tags.split(",")] + + if any( + tag + in [ + config["destinataere_tag"], + config["land_tag"], + config["admin_tag"], + ] + for tag in tags + ): + bereits_verknuepft = DokumentLink.objects.filter( + paperless_document_id=doc["id"] + ).exists() + + if not bereits_verknuepft: + available_dokumente.append( + { + "id": doc["id"], + "title": doc.get("title", f'Dokument {doc["id"]}'), + "created_date": doc.get("created_date", ""), + "tags": tags, + "thumbnail_url": f"{base_url}/api/documents/{doc['id']}/thumb/", + "document_url": f"{base_url}/documents/{doc['id']}/", + } + ) + except Exception: + continue + + # Nach Erstellungsdatum sortieren + available_dokumente.sort(key=lambda x: x["created_date"], reverse=True) + + except Exception: + pass + + context = { + "dokumente": dokumente, + "available_dokumente": available_dokumente, + "title": "Alle verknüpften Dokumente", + } + return render(request, "stiftung/dokument_list.html", context) + + +@login_required +def dokument_detail(request, pk): + """Show details of a specific document link""" + dokument = get_object_or_404(DokumentLink, pk=pk) + + context = { + "dokument": dokument, + "title": f"Dokument: {dokument}", + } + return render(request, "stiftung/dokument_detail.html", context) + + +@login_required +def dokument_create(request): + """Create a new document link""" + if request.method == "POST": + form = DokumentLinkForm(request.POST) + if form.is_valid(): + dokument = form.save() + messages.success( + request, f'Dokument "{dokument.titel}" wurde erfolgreich verknüpft.' + ) + + # Zurück zur verknüpften Entität leiten + if dokument.land_verpachtung_id: + return redirect( + "stiftung:verpachtung_detail", pk=dokument.land_verpachtung_id + ) + elif dokument.verpachtung_id: + return redirect( + "stiftung:verpachtung_detail", pk=dokument.verpachtung_id + ) + elif dokument.land_id: + return redirect("stiftung:land_detail", pk=dokument.land_id) + elif dokument.paechter_id: + return redirect("stiftung:paechter_detail", pk=dokument.paechter_id) + elif dokument.destinataer_id: + return redirect( + "stiftung:destinataer_detail", pk=dokument.destinataer_id + ) + elif dokument.foerderung_id: + return redirect("stiftung:foerderung_detail", pk=dokument.foerderung_id) + else: + return redirect("stiftung:dokument_detail", pk=dokument.pk) + else: + # Initial-Werte aus GET-Parametern setzen + initial_data = {} + if request.GET.get("land_verpachtung_id"): + initial_data["land_verpachtung_id"] = request.GET.get("land_verpachtung_id") + if request.GET.get("verpachtung"): + initial_data["verpachtung_id"] = request.GET.get("verpachtung") + if request.GET.get("land"): + initial_data["land_id"] = request.GET.get("land") + if request.GET.get("paechter"): + initial_data["paechter_id"] = request.GET.get("paechter") + if request.GET.get("destinataer"): + initial_data["destinataer_id"] = request.GET.get("destinataer") + if request.GET.get("foerderung"): + initial_data["foerderung_id"] = request.GET.get("foerderung") + + form = DokumentLinkForm(initial=initial_data) + + context = { + "form": form, + "title": "Neues Dokument verknüpfen", + } + return render(request, "stiftung/dokument_form.html", context) + + +@login_required +def dokument_update(request, pk): + """Update an existing document link""" + dokument = get_object_or_404(DokumentLink, pk=pk) + + if request.method == "POST": + form = DokumentLinkForm(request.POST, instance=dokument) + if form.is_valid(): + form.save() + messages.success( + request, f'Dokument "{dokument.titel}" wurde erfolgreich aktualisiert.' + ) + return redirect("stiftung:dokument_detail", pk=dokument.pk) + else: + form = DokumentLinkForm(instance=dokument) + + context = { + "form": form, + "dokument": dokument, + "title": f"Dokument bearbeiten: {dokument}", + } + return render(request, "stiftung/dokument_form.html", context) + + +@login_required +def dokument_delete(request, pk): + """Delete a document link""" + dokument = get_object_or_404(DokumentLink, pk=pk) + + if request.method == "POST": + dokument.delete() + messages.success( + request, f'Dokument "{dokument.titel}" wurde erfolgreich gelöscht.' + ) + return redirect("stiftung:dokument_list") + + context = { + "dokument": dokument, + "title": f"Dokument löschen: {dokument}", + } + return render(request, "stiftung/dokument_confirm_delete.html", context) + + +# Legacy document views removed - use dokument_management instead + + +# Jahresbericht Views +@api_view(["GET"]) +def paperless_ping(_request): + from stiftung.utils.config import get_paperless_config + + config = get_paperless_config() + url = config["api_url"] + token = config["api_token"] + if not url or not token: + return Response( + {"ok": False, "reason": "Paperless API not configured"}, status=400 + ) + try: + # Entferne /api vom Ende der URL falls vorhanden + base_url = url[:-4] if url.endswith("/api") else url + r = requests.get( + f"{base_url}/api/tags/", + headers={"Authorization": f"Token {token}"}, + timeout=5, + ) + return Response({"ok": r.ok, "status_code": r.status_code}) + except Exception as e: + return Response({"ok": False, "error": str(e)}, status=500) + + +@api_view(["GET"]) +def paperless_documents(request): + """Holt Dokumente aus Paperless mit den erforderlichen Tags. + Optionales Polling: ?poll=1 wartet kurz und versucht erneut, damit frisch ingestete + Dokumente in Paperless erscheinen, bevor die Liste zurückgegeben wird. + """ + from stiftung.utils.config import get_paperless_config + + config = get_paperless_config() + url = config["api_url"] + token = config["api_token"] + required_tag = config["destinataere_tag"] + land_tag = config["land_tag"] + admin_tag = config["admin_tag"] + destinaere_tag_id = config["destinataere_tag_id"] + land_tag_id = config["land_tag_id"] + admin_tag_id = config["admin_tag_id"] + + if not url or not token: + return Response( + { + "error": "Paperless API not configured", + "message": "Please set PAPERLESS_API_URL and PAPERLESS_API_TOKEN in your environment variables", + "documents": [], + "total_destinaere": 0, + "total_land": 0, + "total_admin": 0, + "total_all": 0, + }, + status=400, + ) + + try: + # Entferne /api vom Ende der URL falls vorhanden + base_url = url[:-4] if url.endswith("/api") else url + headers = {"Authorization": f"Token {token}"} + + def fetch_tagged(): + # mit ordering=-created neueste zuerst + dest_resp = requests.get( + f"{base_url}/api/documents/?tags__id={destinaere_tag_id}&ordering=-created", + headers=headers, + timeout=10, + ) + dest_resp.raise_for_status() + dest_docs = dest_resp.json() + + land_resp = requests.get( + f"{base_url}/api/documents/?tags__id={land_tag_id}&ordering=-created", + headers=headers, + timeout=10, + ) + land_resp.raise_for_status() + land_docs = land_resp.json() + + admin_resp = requests.get( + f"{base_url}/api/documents/?tags__id={admin_tag_id}&ordering=-created", + headers=headers, + timeout=10, + ) + admin_resp.raise_for_status() + admin_docs = admin_resp.json() + + return dest_docs, land_docs, admin_docs + + dest_docs, land_docs, admin_docs = fetch_tagged() + + # Optionales kurzes Polling, wenn angefordert + if request.GET.get("poll") in ("1", "true", "yes"): + start_total = sum( + [ + dest_docs.get("count", 0), + land_docs.get("count", 0), + admin_docs.get("count", 0), + ] + ) + deadline = time.time() + 6.0 # bis zu 6 Sekunden warten + while time.time() < deadline: + time.sleep(1.0) + d2, l2, a2 = fetch_tagged() + new_total = sum( + [d2.get("count", 0), l2.get("count", 0), a2.get("count", 0)] + ) + if new_total > start_total: + dest_docs, land_docs, admin_docs = d2, l2, a2 + break + + # Alle Dokumente zusammenfassen + all_documents = [] + for doc in dest_docs.get("results", []): + doc["tag_category"] = "destinaere" + all_documents.append(doc) + for doc in land_docs.get("results", []): + doc["tag_category"] = "land" + all_documents.append(doc) + for doc in admin_docs.get("results", []): + doc["tag_category"] = "admin" + all_documents.append(doc) + + return Response( + { + "documents": all_documents, + "total_destinaere": dest_docs.get("count", 0), + "total_land": land_docs.get("count", 0), + "total_admin": admin_docs.get("count", 0), + "total_all": len(all_documents), + } + ) + + except requests.exceptions.RequestException as e: + import logging + logger = logging.getLogger(__name__) + logger.error(f"Paperless API request failed: {e}") + logger.error(f"Paperless API URL: {base_url}") + logger.error(f"Token configured: {'Yes' if token else 'No'}") + + return Response( + { + "error": f"API-Fehler: {e}", + "message": f"Could not connect to Paperless API at {base_url}. Please check your configuration.", + "debug_info": { + "api_url": base_url, + "has_token": bool(token), + "error_type": type(e).__name__ + }, + "documents": [], + "total_destinaere": 0, + "total_land": 0, + "total_admin": 0, + "total_all": 0, + }, + status=500, + ) + except Exception as e: + return Response( + { + "error": f"Unerwarteter Fehler: {e}", + "message": "An unexpected error occurred while fetching documents.", + "documents": [], + "total_destinaere": 0, + "total_land": 0, + "total_admin": 0, + "total_all": 0, + }, + status=500, + ) + + +# Legacy dokument_integration view removed - use dokument_management instead + + +@api_view(["GET"]) +def paperless_debug(request): + """Debug-View für Paperless-Integration""" + from stiftung.utils.config import get_paperless_config + + config = get_paperless_config() + url = config["api_url"] + token = config["api_token"] + required_tag = config["destinataere_tag"] + land_tag = config["land_tag"] + admin_tag = config["admin_tag"] + destinaere_tag_id = config["destinataere_tag_id"] + land_tag_id = config["land_tag_id"] + admin_tag_id = config["admin_tag_id"] + + if not url or not token: + return Response({"error": "Paperless API not configured"}, status=400) + + try: + # Entferne /api vom Ende der URL falls vorhanden + base_url = url[:-4] if url.endswith("/api") else url + + headers = {"Authorization": f"Token {token}"} + + # Alle Tags abrufen + tags_response = requests.get( + f"{base_url}/api/tags/?page_size=1000", headers=headers, timeout=10 + ) + tags_response.raise_for_status() + tags_data = tags_response.json() + + # Alle Tags durchsuchen + all_tags = tags_data.get("results", []) + exact_match_destinaere = None + exact_match_land = None + exact_match_admin = None + similar_tags = [] + + # Nach den neuen Tag-Namen suchen (mit Unterstrichen) + for tag in all_tags: + tag_name = tag.get("name", "") + tag_id = tag.get("id") + + # Suche nach den neuen Tag-Namen + if tag_name == "Stiftung_Destinatäre": + exact_match_destinaere = {"id": tag_id, "name": tag_name} + elif tag_name == "Stiftung_Land_und_Pächter": + exact_match_land = {"id": tag_id, "name": tag_name} + elif tag_name == "Stiftung_Administration": + exact_match_admin = {"id": tag_id, "name": tag_name} + + # Ähnliche Tags finden + if ( + "stiftung" in tag_name.lower() + or "destinat" in tag_name.lower() + or "land" in tag_name.lower() + or "admin" in tag_name.lower() + ): + similar_tags.append({"id": tag_id, "name": tag_name}) + + # Alle Tag-Namen sammeln + all_tag_names = [tag.get("name", "") for tag in all_tags] + + # Dokumente abrufen + documents_response = requests.get( + f"{base_url}/api/documents/?page_size=10", headers=headers, timeout=10 + ) + documents_response.raise_for_status() + documents_data = documents_response.json() + + # Stiftung-Dokumente finden (mit Tag 21 "Stiftung") + stiftung_documents = [] + for doc in documents_data.get("results", []): + doc_tags = doc.get("tags", []) + if 21 in doc_tags: # Tag 21 ist "Stiftung" + stiftung_documents.append(doc) + + # Sample-Dokumente mit Tag-Namen anreichern + sample_documents = documents_data.get("results", [])[:5] + enriched_documents = [] + + for doc in sample_documents: + doc_copy = doc.copy() + tag_names = [] + for tag_id in doc.get("tags", []): + # Tag-Namen aus der Tag-Liste finden + tag_name = next( + ( + tag.get("name", f"Unknown({tag_id})") + for tag in all_tags + if tag.get("id") == tag_id + ), + f"Unknown({tag_id})", + ) + tag_names.append(tag_name) + doc_copy["tag_names"] = tag_names + enriched_documents.append(doc_copy) + + return Response( + { + "paperless_url": url, + "base_url": base_url, + "required_tag": required_tag, + "land_tag": land_tag, + "admin_tag": admin_tag, + "destinaere_tag_id": destinaere_tag_id, + "land_tag_id": land_tag_id, + "admin_tag_id": admin_tag_id, + "exact_match_destinaere": exact_match_destinaere, + "exact_match_land": exact_match_land, + "exact_match_admin": exact_match_admin, + "similar_tags": similar_tags, + "all_tag_names": all_tag_names, + "total_tags": len(all_tags), + "total_documents": documents_data.get("count", 0), + "sample_documents": sample_documents, + "api_token_length": len(token) if token else 0, + "enriched_documents": enriched_documents, + "stiftung_documents": stiftung_documents, + } + ) + + except requests.exceptions.RequestException as e: + return Response({"error": f"API-Fehler: {e}"}, status=500) + except Exception as e: + return Response({"error": f"Unerwarteter Fehler: {e}"}, status=500) + + +@api_view(["GET"]) +def paperless_tags_only(request): + """Holt nur die Tag-Liste aus Paperless - ohne Dokumente""" + from stiftung.utils.config import get_paperless_config + + config = get_paperless_config() + url = config["api_url"] + token = config["api_token"] + + if not url or not token: + return Response({"error": "Paperless API not configured"}, status=400) + + try: + # Entferne /api vom Ende der URL falls vorhanden + base_url = url[:-4] if url.endswith("/api") else url + + # Alle Tags abrufen (mit großer page_size) + headers = {"Authorization": f"Token {token}"} + + # Erste Anfrage mit großer page_size + tags_response = requests.get( + f"{base_url}/api/tags/?page_size=1000", headers=headers, timeout=10 + ) + tags_response.raise_for_status() + tags_data = tags_response.json() + + all_tags = [] + + # Erste Seite verarbeiten + for tag in tags_data.get("results", []): + tag_detail = { + "id": tag.get("id"), + "name": tag.get("name", ""), + "slug": tag.get("slug", ""), + "color": tag.get("color", ""), + "text_color": tag.get("text_color", ""), + "match": tag.get("match", ""), + "matching_algorithm": tag.get("matching_algorithm"), + "is_inbox_tag": tag.get("is_inbox_tag"), + "document_count": tag.get("document_count", 0), + } + all_tags.append(tag_detail) + + # Weitere Seiten abrufen falls vorhanden + next_url = tags_data.get("next") + while next_url: + next_response = requests.get(next_url, headers=headers, timeout=10) + next_response.raise_for_status() + next_data = next_response.json() + + for tag in next_data.get("results", []): + tag_detail = { + "id": tag.get("id"), + "name": tag.get("name", ""), + "slug": tag.get("slug", ""), + "color": tag.get("color", ""), + "text_color": tag.get("text_color", ""), + "match": tag.get("match", ""), + "matching_algorithm": tag.get("matching_algorithm"), + "is_inbox_tag": tag.get("is_inbox_tag"), + "document_count": tag.get("document_count", 0), + } + all_tags.append(tag_detail) + + next_url = next_data.get("next") + + # Nach ID sortieren + all_tags.sort(key=lambda x: x["id"]) + + return Response( + { + "total_tags": len(all_tags), + "tags": all_tags, + "tag_ids": [tag["id"] for tag in all_tags], + "tag_names": [tag["name"] for tag in all_tags], + "api_info": { + "page_size_used": 1000, + "total_count_from_api": tags_data.get("count", 0), + }, + } + ) + + except requests.exceptions.RequestException as e: + return Response({"error": f"API-Fehler: {e}"}, status=500) + except Exception as e: + return Response({"error": f"Unerwarteter Fehler: {e}"}, status=500) + + +@api_view(["GET"]) +def link_document_search(request): + """Sucht nach Datensätzen für die Dokument-Verknüpfung""" + from django.db.models import Q + + query = request.GET.get("q", "") + category = request.GET.get("category", "all") + + results = {} + + if category in ["all", "destinataer"]: + # Suche nach Destinatären + destinataer_query = Q() + if query and query != "all": + destinataer_query = ( + Q(nachname__icontains=query) + | Q(vorname__icontains=query) + | Q(email__icontains=query) + | Q(telefon__icontains=query) + | Q(strasse__icontains=query) + | Q(ort__icontains=query) + | Q(plz__icontains=query) + | Q(institution__icontains=query) + | Q(familienzweig__icontains=query) + | Q(notizen__icontains=query) + ) + + destinataer_results = Destinataer.objects.filter(destinataer_query)[:25] + results["destinataer"] = [ + { + "id": d.id, + "name": ( + f"{d.vorname} {d.nachname}".strip() + if d.vorname + else (d.institution or d.nachname) + ), + "type": "Destinatär", + "details": f"{d.strasse or ''}{(', ' + d.plz + ' ' + d.ort) if d.plz and d.ort else (' ' + d.ort) if d.ort else ''} {('• ' + d.email) if d.email else ''} {('• ' + d.institution) if d.institution else ''} {('• ' + d.familienzweig) if d.familienzweig else ''}".strip(), + } + for d in destinataer_results + ] + + if category in ["all", "land"]: + # Suche nach Ländereien + land_query = Q() + if query and query != "all": + # Extract numbers from search terms like "Flur 9" or "Flurstück 11" + import re + + flur_match = re.search(r"flur\s*(\d+)", query, re.IGNORECASE) + flurstuck_match = re.search(r"flurstück\s*(\d+)", query, re.IGNORECASE) + + land_query = ( + Q(gemarkung__icontains=query) + | Q(gemeinde__icontains=query) + | Q(flur__icontains=query) + | Q(flurstueck__icontains=query) + | Q(lfd_nr__icontains=query) + | Q(ew_nummer__icontains=query) + | Q(notizen__icontains=query) + ) + + # Add specific searches for extracted numbers + if flur_match: + land_query |= Q(flur__exact=flur_match.group(1)) + if flurstuck_match: + land_query |= Q(flurstueck__exact=flurstuck_match.group(1)) + + land_results = Land.objects.filter(land_query)[:25] + results["land"] = [ + { + "id": l.id, + "name": f"{l.gemarkung} - {l.gemeinde}, Flur {l.flur}, Flurstück {l.flurstueck or 'N/A'}", + "type": "Land", + "details": f"Lfd.Nr: {l.lfd_nr or 'N/A'} • EW-Nr: {l.ew_nummer or 'N/A'} • Größe: {l.groesse_qm or 0} m²", + } + for l in land_results + ] + + if category in ["all", "verpachtung"]: + # Suche nach Verpachtungen (using new LandVerpachtung model) + verpachtung_query = Q() + if query and query != "all": + verpachtung_query = ( + Q(paechter__nachname__icontains=query) + | Q(paechter__vorname__icontains=query) + | Q(paechter__ort__icontains=query) + | Q(paechter__email__icontains=query) + | Q(paechter__pachtnummer__icontains=query) + | Q(land__gemarkung__icontains=query) + | Q(land__gemeinde__icontains=query) + | Q(land__flur__icontains=query) + | Q(land__flurstueck__icontains=query) + | Q(land__lfd_nr__icontains=query) + | Q(vertragsnummer__icontains=query) + | Q(pachtzins_pauschal__icontains=query) + | Q(bemerkungen__icontains=query) + ) + + verpachtung_results = LandVerpachtung.objects.filter( + verpachtung_query + ).select_related("paechter", "land")[:25] + results["verpachtung"] = [ + { + "id": v.id, + "name": f"{(v.paechter.vorname + ' ') if v.paechter and v.paechter.vorname else ''}{v.paechter.nachname if v.paechter else 'Unbekannt'} → {v.land.gemarkung}, Flur {v.land.flur}", + "type": "Verpachtung", + "details": f"Vertrag: {v.vertragsnummer} • Flurstück: {v.land.flurstueck or 'N/A'} • Pachtzins: {v.pachtzins_pauschal or 'N/A'} €/Jahr • Zeitraum: {v.pachtbeginn.strftime('%Y') if v.pachtbeginn else '?'}-{v.pachtende.strftime('%Y') if v.pachtende else 'laufend'}", + } + for v in verpachtung_results + ] + + if category in ["all", "paechter"]: + # Suche nach Pächtern + paechter_query = Q() + if query and query != "all": + paechter_query = ( + Q(nachname__icontains=query) + | Q(vorname__icontains=query) + | Q(ort__icontains=query) + | Q(email__icontains=query) + | Q(telefon__icontains=query) + | Q(strasse__icontains=query) + | Q(pachtnummer__icontains=query) + | Q(plz__icontains=query) + | Q(notizen__icontains=query) + ) + paechter_results = Paechter.objects.filter(paechter_query)[:25] + results["paechter"] = [ + { + "id": p.id, + "name": f"{(p.vorname + ' ') if p.vorname else ''}{p.nachname}" + + (f" (#{p.pachtnummer})" if p.pachtnummer else ""), + "type": "Pächter", + "details": f"{p.strasse or ''}{(', ' + p.plz + ' ' + p.ort) if p.plz and p.ort else (' ' + p.ort) if p.ort else ''} {('• ' + p.email) if p.email else ''} {('• Tel: ' + p.telefon) if p.telefon else ''}".strip(), + } + for p in paechter_results + ] + + if category in ["all", "rentmeister"]: + # Suche nach Rentmeistern + from stiftung.models import Rentmeister + + rentmeister_query = Q() + if query and query != "all": + rentmeister_query = ( + Q(nachname__icontains=query) + | Q(vorname__icontains=query) + | Q(ort__icontains=query) + | Q(email__icontains=query) + | Q(telefon__icontains=query) + | Q(strasse__icontains=query) + | Q(plz__icontains=query) + | Q(notizen__icontains=query) + | Q(titel__icontains=query) + | Q(mobil__icontains=query) + ) + rentmeister_results = Rentmeister.objects.filter(rentmeister_query)[:25] + results["rentmeister"] = [ + { + "id": r.id, + "name": f"{(r.anrede + ' ') if r.anrede else ''}{(r.vorname + ' ') if r.vorname else ''}{r.nachname}" + + (f" ({r.titel})" if r.titel else ""), + "type": "Rentmeister", + "details": f"{r.strasse or ''}{(', ' + r.plz + ' ' + r.ort) if r.plz and r.ort else (' ' + r.ort) if r.ort else ''} {('• ' + r.email) if r.email else ''} {('• Tel: ' + r.telefon) if r.telefon else ''} {('• Mobil: ' + r.mobil) if r.mobil else ''}".strip(), + } + for r in rentmeister_results + ] + + if category in ["all", "abrechnung"]: + # Suche nach Abrechnungen + abrechnung_query = Q() + if query and query != "all": + abrechnung_query = ( + Q(land__gemarkung__icontains=query) + | Q(land__gemeinde__icontains=query) + | Q(land__flur__icontains=query) + | Q(land__flurstueck__icontains=query) + | Q(land__lfd_nr__icontains=query) + | Q(abrechnungsjahr__icontains=query) + | Q(bemerkungen__icontains=query) + ) + + abrechnung_results = LandAbrechnung.objects.filter( + abrechnung_query + ).select_related("land")[:25] + results["abrechnung"] = [ + { + "id": a.id, + "name": f"Abrechnung {a.abrechnungsjahr} - {a.land.gemarkung}, Flur {a.land.flur}", + "type": "Abrechnung", + "details": f"Flurstück: {a.land.flurstueck or 'N/A'} • Jahr: {a.abrechnungsjahr} • Grundsteuer: {a.grundsteuer_betrag or 0} € • Versicherung: {a.versicherungen_betrag or 0} €", + } + for a in abrechnung_results + ] + + if category in ["all", "foerderung"]: + # Suche nach Förderungen + foerderung_query = Q() + if query and query != "all": + foerderung_query = ( + Q(destinataer__nachname__icontains=query) + | Q(destinataer__vorname__icontains=query) + | Q(destinataer__institution__icontains=query) + | Q(destinataer__email__icontains=query) + | Q(jahr__icontains=query) + | Q(betrag__icontains=query) + | Q(kategorie__icontains=query) + | Q(status__icontains=query) + | Q(bemerkungen__icontains=query) + ) + + foerderung_results = Foerderung.objects.filter(foerderung_query).select_related( + "destinataer" + )[:25] + results["foerderung"] = [ + { + "id": str(f.id), # Convert UUID to string for JSON serialization + "name": f"{f.destinataer.get_full_name() if f.destinataer else 'Unbekannt'} - {f.jahr}", + "type": "Förderung", + "details": f"Betrag: {f.betrag} € • Kategorie: {f.get_kategorie_display()} • Status: {f.get_status_display()} • Jahr: {f.jahr}", + } + for f in foerderung_results + ] + + return Response(results) + + +def create_paechter_link_for_verpachtung(paperless_id, paperless_title, verpachtung_id): + """Hilfsfunktion: Erstellt automatisch eine Pächter-Verknüpfung für eine Verpachtung""" + try: + # Hole die LandVerpachtung und den zugehörigen Pächter + verpachtung = LandVerpachtung.objects.select_related("paechter").get( + id=verpachtung_id + ) + if verpachtung.paechter: + # Prüfe, ob bereits eine Verknüpfung für dieses Dokument und diesen Pächter existiert + existing_link = DokumentLink.objects.filter( + paperless_document_id=paperless_id, paechter_id=verpachtung.paechter.id + ).first() + + if not existing_link: + # Erstelle automatische Pächter-Verknüpfung + DokumentLink.objects.create( + paperless_document_id=paperless_id, + titel=paperless_title, + kontext="paechter", + paechter_id=verpachtung.paechter.id, + ) + return True + except (LandVerpachtung.DoesNotExist, Exception): + pass + return False + + +@csrf_exempt +@api_view(["POST"]) +def link_document_create(request): + """Erstellt eine Verknüpfung zwischen einem Paperless-Dokument und einem Datensatz""" + from django.db import transaction + + try: + # Robust JSON parsing to tolerate Latin-1 encoded payloads (e.g., umlauts) + try: + payload = request.data + except Exception: + raw = request.body + try: + payload = json.loads(raw.decode("utf-8")) + except UnicodeDecodeError: + payload = json.loads(raw.decode("latin-1")) + + paperless_id = payload.get("paperless_id") + paperless_title = payload.get("paperless_title") + paperless_url = payload.get("paperless_url") + link_type = payload.get( + "link_type" + ) # 'destinataer', 'land', 'verpachtung', 'paechter', 'abrechnung' + link_id = payload.get("link_id") + + if not all([paperless_id, paperless_title, paperless_url, link_type, link_id]): + return Response({"error": "Alle Felder sind erforderlich"}, status=400) + + with transaction.atomic(): + # Erstelle den DokumentLink + dokument_link = DokumentLink.objects.create( + paperless_document_id=paperless_id, # Korrigiert: 'paperless_document_id' statt 'paperless_id' + titel=paperless_title, # Korrigiert: 'titel' statt 'title' + kontext="anderes", + ) + + # Setze die entsprechenden UUID-Felder basierend auf dem Link-Typ + if link_type == "destinataer": + dokument_link.destinataer_id = link_id + elif link_type == "land": + dokument_link.land_id = link_id + elif link_type == "verpachtung": + # Use new LandVerpachtung field instead of legacy + dokument_link.land_verpachtung_id = link_id + elif link_type == "paechter": + dokument_link.paechter_id = link_id + elif link_type == "foerderung": + dokument_link.foerderung_id = link_id + elif link_type == "rentmeister": + dokument_link.rentmeister_id = link_id + elif link_type == "abrechnung": + dokument_link.abrechnung_id = link_id + + dokument_link.save() + + # Log the document linking action + from stiftung.audit import log_link + + try: + # Get the linked entity name for logging + entity_name = paperless_title + if link_type == "destinataer": + from stiftung.models import Destinataer + + entity = Destinataer.objects.get(id=link_id) + target_name = entity.get_full_name() + elif link_type == "land": + from stiftung.models import Land + + entity = Land.objects.get(id=link_id) + target_name = str(entity) + elif link_type == "paechter": + from stiftung.models import Paechter + + entity = Paechter.objects.get(id=link_id) + target_name = f"{entity.vorname} {entity.nachname}".strip() + elif link_type == "foerderung": + from stiftung.models import Foerderung + + entity = Foerderung.objects.get(id=link_id) + target_name = f"{entity.destinataer.get_full_name() if entity.destinataer else 'Unbekannt'} - {entity.jahr}" + elif link_type == "verpachtung": + entity = LandVerpachtung.objects.get(id=link_id) + target_name = str(entity) + elif link_type == "rentmeister": + from stiftung.models import Rentmeister + + entity = Rentmeister.objects.get(id=link_id) + target_name = entity.get_full_name() + else: + target_name = f"ID {link_id}" + + log_link( + request=request, + entity_type="dokumentlink", + entity_id=str(dokument_link.id), + entity_name=entity_name, + target_type=link_type, + target_name=target_name, + ) + except Exception as e: + # Don't fail the main operation if logging fails + print(f"Audit logging failed: {e}") + + # Automatische Pächter-Verknüpfung NACH der Haupttransaktion + paechter_linked = False + if link_type == "verpachtung": + paechter_linked = create_paechter_link_for_verpachtung( + paperless_id, paperless_title, link_id + ) + + message = f"Dokument erfolgreich mit {link_type} verknüpft" + if paechter_linked: + message += " (automatisch auch mit Pächter verknüpft)" + + return Response( + {"success": True, "message": message, "dokument_id": dokument_link.id} + ) + + except Exception as e: + return Response( + {"error": f"Fehler beim Erstellen der Verknüpfung: {str(e)}"}, status=500 + ) + + +# Legacy dokument_verknuepfung view removed - use dokument_management instead + + +@api_view(["GET"]) +def link_document_list(request): + """Listet alle bestehenden Dokument-Verknüpfungen auf, gruppiert nach Paperless-Document-ID""" + try: + dokument_links = DokumentLink.objects.all().order_by("-id") + + # Group links by paperless_document_id to show multiple links per document + links_by_document = {} + + for link in dokument_links: + paperless_id = link.paperless_document_id + + if paperless_id not in links_by_document: + links_by_document[paperless_id] = { + "paperless_id": paperless_id, + "title": link.titel, + "paperless_url": f"/api/paperless/documents/{paperless_id}/", + "links": [], + } + + # Create link info + link_info = { + "id": str(link.id), # Ensure UUID is stringified + "kontext": link.kontext, + "link_type": None, + "linked_object": None, + } + + # Determine link type and get linked object details + if link.destinataer_id: + link_info["link_type"] = "destinataer" + try: + dest = Destinataer.objects.get(id=link.destinataer_id) + link_info["linked_object"] = { + "id": str(dest.id), + "type": "Destinatär", + "name": ( + f"{dest.vorname} {dest.nachname}".strip() + if dest.vorname + else dest.institution + ), + "details": ( + f"Institution: {dest.institution}" + if dest.institution + else f"Person: {dest.vorname} {dest.nachname}".strip() + ), + } + except Destinataer.DoesNotExist: + link_info["linked_object"] = { + "type": "Destinatär", + "name": "Gelöscht", + "details": "Datensatz nicht mehr verfügbar", + } + + elif link.land_id: + link_info["link_type"] = "land" + try: + land = Land.objects.get(id=link.land_id) + link_info["linked_object"] = { + "id": str(land.id), + "type": "Land", + "name": f"{land.gemarkung} - {land.gemeinde}", + "details": f"Flur: {land.flur}, Größe: {land.groesse_qm or 0} m²", + } + except Land.DoesNotExist: + link_info["linked_object"] = { + "type": "Land", + "name": "Gelöscht", + "details": "Datensatz nicht mehr verfügbar", + } + + elif link.paechter_id: + link_info["link_type"] = "paechter" + try: + p = Paechter.objects.get(id=link.paechter_id) + link_info["linked_object"] = { + "id": str(p.id), + "type": "Pächter", + "name": f"{(p.vorname + ' ') if p.vorname else ''}{p.nachname}", + "details": f"{p.ort or ''}", + } + except Paechter.DoesNotExist: + link_info["linked_object"] = { + "type": "Pächter", + "name": "Gelöscht", + "details": "Datensatz nicht mehr verfügbar", + } + + elif link.land_verpachtung_id: + link_info["link_type"] = "verpachtung" + try: + from stiftung.models import LandVerpachtung + + verp = LandVerpachtung.objects.select_related( + "paechter", "land" + ).get(id=link.land_verpachtung_id) + link_info["linked_object"] = { + "id": str(verp.id), + "type": "Verpachtung", + "name": f"Verpachtung {(verp.paechter.vorname + ' ') if verp.paechter and verp.paechter.vorname else ''}{verp.paechter.nachname if verp.paechter else 'Unbekannt'}", + "details": f"Land: {verp.land.gemarkung} - {verp.land.gemeinde}, Pächter: {(verp.paechter.vorname + ' ') if verp.paechter and verp.paechter.vorname else ''}{verp.paechter.nachname if verp.paechter else 'Unbekannt'}, Vertrag: {verp.vertragsnummer}", + } + except LandVerpachtung.DoesNotExist: + link_info["linked_object"] = { + "type": "Verpachtung", + "name": "Gelöscht", + "details": "Datensatz nicht mehr verfügbar", + } + + elif link.rentmeister_id: + link_info["link_type"] = "rentmeister" + try: + from stiftung.models import Rentmeister + + rentmeister = Rentmeister.objects.get(id=link.rentmeister_id) + link_info["linked_object"] = { + "id": str(rentmeister.id), + "type": "Rentmeister", + "name": f"{(rentmeister.anrede + ' ') if rentmeister.anrede else ''}{(rentmeister.vorname + ' ') if rentmeister.vorname else ''}{rentmeister.nachname}" + + (f" ({rentmeister.titel})" if rentmeister.titel else ""), + "details": f"Seit: {rentmeister.seit_datum.strftime('%d.%m.%Y') if rentmeister.seit_datum else 'Unbekannt'}" + + ( + f", Tel: {rentmeister.telefon}" + if rentmeister.telefon + else "" + ) + + (f", {rentmeister.email}" if rentmeister.email else ""), + "url": f"/geschaeftsfuehrung/rentmeister/{rentmeister.id}/", + } + except Rentmeister.DoesNotExist: + link_info["linked_object"] = { + "type": "Rentmeister", + "name": "Gelöscht", + "details": "Datensatz nicht mehr verfügbar", + } + + elif link.abrechnung_id: + link_info["link_type"] = "abrechnung" + try: + abrechnung = LandAbrechnung.objects.select_related("land").get( + id=link.abrechnung_id + ) + link_info["linked_object"] = { + "id": str(abrechnung.id), + "type": "Abrechnung", + "name": f"Abrechnung {abrechnung.abrechnungsjahr} - {abrechnung.land.gemarkung}", + "details": f"Land: {abrechnung.land.gemarkung} - {abrechnung.land.gemeinde}, Flur {abrechnung.land.flur}, Jahr {abrechnung.abrechnungsjahr}", + "url": f"/laendereien/abrechnungen/{abrechnung.id}/", + } + except LandAbrechnung.DoesNotExist: + link_info["linked_object"] = { + "type": "Abrechnung", + "name": "Gelöscht", + "details": "Datensatz nicht mehr verfügbar", + } + + links_by_document[paperless_id]["links"].append(link_info) + + # Convert to list format for frontend + results = list(links_by_document.values()) + + return Response( + { + "total_documents": len(results), + "total_links": sum(len(doc["links"]) for doc in results), + "links": results, + } + ) + + except Exception as e: + return Response( + {"error": f"Fehler beim Abrufen der Verknüpfungen: {str(e)}"}, status=500 + ) + + +@csrf_exempt +@api_view(["POST"]) +def link_document_update(request): + """Aktualisiert die Verknüpfung eines Dokuments (Re-Linking auf anderen Datensatz/Kontext).""" + from django.db import transaction + + try: + # Robust JSON parsing to tolerate Latin-1 encoded payloads (e.g., umlauts) + try: + payload = request.data + except Exception: + raw = request.body + try: + payload = json.loads(raw.decode("utf-8")) + except UnicodeDecodeError: + payload = json.loads(raw.decode("latin-1")) + + link_id = payload.get("link_id") + link_type = payload.get( + "link_type" + ) # 'destinataer' | 'land' | 'verpachtung' | 'paechter' | 'rentmeister' + link_target_id = payload.get("link_id_target") + if not all([link_id, link_type, link_target_id]): + return Response( + {"error": "link_id, link_type und link_id_target sind erforderlich"}, + status=400, + ) + + with transaction.atomic(): + link = DokumentLink.objects.get(id=link_id) + old_verpachtung_id = ( + link.verpachtung_id + ) # Merke alte Verpachtung für Cleanup + paperless_id_for_cleanup = link.paperless_document_id + titel_for_new_link = link.titel + + # Reset all associations first + link.destinataer_id = None + link.land_id = None + link.verpachtung_id = None + link.paechter_id = None + link.foerderung_id = None + link.rentmeister_id = None + link.kontext = link_type + + if link_type == "destinataer": + link.destinataer_id = link_target_id + elif link_type == "land": + link.land_id = link_target_id + elif link_type == "verpachtung": + link.verpachtung_id = link_target_id + elif link_type == "paechter": + link.paechter_id = link_target_id + elif link_type == "foerderung": + link.foerderung_id = link_target_id + elif link_type == "rentmeister": + link.rentmeister_id = link_target_id + else: + return Response({"error": "Ungültiger link_type"}, status=400) + + link.save() + + # Automatische Pächter-Verknüpfung und Cleanup NACH der Haupttransaktion + paechter_linked = False + if link_type == "verpachtung": + paechter_linked = create_paechter_link_for_verpachtung( + paperless_id_for_cleanup, titel_for_new_link, link_target_id + ) + + # Cleanup: Entferne verwaiste Pächter-Verknüpfungen wenn von Verpachtung weg geändert + if old_verpachtung_id and link_type != "verpachtung": + try: + old_verpachtung = LandVerpachtung.objects.select_related( + "paechter" + ).get(id=old_verpachtung_id) + if old_verpachtung.paechter: + # Prüfe ob noch andere Verpachtungs-Links für diesen Pächter existieren + other_verpachtung_links = DokumentLink.objects.filter( + paperless_document_id=paperless_id_for_cleanup, + verpachtung__paechter_id=old_verpachtung.paechter.id, + ).exists() + + if not other_verpachtung_links: + # Entferne automatisch erstellte Pächter-Verknüpfung + DokumentLink.objects.filter( + paperless_document_id=paperless_id_for_cleanup, + paechter_id=old_verpachtung.paechter.id, + kontext="paechter", + ).delete() + except (LandVerpachtung.DoesNotExist, Exception): + pass + + message = "Verknüpfung aktualisiert" + if paechter_linked: + message += " (automatisch auch mit Pächter verknüpft)" + + return Response({"success": True, "message": message}) + except DokumentLink.DoesNotExist: + return Response({"error": "Verknüpfung nicht gefunden"}, status=404) + except Exception as e: + return Response({"error": f"Unerwarteter Fehler: {e}"}, status=500) + + +@csrf_exempt +@api_view(["DELETE"]) +def link_document_delete(request, link_id): + """Löscht eine bestehende Verknüpfung.""" + from django.db import transaction + + try: + with transaction.atomic(): + link = DokumentLink.objects.get(id=link_id) + verpachtung_id_for_cleanup = link.verpachtung_id + paperless_id_for_cleanup = link.paperless_document_id + + # Log the unlinking action before deletion + from stiftung.audit import log_unlink + + try: + # Determine what entity this was linked to + target_type = "unknown" + target_name = "Unknown" + + if link.destinataer_id: + target_type = "destinataer" + try: + entity = Destinataer.objects.get(id=link.destinataer_id) + target_name = entity.get_full_name() + except Destinataer.DoesNotExist: + target_name = f"Destinatär ID {link.destinataer_id}" + elif link.land_id: + target_type = "land" + try: + entity = Land.objects.get(id=link.land_id) + target_name = str(entity) + except Land.DoesNotExist: + target_name = f"Land ID {link.land_id}" + elif link.paechter_id: + target_type = "paechter" + try: + entity = Paechter.objects.get(id=link.paechter_id) + target_name = f"{entity.vorname} {entity.nachname}".strip() + except Paechter.DoesNotExist: + target_name = f"Pächter ID {link.paechter_id}" + elif link.verpachtung_id: + target_type = "verpachtung" + try: + entity = LandVerpachtung.objects.get(id=link.verpachtung_id) + target_name = str(entity) + except LandVerpachtung.DoesNotExist: + target_name = f"Verpachtung ID {link.verpachtung_id}" + elif link.rentmeister_id: + target_type = "rentmeister" + try: + from stiftung.models import Rentmeister + + entity = Rentmeister.objects.get(id=link.rentmeister_id) + target_name = entity.get_full_name() + except Rentmeister.DoesNotExist: + target_name = f"Rentmeister ID {link.rentmeister_id}" + + log_unlink( + request=request, + entity_type="dokumentlink", + entity_id=str(link.id), + entity_name=link.titel, + target_type=target_type, + target_name=target_name, + ) + except Exception as e: + # Don't fail the main operation if logging fails + print(f"Audit logging failed: {e}") + + link.delete() + + # Cleanup NACH der Haupttransaktion: Wenn eine Verpachtungs-Verknüpfung gelöscht wurde, prüfe Pächter-Links + if verpachtung_id_for_cleanup: + try: + verpachtung = LandVerpachtung.objects.select_related("paechter").get( + id=verpachtung_id_for_cleanup + ) + if verpachtung.paechter: + # Prüfe ob noch andere Verpachtungs-Links für diesen Pächter existieren + other_verpachtung_links = DokumentLink.objects.filter( + paperless_document_id=paperless_id_for_cleanup, + verpachtung__paechter_id=verpachtung.paechter.id, + ).exists() + + if not other_verpachtung_links: + # Entferne automatisch erstellte Pächter-Verknüpfung + DokumentLink.objects.filter( + paperless_document_id=paperless_id_for_cleanup, + paechter_id=verpachtung.paechter.id, + kontext="paechter", + ).delete() + except (LandVerpachtung.DoesNotExist, Exception): + pass + + return Response({"success": True}) + except DokumentLink.DoesNotExist: + return Response({"error": "Verknüpfung nicht gefunden"}, status=404) + except Exception as e: + return Response({"error": f"Unerwarteter Fehler: {e}"}, status=500) + + diff --git a/app/stiftung/views/finanzen.py b/app/stiftung/views/finanzen.py new file mode 100644 index 0000000..6d60671 --- /dev/null +++ b/app/stiftung/views/finanzen.py @@ -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 +# ============================================================================= + + diff --git a/app/stiftung/views/foerderung.py b/app/stiftung/views/foerderung.py new file mode 100644 index 0000000..012e09d --- /dev/null +++ b/app/stiftung/views/foerderung.py @@ -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 diff --git a/app/stiftung/views/geschichte.py b/app/stiftung/views/geschichte.py new file mode 100644 index 0000000..c38bb86 --- /dev/null +++ b/app/stiftung/views/geschichte.py @@ -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 +# ============================================================ + diff --git a/app/stiftung/views/land.py b/app/stiftung/views/land.py new file mode 100644 index 0000000..5b94acc --- /dev/null +++ b/app/stiftung/views/land.py @@ -0,0 +1,1553 @@ +# views/land.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 paechter_list(request): + search_query = request.GET.get("search", "") + ausbildung_filter = request.GET.get("ausbildung", "") + aktiv_filter = request.GET.get("aktiv", "") + sort = request.GET.get("sort", "") + direction = request.GET.get("dir", "asc") + + paechter = Paechter.objects.all() + + if search_query: + paechter = paechter.filter( + Q(nachname__icontains=search_query) + | Q(vorname__icontains=search_query) + | Q(email__icontains=search_query) + | Q(pachtnummer__icontains=search_query) + ) + + if ausbildung_filter == "true": + paechter = paechter.filter(landwirtschaftliche_ausbildung=True) + elif ausbildung_filter == "false": + paechter = paechter.filter(landwirtschaftliche_ausbildung=False) + + if aktiv_filter == "true": + paechter = paechter.filter(aktiv=True) + elif aktiv_filter == "false": + paechter = paechter.filter(aktiv=False) + + # Annotate with total leased area and rent (coalesce nulls to Decimal for stable sorting) + paechter = paechter.annotate( + gesamt_flaeche=Coalesce( + Sum("neue_verpachtungen__verpachtete_flaeche"), + Value( + Decimal("0.00"), + output_field=DecimalField(max_digits=12, decimal_places=2), + ), + output_field=DecimalField(max_digits=12, decimal_places=2), + ), + gesamt_pachtzins=Coalesce( + Sum("neue_verpachtungen__pachtzins_pauschal"), + 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 = { + "name": ["nachname", "vorname"], + "pachtnummer": ["pachtnummer"], + "ausbildung": ["landwirtschaftliche_ausbildung"], + "spezialisierung": ["spezialisierung"], + "flaeche": ["gesamt_flaeche"], + "pachtzins": ["gesamt_pachtzins"], + "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 + paechter = paechter.order_by(*order_fields) + + paginator = Paginator(paechter, 20) + page_number = request.GET.get("page") + page_obj = paginator.get_page(page_number) + + context = { + "page_obj": page_obj, + "search_query": search_query, + "ausbildung_filter": ausbildung_filter, + "aktiv_filter": aktiv_filter, + "sort": sort, + "dir": direction, + } + return render(request, "stiftung/paechter_list.html", context) + + +@login_required +def paechter_detail(request, pk): + paechter = get_object_or_404(Paechter, pk=pk) + + # Alle mit diesem Pächter verknüpften Dokumente laden + verknuepfte_dokumente = DokumentLink.objects.filter( + paechter_id=paechter.pk + ).order_by("kontext", "titel") + + # Neue LandVerpachtungen für diesen Pächter laden + verpachtungen = LandVerpachtung.objects.filter(paechter=paechter).order_by( + "-pachtbeginn" + ) + + # Neue gepachtete Ländereien (über aktueller_paechter) + gepachtete_laendereien = paechter.gepachtete_laendereien.filter( + aktiv=True + ).order_by("gemeinde", "gemarkung") + + # Statistiken berechnen + total_flaeche_neu = sum( + land.verp_flaeche_aktuell or 0 for land in gepachtete_laendereien + ) + total_pachtzins_neu = sum( + land.pachtzins_pauschal or 0 for land in gepachtete_laendereien + ) + + context = { + "paechter": paechter, + "verknuepfte_dokumente": verknuepfte_dokumente, + "verpachtungen": verpachtungen, # Now using LandVerpachtung + "gepachtete_laendereien": gepachtete_laendereien, # Neu + "total_flaeche_neu": total_flaeche_neu, + "total_pachtzins_neu": total_pachtzins_neu, + } + return render(request, "stiftung/paechter_detail.html", context) + + +@login_required +def paechter_create(request): + if request.method == "POST": + form = PaechterForm(request.POST) + if form.is_valid(): + paechter = form.save() + messages.success( + request, + f'Pächter "{paechter.get_full_name()}" wurde erfolgreich erstellt.', + ) + return redirect("stiftung:paechter_detail", pk=paechter.pk) + else: + # Debug: Log form errors and show them to user + print(f"Form errors: {form.errors}") + print(f"Form data: {request.POST}") + messages.error(request, f"Formular-Fehler: {form.errors}") + for field, errors in form.errors.items(): + for error in errors: + messages.error(request, f"{field}: {error}") + else: + form = PaechterForm() + + context = {"form": form, "title": "Neuen Pächter erstellen"} + return render(request, "stiftung/paechter_form.html", context) + + +@login_required +def paechter_update(request, pk): + paechter = get_object_or_404(Paechter, pk=pk) + if request.method == "POST": + form = PaechterForm(request.POST, instance=paechter) + if form.is_valid(): + paechter = form.save() + messages.success( + request, + f'Pächter "{paechter.get_full_name()}" wurde erfolgreich aktualisiert.', + ) + return redirect("stiftung:paechter_detail", pk=paechter.pk) + else: + # Debug: Log form errors and show them to user + print(f"Form errors: {form.errors}") + print(f"Form data: {request.POST}") + messages.error(request, f"Formular-Fehler: {form.errors}") + for field, errors in form.errors.items(): + for error in errors: + messages.error(request, f"{field}: {error}") + else: + form = PaechterForm(instance=paechter) + + context = { + "form": form, + "paechter": paechter, + "title": f"Pächter bearbeiten: {paechter.get_full_name()}", + } + return render(request, "stiftung/paechter_form.html", context) + + +@login_required +def paechter_delete(request, pk): + paechter = get_object_or_404(Paechter, pk=pk) + if request.method == "POST": + paechter.delete() + messages.success( + request, f'Pächter "{paechter.get_full_name()}" wurde erfolgreich gelöscht.' + ) + return redirect("stiftung:paechter_list") + + context = {"paechter": paechter} + return render(request, "stiftung/paechter_confirm_delete.html", context) + + +# Land Views +@login_required +def land_list(request): + search_query = request.GET.get("search", "") + gemeinde_filter = request.GET.get("gemeinde", "") + aktiv_filter = request.GET.get("aktiv", "") + sort = request.GET.get("sort", "") + direction = request.GET.get("dir", "asc") + + lands = Land.objects.all() + + if search_query: + lands = lands.filter( + Q(lfd_nr__icontains=search_query) + | Q(gemeinde__icontains=search_query) + | Q(gemarkung__icontains=search_query) + | Q(flur__icontains=search_query) + | Q(flurstueck__icontains=search_query) + ) + + if gemeinde_filter: + lands = lands.filter(gemeinde=gemeinde_filter) + + if aktiv_filter == "true": + lands = lands.filter(aktiv=True) + elif aktiv_filter == "false": + lands = lands.filter(aktiv=False) + + # Annotate with verpachtungsgrad and numeric casts for natural sorting + # Prepare numeric versions of textual fields by stripping common non-digits + def digits_only(field_expr): + expr = Replace(field_expr, Value(" "), Value("")) + expr = Replace(expr, Value("-"), Value("")) + expr = Replace(expr, Value("."), Value("")) + expr = Replace(expr, Value("/"), Value("")) + expr = Replace(expr, Value("L"), Value("")) + return expr + + lands = lands.extra( + select={ + "verpachtungsgrad": "CASE WHEN groesse_qm > 0 THEN (verp_flaeche_aktuell / groesse_qm) * 100 ELSE 0 END" + } + ).annotate( + lfd_nr_num=Cast(NullIf(digits_only(F("lfd_nr")), Value("")), IntegerField()), + flur_num=Cast(NullIf(digits_only(F("flur")), Value("")), IntegerField()), + flurstueck_num=Cast( + NullIf(digits_only(F("flurstueck")), Value("")), IntegerField() + ), + ) + + # Sorting + sort_map = { + "lfd_nr": ["lfd_nr_num", "lfd_nr"], + "gemeinde": ["gemeinde"], + "gemarkung": ["gemarkung"], + "flur": ["flur_num", "flur"], + "flurstueck": ["flurstueck_num", "flurstueck"], + "groesse": ["groesse_qm"], + "verp": ["verp_flaeche_aktuell"], + "grad": ["verpachtungsgrad"], + } + if sort in sort_map: + fields = sort_map[sort] + if direction == "desc": + order_fields = [f"-{f}" for f in fields] + else: + order_fields = fields + lands = lands.order_by(*order_fields) + + # Aggregated statistics for current filter set + aggregates = lands.aggregate( + sum_groesse_qm=Sum("groesse_qm"), + sum_gruenland_qm=Sum("gruenland_qm"), + sum_acker_qm=Sum("acker_qm"), + sum_wald_qm=Sum("wald_qm"), + sum_sonstiges_qm=Sum("sonstiges_qm"), + ) + sum_groesse_qm = float(aggregates.get("sum_groesse_qm") or 0) + sum_gruenland_qm = float(aggregates.get("sum_gruenland_qm") or 0) + sum_acker_qm = float(aggregates.get("sum_acker_qm") or 0) + sum_wald_qm = float(aggregates.get("sum_wald_qm") or 0) + sum_sonstiges_qm = float(aggregates.get("sum_sonstiges_qm") or 0) + sum_total_use_qm = sum_gruenland_qm + sum_acker_qm + sum_wald_qm + sum_sonstiges_qm + + # Calculate verpachtung statistics + total_plots = lands.count() + verpachtete_plots = lands.filter(verp_flaeche_aktuell__gt=0).count() + unveerpachtete_plots = total_plots - verpachtete_plots + + def pct(part, total): + return round((part / total) * 100, 1) if total and part is not None else 0.0 + + stats = { + "sum_groesse_qm": sum_groesse_qm, + "sum_gruenland_qm": sum_gruenland_qm, + "sum_acker_qm": sum_acker_qm, + "sum_wald_qm": sum_wald_qm, + "sum_sonstiges_qm": sum_sonstiges_qm, + "sum_total_use_qm": sum_total_use_qm, + "pct_gruenland": pct(sum_gruenland_qm, sum_total_use_qm), + "pct_acker": pct(sum_acker_qm, sum_total_use_qm), + "pct_wald": pct(sum_wald_qm, sum_total_use_qm), + "total_plots": total_plots, + "verpachtete_plots": verpachtete_plots, + "unveerpachtete_plots": unveerpachtete_plots, + "pct_verpachtet": pct(verpachtete_plots, total_plots), + "pct_unveerpachtet": pct(unveerpachtete_plots, total_plots), + } + + # Prepare size chart data (top 30 by size) + top_sizes = list( + lands.order_by("-groesse_qm").values_list("lfd_nr", "groesse_qm")[:30] + ) + size_chart_labels = [label or "" for label, _ in top_sizes] + size_chart_values = [float(val or 0) for _, val in top_sizes] + + paginator = Paginator(lands, 20) + page_number = request.GET.get("page") + page_obj = paginator.get_page(page_number) + + # Get unique gemeinden for filter + gemeinden = ( + Land.objects.values_list("gemeinde", flat=True).distinct().order_by("gemeinde") + ) + + context = { + "page_obj": page_obj, + "search_query": search_query, + "gemeinde_filter": gemeinde_filter, + "aktiv_filter": aktiv_filter, + "gemeinden": gemeinden, + "stats": stats, + "size_chart_labels_json": json.dumps(size_chart_labels), + "size_chart_values_json": json.dumps(size_chart_values), + "sort": sort, + "dir": direction, + } + return render(request, "stiftung/land_list.html", context) + + +@login_required +def land_detail(request, pk): + land = get_object_or_404(Land, pk=pk) + + # Alle mit dieser Länderei verknüpften Dokumente laden + verknuepfte_dokumente = DokumentLink.objects.filter(land_id=land.pk).order_by( + "kontext", "titel" + ) + + # Neue LandVerpachtungen laden (mit related data) + neue_verpachtungen = land.neue_verpachtungen.select_related("paechter").order_by( + "-pachtbeginn" + ) + + context = { + "land": land, + "verknuepfte_dokumente": verknuepfte_dokumente, + "verpachtungen": neue_verpachtungen, # Using only new system now + "neue_verpachtungen": neue_verpachtungen, + } + return render(request, "stiftung/land_detail.html", context) + + +@login_required +def land_create(request): + if request.method == "POST": + form = LandForm(request.POST) + + # Debug: Print form data + print("=== LAND CREATE DEBUG ===") + print(f"POST data: {dict(request.POST)}") + print(f"Form is valid: {form.is_valid()}") + + if not form.is_valid(): + print(f"Form errors: {form.errors}") + print(f"Form non-field errors: {form.non_field_errors()}") + # Add error messages for debugging + for field, errors in form.errors.items(): + for error in errors: + messages.error(request, f"{field}: {error}") + + if form.is_valid(): + try: + land = form.save() + messages.success(request, f'Länderei "{land}" wurde erfolgreich erstellt.') + print(f"Successfully created land: {land}") + return redirect("stiftung:land_detail", pk=land.pk) + except Exception as e: + print(f"Error saving land: {e}") + messages.error(request, f"Fehler beim Speichern: {str(e)}") + else: + messages.error(request, "Bitte korrigieren Sie die Fehler im Formular.") + else: + form = LandForm() + + context = {"form": form, "title": "Neue Länderei erstellen"} + return render(request, "stiftung/land_form.html", context) + + +@login_required +def land_update(request, pk): + land = get_object_or_404(Land, pk=pk) + if request.method == "POST": + form = LandForm(request.POST, instance=land) + if form.is_valid(): + land = form.save() + messages.success( + request, f'Länderei "{land}" wurde erfolgreich aktualisiert.' + ) + return redirect("stiftung:land_detail", pk=land.pk) + else: + form = LandForm(instance=land) + + context = {"form": form, "land": land, "title": f"Länderei bearbeiten: {land}"} + return render(request, "stiftung/land_form.html", context) + + +@login_required +def land_delete(request, pk): + land = get_object_or_404(Land, pk=pk) + if request.method == "POST": + land.delete() + messages.success(request, f'Länderei "{land}" wurde erfolgreich gelöscht.') + return redirect("stiftung:land_list") + + context = {"land": land} + return render(request, "stiftung/land_confirm_delete.html", context) + + +# Verpachtung Views +@login_required +def verpachtung_list(request): + search_query = request.GET.get("search", "") + status_filter = request.GET.get("status", "") + gemeinde_filter = request.GET.get("gemeinde", "") + sort = request.GET.get("sort", "") + direction = request.GET.get("dir", "asc") + + verpachtungen = LandVerpachtung.objects.select_related("land", "paechter").all() + + if search_query: + verpachtungen = verpachtungen.filter( + Q(vertragsnummer__icontains=search_query) + | Q(land__gemeinde__icontains=search_query) + | Q(paechter__nachname__icontains=search_query) + | Q(paechter__vorname__icontains=search_query) + ) + + if status_filter: + verpachtungen = verpachtungen.filter(status=status_filter) + + if gemeinde_filter: + verpachtungen = verpachtungen.filter(land__gemeinde=gemeinde_filter) + + # Sorting + sort_map = { + "vertragsnummer": ["vertragsnummer"], + "land": ["land__gemeinde"], + "paechter": ["paechter__nachname", "paechter__vorname"], + "beginn": ["pachtbeginn"], + "ende": ["pachtende"], + "flaeche": ["verpachtete_flaeche"], + "pachtzins": ["pachtzins_pauschal"], + "status": ["status"], + } + if sort in sort_map: + fields = sort_map[sort] + if direction == "desc": + order_fields = [f"-{f}" for f in fields] + else: + order_fields = fields + verpachtungen = verpachtungen.order_by(*order_fields) + + paginator = Paginator(verpachtungen, 20) + page_number = request.GET.get("page") + page_obj = paginator.get_page(page_number) + + # Calculate statistics for the summary cards + # Get ALL verpachtungen (not filtered) for accurate statistics + all_verpachtungen = LandVerpachtung.objects.select_related("land", "paechter").all() + + # Active verpachtungen count + aktive_verpachtungen = all_verpachtungen.filter(status="aktiv").count() + + # Total leased area (only active verpachtungen) + gesamt_flaeche_result = all_verpachtungen.filter(status="aktiv").aggregate( + total=Sum("verpachtete_flaeche") + ) + gesamt_flaeche = ( + gesamt_flaeche_result["total"] + if gesamt_flaeche_result["total"] is not None + else 0 + ) + + # Total annual rent (only active verpachtungen) + jaehrlicher_pachtzins_result = all_verpachtungen.filter(status="aktiv").aggregate( + total=Sum("pachtzins_pauschal") + ) + jaehrlicher_pachtzins = ( + jaehrlicher_pachtzins_result["total"] + if jaehrlicher_pachtzins_result["total"] is not None + else 0 + ) + + # Total count of all verpachtungen + anzahl_verpachtungen = all_verpachtungen.count() + + # Get unique gemeinden and statuses for filters + gemeinden = ( + Land.objects.values_list("gemeinde", flat=True).distinct().order_by("gemeinde") + ) + status_choices = LandVerpachtung.STATUS_CHOICES + + context = { + "page_obj": page_obj, + "search_query": search_query, + "status_filter": status_filter, + "gemeinde_filter": gemeinde_filter, + "gemeinden": gemeinden, + "status_choices": status_choices, + # Statistics for summary cards + "aktive_verpachtungen": aktive_verpachtungen, + "gesamt_flaeche": gesamt_flaeche, + "jaehrlicher_pachtzins": jaehrlicher_pachtzins, + "anzahl_verpachtungen": anzahl_verpachtungen, + "sort": sort, + "dir": direction, + } + return render(request, "stiftung/verpachtung_list.html", context) + + +@login_required +def land_verpachtung_detail(request, pk): + """Detail view for LandVerpachtung""" + verpachtung = get_object_or_404(LandVerpachtung, pk=pk) + + # Alle mit dieser Verpachtung verknüpften Dokumente laden + verknuepfte_dokumente = DokumentLink.objects.filter( + land_verpachtung_id=verpachtung.pk + ).order_by("kontext", "titel") + + context = { + "verpachtung": verpachtung, + "landverpachtung": verpachtung, # Template expects this variable name + "verknuepfte_dokumente": verknuepfte_dokumente, + } + return render(request, "stiftung/land_verpachtung_detail.html", context) + + +@login_required +def land_verpachtung_update(request, pk): + """Update an existing LandVerpachtung by its primary key""" + verpachtung = get_object_or_404(LandVerpachtung, pk=pk) + + if request.method == "POST": + # Handle the update form submission + vertragsnummer = request.POST.get("vertragsnummer") + pachtbeginn = request.POST.get("pachtbeginn") + pachtende = request.POST.get("pachtende") + pachtzins_pauschal = request.POST.get("pachtzins_pauschal") + + if vertragsnummer: + verpachtung.vertragsnummer = vertragsnummer + if pachtbeginn: + verpachtung.pachtbeginn = pachtbeginn + if pachtende: + verpachtung.pachtende = pachtende + if pachtzins_pauschal: + verpachtung.pachtzins_pauschal = pachtzins_pauschal + + verpachtung.save() + messages.success(request, "Verpachtung wurde erfolgreich aktualisiert.") + return redirect("stiftung:land_verpachtung_detail", pk=verpachtung.pk) + + context = { + "verpachtung": verpachtung, + "landverpachtung": verpachtung, # Template expects this variable name + "is_edit": True, + "is_update": True, # Form template uses this flag + } + return render(request, "stiftung/land_verpachtung_form.html", context) + + +@login_required +def land_verpachtung_end_direct(request, pk): + """End a LandVerpachtung directly by its primary key""" + verpachtung = get_object_or_404(LandVerpachtung, pk=pk) + + if request.method == "POST": + verpachtung.status = "beendet" + verpachtung.pachtende = timezone.now().date() + verpachtung.save() + messages.success(request, "Verpachtung wurde erfolgreich beendet.") + return redirect("stiftung:land_detail", pk=verpachtung.land.pk) + + context = { + "verpachtung": verpachtung, + } + return render(request, "stiftung/land_verpachtung_end_confirm.html", context) + + +# Förderung Views +@login_required +def land_stats_api(request): + """API endpoint for land statistics""" + if request.method == "GET": + gemeinde = request.GET.get("gemeinde", "") + + if gemeinde: + lands = Land.objects.filter(gemeinde=gemeinde) + else: + lands = Land.objects.all() + + stats = { + "total_count": lands.count(), + "total_flaeche": float( + lands.aggregate(total=Sum("groesse_qm"))["total"] or 0 + ), + "total_verpachtet": float( + LandVerpachtung.objects.filter( + status="aktiv", land__in=lands + ).aggregate(total=Sum("verpachtete_flaeche"))["total"] + or 0 + ), + "avg_verpachtungsgrad": 0, + } + + if stats["total_flaeche"] > 0: + stats["avg_verpachtungsgrad"] = ( + stats["total_verpachtet"] / stats["total_flaeche"] + ) * 100 + + return JsonResponse(stats) + + return JsonResponse({"error": "Invalid request method"}, status=400) + + +@login_required +def paechter_export(request, pk): + """Export complete Pächter data as ZIP with documents""" + import json + import os + import tempfile + import zipfile + + from django.http import HttpResponse + + paechter = get_object_or_404(Paechter, 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(paechter.id), + "vorname": paechter.vorname, + "nachname": paechter.nachname, + "geburtsdatum": ( + paechter.geburtsdatum.isoformat() if paechter.geburtsdatum else None + ), + "email": paechter.email, + "telefon": paechter.telefon, + "iban": paechter.iban, + "strasse": paechter.strasse, + "plz": paechter.plz, + "ort": paechter.ort, + "personentyp": paechter.get_personentyp_display(), + "pachtnummer": paechter.pachtnummer, + "pachtbeginn_erste": ( + paechter.pachtbeginn_erste.isoformat() + if paechter.pachtbeginn_erste + else None + ), + "pachtende_letzte": ( + paechter.pachtende_letzte.isoformat() + if paechter.pachtende_letzte + else None + ), + "pachtzins_aktuell": ( + str(paechter.pachtzins_aktuell) + if paechter.pachtzins_aktuell + else None + ), + "landwirtschaftliche_ausbildung": paechter.landwirtschaftliche_ausbildung, + "berufserfahrung_jahre": paechter.berufserfahrung_jahre, + "spezialisierung": paechter.spezialisierung, + "notizen": paechter.notizen, + "aktiv": paechter.aktiv, + "gesamt_pachtflaeche": float(paechter.get_gesamt_pachtflaeche()), + "gesamt_pachtzins": float(paechter.get_gesamt_pachtzins()), + "export_datum": timezone.now().isoformat(), + "export_user": request.user.username, + } + zipf.writestr( + "paechter_data.json", + json.dumps(entity_data, indent=2, ensure_ascii=False), + ) + + # 2. Linked documents from Paperless + dokumente = DokumentLink.objects.filter(paechter_id=paechter.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: + 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" + + 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), + ) + + # Prepare response + with open(temp_file.name, "rb") as f: + response = HttpResponse(f.read(), content_type="application/zip") + filename = f"paechter_{paechter.nachname}_{paechter.vorname}_{timezone.now().strftime('%Y%m%d_%H%M%S')}.zip" + response["Content-Disposition"] = f'attachment; filename="{filename}"' + return response + + finally: + try: + os.unlink(temp_file.name) + except: + pass + + +@login_required +def land_export(request, pk): + """Export complete Land data as ZIP with documents""" + import json + import os + import tempfile + import zipfile + + from django.http import HttpResponse + + land = get_object_or_404(Land, 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(land.id), + "lfd_nr": land.lfd_nr, + "ew_nummer": land.ew_nummer, + "amtsgericht": land.amtsgericht, + "gemeinde": land.gemeinde, + "gemarkung": land.gemarkung, + "flur": land.flur, + "flurstueck": land.flurstueck, + "groesse_qm": str(land.groesse_qm), + "gruenland_qm": str(land.gruenland_qm), + "acker_qm": str(land.acker_qm), + "wald_qm": str(land.wald_qm), + "sonstiges_qm": str(land.sonstiges_qm), + "verpachtete_gesamtflaeche": str(land.verpachtete_gesamtflaeche), + "flaeche_alte_liste": ( + str(land.flaeche_alte_liste) if land.flaeche_alte_liste else None + ), + "verp_flaeche_aktuell": str(land.verp_flaeche_aktuell), + "anteil_grundsteuer": ( + str(land.anteil_grundsteuer) if land.anteil_grundsteuer else None + ), + "anteil_lwk": str(land.anteil_lwk) if land.anteil_lwk else None, + "aktiv": land.aktiv, + "notizen": land.notizen, + "erstellt_am": land.erstellt_am.isoformat(), + "aktualisiert_am": land.aktualisiert_am.isoformat(), + "gesamtflaeche_berechnet": float(land.get_gesamtflaeche()), + "verpachtungsgrad": float(land.get_verpachtungsgrad()), + "export_datum": timezone.now().isoformat(), + "export_user": request.user.username, + } + zipf.writestr( + "land_data.json", json.dumps(entity_data, indent=2, ensure_ascii=False) + ) + + # 2. Linked documents from Paperless + dokumente = DokumentLink.objects.filter(land_id=land.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: + 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" + + 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), + ) + + # Prepare response + with open(temp_file.name, "rb") as f: + response = HttpResponse(f.read(), content_type="application/zip") + filename = f"land_{land.gemeinde}_{land.gemarkung}_flur{land.flur}_{timezone.now().strftime('%Y%m%d_%H%M%S')}.zip" + response["Content-Disposition"] = f'attachment; filename="{filename}"' + return response + + finally: + try: + os.unlink(temp_file.name) + except: + pass + + +@login_required +def verpachtung_export(request, pk): + """Export complete Verpachtung data as ZIP with documents""" + import json + import os + import tempfile + import zipfile + + from django.http import HttpResponse + + verpachtung = get_object_or_404(LandVerpachtung, 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(verpachtung.id), + "vertragsnummer": verpachtung.vertragsnummer, + "land": str(verpachtung.land), + "land_id": str(verpachtung.land.id), + "paechter": str(verpachtung.paechter), + "paechter_id": str(verpachtung.paechter.id), + "pachtbeginn": verpachtung.pachtbeginn.isoformat(), + "pachtende": verpachtung.pachtende.isoformat(), + "verlaengerung": ( + verpachtung.verlaengerung.isoformat() + if verpachtung.verlaengerung + else None + ), + "pachtzins_pro_qm": str(verpachtung.pachtzins_pro_qm), + "pachtzins_jaehrlich": str(verpachtung.pachtzins_pauschal), + "verpachtete_flaeche": str(verpachtung.verpachtete_flaeche), + "status": verpachtung.get_status_display(), + "verwendungsnachweis": ( + str(verpachtung.verwendungsnachweis) + if verpachtung.verwendungsnachweis + else None + ), + "bemerkungen": verpachtung.bemerkungen, + "erstellt_am": verpachtung.erstellt_am.isoformat(), + "aktualisiert_am": verpachtung.aktualisiert_am.isoformat(), + "vertragsdauer_tage": verpachtung.get_vertragsdauer_tage(), + "restlaufzeit_tage": verpachtung.get_restlaufzeit_tage(), + "ist_aktiv": verpachtung.is_aktiv(), + "export_datum": timezone.now().isoformat(), + "export_user": request.user.username, + } + zipf.writestr( + "verpachtung_data.json", + json.dumps(entity_data, indent=2, ensure_ascii=False), + ) + + # 2. Linked documents from Paperless + dokumente = DokumentLink.objects.filter(verpachtung_id=verpachtung.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: + 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" + + 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), + ) + + # Prepare response + with open(temp_file.name, "rb") as f: + response = HttpResponse(f.read(), content_type="application/zip") + filename = f"verpachtung_{verpachtung.vertragsnummer}_{timezone.now().strftime('%Y%m%d_%H%M%S')}.zip" + response["Content-Disposition"] = f'attachment; filename="{filename}"' + return response + + finally: + try: + os.unlink(temp_file.name) + except: + pass + + +@login_required +def land_abrechnung_list(request): + """Liste aller Landabrechnungen""" + abrechnungen = LandAbrechnung.objects.select_related("land").all() + + # Filter + jahr_filter = request.GET.get("jahr") + land_filter = request.GET.get("land") + + if jahr_filter: + abrechnungen = abrechnungen.filter(abrechnungsjahr=jahr_filter) + if land_filter: + abrechnungen = abrechnungen.filter(land__pk=land_filter) + + # Pagination + paginator = Paginator(abrechnungen, 20) + page_number = request.GET.get("page") + abrechnungen = paginator.get_page(page_number) + + # Statistiken + stats = LandAbrechnung.objects.aggregate( + total_einnahmen=Sum("pacht_vereinnahmt"), + total_ausgaben=Sum("grundsteuer_betrag"), + anzahl_abrechnungen=Count("id"), + ) + + context = { + "abrechnungen": abrechnungen, + "stats": stats, + "jahre": LandAbrechnung.objects.values_list("abrechnungsjahr", flat=True) + .distinct() + .order_by("-abrechnungsjahr"), + "laendereien": Land.objects.filter(aktiv=True).order_by( + "gemeinde", "gemarkung" + ), + "jahr_filter": jahr_filter, + "land_filter": land_filter, + } + + return render(request, "stiftung/land_abrechnung_list.html", context) + + +@login_required +def land_abrechnung_detail(request, pk): + """Detail-Ansicht einer Landabrechnung""" + abrechnung = get_object_or_404(LandAbrechnung, pk=pk) + + context = { + "abrechnung": abrechnung, + "land": abrechnung.land, + } + + return render(request, "stiftung/land_abrechnung_detail.html", context) + + +@login_required +def land_abrechnung_create(request): + """Neue Landabrechnung erstellen""" + from stiftung.forms import LandAbrechnungForm + + land_pk = request.GET.get("land") + initial = {} + land = None + + if land_pk: + land = get_object_or_404(Land, pk=land_pk) + initial["land"] = land + initial["abrechnungsjahr"] = datetime.now().year + + # Automatische Vorausfüllung aus Verpachtungsdaten + if land.pachtzins_pauschal: + initial["pacht_vereinnahmt"] = land.pachtzins_pauschal + + if request.method == "POST": + form = LandAbrechnungForm(request.POST, request.FILES) + if form.is_valid(): + abrechnung = form.save() + messages.success( + request, + f"Landabrechnung für {abrechnung.land} ({abrechnung.abrechnungsjahr}) wurde erfolgreich erstellt.", + ) + return redirect("stiftung:land_abrechnung_detail", pk=abrechnung.pk) + else: + form = LandAbrechnungForm(initial=initial) + + context = { + "form": form, + "title": "Neue Landabrechnung", + "land": land, + } + + return render(request, "stiftung/land_abrechnung_form.html", context) + + +@login_required +def land_abrechnung_update(request, pk): + """Landabrechnung bearbeiten""" + from stiftung.forms import LandAbrechnungForm + + abrechnung = get_object_or_404(LandAbrechnung, pk=pk) + + if request.method == "POST": + form = LandAbrechnungForm(request.POST, request.FILES, instance=abrechnung) + if form.is_valid(): + abrechnung = form.save() + messages.success( + request, + f"Landabrechnung für {abrechnung.land} ({abrechnung.abrechnungsjahr}) wurde erfolgreich aktualisiert.", + ) + return redirect("stiftung:land_abrechnung_detail", pk=abrechnung.pk) + else: + form = LandAbrechnungForm(instance=abrechnung) + + context = { + "form": form, + "abrechnung": abrechnung, + "title": f"Abrechnung bearbeiten - {abrechnung.land} ({abrechnung.abrechnungsjahr})", + } + + return render(request, "stiftung/land_abrechnung_form.html", context) + + +@login_required +def land_abrechnung_delete(request, pk): + """Landabrechnung löschen""" + abrechnung = get_object_or_404(LandAbrechnung, pk=pk) + land = abrechnung.land + + if request.method == "POST": + abrechnung.delete() + messages.success( + request, + f"Landabrechnung für {land} ({abrechnung.abrechnungsjahr}) wurde erfolgreich gelöscht.", + ) + return redirect("stiftung:land_detail", pk=land.pk) + + context = { + "abrechnung": abrechnung, + "land": land, + } + + return render(request, "stiftung/land_abrechnung_confirm_delete.html", context) + + +# ============================================================================ +# VEREINHEITLICHTE VERPACHTUNGS VIEWS +# ============================================================================ + + +@login_required +def land_verpachtung_create(request, land_pk): + """Erstelle eine neue Verpachtung direkt im Land-Model""" + from datetime import datetime as dt + + land = get_object_or_404(Land, pk=land_pk) + + if request.method == "POST": + # Einfaches Formular für die wichtigsten Verpachtungsfelder + aktueller_paechter_id = request.POST.get("aktueller_paechter") + pachtbeginn = request.POST.get("pachtbeginn") + pachtende = request.POST.get("pachtende") + pachtzins_pauschal = request.POST.get("pachtzins_pauschal") + zahlungsweise = request.POST.get("zahlungsweise") + ust_option = request.POST.get("ust_option") == "on" + + if aktueller_paechter_id and pachtbeginn: + paechter = get_object_or_404(Paechter, pk=aktueller_paechter_id) + verpachtete_flaeche = request.POST.get("verpachtete_flaeche") + + # Validiere verpachtete Fläche + if not verpachtete_flaeche: + verpachtete_flaeche = land.groesse_qm # Standard: gesamte Fläche + else: + verpachtete_flaeche = float(verpachtete_flaeche) + if verpachtete_flaeche > land.groesse_qm: + messages.error( + request, + f"Die verpachtete Fläche ({verpachtete_flaeche} qm) kann nicht größer als die Gesamtfläche ({land.groesse_qm} qm) sein.", + ) + # Erstelle context für Fehlerfall + paechter_list = Paechter.objects.filter(aktiv=True).order_by( + "nachname", "vorname" + ) + verfuegbare_flaeche = land.groesse_qm + if land.verp_flaeche_aktuell and land.verp_flaeche_aktuell > 0: + verfuegbare_flaeche = ( + land.groesse_qm - land.verp_flaeche_aktuell + ) + + context = { + "land": land, + "paechter_list": paechter_list, + "current_year": dt.now().year, + "is_edit": False, + "verfuegbare_flaeche": verfuegbare_flaeche, + } + return render( + request, "stiftung/land_verpachtung_form.html", context + ) + + # Land aktualisieren + land.aktueller_paechter = paechter + land.paechter_name = paechter.get_full_name() + land.paechter_anschrift = f"{paechter.strasse or ''}\n{paechter.plz or ''} {paechter.ort or ''}".strip() + land.pachtbeginn = pachtbeginn + land.pachtende = pachtende if pachtende else None + land.pachtzins_pauschal = pachtzins_pauschal if pachtzins_pauschal else None + land.zahlungsweise = zahlungsweise + land.ust_option = ust_option + land.verp_flaeche_aktuell = verpachtete_flaeche + land.verpachtete_gesamtflaeche = verpachtete_flaeche + land.save() + + # Erstelle LandVerpachtung-Objekt für bessere Nachverfolgung + land_verpachtung = LandVerpachtung.objects.create( + land=land, + paechter=paechter, + vertragsnummer=f"V-{land.lfd_nr}-{dt.now().year}", + pachtbeginn=pachtbeginn, + pachtende=pachtende if pachtende else None, + verpachtete_flaeche=verpachtete_flaeche, + pachtzins_pauschal=pachtzins_pauschal if pachtzins_pauschal else 0, + zahlungsweise=zahlungsweise, + ust_option=ust_option, + status="aktiv", + ) + + # Erstelle automatisch eine Abrechnung für das aktuelle Jahr + current_year = dt.now().year + + # Berechne erwartete jährliche Pacht basierend auf Zahlungsweise + expected_annual_rent = pachtzins_pauschal if pachtzins_pauschal else 0 + + abrechnung, created = LandAbrechnung.objects.get_or_create( + land=land, + abrechnungsjahr=current_year, + defaults={ + "pacht_vereinnahmt": expected_annual_rent, # Setze erwartete Jahrespacht + "umlagen_vereinnahmt": 0, + "grundsteuer_betrag": 0, + "versicherungen_betrag": 0, + }, + ) + + # Falls Abrechnung bereits existiert, aktualisiere die Pacht wenn höher + if not created and expected_annual_rent > abrechnung.pacht_vereinnahmt: + abrechnung.pacht_vereinnahmt = expected_annual_rent + abrechnung.save() + + success_msg = f"Verpachtung von {land} an {paechter.get_full_name()} wurde erfolgreich erstellt." + if created: + success_msg += ( + f" Abrechnung für {current_year} wurde automatisch angelegt" + ) + if expected_annual_rent > 0: + success_msg += f" (Erwartete Jahrespacht: {expected_annual_rent}€)" + success_msg += "." + elif expected_annual_rent > 0: + success_msg += f" Erwartete Jahrespacht in Abrechnung {current_year} wurde aktualisiert ({expected_annual_rent}€)." + + messages.success(request, success_msg) + return redirect("stiftung:land_detail", pk=land.pk) + else: + messages.error(request, "Bitte füllen Sie alle Pflichtfelder aus.") + + # Verfügbare Pächter + paechter_list = Paechter.objects.filter(aktiv=True).order_by("nachname", "vorname") + + # Berechne verfügbare Fläche (Gesamtfläche minus bereits verpachtete Fläche) + verfuegbare_flaeche = land.groesse_qm + if land.verp_flaeche_aktuell and land.verp_flaeche_aktuell > 0: + verfuegbare_flaeche = land.groesse_qm - land.verp_flaeche_aktuell + + context = { + "land": land, + "paechter_list": paechter_list, + "current_year": dt.now().year, + "is_edit": False, + "verfuegbare_flaeche": verfuegbare_flaeche, + } + + return render(request, "stiftung/land_verpachtung_form.html", context) + + +@login_required +def land_verpachtung_end(request, land_pk): + """Beende die aktuelle Verpachtung eines Landes""" + land = get_object_or_404(Land, pk=land_pk) + + if request.method == "POST": + # Verpachtung beenden + land.aktueller_paechter = None + land.paechter_name = None + land.paechter_anschrift = None + land.pachtende = datetime.now().date() + land.save() + + messages.success(request, f"Verpachtung von {land} wurde beendet.") + return redirect("stiftung:land_detail", pk=land.pk) + + context = { + "land": land, + } + + return render(request, "stiftung/land_verpachtung_end.html", context) + + +@login_required +def land_verpachtung_edit(request, land_pk): + """Bearbeite eine bestehende Verpachtung direkt im Land-Model""" + land = get_object_or_404(Land, pk=land_pk) + + if request.method == "POST": + # Einfaches Formular für die wichtigsten Verpachtungsfelder + aktueller_paechter_id = request.POST.get("aktueller_paechter") + pachtbeginn = request.POST.get("pachtbeginn") + pachtende = request.POST.get("pachtende") + pachtzins_pauschal = request.POST.get("pachtzins_pauschal") + zahlungsweise = request.POST.get("zahlungsweise") + ust_option = request.POST.get("ust_option") == "on" + verpachtete_flaeche = request.POST.get("verpachtete_flaeche") + + if aktueller_paechter_id and pachtbeginn: + paechter = get_object_or_404(Paechter, pk=aktueller_paechter_id) + + # Land aktualisieren + land.aktueller_paechter = paechter + land.paechter_name = paechter.get_full_name() + land.paechter_anschrift = f"{paechter.strasse or ''}\n{paechter.plz or ''} {paechter.ort or ''}".strip() + land.pachtbeginn = pachtbeginn + land.pachtende = pachtende if pachtende else None + land.pachtzins_pauschal = pachtzins_pauschal if pachtzins_pauschal else None + land.zahlungsweise = zahlungsweise + land.ust_option = ust_option + if verpachtete_flaeche: + land.verp_flaeche_aktuell = verpachtete_flaeche + land.save() + + messages.success( + request, + f"Verpachtung von {land} an {paechter.get_full_name()} wurde erfolgreich aktualisiert.", + ) + return redirect("stiftung:land_detail", pk=land.pk) + else: + messages.error(request, "Bitte füllen Sie alle Pflichtfelder aus.") + + # Verfügbare Pächter + paechter_list = Paechter.objects.filter(aktiv=True).order_by("nachname", "vorname") + + # Berechne verfügbare Fläche (Gesamtfläche minus bereits verpachtete Fläche) + verfuegbare_flaeche = land.groesse_qm + if land.verp_flaeche_aktuell and land.verp_flaeche_aktuell > 0: + verfuegbare_flaeche = land.groesse_qm - land.verp_flaeche_aktuell + + context = { + "land": land, + "paechter_list": paechter_list, + "current_year": datetime.now().year, + "is_edit": True, + "verfuegbare_flaeche": verfuegbare_flaeche, + } + + return render(request, "stiftung/land_verpachtung_form.html", context) + + +# Settings Management Views +@login_required +def verpachtung_detail(request, pk): + """Standalone detail view for verpachtung""" + verpachtung = get_object_or_404(LandVerpachtung, pk=pk) + + # Alle mit dieser Verpachtung verknüpften Dokumente laden + verknuepfte_dokumente = DokumentLink.objects.filter( + land_verpachtung_id=verpachtung.pk + ).order_by("kontext", "titel") + + context = { + "verpachtung": verpachtung, + "landverpachtung": verpachtung, # Template compatibility + "verknuepfte_dokumente": verknuepfte_dokumente, + "title": f"Verpachtung {verpachtung.vertragsnummer}", + } + return render(request, "stiftung/verpachtung_detail.html", context) + + +@login_required +def verpachtung_create(request): + """Standalone create view for verpachtung""" + from stiftung.forms import LandVerpachtungForm + from datetime import datetime as dt + + if request.method == 'POST': + form = LandVerpachtungForm(request.POST, request.FILES) + if form.is_valid(): + verpachtung = form.save() + + # Update the Land model to reflect this verpachtung + land = verpachtung.land + land.aktueller_paechter = verpachtung.paechter + land.paechter_name = verpachtung.paechter.get_full_name() + land.paechter_anschrift = f"{verpachtung.paechter.strasse or ''}\n{verpachtung.paechter.plz or ''} {verpachtung.paechter.ort or ''}".strip() + land.pachtbeginn = verpachtung.pachtbeginn + land.pachtende = verpachtung.pachtende + land.pachtzins_pauschal = verpachtung.pachtzins_pauschal + land.zahlungsweise = verpachtung.zahlungsweise + land.ust_option = verpachtung.ust_option + land.verpachtete_gesamtflaeche = verpachtung.verpachtete_flaeche + land.verp_flaeche_aktuell = verpachtung.verpachtete_flaeche + land.save() + + # Create automatic abrechnung + current_year = dt.now().year + expected_annual_rent = verpachtung.pachtzins_pauschal if verpachtung.pachtzins_pauschal else 0 + + abrechnung, created = LandAbrechnung.objects.get_or_create( + land=land, + abrechnungsjahr=current_year, + defaults={ + "pacht_vereinnahmt": expected_annual_rent, + "umlagen_vereinnahmt": 0, + "grundsteuer_betrag": 0, + "versicherungen_betrag": 0, + }, + ) + + if not created and expected_annual_rent > abrechnung.pacht_vereinnahmt: + abrechnung.pacht_vereinnahmt = expected_annual_rent + abrechnung.save() + + success_msg = f'Verpachtung "{verpachtung.vertragsnummer}" wurde erfolgreich erstellt.' + if created: + success_msg += f" Abrechnung für {current_year} wurde automatisch angelegt" + if expected_annual_rent > 0: + success_msg += f" (Erwartete Jahrespacht: {expected_annual_rent}€)" + success_msg += "." + elif expected_annual_rent > 0: + success_msg += f" Erwartete Jahrespacht in Abrechnung {current_year} wurde aktualisiert ({expected_annual_rent}€)." + + messages.success(request, success_msg) + return redirect('stiftung:verpachtung_detail', pk=verpachtung.pk) + else: + form = LandVerpachtungForm() + + # Get available Länder and Pächter for the template + laender_list = Land.objects.all().order_by('lfd_nr') + paechter_list = Paechter.objects.filter(aktiv=True).order_by('nachname', 'vorname') + + context = { + 'form': form, + 'title': 'Neue Verpachtung erstellen', + 'laender_list': laender_list, + 'paechter_list': paechter_list, + 'current_year': dt.now().year, + 'is_edit': False, + } + return render(request, 'stiftung/verpachtung_form.html', context) + + +@login_required +def verpachtung_update(request, pk): + """Standalone update view for verpachtung""" + return land_verpachtung_update(request, pk) + + +@login_required +def verpachtung_delete(request, pk): + """Standalone delete view for verpachtung""" + verpachtung = get_object_or_404(LandVerpachtung, pk=pk) + + if request.method == 'POST': + vertragsnummer = verpachtung.vertragsnummer + verpachtung.delete() + messages.success( + request, + f'Verpachtung "{vertragsnummer}" wurde erfolgreich gelöscht.' + ) + return redirect('stiftung:verpachtung_list') + + context = { + 'verpachtung': verpachtung, + 'title': f'Verpachtung {verpachtung.vertragsnummer} löschen', + } + return render(request, 'stiftung/verpachtung_confirm_delete.html', context) + + diff --git a/app/stiftung/views/system.py b/app/stiftung/views/system.py new file mode 100644 index 0000000..bb7044d --- /dev/null +++ b/app/stiftung/views/system.py @@ -0,0 +1,2139 @@ +# views/system.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, +) + + +def get_pdf_generator(): + """Lazy load PDF generator to handle missing dependencies gracefully""" + try: + from .utils.pdf_generator import pdf_generator + + return pdf_generator + except ImportError as e: + # Store the error message for use in MockPDFGenerator + error_message = str(e) + + # Return a mock generator if dependencies are missing + class MockPDFGenerator: + def is_available(self): + return False + + def export_data_list_pdf(self, *args, **kwargs): + from django.http import HttpResponse + + error_html = f""" + + +PDF generation requires additional system dependencies that are not installed.
+Error: {error_message}
+Please install WeasyPrint dependencies or use CSV export instead.
+ + + """ + response = HttpResponse(error_html, content_type="text/html") + response["Content-Disposition"] = ( + 'inline; filename="pdf_not_available.html"' + ) + return response + + return MockPDFGenerator() + + +class GrampsClient: + """Lightweight client for Gramps Web API.""" + + def __init__( + self, base_url: str, token: str = "", username: str = "", password: str = "" + ): + self.base_url = base_url.rstrip("/") + self.session = requests.Session() + if token: + self.session.headers.update({"Authorization": f"Bearer {token}"}) + self.username = username + self.password = password + self._cached_token = token + + def search_people(self, query: str, limit: int = 5): + try: + r = self.session.get( + f"{self.base_url}/api/people/", + params={"q": query, "limit": limit}, + timeout=10, + ) + r.raise_for_status() + return r.json() + except Exception as e: + # try login-once if unauthorized and we have credentials + if self.username and self.password and "401" in str(e): + if self._login(): + return self.search_people(query, limit) + return {"error": str(e)} + + def get_person(self, handle_or_id: str): + try: + r = self.session.get( + f"{self.base_url}/api/people/{handle_or_id}", timeout=10 + ) + r.raise_for_status() + return r.json() + except Exception as e: + if self.username and self.password and "401" in str(e): + if self._login(): + return self.get_person(handle_or_id) + return {"error": str(e)} + + def _login(self) -> bool: + try: + # try common endpoints + endpoints = [ + ( + "/api/auth/login", + {"username": self.username, "password": self.password}, + "json", + ), + ( + "/auth/login", + {"username": self.username, "password": self.password}, + "json", + ), + ( + "/api/token", + {"username": self.username, "password": self.password}, + "form", + ), + ( + "/login", + {"username": self.username, "password": self.password}, + "form", + ), + ( + "/token", + {"username": self.username, "password": self.password}, + "form", + ), + ( + "/api/login", + {"username": self.username, "password": self.password}, + "json", + ), + ] + for path, payload, mode in endpoints: + url = f"{self.base_url}{path}" + if mode == "json": + r = self.session.post( + url, json=payload, timeout=10, allow_redirects=False + ) + else: + r = self.session.post( + url, data=payload, timeout=10, allow_redirects=False + ) + # Success with token body + if r.status_code in (200, 201) and "application/json" in r.headers.get( + "Content-Type", "" + ): + data = r.json() + token = ( + data.get("access_token") + or data.get("token") + or data.get("access") + or data.get("jwt") + ) + if token: + self._cached_token = token + self.session.headers.update( + {"Authorization": f"Bearer {token}"} + ) + return True + # Success via session cookie and redirect + if r.status_code in (200, 302) and ( + "set-cookie" in {k.lower(): v for k, v in r.headers.items()} + ): + return True + # Basic Auth fallback (some setups protect API with Basic) + try: + self.session.auth = (self.username, self.password) + r = self.session.get(f"{self.base_url}/api/people/?limit=1", timeout=10) + if r.status_code == 200: + return True + except Exception: + pass + return False + except Exception: + return False + + +def get_gramps_client() -> GrampsClient: + return GrampsClient( + getattr(settings, "GRAMPS_URL", ""), + getattr(settings, "GRAMPS_API_TOKEN", ""), + getattr(settings, "GRAMPS_USERNAME", ""), + getattr(settings, "GRAMPS_PASSWORD", ""), + ) + + +@api_view(["GET"]) +def gramps_debug_api(_request): + return Response( + { + "GRAMPS_URL": getattr(settings, "GRAMPS_URL", ""), + "has_username": bool(getattr(settings, "GRAMPS_USERNAME", "")), + "has_password": bool(getattr(settings, "GRAMPS_PASSWORD", "")), + } + ) + + +from stiftung.models import DestinataerNotiz, DestinataerUnterstuetzung + +from stiftung.forms import (DestinataerForm, DestinataerNotizForm, + DestinataerUnterstuetzungForm, DokumentLinkForm, + FoerderungForm, LandForm, PaechterForm, PersonForm, + UnterstuetzungForm, UnterstuetzungMarkAsPaidForm, VierteljahresNachweisForm) + +@login_required +def csv_import_list(request): + """List all CSV import operations""" + imports = CSVImport.objects.all().order_by("-started_at") + + paginator = Paginator(imports, 20) + page_number = request.GET.get("page") + page_obj = paginator.get_page(page_number) + + context = { + "page_obj": page_obj, + "import_types": CSVImport.IMPORT_TYPE_CHOICES, + "status_choices": CSVImport.STATUS_CHOICES, + } + return render(request, "stiftung/csv_import_list.html", context) + + +@login_required +def csv_import_create(request): + """Show CSV import form and handle file upload""" + if request.method == "POST": + import_type = request.POST.get("import_type") + csv_file = request.FILES.get("csv_file") + + if not csv_file or not import_type: + messages.error( + request, "Bitte wählen Sie einen Import-Typ und eine CSV-Datei aus." + ) + return redirect("stiftung:csv_import_create") + + if not csv_file.name.endswith(".csv"): + messages.error(request, "Bitte wählen Sie eine gültige CSV-Datei aus.") + return redirect("stiftung:csv_import_create") + + try: + # Create import record + csv_import = CSVImport.objects.create( + import_type=import_type, + filename=csv_file.name, + file_size=csv_file.size, + created_by=( + request.user.username + if request.user.is_authenticated + else "Unknown" + ), + status="processing", + ) + + # Process the CSV file + if import_type == "destinataere": + result = process_destinataere_csv(csv_file, csv_import) + elif import_type == "paechter": + result = process_paechter_csv(csv_file, csv_import) + elif import_type == "personen": + result = process_personen_csv(csv_file, csv_import) + elif import_type == "laendereien": + result = process_laendereien_csv(csv_file, csv_import) + else: + messages.error(request, "Unbekannter Import-Typ.") + csv_import.status = "failed" + csv_import.save() + return redirect("stiftung:csv_import_create") + + # Update import record + csv_import.total_rows = result["total_rows"] + csv_import.imported_rows = result["imported_rows"] + csv_import.failed_rows = result["failed_rows"] + csv_import.error_log = result["error_log"] + csv_import.status = result["status"] + csv_import.completed_at = timezone.now() + csv_import.save() + + if result["status"] == "completed": + messages.success( + request, + f'CSV-Import erfolgreich! {result["imported_rows"]} Datensätze importiert.', + ) + elif result["status"] == "partial": + messages.warning( + request, + f'CSV-Import teilweise erfolgreich. {result["imported_rows"]} importiert, {result["failed_rows"]} fehlgeschlagen.', + ) + else: + messages.error( + request, f'CSV-Import fehlgeschlagen. {result["error_log"]}' + ) + + return redirect("stiftung:csv_import_list") + + except Exception as e: + messages.error(request, f"Fehler beim CSV-Import: {str(e)}") + return redirect("stiftung:csv_import_create") + + context = { + "import_types": CSVImport.IMPORT_TYPE_CHOICES, + } + return render(request, "stiftung/csv_import_form.html", context) + + +def process_personen_csv(csv_file, csv_import): + """Process CSV file for Personen import""" + decoded_file = csv_file.read().decode("utf-8") + # Handle both comma and semicolon separated files + if ";" in decoded_file.split("\n")[0]: + csv_data = csv.DictReader(io.StringIO(decoded_file), delimiter=";") + else: + csv_data = csv.DictReader(io.StringIO(decoded_file)) + + total_rows = 0 + imported_rows = 0 + failed_rows = 0 + error_log = [] + + for row_num, row in enumerate( + csv_data, start=2 + ): # Start at 2 because row 1 is header + total_rows += 1 + + try: + # Map CSV columns to model fields + person_data = { + "vorname": row.get("Vorname", "").strip(), + "nachname": row.get("Nachname", "").strip(), + "familienzweig": row.get("Familienzweig", "hauptzweig").strip(), + "email": row.get("E-Mail", "").strip() or None, + "telefon": row.get("Telefon", "").strip() or None, + "iban": row.get("IBAN", "").strip() or None, + "adresse": row.get("Adresse", "").strip() or None, + "notizen": row.get("Notizen", "").strip() or None, + "aktiv": row.get("Aktiv", "true").lower() == "true", + } + + # Handle date fields + if row.get("Geburtsdatum"): + try: + person_data["geburtsdatum"] = datetime.strptime( + row["Geburtsdatum"], "%d.%m.%Y" + ).date() + except ValueError: + try: + person_data["geburtsdatum"] = datetime.strptime( + row["Geburtsdatum"], "%Y-%m-%d" + ).date() + except ValueError: + person_data["geburtsdatum"] = None + + # Validate required fields + if not person_data["vorname"] or not person_data["nachname"]: + error_log.append( + f"Zeile {row_num}: Vorname und Nachname sind erforderlich" + ) + failed_rows += 1 + continue + + # Check if person already exists + existing_person = Person.objects.filter( + vorname__iexact=person_data["vorname"], + nachname__iexact=person_data["nachname"], + ).first() + + if existing_person: + # Update existing person + for field, value in person_data.items(): + if value is not None: + setattr(existing_person, field, value) + existing_person.save() + else: + # Create new person + Person.objects.create(**person_data) + + imported_rows += 1 + + except Exception as e: + error_log.append(f"Zeile {row_num}: {str(e)}") + failed_rows += 1 + + # Determine status + if failed_rows == 0: + status = "completed" + elif imported_rows > 0: + status = "partial" + else: + status = "failed" + + return { + "total_rows": total_rows, + "imported_rows": imported_rows, + "failed_rows": failed_rows, + "error_log": "\n".join(error_log) if error_log else None, + "status": status, + } + + +def process_destinataere_csv(csv_file, csv_import): + """Process CSV file for Destinatäre import""" + decoded_file = csv_file.read().decode("utf-8") + # Handle both comma and semicolon separated files + if ";" in decoded_file.split("\n")[0]: + csv_data = csv.DictReader(io.StringIO(decoded_file), delimiter=";") + else: + csv_data = csv.DictReader(io.StringIO(decoded_file)) + + total_rows = 0 + imported_rows = 0 + failed_rows = 0 + error_log = [] + + for row_num, row in enumerate( + csv_data, start=2 + ): # Start at 2 because row 1 is header + total_rows += 1 + + try: + # Helper function to parse boolean values from CSV + def parse_boolean(value, default=False): + """Parse boolean values from CSV with multiple accepted formats""" + if not value: + return default + value_str = str(value).strip().lower() + # Accept various true values + true_values = ['true', 'ja', 'yes', '1', 'wahr', 'x'] + # Accept various false values + false_values = ['false', 'nein', 'no', '0', 'falsch', ''] + + if value_str in true_values: + return True + elif value_str in false_values: + return False + else: + # If unclear, return default + return default + + # Map CSV columns to model fields + destinataer_data = { + "vorname": row.get("Vorname", "").strip(), + "nachname": row.get("Nachname", "").strip(), + "familienzweig": row.get("Familienzweig", "hauptzweig").strip(), + "email": row.get("E-Mail", "").strip() or None, + "telefon": row.get("Telefon", "").strip() or None, + "iban": row.get("IBAN", "").strip() or None, + "strasse": row.get("Straße", "").strip() or None, + "plz": row.get("PLZ", "").strip() or None, + "ort": row.get("Ort", "").strip() or None, + "berufsgruppe": row.get("Berufsgruppe", "andere").strip(), + "ausbildungsstand": row.get("Ausbildungsstand", "").strip() or None, + "institution": row.get("Institution", "").strip() or None, + "projekt_beschreibung": row.get("Projektbeschreibung", "").strip() + or None, + "jaehrliches_einkommen": ( + float(row.get("Jährliches_Einkommen", 0)) + if row.get("Jährliches_Einkommen") + else None + ), + "notizen": row.get("Notizen", "").strip() or None, + # Boolean fields with improved parsing + "finanzielle_notlage": parse_boolean(row.get("Finanzielle_Notlage"), False), + "aktiv": parse_boolean(row.get("Aktiv"), True), + "ist_abkoemmling": parse_boolean(row.get("Ist_Abkömmling"), False), + "unterstuetzung_bestaetigt": parse_boolean(row.get("Unterstützung_bestätigt"), False), + "studiennachweis_erforderlich": parse_boolean(row.get("Studiennachweis_erforderlich"), False), + } + + # Handle numeric fields + if row.get("Haushaltsgröße"): + try: + destinataer_data["haushaltsgroesse"] = int(row["Haushaltsgröße"]) + except ValueError: + pass + + if row.get("Monatliche_Bezüge"): + try: + destinataer_data["monatliche_bezuege"] = float(row["Monatliche_Bezüge"]) + except ValueError: + pass + + if row.get("Vermögen"): + try: + destinataer_data["vermoegen"] = float(row["Vermögen"]) + except ValueError: + pass + + if row.get("Vierteljährlicher_Betrag"): + try: + destinataer_data["vierteljaehrlicher_betrag"] = float(row["Vierteljährlicher_Betrag"]) + except ValueError: + pass + + # Handle date fields + if row.get("Geburtsdatum"): + try: + destinataer_data["geburtsdatum"] = datetime.strptime( + row["Geburtsdatum"], "%d.%m.%Y" + ).date() + except ValueError: + try: + destinataer_data["geburtsdatum"] = datetime.strptime( + row["Geburtsdatum"], "%Y-%m-%d" + ).date() + except ValueError: + destinataer_data["geburtsdatum"] = None + + if row.get("Letzter_Studiennachweis"): + try: + destinataer_data["letzter_studiennachweis"] = datetime.strptime( + row["Letzter_Studiennachweis"], "%d.%m.%Y" + ).date() + except ValueError: + try: + destinataer_data["letzter_studiennachweis"] = datetime.strptime( + row["Letzter_Studiennachweis"], "%Y-%m-%d" + ).date() + except ValueError: + destinataer_data["letzter_studiennachweis"] = None + + # Validate required fields + if not destinataer_data["vorname"] or not destinataer_data["nachname"]: + error_log.append( + f"Zeile {row_num}: Vorname und Nachname sind erforderlich" + ) + failed_rows += 1 + continue + + # Check if destinataer already exists + existing_destinataer = Destinataer.objects.filter( + vorname__iexact=destinataer_data["vorname"], + nachname__iexact=destinataer_data["nachname"], + ).first() + + if existing_destinataer: + # Update existing destinataer + for field, value in destinataer_data.items(): + if value is not None: + setattr(existing_destinataer, field, value) + existing_destinataer.save() + else: + # Create new destinataer + Destinataer.objects.create(**destinataer_data) + + imported_rows += 1 + + except Exception as e: + error_log.append(f"Zeile {row_num}: {str(e)}") + failed_rows += 1 + + # Determine status + if failed_rows == 0: + status = "completed" + elif imported_rows > 0: + status = "partial" + else: + status = "failed" + + return { + "total_rows": total_rows, + "imported_rows": imported_rows, + "failed_rows": failed_rows, + "error_log": "\n".join(error_log) if error_log else None, + "status": status, + } + + +def process_paechter_csv(csv_file, csv_import): + """Process CSV file for Paechter import""" + decoded_file = csv_file.read().decode("utf-8") + + # Handle both comma and semicolon separated files + if ";" in decoded_file.split("\n")[0]: + csv_data = csv.DictReader(io.StringIO(decoded_file), delimiter=";") + else: + csv_data = csv.DictReader(io.StringIO(decoded_file)) + + total_rows = 0 + imported_rows = 0 + failed_rows = 0 + error_log = [] + + for row_num, row in enumerate( + csv_data, start=2 + ): # Start at 2 because row 1 is header + total_rows += 1 + + try: + # Get raw values from CSV - handle both semicolon and comma separated + # Handle BOM in column names + vorname_raw = row.get("Vorname", "") or row.get("\ufeffVorname", "") + nachname_raw = row.get("Nachname", "") + personentyp_raw = row.get("Personentyp", "") + + # Clean up the values (remove extra whitespace but keep empty strings) + vorname_raw = vorname_raw.strip() if vorname_raw else "" + nachname_raw = nachname_raw.strip() if nachname_raw else "" + personentyp_raw = personentyp_raw.strip() if personentyp_raw else "" + + # Debug: Log raw values and available columns + error_log.append(f"Zeile {row_num}: Available columns: {list(row.keys())}") + error_log.append( + f"Zeile {row_num}: RAW Vorname='{vorname_raw}', Nachname='{nachname_raw}', Personentyp='{personentyp_raw}'" + ) + + # Determine personentyp based on the data + if personentyp_raw in ["Gesellschaft", "KG", "GbR", "GmbH"]: + personentyp = "gesellschaft" + elif personentyp_raw in ["Herrn", "Frau"]: + personentyp = "natuerlich" + else: + # Fallback: analyze the Nachname to detect companies + nachname_lower = nachname_raw.lower() + if any( + keyword in nachname_lower + for keyword in [ + "kg", + "gbr", + "gmbh", + "ag", + "ohg", + "e.v.", + "stiftung", + "genossenschaft", + ] + ): + personentyp = "gesellschaft" + else: + personentyp = "natuerlich" + + # Handle Vorname - keep original value unless it's 'N/A' + vorname = vorname_raw if vorname_raw and vorname_raw != "N/A" else "" + + # Debug: Log processed values + error_log.append( + f"Zeile {row_num}: PROCESSED Vorname='{vorname}', Nachname='{nachname_raw}', Personentyp='{personentyp}'" + ) + + paechter_data = { + "vorname": vorname, + "nachname": nachname_raw, + "email": row.get("E-Mail", "").strip() or None, + "telefon": row.get("Telefon", "").strip() or None, + "iban": row.get("IBAN", "").strip() or None, + "strasse": row.get("Straße", "").strip() or None, + "plz": row.get("PLZ", "").strip() or None, + "ort": row.get("Ort", "").strip() or None, + "personentyp": personentyp, + "pachtnummer": row.get("Pachtnummer", "").strip() or None, + "landwirtschaftliche_ausbildung": row.get( + "Landwirtschaftliche_Ausbildung", "false" + ).lower() + == "true", + "berufserfahrung_jahre": ( + int(row.get("Berufserfahrung_Jahre", 0)) + if row.get("Berufserfahrung_Jahre") + else None + ), + "spezialisierung": row.get("Spezialisierung", "").strip() or None, + "notizen": row.get("Notizen", "").strip() or None, + "aktiv": row.get("Aktiv", "true").lower() + in ["true", "wahr", "ja", "1"], + } + + # Handle date fields + if row.get("Geburtsdatum"): + try: + paechter_data["geburtsdatum"] = datetime.strptime( + row["Geburtsdatum"], "%d.%m.%Y" + ).date() + except ValueError: + try: + paechter_data["geburtsdatum"] = datetime.strptime( + row["Geburtsdatum"], "%Y-%m-%d" + ).date() + except ValueError: + paechter_data["geburtsdatum"] = None + + if row.get("Pachtbeginn_Erste"): + try: + paechter_data["pachtbeginn_erste"] = datetime.strptime( + row["Pachtbeginn_Erste"], "%d.%m.%Y" + ).date() + except ValueError: + try: + paechter_data["pachtbeginn_erste"] = datetime.strptime( + row["Pachtbeginn_Erste"], "%Y-%m-%d" + ).date() + except ValueError: + paechter_data["pachtbeginn_erste"] = None + + if row.get("Pachtende_Letzte"): + try: + paechter_data["pachtende_letzte"] = datetime.strptime( + row["Pachtende_Letzte"], "%d.%m.%Y" + ).date() + except ValueError: + try: + paechter_data["pachtende_letzte"] = datetime.strptime( + row["Pachtende_Letzte"], "%Y-%m-%d" + ).date() + except ValueError: + paechter_data["pachtende_letzte"] = None + + # Handle decimal fields + if row.get("Pachtzins_Aktuell"): + try: + paechter_data["pachtzins_aktuell"] = float(row["Pachtzins_Aktuell"]) + except ValueError: + paechter_data["pachtzins_aktuell"] = None + + # Validate required fields + if personentyp == "gesellschaft": + # For companies, only Nachname is required + if not paechter_data["nachname"]: + error_log.append( + f"Zeile {row_num}: Nachname ist für Gesellschaften erforderlich" + ) + failed_rows += 1 + continue + else: + # For natural persons, only Nachname is required + if not paechter_data["nachname"]: + error_log.append( + f"Zeile {row_num}: Nachname ist für natürliche Personen erforderlich" + ) + failed_rows += 1 + continue + + # Check if paechter already exists + if personentyp == "gesellschaft": + # For companies, search by Nachname only + existing_paechter = Paechter.objects.filter( + nachname__iexact=paechter_data["nachname"], + personentyp="gesellschaft", + ).first() + else: + # For natural persons, search by Nachname only (since Vorname can be empty) + existing_paechter = Paechter.objects.filter( + nachname__iexact=paechter_data["nachname"], personentyp="natuerlich" + ).first() + + if existing_paechter: + # Update existing paechter + for field, value in paechter_data.items(): + if value is not None: + setattr(existing_paechter, field, value) + existing_paechter.save() + else: + # Create new paechter + Paechter.objects.create(**paechter_data) + + imported_rows += 1 + + except Exception as e: + error_log.append(f"Zeile {row_num}: {str(e)}") + failed_rows += 1 + + # Determine status + if failed_rows == 0: + status = "completed" + elif imported_rows > 0: + status = "partial" + else: + status = "failed" + + return { + "total_rows": total_rows, + "imported_rows": imported_rows, + "failed_rows": failed_rows, + "error_log": "\n".join(error_log) if error_log else None, + "status": status, + } + + +def process_laendereien_csv(csv_file, csv_import): + """Process CSV file for Ländereien import""" + decoded_file = csv_file.read().decode("utf-8") + # Handle both comma and semicolon separated files + if ";" in decoded_file.split("\n")[0]: + csv_data = csv.DictReader(io.StringIO(decoded_file), delimiter=";") + else: + csv_data = csv.DictReader(io.StringIO(decoded_file)) + + total_rows = 0 + imported_rows = 0 + failed_rows = 0 + error_log = [] + + last_gemeinde = None + for row_num, row in enumerate(csv_data, start=2): + total_rows += 1 + + try: + # Build case-insensitive access helpers (strip BOM, normalize separators) + def clean_key(key: str) -> str: + return (key or "").replace("\ufeff", "").replace("\ufeff", "").strip() + + normalized_row = {clean_key(k): (v or "").strip() for k, v in row.items()} + lower_row = { + clean_key(k).lower(): (v or "").strip() for k, v in row.items() + } + sanitized_row = { + clean_key(k) + .lower() + .replace("-", "_") + .replace(" ", "_"): (v or "") + .strip() + for k, v in row.items() + } + + def get_val(*keys): + # Try exact keys first, then case-insensitive + for key in keys: + if key in normalized_row: + return normalized_row[key] + for key in keys: + lk = key.lower() + if lk in lower_row: + return lower_row[lk] + sk = lk.replace("-", "_").replace(" ", "_") + if sk in sanitized_row: + return sanitized_row[sk] + return "" + + def parse_float(value): + if not value: + return 0 + # replace comma decimal if present + v = ( + value.replace(".", "").replace(",", ".") + if value.count(",") == 1 and value.count(".") > 1 + else value.replace(",", ".") + ) + try: + return float(v) + except ValueError: + return 0 + + # Map CSV columns to model fields (robust to header variants) + lfd_nr_val = get_val( + "Lfd_Nr", + "lfd_nr", + "LfdNr", + "lfdnr", + "laufende_nummer", + "laufende-nummer", + ) + land_data = { + "lfd_nr": lfd_nr_val, + "ew_nummer": get_val("EW_Nummer", "ew_nummer") or None, + "amtsgericht": get_val("Amtsgericht", "amtsgericht"), + "gemeinde": get_val("Gemeinde", "gemeinde"), + "gemarkung": get_val("Gemarkung", "gemarkung"), + "flur": get_val("Flur", "flur"), + "flurstueck": get_val( + "Flurstück", "Flurstueck", "flurstück", "flurstueck" + ), + "groesse_qm": parse_float( + get_val("Größe_qm", "Groesse_qm", "groesse_qm", "größe_qm") + ), + "gruenland_qm": parse_float( + get_val( + "Grünland_qm", "Gruenland_qm", "gruenland_qm", "grünland_qm" + ) + ), + "acker_qm": parse_float(get_val("Acker_qm", "acker_qm")), + "wald_qm": parse_float(get_val("Wald_qm", "wald_qm")), + "sonstiges_qm": parse_float(get_val("Sonstiges_qm", "sonstiges_qm")), + "verpachtete_gesamtflaeche": parse_float( + get_val( + "Verpachtete_Gesamtfläche_qm", + "Verpachtete_Gesamtflaeche_qm", + "verpachtete_gesamtfläche_qm", + "verpachtete_gesamtflaeche_qm", + ) + ), + "verp_flaeche_aktuell": parse_float( + get_val( + "Verp_Fläche_aktuell_qm", + "Verp_Flaeche_aktuell_qm", + "verp_flaeche_aktuell_qm", + "verp_fläche_aktuell_qm", + ) + ), + "aktiv": get_val("Aktiv", "aktiv").lower() + in ["true", "wahr", "ja", "1"], + "notizen": get_val("Notizen", "notizen") or None, + } + + # Fallback for missing 'Gemeinde' -> set explicit placeholder + if not land_data["gemeinde"]: + land_data["gemeinde"] = "FEHLT" + + # Validate required fields + required_fields = ["lfd_nr", "gemeinde", "gemarkung", "flur", "flurstueck"] + missing_fields = [ + field for field in required_fields if not land_data[field] + ] + + if missing_fields: + # Log header diagnostics on first failure only to help debugging + if row_num == 2: + error_log.append(f"Erkannte Spalten: {list(normalized_row.keys())}") + error_log.append( + f"Zeile {row_num}: Fehlende Pflichtfelder: {', '.join(missing_fields)}" + ) + failed_rows += 1 + continue + + # Check if land already exists + existing_land = Land.objects.filter(lfd_nr=land_data["lfd_nr"]).first() + + if existing_land: + # Update existing land + for field, value in land_data.items(): + if value is not None: + setattr(existing_land, field, value) + existing_land.save() + else: + # Create new land + Land.objects.create(**land_data) + + imported_rows += 1 + if land_data["gemeinde"]: + last_gemeinde = land_data["gemeinde"] + + except Exception as e: + error_log.append(f"Zeile {row_num}: {str(e)}") + failed_rows += 1 + + # Determine status + if failed_rows == 0: + status = "completed" + elif imported_rows > 0: + status = "partial" + else: + status = "failed" + + return { + "total_rows": total_rows, + "imported_rows": imported_rows, + "failed_rows": failed_rows, + "error_log": "\n".join(error_log) if error_log else None, + "status": status, + } + + +# Person Views +@api_view(["GET"]) +def gramps_search_api(request): + """Probe-Endpoint: Suche Personen in Gramps Web nach q (Nachname, Vorname).""" + q = request.GET.get("q", "") + if not q: + return Response({"error": "Parameter q erforderlich"}, status=400) + client = get_gramps_client() + result = client.search_people(q) + return Response(result) + + +# Geschäftsführung Views +@login_required +def administration(request): + """Administration Dashboard""" + from datetime import datetime, timedelta + + from django.db.models import Count + + from stiftung.models import AuditLog, BackupJob + + # Recent audit activity + recent_audit = AuditLog.objects.all()[:10] + + # Audit statistics + heute = datetime.now().date() + stats = { + "total_logs": AuditLog.objects.count(), + "logs_today": AuditLog.objects.filter(timestamp__date=heute).count(), + "logs_week": AuditLog.objects.filter( + timestamp__gte=heute - timedelta(days=7) + ).count(), + "recent_backups": BackupJob.objects.all()[:5], + "last_backup": BackupJob.objects.filter(status="completed").first(), + } + + # User activity summary + user_activity = ( + AuditLog.objects.values("username") + .annotate(count=Count("id")) + .order_by("-count")[:10] + ) + + context = { + "recent_audit": recent_audit, + "stats": stats, + "user_activity": user_activity, + } + + return render(request, "stiftung/administration.html", context) + + +@login_required +def audit_log_list(request): + """Liste aller Audit Log Einträge""" + from django.core.paginator import Paginator + + from stiftung.models import AuditLog + + logs = AuditLog.objects.all() + + # Filter + user_filter = request.GET.get("user") + if user_filter: + logs = logs.filter(username__icontains=user_filter) + + action_filter = request.GET.get("action") + if action_filter: + logs = logs.filter(action=action_filter) + + entity_filter = request.GET.get("entity_type") + if entity_filter: + logs = logs.filter(entity_type=entity_filter) + + date_from = request.GET.get("date_from") + if date_from: + logs = logs.filter(timestamp__date__gte=date_from) + + date_to = request.GET.get("date_to") + if date_to: + logs = logs.filter(timestamp__date__lte=date_to) + + # Pagination + paginator = Paginator(logs, 50) + page_number = request.GET.get("page") + page_obj = paginator.get_page(page_number) + + context = { + "page_obj": page_obj, + "action_choices": AuditLog.ACTION_TYPES, + "entity_choices": AuditLog.ENTITY_TYPES, + "user_filter": user_filter, + "action_filter": action_filter, + "entity_filter": entity_filter, + "date_from": date_from, + "date_to": date_to, + } + + return render(request, "stiftung/audit_log_list.html", context) + + +@login_required +def backup_management(request): + """Backup Management Interface""" + from django.core.paginator import Paginator + + from stiftung.models import BackupJob + + # Handle backup creation + if request.method == "POST": + backup_type = request.POST.get("backup_type", "full") + + # Create backup job + backup_job = BackupJob.objects.create( + backup_type=backup_type, created_by=request.user + ) + + # Log the backup initiation + from stiftung.audit import log_system_action + + log_system_action( + request=request, + action="backup", + description=f"Backup-Job erstellt: {backup_job.get_backup_type_display()}", + details={"backup_job_id": str(backup_job.id), "backup_type": backup_type}, + ) + + # Start backup process asynchronously (we'll create a simple version for now) + import threading + + from stiftung.backup_utils import run_backup + + backup_thread = threading.Thread(target=run_backup, args=(str(backup_job.id),)) + backup_thread.start() + + messages.success( + request, + f'Backup-Job "{backup_job.get_backup_type_display()}" wurde gestartet.', + ) + return redirect("stiftung:backup_management") + + # List backup jobs + backup_jobs = BackupJob.objects.all() + + # Pagination + paginator = Paginator(backup_jobs, 20) + page_number = request.GET.get("page") + page_obj = paginator.get_page(page_number) + + context = { + "page_obj": page_obj, + "backup_types": BackupJob.TYPE_CHOICES, + } + + return render(request, "stiftung/backup_management.html", context) + + +@login_required +def backup_download(request, backup_id): + """Download a backup file""" + import os + + from django.http import FileResponse, Http404 + + from stiftung.models import BackupJob + + try: + backup_job = BackupJob.objects.get(id=backup_id, status="completed") + except BackupJob.DoesNotExist: + raise Http404("Backup nicht gefunden oder nicht vollständig") + + backup_path = os.path.join("/app/backups", backup_job.backup_filename) + if not os.path.exists(backup_path): + raise Http404("Backup-Datei nicht gefunden") + + # Log download + from stiftung.audit import log_system_action + + log_system_action( + request=request, + action="export", + description=f"Backup heruntergeladen: {backup_job.backup_filename}", + details={"backup_job_id": str(backup_job.id)}, + ) + + response = FileResponse( + open(backup_path, "rb"), as_attachment=True, filename=backup_job.backup_filename + ) + return response + + +@login_required +def backup_restore(request): + """Restore from backup""" + if request.method == "POST": + from stiftung.models import BackupJob + + backup_file = request.FILES.get("backup_file") + + if not backup_file: + messages.error(request, "Bitte wählen Sie eine Backup-Datei aus.") + return redirect("stiftung:backup_management") + + # Validate file format + if not backup_file.name.endswith(".tar.gz"): + messages.error( + request, "Ungültiges Backup-Format. Nur .tar.gz Dateien sind erlaubt." + ) + return redirect("stiftung:backup_management") + + # Save uploaded file to temporary location + import os + import tempfile + + temp_dir = tempfile.mkdtemp() + backup_path = os.path.join(temp_dir, backup_file.name) + + try: + with open(backup_path, "wb+") as destination: + for chunk in backup_file.chunks(): + destination.write(chunk) + + # Validate the backup file + from stiftung.backup_utils import validate_backup_file + + is_valid, message = validate_backup_file(backup_path) + if not is_valid: + messages.error(request, f"Ungültiges Backup: {message}") + return redirect("stiftung:backup_management") + + # Show validation success + messages.info(request, f"Backup validiert: {message}") + + # Create restore job + restore_job = BackupJob.objects.create( + operation="restore", + backup_type="full", + created_by=request.user, + backup_filename=backup_file.name, + ) + + # Log restore initiation + from stiftung.audit import log_system_action + + log_system_action( + request=request, + action="restore", + description=f"Wiederherstellung gestartet von: {backup_file.name}", + details={ + "restore_job_id": str(restore_job.id), + "filename": backup_file.name, + }, + ) + + # Start restore process + import threading + + from stiftung.backup_utils import run_restore + + restore_thread = threading.Thread( + target=run_restore, args=(str(restore_job.id), backup_path) + ) + restore_thread.start() + + messages.success( + request, f'Wiederherstellung von "{backup_file.name}" wurde gestartet. ' + f'Überwachen Sie den Fortschritt in der Backup-Historie.' + ) + return redirect("stiftung:backup_management") + + except Exception as e: + messages.error(request, f"Fehler beim Verarbeiten der Backup-Datei: {e}") + return redirect("stiftung:backup_management") + + return redirect("stiftung:backup_management") + + +@login_required +def backup_cancel(request, backup_id): + """Cancel a running backup job""" + from stiftung.models import BackupJob + import traceback + + try: + print(f"DEBUG: Attempting to cancel backup job {backup_id}") + backup_job = BackupJob.objects.get(id=backup_id) + print(f"DEBUG: Found backup job - ID: {backup_job.id}, Status: {backup_job.status}") + + # Use created_by_id instead of created_by to avoid triggering the foreign key lookup + print(f"DEBUG: Created by ID: {backup_job.created_by_id}, Current user ID: {request.user.id}") + + # Only allow cancelling running or pending jobs + if backup_job.status not in ['running', 'pending']: + messages.error(request, "Nur laufende oder wartende Backups können abgebrochen werden.") + return redirect("stiftung:backup_management") + + # Check if user has permission to cancel (either own job or admin) + # Use created_by_id to avoid database lookup for potentially non-existent user + print(f"DEBUG: Checking permissions - created_by_id: {backup_job.created_by_id}, is_staff: {request.user.is_staff}") + if backup_job.created_by_id is not None and backup_job.created_by_id != request.user.id and not request.user.is_staff: + messages.error(request, "Sie können nur Ihre eigenen Backup-Jobs abbrechen.") + return redirect("stiftung:backup_management") + + # Mark as cancelled + print("DEBUG: About to mark job as cancelled") + from django.utils import timezone + backup_job.status = "cancelled" + backup_job.completed_at = timezone.now() + + print(f"DEBUG: About to set error message with username: {request.user.username}") + backup_job.error_message = f"Abgebrochen von {request.user.username}" + + print("DEBUG: About to save backup job") + backup_job.save() + print("DEBUG: Backup job saved successfully") + + # Log the cancellation (with error handling) + try: + print("DEBUG: About to log system action") + from stiftung.audit import log_system_action + + print(f"DEBUG: About to call get_backup_type_display") + backup_type_display = backup_job.get_backup_type_display() + print(f"DEBUG: Backup type display: {backup_type_display}") + + log_system_action( + request=request, + action="backup_cancel", + description=f"Backup-Job abgebrochen: {backup_type_display}", + details={"backup_job_id": str(backup_job.id)}, + ) + print("DEBUG: System action logged successfully") + except Exception as audit_error: + print(f"ERROR in audit logging: {audit_error}") + print(f"ERROR traceback: {traceback.format_exc()}") + # Don't fail the cancellation if logging fails + + messages.success(request, f"Backup-Job wurde abgebrochen.") + + except BackupJob.DoesNotExist: + print(f"ERROR: Backup job {backup_id} not found") + messages.error(request, "Backup-Job nicht gefunden.") + except Exception as e: + print(f"ERROR: Unexpected error in backup_cancel: {e}") + print(f"ERROR traceback: {traceback.format_exc()}") + messages.error(request, f"Fehler beim Abbrechen des Backup-Jobs: {e}") + + return redirect("stiftung:backup_management") + + +# ============================================================================= +# USER MANAGEMENT VIEWS +# ============================================================================= + + +@login_required +def user_management(request): + """User Management Dashboard""" + from django.contrib.auth.models import User + from django.core.paginator import Paginator + from django.db.models import Q + + # Check permission + if not request.user.has_perm("stiftung.manage_users"): + messages.error( + request, "Sie haben keine Berechtigung für die Benutzerverwaltung." + ) + return redirect("stiftung:administration") + + users = User.objects.all().order_by("username") + + # Search functionality + search = request.GET.get("search") + if search: + users = users.filter( + Q(username__icontains=search) + | Q(email__icontains=search) + | Q(first_name__icontains=search) + | Q(last_name__icontains=search) + ) + + # Filter by status + status_filter = request.GET.get("status") + if status_filter == "active": + users = users.filter(is_active=True) + elif status_filter == "inactive": + users = users.filter(is_active=False) + elif status_filter == "staff": + users = users.filter(is_staff=True) + + # Pagination + paginator = Paginator(users, 20) + page_number = request.GET.get("page") + page_obj = paginator.get_page(page_number) + + # Statistics + stats = { + "total_users": User.objects.count(), + "active_users": User.objects.filter(is_active=True).count(), + "staff_users": User.objects.filter(is_staff=True).count(), + "inactive_users": User.objects.filter(is_active=False).count(), + } + + context = { + "page_obj": page_obj, + "stats": stats, + "search": search, + "status_filter": status_filter, + } + + return render(request, "stiftung/user_management.html", context) + + +@login_required +def user_create(request): + """Create a new user""" + from django.contrib.auth.models import User + + from stiftung.forms import UserCreationForm + + # Check permission + if not request.user.has_perm("stiftung.manage_users"): + messages.error( + request, "Sie haben keine Berechtigung für die Benutzerverwaltung." + ) + return redirect("stiftung:administration") + + if request.method == "POST": + form = UserCreationForm(request.POST) + if form.is_valid(): + # Create user + user = User.objects.create_user( + username=form.cleaned_data["username"], + email=form.cleaned_data["email"], + password=form.cleaned_data["password1"], + first_name=form.cleaned_data["first_name"], + last_name=form.cleaned_data["last_name"], + is_active=form.cleaned_data["is_active"], + is_staff=form.cleaned_data["is_staff"], + ) + + # Log user creation + from stiftung.audit import log_action + + log_action( + request=request, + action="create", + entity_type="user", + entity_id=str(user.pk), + entity_name=user.username, + description=f'Neuer Benutzer "{user.username}" wurde erstellt', + ) + + messages.success( + request, f'Benutzer "{user.username}" wurde erfolgreich erstellt.' + ) + return redirect("stiftung:user_detail", pk=user.pk) + else: + form = UserCreationForm() + + context = {"form": form, "title": "Neuen Benutzer erstellen"} + + return render(request, "stiftung/user_form.html", context) + + +@login_required +def user_detail(request, pk): + """User detail view""" + from django.contrib.auth.models import User + + # Check permission + if not request.user.has_perm("stiftung.manage_users"): + messages.error( + request, "Sie haben keine Berechtigung für die Benutzerverwaltung." + ) + return redirect("stiftung:administration") + + user = get_object_or_404(User, pk=pk) + + # Get user's permissions + user_permissions = user.get_all_permissions() + stiftung_permissions = [ + perm for perm in user_permissions if perm.startswith("stiftung.") + ] + + # Get recent audit activity + from stiftung.models import AuditLog + + recent_activity = AuditLog.objects.filter(user=user).order_by("-timestamp")[:10] + + context = { + "user_obj": user, # Use user_obj to avoid conflict with request.user + "stiftung_permissions": stiftung_permissions, + "recent_activity": recent_activity, + } + + return render(request, "stiftung/user_detail.html", context) + + +@login_required +def user_edit(request, pk): + """Edit user""" + from django.contrib.auth.models import User + + from stiftung.forms import UserUpdateForm + + # Check permission + if not request.user.has_perm("stiftung.manage_users"): + messages.error( + request, "Sie haben keine Berechtigung für die Benutzerverwaltung." + ) + return redirect("stiftung:administration") + + user = get_object_or_404(User, pk=pk) + + if request.method == "POST": + form = UserUpdateForm(request.POST, instance=user) + if form.is_valid(): + # Track changes + from stiftung.audit import log_action, track_model_changes + + old_user = User.objects.get(pk=user.pk) + + updated_user = form.save() + + # Log changes + changes = track_model_changes(old_user, updated_user) + if changes: + log_action( + request=request, + action="update", + entity_type="user", + entity_id=str(updated_user.pk), + entity_name=updated_user.username, + description=f'Benutzer "{updated_user.username}" wurde aktualisiert', + changes=changes, + ) + + messages.success( + request, + f'Benutzer "{updated_user.username}" wurde erfolgreich aktualisiert.', + ) + return redirect("stiftung:user_detail", pk=updated_user.pk) + else: + form = UserUpdateForm(instance=user) + + context = { + "form": form, + "user_obj": user, + "title": f'Benutzer "{user.username}" bearbeiten', + } + + return render(request, "stiftung/user_form.html", context) + + +@login_required +def user_change_password(request, pk): + """Change user password""" + from django.contrib.auth.models import User + + from stiftung.forms import PasswordChangeForm + + # Check permission + if not request.user.has_perm("stiftung.manage_users"): + messages.error( + request, "Sie haben keine Berechtigung für die Benutzerverwaltung." + ) + return redirect("stiftung:administration") + + user = get_object_or_404(User, pk=pk) + + if request.method == "POST": + form = PasswordChangeForm(request.POST) + if form.is_valid(): + user.set_password(form.cleaned_data["new_password1"]) + user.save() + + # Log password change + from stiftung.audit import log_action + + log_action( + request=request, + action="update", + entity_type="user", + entity_id=str(user.pk), + entity_name=user.username, + description=f'Passwort für Benutzer "{user.username}" wurde geändert', + ) + + messages.success( + request, + f'Passwort für Benutzer "{user.username}" wurde erfolgreich geändert.', + ) + return redirect("stiftung:user_detail", pk=user.pk) + else: + form = PasswordChangeForm() + + context = { + "form": form, + "user_obj": user, + "title": f'Passwort für "{user.username}" ändern', + } + + return render(request, "stiftung/user_change_password.html", context) + + +@login_required +def user_permissions(request, pk): + """Manage user permissions""" + from django.contrib.auth.models import Permission, User + + from stiftung.forms import UserPermissionForm + + # Check permission + if not request.user.has_perm("stiftung.manage_permissions"): + messages.error( + request, "Sie haben keine Berechtigung für die Berechtigungsverwaltung." + ) + return redirect("stiftung:administration") + + user = get_object_or_404(User, pk=pk) + + if request.method == "POST": + form = UserPermissionForm(request.POST, user=user) + if form.is_valid(): + # Get selected permissions + selected_perms = [] + for field_name, value in form.cleaned_data.items(): + if field_name.startswith("perm_") and value: + perm_id = field_name.replace("perm_", "") + selected_perms.append(int(perm_id)) + + # Get current stiftung permissions + current_perms = user.user_permissions.filter( + content_type__app_label="stiftung" + ) + current_perm_ids = set(current_perms.values_list("id", flat=True)) + selected_perm_ids = set(selected_perms) + + # Remove permissions that are no longer selected + to_remove = current_perm_ids - selected_perm_ids + if to_remove: + user.user_permissions.remove( + *Permission.objects.filter(id__in=to_remove) + ) + + # Add new permissions + to_add = selected_perm_ids - current_perm_ids + if to_add: + user.user_permissions.add(*Permission.objects.filter(id__in=to_add)) + + # Log permission changes + from stiftung.audit import log_action + + if to_remove or to_add: + changes = { + "removed_permissions": list( + Permission.objects.filter(id__in=to_remove).values_list( + "name", flat=True + ) + ), + "added_permissions": list( + Permission.objects.filter(id__in=to_add).values_list( + "name", flat=True + ) + ), + } + log_action( + request=request, + action="update", + entity_type="user", + entity_id=str(user.pk), + entity_name=user.username, + description=f'Berechtigungen für Benutzer "{user.username}" wurden aktualisiert', + changes=changes, + ) + + messages.success( + request, + f'Berechtigungen für Benutzer "{user.username}" wurden erfolgreich aktualisiert.', + ) + return redirect("stiftung:user_detail", pk=user.pk) + else: + form = UserPermissionForm(user=user) + + context = { + "form": form, + "user_obj": user, + "permission_groups": form.get_permission_groups(), + "title": f'Berechtigungen für "{user.username}"', + } + + return render(request, "stiftung/user_permissions.html", context) + + +@login_required +def user_delete(request, pk): + """Delete user""" + from django.contrib.auth.models import User + + # Check permission + if not request.user.has_perm("stiftung.manage_users"): + messages.error( + request, "Sie haben keine Berechtigung für die Benutzerverwaltung." + ) + return redirect("stiftung:administration") + + user = get_object_or_404(User, pk=pk) + + # Prevent deletion of current user + if user == request.user: + messages.error(request, "Sie können sich nicht selbst löschen.") + return redirect("stiftung:user_detail", pk=pk) + + if request.method == "POST": + username = user.username + + # Log deletion before deleting + from stiftung.audit import log_action + + log_action( + request=request, + action="delete", + entity_type="user", + entity_id=str(user.pk), + entity_name=username, + description=f'Benutzer "{username}" wurde gelöscht', + ) + + user.delete() + + messages.success(request, f'Benutzer "{username}" wurde erfolgreich gelöscht.') + return redirect("stiftung:user_management") + + context = {"user_obj": user, "title": f'Benutzer "{user.username}" löschen'} + + return render(request, "stiftung/user_delete.html", context) + + +# ============================================================================= +# AUTHENTICATION VIEWS +# ============================================================================= + + +def user_login(request): + """User login view""" + from django.contrib.auth import authenticate, login + from django.contrib.auth.forms import AuthenticationForm + + if request.user.is_authenticated: + return redirect("stiftung:home") + + if request.method == "POST": + form = AuthenticationForm(request, data=request.POST) + if form.is_valid(): + username = form.cleaned_data.get("username") + password = form.cleaned_data.get("password") + user = authenticate(username=username, password=password) + if user is not None: + login(request, user) + + # Log the login + from stiftung.audit import log_login + + log_login(request, user) + + # Determine redirect target + next_param = request.GET.get("next") or request.POST.get("next") + if not next_param or not next_param.startswith("/"): + next_param = reverse("stiftung:home") + + # Check if user has 2FA enabled - redirect to verification first + has_2fa = TOTPDevice.objects.filter(user=user, confirmed=True).exists() + if has_2fa: + from urllib.parse import urlencode + verify_url = reverse("stiftung:two_factor_verify") + "?" + urlencode({"next": next_param}) + return redirect(verify_url) + + messages.success(request, f"Willkommen zurück, {user.username}!") + return redirect(next_param) + else: + messages.error(request, "Ungültige Anmeldedaten.") + else: + messages.error(request, "Bitte korrigieren Sie die Fehler im Formular.") + else: + form = AuthenticationForm() + + context = {"form": form, "next": request.GET.get("next", "")} + + return render(request, "stiftung/login.html", context) + + +@login_required +def user_logout(request): + """User logout view""" + from django.contrib.auth import logout + + # Log the logout before actually logging out + from stiftung.audit import log_logout + + log_logout(request, request.user) + + username = request.user.username + logout(request) + + messages.success(request, f"Sie wurden erfolgreich abgemeldet, {username}.") + return redirect("stiftung:login") + + +# ============================================================================ +# LANDABRECHNUNGS VIEWS +# ============================================================================ + + +@login_required +def app_settings(request): + """Application settings management interface""" + + # Group settings by category + categories = {} + for setting in AppConfiguration.objects.filter(is_active=True).order_by( + "category", "order", "display_name" + ): + if setting.category not in categories: + categories[setting.category] = [] + categories[setting.category].append(setting) + + if request.method == "POST": + # Handle form submission + updated_count = 0 + for key, value in request.POST.items(): + if key.startswith("setting_"): + setting_key = key.replace("setting_", "") + try: + setting = AppConfiguration.objects.get( + key=setting_key, is_active=True + ) + if not setting.is_system and setting.value != value: + setting.value = value + setting.save() + updated_count += 1 + except AppConfiguration.DoesNotExist: + continue + + if updated_count > 0: + messages.success(request, f"Successfully updated {updated_count} settings!") + else: + messages.info(request, "No changes were made.") + + return redirect("stiftung:app_settings") + + context = { + "categories": categories, + "title": "Application Settings", + } + return render(request, "stiftung/app_settings.html", context) + + +# Unterstützungen Views (Destinataer-focused) +@login_required +def edit_help_box(request): + """Bearbeite oder erstelle eine Hilfs-Infobox""" + from stiftung.models import HelpBox + + # Nur root oder Superuser dürfen bearbeiten + if request.user.username != "root" and not request.user.is_superuser: + messages.error( + request, "Sie haben keine Berechtigung, Hilfsboxen zu bearbeiten." + ) + return redirect("stiftung:home") + + if request.method == "POST": + page_key = request.POST.get("page_key") + title = request.POST.get("title") + content = request.POST.get("content") + is_active = request.POST.get("is_active") == "on" + + if not page_key or not title or not content: + messages.error(request, "Alle Felder sind erforderlich.") + return redirect(request.META.get("HTTP_REFERER", "stiftung:home")) + + # Hilfsbox erstellen oder aktualisieren + help_box, created = HelpBox.objects.get_or_create( + page_key=page_key, + defaults={ + "title": title, + "content": content, + "is_active": is_active, + "created_by": request.user.username, + "updated_by": request.user.username, + }, + ) + + if not created: + # Existierende Hilfsbox aktualisieren + help_box.title = title + help_box.content = content + help_box.is_active = is_active + help_box.updated_by = request.user.username + help_box.save() + + messages.success(request, f'Hilfsbox "{title}" wurde aktualisiert.') + else: + messages.success(request, f'Hilfsbox "{title}" wurde erstellt.') + + # Zurück zur vorherigen Seite + return redirect(request.META.get("HTTP_REFERER", "stiftung:home")) + + # GET Request - Zeige Admin-Übersicht der Hilfsboxen + help_boxes = HelpBox.objects.all().order_by("page_key", "-updated_at") + + # Statistiken berechnen + active_count = help_boxes.filter(is_active=True).count() + inactive_count = help_boxes.filter(is_active=False).count() + existing_pages = set(help_boxes.values_list("page_key", flat=True)) + + # Verfügbare Seiten aus dem Model holen + available_pages = HelpBox.PAGE_CHOICES + + context = { + "help_boxes": help_boxes, + "active_count": active_count, + "inactive_count": inactive_count, + "existing_pages": existing_pages, + "available_pages": available_pages, + "title": "Hilfs-Infoboxen verwalten", + } + return render(request, "stiftung/help_boxes_admin.html", context) + + +# ============================================================================= +# Verpachtung Management Views (Standalone CRUD) +# ============================================================================= + +@login_required +def two_factor_setup(request): + """Setup or manage TOTP 2FA for the current user""" + + # Check if user already has TOTP device + device = TOTPDevice.objects.filter(user=request.user, confirmed=True).first() + static_device = StaticDevice.objects.filter(user=request.user).first() + + if device: + # User has 2FA enabled - show management options + context = { + 'has_2fa': True, + 'device': device, + 'backup_token_count': static_device.token_set.count() if static_device else 0, + 'title': 'Zwei-Faktor-Authentifizierung verwalten' + } + return render(request, 'stiftung/auth/two_factor_manage.html', context) + + # User doesn't have 2FA - show setup + # Get or create unconfirmed TOTP device + device, created = TOTPDevice.objects.get_or_create( + user=request.user, + name='default', + defaults={'confirmed': False} + ) + + if request.method == "POST": + token = request.POST.get('token', '').strip() + if device.verify_token(token): + device.confirmed = True + device.save() + + # Generate backup tokens + static_device = StaticDevice.objects.create( + user=request.user, + name='backup' + ) + + backup_tokens = [] + for _ in range(10): # Generate 10 backup codes + token_value = random_hex()[:8] # 8 character backup codes + StaticToken.objects.create( + device=static_device, + token=token_value + ) + backup_tokens.append(token_value) + + messages.success( + request, + "Zwei-Faktor-Authentifizierung wurde erfolgreich aktiviert! " + "Bitte speichern Sie Ihre Backup-Codes sicher." + ) + + return render(request, 'stiftung/auth/backup_tokens.html', { + 'backup_tokens': backup_tokens, + 'title': 'Backup-Codes' + }) + else: + messages.error(request, "Ungültiger Bestätigungscode. Bitte versuchen Sie es erneut.") + + # Generate QR code URL + qr_url = device.config_url + + context = { + 'device': device, + 'qr_url': qr_url, + 'title': 'Zwei-Faktor-Authentifizierung einrichten' + } + + return render(request, 'stiftung/auth/two_factor_setup.html', context) + + +@login_required +def two_factor_qr(request): + """Generate QR code for TOTP setup""" + device = TOTPDevice.objects.filter(user=request.user, confirmed=False).first() + + if not device: + return HttpResponse("Kein Setup-Device gefunden", status=404) + + # Generate QR code + qr = qrcode.QRCode(version=1, box_size=10, border=5) + qr.add_data(device.config_url) + qr.make(fit=True) + + img = qr.make_image(fill_color="black", back_color="white") + + response = HttpResponse(content_type="image/png") + img.save(response, "PNG") + + return response + + +@login_required +def two_factor_verify(request): + """Verify TOTP token during login process""" + if request.method == "POST": + token = request.POST.get('otp_token', '').strip() + + # Check TOTP devices + devices = TOTPDevice.objects.filter(user=request.user, confirmed=True) + for device in devices: + if device.verify_token(token): + request.session['2fa_verified'] = True + messages.success(request, "Zwei-Faktor-Authentifizierung erfolgreich.") + return redirect(request.GET.get('next', 'stiftung:home')) + + # Check static backup tokens + static_devices = StaticDevice.objects.filter(user=request.user) + for device in static_devices: + if device.verify_token(token): + request.session['2fa_verified'] = True + messages.success(request, "Backup-Code erfolgreich verwendet.") + return redirect(request.GET.get('next', 'stiftung:home')) + + messages.error(request, "Ungültiger Code. Bitte versuchen Sie es erneut.") + + context = { + 'title': 'Zwei-Faktor-Authentifizierung', + 'next': request.GET.get('next', '') + } + + return render(request, 'stiftung/auth/two_factor_verify.html', context) + + +@login_required +def two_factor_disable(request): + """Disable TOTP 2FA for the current user""" + if request.method == "POST": + password = request.POST.get('password', '') + + if request.user.check_password(password): + # Remove all TOTP devices + TOTPDevice.objects.filter(user=request.user).delete() + + # Remove all static backup token devices + StaticDevice.objects.filter(user=request.user).delete() + + messages.success( + request, + "Zwei-Faktor-Authentifizierung wurde erfolgreich deaktiviert." + ) + return redirect("stiftung:home") + else: + messages.error(request, "Ungültiges Passwort.") + + context = { + 'title': 'Zwei-Faktor-Authentifizierung deaktivieren' + } + + return render(request, 'stiftung/auth/two_factor_disable.html', context) + + +@login_required +def backup_tokens(request): + """Display or regenerate backup tokens""" + static_device = StaticDevice.objects.filter(user=request.user).first() + + if request.method == "POST" and 'regenerate' in request.POST: + password = request.POST.get('password', '') + + if request.user.check_password(password): + # Delete old tokens + if static_device: + static_device.delete() + + # Generate new backup tokens + static_device = StaticDevice.objects.create( + user=request.user, + name='backup' + ) + + backup_tokens = [] + for _ in range(10): # Generate 10 backup codes + token_value = random_hex()[:8] # 8 character backup codes + StaticToken.objects.create( + device=static_device, + token=token_value + ) + backup_tokens.append(token_value) + + messages.success( + request, + "Neue Backup-Codes wurden generiert. Bitte speichern Sie diese sicher." + ) + + context = { + 'backup_tokens': backup_tokens, + 'title': 'Neue Backup-Codes' + } + + return render(request, 'stiftung/auth/backup_tokens.html', context) + else: + messages.error(request, "Ungültiges Passwort.") + + # Show existing tokens (count only for security) + token_count = 0 + if static_device: + token_count = static_device.token_set.count() + + context = { + 'token_count': token_count, + 'has_tokens': token_count > 0, + 'title': 'Backup-Codes' + } + + return render(request, 'stiftung/auth/backup_tokens_manage.html', context) + + +# Geschichte (History) Views +from stiftung.models import GeschichteSeite, GeschichteBild +from stiftung.forms import GeschichteSeiteForm, GeschichteBildForm + + diff --git a/app/stiftung/views/unterstuetzungen.py b/app/stiftung/views/unterstuetzungen.py new file mode 100644 index 0000000..b4bbb5a --- /dev/null +++ b/app/stiftung/views/unterstuetzungen.py @@ -0,0 +1,1495 @@ +# views/unterstuetzungen.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 unterstuetzungen_list(request): + """Liste der Destinatärunterstützungen (Administration).""" + status = request.GET.get("status", "") + 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 [] + ) + + qs = DestinataerUnterstuetzung.objects.select_related( + "destinataer", "konto", "ausgezahlt_von", "wiederkehrend_von" + ).order_by("-faellig_am", "destinataer__nachname") + + if status: + qs = qs.filter(status=status) + + # Enhanced CSV export with field selection + if export_format == "csv": + return export_unterstuetzungen_csv(request, qs, selected_ids) + + # Enhanced PDF export with corporate identity + elif export_format == "pdf": + return export_unterstuetzungen_pdf(request, qs, selected_ids) + + # Get quarterly confirmation statistics + quarterly_stats = {} + total_quarterly = VierteljahresNachweis.objects.count() + for status_code, status_name in VierteljahresNachweis.STATUS_CHOICES: + count = VierteljahresNachweis.objects.filter(status=status_code).count() + quarterly_stats[status_code] = { + 'name': status_name, + 'count': count + } + + context = { + "unterstuetzungen": qs, + "status_filter": status, + "quarterly_stats": quarterly_stats, + "total_quarterly": total_quarterly, + } + return render(request, "stiftung/unterstuetzungen_list.html", context) + + +def export_unterstuetzungen_csv(request, queryset, selected_ids=None): + """Enhanced CSV export with field selection""" + import csv + from datetime import datetime + + from django.http import HttpResponse + + # If specific entries are selected, filter to only those + if selected_ids: + queryset = queryset.filter(id__in=selected_ids) + + # Get selected fields from request (handle both 'fields' and 'selected_fields' parameter names) + selected_fields_param = "" + if request.method == "POST": + # Try 'fields' first (new format), then 'selected_fields' (legacy) + fields_list = request.POST.getlist("fields") + if fields_list: + selected_fields_param = ",".join(fields_list) + else: + selected_fields_param = request.POST.get("selected_fields", "") + else: + # Try 'fields' first (new format), then 'selected_fields' (legacy) + fields_list = request.GET.getlist("fields") + if fields_list: + selected_fields_param = ",".join(fields_list) + else: + selected_fields_param = request.GET.get("selected_fields", "") + + selected_fields = selected_fields_param.split(",") if selected_fields_param else [] + + if not selected_fields: + # Default field set + selected_fields = [ + "destinataer_name", + "betrag", + "faellig_am", + "empfaenger_iban", + "verwendungszweck", + "status", + "empfaenger_name", + "beschreibung", + ] + + # Field definitions with headers and data extraction + field_definitions = { + # Core payment fields + "id": ("ID", lambda u: str(u.id)), + "betrag": ("Betrag (€)", lambda u: f"{u.betrag:.2f}"), + "faellig_am": ( + "Fällig am", + lambda u: u.faellig_am.strftime("%d.%m.%Y") if u.faellig_am else "", + ), + "status": ("Status", lambda u: u.get_status_display()), + "beschreibung": ("Beschreibung", lambda u: u.beschreibung or ""), + "ausgezahlt_am": ( + "Ausgezahlt am", + lambda u: u.ausgezahlt_am.strftime("%d.%m.%Y") if u.ausgezahlt_am else "", + ), + "erstellt_am": ( + "Erstellt am", + lambda u: u.erstellt_am.strftime("%d.%m.%Y %H:%M") if u.erstellt_am else "", + ), + "aktualisiert_am": ( + "Aktualisiert am", + lambda u: ( + u.aktualisiert_am.strftime("%d.%m.%Y %H:%M") + if u.aktualisiert_am + else "" + ), + ), + # Destinataer fields + "destinataer_name": ( + "Destinatär Name", + lambda u: u.destinataer.get_full_name() if u.destinataer else "", + ), + "destinataer_vorname": ( + "Vorname", + lambda u: u.destinataer.vorname if u.destinataer else "", + ), + "destinataer_nachname": ( + "Nachname", + lambda u: u.destinataer.nachname if u.destinataer else "", + ), + "familienzweig": ( + "Familienzweig", + lambda u: u.destinataer.familienzweig if u.destinataer else "", + ), + "geburtsdatum": ( + "Geburtsdatum", + lambda u: ( + u.destinataer.geburtsdatum.strftime("%d.%m.%Y") + if u.destinataer and u.destinataer.geburtsdatum + else "" + ), + ), + "email": ("E-Mail", lambda u: u.destinataer.email if u.destinataer else ""), + "telefon": ( + "Telefon", + lambda u: u.destinataer.telefon if u.destinataer else "", + ), + "destinataer_iban": ( + "Destinatär IBAN", + lambda u: u.destinataer.iban if u.destinataer else "", + ), + "strasse": ("Straße", lambda u: u.destinataer.strasse if u.destinataer else ""), + "plz": ("PLZ", lambda u: u.destinataer.plz if u.destinataer else ""), + "ort": ("Ort", lambda u: u.destinataer.ort if u.destinataer else ""), + "adresse": ( + "Adresse", + lambda u: ( + f"{u.destinataer.strasse}, {u.destinataer.plz} {u.destinataer.ort}".strip( + ", " + ) + if u.destinataer + else "" + ), + ), + "berufsgruppe": ( + "Berufsgruppe", + lambda u: u.destinataer.berufsgruppe if u.destinataer else "", + ), + "ausbildungsstand": ( + "Ausbildungsstand", + lambda u: u.destinataer.ausbildungsstand if u.destinataer else "", + ), + "institution": ( + "Institution", + lambda u: u.destinataer.institution if u.destinataer else "", + ), + "jaehrliches_einkommen": ( + "Jährliches Einkommen (€)", + lambda u: ( + f"{u.destinataer.jaehrliches_einkommen:.2f}" + if u.destinataer and u.destinataer.jaehrliches_einkommen + else "" + ), + ), + "haushaltsgroesse": ( + "Haushaltsgröße", + lambda u: ( + str(u.destinataer.haushaltsgroesse) + if u.destinataer and u.destinataer.haushaltsgroesse + else "" + ), + ), + "monatliche_bezuege": ( + "Monatliche Bezüge (€)", + lambda u: ( + f"{u.destinataer.monatliche_bezuege:.2f}" + if u.destinataer and u.destinataer.monatliche_bezuege + else "" + ), + ), + "vermoegen": ( + "Vermögen (€)", + lambda u: ( + f"{u.destinataer.vermoegen:.2f}" + if u.destinataer and u.destinataer.vermoegen + else "" + ), + ), + # Payment details + "empfaenger_iban": ("Empfänger IBAN", lambda u: u.empfaenger_iban or ""), + "empfaenger_name": ("Empfänger Name", lambda u: u.empfaenger_name or ""), + "verwendungszweck": ("Verwendungszweck", lambda u: u.verwendungszweck or ""), + # Account fields + "konto_name": ("Konto", lambda u: str(u.konto) if u.konto else ""), + "konto_bank": ("Bank", lambda u: u.konto.bank_name if u.konto else ""), + "konto_iban": ("Konto IBAN", lambda u: u.konto.iban if u.konto else ""), + # System fields + "ausgezahlt_von": ( + "Ausgezahlt von", + lambda u: u.ausgezahlt_von.get_full_name() if u.ausgezahlt_von else "", + ), + "ist_wiederkehrend": ( + "Wiederkehrend", + lambda u: "Ja" if u.wiederkehrend_von else "Nein", + ), + } + + # Create CSV response + timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") + filename = f"unterstuetzungen_{timestamp}.csv" + + response = HttpResponse(content_type="text/csv; charset=utf-8") + response["Content-Disposition"] = f'attachment; filename="{filename}"' + + writer = csv.writer(response, delimiter=";", quoting=csv.QUOTE_ALL) + + # Write headers + headers = [ + field_definitions[field][0] + for field in selected_fields + if field in field_definitions + ] + writer.writerow(headers) + + # Write data rows + for u in queryset: + row = [] + for field in selected_fields: + if field in field_definitions: + try: + value = field_definitions[field][1](u) + row.append(value) + except Exception: + row.append("") # Fallback for any errors + else: + row.append("") # Unknown field + writer.writerow(row) + + return response + + +def export_unterstuetzungen_pdf(request, queryset, selected_ids=None): + """Enhanced PDF export with corporate identity and field selection""" + # If specific entries are selected, filter to only those + if selected_ids: + queryset = queryset.filter(id__in=selected_ids) + + # Get selected fields from request (handle both 'fields' and 'selected_fields' parameter names) + selected_fields_param = "" + if request.method == "POST": + # Try 'fields' first (new format), then 'selected_fields' (legacy) + fields_list = request.POST.getlist("fields") + if fields_list: + selected_fields_param = ",".join(fields_list) + else: + selected_fields_param = request.POST.get("selected_fields", "") + else: + # Try 'fields' first (new format), then 'selected_fields' (legacy) + fields_list = request.GET.getlist("fields") + if fields_list: + selected_fields_param = ",".join(fields_list) + else: + selected_fields_param = request.GET.get("selected_fields", "") + + selected_fields = selected_fields_param.split(",") if selected_fields_param else [] + + if not selected_fields: + # Default field set for PDF (fewer fields than CSV for better readability) + selected_fields = [ + "destinataer_name", + "betrag", + "faellig_am", + "empfaenger_iban", + "verwendungszweck", + "status", + "beschreibung", + "ausgezahlt_am", + ] + + # Field definitions with display names (reuse from CSV but select PDF-appropriate subset) + field_definitions = { + # Core payment fields + "destinataer_name": "Destinatär", + "betrag": "Betrag (€)", + "faellig_am": "Fällig am", + "status": "Status", + "beschreibung": "Beschreibung", + "ausgezahlt_am": "Ausgezahlt am", + "erstellt_am": "Erstellt am", + "empfaenger_iban": "Empfänger IBAN", + "empfaenger_name": "Empfänger", + "verwendungszweck": "Verwendungszweck", + "konto_name": "Konto", + "ist_wiederkehrend": "Wiederkehrend", + } + + # Filter to only include fields that are both selected and defined + filtered_fields = { + k: v for k, v in field_definitions.items() if k in selected_fields + } + + # Prepare data with field extraction logic + data_for_pdf = [] + for item in queryset: + row_data = {} + for field_key in filtered_fields.keys(): + try: + if field_key == "destinataer_name": + row_data[field_key] = ( + item.destinataer.get_full_name() if item.destinataer else "" + ) + elif field_key == "betrag": + row_data[field_key] = f"€{item.betrag:.2f}" if item.betrag else "" + elif field_key == "faellig_am": + row_data[field_key] = ( + item.faellig_am.strftime("%d.%m.%Y") if item.faellig_am else "" + ) + elif field_key == "status": + row_data[field_key] = item.get_status_display() + elif field_key == "beschreibung": + row_data[field_key] = item.beschreibung or "" + elif field_key == "ausgezahlt_am": + row_data[field_key] = ( + item.ausgezahlt_am.strftime("%d.%m.%Y") + if item.ausgezahlt_am + else "" + ) + elif field_key == "erstellt_am": + row_data[field_key] = ( + item.erstellt_am.strftime("%d.%m.%Y") + if item.erstellt_am + else "" + ) + elif field_key == "empfaenger_iban": + row_data[field_key] = item.empfaenger_iban or "" + elif field_key == "empfaenger_name": + row_data[field_key] = item.empfaenger_name or "" + elif field_key == "verwendungszweck": + row_data[field_key] = item.verwendungszweck or "" + elif field_key == "konto_name": + row_data[field_key] = str(item.konto) if item.konto else "" + elif field_key == "ist_wiederkehrend": + row_data[field_key] = "Ja" if item.wiederkehrend_von else "Nein" + else: + # Generic field access + row_data[field_key] = getattr(item, field_key, "") or "" + except Exception: + row_data[field_key] = "" # Fallback for any errors + + data_for_pdf.append(row_data) + + # Use PDF generator + pdf_gen = get_pdf_generator() + return pdf_gen.export_data_list_pdf( + data=data_for_pdf, + fields_config=filtered_fields, + title="Unterstützungen Export", + filename_prefix="unterstuetzungen", + request_user=request.user, + ) + + +def export_foerderungen_csv(request, queryset, selected_ids=None): + """Enhanced CSV export for Förderungen with field selection""" + import csv + from datetime import datetime + + from django.http import HttpResponse + + # If specific entries are selected, filter to only those + if selected_ids: + queryset = queryset.filter(id__in=selected_ids) + + # Get selected fields from request (default to all if none specified) + selected_fields_param = ( + request.POST.get("selected_fields", "") + if request.method == "POST" + else request.GET.get("selected_fields", "") + ) + selected_fields = selected_fields_param.split(",") if selected_fields_param else [] + + if not selected_fields: + # Default field set + selected_fields = [ + "destinataer_name", + "jahr", + "betrag", + "kategorie", + "status", + "antragsdatum", + "beschreibung", + ] + + # Field definitions with headers and data extraction + field_definitions = { + # Core fields + "id": ("ID", lambda f: str(f.id)), + "destinataer_name": ( + "Destinatär Name", + lambda f: f.destinataer.get_full_name() if f.destinataer else "", + ), + "jahr": ("Jahr", lambda f: str(f.jahr)), + "betrag": ("Betrag (€)", lambda f: f"{f.betrag:.2f}"), + "kategorie": ("Kategorie", lambda f: f.get_kategorie_display()), + "status": ("Status", lambda f: f.get_status_display()), + "antragsdatum": ( + "Antragsdatum", + lambda f: f.antragsdatum.strftime("%d.%m.%Y") if f.antragsdatum else "", + ), + "bewilligungsdatum": ( + "Bewilligungsdatum", + lambda f: ( + f.bewilligungsdatum.strftime("%d.%m.%Y") if f.bewilligungsdatum else "" + ), + ), + "auszahlungsdatum": ( + "Auszahlungsdatum", + lambda f: ( + f.auszahlungsdatum.strftime("%d.%m.%Y") if f.auszahlungsdatum else "" + ), + ), + "beschreibung": ("Beschreibung", lambda f: f.beschreibung or ""), + "begruendung": ("Begründung", lambda f: f.begruendung or ""), + "verwendungsnachweis_datum": ( + "Verwendungsnachweis Datum", + lambda f: ( + f.verwendungsnachweis_datum.strftime("%d.%m.%Y") + if f.verwendungsnachweis_datum + else "" + ), + ), + "verwendungsnachweis_status": ( + "Verwendungsnachweis Status", + lambda f: ( + f.get_verwendungsnachweis_status_display() + if f.verwendungsnachweis_status + else "" + ), + ), + # Destinataer fields + "destinataer_vorname": ( + "Vorname", + lambda f: f.destinataer.vorname if f.destinataer else "", + ), + "destinataer_nachname": ( + "Nachname", + lambda f: f.destinataer.nachname if f.destinataer else "", + ), + "familienzweig": ( + "Familienzweig", + lambda f: f.destinataer.familienzweig if f.destinataer else "", + ), + "email": ("E-Mail", lambda f: f.destinataer.email if f.destinataer else ""), + "telefon": ( + "Telefon", + lambda f: f.destinataer.telefon if f.destinataer else "", + ), + "adresse": ( + "Adresse", + lambda f: ( + f"{f.destinataer.strasse}, {f.destinataer.plz} {f.destinataer.ort}".strip( + ", " + ) + if f.destinataer + else "" + ), + ), + "berufsgruppe": ( + "Berufsgruppe", + lambda f: f.destinataer.berufsgruppe if f.destinataer else "", + ), + "ausbildungsstand": ( + "Ausbildungsstand", + lambda f: f.destinataer.ausbildungsstand if f.destinataer else "", + ), + "institution": ( + "Institution", + lambda f: f.destinataer.institution if f.destinataer else "", + ), + # System fields + "erstellt_am": ( + "Erstellt am", + lambda f: f.erstellt_am.strftime("%d.%m.%Y %H:%M") if f.erstellt_am else "", + ), + "aktualisiert_am": ( + "Aktualisiert am", + lambda f: ( + f.aktualisiert_am.strftime("%d.%m.%Y %H:%M") + if f.aktualisiert_am + else "" + ), + ), + } + + # Create CSV response + timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") + filename = f"foerderungen_{timestamp}.csv" + + response = HttpResponse(content_type="text/csv; charset=utf-8") + response["Content-Disposition"] = f'attachment; filename="{filename}"' + + writer = csv.writer(response, delimiter=";", quoting=csv.QUOTE_ALL) + + # Write headers + headers = [ + field_definitions[field][0] + for field in selected_fields + if field in field_definitions + ] + writer.writerow(headers) + + # Write data rows + for f in queryset: + row = [] + for field in selected_fields: + if field in field_definitions: + try: + value = field_definitions[field][1](f) + row.append(value) + except Exception: + row.append("") # Fallback for any errors + else: + row.append("") # Unknown field + writer.writerow(row) + + return response + + +def export_foerderungen_pdf(request, queryset, selected_ids=None): + """Enhanced PDF export for Förderungen with corporate identity and field selection""" + # If specific entries are selected, filter to only those + if selected_ids: + queryset = queryset.filter(id__in=selected_ids) + + # Get selected fields from request (default to key fields if none specified) + selected_fields_param = ( + request.POST.get("selected_fields", "") + if request.method == "POST" + else request.GET.get("selected_fields", "") + ) + selected_fields = selected_fields_param.split(",") if selected_fields_param else [] + + if not selected_fields: + # Default field set for PDF (fewer fields than CSV for better readability) + selected_fields = [ + "destinataer_name", + "jahr", + "betrag", + "kategorie", + "status", + "antragsdatum", + ] + + # Field definitions with display names + field_definitions = { + "destinataer_name": "Destinatär", + "jahr": "Jahr", + "betrag": "Betrag (€)", + "kategorie": "Kategorie", + "status": "Status", + "antragsdatum": "Antragsdatum", + "bewilligungsdatum": "Bewilligungsdatum", + "auszahlungsdatum": "Auszahlungsdatum", + "beschreibung": "Beschreibung", + "begruendung": "Begründung", + "verwendungsnachweis_status": "Verwendungsnachweis", + } + + # Filter to only include fields that are both selected and defined + filtered_fields = { + k: v for k, v in field_definitions.items() if k in selected_fields + } + + # Prepare data with field extraction logic + data_for_pdf = [] + for item in queryset: + row_data = {} + for field_key in filtered_fields.keys(): + try: + if field_key == "destinataer_name": + row_data[field_key] = ( + item.destinataer.get_full_name() if item.destinataer else "" + ) + elif field_key == "jahr": + row_data[field_key] = str(item.jahr) + elif field_key == "betrag": + row_data[field_key] = f"€{item.betrag:.2f}" if item.betrag else "" + elif field_key == "kategorie": + row_data[field_key] = item.get_kategorie_display() + elif field_key == "status": + row_data[field_key] = item.get_status_display() + elif field_key == "antragsdatum": + row_data[field_key] = ( + item.antragsdatum.strftime("%d.%m.%Y") + if item.antragsdatum + else "" + ) + elif field_key == "bewilligungsdatum": + row_data[field_key] = ( + item.bewilligungsdatum.strftime("%d.%m.%Y") + if item.bewilligungsdatum + else "" + ) + elif field_key == "auszahlungsdatum": + row_data[field_key] = ( + item.auszahlungsdatum.strftime("%d.%m.%Y") + if item.auszahlungsdatum + else "" + ) + elif field_key == "beschreibung": + row_data[field_key] = (item.beschreibung or "")[:100] + ( + "..." if len(item.beschreibung or "") > 100 else "" + ) + elif field_key == "begruendung": + row_data[field_key] = (item.begruendung or "")[:100] + ( + "..." if len(item.begruendung or "") > 100 else "" + ) + elif field_key == "verwendungsnachweis_status": + row_data[field_key] = ( + item.get_verwendungsnachweis_status_display() + if item.verwendungsnachweis_status + else "" + ) + else: + # Generic field access + row_data[field_key] = getattr(item, field_key, "") or "" + except Exception: + row_data[field_key] = "" # Fallback for any errors + + data_for_pdf.append(row_data) + + # Use PDF generator + pdf_gen = get_pdf_generator() + return pdf_gen.export_data_list_pdf( + data=data_for_pdf, + fields_config=filtered_fields, + title="Förderungen Export", + filename_prefix="foerderungen", + request_user=request.user, + ) + + +@login_required +def unterstuetzung_edit(request, pk): + obj = get_object_or_404(DestinataerUnterstuetzung, pk=pk) + if request.method == "POST": + form = DestinataerUnterstuetzungForm(request.POST, instance=obj) + if form.is_valid(): + form.save() + messages.success(request, "Unterstützung aktualisiert.") + return redirect("stiftung:unterstuetzungen_list") + else: + form = DestinataerUnterstuetzungForm(instance=obj) + return render( + request, + "stiftung/unterstuetzung_form.html", + {"form": form, "title": "Unterstützung bearbeiten"}, + ) + + +@login_required +def unterstuetzung_delete(request, pk): + obj = get_object_or_404(DestinataerUnterstuetzung, pk=pk) + + # Check if this will also delete the recurring template + will_delete_template = False + if obj.wiederkehrend_von: + andere_zahlungen = ( + DestinataerUnterstuetzung.objects.filter( + wiederkehrend_von=obj.wiederkehrend_von + ) + .exclude(pk=pk) + .exists() + ) + will_delete_template = not andere_zahlungen + + if request.method == "POST": + # Check if this support payment is linked to a recurring payment template + wiederkehrend_template = obj.wiederkehrend_von + + # Delete the support payment + obj.delete() + + # If this was generated from a recurring template and there are no other + # payments from this template, delete the template too + if wiederkehrend_template: + # Check if there are other payments from this recurring template + andere_zahlungen = DestinataerUnterstuetzung.objects.filter( + wiederkehrend_von=wiederkehrend_template + ).exists() + + # If no other payments exist from this template, delete the template too + if not andere_zahlungen: + wiederkehrend_template.delete() + messages.success( + request, + "Unterstützung und wiederkehrende Zahlungsvorlage gelöscht.", + ) + else: + messages.success(request, "Unterstützung gelöscht.") + else: + messages.success(request, "Unterstützung gelöscht.") + + return redirect("stiftung:unterstuetzungen_list") + + context = { + "obj": obj, + "will_delete_template": will_delete_template, + } + return render(request, "stiftung/unterstuetzung_confirm_delete.html", context) + + +@login_required +def unterstuetzungen_all(request): + """List all support payments - destinataer-focused view""" + status = request.GET.get("status") + destinataer_id = request.GET.get("destinataer") + export = request.GET.get("format", "") + selected_ids = ( + request.POST.getlist("selected_entries") if request.method == "POST" else [] + ) + + unterstuetzungen = DestinataerUnterstuetzung.objects.select_related( + "destinataer", "konto", "ausgezahlt_von", "wiederkehrend_von" + ).order_by("-faellig_am") + + # Filtering + if status: + unterstuetzungen = unterstuetzungen.filter(status=status) + if destinataer_id: + unterstuetzungen = unterstuetzungen.filter(destinataer_id=destinataer_id) + + # Enhanced CSV export with field selection + if export == "csv": + return export_unterstuetzungen_csv(request, unterstuetzungen, selected_ids) + + # PDF export (simple table via WeasyPrint; graceful fallback if missing) + if export == "pdf": + try: + from django.template.loader import render_to_string + from weasyprint import HTML + + html = render_to_string( + "stiftung/unterstuetzungen_pdf.html", + {"unterstuetzungen": unterstuetzungen}, + ) + from django.http import HttpResponse + + pdf = HTML(string=html).write_pdf() + resp = HttpResponse(pdf, content_type="application/pdf") + resp["Content-Disposition"] = "inline; filename=unterstuetzungen.pdf" + return resp + except Exception: + pass + + # Statistics + total_betrag = unterstuetzungen.aggregate(total=Sum("betrag"))["total"] or 0 + + # Get quarterly confirmation statistics + quarterly_stats = {} + total_quarterly = VierteljahresNachweis.objects.count() + for status_code, status_name in VierteljahresNachweis.STATUS_CHOICES: + count = VierteljahresNachweis.objects.filter(status=status_code).count() + quarterly_stats[status_code] = { + 'name': status_name, + 'count': count + } + + # Available destinataer for filter + destinataer = Destinataer.objects.all().order_by("nachname", "vorname") + + context = { + "page_obj": unterstuetzungen, # Use directly for now (pagination can be added later) + "unterstuetzungen": unterstuetzungen, + "title": "Alle Unterstützungen", + "status_filter": status, + "total_betrag": total_betrag, + "quarterly_stats": quarterly_stats, + "total_quarterly": total_quarterly, + "status_choices": DestinataerUnterstuetzung.STATUS_CHOICES, + "destinataer": destinataer, + } + return render(request, "stiftung/unterstuetzungen_all.html", context) + + +@login_required +def unterstuetzung_create(request): + """Create a new support payment""" + # Get destinataer from URL parameter if provided + destinataer_id = request.GET.get("destinataer") + initial = {} + if destinataer_id: + initial["destinataer"] = destinataer_id + # Pre-populate IBAN and name if destinataer is specified + try: + destinataer = Destinataer.objects.get(pk=destinataer_id) + if hasattr(destinataer, "iban") and destinataer.iban: + initial["empfaenger_iban"] = destinataer.iban + initial["empfaenger_name"] = destinataer.get_full_name() + except Destinataer.DoesNotExist: + pass + + if request.method == "POST": + form = UnterstuetzungForm(request.POST) + if form.is_valid(): + ist_wiederkehrend = form.cleaned_data.get("ist_wiederkehrend", False) + + if ist_wiederkehrend: + # Create recurring payment template + wiederkehrend = UnterstuetzungWiederkehrend.objects.create( + destinataer=form.cleaned_data["destinataer"], + konto=form.cleaned_data["konto"], + betrag=form.cleaned_data["betrag"], + intervall=form.cleaned_data["intervall"], + beschreibung=form.cleaned_data["beschreibung"], + empfaenger_iban=form.cleaned_data["empfaenger_iban"], + empfaenger_name=form.cleaned_data["empfaenger_name"], + verwendungszweck=form.cleaned_data["verwendungszweck"], + erste_zahlung_am=form.cleaned_data["faellig_am"], + letzte_zahlung_am=form.cleaned_data.get("letzte_zahlung_am"), + naechste_generierung=form.cleaned_data["faellig_am"], + erstellt_von=request.user, + ) + + # Create the first payment + unterstuetzung = form.save(commit=False) + unterstuetzung.wiederkehrend_von = wiederkehrend + unterstuetzung.save() + + messages.success( + request, + f"Wiederkehrende Unterstützung für {unterstuetzung.destinataer} wurde erfolgreich erstellt. Die erste Zahlung ist am {unterstuetzung.faellig_am} fällig.", + ) + else: + # Create single payment + unterstuetzung = form.save() + messages.success( + request, + f"Unterstützung für {unterstuetzung.destinataer} wurde erfolgreich erstellt.", + ) + + return redirect("stiftung:unterstuetzung_detail", pk=unterstuetzung.pk) + else: + form = UnterstuetzungForm(initial=initial) + + context = { + "form": form, + "title": "Neue Unterstützung erstellen", + } + return render(request, "stiftung/unterstuetzung_form.html", context) + + +@login_required +def get_destinataer_info(request, destinataer_id): + """AJAX endpoint to get Destinataer IBAN and name information""" + try: + destinataer = Destinataer.objects.get(pk=destinataer_id) + data = { + "success": True, + "name": destinataer.get_full_name(), + "iban": getattr(destinataer, "iban", "") or "", + } + except Destinataer.DoesNotExist: + data = {"success": False, "error": "Destinataer not found"} + + return JsonResponse(data) + + +@login_required +def unterstuetzung_detail(request, pk): + """View support payment details""" + unterstuetzung = get_object_or_404(DestinataerUnterstuetzung, pk=pk) + + # Check if this payment can be marked as paid + can_mark_paid = unterstuetzung.can_be_marked_paid() + + context = { + "unterstuetzung": unterstuetzung, + "title": f"Unterstützung für {unterstuetzung.destinataer.get_full_name()}", + "can_mark_paid": can_mark_paid, + } + return render(request, "stiftung/unterstuetzung_detail.html", context) + + +@login_required +def unterstuetzung_mark_paid(request, pk): + """Mark a support payment as paid""" + unterstuetzung = get_object_or_404(DestinataerUnterstuetzung, pk=pk) + + if not unterstuetzung.can_be_marked_paid(): + messages.error( + request, "Diese Unterstützung kann nicht als bezahlt markiert werden." + ) + return redirect("stiftung:unterstuetzung_detail", pk=pk) + + if request.method == "POST": + form = UnterstuetzungMarkAsPaidForm(request.POST) + if form.is_valid(): + unterstuetzung.status = "ausgezahlt" + unterstuetzung.ausgezahlt_am = form.cleaned_data["ausgezahlt_am"] + unterstuetzung.ausgezahlt_von = request.user + + # Add optional note to description + bemerkung = form.cleaned_data.get("bemerkung") + if bemerkung: + if unterstuetzung.beschreibung: + unterstuetzung.beschreibung += f" | Zahlung: {bemerkung}" + else: + unterstuetzung.beschreibung = f"Zahlung: {bemerkung}" + + unterstuetzung.save() + messages.success(request, f"Unterstützung wurde als bezahlt markiert.") + return redirect("stiftung:unterstuetzung_detail", pk=pk) + else: + form = UnterstuetzungMarkAsPaidForm() + + context = { + "form": form, + "unterstuetzung": unterstuetzung, + "title": f"Zahlung markieren - {unterstuetzung.destinataer.get_full_name()}", + } + return render(request, "stiftung/unterstuetzung_mark_paid.html", context) + + +@login_required +def wiederkehrende_unterstuetzungen(request): + """List all recurring support payment templates""" + from django.db.models import Count + + # Check for cleanup request + if request.GET.get("cleanup") == "1": + # Find templates with no associated payments + verwaiste_templates = UnterstuetzungWiederkehrend.objects.annotate( + zahlung_count=Count("destinataerunterstuetzung") + ).filter(zahlung_count=0) + + if verwaiste_templates.exists(): + anzahl_geloescht = verwaiste_templates.count() + template_namen = list( + verwaiste_templates.values_list("destinataer__nachname", flat=True) + ) + verwaiste_templates.delete() + messages.success( + request, + f'{anzahl_geloescht} verwaiste Zahlungsvorlagen bereinigt: {", ".join(template_namen[:5])}{"..." if len(template_namen) > 5 else ""}', + ) + else: + messages.info(request, "Keine verwaisten Zahlungsvorlagen gefunden.") + + return redirect("stiftung:wiederkehrende_unterstuetzungen") + + # Get all templates with payment counts + templates = ( + UnterstuetzungWiederkehrend.objects.select_related("destinataer", "konto") + .annotate(aktive_zahlungen=Count("destinataerunterstuetzung")) + .all() + ) + + context = { + "templates": templates, + "title": "Wiederkehrende Unterstützungen", + } + return render(request, "stiftung/wiederkehrende_unterstuetzungen.html", context) + + +@login_required +def quarterly_confirmation_update(request, pk): + """Update quarterly confirmation for destinataer""" + nachweis = get_object_or_404(VierteljahresNachweis, pk=pk) + + if request.method == "POST": + form = VierteljahresNachweisForm(request.POST, request.FILES, instance=nachweis) + if form.is_valid(): + quarterly_proof = form.save(commit=False) + + # Calculate current status before saving + old_status = nachweis.status + + # Auto-update status based on completion + if quarterly_proof.is_complete(): + if quarterly_proof.status in ['offen', 'teilweise']: + quarterly_proof.status = 'eingereicht' + quarterly_proof.eingereicht_am = timezone.now() + else: + # If not complete, set to teilweise if some fields are filled + has_partial_data = ( + quarterly_proof.einkommenssituation_bestaetigt or + quarterly_proof.vermogenssituation_bestaetigt or + quarterly_proof.studiennachweis_eingereicht + ) + if has_partial_data and quarterly_proof.status == 'offen': + quarterly_proof.status = 'teilweise' + + quarterly_proof.save() + + # Try to create automatic support payment if complete + if quarterly_proof.is_complete() and quarterly_proof.status == 'eingereicht': + support_payment = create_quarterly_support_payment(quarterly_proof) + if support_payment: + messages.success( + request, + f"Automatische Unterstützung über {support_payment.betrag}€ für {support_payment.destinataer.get_full_name()} wurde erstellt (fällig am {support_payment.faellig_am.strftime('%d.%m.%Y')})." + ) + else: + # Log why payment wasn't created + reasons = [] + if not quarterly_proof.destinataer.vierteljaehrlicher_betrag: + reasons.append("kein vierteljährlicher Betrag hinterlegt") + if not quarterly_proof.destinataer.iban: + reasons.append("keine IBAN hinterlegt") + if not quarterly_proof.destinataer.standard_konto and not StiftungsKonto.objects.exists(): + reasons.append("kein Auszahlungskonto verfügbar") + + if reasons: + messages.warning( + request, + f"Automatische Unterstützung konnte nicht erstellt werden: {', '.join(reasons)}" + ) + + # Debug message to see what happened + status_changed = old_status != quarterly_proof.status + status_msg = f" (Status: {old_status} → {quarterly_proof.status})" if status_changed else f" (Status: {quarterly_proof.status})" + + messages.success( + request, + f"Vierteljahresnachweis für {nachweis.destinataer.get_full_name()} " + f"({nachweis.jahr} Q{nachweis.quartal}) wurde erfolgreich aktualisiert{status_msg}." + ) + return redirect("stiftung:destinataer_detail", pk=nachweis.destinataer.pk) + else: + # Add form errors to messages + for field, errors in form.errors.items(): + for error in errors: + messages.error(request, f"Fehler in {field}: {error}") + + # If GET request or form errors, redirect back to destinataer detail + return redirect("stiftung:destinataer_detail", pk=nachweis.destinataer.pk) + + +def create_quarterly_support_payment(nachweis): + """ + Get or create a single support payment for this quarterly confirmation + Ensures only one payment exists per destinataer per quarter + """ + from datetime import date + destinataer = nachweis.destinataer + + # Check if all requirements are met + if not nachweis.is_complete(): + return None + + # Check if destinataer has required payment info + if not destinataer.vierteljaehrlicher_betrag or destinataer.vierteljaehrlicher_betrag <= 0: + return None + + if not destinataer.iban: + return None + + # Search for existing payment using payment due date from quarterly confirmation + # This is more accurate than using quarter date range, especially for Q1 (Dec 15 prev year) + payment_due_date = nachweis.zahlung_faelligkeitsdatum + if not payment_due_date: + # Fallback: calculate if not set + if nachweis.quartal == 1: + payment_due_date = date(nachweis.jahr - 1, 12, 15) + elif nachweis.quartal == 2: + payment_due_date = date(nachweis.jahr, 3, 15) + elif nachweis.quartal == 3: + payment_due_date = date(nachweis.jahr, 6, 15) + else: # Q4 + payment_due_date = date(nachweis.jahr, 9, 15) + + # Search for existing payment - match by payment due date and description + # Use a date range around the due date (±30 days) to catch any variations + from datetime import timedelta + date_start = payment_due_date - timedelta(days=30) + date_end = payment_due_date + timedelta(days=30) + + existing_payment = DestinataerUnterstuetzung.objects.filter( + destinataer=destinataer, + faellig_am__gte=date_start, + faellig_am__lte=date_end + ).filter( + Q(beschreibung__contains=f"Q{nachweis.quartal}/{nachweis.jahr}") | + Q(beschreibung__contains=f"Vierteljährliche Unterstützung Q{nachweis.quartal}/{nachweis.jahr}") + ).first() + + if existing_payment: + # Update existing payment to ensure it matches current requirements + existing_payment.betrag = destinataer.vierteljaehrlicher_betrag + existing_payment.empfaenger_iban = destinataer.iban + existing_payment.empfaenger_name = destinataer.get_full_name() + existing_payment.verwendungszweck = f"Vierteljährliche Unterstützung Q{nachweis.quartal}/{nachweis.jahr} - {destinataer.get_full_name()}" + existing_payment.beschreibung = f"Vierteljährliche Unterstützung Q{nachweis.quartal}/{nachweis.jahr} (automatisch erstellt)" + existing_payment.save() + return existing_payment + + # Get default payment account + default_konto = destinataer.standard_konto + if not default_konto: + # Try to get any StiftungsKonto + default_konto = StiftungsKonto.objects.first() + if not default_konto: + return None + + # Use payment due date from quarterly confirmation (already calculated by model) + # This ensures consistency with zahlung_faelligkeitsdatum + payment_due_date = nachweis.zahlung_faelligkeitsdatum + if not payment_due_date: + # Fallback: calculate if not set (should not happen, but safety check) + if nachweis.quartal == 1: # Q1 payment due December 15 of previous year + payment_due_date = date(nachweis.jahr - 1, 12, 15) + elif nachweis.quartal == 2: # Q2 payment due March 15 + payment_due_date = date(nachweis.jahr, 3, 15) + elif nachweis.quartal == 3: # Q3 payment due June 15 + payment_due_date = date(nachweis.jahr, 6, 15) + else: # Q4 payment due September 15 + payment_due_date = date(nachweis.jahr, 9, 15) + + # Create the support payment + payment = DestinataerUnterstuetzung.objects.create( + destinataer=destinataer, + konto=default_konto, + betrag=destinataer.vierteljaehrlicher_betrag, + faellig_am=payment_due_date, + status='geplant', + beschreibung=f"Vierteljährliche Unterstützung Q{nachweis.quartal}/{nachweis.jahr} (automatisch erstellt)", + empfaenger_iban=destinataer.iban, + empfaenger_name=destinataer.get_full_name(), + verwendungszweck=f"Vierteljährliche Unterstützung Q{nachweis.quartal}/{nachweis.jahr} - {destinataer.get_full_name()}", + erstellt_am=timezone.now(), + aktualisiert_am=timezone.now() + ) + + return payment + + +@login_required +def quarterly_confirmation_create(request, destinataer_id): + """Create a new quarterly confirmation for a destinataer""" + import logging + logger = logging.getLogger(__name__) + logger.info(f"quarterly_confirmation_create called: method={request.method}, destinataer_id={destinataer_id}") + + destinataer = get_object_or_404(Destinataer, pk=destinataer_id) + + if request.method == "POST": + logger.info(f"POST data: {request.POST}") + jahr = request.POST.get('jahr') + quartal = request.POST.get('quartal') + + if jahr and quartal: + try: + jahr = int(jahr) + quartal = int(quartal) + + # Check if this quarter already exists + existing = VierteljahresNachweis.objects.filter( + destinataer=destinataer, + jahr=jahr, + quartal=quartal + ).exists() + + if existing: + messages.warning( + request, + f"Quartal {jahr} Q{quartal} existiert bereits für {destinataer.get_full_name()}." + ) + else: + # Create new quarterly confirmation + try: + nachweis = VierteljahresNachweis.objects.create( + destinataer=destinataer, + jahr=jahr, + quartal=quartal, + studiennachweis_erforderlich=True, # Always required now + ) + # Deadlines are automatically set by the model's save() method + # studiennachweis_faelligkeitsdatum: semester-based (Q1/Q2→Mar 15, Q3/Q4→Sep 15) + # zahlung_faelligkeitsdatum: quarterly advance (Q1→Dec 15 prev year, Q2→Mar 15, Q3→Jun 15, Q4→Sep 15) + + # Refresh from database to ensure deadlines are set + nachweis.refresh_from_db() + + studiennachweis_str = nachweis.studiennachweis_faelligkeitsdatum.strftime('%d.%m.%Y') if nachweis.studiennachweis_faelligkeitsdatum else "Nicht gesetzt" + zahlung_str = nachweis.zahlung_faelligkeitsdatum.strftime('%d.%m.%Y') if nachweis.zahlung_faelligkeitsdatum else "Nicht gesetzt" + + messages.success( + request, + f"Quartal {jahr} Q{quartal} wurde erfolgreich für {destinataer.get_full_name()} erstellt. " + f"Studiennachweis fällig: {studiennachweis_str}, " + f"Zahlung fällig: {zahlung_str}." + ) + except Exception as e: + from django.db import IntegrityError + if isinstance(e, IntegrityError): + messages.error( + request, + f"Quartal {jahr} Q{quartal} existiert bereits für {destinataer.get_full_name()}." + ) + else: + messages.error( + request, + f"Fehler beim Erstellen des Quartals: {str(e)}" + ) + + except (ValueError, TypeError): + messages.error(request, "Ungültige Jahr- oder Quartalswerte.") + else: + messages.error(request, "Jahr und Quartal müssen angegeben werden.") + + return redirect("stiftung:destinataer_detail", pk=destinataer.pk) + + +@login_required +def quarterly_confirmation_edit(request, pk): + """Standalone edit view for quarterly confirmation""" + nachweis = get_object_or_404(VierteljahresNachweis, pk=pk) + + if request.method == "POST": + form = VierteljahresNachweisForm(request.POST, request.FILES, instance=nachweis) + if form.is_valid(): + quarterly_proof = form.save(commit=False) + + # Calculate current status before saving + old_status = nachweis.status + + # Auto-update status based on completion + if quarterly_proof.is_complete(): + if quarterly_proof.status in ['offen', 'teilweise']: + quarterly_proof.status = 'eingereicht' + quarterly_proof.eingereicht_am = timezone.now() + else: + # If not complete, set to teilweise if some fields are filled + has_partial_data = ( + quarterly_proof.einkommenssituation_bestaetigt or + quarterly_proof.vermogenssituation_bestaetigt or + quarterly_proof.studiennachweis_eingereicht + ) + if has_partial_data and quarterly_proof.status == 'offen': + quarterly_proof.status = 'teilweise' + + quarterly_proof.save() + + # Try to create automatic support payment if complete + if quarterly_proof.is_complete() and quarterly_proof.status == 'eingereicht': + support_payment = create_quarterly_support_payment(quarterly_proof) + if support_payment: + messages.success( + request, + f"Automatische Unterstützung über {support_payment.betrag}€ für {support_payment.destinataer.get_full_name()} wurde erstellt (fällig am {support_payment.faellig_am.strftime('%d.%m.%Y')})." + ) + else: + # Log why payment wasn't created + reasons = [] + if not quarterly_proof.destinataer.vierteljaehrlicher_betrag: + reasons.append("kein vierteljährlicher Betrag hinterlegt") + if not quarterly_proof.destinataer.iban: + reasons.append("keine IBAN hinterlegt") + if not quarterly_proof.destinataer.standard_konto and not StiftungsKonto.objects.exists(): + reasons.append("kein Auszahlungskonto verfügbar") + + if reasons: + messages.warning( + request, + f"Automatische Unterstützung konnte nicht erstellt werden: {', '.join(reasons)}" + ) + + # Debug message to see what happened + status_changed = old_status != quarterly_proof.status + status_msg = f" (Status: {old_status} → {quarterly_proof.status})" if status_changed else f" (Status: {quarterly_proof.status})" + + messages.success( + request, + f"Vierteljahresnachweis für {nachweis.destinataer.get_full_name()} " + f"({nachweis.jahr} Q{nachweis.quartal}) wurde erfolgreich aktualisiert{status_msg}." + ) + return redirect("stiftung:destinataer_detail", pk=nachweis.destinataer.pk) + else: + # Add form errors to messages + for field, errors in form.errors.items(): + for error in errors: + messages.error(request, f"Fehler in {field}: {error}") + else: + form = VierteljahresNachweisForm(instance=nachweis) + + context = { + 'form': form, + 'nachweis': nachweis, + 'destinataer': nachweis.destinataer, + 'title': f'Vierteljahresnachweis bearbeiten - {nachweis.destinataer.get_full_name()} {nachweis.jahr} Q{nachweis.quartal}', + } + return render(request, 'stiftung/quarterly_confirmation_edit.html', context) + + +@login_required +def quarterly_confirmation_approve(request, pk): + """Approve quarterly confirmation (staff only)""" + if not request.user.is_staff: + messages.error(request, "Sie haben keine Berechtigung für diese Aktion.") + return redirect("stiftung:destinataer_list") + + nachweis = get_object_or_404(VierteljahresNachweis, pk=pk) + + if request.method == "POST": + if nachweis.status in ['eingereicht', 'geprueft']: + # Check if we need to create or update support payment + related_payment = nachweis.get_related_support_payment() + + if nachweis.status == 'eingereicht' or (nachweis.status == 'geprueft' and not related_payment): + # Approve the quarterly confirmation + nachweis.status = 'geprueft' + nachweis.geprueft_am = timezone.now() + nachweis.geprueft_von = request.user + nachweis.save() + + # Auto-approve next quarter for semester-based tracking (Q1→Q2, Q3→Q4) + auto_approved_next = nachweis.auto_approve_next_quarter() + if auto_approved_next: + messages.info( + request, + f"Q{auto_approved_next.quartal} wurde automatisch auf Basis der Q{nachweis.quartal}-Nachweise freigegeben." + ) + + # Handle support payment - create if missing, update if exists + # Check if payment already exists before calling create_quarterly_support_payment() + payment_existed_before = related_payment is not None + + # Use create_quarterly_support_payment() which handles both cases (find existing or create new) + related_payment = create_quarterly_support_payment(nachweis) + if related_payment: + # Update status to 'in_bearbeitung' for both new and existing payments + old_status = related_payment.status + related_payment.status = 'in_bearbeitung' + related_payment.aktualisiert_am = timezone.now() + related_payment.save() + + if payment_existed_before: + messages.success( + request, + f"Vierteljahresnachweis freigegeben und bestehende Unterstützung für {nachweis.destinataer.get_full_name()} " + f"({nachweis.jahr} Q{nachweis.quartal}) wurde von '{old_status}' auf 'in Bearbeitung' aktualisiert." + ) + else: + messages.success( + request, + f"Vierteljahresnachweis freigegeben und neue Unterstützung über {related_payment.betrag}€ für {nachweis.destinataer.get_full_name()} " + f"({nachweis.jahr} Q{nachweis.quartal}) wurde erstellt." + ) + else: + messages.warning( + request, + f"Vierteljahresnachweis freigegeben, aber Unterstützung konnte nicht erstellt werden. " + f"Bitte prüfen Sie die Einstellungen für {nachweis.destinataer.get_full_name()}." + ) + else: + messages.error( + request, + "Nur eingereichte oder bereits genehmigte Nachweise können verarbeitet werden." + ) + + return redirect("stiftung:destinataer_detail", pk=nachweis.destinataer.pk) + + +@login_required +def quarterly_confirmation_reset(request, pk): + """Reset quarterly confirmation status (staff only)""" + if not request.user.is_staff: + messages.error(request, "Sie haben keine Berechtigung für diese Aktion.") + return redirect("stiftung:destinataer_list") + + nachweis = get_object_or_404(VierteljahresNachweis, pk=pk) + + if request.method == "POST": + if nachweis.status in ['geprueft', 'eingereicht']: + # Reset the quarterly confirmation status + nachweis.status = 'eingereicht' if nachweis.is_complete() else 'teilweise' + nachweis.geprueft_am = None + nachweis.geprueft_von = None + nachweis.aktualisiert_am = timezone.now() + nachweis.save() + + # Reset related support payment status if it exists + related_payment = nachweis.get_related_support_payment() + if related_payment and related_payment.status == 'in_bearbeitung': + related_payment.status = 'geplant' + related_payment.aktualisiert_am = timezone.now() + related_payment.save() + + messages.success( + request, + f"Vierteljahresnachweis und zugehörige Unterstützung für {nachweis.destinataer.get_full_name()} " + f"({nachweis.jahr} Q{nachweis.quartal}) wurden zurückgesetzt." + ) + else: + messages.success( + request, + f"Vierteljahresnachweis für {nachweis.destinataer.get_full_name()} " + f"({nachweis.jahr} Q{nachweis.quartal}) wurde zurückgesetzt." + ) + else: + messages.error( + request, + "Nur genehmigte oder eingereichte Nachweise können zurückgesetzt werden." + ) + + return redirect("stiftung:destinataer_detail", pk=nachweis.destinataer.pk) + + +# Two-Factor Authentication Views + diff --git a/app/stiftung/views/veranstaltung.py b/app/stiftung/views/veranstaltung.py new file mode 100644 index 0000000..1199854 --- /dev/null +++ b/app/stiftung/views/veranstaltung.py @@ -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, + })