From b4bad7bc8367df14e8040b53a76e153fd7d289a6 Mon Sep 17 00:00:00 2001 From: SysAdmin Agent Date: Wed, 11 Mar 2026 09:02:08 +0000 Subject: [PATCH] =?UTF-8?q?Phase=200:=20models.py=20=E2=86=92=20models/=20?= =?UTF-8?q?Package=20aufgeteilt?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit models.py (3.496 Zeilen) in 6 Domain-Module aufgeteilt: - system.py: CSVImport, ApplicationPermission, AuditLog, BackupJob, AppConfiguration, HelpBox - land.py: Paechter, Land, LandVerpachtung, LandAbrechnung, DokumentLink - finanzen.py: Rentmeister, StiftungsKonto, BankTransaction, Verwaltungskosten - destinataere.py: Destinataer, Person, Foerderung, DestinataerUnterstuetzung, UnterstuetzungWiederkehrend, DestinataerNotiz, VierteljahresNachweis, DestinataerEmailEingang - veranstaltungen.py: BriefVorlage, Veranstaltung, Veranstaltungsteilnehmer - geschichte.py: GeschichteSeite, GeschichteBild, StiftungsKalenderEintrag __init__.py re-exportiert alle Models für volle Rückwärtskompatibilität. Co-Authored-By: Claude Opus 4.6 --- app/stiftung/models/__init__.py | 49 + app/stiftung/models/destinataere.py | 1143 ++++++++++++++++++++++++ app/stiftung/models/finanzen.py | 385 ++++++++ app/stiftung/models/geschichte.py | 213 +++++ app/stiftung/models/land.py | 1089 ++++++++++++++++++++++ app/stiftung/models/system.py | 471 ++++++++++ app/stiftung/models/veranstaltungen.py | 215 +++++ 7 files changed, 3565 insertions(+) create mode 100644 app/stiftung/models/__init__.py create mode 100644 app/stiftung/models/destinataere.py create mode 100644 app/stiftung/models/finanzen.py create mode 100644 app/stiftung/models/geschichte.py create mode 100644 app/stiftung/models/land.py create mode 100644 app/stiftung/models/system.py create mode 100644 app/stiftung/models/veranstaltungen.py diff --git a/app/stiftung/models/__init__.py b/app/stiftung/models/__init__.py new file mode 100644 index 0000000..67ab57f --- /dev/null +++ b/app/stiftung/models/__init__.py @@ -0,0 +1,49 @@ +# models/ package – re-exports all models for backward compatibility +# Phase 0: Vision 2026 – Code-Refactoring + +from .system import ( # noqa: F401 + AppConfiguration, + ApplicationPermission, + AuditLog, + BackupJob, + CSVImport, + HelpBox, +) + +from .land import ( # noqa: F401 + DokumentLink, + Land, + LandAbrechnung, + LandVerpachtung, + Paechter, +) + +from .finanzen import ( # noqa: F401 + BankTransaction, + Rentmeister, + StiftungsKonto, + Verwaltungskosten, +) + +from .destinataere import ( # noqa: F401 + Destinataer, + DestinataerEmailEingang, + DestinataerNotiz, + DestinataerUnterstuetzung, + Foerderung, + Person, + UnterstuetzungWiederkehrend, + VierteljahresNachweis, +) + +from .geschichte import ( # noqa: F401 + GeschichteBild, + GeschichteSeite, + StiftungsKalenderEintrag, +) + +from .veranstaltungen import ( # noqa: F401 + BriefVorlage, + Veranstaltung, + Veranstaltungsteilnehmer, +) diff --git a/app/stiftung/models/destinataere.py b/app/stiftung/models/destinataere.py new file mode 100644 index 0000000..03f0cbb --- /dev/null +++ b/app/stiftung/models/destinataere.py @@ -0,0 +1,1143 @@ +import uuid + +from django.core.validators import MaxValueValidator, MinValueValidator +from django.db import models +from django.utils import timezone + +from .land import DokumentLink + + +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 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 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 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 []) + ] diff --git a/app/stiftung/models/finanzen.py b/app/stiftung/models/finanzen.py new file mode 100644 index 0000000..554f938 --- /dev/null +++ b/app/stiftung/models/finanzen.py @@ -0,0 +1,385 @@ +import uuid + +from django.db import models + + +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 diff --git a/app/stiftung/models/geschichte.py b/app/stiftung/models/geschichte.py new file mode 100644 index 0000000..08018ae --- /dev/null +++ b/app/stiftung/models/geschichte.py @@ -0,0 +1,213 @@ +import uuid + +from django.db import models +from django.utils import timezone + + +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( + 'stiftung.Destinataer', + null=True, + blank=True, + on_delete=models.CASCADE, + verbose_name="Bezogener Destinatär" + ) + verpachtung = models.ForeignKey( + 'stiftung.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)) diff --git a/app/stiftung/models/land.py b/app/stiftung/models/land.py new file mode 100644 index 0000000..bc66502 --- /dev/null +++ b/app/stiftung/models/land.py @@ -0,0 +1,1089 @@ +import uuid + +from django.core.validators import MinValueValidator +from django.db import models +from django.utils import timezone + +from stiftung.utils.date_utils import ensure_date, get_year_from_date + + +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 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: + from stiftung.models import Destinataer + 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: + from stiftung.models import Foerderung + 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 diff --git a/app/stiftung/models/system.py b/app/stiftung/models/system.py new file mode 100644 index 0000000..47eaa1f --- /dev/null +++ b/app/stiftung/models/system.py @@ -0,0 +1,471 @@ +import uuid + +from django.db import models + + +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 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 + diff --git a/app/stiftung/models/veranstaltungen.py b/app/stiftung/models/veranstaltungen.py new file mode 100644 index 0000000..9dba42e --- /dev/null +++ b/app/stiftung/models/veranstaltungen.py @@ -0,0 +1,215 @@ +import uuid + +from django.db import models + + +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( + "stiftung.Paechter", + null=True, + blank=True, + on_delete=models.SET_NULL, + verbose_name="Pächter (optional)", + ) + destinataer = models.ForeignKey( + "stiftung.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)