Adds optional salutation (Herr/Frau/Divers) to the Destinatär model with migration, form support, admin integration and template display. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1468 lines
51 KiB
Python
1468 lines
51 KiB
Python
import uuid
|
||
|
||
from django.core.validators import MaxValueValidator, MinValueValidator
|
||
from django.db import models
|
||
from django.utils import timezone
|
||
|
||
from .land import DokumentLink
|
||
|
||
|
||
class Destinataer(models.Model):
|
||
"""Destinatäre (Beneficiaries) für Förderungen"""
|
||
|
||
FAMILIENZWIG_CHOICES = [
|
||
("hauptzweig", "Hauptzweig"),
|
||
("nebenzweig", "Nebenzweig"),
|
||
("verwandt", "Verwandt"),
|
||
("anderer", "Anderer"),
|
||
]
|
||
|
||
BERUFSGRUPPE_CHOICES = [
|
||
("student", "Student/Studentin"),
|
||
("wissenschaftler", "Wissenschaftler/in"),
|
||
("künstler", "Künstler/in"),
|
||
("sozialarbeiter", "Sozialarbeiter/in"),
|
||
("umweltschützer", "Umweltschützer/in"),
|
||
("andere", "Andere"),
|
||
]
|
||
|
||
ANREDE_CHOICES = [
|
||
("Herr", "Herr"),
|
||
("Frau", "Frau"),
|
||
("Divers", "Divers"),
|
||
]
|
||
|
||
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
|
||
anrede = models.CharField(
|
||
max_length=20,
|
||
choices=ANREDE_CHOICES,
|
||
blank=True,
|
||
null=True,
|
||
verbose_name="Anrede",
|
||
)
|
||
familienzweig = models.CharField(
|
||
max_length=100, choices=FAMILIENZWIG_CHOICES, blank=True, null=True
|
||
)
|
||
vorname = models.CharField(max_length=100, verbose_name="Vorname")
|
||
nachname = models.CharField(max_length=100, verbose_name="Nachname")
|
||
geburtsdatum = models.DateField(null=True, blank=True, verbose_name="Geburtsdatum")
|
||
email = models.EmailField(null=True, blank=True, verbose_name="E-Mail")
|
||
telefon = models.CharField(
|
||
max_length=20, null=True, blank=True, verbose_name="Telefon"
|
||
)
|
||
iban = models.CharField(max_length=34, null=True, blank=True, verbose_name="IBAN")
|
||
|
||
# Adressfelder
|
||
strasse = models.CharField(
|
||
max_length=200, verbose_name="Straße", blank=True, null=True
|
||
)
|
||
plz = models.CharField(max_length=10, verbose_name="PLZ", blank=True, null=True)
|
||
ort = models.CharField(max_length=100, verbose_name="Ort", blank=True, null=True)
|
||
|
||
# Förderungs-spezifische Felder
|
||
berufsgruppe = models.CharField(
|
||
max_length=20,
|
||
choices=BERUFSGRUPPE_CHOICES,
|
||
blank=True,
|
||
null=True,
|
||
verbose_name="Berufsgruppe",
|
||
)
|
||
ausbildungsstand = models.CharField(
|
||
max_length=100, null=True, blank=True, verbose_name="Ausbildungsstand"
|
||
)
|
||
institution = models.CharField(
|
||
max_length=200, null=True, blank=True, verbose_name="Institution/Organisation"
|
||
)
|
||
projekt_beschreibung = models.TextField(
|
||
null=True, blank=True, verbose_name="Projektbeschreibung"
|
||
)
|
||
|
||
# Finanzielle Informationen
|
||
jaehrliches_einkommen = models.DecimalField(
|
||
max_digits=12,
|
||
decimal_places=2,
|
||
null=True,
|
||
blank=True,
|
||
verbose_name="Jährliches Einkommen (€)",
|
||
)
|
||
finanzielle_notlage = models.BooleanField(
|
||
default=False, verbose_name="Finanzielle Notlage"
|
||
)
|
||
|
||
# Kontakt und Notizen
|
||
notizen = models.TextField(null=True, blank=True, verbose_name="Notizen")
|
||
aktiv = models.BooleanField(default=True, verbose_name="Aktiv")
|
||
|
||
# Unterstützung – Prüf- und Verwaltungsfelder
|
||
ist_abkoemmling = models.BooleanField(
|
||
default=False, verbose_name="Abkömmling gem. Satzung"
|
||
)
|
||
haushaltsgroesse = models.PositiveIntegerField(
|
||
default=1, verbose_name="Haushaltsgröße"
|
||
)
|
||
monatliche_bezuege = models.DecimalField(
|
||
max_digits=10,
|
||
decimal_places=2,
|
||
null=True,
|
||
blank=True,
|
||
verbose_name="Monatliche Bezüge (€)",
|
||
)
|
||
vermoegen = models.DecimalField(
|
||
max_digits=12,
|
||
decimal_places=2,
|
||
null=True,
|
||
blank=True,
|
||
verbose_name="Vermögen (€)",
|
||
)
|
||
unterstuetzung_bestaetigt = models.BooleanField(
|
||
default=False, verbose_name="Unterstützung bestätigt"
|
||
)
|
||
standard_konto = models.ForeignKey(
|
||
"StiftungsKonto",
|
||
null=True,
|
||
blank=True,
|
||
on_delete=models.SET_NULL,
|
||
verbose_name="Standard Auszahlungskonto",
|
||
)
|
||
vierteljaehrlicher_betrag = models.DecimalField(
|
||
max_digits=12,
|
||
decimal_places=2,
|
||
null=True,
|
||
blank=True,
|
||
verbose_name="Vierteljährlicher Betrag (€)",
|
||
)
|
||
|
||
# Studiennachweise
|
||
studiennachweis_erforderlich = models.BooleanField(
|
||
default=False, verbose_name="Studiennachweis erforderlich"
|
||
)
|
||
letzter_studiennachweis = models.DateField(
|
||
null=True, blank=True, verbose_name="Letzter Studiennachweis"
|
||
)
|
||
|
||
class Meta:
|
||
verbose_name = "Destinatär"
|
||
verbose_name_plural = "Destinatäre"
|
||
ordering = ["nachname", "vorname"]
|
||
|
||
def __str__(self):
|
||
if self.vorname:
|
||
return f"{self.nachname}, {self.vorname}"
|
||
else:
|
||
return self.nachname
|
||
|
||
def get_full_name(self):
|
||
if self.vorname:
|
||
return f"{self.vorname} {self.nachname}"
|
||
else:
|
||
return self.nachname
|
||
|
||
def get_total_foerderungen(self):
|
||
"""Calculate total funding received"""
|
||
return self.foerderung_set.aggregate(total=models.Sum("betrag"))["total"] or 0
|
||
|
||
def get_foerderungen_count(self):
|
||
"""Count total funding grants"""
|
||
return self.foerderung_set.count()
|
||
|
||
def get_letzte_foerderung(self):
|
||
"""Get the most recent funding grant"""
|
||
return self.foerderung_set.order_by("-jahr", "-betrag").first()
|
||
|
||
@property
|
||
def adresse(self):
|
||
"""Construct full address from separate fields"""
|
||
parts = []
|
||
if self.strasse:
|
||
parts.append(self.strasse)
|
||
if self.plz or self.ort:
|
||
city_part = []
|
||
if self.plz:
|
||
city_part.append(self.plz)
|
||
if self.ort:
|
||
city_part.append(self.ort)
|
||
parts.append(" ".join(city_part))
|
||
return "\n".join(parts) if parts else ""
|
||
|
||
def erfuellt_voraussetzungen(self):
|
||
"""Prüft die Unterstützungsvoraussetzungen gemäß Angaben.
|
||
- Abkömmling muss True sein
|
||
- Monatliche Bezüge ≤ zulässige Grenze
|
||
- Vermögen ≤ 15.500 €
|
||
Die zulässige Grenze wird aus dem Regelsatz (standard 563 €) * 5 für die erste Person
|
||
und + 0.8 * Regelsatz je weiterer Person approximiert.
|
||
"""
|
||
from decimal import Decimal
|
||
|
||
regelsatz = Decimal("563.00")
|
||
basis = regelsatz * 5
|
||
zuschlag = max(0, (self.haushaltsgroesse or 1) - 1) * (
|
||
regelsatz * Decimal("0.80")
|
||
)
|
||
grenze = basis + zuschlag
|
||
einkommen_ok = (self.monatliche_bezuege or Decimal("0")) <= grenze
|
||
vermoegen_ok = (self.vermoegen or Decimal("0")) <= Decimal("15500")
|
||
return bool(self.ist_abkoemmling and einkommen_ok and vermoegen_ok)
|
||
|
||
@property
|
||
def adresse(self):
|
||
"""Computed address property combining strasse, plz, ort"""
|
||
parts = []
|
||
if self.strasse:
|
||
parts.append(self.strasse)
|
||
if self.plz and self.ort:
|
||
parts.append(f"{self.plz} {self.ort}")
|
||
elif self.plz:
|
||
parts.append(self.plz)
|
||
elif self.ort:
|
||
parts.append(self.ort)
|
||
return "\n".join(parts) if parts else None
|
||
|
||
def naechste_studiennachweis_termine(self):
|
||
"""Gibt die nächsten beiden Stichtage (15.03, 15.09) zurück."""
|
||
import datetime as _dt
|
||
|
||
today = _dt.date.today()
|
||
jahr = today.year
|
||
maerz = _dt.date(jahr, 3, 15)
|
||
sep = _dt.date(jahr, 9, 15)
|
||
termine = []
|
||
for d in (maerz, sep):
|
||
if d >= today:
|
||
termine.append(d)
|
||
if len(termine) < 2:
|
||
# Ergänzen aus folgendem Jahr
|
||
termine.append(_dt.date(jahr + 1, 3, 15))
|
||
if len(termine) < 2:
|
||
termine.append(_dt.date(jahr + 1, 9, 15))
|
||
return termine[:2]
|
||
|
||
|
||
# Keep the old Person model for backward compatibility (will be removed in future)
|
||
class Person(models.Model):
|
||
FAMILIENZWIG_CHOICES = [
|
||
("hauptzweig", "Hauptzweig"),
|
||
("nebenzweig", "Nebenzweig"),
|
||
("verwandt", "Verwandt"),
|
||
("anderer", "Anderer"),
|
||
]
|
||
|
||
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
|
||
familienzweig = models.CharField(
|
||
max_length=100, choices=FAMILIENZWIG_CHOICES, default="hauptzweig"
|
||
)
|
||
vorname = models.CharField(max_length=100)
|
||
nachname = models.CharField(max_length=100)
|
||
geburtsdatum = models.DateField(null=True, blank=True)
|
||
email = models.EmailField(null=True, blank=True)
|
||
telefon = models.CharField(max_length=20, null=True, blank=True)
|
||
iban = models.CharField(max_length=34, null=True, blank=True)
|
||
adresse = models.TextField(null=True, blank=True)
|
||
notizen = models.TextField(null=True, blank=True)
|
||
aktiv = models.BooleanField(default=True)
|
||
|
||
class Meta:
|
||
verbose_name = "Person (Legacy)"
|
||
verbose_name_plural = "Personen (Legacy)"
|
||
ordering = ["nachname", "vorname"]
|
||
|
||
def __str__(self):
|
||
return f"{self.nachname}, {self.vorname} (Legacy)"
|
||
|
||
def get_full_name(self):
|
||
return f"{self.vorname} {self.nachname}"
|
||
|
||
def get_total_foerderungen(self):
|
||
return self.foerderung_set.aggregate(total=models.Sum("betrag"))["total"] or 0
|
||
|
||
|
||
class Foerderung(models.Model):
|
||
KATEGORIE_CHOICES = [
|
||
("bildung", "Bildung"),
|
||
("forschung", "Forschung"),
|
||
("kultur", "Kultur"),
|
||
("soziales", "Soziales"),
|
||
("umwelt", "Umwelt"),
|
||
("anderes", "Anderes"),
|
||
]
|
||
|
||
STATUS_CHOICES = [
|
||
("beantragt", "Beantragt"),
|
||
("genehmigt", "Genehmigt"),
|
||
("ausgezahlt", "Ausgezahlt"),
|
||
("abgelehnt", "Abgelehnt"),
|
||
("storniert", "Storniert"),
|
||
]
|
||
|
||
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
|
||
# Legacy field for migration - will be removed after data migration
|
||
person = models.ForeignKey(
|
||
Person,
|
||
on_delete=models.CASCADE,
|
||
verbose_name="Person (Legacy)",
|
||
null=True,
|
||
blank=True,
|
||
)
|
||
destinataer = models.ForeignKey(
|
||
Destinataer,
|
||
on_delete=models.CASCADE,
|
||
verbose_name="Destinatär",
|
||
null=True,
|
||
blank=True,
|
||
)
|
||
jahr = models.IntegerField(
|
||
validators=[MinValueValidator(1900), MaxValueValidator(2100)]
|
||
)
|
||
betrag = models.DecimalField(max_digits=12, decimal_places=2)
|
||
kategorie = models.CharField(
|
||
max_length=20, choices=KATEGORIE_CHOICES, default="anderes"
|
||
)
|
||
status = models.CharField(
|
||
max_length=20, choices=STATUS_CHOICES, default="beantragt"
|
||
)
|
||
verwendungsnachweis = models.ForeignKey(
|
||
DokumentLink,
|
||
null=True,
|
||
blank=True,
|
||
on_delete=models.SET_NULL,
|
||
verbose_name="Verwendungsnachweis",
|
||
)
|
||
bemerkungen = models.TextField(null=True, blank=True)
|
||
antragsdatum = models.DateField(default=timezone.now)
|
||
entscheidungsdatum = models.DateField(null=True, blank=True)
|
||
|
||
class Meta:
|
||
verbose_name = "Förderung"
|
||
verbose_name_plural = "Förderungen"
|
||
ordering = ["-jahr", "-betrag"]
|
||
# Note: unique_together will be updated after migration
|
||
|
||
def __str__(self):
|
||
if self.destinataer:
|
||
return f"{self.destinataer} - {self.jahr} - €{self.betrag}"
|
||
elif self.person:
|
||
return f"{self.person} (Legacy) - {self.jahr} - €{self.betrag}"
|
||
return f"Unbekannt - {self.jahr} - €{self.betrag}"
|
||
|
||
def get_status_color(self):
|
||
colors = {
|
||
"beantragt": "orange",
|
||
"genehmigt": "blue",
|
||
"ausgezahlt": "green",
|
||
"abgelehnt": "red",
|
||
"storniert": "gray",
|
||
}
|
||
return colors.get(self.status, "black")
|
||
|
||
|
||
class DestinataerUnterstuetzung(models.Model):
|
||
"""Geplante/ausgeführte Unterstützungszahlungen an Destinatäre"""
|
||
|
||
STATUS_CHOICES = [
|
||
("geplant", "Offen"),
|
||
("faellig", "Fällig"),
|
||
("nachweis_eingereicht", "Nachweis eingereicht"),
|
||
("freigegeben", "Freigegeben (4-Augen)"),
|
||
("in_bearbeitung", "In Bearbeitung"),
|
||
("ausgezahlt", "Überwiesen"),
|
||
("abgeschlossen", "Abgeschlossen"),
|
||
("storniert", "Storniert"),
|
||
]
|
||
|
||
# Pipeline-stage für Zahlungsstatus-Anzeige
|
||
PIPELINE_STAGES = [
|
||
("geplant", "Offen"),
|
||
("nachweis_eingereicht", "Nachweis eingereicht"),
|
||
("freigegeben", "Freigegeben"),
|
||
("ausgezahlt", "Überwiesen"),
|
||
]
|
||
|
||
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,
|
||
related_name="ausgezahlte_unterstuetzungen",
|
||
verbose_name="Ausgezahlt von",
|
||
)
|
||
|
||
# 4-Augen-Prinzip: Freigabe durch zweiten Nutzer
|
||
freigegeben_am = models.DateField(
|
||
null=True, blank=True, verbose_name="Freigegeben am"
|
||
)
|
||
freigegeben_von = models.ForeignKey(
|
||
"auth.User",
|
||
on_delete=models.SET_NULL,
|
||
null=True,
|
||
blank=True,
|
||
related_name="freigegebene_unterstuetzungen",
|
||
verbose_name="Freigegeben von (4-Augen)",
|
||
help_text="Muss ein anderer Nutzer als der Ersteller sein (Vier-Augen-Prinzip)",
|
||
)
|
||
erstellt_von = models.ForeignKey(
|
||
"auth.User",
|
||
on_delete=models.SET_NULL,
|
||
null=True,
|
||
blank=True,
|
||
related_name="erstellte_unterstuetzungen",
|
||
verbose_name="Erstellt 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", "nachweis_eingereicht", "freigegeben", "in_bearbeitung"]
|
||
|
||
def can_be_freigegeben(self, requesting_user):
|
||
"""4-Augen: Freigabe nur durch anderen Nutzer als Ersteller"""
|
||
if self.status not in ["nachweis_eingereicht", "faellig", "in_bearbeitung"]:
|
||
return False
|
||
if self.erstellt_von and self.erstellt_von == requesting_user:
|
||
return False # Selber Nutzer darf nicht freigeben
|
||
return True
|
||
|
||
def get_pipeline_stage(self):
|
||
"""Gibt die Pipeline-Stufe als Integer zurück (1-5)"""
|
||
stage_map = {
|
||
"geplant": 1,
|
||
"faellig": 2,
|
||
"nachweis_eingereicht": 2,
|
||
"in_bearbeitung": 3,
|
||
"freigegeben": 3,
|
||
"ausgezahlt": 4,
|
||
"abgeschlossen": 4,
|
||
"storniert": 0,
|
||
}
|
||
return stage_map.get(self.status, 1)
|
||
|
||
|
||
class UnterstuetzungWiederkehrend(models.Model):
|
||
"""Template for recurring support payments"""
|
||
|
||
INTERVALL_CHOICES = [
|
||
("monatlich", "Monatlich"),
|
||
("quartalsweise", "Vierteljährlich"),
|
||
("halbjaehrlich", "Halbjährlich"),
|
||
("jaehrlich", "Jährlich"),
|
||
]
|
||
|
||
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
|
||
destinataer = models.ForeignKey(
|
||
"Destinataer",
|
||
on_delete=models.CASCADE,
|
||
related_name="wiederkehrende_unterstuetzungen",
|
||
verbose_name="Destinatär",
|
||
)
|
||
konto = models.ForeignKey(
|
||
"StiftungsKonto", on_delete=models.PROTECT, verbose_name="Zahlungskonto"
|
||
)
|
||
betrag = models.DecimalField(
|
||
max_digits=12, decimal_places=2, verbose_name="Betrag (€)"
|
||
)
|
||
intervall = models.CharField(
|
||
max_length=20, choices=INTERVALL_CHOICES, verbose_name="Intervall"
|
||
)
|
||
beschreibung = models.CharField(
|
||
max_length=255, blank=True, verbose_name="Beschreibung"
|
||
)
|
||
|
||
# IBAN and payment details
|
||
empfaenger_iban = models.CharField(max_length=34, verbose_name="Empfänger IBAN")
|
||
empfaenger_name = models.CharField(max_length=200, verbose_name="Empfänger Name")
|
||
verwendungszweck = models.CharField(
|
||
max_length=140, blank=True, verbose_name="Verwendungszweck"
|
||
)
|
||
|
||
# Schedule settings
|
||
erste_zahlung_am = models.DateField(verbose_name="Erste Zahlung am")
|
||
letzte_zahlung_am = models.DateField(
|
||
null=True, blank=True, verbose_name="Letzte Zahlung am (optional)"
|
||
)
|
||
naechste_generierung = models.DateField(verbose_name="Nächste Generierung")
|
||
|
||
aktiv = models.BooleanField(default=True, verbose_name="Aktiv")
|
||
erstellt_am = models.DateTimeField(auto_now_add=True)
|
||
erstellt_von = models.ForeignKey(
|
||
"auth.User",
|
||
on_delete=models.SET_NULL,
|
||
null=True,
|
||
blank=True,
|
||
verbose_name="Erstellt von",
|
||
)
|
||
|
||
class Meta:
|
||
verbose_name = "Wiederkehrende Unterstützung"
|
||
verbose_name_plural = "Wiederkehrende Unterstützungen"
|
||
ordering = ["-erstellt_am"]
|
||
indexes = [
|
||
models.Index(fields=["aktiv", "naechste_generierung"]),
|
||
models.Index(fields=["destinataer", "aktiv"]),
|
||
]
|
||
|
||
def __str__(self):
|
||
return f"{self.destinataer.get_full_name()} – {self.get_intervall_display()} €{self.betrag}"
|
||
|
||
def generiere_naechste_zahlung(self):
|
||
"""Generate the next scheduled payment"""
|
||
from datetime import timedelta
|
||
|
||
from dateutil.relativedelta import relativedelta
|
||
|
||
if not self.aktiv:
|
||
return None
|
||
|
||
heute = timezone.now().date()
|
||
if self.naechste_generierung > heute:
|
||
return None # Not yet time to generate
|
||
|
||
# Check if we've reached the end date
|
||
if (
|
||
self.letzte_zahlung_am
|
||
and self.naechste_generierung > self.letzte_zahlung_am
|
||
):
|
||
return None
|
||
|
||
# Create the next payment
|
||
neue_zahlung = DestinataerUnterstuetzung.objects.create(
|
||
destinataer=self.destinataer,
|
||
konto=self.konto,
|
||
betrag=self.betrag,
|
||
faellig_am=self.naechste_generierung,
|
||
beschreibung=self.beschreibung
|
||
or f"{self.get_intervall_display()} Unterstützung",
|
||
empfaenger_iban=self.empfaenger_iban,
|
||
empfaenger_name=self.empfaenger_name,
|
||
verwendungszweck=self.verwendungszweck,
|
||
wiederkehrend_von=self,
|
||
status="geplant",
|
||
)
|
||
|
||
# Calculate next generation date
|
||
if self.intervall == "monatlich":
|
||
self.naechste_generierung = self.naechste_generierung + relativedelta(
|
||
months=1
|
||
)
|
||
elif self.intervall == "quartalsweise":
|
||
self.naechste_generierung = self.naechste_generierung + relativedelta(
|
||
months=3
|
||
)
|
||
elif self.intervall == "halbjaehrlich":
|
||
self.naechste_generierung = self.naechste_generierung + relativedelta(
|
||
months=6
|
||
)
|
||
elif self.intervall == "jaehrlich":
|
||
self.naechste_generierung = self.naechste_generierung + relativedelta(
|
||
years=1
|
||
)
|
||
|
||
self.save()
|
||
return neue_zahlung
|
||
|
||
|
||
class DestinataerNotiz(models.Model):
|
||
"""Zeitgestempelte Notizen/Telefonvermerke zu einem Destinatär, optional mit Datei."""
|
||
|
||
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
|
||
destinataer = models.ForeignKey(
|
||
"Destinataer",
|
||
on_delete=models.CASCADE,
|
||
related_name="notizen_eintraege",
|
||
verbose_name="Destinatär",
|
||
)
|
||
titel = models.CharField(max_length=200, blank=True, verbose_name="Titel")
|
||
text = models.TextField(blank=True, verbose_name="Notiz")
|
||
datei = models.FileField(
|
||
upload_to="destinataer_notizen/", null=True, blank=True, verbose_name="Anhang"
|
||
)
|
||
erstellt_von = models.ForeignKey(
|
||
"auth.User",
|
||
on_delete=models.SET_NULL,
|
||
null=True,
|
||
blank=True,
|
||
verbose_name="Erstellt von",
|
||
)
|
||
erstellt_am = models.DateTimeField(auto_now_add=True, verbose_name="Erstellt am")
|
||
|
||
class Meta:
|
||
verbose_name = "Destinatär-Notiz"
|
||
verbose_name_plural = "Destinatär-Notizen"
|
||
ordering = ["-erstellt_am"]
|
||
|
||
def __str__(self):
|
||
return self.titel or f"Notiz {self.erstellt_am.strftime('%d.%m.%Y %H:%M')}"
|
||
|
||
|
||
class VierteljahresNachweis(models.Model):
|
||
"""Quarterly confirmation system for Destinatäre"""
|
||
|
||
QUARTAL_CHOICES = [
|
||
(1, "Q1 (Jan-Mär)"),
|
||
(2, "Q2 (Apr-Jun)"),
|
||
(3, "Q3 (Jul-Sep)"),
|
||
(4, "Q4 (Okt-Dez)"),
|
||
]
|
||
|
||
STATUS_CHOICES = [
|
||
("offen", "Nachweis ausstehend"),
|
||
("teilweise", "Teilweise eingereicht"),
|
||
("eingereicht", "Vollständig eingereicht"),
|
||
("geprueft", "Geprüft & Freigegeben"),
|
||
("auto_geprueft", "Automatisch freigegeben (Semesterbasis)"),
|
||
("nachbesserung", "Nachbesserung erforderlich"),
|
||
("abgelehnt", "Abgelehnt"),
|
||
]
|
||
|
||
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
|
||
destinataer = models.ForeignKey(
|
||
Destinataer,
|
||
on_delete=models.CASCADE,
|
||
related_name="quartalseinreichungen",
|
||
verbose_name="Destinatär"
|
||
)
|
||
|
||
# Time period
|
||
jahr = models.IntegerField(
|
||
verbose_name="Jahr",
|
||
validators=[MinValueValidator(2020), MaxValueValidator(2050)]
|
||
)
|
||
quartal = models.IntegerField(
|
||
choices=QUARTAL_CHOICES,
|
||
verbose_name="Quartal"
|
||
)
|
||
|
||
# Study proof (if required)
|
||
studiennachweis_erforderlich = models.BooleanField(
|
||
default=True,
|
||
verbose_name="Studiennachweis erforderlich"
|
||
)
|
||
studiennachweis_eingereicht = models.BooleanField(
|
||
default=False,
|
||
verbose_name="Studiennachweis eingereicht"
|
||
)
|
||
studiennachweis_datei = models.FileField(
|
||
upload_to="quarterly_proofs/studies/%Y/Q%m/",
|
||
null=True,
|
||
blank=True,
|
||
verbose_name="Studiennachweis (Datei)"
|
||
)
|
||
studiennachweis_bemerkung = models.TextField(
|
||
null=True,
|
||
blank=True,
|
||
verbose_name="Bemerkung zum Studiennachweis"
|
||
)
|
||
|
||
# Income/situation confirmation
|
||
einkommenssituation_bestaetigt = models.BooleanField(
|
||
default=False,
|
||
verbose_name="Einkommenssituation bestätigt"
|
||
)
|
||
einkommenssituation_text = models.TextField(
|
||
null=True,
|
||
blank=True,
|
||
verbose_name="Einkommenssituation (Text)",
|
||
help_text="Z.B. 'Keine Änderungen seit letzter Meldung' oder Details zu Änderungen"
|
||
)
|
||
einkommenssituation_datei = models.FileField(
|
||
upload_to="quarterly_proofs/income/%Y/Q%m/",
|
||
null=True,
|
||
blank=True,
|
||
verbose_name="Einkommenssituation (Datei)"
|
||
)
|
||
|
||
# Asset/wealth confirmation
|
||
vermogenssituation_bestaetigt = models.BooleanField(
|
||
default=False,
|
||
verbose_name="Vermögenssituation bestätigt"
|
||
)
|
||
vermogenssituation_text = models.TextField(
|
||
null=True,
|
||
blank=True,
|
||
verbose_name="Vermögenssituation (Text)",
|
||
help_text="Z.B. 'Keine Änderungen seit letzter Meldung' oder Details zu Änderungen"
|
||
)
|
||
vermogenssituation_datei = models.FileField(
|
||
upload_to="quarterly_proofs/assets/%Y/Q%m/",
|
||
null=True,
|
||
blank=True,
|
||
verbose_name="Vermögenssituation (Datei)"
|
||
)
|
||
|
||
# Additional documents
|
||
weitere_dokumente = models.FileField(
|
||
upload_to="quarterly_proofs/additional/%Y/Q%m/",
|
||
null=True,
|
||
blank=True,
|
||
verbose_name="Weitere Dokumente"
|
||
)
|
||
weitere_dokumente_beschreibung = models.TextField(
|
||
null=True,
|
||
blank=True,
|
||
verbose_name="Beschreibung weitere Dokumente"
|
||
)
|
||
|
||
# DMS-Dokumente als Nachweise verknuepfen (aus dem allgemeinen DMS)
|
||
nachweis_dokumente = models.ManyToManyField(
|
||
"DokumentDatei",
|
||
blank=True,
|
||
related_name="quartalsnachweise",
|
||
verbose_name="Verknuepfte DMS-Dokumente",
|
||
help_text="Dokumente aus dem DMS, die als Nachweise fuer dieses Quartal dienen.",
|
||
)
|
||
|
||
# Kategorie-spezifische DMS-Verknuepfungen
|
||
studiennachweis_dms_dokument = models.ForeignKey(
|
||
"DokumentDatei",
|
||
on_delete=models.SET_NULL,
|
||
null=True, blank=True,
|
||
related_name="als_studiennachweis",
|
||
verbose_name="Studiennachweis (DMS-Dokument)",
|
||
)
|
||
einkommenssituation_dms_dokument = models.ForeignKey(
|
||
"DokumentDatei",
|
||
on_delete=models.SET_NULL,
|
||
null=True, blank=True,
|
||
related_name="als_einkommensnachweis",
|
||
verbose_name="Einkommenssituation (DMS-Dokument)",
|
||
)
|
||
vermogenssituation_dms_dokument = models.ForeignKey(
|
||
"DokumentDatei",
|
||
on_delete=models.SET_NULL,
|
||
null=True, blank=True,
|
||
related_name="als_vermoegensnachweis",
|
||
verbose_name="Vermoegenssituation (DMS-Dokument)",
|
||
)
|
||
|
||
# Review and approval
|
||
status = models.CharField(
|
||
max_length=20,
|
||
choices=STATUS_CHOICES,
|
||
default="offen",
|
||
verbose_name="Status"
|
||
)
|
||
interne_notizen = models.TextField(
|
||
null=True,
|
||
blank=True,
|
||
verbose_name="Interne Notizen (nur für Verwaltung)"
|
||
)
|
||
|
||
# Timestamps and tracking
|
||
erstellt_am = models.DateTimeField(auto_now_add=True, verbose_name="Erstellt am")
|
||
aktualisiert_am = models.DateTimeField(auto_now=True, verbose_name="Aktualisiert am")
|
||
eingereicht_am = models.DateTimeField(
|
||
null=True,
|
||
blank=True,
|
||
verbose_name="Eingereicht am"
|
||
)
|
||
geprueft_am = models.DateTimeField(
|
||
null=True,
|
||
blank=True,
|
||
verbose_name="Geprüft am"
|
||
)
|
||
geprueft_von = models.ForeignKey(
|
||
"auth.User",
|
||
on_delete=models.SET_NULL,
|
||
null=True,
|
||
blank=True,
|
||
verbose_name="Geprüft von"
|
||
)
|
||
|
||
# Deadline tracking
|
||
faelligkeitsdatum = models.DateField(
|
||
null=True,
|
||
blank=True,
|
||
verbose_name="Fälligkeitsdatum",
|
||
help_text="Veraltet - wird durch studiennachweis_faelligkeitsdatum und zahlung_faelligkeitsdatum ersetzt"
|
||
)
|
||
|
||
# Separate deadlines for study proof (semester-based) and payment (quarterly)
|
||
studiennachweis_faelligkeitsdatum = models.DateField(
|
||
null=True,
|
||
blank=True,
|
||
verbose_name="Studiennachweis Fälligkeitsdatum",
|
||
help_text="Semesterbasierte Frist: Q1/Q2 → 15. März, Q3/Q4 → 15. September"
|
||
)
|
||
|
||
zahlung_faelligkeitsdatum = models.DateField(
|
||
null=True,
|
||
blank=True,
|
||
verbose_name="Zahlungsfälligkeit",
|
||
help_text="Vierteljährliche Zahlungsfälligkeit im Voraus: Q1→15. Dez (Vorjahr), Q2→15. Mär, Q3→15. Jun, Q4→15. Sep"
|
||
)
|
||
|
||
class Meta:
|
||
verbose_name = "Vierteljahresnachweis"
|
||
verbose_name_plural = "Vierteljahresnachweise"
|
||
ordering = ["-jahr", "-quartal", "destinataer__nachname"]
|
||
unique_together = ["destinataer", "jahr", "quartal"] # One entry per quarter per person
|
||
indexes = [
|
||
models.Index(fields=["jahr", "quartal", "status"]),
|
||
models.Index(fields=["destinataer", "status"]),
|
||
models.Index(fields=["faelligkeitsdatum"]),
|
||
]
|
||
|
||
def __str__(self):
|
||
return f"{self.destinataer.get_full_name()} - {self.jahr} Q{self.quartal} ({self.get_status_display()})"
|
||
|
||
def get_quarter_display(self):
|
||
"""Get a nice display name for the quarter"""
|
||
quarter_names = {
|
||
1: "Q1 (Januar - März)",
|
||
2: "Q2 (April - Juni)",
|
||
3: "Q3 (Juli - September)",
|
||
4: "Q4 (Oktober - Dezember)"
|
||
}
|
||
return quarter_names.get(self.quartal, f"Q{self.quartal}")
|
||
|
||
def is_complete(self):
|
||
"""Check if all required documents/confirmations are provided"""
|
||
complete = True
|
||
|
||
# DMS-Dokumente (kategorie-spezifisch oder generisch) zaehlen als Nachweis
|
||
has_dms_studiennachweis = (
|
||
bool(self.studiennachweis_dms_dokument_id)
|
||
or self.nachweis_dokumente.filter(kontext="studiennachweis").exists()
|
||
)
|
||
|
||
# Check study proof (always required now)
|
||
complete &= self.studiennachweis_eingereicht and (
|
||
bool(self.studiennachweis_datei) or bool(self.studiennachweis_bemerkung) or has_dms_studiennachweis
|
||
)
|
||
|
||
# Check income situation (either text, file, or DMS document)
|
||
complete &= self.einkommenssituation_bestaetigt and (
|
||
bool(self.einkommenssituation_text) or bool(self.einkommenssituation_datei)
|
||
or bool(self.einkommenssituation_dms_dokument_id)
|
||
)
|
||
|
||
# Check asset situation (either text, file, or DMS document)
|
||
complete &= self.vermogenssituation_bestaetigt and (
|
||
bool(self.vermogenssituation_text) or bool(self.vermogenssituation_datei)
|
||
or bool(self.vermogenssituation_dms_dokument_id)
|
||
)
|
||
|
||
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
|
||
|
||
has_dms_studiennachweis = (
|
||
bool(self.studiennachweis_dms_dokument_id)
|
||
or self.nachweis_dokumente.filter(kontext="studiennachweis").exists()
|
||
)
|
||
|
||
# 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) or has_dms_studiennachweis
|
||
):
|
||
completed_requirements += 1
|
||
|
||
# Income situation
|
||
if self.einkommenssituation_bestaetigt and (
|
||
bool(self.einkommenssituation_text) or bool(self.einkommenssituation_datei)
|
||
or bool(self.einkommenssituation_dms_dokument_id)
|
||
):
|
||
completed_requirements += 1
|
||
|
||
# Asset situation
|
||
if self.vermogenssituation_bestaetigt and (
|
||
bool(self.vermogenssituation_text) or bool(self.vermogenssituation_datei)
|
||
or bool(self.vermogenssituation_dms_dokument_id)
|
||
):
|
||
completed_requirements += 1
|
||
|
||
return int((completed_requirements / total_requirements) * 100) if total_requirements > 0 else 0
|
||
|
||
def get_study_proof_deadline(self):
|
||
"""Calculate semester-based study proof deadline"""
|
||
from datetime import date
|
||
# Q1, Q2 → March 15 (same year)
|
||
# Q3, Q4 → September 15 (same year)
|
||
if self.quartal in [1, 2]:
|
||
return date(self.jahr, 3, 15)
|
||
else: # Q3, Q4
|
||
return date(self.jahr, 9, 15)
|
||
|
||
def get_payment_due_date(self):
|
||
"""Calculate quarterly payment due date (paid in advance)"""
|
||
from datetime import date
|
||
# Q1 → December 15 (previous year)
|
||
# Q2 → March 15 (same year)
|
||
# Q3 → June 15 (same year)
|
||
# Q4 → September 15 (same year)
|
||
if self.quartal == 1:
|
||
return date(self.jahr - 1, 12, 15)
|
||
elif self.quartal == 2:
|
||
return date(self.jahr, 3, 15)
|
||
elif self.quartal == 3:
|
||
return date(self.jahr, 6, 15)
|
||
else: # Q4
|
||
return date(self.jahr, 9, 15)
|
||
|
||
def is_study_proof_overdue(self):
|
||
"""Check if study proof deadline has passed"""
|
||
if not self.studiennachweis_faelligkeitsdatum:
|
||
return False
|
||
from django.utils import timezone
|
||
return timezone.now().date() > self.studiennachweis_faelligkeitsdatum and not self.studiennachweis_eingereicht
|
||
|
||
def is_payment_overdue(self):
|
||
"""Check if payment due date has passed"""
|
||
if not self.zahlung_faelligkeitsdatum:
|
||
return False
|
||
from django.utils import timezone
|
||
# Payment is overdue if due date passed and no payment exists or payment is not completed
|
||
payment = self.get_related_support_payment()
|
||
if payment and payment.status in ['bezahlt', 'in_bearbeitung']:
|
||
return False
|
||
return timezone.now().date() > self.zahlung_faelligkeitsdatum
|
||
|
||
def is_overdue(self):
|
||
"""Check if either deadline has passed"""
|
||
return self.is_study_proof_overdue() or self.is_payment_overdue()
|
||
|
||
def save(self, *args, **kwargs):
|
||
"""Override save to auto-update status and timestamps"""
|
||
# Set study proof deadline (semester-based) if not provided
|
||
if not self.studiennachweis_faelligkeitsdatum:
|
||
self.studiennachweis_faelligkeitsdatum = self.get_study_proof_deadline()
|
||
|
||
# Set payment due date (quarterly, advance) if not provided
|
||
if not self.zahlung_faelligkeitsdatum:
|
||
self.zahlung_faelligkeitsdatum = self.get_payment_due_date()
|
||
|
||
# Backward compatibility: set faelligkeitsdatum from study proof deadline if not set
|
||
if not self.faelligkeitsdatum:
|
||
self.faelligkeitsdatum = self.studiennachweis_faelligkeitsdatum
|
||
|
||
# Auto-update status based on completion
|
||
if self.is_complete():
|
||
if self.status == "offen":
|
||
self.status = "eingereicht"
|
||
self.eingereicht_am = timezone.now()
|
||
else:
|
||
completion = self.get_completion_percentage()
|
||
if completion > 0 and completion < 100 and self.status == "offen":
|
||
self.status = "teilweise"
|
||
|
||
super().save(*args, **kwargs)
|
||
|
||
def get_related_support_payment(self):
|
||
"""Get the related support payment for this quarterly confirmation"""
|
||
from datetime import date, timedelta
|
||
from django.db.models import Q
|
||
|
||
# Use payment due date from quarterly confirmation for accurate search
|
||
# This is more accurate than using quarter date range, especially for Q1 (Dec 15 prev year)
|
||
payment_due_date = self.zahlung_faelligkeitsdatum
|
||
if not payment_due_date:
|
||
# Fallback: calculate if not set
|
||
if self.quartal == 1:
|
||
payment_due_date = date(self.jahr - 1, 12, 15)
|
||
elif self.quartal == 2:
|
||
payment_due_date = date(self.jahr, 3, 15)
|
||
elif self.quartal == 3:
|
||
payment_due_date = date(self.jahr, 6, 15)
|
||
else: # Q4
|
||
payment_due_date = date(self.jahr, 9, 15)
|
||
|
||
# Search for existing payment - match by payment due date and description
|
||
# Use a date range around the due date (±30 days) to catch any variations
|
||
date_start = payment_due_date - timedelta(days=30)
|
||
date_end = payment_due_date + timedelta(days=30)
|
||
|
||
return DestinataerUnterstuetzung.objects.filter(
|
||
destinataer=self.destinataer,
|
||
faellig_am__gte=date_start,
|
||
faellig_am__lte=date_end
|
||
).filter(
|
||
Q(beschreibung__contains=f"Q{self.quartal}/{self.jahr}") |
|
||
Q(beschreibung__contains=f"Vierteljährliche Unterstützung Q{self.quartal}/{self.jahr}")
|
||
).first()
|
||
|
||
def auto_approve_next_quarter(self):
|
||
"""Auto-approve the next quarter when Q1 or Q3 is approved (semester-based logic)"""
|
||
if self.quartal in [1, 3] and self.status == "geprueft":
|
||
next_quarter = self.quartal + 1
|
||
try:
|
||
next_nachweis = VierteljahresNachweis.objects.get(
|
||
destinataer=self.destinataer,
|
||
jahr=self.jahr,
|
||
quartal=next_quarter
|
||
)
|
||
|
||
if next_nachweis.status in ["offen", "teilweise"]:
|
||
# Copy study proof confirmations from current quarter (semester-based)
|
||
next_nachweis.studiennachweis_eingereicht = self.studiennachweis_eingereicht
|
||
next_nachweis.studiennachweis_datei = self.studiennachweis_datei
|
||
next_nachweis.studiennachweis_bemerkung = self.studiennachweis_bemerkung
|
||
|
||
# Set study proof deadline for next quarter (same semester)
|
||
next_nachweis.studiennachweis_faelligkeitsdatum = next_nachweis.get_study_proof_deadline()
|
||
|
||
# Set auto-approved status
|
||
next_nachweis.status = "auto_geprueft"
|
||
next_nachweis.geprueft_am = timezone.now()
|
||
next_nachweis.geprueft_von = self.geprueft_von
|
||
next_nachweis.save(update_fields=[
|
||
'studiennachweis_eingereicht', 'studiennachweis_datei', 'studiennachweis_bemerkung',
|
||
'studiennachweis_faelligkeitsdatum', 'status', 'geprueft_am', 'geprueft_von'
|
||
])
|
||
|
||
return next_nachweis
|
||
except VierteljahresNachweis.DoesNotExist:
|
||
pass
|
||
return None
|
||
|
||
@classmethod
|
||
def get_or_create_for_period(cls, destinataer, jahr, quartal):
|
||
"""Get or create a quarterly confirmation for a specific period"""
|
||
nachweis, created = cls.objects.get_or_create(
|
||
destinataer=destinataer,
|
||
jahr=jahr,
|
||
quartal=quartal,
|
||
defaults={
|
||
'studiennachweis_erforderlich': destinataer.studiennachweis_erforderlich,
|
||
'status': 'offen'
|
||
}
|
||
)
|
||
return nachweis, created
|
||
|
||
@classmethod
|
||
def get_current_quarter(cls):
|
||
"""Get the current quarter based on today's date"""
|
||
from datetime import date
|
||
today = date.today()
|
||
month = today.month
|
||
|
||
if month <= 3:
|
||
return today.year, 1
|
||
elif month <= 6:
|
||
return today.year, 2
|
||
elif month <= 9:
|
||
return today.year, 3
|
||
else:
|
||
return today.year, 4
|
||
|
||
@classmethod
|
||
def get_overdue_confirmations(cls):
|
||
"""Get all overdue quarterly confirmations"""
|
||
from datetime import date
|
||
today = date.today()
|
||
|
||
return cls.objects.filter(
|
||
faelligkeitsdatum__lt=today,
|
||
status__in=["offen", "teilweise"]
|
||
).select_related("destinataer")
|
||
|
||
def auto_approve_next_quarter(self):
|
||
"""Auto-approve the next quarter when Q1 or Q3 is approved (semester-based logic)"""
|
||
if self.quartal in [1, 3] and self.status == "geprueft":
|
||
next_quarter = self.quartal + 1
|
||
try:
|
||
next_nachweis = VierteljahresNachweis.objects.get(
|
||
destinataer=self.destinataer,
|
||
jahr=self.jahr,
|
||
quartal=next_quarter
|
||
)
|
||
|
||
if next_nachweis.status in ["offen", "teilweise"]:
|
||
# Copy document confirmations from current quarter
|
||
next_nachweis.studiennachweis_eingereicht = self.studiennachweis_eingereicht
|
||
next_nachweis.einkommenssituation_bestaetigt = self.einkommenssituation_bestaetigt
|
||
next_nachweis.vermogenssituation_bestaetigt = self.vermogenssituation_bestaetigt
|
||
|
||
# Set auto-approved status
|
||
next_nachweis.status = "auto_geprueft"
|
||
next_nachweis.geprueft_am = timezone.now()
|
||
next_nachweis.geprueft_von = self.geprueft_von
|
||
next_nachweis.save(update_fields=[
|
||
'studiennachweis_eingereicht', 'einkommenssituation_bestaetigt',
|
||
'vermogenssituation_bestaetigt', 'status', 'geprueft_am', 'geprueft_von'
|
||
])
|
||
|
||
return next_nachweis
|
||
except VierteljahresNachweis.DoesNotExist:
|
||
pass
|
||
return None
|
||
|
||
|
||
class EmailEingang(models.Model):
|
||
"""
|
||
Erfasst eingehende E-Mails (Destinataere, Rechnungen, Grundstuecke, Allgemein).
|
||
|
||
Wird automatisch durch den Celery-Task `poll_emails` befuellt,
|
||
der das IMAP-Postfach der Stiftung (paperless@vhtv-stiftung.de) ueberwacht.
|
||
Anhaenge werden direkt als DokumentDatei im Django-DMS gespeichert.
|
||
"""
|
||
|
||
KATEGORIE_CHOICES = [
|
||
("destinataer", "Destinataer"),
|
||
("rechnung", "Rechnung"),
|
||
("land_pacht", "Grundstueck / Pacht"),
|
||
("stiftungsgeschichte", "Stiftungsgeschichte"),
|
||
("allgemein", "Allgemein"),
|
||
]
|
||
|
||
STATUS_CHOICES = [
|
||
("neu", "Neu / Unbearbeitet"),
|
||
("zugewiesen", "Destinataer zugewiesen"),
|
||
("verarbeitet", "Verarbeitet"),
|
||
("rechnung_erfasst", "Rechnung erfasst"),
|
||
("zahlung_gebucht", "Zahlung gebucht"),
|
||
("unbekannt", "Unbekannter Absender"),
|
||
("fehler", "Fehler bei Verarbeitung"),
|
||
]
|
||
|
||
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
|
||
|
||
# Klassifizierung
|
||
kategorie = models.CharField(
|
||
max_length=20,
|
||
choices=KATEGORIE_CHOICES,
|
||
default="allgemein",
|
||
verbose_name="Kategorie",
|
||
)
|
||
|
||
# Verknuepfung zum Destinataer (None = kein Destinataer-Bezug)
|
||
destinataer = models.ForeignKey(
|
||
Destinataer,
|
||
on_delete=models.SET_NULL,
|
||
null=True,
|
||
blank=True,
|
||
related_name="email_eingaenge",
|
||
verbose_name="Destinataer",
|
||
)
|
||
|
||
# Verknuepfung zu Verwaltungskosten (Rechnungsworkflow)
|
||
verwaltungskosten = models.ForeignKey(
|
||
"Verwaltungskosten",
|
||
on_delete=models.SET_NULL,
|
||
null=True,
|
||
blank=True,
|
||
related_name="email_eingaenge",
|
||
verbose_name="Verwaltungskosten / Rechnung",
|
||
)
|
||
|
||
# Verknuepfung zu Land / Verpachtung
|
||
land = models.ForeignKey(
|
||
"Land",
|
||
on_delete=models.SET_NULL,
|
||
null=True,
|
||
blank=True,
|
||
related_name="email_eingaenge",
|
||
verbose_name="Laenderei",
|
||
)
|
||
verpachtung = models.ForeignKey(
|
||
"LandVerpachtung",
|
||
on_delete=models.SET_NULL,
|
||
null=True,
|
||
blank=True,
|
||
related_name="email_eingaenge",
|
||
verbose_name="Verpachtung",
|
||
)
|
||
|
||
# E-Mail-Metadaten
|
||
absender_email = models.EmailField(verbose_name="Absender-E-Mail")
|
||
absender_name = models.CharField(
|
||
max_length=255, blank=True, verbose_name="Absender-Name"
|
||
)
|
||
betreff = models.CharField(max_length=500, blank=True, verbose_name="Betreff")
|
||
eingangsdatum = models.DateTimeField(verbose_name="Eingangsdatum")
|
||
email_text = models.TextField(blank=True, verbose_name="E-Mail-Text")
|
||
|
||
# Anhaenge: DMS-Dokumente (Phase 3 – DokumentDatei)
|
||
dokument_dateien = models.ManyToManyField(
|
||
"DokumentDatei",
|
||
blank=True,
|
||
related_name="email_eingaenge",
|
||
verbose_name="DMS-Dokumente (Anhaenge)",
|
||
help_text="Automatisch befuellte Anhaenge als Django-DMS-Dateien.",
|
||
)
|
||
|
||
# Anhaenge: Liste der Paperless-Dokument-IDs (JSON-Format, deprecated)
|
||
paperless_dokument_ids = models.JSONField(
|
||
default=list,
|
||
blank=True,
|
||
verbose_name="Paperless Dokument-IDs (Anhaenge, veraltet)",
|
||
help_text="Veraltet – wird nach vollstaendiger Migration entfernt. Neue Anhaenge in dokument_dateien.",
|
||
)
|
||
|
||
# Verarbeitungsstatus
|
||
status = models.CharField(
|
||
max_length=20,
|
||
choices=STATUS_CHOICES,
|
||
default="neu",
|
||
verbose_name="Status",
|
||
)
|
||
fehler_details = models.TextField(
|
||
blank=True,
|
||
verbose_name="Fehlerdetails",
|
||
help_text="Technische Fehlermeldung bei Verarbeitungsfehlern",
|
||
)
|
||
notizen = models.TextField(
|
||
blank=True,
|
||
verbose_name="Interne Notizen",
|
||
help_text="Manuelle Notizen der Verwaltung zur E-Mail",
|
||
)
|
||
|
||
# Verweis auf VierteljahresNachweis, falls E-Mail einem Quartal zugeordnet
|
||
quartalsnachweis = models.ForeignKey(
|
||
"VierteljahresNachweis",
|
||
on_delete=models.SET_NULL,
|
||
null=True,
|
||
blank=True,
|
||
related_name="email_eingaenge",
|
||
verbose_name="Quartalsnachweis (zugeordnet)",
|
||
)
|
||
|
||
created_at = models.DateTimeField(auto_now_add=True, verbose_name="Erfasst am")
|
||
|
||
class Meta:
|
||
verbose_name = "E-Mail-Eingang"
|
||
verbose_name_plural = "E-Mail-Eingaenge"
|
||
ordering = ["-eingangsdatum"]
|
||
|
||
def __str__(self):
|
||
dest = str(self.destinataer) if self.destinataer else self.absender_email
|
||
return f"[{self.eingangsdatum.strftime('%d.%m.%Y')}] {dest}: {self.betreff[:60]}"
|
||
|
||
def get_paperless_links(self):
|
||
"""Gibt Liste der Paperless-Dokument-URLs zurueck (deprecated)."""
|
||
from django.conf import settings
|
||
base = settings.PAPERLESS_API_URL or ""
|
||
return [
|
||
f"{base}/documents/{doc_id}/"
|
||
for doc_id in (self.paperless_dokument_ids or [])
|
||
]
|
||
|
||
def get_dms_dokumente(self):
|
||
"""Gibt alle verknuepften DokumentDatei-Objekte zurueck."""
|
||
return self.dokument_dateien.all()
|
||
|
||
|
||
# Backward-compatible alias
|
||
DestinataerEmailEingang = EmailEingang
|
||
|
||
|
||
class UploadToken(models.Model):
|
||
"""
|
||
Einmaliger Upload-Token für tokenbasiertes Nachweis-Upload-Portal.
|
||
|
||
Ermöglicht Destinatären den Dokumenten-Upload ohne Nutzerkonto.
|
||
Der Token wird per E-Mail (mit QR-Code) versendet und ist 30 Tage gültig.
|
||
Nach einmaliger Nutzung (Upload) wird eingeloest_am gesetzt.
|
||
Die IP-Adresse wird nur als SHA-256-Hash gespeichert (DSGVO-konform).
|
||
"""
|
||
|
||
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
|
||
token = models.CharField(
|
||
max_length=128,
|
||
unique=True,
|
||
db_index=True,
|
||
verbose_name="Token",
|
||
)
|
||
destinataer = models.ForeignKey(
|
||
"Destinataer",
|
||
on_delete=models.CASCADE,
|
||
related_name="upload_tokens",
|
||
verbose_name="Destinatär",
|
||
)
|
||
nachweis = models.ForeignKey(
|
||
"VierteljahresNachweis",
|
||
on_delete=models.CASCADE,
|
||
related_name="upload_tokens",
|
||
verbose_name="Nachweis",
|
||
)
|
||
gueltig_bis = models.DateTimeField(verbose_name="Gültig bis")
|
||
erstellt_am = models.DateTimeField(auto_now_add=True, verbose_name="Erstellt am")
|
||
eingeloest_am = models.DateTimeField(
|
||
null=True, blank=True, verbose_name="Eingelöst am"
|
||
)
|
||
ist_aktiv = models.BooleanField(default=True, verbose_name="Aktiv")
|
||
ip_hash = models.CharField(
|
||
max_length=64, blank=True, null=True, verbose_name="IP-Hash (SHA-256)"
|
||
)
|
||
erinnerung_gesendet = models.BooleanField(
|
||
default=False, verbose_name="Erinnerung gesendet"
|
||
)
|
||
|
||
class Meta:
|
||
verbose_name = "Upload-Token"
|
||
verbose_name_plural = "Upload-Token"
|
||
ordering = ["-erstellt_am"]
|
||
|
||
def __str__(self):
|
||
return f"Token für {self.destinataer} ({self.nachweis})"
|
||
|
||
def ist_gueltig(self):
|
||
"""Prüft ob der Token noch gültig und aktiv ist."""
|
||
from django.utils import timezone
|
||
return (
|
||
self.ist_aktiv
|
||
and self.eingeloest_am is None
|
||
and self.gueltig_bis > timezone.now()
|
||
)
|
||
|
||
def einloesen(self, ip_address=None):
|
||
"""Markiert den Token als eingelöst. IP wird als Hash gespeichert."""
|
||
import hashlib
|
||
from django.utils import timezone
|
||
self.eingeloest_am = timezone.now()
|
||
self.ist_aktiv = False
|
||
if ip_address:
|
||
self.ip_hash = hashlib.sha256(ip_address.encode()).hexdigest()
|
||
self.save(update_fields=["eingeloest_am", "ist_aktiv", "ip_hash"])
|
||
|
||
|
||
class OnboardingEinladung(models.Model):
|
||
"""
|
||
Einladung zum Onboarding für neue Destinatäre.
|
||
|
||
Verwaltungsmitarbeiter versenden eine Einladungs-E-Mail.
|
||
Der Eingeladene füllt das mehrstufige Onboarding-Formular aus.
|
||
Nach Abschluss wird ein neuer Destinatär mit unterstuetzung_bestaetigt=False angelegt.
|
||
"""
|
||
|
||
STATUS_CHOICES = [
|
||
("offen", "Offen"),
|
||
("abgeschlossen", "Abgeschlossen"),
|
||
("abgelaufen", "Abgelaufen"),
|
||
("widerrufen", "Widerrufen"),
|
||
]
|
||
|
||
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
|
||
token = models.CharField(
|
||
max_length=128,
|
||
unique=True,
|
||
db_index=True,
|
||
verbose_name="Token",
|
||
)
|
||
email = models.EmailField(verbose_name="E-Mail-Adresse des Eingeladenen")
|
||
vorname = models.CharField(
|
||
max_length=100, blank=True, verbose_name="Vorname (optional)"
|
||
)
|
||
nachname = models.CharField(
|
||
max_length=100, blank=True, verbose_name="Nachname (optional)"
|
||
)
|
||
eingeladen_von = models.ForeignKey(
|
||
"auth.User",
|
||
on_delete=models.SET_NULL,
|
||
null=True,
|
||
blank=True,
|
||
related_name="onboarding_einladungen",
|
||
verbose_name="Eingeladen von",
|
||
)
|
||
gueltig_bis = models.DateTimeField(verbose_name="Gültig bis")
|
||
erstellt_am = models.DateTimeField(auto_now_add=True, verbose_name="Erstellt am")
|
||
abgeschlossen_am = models.DateTimeField(
|
||
null=True, blank=True, verbose_name="Abgeschlossen am"
|
||
)
|
||
destinataer = models.ForeignKey(
|
||
"Destinataer",
|
||
on_delete=models.SET_NULL,
|
||
null=True,
|
||
blank=True,
|
||
related_name="onboarding_einladung",
|
||
verbose_name="Resultierender Destinatär",
|
||
)
|
||
status = models.CharField(
|
||
max_length=20,
|
||
choices=STATUS_CHOICES,
|
||
default="offen",
|
||
verbose_name="Status",
|
||
)
|
||
notizen = models.TextField(blank=True, verbose_name="Interne Notizen")
|
||
|
||
class Meta:
|
||
verbose_name = "Onboarding-Einladung"
|
||
verbose_name_plural = "Onboarding-Einladungen"
|
||
ordering = ["-erstellt_am"]
|
||
|
||
def __str__(self):
|
||
return f"Einladung für {self.email} ({self.get_status_display()})"
|
||
|
||
def ist_gueltig(self):
|
||
"""Prüft ob die Einladung noch gültig ist."""
|
||
from django.utils import timezone
|
||
return (
|
||
self.status == "offen"
|
||
and self.gueltig_bis > timezone.now()
|
||
)
|