diff --git a/app/stiftung/models.py b/app/stiftung/models.py deleted file mode 100644 index c978e17..0000000 --- a/app/stiftung/models.py +++ /dev/null @@ -1,3496 +0,0 @@ -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() - - @property - def adresse(self): - """Construct full address from separate fields""" - parts = [] - if self.strasse: - parts.append(self.strasse) - if self.plz or self.ort: - city_part = [] - if self.plz: - city_part.append(self.plz) - if self.ort: - city_part.append(self.ort) - parts.append(" ".join(city_part)) - return "\n".join(parts) if parts else "" - - 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) - - @property - def adresse(self): - """Computed address property combining strasse, plz, ort""" - parts = [] - if self.strasse: - parts.append(self.strasse) - if self.plz and self.ort: - parts.append(f"{self.plz} {self.ort}") - elif self.plz: - parts.append(self.plz) - elif self.ort: - parts.append(self.ort) - return "\n".join(parts) if parts else None - - 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", - null=True, - blank=True, - 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, - null=True, - blank=True, - 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 and 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"), - # Veranstaltungen Permissions - ("manage_veranstaltungen", "Kann Veranstaltungen verwalten"), - ("view_veranstaltungen", "Kann Veranstaltungen anzeigen"), - # Import/Export Permissions - ("import_data", "Kann Daten importieren"), - ("export_data", "Kann Daten exportieren"), - # System Permissions - ("access_django_admin", "Kann Django Admin aufrufen"), - ("view_system_stats", "Kann Systemstatistiken anzeigen"), - ] - - -class AuditLog(models.Model): - """Audit Log für alle Benutzeraktionen im System""" - - ACTION_TYPES = [ - ("create", "Erstellt"), - ("update", "Aktualisiert"), - ("delete", "Gelöscht"), - ("link", "Verknüpft"), - ("unlink", "Verknüpfung entfernt"), - ("login", "Anmeldung"), - ("logout", "Abmeldung"), - ("backup", "Backup erstellt"), - ("restore", "Wiederherstellung"), - ("export", "Export"), - ("import", "Import"), - ] - - ENTITY_TYPES = [ - ("destinataer", "Destinatär"), - ("land", "Länderei"), - ("paechter", "Pächter"), - ("verpachtung", "Verpachtung"), - ("foerderung", "Förderung"), - ("rentmeister", "Rentmeister"), - ("stiftungskonto", "Stiftungskonto"), - ("verwaltungskosten", "Verwaltungskosten"), - ("banktransaction", "Bank-Transaktion"), - ("dokumentlink", "Dokument-Verknüpfung"), - ("system", "System"), - ("user", "Benutzer"), - ] - - id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) - - # Benutzer und Zeitpunkt - user = models.ForeignKey( - "auth.User", on_delete=models.SET_NULL, null=True, verbose_name="Benutzer" - ) - username = models.CharField( - max_length=150, verbose_name="Benutzername" - ) # Fallback falls User gelöscht wird - timestamp = models.DateTimeField(auto_now_add=True, verbose_name="Zeitpunkt") - - # Aktion - action = models.CharField( - max_length=20, choices=ACTION_TYPES, verbose_name="Aktion" - ) - entity_type = models.CharField( - max_length=20, choices=ENTITY_TYPES, verbose_name="Entitätstyp" - ) - entity_id = models.CharField(max_length=100, blank=True, verbose_name="Entitäts-ID") - entity_name = models.CharField(max_length=255, verbose_name="Entitätsname") - - # Details - description = models.TextField(verbose_name="Beschreibung") - changes = models.JSONField( - null=True, blank=True, verbose_name="Änderungen" - ) # Alte und neue Werte - - # Request-Informationen - ip_address = models.GenericIPAddressField( - null=True, blank=True, verbose_name="IP-Adresse" - ) - user_agent = models.TextField(blank=True, verbose_name="User Agent") - session_key = models.CharField( - max_length=40, blank=True, verbose_name="Session-Key" - ) - - class Meta: - verbose_name = "Audit Log Eintrag" - verbose_name_plural = "Audit Log Einträge" - ordering = ["-timestamp"] - indexes = [ - models.Index(fields=["timestamp"]), - models.Index(fields=["user", "timestamp"]), - models.Index(fields=["entity_type", "timestamp"]), - models.Index(fields=["action", "timestamp"]), - ] - - def __str__(self): - return f"{self.username} - {self.get_action_display()} {self.get_entity_type_display()} '{self.entity_name}' ({self.timestamp.strftime('%d.%m.%Y %H:%M')})" - - def get_changes_summary(self): - """Erstellt eine lesbare Zusammenfassung der Änderungen""" - if not self.changes: - return "Keine Details verfügbar" - - if isinstance(self.changes, dict): - summary = [] - for field, values in self.changes.items(): - if isinstance(values, dict) and "old" in values and "new" in values: - old_val = values["old"] or "Leer" - new_val = values["new"] or "Leer" - summary.append(f"{field}: '{old_val}' → '{new_val}'") - return "; ".join(summary) if summary else "Keine Änderungen dokumentiert" - - return str(self.changes) - - -class BackupJob(models.Model): - """Backup-Jobs und deren Status""" - - STATUS_CHOICES = [ - ("pending", "Wartend"), - ("running", "Läuft"), - ("completed", "Abgeschlossen"), - ("failed", "Fehlgeschlagen"), - ("cancelled", "Abgebrochen"), - ] - - TYPE_CHOICES = [ - ("full", "Vollständiges Backup"), - ("database", "Nur Datenbank"), - ("files", "Nur Dateien"), - ] - - OPERATION_CHOICES = [ - ("backup", "Backup"), - ("restore", "Wiederherstellung"), - ] - - id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) - - # Job-Details - operation = models.CharField( - max_length=20, choices=OPERATION_CHOICES, default="backup", verbose_name="Vorgang" - ) - backup_type = models.CharField( - max_length=20, choices=TYPE_CHOICES, verbose_name="Backup-Typ" - ) - status = models.CharField( - max_length=20, choices=STATUS_CHOICES, default="pending", verbose_name="Status" - ) - - # Ausführung - created_by = models.ForeignKey( - "auth.User", on_delete=models.SET_NULL, null=True, verbose_name="Erstellt von" - ) - created_at = models.DateTimeField(auto_now_add=True, verbose_name="Erstellt am") - started_at = models.DateTimeField( - null=True, blank=True, verbose_name="Gestartet am" - ) - completed_at = models.DateTimeField( - null=True, blank=True, verbose_name="Abgeschlossen am" - ) - - # Ergebnis - backup_filename = models.CharField( - max_length=255, blank=True, verbose_name="Backup-Dateiname" - ) - backup_size = models.BigIntegerField( - null=True, blank=True, verbose_name="Backup-Größe (Bytes)" - ) - error_message = models.TextField(blank=True, verbose_name="Fehlermeldung") - - # Metadaten - database_size = models.BigIntegerField( - null=True, blank=True, verbose_name="Datenbankgröße (Bytes)" - ) - files_count = models.IntegerField( - null=True, blank=True, verbose_name="Anzahl Dateien" - ) - - class Meta: - verbose_name = "Backup-Job" - verbose_name_plural = "Backup-Jobs" - ordering = ["-created_at"] - - def __str__(self): - return f"{self.get_backup_type_display()} - {self.get_status_display()} ({self.created_at.strftime('%d.%m.%Y %H:%M')})" - - def get_duration(self): - """Berechnet die Dauer des Backup-Jobs""" - if self.started_at and self.completed_at: - return self.completed_at - self.started_at - elif self.started_at: - from django.utils import timezone - - return timezone.now() - self.started_at - return None - - def get_size_display(self): - """Formatiert die Backup-Größe für die Anzeige""" - if not self.backup_size: - return "Unbekannt" - - size = self.backup_size - for unit in ["B", "KB", "MB", "GB"]: - if size < 1024: - return f"{size:.1f} {unit}" - size /= 1024 - return f"{size:.1f} TB" - - -class AppConfiguration(models.Model): - """Application configuration settings that can be managed through the admin interface""" - - SETTING_TYPE_CHOICES = [ - ("text", "Text"), - ("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"), - ("auto_geprueft", "Automatisch freigegeben (Semesterbasis)"), - ("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", - help_text="Veraltet - wird durch studiennachweis_faelligkeitsdatum und zahlung_faelligkeitsdatum ersetzt" - ) - - # Separate deadlines for study proof (semester-based) and payment (quarterly) - studiennachweis_faelligkeitsdatum = models.DateField( - null=True, - blank=True, - verbose_name="Studiennachweis Fälligkeitsdatum", - help_text="Semesterbasierte Frist: Q1/Q2 → 15. März, Q3/Q4 → 15. September" - ) - - zahlung_faelligkeitsdatum = models.DateField( - null=True, - blank=True, - verbose_name="Zahlungsfälligkeit", - help_text="Vierteljährliche Zahlungsfälligkeit im Voraus: Q1→15. Dez (Vorjahr), Q2→15. Mär, Q3→15. Jun, Q4→15. Sep" - ) - - 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 get_study_proof_deadline(self): - """Calculate semester-based study proof deadline""" - from datetime import date - # Q1, Q2 → March 15 (same year) - # Q3, Q4 → September 15 (same year) - if self.quartal in [1, 2]: - return date(self.jahr, 3, 15) - else: # Q3, Q4 - return date(self.jahr, 9, 15) - - def get_payment_due_date(self): - """Calculate quarterly payment due date (paid in advance)""" - from datetime import date - # Q1 → December 15 (previous year) - # Q2 → March 15 (same year) - # Q3 → June 15 (same year) - # Q4 → September 15 (same year) - if self.quartal == 1: - return date(self.jahr - 1, 12, 15) - elif self.quartal == 2: - return date(self.jahr, 3, 15) - elif self.quartal == 3: - return date(self.jahr, 6, 15) - else: # Q4 - return date(self.jahr, 9, 15) - - def is_study_proof_overdue(self): - """Check if study proof deadline has passed""" - if not self.studiennachweis_faelligkeitsdatum: - return False - from django.utils import timezone - return timezone.now().date() > self.studiennachweis_faelligkeitsdatum and not self.studiennachweis_eingereicht - - def is_payment_overdue(self): - """Check if payment due date has passed""" - if not self.zahlung_faelligkeitsdatum: - return False - from django.utils import timezone - # Payment is overdue if due date passed and no payment exists or payment is not completed - payment = self.get_related_support_payment() - if payment and payment.status in ['bezahlt', 'in_bearbeitung']: - return False - return timezone.now().date() > self.zahlung_faelligkeitsdatum - - def is_overdue(self): - """Check if either deadline has passed""" - return self.is_study_proof_overdue() or self.is_payment_overdue() - - def save(self, *args, **kwargs): - """Override save to auto-update status and timestamps""" - # Set study proof deadline (semester-based) if not provided - if not self.studiennachweis_faelligkeitsdatum: - self.studiennachweis_faelligkeitsdatum = self.get_study_proof_deadline() - - # Set payment due date (quarterly, advance) if not provided - if not self.zahlung_faelligkeitsdatum: - self.zahlung_faelligkeitsdatum = self.get_payment_due_date() - - # Backward compatibility: set faelligkeitsdatum from study proof deadline if not set - if not self.faelligkeitsdatum: - self.faelligkeitsdatum = self.studiennachweis_faelligkeitsdatum - - # 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) - - def get_related_support_payment(self): - """Get the related support payment for this quarterly confirmation""" - from datetime import date, timedelta - from django.db.models import Q - - # Use payment due date from quarterly confirmation for accurate search - # This is more accurate than using quarter date range, especially for Q1 (Dec 15 prev year) - payment_due_date = self.zahlung_faelligkeitsdatum - if not payment_due_date: - # Fallback: calculate if not set - if self.quartal == 1: - payment_due_date = date(self.jahr - 1, 12, 15) - elif self.quartal == 2: - payment_due_date = date(self.jahr, 3, 15) - elif self.quartal == 3: - payment_due_date = date(self.jahr, 6, 15) - else: # Q4 - payment_due_date = date(self.jahr, 9, 15) - - # Search for existing payment - match by payment due date and description - # Use a date range around the due date (±30 days) to catch any variations - date_start = payment_due_date - timedelta(days=30) - date_end = payment_due_date + timedelta(days=30) - - return DestinataerUnterstuetzung.objects.filter( - destinataer=self.destinataer, - faellig_am__gte=date_start, - faellig_am__lte=date_end - ).filter( - Q(beschreibung__contains=f"Q{self.quartal}/{self.jahr}") | - Q(beschreibung__contains=f"Vierteljährliche Unterstützung Q{self.quartal}/{self.jahr}") - ).first() - - def auto_approve_next_quarter(self): - """Auto-approve the next quarter when Q1 or Q3 is approved (semester-based logic)""" - if self.quartal in [1, 3] and self.status == "geprueft": - next_quarter = self.quartal + 1 - try: - next_nachweis = VierteljahresNachweis.objects.get( - destinataer=self.destinataer, - jahr=self.jahr, - quartal=next_quarter - ) - - if next_nachweis.status in ["offen", "teilweise"]: - # Copy study proof confirmations from current quarter (semester-based) - next_nachweis.studiennachweis_eingereicht = self.studiennachweis_eingereicht - next_nachweis.studiennachweis_datei = self.studiennachweis_datei - next_nachweis.studiennachweis_bemerkung = self.studiennachweis_bemerkung - - # Set study proof deadline for next quarter (same semester) - next_nachweis.studiennachweis_faelligkeitsdatum = next_nachweis.get_study_proof_deadline() - - # Set auto-approved status - next_nachweis.status = "auto_geprueft" - next_nachweis.geprueft_am = timezone.now() - next_nachweis.geprueft_von = self.geprueft_von - next_nachweis.save(update_fields=[ - 'studiennachweis_eingereicht', 'studiennachweis_datei', 'studiennachweis_bemerkung', - 'studiennachweis_faelligkeitsdatum', 'status', 'geprueft_am', 'geprueft_von' - ]) - - return next_nachweis - except VierteljahresNachweis.DoesNotExist: - pass - return None - - @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") - - def auto_approve_next_quarter(self): - """Auto-approve the next quarter when Q1 or Q3 is approved (semester-based logic)""" - if self.quartal in [1, 3] and self.status == "geprueft": - next_quarter = self.quartal + 1 - try: - next_nachweis = VierteljahresNachweis.objects.get( - destinataer=self.destinataer, - jahr=self.jahr, - quartal=next_quarter - ) - - if next_nachweis.status in ["offen", "teilweise"]: - # Copy document confirmations from current quarter - next_nachweis.studiennachweis_eingereicht = self.studiennachweis_eingereicht - next_nachweis.einkommenssituation_bestaetigt = self.einkommenssituation_bestaetigt - next_nachweis.vermogenssituation_bestaetigt = self.vermogenssituation_bestaetigt - - # Set auto-approved status - next_nachweis.status = "auto_geprueft" - next_nachweis.geprueft_am = timezone.now() - next_nachweis.geprueft_von = self.geprueft_von - next_nachweis.save(update_fields=[ - 'studiennachweis_eingereicht', 'einkommenssituation_bestaetigt', - 'vermogenssituation_bestaetigt', 'status', 'geprueft_am', 'geprueft_von' - ]) - - return next_nachweis - except VierteljahresNachweis.DoesNotExist: - pass - return None - - -class GeschichteSeite(models.Model): - """Wiki-style pages for foundation history""" - - id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) - titel = models.CharField(max_length=200, verbose_name="Titel") - slug = models.SlugField(max_length=200, unique=True, verbose_name="URL-Slug") - inhalt = models.TextField( - verbose_name="Inhalt (Markdown)", - blank=True, - help_text="Sie können Markdown verwenden: **fett**, *kursiv*, # Überschriften, [Links](URL), Listen, etc." - ) - - # Metadata - erstellt_am = models.DateTimeField(auto_now_add=True, verbose_name="Erstellt am") - aktualisiert_am = models.DateTimeField(auto_now=True, verbose_name="Aktualisiert am") - erstellt_von = models.ForeignKey( - 'auth.User', - on_delete=models.SET_NULL, - null=True, - blank=True, - related_name='geschichte_seiten_erstellt', - verbose_name="Erstellt von" - ) - aktualisiert_von = models.ForeignKey( - 'auth.User', - on_delete=models.SET_NULL, - null=True, - blank=True, - related_name='geschichte_seiten_aktualisiert', - verbose_name="Aktualisiert von" - ) - - # Options - ist_veroeffentlicht = models.BooleanField(default=True, verbose_name="Veröffentlicht") - sortierung = models.IntegerField(default=0, verbose_name="Sortierung") - - class Meta: - verbose_name = "Geschichte Seite" - verbose_name_plural = "Geschichte Seiten" - ordering = ['sortierung', 'titel'] - - def __str__(self): - return self.titel - - def get_absolute_url(self): - from django.urls import reverse - return reverse('stiftung:geschichte_detail', kwargs={'slug': self.slug}) - - -class GeschichteBild(models.Model): - """Images for history pages""" - - id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) - seite = models.ForeignKey( - GeschichteSeite, - on_delete=models.CASCADE, - related_name='bilder', - verbose_name="Geschichte Seite" - ) - titel = models.CharField(max_length=200, verbose_name="Bildtitel") - bild = models.ImageField( - upload_to='geschichte/bilder/%Y/%m/', - verbose_name="Bild" - ) - beschreibung = models.TextField(blank=True, verbose_name="Beschreibung") - alt_text = models.CharField(max_length=200, blank=True, verbose_name="Alt-Text") - - # Metadata - hochgeladen_am = models.DateTimeField(auto_now_add=True, verbose_name="Hochgeladen am") - hochgeladen_von = models.ForeignKey( - 'auth.User', - on_delete=models.SET_NULL, - null=True, - blank=True, - verbose_name="Hochgeladen von" - ) - - sortierung = models.IntegerField(default=0, verbose_name="Sortierung") - - class Meta: - verbose_name = "Geschichte Bild" - verbose_name_plural = "Geschichte Bilder" - ordering = ['sortierung', 'titel'] - - def __str__(self): - return f"{self.titel} ({self.seite.titel})" - - -class StiftungsKalenderEintrag(models.Model): - """Custom calendar events for foundation management""" - - KATEGORIE_CHOICES = [ - ('termin', 'Termin/Meeting'), - ('zahlung', 'Zahlungserinnerung'), - ('deadline', 'Frist/Deadline'), - ('geburtstag', 'Geburtstag'), - ('vertrag', 'Vertrag läuft aus'), - ('pruefung', 'Prüfung/Nachweis'), - ('sonstiges', 'Sonstiges'), - ] - - PRIORITAET_CHOICES = [ - ('niedrig', 'Niedrig'), - ('normal', 'Normal'), - ('hoch', 'Hoch'), - ('kritisch', 'Kritisch'), - ] - - id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) - titel = models.CharField(max_length=200, verbose_name="Titel") - beschreibung = models.TextField(blank=True, verbose_name="Beschreibung") - - # Date and time - datum = models.DateField(verbose_name="Datum") - uhrzeit = models.TimeField(null=True, blank=True, verbose_name="Uhrzeit") - ganztags = models.BooleanField(default=True, verbose_name="Ganztägig") - - # Categorization - kategorie = models.CharField( - max_length=20, - choices=KATEGORIE_CHOICES, - default='termin', - verbose_name="Kategorie" - ) - prioritaet = models.CharField( - max_length=20, - choices=PRIORITAET_CHOICES, - default='normal', - verbose_name="Priorität" - ) - - # Links to related objects - destinataer = models.ForeignKey( - 'Destinataer', - null=True, - blank=True, - on_delete=models.CASCADE, - verbose_name="Bezogener Destinatär" - ) - verpachtung = models.ForeignKey( - 'LandVerpachtung', - null=True, - blank=True, - on_delete=models.CASCADE, - verbose_name="Bezogene Verpachtung" - ) - - # Status and completion - erledigt = models.BooleanField(default=False, verbose_name="Erledigt") - erledigt_am = models.DateTimeField(null=True, blank=True, verbose_name="Erledigt am") - - # Metadata - erstellt_von = models.CharField( - max_length=100, - null=True, - blank=True, - verbose_name="Erstellt von" - ) - erstellt_am = models.DateTimeField(auto_now_add=True, verbose_name="Erstellt am") - aktualisiert_am = models.DateTimeField(auto_now=True, verbose_name="Aktualisiert am") - - class Meta: - verbose_name = "Kalender Eintrag" - verbose_name_plural = "Kalender Einträge" - ordering = ['datum', 'uhrzeit'] - indexes = [ - models.Index(fields=['datum']), - models.Index(fields=['kategorie', 'datum']), - models.Index(fields=['erledigt', 'datum']), - ] - - def __str__(self): - return f"{self.datum}: {self.titel}" - - def get_kategorie_icon(self): - icons = { - 'termin': 'fas fa-calendar-alt', - 'zahlung': 'fas fa-euro-sign', - 'deadline': 'fas fa-exclamation-triangle', - 'geburtstag': 'fas fa-birthday-cake', - 'vertrag': 'fas fa-file-contract', - 'pruefung': 'fas fa-clipboard-check', - 'sonstiges': 'fas fa-calendar', - } - return icons.get(self.kategorie, 'fas fa-calendar') - - def get_prioritaet_color(self): - colors = { - 'niedrig': 'success', - 'normal': 'primary', - 'hoch': 'warning', - 'kritisch': 'danger', - } - return colors.get(self.prioritaet, 'primary') - - def is_overdue(self): - """Check if event is overdue (past due and not completed)""" - if self.erledigt: - return False - return self.datum < timezone.now().date() - - def is_upcoming(self, days=7): - """Check if event is upcoming within specified days""" - if self.erledigt: - return False - today = timezone.now().date() - return today <= self.datum <= (today + timezone.timedelta(days=days)) - - -class DestinataerEmailEingang(models.Model): - """ - Erfasst eingehende E-Mails von Destinatären. - - Wird automatisch durch den Celery-Task `poll_destinataer_emails` befüllt, - der das IMAP-Postfach der Stiftung (paperless@vhtv-stiftung.de) überwacht. - Anhänge werden automatisch in Paperless-NGX hochgeladen und als DokumentLink - mit dem jeweiligen Destinatär verknüpft. - """ - - STATUS_CHOICES = [ - ("neu", "Neu / Unbearbeitet"), - ("zugewiesen", "Destinatär zugewiesen"), - ("verarbeitet", "Verarbeitet"), - ("unbekannt", "Unbekannter Absender"), - ("fehler", "Fehler bei Verarbeitung"), - ] - - id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) - - # Verknüpfung zum Destinatär (None = unbekannter Absender) - destinataer = models.ForeignKey( - Destinataer, - on_delete=models.SET_NULL, - null=True, - blank=True, - related_name="email_eingaenge", - verbose_name="Destinatär", - ) - - # E-Mail-Metadaten - absender_email = models.EmailField(verbose_name="Absender-E-Mail") - absender_name = models.CharField( - max_length=255, blank=True, verbose_name="Absender-Name" - ) - betreff = models.CharField(max_length=500, blank=True, verbose_name="Betreff") - eingangsdatum = models.DateTimeField(verbose_name="Eingangsdatum") - email_text = models.TextField(blank=True, verbose_name="E-Mail-Text") - - # Anhänge: Liste der Paperless-Dokument-IDs (JSON-Format) - paperless_dokument_ids = models.JSONField( - default=list, - blank=True, - verbose_name="Paperless Dokument-IDs (Anhänge)", - help_text="Automatisch befüllte Liste der hochgeladenen Anhänge in Paperless-NGX", - ) - - # Verarbeitungsstatus - status = models.CharField( - max_length=20, - choices=STATUS_CHOICES, - default="neu", - verbose_name="Status", - ) - fehler_details = models.TextField( - blank=True, - verbose_name="Fehlerdetails", - help_text="Technische Fehlermeldung bei Verarbeitungsfehlern", - ) - notizen = models.TextField( - blank=True, - verbose_name="Interne Notizen", - help_text="Manuelle Notizen der Verwaltung zur E-Mail", - ) - - # Verweis auf VierteljahresNachweis, falls E-Mail einem Quartal zugeordnet - quartalsnachweis = models.ForeignKey( - "VierteljahresNachweis", - on_delete=models.SET_NULL, - null=True, - blank=True, - related_name="email_eingaenge", - verbose_name="Quartalsnachweis (zugeordnet)", - ) - - created_at = models.DateTimeField(auto_now_add=True, verbose_name="Erfasst am") - - class Meta: - verbose_name = "E-Mail-Eingang (Destinatär)" - verbose_name_plural = "E-Mail-Eingänge (Destinatäre)" - ordering = ["-eingangsdatum"] - - def __str__(self): - dest = str(self.destinataer) if self.destinataer else self.absender_email - return f"[{self.eingangsdatum.strftime('%d.%m.%Y')}] {dest}: {self.betreff[:60]}" - - def get_paperless_links(self): - """Gibt Liste der Paperless-Dokument-URLs zurück.""" - from django.conf import settings - base = settings.PAPERLESS_API_URL or "" - return [ - f"{base}/documents/{doc_id}/" - for doc_id in (self.paperless_dokument_ids or []) - ] - - -class BriefVorlage(models.Model): - """Wiederverwendbare Briefvorlagen für Serienbriefe (Veranstaltungseinladungen u.ä.)""" - - name = models.CharField(max_length=100, verbose_name="Vorlagenname") - beschreibung = models.TextField( - blank=True, - verbose_name="Beschreibung", - help_text="Kurze Beschreibung des Verwendungszwecks dieser Vorlage.", - ) - briefvorlage = models.TextField( - verbose_name="Brieftext (HTML)", - help_text=( - "HTML-Text des Briefs. Verfügbare Platzhalter: " - "{{ anrede }}, {{ vorname }}, {{ nachname }}, {{ strasse }}, " - "{{ plz }}, {{ ort }}, {{ datum }}, {{ uhrzeit }}, " - "{{ veranstaltungsort }}, {{ gasthaus_adresse }}" - ), - ) - betreff = models.CharField( - max_length=300, - blank=True, - verbose_name="Standard-Betreff", - help_text="Wird beim Laden der Vorlage in die Veranstaltung übernommen. Leer = unveränderter Betreff.", - ) - - erstellt_am = models.DateTimeField(auto_now_add=True) - aktualisiert_am = models.DateTimeField(auto_now=True) - - class Meta: - verbose_name = "Briefvorlage" - verbose_name_plural = "Briefvorlagen" - ordering = ["name"] - - def __str__(self): - return self.name - - -class Veranstaltung(models.Model): - """Veranstaltungen der Stiftung, z.B. Stiftungsessen mit Rechnungslegung""" - - STATUS_CHOICES = [ - ("geplant", "Geplant"), - ("einladungen_versendet", "Einladungen versendet"), - ("abgeschlossen", "Abgeschlossen"), - ("abgesagt", "Abgesagt"), - ] - - id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) - titel = models.CharField(max_length=200, verbose_name="Titel") - datum = models.DateField(verbose_name="Datum") - uhrzeit = models.TimeField(null=True, blank=True, verbose_name="Uhrzeit") - ort = models.CharField(max_length=200, verbose_name="Ort / Gasthaus") - adresse = models.TextField(blank=True, verbose_name="Adresse Gasthaus") - beschreibung = models.TextField(blank=True, verbose_name="Beschreibung / Zweck") - status = models.CharField( - max_length=30, - choices=STATUS_CHOICES, - default="geplant", - verbose_name="Status", - ) - budget_pro_person = models.DecimalField( - max_digits=8, - decimal_places=2, - null=True, - blank=True, - verbose_name="Budget pro Person (€)", - help_text="Geschätztes Budget je Teilnehmer in €", - ) - briefvorlage = models.TextField( - blank=True, - verbose_name="Briefvorlage", - help_text=( - "HTML/Text-Template für Serienbrief. Platzhalter: " - "{{ anrede }}, {{ vorname }}, {{ nachname }}, {{ strasse }}, " - "{{ plz }}, {{ ort }}, {{ datum }}, {{ uhrzeit }}, " - "{{ veranstaltungsort }}, {{ gasthaus_adresse }}" - ), - ) - betreff = models.CharField( - max_length=300, - blank=True, - verbose_name="Betreff", - help_text="Betreffzeile des Serienbriefs. Leer = Standardbetreff.", - ) - unterschrift_1_name = models.CharField( - max_length=100, - blank=True, - default="Katrin Kleinpaß", - verbose_name="Unterschrift 1 – Name", - ) - unterschrift_1_titel = models.CharField( - max_length=100, - blank=True, - default="Rentmeisterin", - verbose_name="Unterschrift 1 – Titel", - ) - unterschrift_2_name = models.CharField( - max_length=100, - blank=True, - default="Jan Remmer Siebels", - verbose_name="Unterschrift 2 – Name", - ) - unterschrift_2_titel = models.CharField( - max_length=100, - blank=True, - default="Rentmeister", - verbose_name="Unterschrift 2 – Titel", - ) - - erstellt_am = models.DateTimeField(auto_now_add=True) - aktualisiert_am = models.DateTimeField(auto_now=True) - - class Meta: - verbose_name = "Veranstaltung" - verbose_name_plural = "Veranstaltungen" - ordering = ["-datum"] - - def __str__(self): - return f"{self.titel} ({self.datum})" - - def get_teilnehmer_count(self): - return self.teilnehmer.count() - - def get_zugesagte_count(self): - return self.teilnehmer.filter(rsvp_status="zugesagt").count() - - def get_abgesagte_count(self): - return self.teilnehmer.filter(rsvp_status="abgesagt").count() - - def get_keine_rueckmeldung_count(self): - return self.teilnehmer.filter(rsvp_status="keine_rueckmeldung").count() - - -class Veranstaltungsteilnehmer(models.Model): - """Teilnehmer einer Veranstaltung – primär freie Eingabe für Familienmitglieder""" - - ANREDE_CHOICES = [ - ("Herr", "Herr"), - ("Frau", "Frau"), - ("", "Keine Anrede"), - ] - - RSVP_CHOICES = [ - ("eingeladen", "Eingeladen"), - ("zugesagt", "Zugesagt"), - ("abgesagt", "Abgesagt"), - ("keine_rueckmeldung", "Keine Rückmeldung"), - ] - - id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) - veranstaltung = models.ForeignKey( - Veranstaltung, - on_delete=models.CASCADE, - related_name="teilnehmer", - verbose_name="Veranstaltung", - ) - - # Optionale Verknüpfung zu bestehenden Datensätzen - paechter = models.ForeignKey( - "Paechter", - null=True, - blank=True, - on_delete=models.SET_NULL, - verbose_name="Pächter (optional)", - ) - destinataer = models.ForeignKey( - "Destinataer", - null=True, - blank=True, - on_delete=models.SET_NULL, - verbose_name="Destinatär (optional)", - ) - - # Freie Felder (Pflichtfelder für Serienbrief) - anrede = models.CharField( - max_length=10, choices=ANREDE_CHOICES, blank=True, verbose_name="Anrede" - ) - vorname = models.CharField(max_length=100, verbose_name="Vorname") - nachname = models.CharField(max_length=100, verbose_name="Nachname") - strasse = models.CharField(max_length=200, blank=True, verbose_name="Straße") - plz = models.CharField(max_length=10, blank=True, verbose_name="PLZ") - ort = models.CharField(max_length=100, blank=True, verbose_name="Ort") - email = models.EmailField( - blank=True, verbose_name="E-Mail", help_text="Optional, für späteren E-Mail-Versand" - ) - - rsvp_status = models.CharField( - max_length=20, - choices=RSVP_CHOICES, - default="eingeladen", - verbose_name="RSVP-Status", - ) - bemerkungen = models.TextField(blank=True, verbose_name="Bemerkungen") - - erstellt_am = models.DateTimeField(auto_now_add=True) - - class Meta: - verbose_name = "Veranstaltungsteilnehmer" - verbose_name_plural = "Veranstaltungsteilnehmer" - ordering = ["nachname", "vorname"] - - def __str__(self): - return f"{self.anrede} {self.vorname} {self.nachname}".strip() - - def get_full_name(self): - return f"{self.vorname} {self.nachname}".strip() - - def get_full_address(self): - parts = [self.strasse, f"{self.plz} {self.ort}".strip()] - return ", ".join(p for p in parts if p)