- Update quarterly confirmation deadlines to semester-based schedule: - Q1: March 15 (covers Spring semester Q1+Q2) - Q2: June 15 (auto-approved when Q1 approved) - Q3: September 15 (covers Fall semester Q3+Q4) - Q4: December 15 (auto-approved when Q3 approved) - Add auto-approval functionality: - Q1 approval automatically approves Q2 with same document status - Q3 approval automatically approves Q4 with same document status - New 'auto_geprueft' status with distinct badge UI - Maintain quarterly payment cycle while simplifying document submissions - Remove modal edit functionality, keep full-screen editor only - Update copilot instructions documentation Changes align with academic semester system where students submit documents twice yearly instead of quarterly.
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 (covers Q1+Q2 semester)
|
||
2: date(self.jahr, 6, 15), # Q2 deadline: June 15 (auto-approved if Q1 complete)
|
||
3: date(self.jahr, 9, 15), # Q3 deadline: September 15 (covers Q3+Q4 semester)
|
||
4: date(self.jahr, 12, 15), # Q4 deadline: December 15 (auto-approved if Q3 complete)
|
||
}
|
||
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
|