import csv import uuid from io import StringIO from dateutil.relativedelta import relativedelta from django.core.validators import MaxValueValidator, MinValueValidator from django.db import models from django.utils import timezone from stiftung.utils.date_utils import ensure_date, get_year_from_date class CSVImport(models.Model): """Track CSV import operations for audit purposes""" IMPORT_TYPE_CHOICES = [ ("destinataere", "Destinatäre"), ("paechter", "Pächter"), ("laendereien", "Ländereien"), ("verpachtungen", "Verpachtungen"), ("personen", "Personen (Legacy)"), ] STATUS_CHOICES = [ ("pending", "Ausstehend"), ("processing", "Wird verarbeitet"), ("completed", "Abgeschlossen"), ("failed", "Fehlgeschlagen"), ("partial", "Teilweise erfolgreich"), ] id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) import_type = models.CharField( max_length=20, choices=IMPORT_TYPE_CHOICES, verbose_name="Import-Typ" ) filename = models.CharField(max_length=255, verbose_name="Dateiname") file_size = models.IntegerField(verbose_name="Dateigröße (Bytes)") status = models.CharField(max_length=20, choices=STATUS_CHOICES, default="pending") # Results total_rows = models.IntegerField(default=0, verbose_name="Gesamtzeilen") imported_rows = models.IntegerField(default=0, verbose_name="Importierte Zeilen") failed_rows = models.IntegerField(default=0, verbose_name="Fehlgeschlagene Zeilen") error_log = models.TextField(null=True, blank=True, verbose_name="Fehlerprotokoll") # Metadata created_by = models.CharField( max_length=100, null=True, blank=True, verbose_name="Erstellt von" ) started_at = models.DateTimeField(auto_now_add=True, verbose_name="Gestartet um") completed_at = models.DateTimeField( null=True, blank=True, verbose_name="Abgeschlossen um" ) class Meta: verbose_name = "CSV Import" verbose_name_plural = "CSV Imports" ordering = ["-started_at"] def __str__(self): return f"{self.get_import_type_display()} - {self.filename} ({self.status})" def get_duration(self): """Calculate import duration""" if self.completed_at and self.started_at: return self.completed_at - self.started_at return None def get_success_rate(self): """Calculate success rate percentage""" if self.total_rows > 0: return (self.imported_rows / self.total_rows) * 100 return 0 class Paechter(models.Model): """Pächter (Tenants) für Ländereien und Verpachtungen""" id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) vorname = models.CharField(max_length=100, verbose_name="Vorname") nachname = models.CharField(max_length=100, verbose_name="Nachname") geburtsdatum = models.DateField(null=True, blank=True, verbose_name="Geburtsdatum") email = models.EmailField(null=True, blank=True, verbose_name="E-Mail") telefon = models.CharField( max_length=20, null=True, blank=True, verbose_name="Telefon" ) iban = models.CharField(max_length=34, null=True, blank=True, verbose_name="IBAN") # Adressfelder strasse = models.CharField( max_length=200, verbose_name="Straße", blank=True, null=True ) plz = models.CharField(max_length=10, verbose_name="PLZ", blank=True, null=True) ort = models.CharField(max_length=100, verbose_name="Ort", blank=True, null=True) # Typ des Pächters PERSONENTYP_CHOICES = [ ("natuerlich", "Natürliche Person"), ("gesellschaft", "Gesellschaft (GmbH, KG, etc.)"), ] personentyp = models.CharField( max_length=20, choices=PERSONENTYP_CHOICES, default="natuerlich", verbose_name="Typ des Pächters", ) # Pacht-spezifische Felder pachtnummer = models.CharField( max_length=50, null=True, blank=True, verbose_name="Pachtnummer" ) pachtbeginn_erste = models.DateField( null=True, blank=True, verbose_name="Erster Pachtbeginn" ) pachtende_letzte = models.DateField( null=True, blank=True, verbose_name="Letztes Pachtende" ) pachtzins_aktuell = models.DecimalField( max_digits=12, decimal_places=2, null=True, blank=True, verbose_name="Aktueller Pachtzins (€/Jahr)", ) # Landwirtschaftliche Informationen landwirtschaftliche_ausbildung = models.BooleanField( default=False, verbose_name="Landwirtschaftliche Ausbildung" ) berufserfahrung_jahre = models.IntegerField( null=True, blank=True, verbose_name="Berufserfahrung (Jahre)" ) spezialisierung = models.CharField( max_length=100, null=True, blank=True, verbose_name="Spezialisierung" ) # Kontakt und Notizen notizen = models.TextField(null=True, blank=True, verbose_name="Notizen") aktiv = models.BooleanField(default=True, verbose_name="Aktiv") class Meta: verbose_name = "Pächter" verbose_name_plural = "Pächter" ordering = ["nachname", "vorname"] def __str__(self): if self.vorname: return f"{self.nachname}, {self.vorname}" else: return self.nachname def get_full_name(self): if self.vorname: return f"{self.vorname} {self.nachname}" else: return self.nachname def get_aktive_verpachtungen(self): """Get all active leases for this tenant""" return self.neue_verpachtungen.filter(status="aktiv") def get_gesamt_pachtflaeche(self): """Calculate total leased area""" return ( self.neue_verpachtungen.filter(status="aktiv").aggregate( total=models.Sum("verpachtete_flaeche") )["total"] or 0 ) def get_gesamt_pachtzins(self): """Calculate total annual rent""" return ( self.neue_verpachtungen.filter(status="aktiv").aggregate( total=models.Sum("pachtzins_pauschal") )["total"] or 0 ) class Destinataer(models.Model): """Destinatäre (Beneficiaries) für Förderungen""" FAMILIENZWIG_CHOICES = [ ("hauptzweig", "Hauptzweig"), ("nebenzweig", "Nebenzweig"), ("verwandt", "Verwandt"), ("anderer", "Anderer"), ] BERUFSGRUPPE_CHOICES = [ ("student", "Student/Studentin"), ("wissenschaftler", "Wissenschaftler/in"), ("künstler", "Künstler/in"), ("sozialarbeiter", "Sozialarbeiter/in"), ("umweltschützer", "Umweltschützer/in"), ("andere", "Andere"), ] id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) familienzweig = models.CharField( max_length=100, choices=FAMILIENZWIG_CHOICES, blank=True, null=True ) vorname = models.CharField(max_length=100, verbose_name="Vorname") nachname = models.CharField(max_length=100, verbose_name="Nachname") geburtsdatum = models.DateField(null=True, blank=True, verbose_name="Geburtsdatum") email = models.EmailField(null=True, blank=True, verbose_name="E-Mail") telefon = models.CharField( max_length=20, null=True, blank=True, verbose_name="Telefon" ) iban = models.CharField(max_length=34, null=True, blank=True, verbose_name="IBAN") # Adressfelder strasse = models.CharField( max_length=200, verbose_name="Straße", blank=True, null=True ) plz = models.CharField(max_length=10, verbose_name="PLZ", blank=True, null=True) ort = models.CharField(max_length=100, verbose_name="Ort", blank=True, null=True) # Förderungs-spezifische Felder berufsgruppe = models.CharField( max_length=20, choices=BERUFSGRUPPE_CHOICES, blank=True, null=True, verbose_name="Berufsgruppe", ) ausbildungsstand = models.CharField( max_length=100, null=True, blank=True, verbose_name="Ausbildungsstand" ) institution = models.CharField( max_length=200, null=True, blank=True, verbose_name="Institution/Organisation" ) projekt_beschreibung = models.TextField( null=True, blank=True, verbose_name="Projektbeschreibung" ) # Finanzielle Informationen jaehrliches_einkommen = models.DecimalField( max_digits=12, decimal_places=2, null=True, blank=True, verbose_name="Jährliches Einkommen (€)", ) finanzielle_notlage = models.BooleanField( default=False, verbose_name="Finanzielle Notlage" ) # Kontakt und Notizen notizen = models.TextField(null=True, blank=True, verbose_name="Notizen") aktiv = models.BooleanField(default=True, verbose_name="Aktiv") # Unterstützung – Prüf- und Verwaltungsfelder ist_abkoemmling = models.BooleanField( default=False, verbose_name="Abkömmling gem. Satzung" ) haushaltsgroesse = models.PositiveIntegerField( default=1, verbose_name="Haushaltsgröße" ) monatliche_bezuege = models.DecimalField( max_digits=10, decimal_places=2, null=True, blank=True, verbose_name="Monatliche Bezüge (€)", ) vermoegen = models.DecimalField( max_digits=12, decimal_places=2, null=True, blank=True, verbose_name="Vermögen (€)", ) unterstuetzung_bestaetigt = models.BooleanField( default=False, verbose_name="Unterstützung bestätigt" ) standard_konto = models.ForeignKey( "StiftungsKonto", null=True, blank=True, on_delete=models.SET_NULL, verbose_name="Standard Auszahlungskonto", ) vierteljaehrlicher_betrag = models.DecimalField( max_digits=12, decimal_places=2, null=True, blank=True, verbose_name="Vierteljährlicher Betrag (€)", ) # Studiennachweise studiennachweis_erforderlich = models.BooleanField( default=False, verbose_name="Studiennachweis erforderlich" ) letzter_studiennachweis = models.DateField( null=True, blank=True, verbose_name="Letzter Studiennachweis" ) class Meta: verbose_name = "Destinatär" verbose_name_plural = "Destinatäre" ordering = ["nachname", "vorname"] def __str__(self): if self.vorname: return f"{self.nachname}, {self.vorname}" else: return self.nachname def get_full_name(self): if self.vorname: return f"{self.vorname} {self.nachname}" else: return self.nachname def get_total_foerderungen(self): """Calculate total funding received""" return self.foerderung_set.aggregate(total=models.Sum("betrag"))["total"] or 0 def get_foerderungen_count(self): """Count total funding grants""" return self.foerderung_set.count() def get_letzte_foerderung(self): """Get the most recent funding grant""" return self.foerderung_set.order_by("-jahr", "-betrag").first() def erfuellt_voraussetzungen(self): """Prüft die Unterstützungsvoraussetzungen gemäß Angaben. - Abkömmling muss True sein - Monatliche Bezüge ≤ zulässige Grenze - Vermögen ≤ 15.500 € Die zulässige Grenze wird aus dem Regelsatz (standard 563 €) * 5 für die erste Person und + 0.8 * Regelsatz je weiterer Person approximiert. """ from decimal import Decimal regelsatz = Decimal("563.00") basis = regelsatz * 5 zuschlag = max(0, (self.haushaltsgroesse or 1) - 1) * ( regelsatz * Decimal("0.80") ) grenze = basis + zuschlag einkommen_ok = (self.monatliche_bezuege or Decimal("0")) <= grenze vermoegen_ok = (self.vermoegen or Decimal("0")) <= Decimal("15500") return bool(self.ist_abkoemmling and einkommen_ok and vermoegen_ok) def naechste_studiennachweis_termine(self): """Gibt die nächsten beiden Stichtage (15.03, 15.09) zurück.""" import datetime as _dt today = _dt.date.today() jahr = today.year maerz = _dt.date(jahr, 3, 15) sep = _dt.date(jahr, 9, 15) termine = [] for d in (maerz, sep): if d >= today: termine.append(d) if len(termine) < 2: # Ergänzen aus folgendem Jahr termine.append(_dt.date(jahr + 1, 3, 15)) if len(termine) < 2: termine.append(_dt.date(jahr + 1, 9, 15)) return termine[:2] # Keep the old Person model for backward compatibility (will be removed in future) class Person(models.Model): FAMILIENZWIG_CHOICES = [ ("hauptzweig", "Hauptzweig"), ("nebenzweig", "Nebenzweig"), ("verwandt", "Verwandt"), ("anderer", "Anderer"), ] id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) familienzweig = models.CharField( max_length=100, choices=FAMILIENZWIG_CHOICES, default="hauptzweig" ) vorname = models.CharField(max_length=100) nachname = models.CharField(max_length=100) geburtsdatum = models.DateField(null=True, blank=True) email = models.EmailField(null=True, blank=True) telefon = models.CharField(max_length=20, null=True, blank=True) iban = models.CharField(max_length=34, null=True, blank=True) adresse = models.TextField(null=True, blank=True) notizen = models.TextField(null=True, blank=True) aktiv = models.BooleanField(default=True) class Meta: verbose_name = "Person (Legacy)" verbose_name_plural = "Personen (Legacy)" ordering = ["nachname", "vorname"] def __str__(self): return f"{self.nachname}, {self.vorname} (Legacy)" def get_full_name(self): return f"{self.vorname} {self.nachname}" def get_total_foerderungen(self): return self.foerderung_set.aggregate(total=models.Sum("betrag"))["total"] or 0 class Land(models.Model): """Landverwaltung für verpachtete Ländereien""" id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) # Grundlegende Identifikation lfd_nr = models.CharField(max_length=20, unique=True, verbose_name="Lfd. Nr.") ew_nummer = models.CharField( max_length=50, null=True, blank=True, verbose_name="EW-Nummer" ) grundbuchblatt = models.CharField( max_length=50, null=True, blank=True, verbose_name="Grundbuchblatt" ) # Gerichtliche Zuständigkeit amtsgericht = models.CharField(max_length=100, verbose_name="Amtsgericht") # Verwaltungsstruktur gemeinde = models.CharField(max_length=100, verbose_name="Gemeinde") gemarkung = models.CharField(max_length=100, verbose_name="Gemarkung") flur = models.CharField(max_length=50, verbose_name="Flur") flurstueck = models.CharField(max_length=50, verbose_name="Flurstück") adresse = models.CharField( max_length=200, null=True, blank=True, verbose_name="Adresse/Ortsangabe" ) # Flächenangaben groesse_qm = models.DecimalField( max_digits=12, decimal_places=2, verbose_name="Größe in qm", validators=[MinValueValidator(0.01)], ) # Landnutzung gruenland_qm = models.DecimalField( max_digits=12, decimal_places=2, default=0, verbose_name="Grünland (qm)", validators=[MinValueValidator(0)], ) acker_qm = models.DecimalField( max_digits=12, decimal_places=2, default=0, verbose_name="Acker (qm)", validators=[MinValueValidator(0)], ) wald_qm = models.DecimalField( max_digits=12, decimal_places=2, default=0, verbose_name="Wald (qm)", validators=[MinValueValidator(0)], ) sonstiges_qm = models.DecimalField( max_digits=12, decimal_places=2, default=0, verbose_name="Sonstiges (qm)", validators=[MinValueValidator(0)], ) # Verpachtung (Legacy-Felder für Kompatibilität) verpachtete_gesamtflaeche = models.DecimalField( max_digits=12, decimal_places=2, verbose_name="Verpachtete Gesamtfläche (qm)", validators=[MinValueValidator(0)], ) flaeche_alte_liste = models.DecimalField( max_digits=12, decimal_places=2, null=True, blank=True, verbose_name="Fläche alte Liste (qm)", ) verp_flaeche_aktuell = models.DecimalField( max_digits=12, decimal_places=2, verbose_name="Verp. Fläche aktuell (qm)", validators=[MinValueValidator(0)], ) # Aktuelle Verpachtung (Neue Struktur) aktueller_paechter = models.ForeignKey( "Paechter", on_delete=models.SET_NULL, null=True, blank=True, verbose_name="Aktueller Pächter", related_name="gepachtete_laendereien", ) paechter_name = models.CharField( max_length=150, null=True, blank=True, verbose_name="Pächter Name" ) paechter_anschrift = models.TextField( null=True, blank=True, verbose_name="Pächter Anschrift" ) pachtbeginn = models.DateField(null=True, blank=True, verbose_name="Pachtbeginn") pachtende = models.DateField(null=True, blank=True, verbose_name="Pachtende") verlaengerung_klausel = models.BooleanField( default=False, verbose_name="Automatische Verlängerung" ) # Pachtzins und Zahlungsweise ZAHLUNGSWEISE_CHOICES = [ ("jaehrlich", "Jährlich"), ("halbjaehrlich", "Halbjährlich"), ("vierteljaehrlich", "Vierteljährlich"), ("monatlich", "Monatlich"), ] zahlungsweise = models.CharField( max_length=20, choices=ZAHLUNGSWEISE_CHOICES, default="jaehrlich", verbose_name="Zahlungsweise", ) pachtzins_pro_ha = models.DecimalField( max_digits=12, decimal_places=2, null=True, blank=True, verbose_name="Pachtzins pro ha (€)", validators=[MinValueValidator(0)], ) pachtzins_pauschal = models.DecimalField( max_digits=12, decimal_places=2, null=True, blank=True, verbose_name="Pachtzins pauschal/Jahr (€)", validators=[MinValueValidator(0)], ) # Umsatzsteuer ust_option = models.BooleanField(default=False, verbose_name="USt-Option") ust_satz = models.DecimalField( max_digits=4, decimal_places=2, default=19.00, verbose_name="USt-Satz (%)" ) # Umlagen (Durchreichungen) grundsteuer_umlage = models.BooleanField( default=True, verbose_name="Grundsteuer umlagefähig" ) versicherungen_umlage = models.BooleanField( default=True, verbose_name="Versicherungen umlagefähig" ) verbandsbeitraege_umlage = models.BooleanField( default=True, verbose_name="Verbandsbeiträge umlagefähig" ) jagdpacht_anteil_umlage = models.BooleanField( default=False, verbose_name="Jagdpachtanteile umlagefähig" ) # Steuern und Abgaben anteil_grundsteuer = models.DecimalField( max_digits=8, decimal_places=2, null=True, blank=True, verbose_name="Anteil Grundsteuer (%)", ) anteil_lwk = models.DecimalField( max_digits=8, decimal_places=2, null=True, blank=True, verbose_name="Anteil LWK (%)", ) # Status aktiv = models.BooleanField(default=True, verbose_name="Aktiv") notizen = models.TextField( null=True, blank=True, verbose_name="Ergänzende Kommentare" ) # Zeitstempel erstellt_am = models.DateTimeField(auto_now_add=True) aktualisiert_am = models.DateTimeField(auto_now=True) class Meta: verbose_name = "Land" verbose_name_plural = "Ländereien" ordering = ["gemeinde", "gemarkung", "flur", "flurstueck"] def __str__(self): return f"{self.gemeinde} - {self.gemarkung} Flur {self.flur} Flurstück {self.flurstueck}" def get_gesamtflaeche(self): """Berechnet die Gesamtfläche aus allen Nutzungsarten""" return self.gruenland_qm + self.acker_qm + self.wald_qm + self.sonstiges_qm def get_verpachtungsgrad(self): """Berechnet den Verpachtungsgrad in Prozent""" if self.get_gesamtflaeche() > 0: return ( self.get_verpachtete_flaeche_aktuell() / self.get_gesamtflaeche() ) * 100 return 0 def get_verpachtete_flaeche_aktuell(self): """Gibt die aktuell verpachtete Fläche zurück (aus neuen Verpachtungen oder Legacy)""" from django.db.models import Sum # Priorität 1: Neue Verpachtungen (LandVerpachtung) neue_total = ( self.neue_verpachtungen.filter(status="aktiv").aggregate( total=Sum("verpachtete_flaeche") )["total"] or 0 ) if neue_total > 0: return neue_total # Priorität 2: Einzelverpachtung im Land-Model (verp_flaeche_aktuell) if self.verp_flaeche_aktuell and self.verp_flaeche_aktuell > 0: return self.verp_flaeche_aktuell # No legacy system - return neue_total (could be 0) return neue_total def get_verfuegbare_flaeche(self): """Berechnet die noch verfügbare Fläche für neue Verpachtungen""" return self.groesse_qm - self.get_verpachtete_flaeche_aktuell() def get_verpachtungsgrad_neu(self): """Berechnet den Verpachtungsgrad basierend auf neuen Verpachtungen""" if self.groesse_qm > 0: return (self.get_verpachtete_flaeche_aktuell() / self.groesse_qm) * 100 return 0 def get_steuer_gesamt(self): """Berechnet den Gesamtsteueranteil""" grundsteuer = self.anteil_grundsteuer or 0 lwk = self.anteil_lwk or 0 return grundsteuer + lwk def _qm_to_hektar(self, qm_value): """Hilfsmethode zur Umrechnung von qm in Hektar""" from decimal import ROUND_HALF_UP, Decimal if qm_value and qm_value > 0: # Umrechnung: 1 Hektar = 10.000 qm hektar = Decimal(str(qm_value)) / Decimal("10000") # Runden auf 2 Nachkommastellen return hektar.quantize(Decimal("0.01"), rounding=ROUND_HALF_UP) return Decimal("0.00") @property def groesse_hektar(self): """Berechnet die Gesamtgröße in Hektar""" return self._qm_to_hektar(self.groesse_qm) @property def gruenland_hektar(self): """Berechnet die Grünlandfläche in Hektar""" return self._qm_to_hektar(self.gruenland_qm) @property def acker_hektar(self): """Berechnet die Ackerfläche in Hektar""" return self._qm_to_hektar(self.acker_qm) @property def wald_hektar(self): """Berechnet die Waldfläche in Hektar""" return self._qm_to_hektar(self.wald_qm) @property def sonstiges_hektar(self): """Berechnet die sonstige Fläche in Hektar""" return self._qm_to_hektar(self.sonstiges_qm) @property def verpachtete_gesamtflaeche_hektar(self): """Berechnet die verpachtete Gesamtfläche in Hektar""" return self._qm_to_hektar(self.verpachtete_gesamtflaeche) @property def flaeche_alte_liste_hektar(self): """Berechnet die Fläche aus alter Liste in Hektar""" return self._qm_to_hektar(self.flaeche_alte_liste) @property def verp_flaeche_aktuell_hektar(self): """Berechnet die aktuell verpachtete Fläche in Hektar""" return self._qm_to_hektar(self.verp_flaeche_aktuell) def get_gesamtflaeche_hektar(self): """Berechnet die Gesamtfläche aus allen Nutzungsarten in Hektar""" return self._qm_to_hektar(self.get_gesamtflaeche()) def get_verpachtete_flaeche_aktuell_hektar(self): """Berechnet die aktuell verpachtete Fläche basierend auf aktiven Verpachtungen in Hektar""" return self._qm_to_hektar(self.get_verpachtete_flaeche_aktuell()) class LandVerpachtung(models.Model): """Neue Verpachtungsverträge - mehrere pro Land möglich""" STATUS_CHOICES = [ ("aktiv", "Aktiv"), ("beendet", "Beendet"), ("gekuendigt", "Gekündigt"), ("verlängert", "Verlängert"), ] id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) # Grundlegende Verknüpfungen land = models.ForeignKey( Land, on_delete=models.CASCADE, related_name="neue_verpachtungen", verbose_name="Länderei", ) paechter = models.ForeignKey( Paechter, on_delete=models.CASCADE, related_name="neue_verpachtungen", verbose_name="Pächter", ) # Vertragsdaten vertragsnummer = models.CharField( max_length=50, unique=True, verbose_name="Vertragsnummer" ) pachtbeginn = models.DateField(verbose_name="Pachtbeginn") pachtende = models.DateField(null=True, blank=True, verbose_name="Pachtende") verlaengerung_klausel = models.BooleanField( default=False, verbose_name="Automatische Verlängerung" ) # Flächenangaben verpachtete_flaeche = models.DecimalField( max_digits=12, decimal_places=2, verbose_name="Verpachtete Fläche (qm)", validators=[MinValueValidator(0.01)], ) # Pachtzins pachtzins_pauschal = models.DecimalField( max_digits=12, decimal_places=2, verbose_name="Pachtzins pauschal/Jahr (€)", validators=[MinValueValidator(0)], ) pachtzins_pro_ha = models.DecimalField( max_digits=12, decimal_places=2, null=True, blank=True, verbose_name="Pachtzins pro ha (€)", validators=[MinValueValidator(0)], ) # Zahlungsweise ZAHLUNGSWEISE_CHOICES = [ ("jaehrlich", "Jährlich"), ("halbjaehrlich", "Halbjährlich"), ("vierteljaehrlich", "Vierteljährlich"), ("monatlich", "Monatlich"), ] zahlungsweise = models.CharField( max_length=20, choices=ZAHLUNGSWEISE_CHOICES, default="jaehrlich", verbose_name="Zahlungsweise", ) # Umsatzsteuer ust_option = models.BooleanField(default=False, verbose_name="USt-Option") ust_satz = models.DecimalField( max_digits=4, decimal_places=2, default=19.00, verbose_name="USt-Satz (%)" ) # Umlagen (Durchreichungen) grundsteuer_umlage = models.BooleanField( default=True, verbose_name="Grundsteuer umlagefähig" ) versicherungen_umlage = models.BooleanField( default=True, verbose_name="Versicherungen umlagefähig" ) verbandsbeitraege_umlage = models.BooleanField( default=True, verbose_name="Verbandsbeiträge umlagefähig" ) jagdpacht_anteil_umlage = models.BooleanField( default=False, verbose_name="Jagdpachtanteile umlagefähig" ) # Status und Notizen status = models.CharField( max_length=20, choices=STATUS_CHOICES, default="aktiv", verbose_name="Status" ) bemerkungen = models.TextField(null=True, blank=True, verbose_name="Bemerkungen") # Zeitstempel erstellt_am = models.DateTimeField(auto_now_add=True) aktualisiert_am = models.DateTimeField(auto_now=True) class Meta: verbose_name = "Landverpachtung" verbose_name_plural = "Landverpachtungen" ordering = ["-pachtbeginn", "land"] def __str__(self): return f"{self.land} - {self.paechter} ({self.vertragsnummer})" @property def verpachtete_flaeche_hektar(self): """Berechnet die verpachtete Fläche in Hektar""" from decimal import ROUND_HALF_UP, Decimal if self.verpachtete_flaeche and self.verpachtete_flaeche > 0: hektar = Decimal(str(self.verpachtete_flaeche)) / Decimal("10000") return hektar.quantize(Decimal("0.01"), rounding=ROUND_HALF_UP) return Decimal("0.00") def is_aktiv(self): """Prüft ob der Vertrag noch aktiv ist""" from datetime import date heute = date.today() pachtbeginn_date = ensure_date(self.pachtbeginn) pachtende_date = ensure_date(self.pachtende) if not pachtbeginn_date: return False if pachtende_date: return pachtbeginn_date <= heute <= pachtende_date return pachtbeginn_date <= heute # Unbefristet def get_restlaufzeit_tage(self): """Berechnet die Restlaufzeit in Tagen""" from datetime import date heute = date.today() pachtende_date = ensure_date(self.pachtende) if pachtende_date and pachtende_date > heute: return (pachtende_date - heute).days return None # Unbefristet @property def ust_pacht_betrag(self): """Berechnet die USt auf Pacht (falls optiert)""" from decimal import ROUND_HALF_UP, Decimal if self.ust_option and self.pachtzins_pauschal: ust_betrag = ( Decimal(str(self.pachtzins_pauschal)) * Decimal(str(self.ust_satz)) / Decimal("100") ) return ust_betrag.quantize(Decimal("0.01"), rounding=ROUND_HALF_UP) return Decimal("0.00") def save(self, *args, **kwargs): """Override save to trigger Abrechnung updates""" is_new = self.pk is None old_instance = None if not is_new: try: old_instance = LandVerpachtung.objects.get(pk=self.pk) except LandVerpachtung.DoesNotExist: old_instance = None super().save(*args, **kwargs) # Update Abrechnungen after save self._update_abrechnungen(old_instance, is_new) def _update_abrechnungen(self, old_instance, is_new): """Update LandAbrechnung records when Verpachtung changes""" from datetime import date # Determine affected years years_to_update = set() pachtbeginn_year = get_year_from_date(self.pachtbeginn) if pachtbeginn_year: years_to_update.add(pachtbeginn_year) pachtende_year = get_year_from_date(self.pachtende) if pachtende_year: years_to_update.add(pachtende_year) # If updated, check old dates too if old_instance: old_pachtbeginn_year = get_year_from_date(old_instance.pachtbeginn) if old_pachtbeginn_year: years_to_update.add(old_pachtbeginn_year) old_pachtende_year = get_year_from_date(old_instance.pachtende) if old_pachtende_year: years_to_update.add(old_pachtende_year) # Add current year if contract is active if self.is_aktiv(): years_to_update.add(date.today().year) # Update each affected year for year in years_to_update: self._update_abrechnung_for_year(year, old_instance, is_new) def _update_abrechnung_for_year(self, year, old_instance, is_new): """Update or create LandAbrechnung for specific year""" from datetime import date from decimal import Decimal # Get or create Abrechnung for this year abrechnung, created = LandAbrechnung.objects.get_or_create( land=self.land, abrechnungsjahr=year, defaults={ "pacht_vereinnahmt": Decimal("0.00"), "umlagen_vereinnahmt": Decimal("0.00"), "bemerkungen": f"Automatisch erstellt für {self.vertragsnummer}", }, ) # Calculate rent for this year rent_for_year = self._calculate_rent_for_year(year) umlage_for_year = self._calculate_umlage_for_year(year) # Update or add to existing amounts if created or is_new: # New Abrechnung or new Verpachtung abrechnung.pacht_vereinnahmt += rent_for_year abrechnung.umlagen_vereinnahmt += umlage_for_year change_note = f"Neue Verpachtung {self.vertragsnummer} hinzugefügt" else: # Update existing - calculate difference old_rent = ( old_instance._calculate_rent_for_year(year) if old_instance else Decimal("0.00") ) old_umlage = ( old_instance._calculate_umlage_for_year(year) if old_instance else Decimal("0.00") ) rent_diff = rent_for_year - old_rent umlage_diff = umlage_for_year - old_umlage abrechnung.pacht_vereinnahmt += rent_diff abrechnung.umlagen_vereinnahmt += umlage_diff if rent_diff != 0 or umlage_diff != 0: change_note = f"Verpachtung {self.vertragsnummer} geändert: Pacht {rent_diff:+.2f}€, Umlagen {umlage_diff:+.2f}€" else: change_note = f"Verpachtung {self.vertragsnummer} aktualisiert (keine Betragsänderung)" # Add change tracking to bemerkungen (if significant change) if change_note and ("hinzugefügt" in change_note or "geändert" in change_note): if abrechnung.bemerkungen: abrechnung.bemerkungen += ( f"\n[{date.today().strftime('%d.%m.%Y')}] {change_note}" ) else: abrechnung.bemerkungen = ( f"[{date.today().strftime('%d.%m.%Y')}] {change_note}" ) abrechnung.save() def _calculate_rent_for_year(self, year): """Calculate rent amount for specific year""" from datetime import date from decimal import Decimal from django.utils.dateparse import parse_date # Helper function to convert date strings to date objects def ensure_date(date_value): if not date_value: return None if isinstance(date_value, str): return parse_date(date_value) return date_value if not self.pachtzins_pauschal or not self.pachtbeginn: return Decimal("0.00") # Check if contract is active in this year year_start = date(year, 1, 1) year_end = date(year, 12, 31) # Convert dates to ensure they are date objects pachtbeginn_date = ensure_date(self.pachtbeginn) pachtende_date = ensure_date(self.pachtende) if not pachtbeginn_date: return Decimal("0.00") contract_start = max(pachtbeginn_date, year_start) contract_end = min(pachtende_date or year_end, year_end) if contract_start > contract_end: return Decimal("0.00") # No overlap # Calculate proportion of year days_in_year = (year_end - year_start).days + 1 days_active = (contract_end - contract_start).days + 1 proportion = Decimal(str(days_active)) / Decimal(str(days_in_year)) return Decimal(str(self.pachtzins_pauschal)) * proportion def _calculate_umlage_for_year(self, year): """Calculate Umlage amount for specific year based on what can be passed through""" from decimal import Decimal # This would need to be calculated based on actual costs and what's umlagefähig # For now, return 0 - this can be enhanced later with actual cost calculation return Decimal("0.00") def delete(self, *args, **kwargs): """Override delete to update Abrechnungen when Verpachtung is removed""" # Calculate what needs to be removed from Abrechnungen years_to_update = set() pachtbeginn_year = get_year_from_date(self.pachtbeginn) if pachtbeginn_year: years_to_update.add(pachtbeginn_year) pachtende_year = get_year_from_date(self.pachtende) if pachtende_year: years_to_update.add(pachtende_year) # Remove from Abrechnungen before deleting for year in years_to_update: try: abrechnung = LandAbrechnung.objects.get( land=self.land, abrechnungsjahr=year ) rent_to_remove = self._calculate_rent_for_year(year) umlage_to_remove = self._calculate_umlage_for_year(year) abrechnung.pacht_vereinnahmt -= rent_to_remove abrechnung.umlagen_vereinnahmt -= umlage_to_remove # Add deletion note from datetime import date change_note = f"Verpachtung {self.vertragsnummer} gelöscht: Pacht -{rent_to_remove:.2f}€, Umlagen -{umlage_to_remove:.2f}€" if abrechnung.bemerkungen: abrechnung.bemerkungen += ( f"\n[{date.today().strftime('%d.%m.%Y')}] {change_note}" ) else: abrechnung.bemerkungen = ( f"[{date.today().strftime('%d.%m.%Y')}] {change_note}" ) abrechnung.save() except LandAbrechnung.DoesNotExist: pass # No Abrechnung to update super().delete(*args, **kwargs) class LandAbrechnung(models.Model): """Jahresabrechnung für Ländereien""" id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) land = models.ForeignKey( Land, on_delete=models.CASCADE, related_name="abrechnungen", verbose_name="Länderei", ) abrechnungsjahr = models.IntegerField( verbose_name="Abrechnungsjahr", validators=[MinValueValidator(2000)] ) # Einnahmen pacht_vereinnahmt = models.DecimalField( max_digits=12, decimal_places=2, default=0, verbose_name="Pacht vereinnahmt (€)", validators=[MinValueValidator(0)], ) umlagen_vereinnahmt = models.DecimalField( max_digits=12, decimal_places=2, default=0, verbose_name="Umlagen vereinnahmt (€)", validators=[MinValueValidator(0)], ) sonstige_einnahmen = models.DecimalField( max_digits=12, decimal_places=2, default=0, verbose_name="Sonstige Einnahmen (€)", validators=[MinValueValidator(0)], ) # Zahlungstermine (optional) zahlungen = models.JSONField( null=True, blank=True, verbose_name="Zahlungstermine", help_text="Liste von Objekten {datum, betrag, art}", ) # Ausgaben grundsteuer_bescheid_nr = models.CharField( max_length=80, null=True, blank=True, verbose_name="Grundsteuer-Bescheid Nr." ) grundsteuer_betrag = models.DecimalField( max_digits=12, decimal_places=2, default=0, verbose_name="Grundsteuer Betrag (€)", validators=[MinValueValidator(0)], ) versicherungen_betrag = models.DecimalField( max_digits=12, decimal_places=2, default=0, verbose_name="Versicherungen Betrag (€)", validators=[MinValueValidator(0)], ) verbandsbeitraege_betrag = models.DecimalField( max_digits=12, decimal_places=2, default=0, verbose_name="Verbandsbeiträge Betrag (€)", validators=[MinValueValidator(0)], ) sonstige_abgaben_betrag = models.DecimalField( max_digits=12, decimal_places=2, default=0, verbose_name="Sonstige öffentliche Abgaben (€)", validators=[MinValueValidator(0)], ) instandhaltung_betrag = models.DecimalField( max_digits=12, decimal_places=2, default=0, verbose_name="Instandhaltung/Reparaturen (€)", validators=[MinValueValidator(0)], ) verwaltung_recht_betrag = models.DecimalField( max_digits=12, decimal_places=2, default=0, verbose_name="Verwaltung/Recht (€)", validators=[MinValueValidator(0)], ) # Umsatzsteuer/Vorsteuer vorsteuer_aus_umlagen = models.DecimalField( max_digits=12, decimal_places=2, default=0, verbose_name="Vorsteuer aus umgelegten Kosten (€)", validators=[MinValueValidator(0)], ) # Sonstiges offene_posten = models.DecimalField( max_digits=12, decimal_places=2, default=0, verbose_name="Offene Posten (€)" ) bemerkungen = models.TextField( null=True, blank=True, verbose_name="Bemerkungen Abrechnung" ) # Dokumente pachtvertrag_datei = models.FileField( upload_to="land_abrechnungen/vertraege/", null=True, blank=True, verbose_name="Pachtvertrag (Datei)", ) grundsteuer_bescheid_datei = models.FileField( upload_to="land_abrechnungen/bescheide/", null=True, blank=True, verbose_name="Grundsteuerbescheid (Datei)", ) versicherungsnachweis_datei = models.FileField( upload_to="land_abrechnungen/versicherungen/", null=True, blank=True, verbose_name="Versicherungsnachweis (Datei)", ) # Zeitstempel erstellt_am = models.DateTimeField(auto_now_add=True) aktualisiert_am = models.DateTimeField(auto_now=True) class Meta: verbose_name = "Landabrechnung" verbose_name_plural = "Landabrechnungen" ordering = ["-abrechnungsjahr", "land__gemeinde", "land__gemarkung"] unique_together = ["land", "abrechnungsjahr"] # Ein Jahr pro Land def __str__(self): return f"{self.land} - Abrechnung {self.abrechnungsjahr}" @property def einnahmen_gesamt(self): """Berechnet die Gesamteinnahmen""" from decimal import Decimal return ( self.pacht_vereinnahmt + self.umlagen_vereinnahmt + self.sonstige_einnahmen ) @property def ausgaben_gesamt(self): """Berechnet die Gesamtausgaben""" from decimal import Decimal return ( self.grundsteuer_betrag + self.versicherungen_betrag + self.verbandsbeitraege_betrag + self.sonstige_abgaben_betrag + self.instandhaltung_betrag + self.verwaltung_recht_betrag ) @property def nettoergebnis(self): """Berechnet das Nettoergebnis""" return self.einnahmen_gesamt - self.ausgaben_gesamt @property def ust_pacht_betrag(self): """Berechnet die USt auf Pacht (falls optiert)""" from decimal import ROUND_HALF_UP, Decimal if self.land.ust_option and self.pacht_vereinnahmt: ust = self.pacht_vereinnahmt * (self.land.ust_satz / Decimal("100")) return ust.quantize(Decimal("0.01"), rounding=ROUND_HALF_UP) return Decimal("0.00") class DokumentLink(models.Model): KONTEXT_CHOICES = [ ("pachtvertrag", "Pachtvertrag"), ("antrag", "Antrag"), ("verwendungsnachweis", "Verwendungsnachweis"), ("rechnung", "Rechnung"), ("vertrag", "Vertrag"), ("bericht", "Bericht"), ("landkarte", "Landkarte"), ("kataster", "Kataster"), ("anderes", "Anderes"), ] id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) paperless_document_id = models.IntegerField() kontext = models.CharField( max_length=30, choices=KONTEXT_CHOICES, default="anderes" ) titel = models.CharField(max_length=255) beschreibung = models.TextField(null=True, blank=True) # Verknüpfungen zu anderen Modellen (als Strings für Flexibilität) verpachtung_id = models.UUIDField( null=True, blank=True, verbose_name="Verpachtung ID (Legacy)" ) land_verpachtung_id = models.UUIDField( null=True, blank=True, verbose_name="Landverpachtung ID (Neu)" ) land_id = models.UUIDField(null=True, blank=True, verbose_name="Länderei ID") paechter_id = models.UUIDField(null=True, blank=True, verbose_name="Pächter ID") destinataer_id = models.UUIDField( null=True, blank=True, verbose_name="Destinatär ID" ) foerderung_id = models.UUIDField(null=True, blank=True, verbose_name="Förderung ID") rentmeister_id = models.UUIDField( null=True, blank=True, verbose_name="Rentmeister ID" ) abrechnung_id = models.UUIDField( null=True, blank=True, verbose_name="Abrechnung ID" ) class Meta: verbose_name = "Dokument" verbose_name_plural = "Dokumente" ordering = ["titel"] def __str__(self): return f"{self.titel} ({self.get_kontext_display()})" def get_paperless_url(self): """Gibt die URL zum Dokument in Paperless zurück (über Django Redirect)""" return f"/api/paperless/documents/{self.paperless_document_id}/" def get_paperless_thumbnail_url(self): """Gibt die URL zum Thumbnail in Paperless zurück""" from django.conf import settings if settings.PAPERLESS_API_URL: return f"{settings.PAPERLESS_API_URL}/api/documents/{self.paperless_document_id}/thumb/" return None def get_verpachtung(self): """Gibt die verknüpfte Verpachtung zurück""" if self.verpachtung_id: try: return LandVerpachtung.objects.get(pk=self.verpachtung_id) except LandVerpachtung.DoesNotExist: return None return None def get_land(self): """Gibt die verknüpfte Länderei zurück""" if self.land_id: try: return Land.objects.get(pk=self.land_id) except Land.DoesNotExist: return None return None def get_paechter(self): """Gibt den verknüpften Pächter zurück""" if self.paechter_id: try: return Paechter.objects.get(pk=self.paechter_id) except Paechter.DoesNotExist: return None return None def get_destinataer(self): """Gibt den verknüpften Destinatär zurück""" if self.destinataer_id: try: return Destinataer.objects.get(pk=self.destinataer_id) except Destinataer.DoesNotExist: return None return None def get_foerderung(self): """Gibt die verknüpfte Förderung zurück""" if self.foerderung_id: try: return Foerderung.objects.get(pk=self.foerderung_id) except Foerderung.DoesNotExist: return None return None def get_land_verpachtung(self): """Gibt die verknüpfte neue Landverpachtung zurück""" if self.land_verpachtung_id: try: return LandVerpachtung.objects.get(pk=self.land_verpachtung_id) except LandVerpachtung.DoesNotExist: return None return None class Foerderung(models.Model): KATEGORIE_CHOICES = [ ("bildung", "Bildung"), ("forschung", "Forschung"), ("kultur", "Kultur"), ("soziales", "Soziales"), ("umwelt", "Umwelt"), ("anderes", "Anderes"), ] STATUS_CHOICES = [ ("beantragt", "Beantragt"), ("genehmigt", "Genehmigt"), ("ausgezahlt", "Ausgezahlt"), ("abgelehnt", "Abgelehnt"), ("storniert", "Storniert"), ] id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) # Legacy field for migration - will be removed after data migration person = models.ForeignKey( Person, on_delete=models.CASCADE, verbose_name="Person (Legacy)", null=True, blank=True, ) destinataer = models.ForeignKey( Destinataer, on_delete=models.CASCADE, verbose_name="Destinatär", null=True, blank=True, ) jahr = models.IntegerField( validators=[MinValueValidator(1900), MaxValueValidator(2100)] ) betrag = models.DecimalField(max_digits=12, decimal_places=2) kategorie = models.CharField( max_length=20, choices=KATEGORIE_CHOICES, default="anderes" ) status = models.CharField( max_length=20, choices=STATUS_CHOICES, default="beantragt" ) verwendungsnachweis = models.ForeignKey( DokumentLink, null=True, blank=True, on_delete=models.SET_NULL, verbose_name="Verwendungsnachweis", ) bemerkungen = models.TextField(null=True, blank=True) antragsdatum = models.DateField(default=timezone.now) entscheidungsdatum = models.DateField(null=True, blank=True) class Meta: verbose_name = "Förderung" verbose_name_plural = "Förderungen" ordering = ["-jahr", "-betrag"] # Note: unique_together will be updated after migration def __str__(self): if self.destinataer: return f"{self.destinataer} - {self.jahr} - €{self.betrag}" elif self.person: return f"{self.person} (Legacy) - {self.jahr} - €{self.betrag}" return f"Unbekannt - {self.jahr} - €{self.betrag}" def get_status_color(self): colors = { "beantragt": "orange", "genehmigt": "blue", "ausgezahlt": "green", "abgelehnt": "red", "storniert": "gray", } return colors.get(self.status, "black") class DestinataerUnterstuetzung(models.Model): """Geplante/ausgeführte Unterstützungszahlungen an Destinatäre""" STATUS_CHOICES = [ ("geplant", "Geplant"), ("faellig", "Fällig"), ("in_bearbeitung", "In Bearbeitung"), ("ausgezahlt", "Ausgezahlt"), ("storniert", "Storniert"), ] id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) destinataer = models.ForeignKey( "Destinataer", on_delete=models.CASCADE, related_name="unterstuetzungen", verbose_name="Destinatär", ) konto = models.ForeignKey( "StiftungsKonto", on_delete=models.PROTECT, verbose_name="Zahlungskonto" ) betrag = models.DecimalField( max_digits=12, decimal_places=2, verbose_name="Betrag (€)" ) faellig_am = models.DateField(verbose_name="Fällig am") status = models.CharField( max_length=20, choices=STATUS_CHOICES, default="geplant", verbose_name="Status" ) beschreibung = models.CharField( max_length=255, blank=True, verbose_name="Beschreibung" ) # Enhanced fields for recurrent payments and IBAN tracking empfaenger_iban = models.CharField( max_length=34, blank=True, verbose_name="Empfänger IBAN" ) empfaenger_name = models.CharField( max_length=200, blank=True, verbose_name="Empfänger Name" ) verwendungszweck = models.CharField( max_length=140, blank=True, verbose_name="Verwendungszweck" ) ausgezahlt_am = models.DateField( null=True, blank=True, verbose_name="Ausgezahlt am" ) ausgezahlt_von = models.ForeignKey( "auth.User", on_delete=models.SET_NULL, null=True, blank=True, verbose_name="Ausgezahlt von", ) # Link to recurrent payment template if this was auto-generated wiederkehrend_von = models.ForeignKey( "UnterstuetzungWiederkehrend", on_delete=models.SET_NULL, null=True, blank=True, verbose_name="Wiederkehrende Zahlung", ) erstellt_am = models.DateTimeField(auto_now_add=True) aktualisiert_am = models.DateTimeField(auto_now=True) class Meta: verbose_name = "Destinatärunterstützung" verbose_name_plural = "Destinatärunterstützungen" ordering = ["-faellig_am", "-erstellt_am"] indexes = [ models.Index(fields=["status", "faellig_am"]), models.Index(fields=["destinataer", "status"]), models.Index(fields=["wiederkehrend_von"]), ] def __str__(self): return f"{self.destinataer.get_full_name()} – €{self.betrag} am {self.faellig_am} ({self.get_status_display()})" def is_overdue(self): """Check if payment is overdue""" from django.utils import timezone return self.faellig_am < timezone.now().date() and self.status in [ "geplant", "faellig", ] def can_be_marked_paid(self): """Check if payment can be marked as paid""" return self.status in ["geplant", "faellig", "in_bearbeitung"] class UnterstuetzungWiederkehrend(models.Model): """Template for recurring support payments""" INTERVALL_CHOICES = [ ("monatlich", "Monatlich"), ("quartalsweise", "Vierteljährlich"), ("halbjaehrlich", "Halbjährlich"), ("jaehrlich", "Jährlich"), ] id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) destinataer = models.ForeignKey( "Destinataer", on_delete=models.CASCADE, related_name="wiederkehrende_unterstuetzungen", verbose_name="Destinatär", ) konto = models.ForeignKey( "StiftungsKonto", on_delete=models.PROTECT, verbose_name="Zahlungskonto" ) betrag = models.DecimalField( max_digits=12, decimal_places=2, verbose_name="Betrag (€)" ) intervall = models.CharField( max_length=20, choices=INTERVALL_CHOICES, verbose_name="Intervall" ) beschreibung = models.CharField( max_length=255, blank=True, verbose_name="Beschreibung" ) # IBAN and payment details empfaenger_iban = models.CharField(max_length=34, verbose_name="Empfänger IBAN") empfaenger_name = models.CharField(max_length=200, verbose_name="Empfänger Name") verwendungszweck = models.CharField( max_length=140, blank=True, verbose_name="Verwendungszweck" ) # Schedule settings erste_zahlung_am = models.DateField(verbose_name="Erste Zahlung am") letzte_zahlung_am = models.DateField( null=True, blank=True, verbose_name="Letzte Zahlung am (optional)" ) naechste_generierung = models.DateField(verbose_name="Nächste Generierung") aktiv = models.BooleanField(default=True, verbose_name="Aktiv") erstellt_am = models.DateTimeField(auto_now_add=True) erstellt_von = models.ForeignKey( "auth.User", on_delete=models.SET_NULL, null=True, blank=True, verbose_name="Erstellt von", ) class Meta: verbose_name = "Wiederkehrende Unterstützung" verbose_name_plural = "Wiederkehrende Unterstützungen" ordering = ["-erstellt_am"] indexes = [ models.Index(fields=["aktiv", "naechste_generierung"]), models.Index(fields=["destinataer", "aktiv"]), ] def __str__(self): return f"{self.destinataer.get_full_name()} – {self.get_intervall_display()} €{self.betrag}" def generiere_naechste_zahlung(self): """Generate the next scheduled payment""" from datetime import timedelta from dateutil.relativedelta import relativedelta if not self.aktiv: return None heute = timezone.now().date() if self.naechste_generierung > heute: return None # Not yet time to generate # Check if we've reached the end date if ( self.letzte_zahlung_am and self.naechste_generierung > self.letzte_zahlung_am ): return None # Create the next payment neue_zahlung = DestinataerUnterstuetzung.objects.create( destinataer=self.destinataer, konto=self.konto, betrag=self.betrag, faellig_am=self.naechste_generierung, beschreibung=self.beschreibung or f"{self.get_intervall_display()} Unterstützung", empfaenger_iban=self.empfaenger_iban, empfaenger_name=self.empfaenger_name, verwendungszweck=self.verwendungszweck, wiederkehrend_von=self, status="geplant", ) # Calculate next generation date if self.intervall == "monatlich": self.naechste_generierung = self.naechste_generierung + relativedelta( months=1 ) elif self.intervall == "quartalsweise": self.naechste_generierung = self.naechste_generierung + relativedelta( months=3 ) elif self.intervall == "halbjaehrlich": self.naechste_generierung = self.naechste_generierung + relativedelta( months=6 ) elif self.intervall == "jaehrlich": self.naechste_generierung = self.naechste_generierung + relativedelta( years=1 ) self.save() return neue_zahlung class DestinataerNotiz(models.Model): """Zeitgestempelte Notizen/Telefonvermerke zu einem Destinatär, optional mit Datei.""" id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) destinataer = models.ForeignKey( "Destinataer", on_delete=models.CASCADE, related_name="notizen_eintraege", verbose_name="Destinatär", ) titel = models.CharField(max_length=200, blank=True, verbose_name="Titel") text = models.TextField(blank=True, verbose_name="Notiz") datei = models.FileField( upload_to="destinataer_notizen/", null=True, blank=True, verbose_name="Anhang" ) erstellt_von = models.ForeignKey( "auth.User", on_delete=models.SET_NULL, null=True, blank=True, verbose_name="Erstellt von", ) erstellt_am = models.DateTimeField(auto_now_add=True, verbose_name="Erstellt am") class Meta: verbose_name = "Destinatär-Notiz" verbose_name_plural = "Destinatär-Notizen" ordering = ["-erstellt_am"] def __str__(self): return self.titel or f"Notiz {self.erstellt_am.strftime('%d.%m.%Y %H:%M')}" class Rentmeister(models.Model): """Geschäftsführer der Stiftung (natürliche Personen)""" ANREDE_CHOICES = [ ("herr", "Herr"), ("frau", "Frau"), ("dr", "Dr."), ("prof", "Prof."), ("prof_dr", "Prof. Dr."), ] id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) anrede = models.CharField( max_length=10, choices=ANREDE_CHOICES, blank=True, verbose_name="Anrede" ) vorname = models.CharField(max_length=100, verbose_name="Vorname") nachname = models.CharField(max_length=100, verbose_name="Nachname") titel = models.CharField(max_length=50, blank=True, verbose_name="Titel") # Kontaktdaten email = models.EmailField(blank=True, verbose_name="E-Mail") telefon = models.CharField(max_length=20, blank=True, verbose_name="Telefon") mobil = models.CharField(max_length=20, blank=True, verbose_name="Mobil") # Adresse strasse = models.CharField(max_length=200, blank=True, verbose_name="Straße") plz = models.CharField(max_length=10, blank=True, verbose_name="PLZ") ort = models.CharField(max_length=100, blank=True, verbose_name="Ort") # Bankdaten für Abrechnungen iban = models.CharField(max_length=34, blank=True, verbose_name="IBAN") bic = models.CharField(max_length=11, blank=True, verbose_name="BIC") bank_name = models.CharField(max_length=100, blank=True, verbose_name="Bank") # Stiftungs-spezifisch seit_datum = models.DateField(verbose_name="Rentmeister seit") bis_datum = models.DateField(null=True, blank=True, verbose_name="Rentmeister bis") aktiv = models.BooleanField(default=True, verbose_name="Aktiv") # Vergütung/Aufwandsentschädigung monatliche_verguetung = models.DecimalField( max_digits=8, decimal_places=2, null=True, blank=True, verbose_name="Monatliche Vergütung (€)", ) km_pauschale = models.DecimalField( max_digits=4, decimal_places=2, default=0.30, verbose_name="Kilometerpauschale (€/km)", ) notizen = models.TextField(blank=True, verbose_name="Notizen") erstellt_am = models.DateTimeField(auto_now_add=True) aktualisiert_am = models.DateTimeField(auto_now=True) class Meta: verbose_name = "Rentmeister" verbose_name_plural = "Rentmeister" ordering = ["nachname", "vorname"] def __str__(self): name_parts = [] if self.anrede: name_parts.append(self.get_anrede_display()) if self.vorname: name_parts.append(self.vorname) name_parts.append(self.nachname) if self.titel: name_parts.append(f"({self.titel})") return " ".join(name_parts) def get_full_name(self): """Vollständiger Name ohne Anrede""" if self.vorname: return f"{self.vorname} {self.nachname}" return self.nachname def get_address(self): """Vollständige Adresse als String""" parts = [] if self.strasse: parts.append(self.strasse) if self.plz and self.ort: parts.append(f"{self.plz} {self.ort}") elif self.ort: parts.append(self.ort) return ", ".join(parts) class StiftungsKonto(models.Model): """Bankkonten der Stiftung""" KONTO_TYP_CHOICES = [ ("girokonto", "Girokonto"), ("sparkonto", "Sparkonto"), ("festgeld", "Festgeld"), ("tagesgeld", "Tagesgeld"), ("depot", "Depot"), ("sonstiges", "Sonstiges"), ] id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) kontoname = models.CharField(max_length=200, verbose_name="Kontoname") bank_name = models.CharField(max_length=200, verbose_name="Bank") iban = models.CharField(max_length=34, verbose_name="IBAN") bic = models.CharField(max_length=11, blank=True, verbose_name="BIC") konto_typ = models.CharField( max_length=20, choices=KONTO_TYP_CHOICES, default="girokonto", verbose_name="Kontotyp", ) saldo = models.DecimalField( max_digits=10, decimal_places=2, default=0.00, verbose_name="Aktueller Saldo" ) saldo_datum = models.DateField(null=True, blank=True, verbose_name="Saldo-Datum") zinssatz = models.DecimalField( max_digits=5, decimal_places=2, null=True, blank=True, verbose_name="Zinssatz (%)", ) laufzeit_bis = models.DateField(null=True, blank=True, verbose_name="Laufzeit bis") aktiv = models.BooleanField(default=True, verbose_name="Aktiv") notizen = models.TextField(blank=True, verbose_name="Notizen") erstellt_am = models.DateTimeField(auto_now_add=True) aktualisiert_am = models.DateTimeField(auto_now=True) class Meta: verbose_name = "Stiftungskonto" verbose_name_plural = "Stiftungskonten" ordering = ["bank_name", "kontoname"] def __str__(self): return f"{self.bank_name} - {self.kontoname}" class BankTransaction(models.Model): """Banktransaktionen aus importierten Kontodaten""" TRANSACTION_TYPE_CHOICES = [ ("eingang", "Eingang"), ("ausgang", "Ausgang"), ("lastschrift", "Lastschrift"), ("ueberweisung", "Überweisung"), ("dauerauftrag", "Dauerauftrag"), ("kartenzahlung", "Kartenzahlung"), ("zinsen", "Zinsen"), ("gebuehren", "Gebühren"), ("sonstiges", "Sonstiges"), ] STATUS_CHOICES = [ ("imported", "Importiert"), ("verified", "Geprüft"), ("assigned", "Zugeordnet"), ("ignored", "Ignoriert"), ] id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) konto = models.ForeignKey( StiftungsKonto, on_delete=models.CASCADE, verbose_name="Konto" ) # Transaktionsdaten datum = models.DateField(verbose_name="Buchungsdatum") valuta = models.DateField(null=True, blank=True, verbose_name="Valutadatum") betrag = models.DecimalField( max_digits=12, decimal_places=2, verbose_name="Betrag (€)" ) waehrung = models.CharField(max_length=3, default="EUR", verbose_name="Währung") # Transaktionsdetails verwendungszweck = models.TextField(verbose_name="Verwendungszweck") empfaenger_zahlungspflichtiger = models.CharField( max_length=200, blank=True, verbose_name="Empfänger/Zahlungspflichtiger" ) iban_gegenpartei = models.CharField( max_length=34, blank=True, verbose_name="IBAN Gegenpartei" ) bic_gegenpartei = models.CharField( max_length=11, blank=True, verbose_name="BIC Gegenpartei" ) # Bankspezifische Daten referenz = models.CharField( max_length=100, blank=True, verbose_name="Referenz/Transaktions-ID" ) transaction_type = models.CharField( max_length=20, choices=TRANSACTION_TYPE_CHOICES, default="sonstiges", verbose_name="Transaktionsart", ) # Verwaltung status = models.CharField( max_length=20, choices=STATUS_CHOICES, default="imported", verbose_name="Status" ) kommentare = models.TextField(blank=True, verbose_name="Kommentare") verwaltungskosten = models.ForeignKey( "Verwaltungskosten", null=True, blank=True, on_delete=models.SET_NULL, verbose_name="Zugeordnete Verwaltungskosten", ) # Import-Metadaten import_datei = models.CharField( max_length=255, blank=True, verbose_name="Import-Datei" ) importiert_am = models.DateTimeField( auto_now_add=True, verbose_name="Importiert am" ) saldo_nach_buchung = models.DecimalField( max_digits=12, decimal_places=2, null=True, blank=True, verbose_name="Saldo nach Buchung", ) class Meta: verbose_name = "Banktransaktion" verbose_name_plural = "Banktransaktionen" ordering = ["-datum", "-importiert_am"] unique_together = ["konto", "datum", "betrag", "referenz"] # Prevent duplicates def __str__(self): return f"{self.datum} - {self.betrag}€ - {self.verwendungszweck[:50]}" def is_income(self): """Prüft ob es sich um einen Geldeingang handelt""" return self.betrag > 0 def get_absolute_amount(self): """Gibt den absoluten Betrag zurück""" return abs(self.betrag) class Verwaltungskosten(models.Model): """Administrative Kosten und Ausgaben der Stiftung""" KATEGORIE_CHOICES = [ ("rechnung_intern", "Interne Rechnung"), ("bueroausstattung", "Büroausstattung"), ("fahrtkosten", "Fahrtkosten"), ("porto", "Porto & Versand"), ("telefon_internet", "Telefon & Internet"), ("software", "Software & Lizenzen"), ("beratung", "Beratung & Dienstleistungen"), ("versicherung", "Versicherungen"), ("steuerberatung", "Steuerberatung"), ("bankgebuehren", "Bankgebühren"), ("sonstiges", "Sonstiges"), ] STATUS_CHOICES = [ ("geplant", "Geplant"), ("bestellt", "Bestellt"), ("erhalten", "Erhalten"), ("in_bearbeitung", "In Bearbeitung"), ("bezahlt", "Bezahlt"), ("storniert", "Storniert"), ] id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) bezeichnung = models.CharField(max_length=200, verbose_name="Bezeichnung") kategorie = models.CharField( max_length=30, choices=KATEGORIE_CHOICES, verbose_name="Kategorie" ) betrag = models.DecimalField( max_digits=10, decimal_places=2, verbose_name="Betrag (€)" ) datum = models.DateField(verbose_name="Datum") lieferant_firma = models.CharField( max_length=200, blank=True, verbose_name="Lieferant/Firma" ) rechnungsnummer = models.CharField( max_length=100, blank=True, verbose_name="Rechnungsnummer" ) status = models.CharField( max_length=20, choices=STATUS_CHOICES, default="geplant", verbose_name="Status" ) # Zuständigkeit und Zahlung rentmeister = models.ForeignKey( Rentmeister, on_delete=models.SET_NULL, null=True, blank=True, verbose_name="Zuständiger Rentmeister", ) zahlungskonto = models.ForeignKey( StiftungsKonto, on_delete=models.SET_NULL, null=True, blank=True, related_name="zahlungen", verbose_name="Zahlungskonto", ) quellkonto = models.ForeignKey( StiftungsKonto, on_delete=models.SET_NULL, null=True, blank=True, related_name="ausgaben", verbose_name="Quellkonto", ) # Legacy field für Rückwärtskompatibilität konto = models.ForeignKey( StiftungsKonto, on_delete=models.SET_NULL, null=True, blank=True, verbose_name="Konto (Legacy)", help_text="Veraltet - verwende Zahlungskonto und Quellkonto", ) # Fahrtkosten spezifisch km_anzahl = models.DecimalField( max_digits=8, decimal_places=1, null=True, blank=True, verbose_name="Kilometer" ) km_satz = models.DecimalField( max_digits=4, decimal_places=2, null=True, blank=True, verbose_name="€/km" ) von_ort = models.CharField(max_length=100, blank=True, verbose_name="Von (Ort)") nach_ort = models.CharField(max_length=100, blank=True, verbose_name="Nach (Ort)") zweck = models.CharField(max_length=200, blank=True, verbose_name="Zweck der Fahrt") beschreibung = models.TextField(blank=True, verbose_name="Beschreibung") notizen = models.TextField(blank=True, verbose_name="Notizen") erstellt_am = models.DateTimeField(auto_now_add=True) aktualisiert_am = models.DateTimeField(auto_now=True) class Meta: verbose_name = "Verwaltungskosten" verbose_name_plural = "Verwaltungskosten" ordering = ["-datum", "-erstellt_am"] def __str__(self): return f"{self.bezeichnung} - €{self.betrag} ({self.datum})" def get_status_color(self): colors = { "geplant": "secondary", "bestellt": "warning", "erhalten": "info", "in_bearbeitung": "primary", "bezahlt": "success", "storniert": "danger", } return colors.get(self.status, "secondary") def get_effective_zahlungskonto(self): """Gibt das Zahlungskonto zurück, fallback auf Legacy-Konto""" return self.zahlungskonto or self.konto def get_effective_quellkonto(self): """Gibt das Quellkonto zurück, fallback auf Zahlungskonto oder Legacy-Konto""" return self.quellkonto or self.zahlungskonto or self.konto def is_fahrtkosten(self): """Prüft ob es sich um Fahrtkosten handelt""" return self.kategorie == "fahrtkosten" def calculate_fahrtkosten(self): """Berechnet Fahrtkosten automatisch wenn km_anzahl und km_satz gesetzt sind""" if self.km_anzahl and self.km_satz: return self.km_anzahl * self.km_satz return None class ApplicationPermission(models.Model): """Custom permissions for application functions""" class Meta: managed = False # No database table creation default_permissions = () # Remove default Django permissions permissions = [ # Entity Management Permissions ("manage_destinataere", "Kann Destinatäre verwalten"), ("view_destinataere", "Kann Destinatäre anzeigen"), ("manage_land", "Kann Ländereien verwalten"), ("view_land", "Kann Ländereien anzeigen"), ("manage_paechter", "Kann Pächter verwalten"), ("view_paechter", "Kann Pächter anzeigen"), ("manage_verpachtungen", "Kann Verpachtungen verwalten"), ("view_verpachtungen", "Kann Verpachtungen anzeigen"), ("manage_foerderungen", "Kann Förderungen verwalten"), ("view_foerderungen", "Kann Förderungen anzeigen"), # Document Management Permissions ("manage_documents", "Kann Dokumente verwalten"), ("view_documents", "Kann Dokumente anzeigen"), ("link_documents", "Kann Dokumente verknüpfen"), # Financial Management Permissions ("manage_verwaltungskosten", "Kann Verwaltungskosten verwalten"), ("view_verwaltungskosten", "Kann Verwaltungskosten anzeigen"), ("approve_payments", "Kann Zahlungen genehmigen"), ("manage_konten", "Kann Stiftungskonten verwalten"), ("view_konten", "Kann Stiftungskonten anzeigen"), ("manage_rentmeister", "Kann Rentmeister verwalten"), ("view_rentmeister", "Kann Rentmeister anzeigen"), # Administration Permissions ("access_administration", "Kann Administration aufrufen"), ("view_audit_logs", "Kann Audit-Logs anzeigen"), ("manage_backups", "Kann Backups erstellen und verwalten"), ("manage_users", "Kann Benutzer verwalten"), ("manage_permissions", "Kann Berechtigungen verwalten"), # Import/Export Permissions ("import_data", "Kann Daten importieren"), ("export_data", "Kann Daten exportieren"), # System Permissions ("access_django_admin", "Kann Django Admin aufrufen"), ("view_system_stats", "Kann Systemstatistiken anzeigen"), ] class AuditLog(models.Model): """Audit Log für alle Benutzeraktionen im System""" ACTION_TYPES = [ ("create", "Erstellt"), ("update", "Aktualisiert"), ("delete", "Gelöscht"), ("link", "Verknüpft"), ("unlink", "Verknüpfung entfernt"), ("login", "Anmeldung"), ("logout", "Abmeldung"), ("backup", "Backup erstellt"), ("restore", "Wiederherstellung"), ("export", "Export"), ("import", "Import"), ] ENTITY_TYPES = [ ("destinataer", "Destinatär"), ("land", "Länderei"), ("paechter", "Pächter"), ("verpachtung", "Verpachtung"), ("foerderung", "Förderung"), ("rentmeister", "Rentmeister"), ("stiftungskonto", "Stiftungskonto"), ("verwaltungskosten", "Verwaltungskosten"), ("banktransaction", "Bank-Transaktion"), ("dokumentlink", "Dokument-Verknüpfung"), ("system", "System"), ("user", "Benutzer"), ] id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) # Benutzer und Zeitpunkt user = models.ForeignKey( "auth.User", on_delete=models.SET_NULL, null=True, verbose_name="Benutzer" ) username = models.CharField( max_length=150, verbose_name="Benutzername" ) # Fallback falls User gelöscht wird timestamp = models.DateTimeField(auto_now_add=True, verbose_name="Zeitpunkt") # Aktion action = models.CharField( max_length=20, choices=ACTION_TYPES, verbose_name="Aktion" ) entity_type = models.CharField( max_length=20, choices=ENTITY_TYPES, verbose_name="Entitätstyp" ) entity_id = models.CharField(max_length=100, blank=True, verbose_name="Entitäts-ID") entity_name = models.CharField(max_length=255, verbose_name="Entitätsname") # Details description = models.TextField(verbose_name="Beschreibung") changes = models.JSONField( null=True, blank=True, verbose_name="Änderungen" ) # Alte und neue Werte # Request-Informationen ip_address = models.GenericIPAddressField( null=True, blank=True, verbose_name="IP-Adresse" ) user_agent = models.TextField(blank=True, verbose_name="User Agent") session_key = models.CharField( max_length=40, blank=True, verbose_name="Session-Key" ) class Meta: verbose_name = "Audit Log Eintrag" verbose_name_plural = "Audit Log Einträge" ordering = ["-timestamp"] indexes = [ models.Index(fields=["timestamp"]), models.Index(fields=["user", "timestamp"]), models.Index(fields=["entity_type", "timestamp"]), models.Index(fields=["action", "timestamp"]), ] def __str__(self): return f"{self.username} - {self.get_action_display()} {self.get_entity_type_display()} '{self.entity_name}' ({self.timestamp.strftime('%d.%m.%Y %H:%M')})" def get_changes_summary(self): """Erstellt eine lesbare Zusammenfassung der Änderungen""" if not self.changes: return "Keine Details verfügbar" if isinstance(self.changes, dict): summary = [] for field, values in self.changes.items(): if isinstance(values, dict) and "old" in values and "new" in values: old_val = values["old"] or "Leer" new_val = values["new"] or "Leer" summary.append(f"{field}: '{old_val}' → '{new_val}'") return "; ".join(summary) if summary else "Keine Änderungen dokumentiert" return str(self.changes) class BackupJob(models.Model): """Backup-Jobs und deren Status""" STATUS_CHOICES = [ ("pending", "Wartend"), ("running", "Läuft"), ("completed", "Abgeschlossen"), ("failed", "Fehlgeschlagen"), ] TYPE_CHOICES = [ ("full", "Vollständiges Backup"), ("database", "Nur Datenbank"), ("files", "Nur Dateien"), ] id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) # Job-Details backup_type = models.CharField( max_length=20, choices=TYPE_CHOICES, verbose_name="Backup-Typ" ) status = models.CharField( max_length=20, choices=STATUS_CHOICES, default="pending", verbose_name="Status" ) # Ausführung created_by = models.ForeignKey( "auth.User", on_delete=models.SET_NULL, null=True, verbose_name="Erstellt von" ) created_at = models.DateTimeField(auto_now_add=True, verbose_name="Erstellt am") started_at = models.DateTimeField( null=True, blank=True, verbose_name="Gestartet am" ) completed_at = models.DateTimeField( null=True, blank=True, verbose_name="Abgeschlossen am" ) # Ergebnis backup_filename = models.CharField( max_length=255, blank=True, verbose_name="Backup-Dateiname" ) backup_size = models.BigIntegerField( null=True, blank=True, verbose_name="Backup-Größe (Bytes)" ) error_message = models.TextField(blank=True, verbose_name="Fehlermeldung") # Metadaten database_size = models.BigIntegerField( null=True, blank=True, verbose_name="Datenbankgröße (Bytes)" ) files_count = models.IntegerField( null=True, blank=True, verbose_name="Anzahl Dateien" ) class Meta: verbose_name = "Backup-Job" verbose_name_plural = "Backup-Jobs" ordering = ["-created_at"] def __str__(self): return f"{self.get_backup_type_display()} - {self.get_status_display()} ({self.created_at.strftime('%d.%m.%Y %H:%M')})" def get_duration(self): """Berechnet die Dauer des Backup-Jobs""" if self.started_at and self.completed_at: return self.completed_at - self.started_at elif self.started_at: from django.utils import timezone return timezone.now() - self.started_at return None def get_size_display(self): """Formatiert die Backup-Größe für die Anzeige""" if not self.backup_size: return "Unbekannt" size = self.backup_size for unit in ["B", "KB", "MB", "GB"]: if size < 1024: return f"{size:.1f} {unit}" size /= 1024 return f"{size:.1f} TB" class AppConfiguration(models.Model): """Application configuration settings that can be managed through the admin interface""" SETTING_TYPE_CHOICES = [ ("text", "Text"), ("number", "Number"), ("boolean", "Boolean"), ("url", "URL"), ("tag", "Tag Name"), ("tag_id", "Tag ID"), ] CATEGORY_CHOICES = [ ("paperless", "Paperless Integration"), ("general", "General Settings"), ("corporate", "Corporate Identity"), ("notifications", "Notifications"), ("system", "System Settings"), ] id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) key = models.CharField(max_length=100, unique=True, verbose_name="Setting Key") display_name = models.CharField(max_length=200, verbose_name="Display Name") description = models.TextField(blank=True, null=True, verbose_name="Description") value = models.TextField(verbose_name="Value") default_value = models.TextField(verbose_name="Default Value") setting_type = models.CharField( max_length=20, choices=SETTING_TYPE_CHOICES, default="text", verbose_name="Type" ) category = models.CharField( max_length=50, choices=CATEGORY_CHOICES, default="general", verbose_name="Category", ) is_active = models.BooleanField(default=True, verbose_name="Active") is_system = models.BooleanField( default=False, verbose_name="System Setting (read-only)" ) order = models.IntegerField(default=0, verbose_name="Display Order") # Metadata created_at = models.DateTimeField(auto_now_add=True) updated_at = models.DateTimeField(auto_now=True) class Meta: verbose_name = "App Configuration" verbose_name_plural = "App Configurations" ordering = ["category", "order", "display_name"] def __str__(self): return f"{self.display_name} ({self.key})" def get_typed_value(self): """Return the value converted to the appropriate type""" if self.setting_type == "boolean": return self.value.lower() in ("true", "1", "yes", "on") elif self.setting_type == "number": try: if "." in self.value: return float(self.value) return int(self.value) except (ValueError, TypeError): return 0 return self.value @classmethod def get_setting(cls, key, default=None): """Get a setting value by key""" try: setting = cls.objects.get(key=key, is_active=True) return setting.get_typed_value() except cls.DoesNotExist: return default @classmethod def set_setting( cls, key, value, display_name=None, description=None, setting_type="text", category="general", ): """Set or update a setting value""" setting, created = cls.objects.get_or_create( key=key, defaults={ "display_name": display_name or key, "description": description, "value": str(value), "default_value": str(value), "setting_type": setting_type, "category": category, }, ) if not created: setting.value = str(value) setting.save() return setting class HelpBox(models.Model): """Editierbare Hilfe-Infoboxen für Formulare""" PAGE_CHOICES = [ ("destinataer_new", "Neuer Destinatär"), ("unterstuetzung_new", "Neue Unterstützung"), ("foerderung_new", "Neue Förderung"), ("paechter_new", "Neuer Pächter"), ("laenderei_new", "Neue Länderei"), ("verpachtung_new", "Neue Verpachtung"), ("land_abrechnung_new", "Neue Landabrechnung"), ("person_new", "Neue Person"), ("konto_new", "Neues Konto"), ("verwaltungskosten_new", "Neue Verwaltungskosten"), ("rentmeister_new", "Neuer Rentmeister"), ("dokument_new", "Neues Dokument"), ("user_new", "Neuer Benutzer"), ("csv_import_new", "CSV Import"), ("destinataer_notiz_new", "Destinatär Notiz"), ] id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) page_key = models.CharField( max_length=50, choices=PAGE_CHOICES, unique=True, verbose_name="Seite" ) title = models.CharField(max_length=200, verbose_name="Titel der Hilfsbox") content = models.TextField( verbose_name="Inhalt (Markdown unterstützt)", help_text="Sie können Markdown verwenden: **fett**, *kursiv*, `code`, [Link](url), etc.", ) is_active = models.BooleanField(default=True, verbose_name="Aktiv") # Metadata created_at = models.DateTimeField(auto_now_add=True, verbose_name="Erstellt am") updated_at = models.DateTimeField(auto_now=True, verbose_name="Aktualisiert am") created_by = models.CharField( max_length=100, null=True, blank=True, verbose_name="Erstellt von" ) updated_by = models.CharField( max_length=100, null=True, blank=True, verbose_name="Aktualisiert von" ) class Meta: verbose_name = "Hilfs-Infobox" verbose_name_plural = "Hilfs-Infoboxen" ordering = ["page_key"] def __str__(self): return f"{self.get_page_key_display()}: {self.title}" @classmethod def get_help_for_page(cls, page_key): """Hole die aktive Hilfs-Infobox für eine bestimmte Seite""" try: return cls.objects.get(page_key=page_key, is_active=True) except cls.DoesNotExist: return None class VierteljahresNachweis(models.Model): """Quarterly confirmation system for Destinatäre""" QUARTAL_CHOICES = [ (1, "Q1 (Jan-Mär)"), (2, "Q2 (Apr-Jun)"), (3, "Q3 (Jul-Sep)"), (4, "Q4 (Okt-Dez)"), ] STATUS_CHOICES = [ ("offen", "Nachweis ausstehend"), ("teilweise", "Teilweise eingereicht"), ("eingereicht", "Vollständig eingereicht"), ("geprueft", "Geprüft & Freigegeben"), ("nachbesserung", "Nachbesserung erforderlich"), ("abgelehnt", "Abgelehnt"), ] id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) destinataer = models.ForeignKey( Destinataer, on_delete=models.CASCADE, related_name="quartalseinreichungen", verbose_name="Destinatär" ) # Time period jahr = models.IntegerField( verbose_name="Jahr", validators=[MinValueValidator(2020), MaxValueValidator(2050)] ) quartal = models.IntegerField( choices=QUARTAL_CHOICES, verbose_name="Quartal" ) # Study proof (if required) studiennachweis_erforderlich = models.BooleanField( default=True, verbose_name="Studiennachweis erforderlich" ) studiennachweis_eingereicht = models.BooleanField( default=False, verbose_name="Studiennachweis eingereicht" ) studiennachweis_datei = models.FileField( upload_to="quarterly_proofs/studies/%Y/Q%m/", null=True, blank=True, verbose_name="Studiennachweis (Datei)" ) studiennachweis_bemerkung = models.TextField( null=True, blank=True, verbose_name="Bemerkung zum Studiennachweis" ) # Income/situation confirmation einkommenssituation_bestaetigt = models.BooleanField( default=False, verbose_name="Einkommenssituation bestätigt" ) einkommenssituation_text = models.TextField( null=True, blank=True, verbose_name="Einkommenssituation (Text)", help_text="Z.B. 'Keine Änderungen seit letzter Meldung' oder Details zu Änderungen" ) einkommenssituation_datei = models.FileField( upload_to="quarterly_proofs/income/%Y/Q%m/", null=True, blank=True, verbose_name="Einkommenssituation (Datei)" ) # Asset/wealth confirmation vermogenssituation_bestaetigt = models.BooleanField( default=False, verbose_name="Vermögenssituation bestätigt" ) vermogenssituation_text = models.TextField( null=True, blank=True, verbose_name="Vermögenssituation (Text)", help_text="Z.B. 'Keine Änderungen seit letzter Meldung' oder Details zu Änderungen" ) vermogenssituation_datei = models.FileField( upload_to="quarterly_proofs/assets/%Y/Q%m/", null=True, blank=True, verbose_name="Vermögenssituation (Datei)" ) # Additional documents weitere_dokumente = models.FileField( upload_to="quarterly_proofs/additional/%Y/Q%m/", null=True, blank=True, verbose_name="Weitere Dokumente" ) weitere_dokumente_beschreibung = models.TextField( null=True, blank=True, verbose_name="Beschreibung weitere Dokumente" ) # Review and approval status = models.CharField( max_length=20, choices=STATUS_CHOICES, default="offen", verbose_name="Status" ) interne_notizen = models.TextField( null=True, blank=True, verbose_name="Interne Notizen (nur für Verwaltung)" ) # Timestamps and tracking erstellt_am = models.DateTimeField(auto_now_add=True, verbose_name="Erstellt am") aktualisiert_am = models.DateTimeField(auto_now=True, verbose_name="Aktualisiert am") eingereicht_am = models.DateTimeField( null=True, blank=True, verbose_name="Eingereicht am" ) geprueft_am = models.DateTimeField( null=True, blank=True, verbose_name="Geprüft am" ) geprueft_von = models.ForeignKey( "auth.User", on_delete=models.SET_NULL, null=True, blank=True, verbose_name="Geprüft von" ) # Deadline tracking faelligkeitsdatum = models.DateField( null=True, blank=True, verbose_name="Fälligkeitsdatum" ) class Meta: verbose_name = "Vierteljahresnachweis" verbose_name_plural = "Vierteljahresnachweise" ordering = ["-jahr", "-quartal", "destinataer__nachname"] unique_together = ["destinataer", "jahr", "quartal"] # One entry per quarter per person indexes = [ models.Index(fields=["jahr", "quartal", "status"]), models.Index(fields=["destinataer", "status"]), models.Index(fields=["faelligkeitsdatum"]), ] def __str__(self): return f"{self.destinataer.get_full_name()} - {self.jahr} Q{self.quartal} ({self.get_status_display()})" def get_quarter_display(self): """Get a nice display name for the quarter""" quarter_names = { 1: "Q1 (Januar - März)", 2: "Q2 (April - Juni)", 3: "Q3 (Juli - September)", 4: "Q4 (Oktober - Dezember)" } return quarter_names.get(self.quartal, f"Q{self.quartal}") def is_complete(self): """Check if all required documents/confirmations are provided""" complete = True # Check study proof (always required now) complete &= self.studiennachweis_eingereicht and ( bool(self.studiennachweis_datei) or bool(self.studiennachweis_bemerkung) ) # Check income situation (either text or file) complete &= self.einkommenssituation_bestaetigt and ( bool(self.einkommenssituation_text) or bool(self.einkommenssituation_datei) ) # Check asset situation (either text or file) complete &= self.vermogenssituation_bestaetigt and ( bool(self.vermogenssituation_text) or bool(self.vermogenssituation_datei) ) return complete def is_overdue(self): """Check if the deadline has passed""" if not self.faelligkeitsdatum: return False return timezone.now().date() > self.faelligkeitsdatum and self.status in ["offen", "teilweise"] def get_completion_percentage(self): """Calculate completion percentage""" total_requirements = 2 # Income and assets always required completed_requirements = 0 # Study proof (if required) if self.studiennachweis_erforderlich: total_requirements += 1 if self.studiennachweis_eingereicht and ( bool(self.studiennachweis_datei) or bool(self.studiennachweis_bemerkung) ): completed_requirements += 1 # Income situation if self.einkommenssituation_bestaetigt and ( bool(self.einkommenssituation_text) or bool(self.einkommenssituation_datei) ): completed_requirements += 1 # Asset situation if self.vermogenssituation_bestaetigt and ( bool(self.vermogenssituation_text) or bool(self.vermogenssituation_datei) ): completed_requirements += 1 return int((completed_requirements / total_requirements) * 100) if total_requirements > 0 else 0 def save(self, *args, **kwargs): """Override save to auto-update status and timestamps""" # Auto-set deadline if not provided (15th of the quarter's second month) if not self.faelligkeitsdatum: from datetime import date quarter_deadlines = { 1: date(self.jahr, 2, 15), # Q1 deadline: Feb 15 2: date(self.jahr, 5, 15), # Q2 deadline: May 15 3: date(self.jahr, 8, 15), # Q3 deadline: Aug 15 4: date(self.jahr, 11, 15), # Q4 deadline: Nov 15 } self.faelligkeitsdatum = quarter_deadlines.get(self.quartal) # Auto-update status based on completion if self.is_complete(): if self.status == "offen": self.status = "eingereicht" self.eingereicht_am = timezone.now() else: completion = self.get_completion_percentage() if completion > 0 and completion < 100 and self.status == "offen": self.status = "teilweise" super().save(*args, **kwargs) @classmethod def get_or_create_for_period(cls, destinataer, jahr, quartal): """Get or create a quarterly confirmation for a specific period""" nachweis, created = cls.objects.get_or_create( destinataer=destinataer, jahr=jahr, quartal=quartal, defaults={ 'studiennachweis_erforderlich': destinataer.studiennachweis_erforderlich, 'status': 'offen' } ) return nachweis, created @classmethod def get_current_quarter(cls): """Get the current quarter based on today's date""" from datetime import date today = date.today() month = today.month if month <= 3: return today.year, 1 elif month <= 6: return today.year, 2 elif month <= 9: return today.year, 3 else: return today.year, 4 @classmethod def get_overdue_confirmations(cls): """Get all overdue quarterly confirmations""" from datetime import date today = date.today() return cls.objects.filter( faelligkeitsdatum__lt=today, status__in=["offen", "teilweise"] ).select_related("destinataer")