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