import uuid from django.core.validators import MaxValueValidator, MinValueValidator from django.db import models from django.utils import timezone from .land import DokumentLink class Destinataer(models.Model): """Destinatäre (Beneficiaries) für Förderungen""" FAMILIENZWIG_CHOICES = [ ("hauptzweig", "Hauptzweig"), ("nebenzweig", "Nebenzweig"), ("verwandt", "Verwandt"), ("anderer", "Anderer"), ] BERUFSGRUPPE_CHOICES = [ ("student", "Student/Studentin"), ("wissenschaftler", "Wissenschaftler/in"), ("künstler", "Künstler/in"), ("sozialarbeiter", "Sozialarbeiter/in"), ("umweltschützer", "Umweltschützer/in"), ("andere", "Andere"), ] id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) familienzweig = models.CharField( max_length=100, choices=FAMILIENZWIG_CHOICES, blank=True, null=True ) vorname = models.CharField(max_length=100, verbose_name="Vorname") nachname = models.CharField(max_length=100, verbose_name="Nachname") geburtsdatum = models.DateField(null=True, blank=True, verbose_name="Geburtsdatum") email = models.EmailField(null=True, blank=True, verbose_name="E-Mail") telefon = models.CharField( max_length=20, null=True, blank=True, verbose_name="Telefon" ) iban = models.CharField(max_length=34, null=True, blank=True, verbose_name="IBAN") # Adressfelder strasse = models.CharField( max_length=200, verbose_name="Straße", blank=True, null=True ) plz = models.CharField(max_length=10, verbose_name="PLZ", blank=True, null=True) ort = models.CharField(max_length=100, verbose_name="Ort", blank=True, null=True) # Förderungs-spezifische Felder berufsgruppe = models.CharField( max_length=20, choices=BERUFSGRUPPE_CHOICES, blank=True, null=True, verbose_name="Berufsgruppe", ) ausbildungsstand = models.CharField( max_length=100, null=True, blank=True, verbose_name="Ausbildungsstand" ) institution = models.CharField( max_length=200, null=True, blank=True, verbose_name="Institution/Organisation" ) projekt_beschreibung = models.TextField( null=True, blank=True, verbose_name="Projektbeschreibung" ) # Finanzielle Informationen jaehrliches_einkommen = models.DecimalField( max_digits=12, decimal_places=2, null=True, blank=True, verbose_name="Jährliches Einkommen (€)", ) finanzielle_notlage = models.BooleanField( default=False, verbose_name="Finanzielle Notlage" ) # Kontakt und Notizen notizen = models.TextField(null=True, blank=True, verbose_name="Notizen") aktiv = models.BooleanField(default=True, verbose_name="Aktiv") # Unterstützung – Prüf- und Verwaltungsfelder ist_abkoemmling = models.BooleanField( default=False, verbose_name="Abkömmling gem. Satzung" ) haushaltsgroesse = models.PositiveIntegerField( default=1, verbose_name="Haushaltsgröße" ) monatliche_bezuege = models.DecimalField( max_digits=10, decimal_places=2, null=True, blank=True, verbose_name="Monatliche Bezüge (€)", ) vermoegen = models.DecimalField( max_digits=12, decimal_places=2, null=True, blank=True, verbose_name="Vermögen (€)", ) unterstuetzung_bestaetigt = models.BooleanField( default=False, verbose_name="Unterstützung bestätigt" ) standard_konto = models.ForeignKey( "StiftungsKonto", null=True, blank=True, on_delete=models.SET_NULL, verbose_name="Standard Auszahlungskonto", ) vierteljaehrlicher_betrag = models.DecimalField( max_digits=12, decimal_places=2, null=True, blank=True, verbose_name="Vierteljährlicher Betrag (€)", ) # Studiennachweise studiennachweis_erforderlich = models.BooleanField( default=False, verbose_name="Studiennachweis erforderlich" ) letzter_studiennachweis = models.DateField( null=True, blank=True, verbose_name="Letzter Studiennachweis" ) class Meta: verbose_name = "Destinatär" verbose_name_plural = "Destinatäre" ordering = ["nachname", "vorname"] def __str__(self): if self.vorname: return f"{self.nachname}, {self.vorname}" else: return self.nachname def get_full_name(self): if self.vorname: return f"{self.vorname} {self.nachname}" else: return self.nachname def get_total_foerderungen(self): """Calculate total funding received""" return self.foerderung_set.aggregate(total=models.Sum("betrag"))["total"] or 0 def get_foerderungen_count(self): """Count total funding grants""" return self.foerderung_set.count() def get_letzte_foerderung(self): """Get the most recent funding grant""" return self.foerderung_set.order_by("-jahr", "-betrag").first() @property def adresse(self): """Construct full address from separate fields""" parts = [] if self.strasse: parts.append(self.strasse) if self.plz or self.ort: city_part = [] if self.plz: city_part.append(self.plz) if self.ort: city_part.append(self.ort) parts.append(" ".join(city_part)) return "\n".join(parts) if parts else "" def erfuellt_voraussetzungen(self): """Prüft die Unterstützungsvoraussetzungen gemäß Angaben. - Abkömmling muss True sein - Monatliche Bezüge ≤ zulässige Grenze - Vermögen ≤ 15.500 € Die zulässige Grenze wird aus dem Regelsatz (standard 563 €) * 5 für die erste Person und + 0.8 * Regelsatz je weiterer Person approximiert. """ from decimal import Decimal regelsatz = Decimal("563.00") basis = regelsatz * 5 zuschlag = max(0, (self.haushaltsgroesse or 1) - 1) * ( regelsatz * Decimal("0.80") ) grenze = basis + zuschlag einkommen_ok = (self.monatliche_bezuege or Decimal("0")) <= grenze vermoegen_ok = (self.vermoegen or Decimal("0")) <= Decimal("15500") return bool(self.ist_abkoemmling and einkommen_ok and vermoegen_ok) @property def adresse(self): """Computed address property combining strasse, plz, ort""" parts = [] if self.strasse: parts.append(self.strasse) if self.plz and self.ort: parts.append(f"{self.plz} {self.ort}") elif self.plz: parts.append(self.plz) elif self.ort: parts.append(self.ort) return "\n".join(parts) if parts else None def naechste_studiennachweis_termine(self): """Gibt die nächsten beiden Stichtage (15.03, 15.09) zurück.""" import datetime as _dt today = _dt.date.today() jahr = today.year maerz = _dt.date(jahr, 3, 15) sep = _dt.date(jahr, 9, 15) termine = [] for d in (maerz, sep): if d >= today: termine.append(d) if len(termine) < 2: # Ergänzen aus folgendem Jahr termine.append(_dt.date(jahr + 1, 3, 15)) if len(termine) < 2: termine.append(_dt.date(jahr + 1, 9, 15)) return termine[:2] # Keep the old Person model for backward compatibility (will be removed in future) class Person(models.Model): FAMILIENZWIG_CHOICES = [ ("hauptzweig", "Hauptzweig"), ("nebenzweig", "Nebenzweig"), ("verwandt", "Verwandt"), ("anderer", "Anderer"), ] id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) familienzweig = models.CharField( max_length=100, choices=FAMILIENZWIG_CHOICES, default="hauptzweig" ) vorname = models.CharField(max_length=100) nachname = models.CharField(max_length=100) geburtsdatum = models.DateField(null=True, blank=True) email = models.EmailField(null=True, blank=True) telefon = models.CharField(max_length=20, null=True, blank=True) iban = models.CharField(max_length=34, null=True, blank=True) adresse = models.TextField(null=True, blank=True) notizen = models.TextField(null=True, blank=True) aktiv = models.BooleanField(default=True) class Meta: verbose_name = "Person (Legacy)" verbose_name_plural = "Personen (Legacy)" ordering = ["nachname", "vorname"] def __str__(self): return f"{self.nachname}, {self.vorname} (Legacy)" def get_full_name(self): return f"{self.vorname} {self.nachname}" def get_total_foerderungen(self): return self.foerderung_set.aggregate(total=models.Sum("betrag"))["total"] or 0 class Foerderung(models.Model): KATEGORIE_CHOICES = [ ("bildung", "Bildung"), ("forschung", "Forschung"), ("kultur", "Kultur"), ("soziales", "Soziales"), ("umwelt", "Umwelt"), ("anderes", "Anderes"), ] STATUS_CHOICES = [ ("beantragt", "Beantragt"), ("genehmigt", "Genehmigt"), ("ausgezahlt", "Ausgezahlt"), ("abgelehnt", "Abgelehnt"), ("storniert", "Storniert"), ] id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) # Legacy field for migration - will be removed after data migration person = models.ForeignKey( Person, on_delete=models.CASCADE, verbose_name="Person (Legacy)", null=True, blank=True, ) destinataer = models.ForeignKey( Destinataer, on_delete=models.CASCADE, verbose_name="Destinatär", null=True, blank=True, ) jahr = models.IntegerField( validators=[MinValueValidator(1900), MaxValueValidator(2100)] ) betrag = models.DecimalField(max_digits=12, decimal_places=2) kategorie = models.CharField( max_length=20, choices=KATEGORIE_CHOICES, default="anderes" ) status = models.CharField( max_length=20, choices=STATUS_CHOICES, default="beantragt" ) verwendungsnachweis = models.ForeignKey( DokumentLink, null=True, blank=True, on_delete=models.SET_NULL, verbose_name="Verwendungsnachweis", ) bemerkungen = models.TextField(null=True, blank=True) antragsdatum = models.DateField(default=timezone.now) entscheidungsdatum = models.DateField(null=True, blank=True) class Meta: verbose_name = "Förderung" verbose_name_plural = "Förderungen" ordering = ["-jahr", "-betrag"] # Note: unique_together will be updated after migration def __str__(self): if self.destinataer: return f"{self.destinataer} - {self.jahr} - €{self.betrag}" elif self.person: return f"{self.person} (Legacy) - {self.jahr} - €{self.betrag}" return f"Unbekannt - {self.jahr} - €{self.betrag}" def get_status_color(self): colors = { "beantragt": "orange", "genehmigt": "blue", "ausgezahlt": "green", "abgelehnt": "red", "storniert": "gray", } return colors.get(self.status, "black") class DestinataerUnterstuetzung(models.Model): """Geplante/ausgeführte Unterstützungszahlungen an Destinatäre""" STATUS_CHOICES = [ ("geplant", "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() )