import csv import io import json import os import time from datetime import datetime, timedelta from decimal import Decimal 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 JsonResponse from django.shortcuts import get_object_or_404, redirect, render from django.utils import timezone from django.views.decorators.csrf import csrf_exempt from rest_framework.decorators import api_view from rest_framework.response import Response from .models import (AppConfiguration, CSVImport, Destinataer, DestinataerUnterstuetzung, DokumentLink, Foerderung, Land, LandAbrechnung, LandVerpachtung, Paechter, Person, StiftungsKonto, UnterstuetzungWiederkehrend, VierteljahresNachweis) 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 .forms import (DestinataerForm, DestinataerNotizForm, DestinataerUnterstuetzungForm, DokumentLinkForm, FoerderungForm, LandForm, PaechterForm, PersonForm, UnterstuetzungForm, UnterstuetzungMarkAsPaidForm, VierteljahresNachweisForm) def home(request): """Home page for the Stiftungsverwaltung application""" return render( request, "stiftung/home.html", {"title": "Stiftungsverwaltung", "description": "Foundation Management System"}, ) @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.rstrip("/api") 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) paginator = Paginator(destinataere, 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, "berufsgruppe_filter": berufsgruppe_filter, "aktiv_filter": aktiv_filter, "familienzweig_choices": Destinataer.FAMILIENZWIG_CHOICES, "berufsgruppe_choices": Destinataer.BERUFSGRUPPE_CHOICES, "sort": sort, "dir": 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') # 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 @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.rstrip("/api") 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 # Dashboard Views @login_required def dashboard(request): # Foerderung statistics (Person statistics removed - was legacy Verpachtung system) total_foerderungen = Foerderung.objects.aggregate(total=Sum("betrag"))["total"] or 0 # Land statistics total_land = Land.objects.count() active_land = Land.objects.filter(aktiv=True).count() total_flaeche = Land.objects.aggregate(total=Sum("groesse_qm"))["total"] or 0 # Calculate total verpachtet from active verpachtungen total_verpachtet = ( LandVerpachtung.objects.filter(status="aktiv").aggregate( total=Sum("verpachtete_flaeche") )["total"] or 0 ) # Verpachtung statistics total_verpachtungen = LandVerpachtung.objects.count() active_verpachtungen = LandVerpachtung.objects.filter(status="aktiv").count() total_pachtzins = ( LandVerpachtung.objects.filter(status="aktiv").aggregate( total=Sum("pachtzins_pauschal") )["total"] or 0 ) # Recent activities recent_lands = Land.objects.order_by("-erstellt_am")[:5] recent_verpachtungen = LandVerpachtung.objects.select_related( "land", "paechter" ).order_by("-erstellt_am")[:5] # Dokumentenübersicht dokumente_uebersicht = DokumentLink.objects.all().order_by("-id")[:10] # Verfügbare Paperless-Dokumente für Dashboard available_paperless_docs = [] from stiftung.utils.config import get_paperless_config config = get_paperless_config() url = config["api_url"] token = config["api_token"] if url and token: try: base_url = url.rstrip("/api") 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_paperless_docs.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 (neueste zuerst) available_paperless_docs.sort(key=lambda x: x["created_date"], reverse=True) except Exception: pass context = { # Person statistics removed - was legacy Verpachtung system "total_foerderungen": total_foerderungen, "total_land": total_land, "active_land": active_land, "total_flaeche": total_flaeche, "total_verpachtet": total_verpachtet, "total_verpachtungen": total_verpachtungen, "active_verpachtungen": active_verpachtungen, "total_pachtzins": total_pachtzins, "recent_lands": recent_lands, "recent_verpachtungen": recent_verpachtungen, "dokumente_uebersicht": dokumente_uebersicht, "available_paperless_docs": available_paperless_docs, } return render(request, "stiftung/dashboard.html", context) # 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.rstrip("/api") 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.rstrip("/api") 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.rstrip("/api") 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.rstrip("/api") 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" redirect_args = [rentmeister_id] 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 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""" try: backup_job = BackupJob.objects.get(id=backup_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) if backup_job.created_by != request.user 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 from django.utils import timezone backup_job.status = "cancelled" backup_job.completed_at = timezone.now() backup_job.error_message = f"Abgebrochen von {request.user.username}" backup_job.save() # Log the cancellation from stiftung.audit import log_system_action log_system_action( request=request, action="backup_cancel", description=f"Backup-Job abgebrochen: {backup_job.get_backup_type_display()}", details={"backup_job_id": str(backup_job.id)}, ) messages.success(request, f"Backup-Job wurde abgebrochen.") except BackupJob.DoesNotExist: messages.error(request, "Backup-Job nicht gefunden.") except Exception as e: 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:dashboard") 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) messages.success(request, f"Willkommen zurück, {user.username}!") # Redirect to safe next URL path or dashboard next_param = request.GET.get("next") or request.POST.get("next") if next_param and next_param.startswith("/"): return redirect(next_param) return redirect("stiftung:dashboard") 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:dashboard") 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:dashboard")) # 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:dashboard")) # 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 """ 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 # Calculate quarter date range for more robust search quarter_start = datetime(nachweis.jahr, (nachweis.quartal - 1) * 3 + 1, 1).date() if nachweis.quartal == 4: # Q4 special case quarter_end = datetime(nachweis.jahr + 1, 1, 1).date() else: quarter_end = datetime(nachweis.jahr, nachweis.quartal * 3 + 1, 1).date() # Search for existing payment - use broader criteria to catch all possibilities existing_payment = DestinataerUnterstuetzung.objects.filter( destinataer=destinataer, faellig_am__gte=quarter_start, faellig_am__lt=quarter_end ).filter( Q(beschreibung__contains=f"Q{nachweis.quartal}/{nachweis.jahr}") | Q(beschreibung__contains=f"Vierteljährliche Unterstützung") ).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 # Calculate payment due date (last day of quarter) quarter_end_month = nachweis.quartal * 3 if nachweis.quartal == 1: # Q1: January-March (ends March 31) quarter_end_day = 31 elif nachweis.quartal == 2: # Q2: April-June (ends June 30) quarter_end_day = 30 elif nachweis.quartal == 3: # Q3: July-September (ends September 30) quarter_end_day = 30 else: # Q4: October-December (ends December 31) quarter_end_day = 31 payment_due_date = datetime(nachweis.jahr, quarter_end_month, quarter_end_day).date() # 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""" destinataer = get_object_or_404(Destinataer, pk=destinataer_id) if request.method == "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 nachweis = VierteljahresNachweis.objects.create( destinataer=destinataer, jahr=jahr, quartal=quartal, studiennachweis_erforderlich=True, # Always required now ) # Set deadline (15th of second month of quarter) deadline_months = {1: 5, 2: 8, 3: 11, 4: 2} # Q1->May, Q2->Aug, Q3->Nov, Q4->Feb(next year) deadline_month = deadline_months[quartal] deadline_year = jahr if quartal != 4 else jahr + 1 from datetime import date nachweis.faelligkeitsdatum = date(deadline_year, deadline_month, 15) nachweis.save() messages.success( request, f"Quartal {jahr} Q{quartal} wurde erfolgreich für {destinataer.get_full_name()} erstellt." ) 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() # Handle support payment - create if missing, update if exists if not related_payment: # Create new support payment related_payment = create_quarterly_support_payment(nachweis) if related_payment: related_payment.status = 'in_bearbeitung' related_payment.aktualisiert_am = timezone.now() related_payment.save() 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()}." ) elif related_payment.status == 'geplant': # Update existing payment related_payment.status = 'in_bearbeitung' 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 freigegeben." ) else: messages.success( request, f"Vierteljahresnachweis für {nachweis.destinataer.get_full_name()} " f"({nachweis.jahr} Q{nachweis.quartal}) wurde freigegeben." ) 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)