Files
stiftung-management-system/app/stiftung/models/destinataere.py
SysAdmin Agent 4d751d861d
Some checks failed
CI/CD Pipeline / test (push) Has been cancelled
Code Quality / quality (push) Has been cancelled
CI/CD Pipeline / deploy (push) Has been cancelled
DSGVO-Compliance: Einwilligung, Datenschutzerklärung & Consent-Logging im Upload-Portal (STI-89)
- Datenschutzerklärung unter /portal/datenschutz/ öffentlich erreichbar
- Link zur Datenschutzerklärung in Nachweis-Aufforderungs-E-Mails (HTML + TXT)
- Einwilligungs-Checkbox vor Upload mit Server-Side-Validierung
- Consent-Logging: einwilligung_erteilt_am auf UploadToken (Art. 7 Abs. 1 DSGVO)
- Regelsatz-Korrektur: 449€→563€ in Onboarding-Template (Stand 01/2024)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-21 22:43:01 +00:00

1472 lines
51 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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"
)
einwilligung_erteilt_am = models.DateTimeField(
null=True, blank=True, verbose_name="Einwilligung erteilt am",
help_text="Zeitpunkt der DSGVO-Einwilligung beim Upload (Art. 7 Abs. 1 DSGVO)"
)
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()
)