feat: Implement quarterly confirmation system with automatic support payments

- Add VierteljahresNachweis model for quarterly document tracking
- Remove studiennachweis_erforderlich field (now always required)
- Fix modal edit view to include studiennachweis section
- Implement automatic DestinataerUnterstuetzung creation when requirements met
- Set payment due dates to exact quarter end dates (Mar 31, Jun 30, Sep 30, Dec 31)
- Add quarterly confirmation CRUD views with modal and full-screen editing
- Update templates with comprehensive quarterly management interface
- Include proper validation, status tracking, and progress indicators
This commit is contained in:
2025-09-23 23:52:44 +02:00
parent 0184982f8c
commit 126f68ec68
9 changed files with 2177 additions and 56 deletions

View File

@@ -1,6 +1,7 @@
from django.contrib import admin
from django.db.models import Count, Sum
from django.urls import reverse
from django.utils import timezone
from django.utils.html import format_html
from django.utils.safestring import mark_safe
@@ -9,7 +10,7 @@ from .models import (AppConfiguration, AuditLog, BackupJob, BankTransaction,
CSVImport, Destinataer, DestinataerUnterstuetzung,
DokumentLink, Foerderung, Land, LandVerpachtung, Paechter, Person,
Rentmeister, StiftungsKonto, UnterstuetzungWiederkehrend,
Verwaltungskosten)
Verwaltungskosten, VierteljahresNachweis)
@admin.register(CSVImport)
@@ -968,6 +969,194 @@ class HelpBoxAdmin(admin.ModelAdmin):
super().save_model(request, obj, form, change)
@admin.register(VierteljahresNachweis)
class VierteljahresNachweisAdmin(admin.ModelAdmin):
list_display = [
"destinataer",
"jahr",
"quartal",
"status",
"completion_percentage",
"faelligkeitsdatum",
"is_overdue_display",
"eingereicht_am",
"geprueft_von",
]
list_filter = [
"jahr",
"quartal",
"status",
"studiennachweis_erforderlich",
"studiennachweis_eingereicht",
"einkommenssituation_bestaetigt",
"vermogenssituation_bestaetigt",
"faelligkeitsdatum",
]
search_fields = [
"destinataer__vorname",
"destinataer__nachname",
"destinataer__email",
]
readonly_fields = [
"id",
"erstellt_am",
"aktualisiert_am",
"completion_percentage",
"is_overdue_display",
]
ordering = ["-jahr", "-quartal", "destinataer__nachname"]
fieldsets = (
(
"Grundinformationen",
{
"fields": (
"destinataer",
"jahr",
"quartal",
"status",
"faelligkeitsdatum",
)
},
),
(
"Studiennachweis",
{
"fields": (
"studiennachweis_erforderlich",
"studiennachweis_eingereicht",
"studiennachweis_datei",
"studiennachweis_bemerkung",
),
"classes": ("collapse",),
},
),
(
"Einkommenssituation",
{
"fields": (
"einkommenssituation_bestaetigt",
"einkommenssituation_text",
"einkommenssituation_datei",
),
"classes": ("collapse",),
},
),
(
"Vermögenssituation",
{
"fields": (
"vermogenssituation_bestaetigt",
"vermogenssituation_text",
"vermogenssituation_datei",
),
"classes": ("collapse",),
},
),
(
"Weitere Dokumente",
{
"fields": (
"weitere_dokumente",
"weitere_dokumente_beschreibung",
),
"classes": ("collapse",),
},
),
(
"Verwaltung & Prüfung",
{
"fields": (
"interne_notizen",
"eingereicht_am",
"geprueft_am",
"geprueft_von",
),
"classes": ("collapse",),
},
),
(
"Metadaten",
{
"fields": (
"id",
"erstellt_am",
"aktualisiert_am",
"completion_percentage",
"is_overdue_display",
)
},
),
)
def completion_percentage(self, obj):
"""Show completion percentage as colored badge"""
percentage = obj.get_completion_percentage()
if percentage == 100:
color = "success"
elif percentage >= 70:
color = "info"
elif percentage >= 30:
color = "warning"
else:
color = "danger"
return format_html(
'<span class="badge bg-{}">{} %</span>',
color,
percentage
)
completion_percentage.short_description = "Fortschritt"
def is_overdue_display(self, obj):
"""Display overdue status with icon"""
if obj.is_overdue():
return format_html(
'<span class="text-danger"><i class="fas fa-exclamation-triangle"></i> Ja</span>'
)
return format_html(
'<span class="text-success"><i class="fas fa-check"></i> Nein</span>'
)
is_overdue_display.short_description = "Überfällig"
actions = ["mark_as_approved", "mark_as_needs_revision"]
def mark_as_approved(self, request, queryset):
"""Bulk action to approve submitted confirmations"""
count = 0
for nachweis in queryset.filter(status="eingereicht"):
nachweis.status = "geprueft"
nachweis.geprueft_am = timezone.now()
nachweis.geprueft_von = request.user
nachweis.save()
count += 1
if count:
self.message_user(
request,
f"{count} Nachweise wurden als geprüft und freigegeben markiert."
)
else:
self.message_user(
request,
"Keine eingereichten Nachweise gefunden.",
level="warning"
)
mark_as_approved.short_description = "Ausgewählte Nachweise freigeben"
def mark_as_needs_revision(self, request, queryset):
"""Bulk action to mark confirmations as needing revision"""
count = queryset.exclude(status__in=["offen", "nachbesserung"]).update(
status="nachbesserung"
)
if count:
self.message_user(
request,
f"{count} Nachweise wurden als nachbesserungsbedürftig markiert."
)
mark_as_needs_revision.short_description = "Nachbesserung erforderlich markieren"
# Customize admin site
admin.site.site_header = "Stiftungsverwaltung Administration"
admin.site.site_title = "Stiftungsverwaltung Admin"

View File

@@ -8,7 +8,7 @@ from .models import (BankTransaction, Destinataer, DestinataerNotiz,
DestinataerUnterstuetzung, DokumentLink, Foerderung, Land,
LandAbrechnung, LandVerpachtung, Paechter, Person, Rentmeister,
StiftungsKonto, UnterstuetzungWiederkehrend,
Verwaltungskosten)
Verwaltungskosten, VierteljahresNachweis)
class RentmeisterForm(forms.ModelForm):
@@ -1381,3 +1381,98 @@ class UserPermissionForm(forms.Form):
groups["system"]["permissions"].append((field_name, field, None))
return groups
class VierteljahresNachweisForm(forms.ModelForm):
"""Form for quarterly confirmations (Vierteljahresnachweise)"""
class Meta:
model = VierteljahresNachweis
fields = [
'studiennachweis_eingereicht',
'studiennachweis_datei',
'studiennachweis_bemerkung',
'einkommenssituation_bestaetigt',
'einkommenssituation_text',
'einkommenssituation_datei',
'vermogenssituation_bestaetigt',
'vermogenssituation_text',
'vermogenssituation_datei',
'weitere_dokumente',
'weitere_dokumente_beschreibung',
'interne_notizen',
]
widgets = {
'studiennachweis_eingereicht': forms.CheckboxInput(attrs={'class': 'form-check-input'}),
'studiennachweis_datei': forms.FileInput(attrs={'class': 'form-control', 'accept': '.pdf,.jpg,.jpeg,.png,.doc,.docx'}),
'studiennachweis_bemerkung': forms.Textarea(attrs={'class': 'form-control', 'rows': 3}),
'einkommenssituation_bestaetigt': forms.CheckboxInput(attrs={'class': 'form-check-input'}),
'einkommenssituation_text': forms.Textarea(attrs={'class': 'form-control', 'rows': 3, 'placeholder': 'Z.B. "Keine Änderungen seit letzter Meldung"'}),
'einkommenssituation_datei': forms.FileInput(attrs={'class': 'form-control', 'accept': '.pdf,.jpg,.jpeg,.png,.doc,.docx'}),
'vermogenssituation_bestaetigt': forms.CheckboxInput(attrs={'class': 'form-check-input'}),
'vermogenssituation_text': forms.Textarea(attrs={'class': 'form-control', 'rows': 3, 'placeholder': 'Z.B. "Keine Änderungen seit letzter Meldung"'}),
'vermogenssituation_datei': forms.FileInput(attrs={'class': 'form-control', 'accept': '.pdf,.jpg,.jpeg,.png,.doc,.docx'}),
'weitere_dokumente': forms.FileInput(attrs={'class': 'form-control', 'accept': '.pdf,.jpg,.jpeg,.png,.doc,.docx'}),
'weitere_dokumente_beschreibung': forms.Textarea(attrs={'class': 'form-control', 'rows': 2}),
'interne_notizen': forms.Textarea(attrs={'class': 'form-control', 'rows': 3}),
}
labels = {
'studiennachweis_erforderlich': 'Studiennachweis erforderlich',
'studiennachweis_eingereicht': 'Studiennachweis eingereicht',
'studiennachweis_datei': 'Studiennachweis (Datei)',
'studiennachweis_bemerkung': 'Bemerkung zum Studiennachweis',
'einkommenssituation_bestaetigt': 'Einkommenssituation bestätigt',
'einkommenssituation_text': 'Einkommenssituation (Text)',
'einkommenssituation_datei': 'Einkommenssituation (Datei)',
'vermogenssituation_bestaetigt': 'Vermögenssituation bestätigt',
'vermogenssituation_text': 'Vermögenssituation (Text)',
'vermogenssituation_datei': 'Vermögenssituation (Datei)',
'weitere_dokumente': 'Weitere Dokumente',
'weitere_dokumente_beschreibung': 'Beschreibung weitere Dokumente',
'interne_notizen': 'Interne Notizen (nur für Verwaltung)',
}
help_texts = {
'einkommenssituation_text': 'Z.B. "Keine Änderungen seit letzter Meldung" oder Details zu Änderungen',
'vermogenssituation_text': 'Z.B. "Keine Änderungen seit letzter Meldung" oder Details zu Änderungen',
'interne_notizen': 'Diese Notizen sind nur für die interne Verwaltung sichtbar',
}
def clean(self):
cleaned_data = super().clean()
# Validate that at least one form of confirmation is provided for income situation
einkommenssituation_text = cleaned_data.get('einkommenssituation_text')
einkommenssituation_datei = cleaned_data.get('einkommenssituation_datei')
einkommenssituation_bestaetigt = cleaned_data.get('einkommenssituation_bestaetigt')
if einkommenssituation_bestaetigt and not einkommenssituation_text and not einkommenssituation_datei:
raise ValidationError(
'Wenn die Einkommenssituation bestätigt wird, muss entweder ein Text oder eine Datei angegeben werden.'
)
# Validate that at least one form of confirmation is provided for asset situation
vermogenssituation_text = cleaned_data.get('vermogenssituation_text')
vermogenssituation_datei = cleaned_data.get('vermogenssituation_datei')
vermogenssituation_bestaetigt = cleaned_data.get('vermogenssituation_bestaetigt')
if vermogenssituation_bestaetigt and not vermogenssituation_text and not vermogenssituation_datei:
raise ValidationError(
'Wenn die Vermögenssituation bestätigt wird, muss entweder ein Text oder eine Datei angegeben werden.'
)
# Validate study proof if required and marked as submitted
studiennachweis_erforderlich = cleaned_data.get('studiennachweis_erforderlich')
studiennachweis_eingereicht = cleaned_data.get('studiennachweis_eingereicht')
studiennachweis_datei = cleaned_data.get('studiennachweis_datei')
studiennachweis_bemerkung = cleaned_data.get('studiennachweis_bemerkung')
if studiennachweis_erforderlich and studiennachweis_eingereicht:
if not studiennachweis_datei and not studiennachweis_bemerkung:
raise ValidationError(
'Wenn der Studiennachweis als eingereicht markiert wird, muss entweder eine Datei oder eine Bemerkung angegeben werden.'
)
return cleaned_data

View File

@@ -0,0 +1,54 @@
# Generated by Django 5.0.6 on 2025-09-23 19:33
import django.core.validators
import django.db.models.deletion
import uuid
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('stiftung', '0029_alter_destinataer_berufsgruppe_and_more'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name='VierteljahresNachweis',
fields=[
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
('jahr', models.IntegerField(validators=[django.core.validators.MinValueValidator(2020), django.core.validators.MaxValueValidator(2050)], verbose_name='Jahr')),
('quartal', models.IntegerField(choices=[(1, 'Q1 (Jan-Mär)'), (2, 'Q2 (Apr-Jun)'), (3, 'Q3 (Jul-Sep)'), (4, 'Q4 (Okt-Dez)')], verbose_name='Quartal')),
('studiennachweis_erforderlich', models.BooleanField(default=True, verbose_name='Studiennachweis erforderlich')),
('studiennachweis_eingereicht', models.BooleanField(default=False, verbose_name='Studiennachweis eingereicht')),
('studiennachweis_datei', models.FileField(blank=True, null=True, upload_to='quarterly_proofs/studies/%Y/Q%m/', verbose_name='Studiennachweis (Datei)')),
('studiennachweis_bemerkung', models.TextField(blank=True, null=True, verbose_name='Bemerkung zum Studiennachweis')),
('einkommenssituation_bestaetigt', models.BooleanField(default=False, verbose_name='Einkommenssituation bestätigt')),
('einkommenssituation_text', models.TextField(blank=True, help_text="Z.B. 'Keine Änderungen seit letzter Meldung' oder Details zu Änderungen", null=True, verbose_name='Einkommenssituation (Text)')),
('einkommenssituation_datei', models.FileField(blank=True, null=True, upload_to='quarterly_proofs/income/%Y/Q%m/', verbose_name='Einkommenssituation (Datei)')),
('vermogenssituation_bestaetigt', models.BooleanField(default=False, verbose_name='Vermögenssituation bestätigt')),
('vermogenssituation_text', models.TextField(blank=True, help_text="Z.B. 'Keine Änderungen seit letzter Meldung' oder Details zu Änderungen", null=True, verbose_name='Vermögenssituation (Text)')),
('vermogenssituation_datei', models.FileField(blank=True, null=True, upload_to='quarterly_proofs/assets/%Y/Q%m/', verbose_name='Vermögenssituation (Datei)')),
('weitere_dokumente', models.FileField(blank=True, null=True, upload_to='quarterly_proofs/additional/%Y/Q%m/', verbose_name='Weitere Dokumente')),
('weitere_dokumente_beschreibung', models.TextField(blank=True, null=True, verbose_name='Beschreibung weitere Dokumente')),
('status', models.CharField(choices=[('offen', 'Nachweis ausstehend'), ('teilweise', 'Teilweise eingereicht'), ('eingereicht', 'Vollständig eingereicht'), ('geprueft', 'Geprüft & Freigegeben'), ('nachbesserung', 'Nachbesserung erforderlich'), ('abgelehnt', 'Abgelehnt')], default='offen', max_length=20, verbose_name='Status')),
('interne_notizen', models.TextField(blank=True, null=True, verbose_name='Interne Notizen (nur für Verwaltung)')),
('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(blank=True, null=True, verbose_name='Eingereicht am')),
('geprueft_am', models.DateTimeField(blank=True, null=True, verbose_name='Geprüft am')),
('faelligkeitsdatum', models.DateField(blank=True, null=True, verbose_name='Fälligkeitsdatum')),
('destinataer', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='quartalseinreichungen', to='stiftung.destinataer', verbose_name='Destinatär')),
('geprueft_von', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to=settings.AUTH_USER_MODEL, verbose_name='Geprüft von')),
],
options={
'verbose_name': 'Vierteljahresnachweis',
'verbose_name_plural': 'Vierteljahresnachweise',
'ordering': ['-jahr', '-quartal', 'destinataer__nachname'],
'indexes': [models.Index(fields=['jahr', 'quartal', 'status'], name='stiftung_vi_jahr_d9607d_idx'), models.Index(fields=['destinataer', 'status'], name='stiftung_vi_destina_957fd8_idx'), models.Index(fields=['faelligkeitsdatum'], name='stiftung_vi_faellig_353e7c_idx')],
'unique_together': {('destinataer', 'jahr', 'quartal')},
},
),
]

View File

@@ -2472,3 +2472,296 @@ class HelpBox(models.Model):
return cls.objects.get(page_key=page_key, is_active=True)
except cls.DoesNotExist:
return None
class VierteljahresNachweis(models.Model):
"""Quarterly confirmation system for Destinatäre"""
QUARTAL_CHOICES = [
(1, "Q1 (Jan-Mär)"),
(2, "Q2 (Apr-Jun)"),
(3, "Q3 (Jul-Sep)"),
(4, "Q4 (Okt-Dez)"),
]
STATUS_CHOICES = [
("offen", "Nachweis ausstehend"),
("teilweise", "Teilweise eingereicht"),
("eingereicht", "Vollständig eingereicht"),
("geprueft", "Geprüft & Freigegeben"),
("nachbesserung", "Nachbesserung erforderlich"),
("abgelehnt", "Abgelehnt"),
]
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
destinataer = models.ForeignKey(
Destinataer,
on_delete=models.CASCADE,
related_name="quartalseinreichungen",
verbose_name="Destinatär"
)
# Time period
jahr = models.IntegerField(
verbose_name="Jahr",
validators=[MinValueValidator(2020), MaxValueValidator(2050)]
)
quartal = models.IntegerField(
choices=QUARTAL_CHOICES,
verbose_name="Quartal"
)
# Study proof (if required)
studiennachweis_erforderlich = models.BooleanField(
default=True,
verbose_name="Studiennachweis erforderlich"
)
studiennachweis_eingereicht = models.BooleanField(
default=False,
verbose_name="Studiennachweis eingereicht"
)
studiennachweis_datei = models.FileField(
upload_to="quarterly_proofs/studies/%Y/Q%m/",
null=True,
blank=True,
verbose_name="Studiennachweis (Datei)"
)
studiennachweis_bemerkung = models.TextField(
null=True,
blank=True,
verbose_name="Bemerkung zum Studiennachweis"
)
# Income/situation confirmation
einkommenssituation_bestaetigt = models.BooleanField(
default=False,
verbose_name="Einkommenssituation bestätigt"
)
einkommenssituation_text = models.TextField(
null=True,
blank=True,
verbose_name="Einkommenssituation (Text)",
help_text="Z.B. 'Keine Änderungen seit letzter Meldung' oder Details zu Änderungen"
)
einkommenssituation_datei = models.FileField(
upload_to="quarterly_proofs/income/%Y/Q%m/",
null=True,
blank=True,
verbose_name="Einkommenssituation (Datei)"
)
# Asset/wealth confirmation
vermogenssituation_bestaetigt = models.BooleanField(
default=False,
verbose_name="Vermögenssituation bestätigt"
)
vermogenssituation_text = models.TextField(
null=True,
blank=True,
verbose_name="Vermögenssituation (Text)",
help_text="Z.B. 'Keine Änderungen seit letzter Meldung' oder Details zu Änderungen"
)
vermogenssituation_datei = models.FileField(
upload_to="quarterly_proofs/assets/%Y/Q%m/",
null=True,
blank=True,
verbose_name="Vermögenssituation (Datei)"
)
# Additional documents
weitere_dokumente = models.FileField(
upload_to="quarterly_proofs/additional/%Y/Q%m/",
null=True,
blank=True,
verbose_name="Weitere Dokumente"
)
weitere_dokumente_beschreibung = models.TextField(
null=True,
blank=True,
verbose_name="Beschreibung weitere Dokumente"
)
# Review and approval
status = models.CharField(
max_length=20,
choices=STATUS_CHOICES,
default="offen",
verbose_name="Status"
)
interne_notizen = models.TextField(
null=True,
blank=True,
verbose_name="Interne Notizen (nur für Verwaltung)"
)
# Timestamps and tracking
erstellt_am = models.DateTimeField(auto_now_add=True, verbose_name="Erstellt am")
aktualisiert_am = models.DateTimeField(auto_now=True, verbose_name="Aktualisiert am")
eingereicht_am = models.DateTimeField(
null=True,
blank=True,
verbose_name="Eingereicht am"
)
geprueft_am = models.DateTimeField(
null=True,
blank=True,
verbose_name="Geprüft am"
)
geprueft_von = models.ForeignKey(
"auth.User",
on_delete=models.SET_NULL,
null=True,
blank=True,
verbose_name="Geprüft von"
)
# Deadline tracking
faelligkeitsdatum = models.DateField(
null=True,
blank=True,
verbose_name="Fälligkeitsdatum"
)
class Meta:
verbose_name = "Vierteljahresnachweis"
verbose_name_plural = "Vierteljahresnachweise"
ordering = ["-jahr", "-quartal", "destinataer__nachname"]
unique_together = ["destinataer", "jahr", "quartal"] # One entry per quarter per person
indexes = [
models.Index(fields=["jahr", "quartal", "status"]),
models.Index(fields=["destinataer", "status"]),
models.Index(fields=["faelligkeitsdatum"]),
]
def __str__(self):
return f"{self.destinataer.get_full_name()} - {self.jahr} Q{self.quartal} ({self.get_status_display()})"
def get_quarter_display(self):
"""Get a nice display name for the quarter"""
quarter_names = {
1: "Q1 (Januar - März)",
2: "Q2 (April - Juni)",
3: "Q3 (Juli - September)",
4: "Q4 (Oktober - Dezember)"
}
return quarter_names.get(self.quartal, f"Q{self.quartal}")
def is_complete(self):
"""Check if all required documents/confirmations are provided"""
complete = True
# Check study proof (always required now)
complete &= self.studiennachweis_eingereicht and (
bool(self.studiennachweis_datei) or bool(self.studiennachweis_bemerkung)
)
# Check income situation (either text or file)
complete &= self.einkommenssituation_bestaetigt and (
bool(self.einkommenssituation_text) or bool(self.einkommenssituation_datei)
)
# Check asset situation (either text or file)
complete &= self.vermogenssituation_bestaetigt and (
bool(self.vermogenssituation_text) or bool(self.vermogenssituation_datei)
)
return complete
def is_overdue(self):
"""Check if the deadline has passed"""
if not self.faelligkeitsdatum:
return False
return timezone.now().date() > self.faelligkeitsdatum and self.status in ["offen", "teilweise"]
def get_completion_percentage(self):
"""Calculate completion percentage"""
total_requirements = 2 # Income and assets always required
completed_requirements = 0
# Study proof (if required)
if self.studiennachweis_erforderlich:
total_requirements += 1
if self.studiennachweis_eingereicht and (
bool(self.studiennachweis_datei) or bool(self.studiennachweis_bemerkung)
):
completed_requirements += 1
# Income situation
if self.einkommenssituation_bestaetigt and (
bool(self.einkommenssituation_text) or bool(self.einkommenssituation_datei)
):
completed_requirements += 1
# Asset situation
if self.vermogenssituation_bestaetigt and (
bool(self.vermogenssituation_text) or bool(self.vermogenssituation_datei)
):
completed_requirements += 1
return int((completed_requirements / total_requirements) * 100) if total_requirements > 0 else 0
def save(self, *args, **kwargs):
"""Override save to auto-update status and timestamps"""
# Auto-set deadline if not provided (15th of the quarter's second month)
if not self.faelligkeitsdatum:
from datetime import date
quarter_deadlines = {
1: date(self.jahr, 2, 15), # Q1 deadline: Feb 15
2: date(self.jahr, 5, 15), # Q2 deadline: May 15
3: date(self.jahr, 8, 15), # Q3 deadline: Aug 15
4: date(self.jahr, 11, 15), # Q4 deadline: Nov 15
}
self.faelligkeitsdatum = quarter_deadlines.get(self.quartal)
# Auto-update status based on completion
if self.is_complete():
if self.status == "offen":
self.status = "eingereicht"
self.eingereicht_am = timezone.now()
else:
completion = self.get_completion_percentage()
if completion > 0 and completion < 100 and self.status == "offen":
self.status = "teilweise"
super().save(*args, **kwargs)
@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")

View File

@@ -348,4 +348,20 @@ urlpatterns = [
# Gramps integration (probe)
path("api/gramps/search/", views.gramps_search_api, name="gramps_search_api"),
path("api/gramps/debug/", views.gramps_debug_api, name="gramps_debug_api"),
# Quarterly Confirmations
path(
"quarterly-confirmations/create/<uuid:destinataer_id>/",
views.quarterly_confirmation_create,
name="quarterly_confirmation_create",
),
path(
"quarterly-confirmations/<uuid:pk>/edit/",
views.quarterly_confirmation_edit,
name="quarterly_confirmation_edit",
),
path(
"quarterly-confirmations/<uuid:pk>/update/",
views.quarterly_confirmation_update,
name="quarterly_confirmation_update",
),
]

View File

@@ -3,7 +3,7 @@ import io
import json
import os
import time
from datetime import datetime
from datetime import datetime, timedelta
from decimal import Decimal
import requests
@@ -24,7 +24,7 @@ from rest_framework.response import Response
from .models import (AppConfiguration, CSVImport, Destinataer,
DestinataerUnterstuetzung, DokumentLink, Foerderung, Land,
LandAbrechnung, LandVerpachtung, Paechter, Person,
StiftungsKonto, UnterstuetzungWiederkehrend)
StiftungsKonto, UnterstuetzungWiederkehrend, VierteljahresNachweis)
def get_pdf_generator():
@@ -214,7 +214,7 @@ from stiftung.models import DestinataerNotiz, DestinataerUnterstuetzung
from .forms import (DestinataerForm, DestinataerNotizForm,
DestinataerUnterstuetzungForm, DokumentLinkForm,
FoerderungForm, LandForm, PaechterForm, PersonForm,
UnterstuetzungForm, UnterstuetzungMarkAsPaidForm)
UnterstuetzungForm, UnterstuetzungMarkAsPaidForm, VierteljahresNachweisForm)
def home(request):
@@ -1233,6 +1233,30 @@ def destinataer_detail(request, pk):
destinataer=destinataer
).order_by("-erstellt_am")
# Quarterly confirmations - load for current and next year
from datetime import date
current_year = date.today().year
quarterly_confirmations = VierteljahresNachweis.objects.filter(
destinataer=destinataer,
jahr__in=[current_year, current_year + 1]
).order_by('-jahr', '-quartal')
# Create missing quarterly confirmations for current year if destinataer requires study proof
if destinataer.studiennachweis_erforderlich:
for quartal in range(1, 5): # Q1-Q4
nachweis, created = VierteljahresNachweis.get_or_create_for_period(
destinataer, current_year, quartal
)
# Reload to get any newly created confirmations
quarterly_confirmations = VierteljahresNachweis.objects.filter(
destinataer=destinataer,
jahr__in=[current_year, current_year + 1]
).order_by('-jahr', '-quartal')
# Generate available years for the add quarter dropdown (current year + next 5 years)
available_years = list(range(current_year, current_year + 6))
# Alle verfügbaren StiftungsKonten für das Select-Feld laden
stiftungskonten = StiftungsKonto.objects.all().order_by("kontoname")
@@ -1243,6 +1267,9 @@ def destinataer_detail(request, pk):
"unterstuetzungen": unterstuetzungen,
"notizen_eintraege": notizen_eintraege,
"stiftungskonten": stiftungskonten,
"quarterly_confirmations": quarterly_confirmations,
"available_years": available_years,
"current_year": current_year,
}
return render(request, "stiftung/destinataer_detail.html", context)
@@ -7195,3 +7222,318 @@ def verpachtung_delete(request, pk):
'title': f'Verpachtung {verpachtung.vertragsnummer} löschen',
}
return render(request, 'stiftung/verpachtung_confirm_delete.html', context)
@login_required
def quarterly_confirmation_update(request, pk):
"""Update quarterly confirmation for destinataer"""
nachweis = get_object_or_404(VierteljahresNachweis, pk=pk)
if request.method == "POST":
form = VierteljahresNachweisForm(request.POST, request.FILES, instance=nachweis)
if form.is_valid():
quarterly_proof = form.save(commit=False)
# Calculate current status before saving
old_status = nachweis.status
# Auto-update status based on completion
if quarterly_proof.is_complete():
if quarterly_proof.status in ['offen', 'teilweise']:
quarterly_proof.status = 'eingereicht'
quarterly_proof.eingereicht_am = timezone.now()
else:
# If not complete, set to teilweise if some fields are filled
has_partial_data = (
quarterly_proof.einkommenssituation_bestaetigt or
quarterly_proof.vermogenssituation_bestaetigt or
quarterly_proof.studiennachweis_eingereicht
)
if has_partial_data and quarterly_proof.status == 'offen':
quarterly_proof.status = 'teilweise'
quarterly_proof.save()
# Try to create automatic support payment if complete
if quarterly_proof.is_complete() and quarterly_proof.status == 'eingereicht':
support_payment = create_quarterly_support_payment(quarterly_proof)
if support_payment:
messages.success(
request,
f"Automatische Unterstützung über {support_payment.betrag}€ für {support_payment.destinataer.get_full_name()} wurde erstellt (fällig am {support_payment.faellig_am.strftime('%d.%m.%Y')})."
)
else:
# Log why payment wasn't created
reasons = []
if not quarterly_proof.destinataer.vierteljaehrlicher_betrag:
reasons.append("kein vierteljährlicher Betrag hinterlegt")
if not quarterly_proof.destinataer.iban:
reasons.append("keine IBAN hinterlegt")
if not quarterly_proof.destinataer.standard_konto and not StiftungsKonto.objects.exists():
reasons.append("kein Auszahlungskonto verfügbar")
if reasons:
messages.warning(
request,
f"Automatische Unterstützung konnte nicht erstellt werden: {', '.join(reasons)}"
)
# Debug message to see what happened
status_changed = old_status != quarterly_proof.status
status_msg = f" (Status: {old_status}{quarterly_proof.status})" if status_changed else f" (Status: {quarterly_proof.status})"
messages.success(
request,
f"Vierteljahresnachweis für {nachweis.destinataer.get_full_name()} "
f"({nachweis.jahr} Q{nachweis.quartal}) wurde erfolgreich aktualisiert{status_msg}."
)
return redirect("stiftung:destinataer_detail", pk=nachweis.destinataer.pk)
else:
# Add form errors to messages
for field, errors in form.errors.items():
for error in errors:
messages.error(request, f"Fehler in {field}: {error}")
# If GET request or form errors, redirect back to destinataer detail
return redirect("stiftung:destinataer_detail", pk=nachweis.destinataer.pk)
def create_quarterly_support_payment(nachweis):
"""
Create an automatic support payment when all quarterly requirements are met
"""
destinataer = nachweis.destinataer
# Check if all requirements are met
if not nachweis.is_complete():
return None
# Check if destinataer has required payment info
if not destinataer.vierteljaehrlicher_betrag or destinataer.vierteljaehrlicher_betrag <= 0:
return None
if not destinataer.iban:
return None
# Check if a payment for this quarter already exists
quarter_start = datetime(nachweis.jahr, (nachweis.quartal - 1) * 3 + 1, 1).date()
quarter_end = datetime(nachweis.jahr, nachweis.quartal * 3, 1).date()
existing_payment = DestinataerUnterstuetzung.objects.filter(
destinataer=destinataer,
faellig_am__gte=quarter_start,
faellig_am__lt=quarter_end,
beschreibung__contains=f"Q{nachweis.quartal}/{nachweis.jahr}"
).first()
if existing_payment:
return existing_payment
# Get default payment account
default_konto = destinataer.standard_konto
if not default_konto:
# Try to get any StiftungsKonto
default_konto = StiftungsKonto.objects.first()
if not default_konto:
return None
# Calculate payment due date (last day of quarter)
# Quarter end months and their last days:
# Q1: March (31), Q2: June (30), Q3: September (30), Q4: December (31)
quarter_end_month = nachweis.quartal * 3
if nachweis.quartal == 1: # Q1: January-March (ends March 31)
quarter_end_day = 31
elif nachweis.quartal == 2: # Q2: April-June (ends June 30)
quarter_end_day = 30
elif nachweis.quartal == 3: # Q3: July-September (ends September 30)
quarter_end_day = 30
else: # Q4: October-December (ends December 31)
quarter_end_day = 31
payment_due_date = datetime(nachweis.jahr, quarter_end_month, quarter_end_day).date()
# Create the support payment
payment = DestinataerUnterstuetzung.objects.create(
destinataer=destinataer,
konto=default_konto,
betrag=destinataer.vierteljaehrlicher_betrag,
faellig_am=payment_due_date,
status='geplant',
beschreibung=f"Vierteljährliche Unterstützung Q{nachweis.quartal}/{nachweis.jahr} (automatisch erstellt)",
empfaenger_iban=destinataer.iban,
empfaenger_name=destinataer.get_full_name(),
verwendungszweck=f"Vierteljährliche Unterstützung Q{nachweis.quartal}/{nachweis.jahr} - {destinataer.get_full_name()}",
erstellt_am=timezone.now(),
aktualisiert_am=timezone.now()
)
return payment
@login_required
def quarterly_confirmation_create(request, destinataer_id):
"""Create a new quarterly confirmation for a destinataer"""
destinataer = get_object_or_404(Destinataer, pk=destinataer_id)
if request.method == "POST":
jahr = request.POST.get('jahr')
quartal = request.POST.get('quartal')
if jahr and quartal:
try:
jahr = int(jahr)
quartal = int(quartal)
# Check if this quarter already exists
existing = VierteljahresNachweis.objects.filter(
destinataer=destinataer,
jahr=jahr,
quartal=quartal
).exists()
if existing:
messages.warning(
request,
f"Quartal {jahr} Q{quartal} existiert bereits für {destinataer.get_full_name()}."
)
else:
# Create new quarterly confirmation
nachweis = VierteljahresNachweis.objects.create(
destinataer=destinataer,
jahr=jahr,
quartal=quartal,
studiennachweis_erforderlich=True, # Always required now
)
# Set deadline (15th of second month of quarter)
deadline_months = {1: 5, 2: 8, 3: 11, 4: 2} # Q1->May, Q2->Aug, Q3->Nov, Q4->Feb(next year)
deadline_month = deadline_months[quartal]
deadline_year = jahr if quartal != 4 else jahr + 1
from datetime import date
nachweis.faelligkeitsdatum = date(deadline_year, deadline_month, 15)
nachweis.save()
messages.success(
request,
f"Quartal {jahr} Q{quartal} wurde erfolgreich für {destinataer.get_full_name()} erstellt."
)
except (ValueError, TypeError):
messages.error(request, "Ungültige Jahr- oder Quartalswerte.")
else:
messages.error(request, "Jahr und Quartal müssen angegeben werden.")
return redirect("stiftung:destinataer_detail", pk=destinataer.pk)
@login_required
def quarterly_confirmation_edit(request, pk):
"""Standalone edit view for quarterly confirmation"""
nachweis = get_object_or_404(VierteljahresNachweis, pk=pk)
if request.method == "POST":
form = VierteljahresNachweisForm(request.POST, request.FILES, instance=nachweis)
if form.is_valid():
quarterly_proof = form.save(commit=False)
# Calculate current status before saving
old_status = nachweis.status
# Auto-update status based on completion
if quarterly_proof.is_complete():
if quarterly_proof.status in ['offen', 'teilweise']:
quarterly_proof.status = 'eingereicht'
quarterly_proof.eingereicht_am = timezone.now()
else:
# If not complete, set to teilweise if some fields are filled
has_partial_data = (
quarterly_proof.einkommenssituation_bestaetigt or
quarterly_proof.vermogenssituation_bestaetigt or
quarterly_proof.studiennachweis_eingereicht
)
if has_partial_data and quarterly_proof.status == 'offen':
quarterly_proof.status = 'teilweise'
quarterly_proof.save()
# Try to create automatic support payment if complete
if quarterly_proof.is_complete() and quarterly_proof.status == 'eingereicht':
support_payment = create_quarterly_support_payment(quarterly_proof)
if support_payment:
messages.success(
request,
f"Automatische Unterstützung über {support_payment.betrag}€ für {support_payment.destinataer.get_full_name()} wurde erstellt (fällig am {support_payment.faellig_am.strftime('%d.%m.%Y')})."
)
else:
# Log why payment wasn't created
reasons = []
if not quarterly_proof.destinataer.vierteljaehrlicher_betrag:
reasons.append("kein vierteljährlicher Betrag hinterlegt")
if not quarterly_proof.destinataer.iban:
reasons.append("keine IBAN hinterlegt")
if not quarterly_proof.destinataer.standard_konto and not StiftungsKonto.objects.exists():
reasons.append("kein Auszahlungskonto verfügbar")
if reasons:
messages.warning(
request,
f"Automatische Unterstützung konnte nicht erstellt werden: {', '.join(reasons)}"
)
# Debug message to see what happened
status_changed = old_status != quarterly_proof.status
status_msg = f" (Status: {old_status}{quarterly_proof.status})" if status_changed else f" (Status: {quarterly_proof.status})"
messages.success(
request,
f"Vierteljahresnachweis für {nachweis.destinataer.get_full_name()} "
f"({nachweis.jahr} Q{nachweis.quartal}) wurde erfolgreich aktualisiert{status_msg}."
)
return redirect("stiftung:destinataer_detail", pk=nachweis.destinataer.pk)
else:
# Add form errors to messages
for field, errors in form.errors.items():
for error in errors:
messages.error(request, f"Fehler in {field}: {error}")
else:
form = VierteljahresNachweisForm(instance=nachweis)
context = {
'form': form,
'nachweis': nachweis,
'destinataer': nachweis.destinataer,
'title': f'Vierteljahresnachweis bearbeiten - {nachweis.destinataer.get_full_name()} {nachweis.jahr} Q{nachweis.quartal}',
}
return render(request, 'stiftung/quarterly_confirmation_edit.html', context)
@login_required
def quarterly_confirmation_approve(request, pk):
"""Approve quarterly confirmation (staff only)"""
if not request.user.is_staff:
messages.error(request, "Sie haben keine Berechtigung für diese Aktion.")
return redirect("stiftung:destinataer_list")
nachweis = get_object_or_404(VierteljahresNachweis, pk=pk)
if request.method == "POST":
if nachweis.status == 'eingereicht':
nachweis.status = 'geprueft'
nachweis.geprueft_am = timezone.now()
nachweis.geprueft_von = request.user
nachweis.save()
messages.success(
request,
f"Vierteljahresnachweis für {nachweis.destinataer.get_full_name()} "
f"({nachweis.jahr} Q{nachweis.quartal}) wurde freigegeben."
)
else:
messages.error(
request,
"Nur eingereichte Nachweise können freigegeben werden."
)
return redirect("stiftung:destinataer_detail", pk=nachweis.destinataer.pk)