Refactors the Berichte section from a single hardcoded Jahresbericht into a modular report-building system. Jahresbericht now uses PDFGenerator for corporate identity (logo, colors, headers/footers, cover page). 8 reusable section templates can be freely combined. 6 predefined report templates (Jahres-, Destinatär-, Grundstücks-, Finanz-, Förder-, Pachtbericht) with HTML preview and PDF export. New Bericht-Baukasten UI lets users compose custom reports from individual sections. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1091 lines
39 KiB
Python
1091 lines
39 KiB
Python
# views/finanzen.py
|
||
# Phase 0: Vision 2026 – Code-Refactoring
|
||
|
||
import csv
|
||
import io
|
||
import json
|
||
import os
|
||
import time
|
||
from datetime import datetime, timedelta, date
|
||
from decimal import Decimal
|
||
|
||
import qrcode
|
||
import qrcode.image.svg
|
||
import requests
|
||
from django.conf import settings
|
||
from django.contrib import messages
|
||
from django.contrib.auth.decorators import login_required
|
||
from django.core.paginator import Paginator
|
||
from django.db.models import (Avg, Count, DecimalField, F, IntegerField, Q,
|
||
Sum, Value)
|
||
from django.db.models.functions import Cast, Coalesce, NullIf, Replace
|
||
from django.http import HttpResponse, JsonResponse
|
||
from django.shortcuts import get_object_or_404, redirect, render
|
||
from django.urls import reverse
|
||
from django.utils import timezone
|
||
from django.views.decorators.csrf import csrf_exempt
|
||
from django_otp.decorators import otp_required
|
||
from django_otp.plugins.otp_totp.models import TOTPDevice
|
||
from django_otp.plugins.otp_static.models import StaticDevice, StaticToken
|
||
from django_otp.util import random_hex
|
||
from rest_framework.decorators import api_view
|
||
from rest_framework.response import Response
|
||
|
||
from stiftung.models import (AppConfiguration, AuditLog, BackupJob, BankTransaction,
|
||
BriefVorlage, CSVImport, Destinataer,
|
||
DestinataerEmailEingang, DestinataerNotiz,
|
||
DestinataerUnterstuetzung,
|
||
DokumentLink, Foerderung, GeschichteBild, GeschichteSeite,
|
||
Land, LandAbrechnung, LandVerpachtung, Paechter, Person,
|
||
Rentmeister, StiftungsKalenderEintrag, StiftungsKonto,
|
||
UnterstuetzungWiederkehrend, Veranstaltung,
|
||
Veranstaltungsteilnehmer, Verwaltungskosten,
|
||
VierteljahresNachweis)
|
||
from stiftung.forms import (
|
||
DestinataerForm, DestinataerUnterstuetzungForm, DestinataerNotizForm,
|
||
FoerderungForm, GeschichteBildForm, GeschichteSeiteForm,
|
||
LandForm, LandVerpachtungForm, LandAbrechnungForm,
|
||
PaechterForm, DokumentLinkForm,
|
||
RentmeisterForm, StiftungsKontoForm, VerwaltungskostenForm,
|
||
BankTransactionForm, BankImportForm,
|
||
UnterstuetzungForm, UnterstuetzungWiederkehrendForm,
|
||
UnterstuetzungMarkAsPaidForm, VierteljahresNachweisForm,
|
||
UserCreationForm, UserUpdateForm, PasswordChangeForm, UserPermissionForm,
|
||
TwoFactorSetupForm, TwoFactorVerifyForm, TwoFactorDisableForm,
|
||
BackupTokenRegenerateForm, PersonForm,
|
||
VeranstaltungForm, VeranstaltungsteilnehmerForm,
|
||
)
|
||
|
||
|
||
@login_required
|
||
def bericht_list(request):
|
||
"""List available reports with modular report builder"""
|
||
# Get available years from data
|
||
jahre = sorted(
|
||
set(
|
||
list(Foerderung.objects.values_list("jahr", flat=True))
|
||
+ list(LandVerpachtung.objects.values_list("pachtbeginn__year", flat=True))
|
||
),
|
||
reverse=True,
|
||
)
|
||
|
||
# Statistics for overview tiles
|
||
total_destinataere = Destinataer.objects.count()
|
||
total_laendereien = Land.objects.count()
|
||
total_verpachtungen = LandVerpachtung.objects.count()
|
||
total_foerderungen = Foerderung.objects.count()
|
||
|
||
context = {
|
||
"jahre": jahre,
|
||
"title": "Berichte",
|
||
"total_destinataere": total_destinataere,
|
||
"total_laendereien": total_laendereien,
|
||
"total_verpachtungen": total_verpachtungen,
|
||
"total_foerderungen": total_foerderungen,
|
||
"bericht_vorlagen": BERICHT_VORLAGEN,
|
||
"bericht_sektionen": BERICHT_SEKTIONEN,
|
||
}
|
||
return render(request, "stiftung/bericht_list.html", context)
|
||
|
||
|
||
def _get_corporate_context():
|
||
"""Holt Corporate-Identity-Einstellungen und CSS für Berichte."""
|
||
from stiftung.utils.pdf_generator import pdf_generator
|
||
corporate_settings = pdf_generator.get_corporate_settings()
|
||
logo_base64 = pdf_generator.get_logo_base64(corporate_settings.get("logo_path", ""))
|
||
css_content = pdf_generator.get_base_css(corporate_settings)
|
||
return {
|
||
"corporate_settings": corporate_settings,
|
||
"logo_base64": logo_base64,
|
||
"css_content": css_content,
|
||
}
|
||
|
||
|
||
def _jahresbericht_context(jahr):
|
||
"""Phase 4: Aggregiert alle Daten für den Jahresbericht."""
|
||
from stiftung.models import (
|
||
DestinataerUnterstuetzung, LandAbrechnung, Verwaltungskosten,
|
||
)
|
||
|
||
# Förderungen (legacy)
|
||
foerderungen = Foerderung.objects.filter(jahr=jahr).select_related("destinataer", "person")
|
||
total_foerderungen_legacy = foerderungen.aggregate(total=Sum("betrag"))["total"] or 0
|
||
|
||
# Unterstützungen (Phase 2 – neue Pipeline)
|
||
unterstuetzungen = DestinataerUnterstuetzung.objects.filter(
|
||
faellig_am__year=jahr
|
||
).exclude(status="storniert").select_related("destinataer", "konto")
|
||
unterstuetzungen_ausgezahlt = unterstuetzungen.filter(
|
||
status__in=["ausgezahlt", "abgeschlossen"]
|
||
)
|
||
total_unterstuetzungen = unterstuetzungen_ausgezahlt.aggregate(total=Sum("betrag"))["total"] or 0
|
||
|
||
# Gesamtausgaben Förderung
|
||
total_ausgaben_foerderung = total_foerderungen_legacy + total_unterstuetzungen
|
||
|
||
# Verpachtungen
|
||
verpachtungen = LandVerpachtung.objects.filter(
|
||
pachtbeginn__year__lte=jahr
|
||
).filter(
|
||
Q(pachtende__isnull=True) | Q(pachtende__year__gte=jahr)
|
||
).select_related("land", "paechter")
|
||
total_pachtzins = verpachtungen.aggregate(total=Sum("pachtzins_pauschal"))["total"] or 0
|
||
|
||
# Landabrechnungen für das Jahr
|
||
landabrechnungen = LandAbrechnung.objects.filter(
|
||
abrechnungsjahr=jahr
|
||
).select_related("land")
|
||
pacht_vereinnahmt = landabrechnungen.aggregate(total=Sum("pacht_vereinnahmt"))["total"] or 0
|
||
grundsteuer_gesamt = landabrechnungen.aggregate(total=Sum("grundsteuer_betrag"))["total"] or 0
|
||
|
||
# Verwaltungskosten
|
||
verwaltungskosten_qs = Verwaltungskosten.objects.filter(datum__year=jahr)
|
||
total_verwaltungskosten = verwaltungskosten_qs.aggregate(total=Sum("betrag"))["total"] or 0
|
||
verwaltungskosten_nach_kategorie = (
|
||
verwaltungskosten_qs
|
||
.values("kategorie")
|
||
.annotate(summe=Sum("betrag"), anzahl=Count("id"))
|
||
.order_by("-summe")
|
||
)
|
||
|
||
# Gesamtbilanz
|
||
total_einnahmen = pacht_vereinnahmt if pacht_vereinnahmt else total_pachtzins
|
||
total_ausgaben = total_ausgaben_foerderung + total_verwaltungskosten
|
||
netto = total_einnahmen - total_ausgaben
|
||
|
||
context = {
|
||
"jahr": jahr,
|
||
"title": f"Jahresbericht {jahr}",
|
||
"foerderungen": foerderungen,
|
||
"total_foerderungen_legacy": total_foerderungen_legacy,
|
||
"unterstuetzungen": unterstuetzungen,
|
||
"unterstuetzungen_ausgezahlt": unterstuetzungen_ausgezahlt,
|
||
"total_unterstuetzungen": total_unterstuetzungen,
|
||
"total_ausgaben_foerderung": total_ausgaben_foerderung,
|
||
"verpachtungen": verpachtungen,
|
||
"total_pachtzins": total_pachtzins,
|
||
"landabrechnungen": landabrechnungen,
|
||
"pacht_vereinnahmt": pacht_vereinnahmt,
|
||
"grundsteuer_gesamt": grundsteuer_gesamt,
|
||
"verwaltungskosten_nach_kategorie": verwaltungskosten_nach_kategorie,
|
||
"total_verwaltungskosten": total_verwaltungskosten,
|
||
"total_einnahmen": total_einnahmen,
|
||
"total_ausgaben": total_ausgaben,
|
||
"netto": netto,
|
||
"total_foerderungen": total_ausgaben_foerderung,
|
||
"show_cover": True,
|
||
"bericht_titel": f"Jahresbericht {jahr}",
|
||
"bericht_untertitel": "Gesamtübersicht des Geschäftsjahres",
|
||
"berichtszeitraum": str(jahr),
|
||
}
|
||
context.update(_get_corporate_context())
|
||
return context
|
||
|
||
|
||
@login_required
|
||
def jahresbericht_generate(request, jahr):
|
||
"""Phase 4: Jahresbericht mit aggregierten Finanzdaten."""
|
||
context = _jahresbericht_context(jahr)
|
||
return render(request, "stiftung/jahresbericht.html", context)
|
||
|
||
|
||
@login_required
|
||
def jahresbericht_generate_redirect(request):
|
||
"""Redirects the GET form without path param to the proper URL using the provided query param 'jahr'."""
|
||
jahr = request.GET.get("jahr")
|
||
if jahr and str(jahr).isdigit():
|
||
return redirect("stiftung:jahresbericht_generate", jahr=int(jahr))
|
||
messages.error(request, "Bitte wählen Sie ein gültiges Jahr aus.")
|
||
return redirect("stiftung:bericht_list")
|
||
|
||
|
||
@login_required
|
||
def jahresbericht_pdf(request, jahr):
|
||
"""Phase 4: PDF-Export des Jahresberichts via PDFGenerator."""
|
||
from django.template.loader import render_to_string
|
||
from stiftung.utils.pdf_generator import pdf_generator
|
||
|
||
context = _jahresbericht_context(jahr)
|
||
html_string = render_to_string("stiftung/jahresbericht.html", context)
|
||
return pdf_generator.generate_pdf_response(
|
||
html_string, f"jahresbericht_{jahr}.pdf", context.get("css_content")
|
||
)
|
||
|
||
|
||
# =============================================================================
|
||
# MODULARE BERICHTE – Berichts-Baukasten
|
||
# =============================================================================
|
||
|
||
# Verfügbare Sektionen mit Metadaten
|
||
BERICHT_SEKTIONEN = {
|
||
"bilanz": {"label": "Jahresbilanz", "icon": "fa-balance-scale", "needs_jahr": True},
|
||
"unterstuetzungen": {"label": "Unterstützungszahlungen", "icon": "fa-hand-holding-heart", "needs_jahr": True},
|
||
"foerderungen": {"label": "Förderungen", "icon": "fa-gift", "needs_jahr": True},
|
||
"grundstuecke": {"label": "Grundstücksverwaltung", "icon": "fa-map", "needs_jahr": True},
|
||
"verwaltungskosten": {"label": "Verwaltungskosten", "icon": "fa-file-invoice-dollar", "needs_jahr": True},
|
||
"destinataere_uebersicht": {"label": "Destinatär-Übersicht", "icon": "fa-users", "needs_jahr": False},
|
||
"konten_uebersicht": {"label": "Kontenübersicht", "icon": "fa-university", "needs_jahr": False},
|
||
"verpachtungen": {"label": "Pachtbericht", "icon": "fa-handshake", "needs_jahr": False},
|
||
}
|
||
|
||
# Vordefinierte Berichtstypen
|
||
BERICHT_VORLAGEN = {
|
||
"jahresbericht": {
|
||
"label": "Jahresbericht",
|
||
"beschreibung": "Vollständige Übersicht eines Geschäftsjahres",
|
||
"sektionen": ["bilanz", "unterstuetzungen", "foerderungen", "grundstuecke", "verwaltungskosten"],
|
||
"needs_jahr": True,
|
||
"icon": "fa-calendar-alt",
|
||
},
|
||
"destinataerbericht": {
|
||
"label": "Destinatärbericht",
|
||
"beschreibung": "Übersicht aller Destinatäre mit Förderstatus",
|
||
"sektionen": ["destinataere_uebersicht", "unterstuetzungen", "foerderungen"],
|
||
"needs_jahr": True,
|
||
"icon": "fa-users",
|
||
},
|
||
"grundstuecksbericht": {
|
||
"label": "Grundstücksbericht",
|
||
"beschreibung": "Liegenschaftsübersicht mit Pachtverträgen",
|
||
"sektionen": ["grundstuecke", "verpachtungen"],
|
||
"needs_jahr": True,
|
||
"icon": "fa-map",
|
||
},
|
||
"finanzbericht": {
|
||
"label": "Finanzbericht",
|
||
"beschreibung": "Einnahmen/Ausgaben und Kontenübersicht",
|
||
"sektionen": ["bilanz", "konten_uebersicht", "verwaltungskosten"],
|
||
"needs_jahr": True,
|
||
"icon": "fa-euro-sign",
|
||
},
|
||
"foerderbericht": {
|
||
"label": "Förderbericht",
|
||
"beschreibung": "Detailansicht aller Förderungen",
|
||
"sektionen": ["foerderungen", "unterstuetzungen"],
|
||
"needs_jahr": True,
|
||
"icon": "fa-gift",
|
||
},
|
||
"pachtbericht": {
|
||
"label": "Pachtbericht",
|
||
"beschreibung": "Pachtzinseinnahmen und Vertragsübersicht",
|
||
"sektionen": ["verpachtungen", "grundstuecke"],
|
||
"needs_jahr": True,
|
||
"icon": "fa-handshake",
|
||
},
|
||
}
|
||
|
||
|
||
def _build_section_context(sektionen, jahr=None):
|
||
"""Baut den Context für die gewählten Sektionen zusammen."""
|
||
from stiftung.models import (
|
||
DestinataerUnterstuetzung, LandAbrechnung, Verwaltungskosten,
|
||
)
|
||
context = {}
|
||
|
||
if jahr:
|
||
context["jahr"] = jahr
|
||
|
||
needs_jahresbericht = any(s in sektionen for s in [
|
||
"bilanz", "unterstuetzungen", "foerderungen", "grundstuecke", "verwaltungskosten"
|
||
])
|
||
if needs_jahresbericht and jahr:
|
||
jb = _jahresbericht_context(jahr)
|
||
context.update(jb)
|
||
|
||
if "destinataere_uebersicht" in sektionen:
|
||
from django.db.models import Count, Sum as DSum
|
||
qs = Destinataer.objects.all()
|
||
context["destinataere_aktiv"] = qs.filter(aktiv=True).count()
|
||
context["destinataere_gesamt"] = qs.count()
|
||
|
||
# Annotate with support stats
|
||
if jahr:
|
||
dest_qs = qs.annotate(
|
||
unterstuetzung_count=Count(
|
||
"unterstuetzungen",
|
||
filter=Q(unterstuetzungen__faellig_am__year=jahr),
|
||
),
|
||
unterstuetzung_summe=Coalesce(
|
||
DSum(
|
||
"unterstuetzungen__betrag",
|
||
filter=Q(
|
||
unterstuetzungen__faellig_am__year=jahr,
|
||
unterstuetzungen__status__in=["ausgezahlt", "abgeschlossen"],
|
||
),
|
||
),
|
||
Decimal("0"),
|
||
),
|
||
)
|
||
else:
|
||
dest_qs = qs.annotate(
|
||
unterstuetzung_count=Count("unterstuetzungen"),
|
||
unterstuetzung_summe=Coalesce(
|
||
DSum(
|
||
"unterstuetzungen__betrag",
|
||
filter=Q(unterstuetzungen__status__in=["ausgezahlt", "abgeschlossen"]),
|
||
),
|
||
Decimal("0"),
|
||
),
|
||
)
|
||
context["destinataere_liste"] = dest_qs.order_by("nachname", "vorname")
|
||
context["destinataere_total_unterstuetzung"] = (
|
||
dest_qs.aggregate(total=DSum("unterstuetzung_summe"))["total"] or 0
|
||
)
|
||
|
||
if "konten_uebersicht" in sektionen:
|
||
konten = StiftungsKonto.objects.filter(aktiv=True).order_by("bank_name", "kontoname")
|
||
context["konten_liste"] = konten
|
||
context["konten_anzahl"] = konten.count()
|
||
context["konten_gesamtsaldo"] = konten.aggregate(total=Sum("saldo"))["total"] or 0
|
||
|
||
if "verpachtungen" in sektionen:
|
||
from datetime import timedelta
|
||
heute = date.today()
|
||
in_12_monaten = heute + timedelta(days=365)
|
||
|
||
aktive = LandVerpachtung.objects.filter(
|
||
status="aktiv"
|
||
).select_related("land", "paechter")
|
||
|
||
auslaufend = aktive.filter(
|
||
pachtende__isnull=False,
|
||
pachtende__lte=in_12_monaten,
|
||
pachtende__gte=heute,
|
||
).order_by("pachtende")
|
||
|
||
total_flaeche = aktive.aggregate(total=Sum("verpachtete_flaeche"))["total"] or 0
|
||
total_pz = aktive.aggregate(total=Sum("pachtzins_pauschal"))["total"] or 0
|
||
|
||
context["pacht_statistik"] = {
|
||
"aktive_vertraege": aktive.count(),
|
||
"total_pachtzins": total_pz,
|
||
"total_flaeche": total_flaeche,
|
||
"auslaufend_12m": auslaufend.count(),
|
||
}
|
||
context["pacht_auslaufend"] = auslaufend
|
||
# Also provide full list if not already from jahresbericht
|
||
if "verpachtungen" not in context or not context.get("verpachtungen"):
|
||
if jahr:
|
||
context["verpachtungen"] = LandVerpachtung.objects.filter(
|
||
pachtbeginn__year__lte=jahr
|
||
).filter(
|
||
Q(pachtende__isnull=True) | Q(pachtende__year__gte=jahr)
|
||
).select_related("land", "paechter")
|
||
else:
|
||
context["verpachtungen"] = aktive
|
||
|
||
return context
|
||
|
||
|
||
@login_required
|
||
def bericht_zusammenstellen(request):
|
||
"""Modularer Bericht: Sektionen auswählen und zusammenstellen."""
|
||
if request.method == "POST":
|
||
sektionen = request.POST.getlist("sektionen")
|
||
jahr_str = request.POST.get("jahr", "")
|
||
show_cover = request.POST.get("show_cover") == "on"
|
||
output_format = request.POST.get("format", "html")
|
||
vorlage = request.POST.get("vorlage", "")
|
||
|
||
# Vorlage anwenden falls gewählt
|
||
if vorlage and vorlage in BERICHT_VORLAGEN and not sektionen:
|
||
sektionen = BERICHT_VORLAGEN[vorlage]["sektionen"]
|
||
|
||
if not sektionen:
|
||
messages.error(request, "Bitte wählen Sie mindestens eine Sektion aus.")
|
||
return redirect("stiftung:bericht_list")
|
||
|
||
jahr = int(jahr_str) if jahr_str and jahr_str.isdigit() else None
|
||
|
||
# Build context
|
||
context = _build_section_context(sektionen, jahr)
|
||
context["sektionen"] = sektionen
|
||
context["show_cover"] = show_cover
|
||
|
||
# Set titles
|
||
if vorlage and vorlage in BERICHT_VORLAGEN:
|
||
titel = BERICHT_VORLAGEN[vorlage]["label"]
|
||
else:
|
||
titel = "Bericht"
|
||
if jahr:
|
||
context["bericht_titel"] = f"{titel} {jahr}"
|
||
context["berichtszeitraum"] = str(jahr)
|
||
else:
|
||
context["bericht_titel"] = titel
|
||
context["berichtszeitraum"] = "Aktuell"
|
||
|
||
context["bericht_untertitel"] = BERICHT_VORLAGEN.get(vorlage, {}).get("beschreibung", "")
|
||
|
||
# Add corporate context if not already present
|
||
if "corporate_settings" not in context:
|
||
context.update(_get_corporate_context())
|
||
|
||
if output_format == "pdf":
|
||
from django.template.loader import render_to_string
|
||
from stiftung.utils.pdf_generator import pdf_generator
|
||
html_string = render_to_string("berichte/bericht_modular.html", context)
|
||
filename = f"bericht_{vorlage or 'custom'}_{jahr or 'aktuell'}.pdf"
|
||
return pdf_generator.generate_pdf_response(
|
||
html_string, filename, context.get("css_content")
|
||
)
|
||
else:
|
||
return render(request, "berichte/bericht_modular.html", context)
|
||
|
||
# GET: Redirect to bericht_list
|
||
return redirect("stiftung:bericht_list")
|
||
|
||
|
||
@login_required
|
||
def bericht_vorlage(request, vorlage_key):
|
||
"""Schnellzugriff: Vordefinierte Berichtsvorlage generieren."""
|
||
if vorlage_key not in BERICHT_VORLAGEN:
|
||
messages.error(request, f"Unbekannter Berichtstyp: {vorlage_key}")
|
||
return redirect("stiftung:bericht_list")
|
||
|
||
vorlage = BERICHT_VORLAGEN[vorlage_key]
|
||
jahr_str = request.GET.get("jahr", "")
|
||
jahr = int(jahr_str) if jahr_str and jahr_str.isdigit() else None
|
||
output_format = request.GET.get("format", "html")
|
||
|
||
if vorlage["needs_jahr"] and not jahr:
|
||
messages.error(request, "Bitte wählen Sie ein Jahr für diesen Bericht.")
|
||
return redirect("stiftung:bericht_list")
|
||
|
||
context = _build_section_context(vorlage["sektionen"], jahr)
|
||
context["sektionen"] = vorlage["sektionen"]
|
||
context["show_cover"] = True
|
||
context["bericht_titel"] = f"{vorlage['label']}" + (f" {jahr}" if jahr else "")
|
||
context["bericht_untertitel"] = vorlage["beschreibung"]
|
||
context["berichtszeitraum"] = str(jahr) if jahr else "Aktuell"
|
||
|
||
if "corporate_settings" not in context:
|
||
context.update(_get_corporate_context())
|
||
|
||
if output_format == "pdf":
|
||
from django.template.loader import render_to_string
|
||
from stiftung.utils.pdf_generator import pdf_generator
|
||
html_string = render_to_string("berichte/bericht_modular.html", context)
|
||
filename = f"{vorlage_key}_{jahr or 'aktuell'}.pdf"
|
||
return pdf_generator.generate_pdf_response(
|
||
html_string, filename, context.get("css_content")
|
||
)
|
||
else:
|
||
return render(request, "berichte/bericht_modular.html", context)
|
||
|
||
|
||
# API Views for AJAX
|
||
@login_required
|
||
def geschaeftsfuehrung(request):
|
||
"""Hauptansicht für die Geschäftsführung mit Übersicht"""
|
||
from datetime import datetime, timedelta
|
||
|
||
from django.db.models import Count, Sum
|
||
|
||
from stiftung.models import Rentmeister, StiftungsKonto, Verwaltungskosten
|
||
|
||
# Rentmeister-Übersicht
|
||
rentmeister = Rentmeister.objects.filter(aktiv=True).order_by("nachname", "vorname")
|
||
|
||
# Konten-Übersicht
|
||
konten = StiftungsKonto.objects.filter(aktiv=True).order_by(
|
||
"bank_name", "kontoname"
|
||
)
|
||
gesamtsaldo = konten.aggregate(total=Sum("saldo"))["total"] or 0
|
||
|
||
# Aktuelle Kosten (letzten 30 Tage)
|
||
heute = datetime.now().date()
|
||
vor_30_tagen = heute - timedelta(days=30)
|
||
|
||
aktuelle_kosten = Verwaltungskosten.objects.filter(
|
||
datum__gte=vor_30_tagen
|
||
).order_by("-datum")[:10]
|
||
|
||
# Statistiken
|
||
kosten_summe_monat = (
|
||
Verwaltungskosten.objects.filter(datum__gte=vor_30_tagen).aggregate(
|
||
total=Sum("betrag")
|
||
)["total"]
|
||
or 0
|
||
)
|
||
|
||
kosten_statistik = (
|
||
Verwaltungskosten.objects.filter(datum__gte=vor_30_tagen)
|
||
.values("kategorie")
|
||
.annotate(summe=Sum("betrag"), anzahl=Count("id"))
|
||
.order_by("-summe")
|
||
)
|
||
|
||
context = {
|
||
"rentmeister": rentmeister,
|
||
"konten": konten,
|
||
"gesamtsaldo": gesamtsaldo,
|
||
"aktuelle_kosten": aktuelle_kosten,
|
||
"kosten_summe_monat": kosten_summe_monat,
|
||
"kosten_statistik": kosten_statistik,
|
||
}
|
||
|
||
return render(request, "stiftung/geschaeftsfuehrung.html", context)
|
||
|
||
|
||
@login_required
|
||
def konto_list(request):
|
||
"""Liste aller Stiftungskonten"""
|
||
from django.db.models import Sum
|
||
|
||
from stiftung.models import StiftungsKonto
|
||
|
||
konten = StiftungsKonto.objects.all().order_by("bank_name", "kontoname")
|
||
gesamtsaldo = konten.aggregate(total=Sum("saldo"))["total"] or 0
|
||
|
||
context = {
|
||
"konten": konten,
|
||
"gesamtsaldo": gesamtsaldo,
|
||
}
|
||
|
||
return render(request, "stiftung/konto_list.html", context)
|
||
|
||
|
||
@login_required
|
||
def verwaltungskosten_list(request):
|
||
"""Liste aller Verwaltungskosten"""
|
||
from django.core.paginator import Paginator
|
||
|
||
from stiftung.models import Verwaltungskosten
|
||
|
||
kosten = Verwaltungskosten.objects.all().order_by("-datum", "-erstellt_am")
|
||
|
||
# Filter nach Kategorie
|
||
kategorie_filter = request.GET.get("kategorie")
|
||
if kategorie_filter:
|
||
kosten = kosten.filter(kategorie=kategorie_filter)
|
||
|
||
# Filter nach Status
|
||
status_filter = request.GET.get("status")
|
||
if status_filter:
|
||
kosten = kosten.filter(status=status_filter)
|
||
|
||
# Pagination
|
||
paginator = Paginator(kosten, 25)
|
||
page_number = request.GET.get("page")
|
||
page_obj = paginator.get_page(page_number)
|
||
|
||
# Für Filter-Dropdowns
|
||
kategorien = Verwaltungskosten.KATEGORIE_CHOICES
|
||
status_choices = Verwaltungskosten.STATUS_CHOICES
|
||
|
||
context = {
|
||
"page_obj": page_obj,
|
||
"kategorien": kategorien,
|
||
"status_choices": status_choices,
|
||
"kategorie_filter": kategorie_filter,
|
||
"status_filter": status_filter,
|
||
}
|
||
|
||
return render(request, "stiftung/verwaltungskosten_list.html", context)
|
||
|
||
|
||
@login_required
|
||
def verwaltungskosten_detail(request, pk):
|
||
"""Detailansicht einer Verwaltungskosten-Position mit verknüpften Dokumenten und E-Mails."""
|
||
from stiftung.models import DokumentDatei, EmailEingang, Verwaltungskosten
|
||
|
||
vk = get_object_or_404(Verwaltungskosten, pk=pk)
|
||
|
||
if request.method == "POST":
|
||
action = request.POST.get("action")
|
||
if action == "set_status":
|
||
new_status = request.POST.get("status", "")
|
||
if new_status in dict(Verwaltungskosten.STATUS_CHOICES):
|
||
vk.status = new_status
|
||
vk.save()
|
||
messages.success(request, f"Status auf '{vk.get_status_display()}' gesetzt.")
|
||
return redirect("stiftung:verwaltungskosten_detail", pk=pk)
|
||
|
||
# Verknüpfte DMS-Dokumente
|
||
dms_dokumente = DokumentDatei.objects.filter(verwaltungskosten=vk).order_by("erstellt_am")
|
||
|
||
# Verknüpfte E-Mails
|
||
email_eingaenge = EmailEingang.objects.filter(verwaltungskosten=vk).order_by("-eingangsdatum")
|
||
|
||
context = {
|
||
"vk": vk,
|
||
"dms_dokumente": dms_dokumente,
|
||
"email_eingaenge": email_eingaenge,
|
||
"status_choices": Verwaltungskosten.STATUS_CHOICES,
|
||
}
|
||
return render(request, "stiftung/verwaltungskosten_detail.html", context)
|
||
|
||
|
||
@login_required
|
||
def rentmeister_list(request):
|
||
"""Liste aller Rentmeister"""
|
||
from stiftung.models import Rentmeister
|
||
|
||
rentmeister = Rentmeister.objects.all().order_by("nachname", "vorname")
|
||
|
||
# Aktive/Inaktive aufteilen
|
||
aktive_rentmeister = rentmeister.filter(aktiv=True)
|
||
ehemalige_rentmeister = rentmeister.filter(aktiv=False)
|
||
|
||
context = {
|
||
"aktive_rentmeister": aktive_rentmeister,
|
||
"ehemalige_rentmeister": ehemalige_rentmeister,
|
||
"total_count": rentmeister.count(),
|
||
}
|
||
|
||
return render(request, "stiftung/rentmeister_list.html", context)
|
||
|
||
|
||
@login_required
|
||
def rentmeister_detail(request, pk):
|
||
"""Detailansicht eines Rentmeisters mit seinen Ausgaben"""
|
||
from datetime import datetime, timedelta
|
||
|
||
from django.db.models import Count, Q, Sum
|
||
|
||
from stiftung.models import Rentmeister, Verwaltungskosten
|
||
|
||
rentmeister = get_object_or_404(Rentmeister, pk=pk)
|
||
|
||
# Ausgaben des Rentmeisters
|
||
ausgaben = Verwaltungskosten.objects.filter(rentmeister=rentmeister).order_by(
|
||
"-datum"
|
||
)
|
||
|
||
# Statistiken
|
||
heute = datetime.now().date()
|
||
aktueller_monat = heute.replace(day=1)
|
||
aktuelles_jahr = heute.replace(month=1, day=1)
|
||
|
||
stats = {
|
||
"gesamt_ausgaben": ausgaben.aggregate(total=Sum("betrag"))["total"] or 0,
|
||
"monat_ausgaben": ausgaben.filter(datum__gte=aktueller_monat).aggregate(
|
||
total=Sum("betrag")
|
||
)["total"]
|
||
or 0,
|
||
"jahr_ausgaben": ausgaben.filter(datum__gte=aktuelles_jahr).aggregate(
|
||
total=Sum("betrag")
|
||
)["total"]
|
||
or 0,
|
||
"anzahl_ausgaben": ausgaben.count(),
|
||
"offene_ausgaben": ausgaben.exclude(status="bezahlt").count(),
|
||
}
|
||
|
||
# Kategorie-Aufschlüsselung
|
||
kategorie_stats = (
|
||
ausgaben.values("kategorie")
|
||
.annotate(summe=Sum("betrag"), anzahl=Count("id"))
|
||
.order_by("-summe")
|
||
)
|
||
|
||
# Aktuelle Ausgaben (letzten 30 Tage)
|
||
vor_30_tagen = heute - timedelta(days=30)
|
||
aktuelle_ausgaben = ausgaben.filter(datum__gte=vor_30_tagen)[:10]
|
||
|
||
# Verknüpfte Dokumente laden
|
||
from stiftung.models import DokumentLink
|
||
|
||
verknuepfte_dokumente = DokumentLink.objects.filter(
|
||
rentmeister_id=rentmeister.id
|
||
).order_by("-id")[
|
||
:10
|
||
] # Neueste 10 Dokumente
|
||
|
||
context = {
|
||
"rentmeister": rentmeister,
|
||
"ausgaben": ausgaben[:20], # Nur erste 20 für Übersicht
|
||
"stats": stats,
|
||
"kategorie_stats": kategorie_stats,
|
||
"aktuelle_ausgaben": aktuelle_ausgaben,
|
||
"verknuepfte_dokumente": verknuepfte_dokumente,
|
||
}
|
||
|
||
return render(request, "stiftung/rentmeister_detail.html", context)
|
||
|
||
|
||
@login_required
|
||
def rentmeister_ausgaben(request, pk):
|
||
"""Vollständige Ausgabenliste eines Rentmeisters mit PDF Export"""
|
||
from django.core.paginator import Paginator
|
||
from django.db import models
|
||
from django.db.models import Count, Q, Sum
|
||
|
||
from stiftung.models import Rentmeister, Verwaltungskosten
|
||
|
||
rentmeister = get_object_or_404(Rentmeister, pk=pk)
|
||
|
||
# Handle PDF export request
|
||
if request.method == "POST" and "export_pdf" in request.POST:
|
||
selected_ids = request.POST.getlist("selected_expenses")
|
||
if selected_ids:
|
||
# Update status to 'in_bearbeitung' and log each change
|
||
from stiftung.audit import log_action
|
||
|
||
expenses_to_update = Verwaltungskosten.objects.filter(
|
||
id__in=selected_ids, rentmeister=rentmeister
|
||
)
|
||
|
||
updated_count = 0
|
||
for expense in expenses_to_update:
|
||
old_status = expense.status
|
||
expense.status = "in_bearbeitung"
|
||
expense.save()
|
||
updated_count += 1
|
||
|
||
# Log the status change
|
||
log_action(
|
||
request=request,
|
||
action="update",
|
||
entity_type="verwaltungskosten",
|
||
entity_id=str(expense.pk),
|
||
entity_name=expense.bezeichnung,
|
||
description=f'Ausgaben-Status für PDF-Export geändert von "{old_status}" zu "in_bearbeitung"',
|
||
changes={"status": {"old": old_status, "new": "in_bearbeitung"}},
|
||
)
|
||
|
||
messages.success(
|
||
request,
|
||
f"{updated_count} Ausgaben wurden zur Bearbeitung markiert und sind bereit für PDF Export.",
|
||
)
|
||
return redirect(
|
||
"stiftung:rentmeister_ausgaben_pdf",
|
||
pk=pk,
|
||
expense_ids=",".join(selected_ids),
|
||
)
|
||
|
||
# Get expenses grouped by status
|
||
ausgaben_by_status = {}
|
||
for status_code, status_name in Verwaltungskosten.STATUS_CHOICES:
|
||
ausgaben_by_status[status_code] = {
|
||
"name": status_name,
|
||
"ausgaben": Verwaltungskosten.objects.filter(
|
||
rentmeister=rentmeister, status=status_code
|
||
).order_by("-datum", "-erstellt_am"),
|
||
"total": Verwaltungskosten.objects.filter(
|
||
rentmeister=rentmeister, status=status_code
|
||
).aggregate(total=Sum("betrag"))["total"]
|
||
or 0,
|
||
}
|
||
|
||
# Get statistics
|
||
stats = Verwaltungskosten.objects.filter(rentmeister=rentmeister).aggregate(
|
||
total_count=Count("id"),
|
||
total_amount=Sum("betrag"),
|
||
geplant_count=Count("id", filter=Q(status="geplant")),
|
||
geplant_amount=Sum("betrag", filter=Q(status="geplant")),
|
||
in_bearbeitung_count=Count("id", filter=Q(status="in_bearbeitung")),
|
||
in_bearbeitung_amount=Sum("betrag", filter=Q(status="in_bearbeitung")),
|
||
bezahlt_count=Count("id", filter=Q(status="bezahlt")),
|
||
bezahlt_amount=Sum("betrag", filter=Q(status="bezahlt")),
|
||
)
|
||
|
||
context = {
|
||
"rentmeister": rentmeister,
|
||
"ausgaben_by_status": ausgaben_by_status,
|
||
"stats": stats,
|
||
"kategorien": Verwaltungskosten.KATEGORIE_CHOICES,
|
||
"status_choices": Verwaltungskosten.STATUS_CHOICES,
|
||
}
|
||
|
||
return render(request, "stiftung/rentmeister_ausgaben.html", context)
|
||
|
||
|
||
@login_required
|
||
def rentmeister_create(request):
|
||
"""Erstelle einen neuen Rentmeister"""
|
||
from stiftung.forms import RentmeisterForm
|
||
|
||
if request.method == "POST":
|
||
form = RentmeisterForm(request.POST)
|
||
if form.is_valid():
|
||
rentmeister = form.save()
|
||
messages.success(
|
||
request,
|
||
f"Rentmeister {rentmeister.get_full_name()} wurde erfolgreich angelegt.",
|
||
)
|
||
return redirect("stiftung:rentmeister_detail", pk=rentmeister.pk)
|
||
else:
|
||
form = RentmeisterForm()
|
||
|
||
context = {
|
||
"form": form,
|
||
"title": "Neuen Rentmeister anlegen",
|
||
"submit_text": "Rentmeister anlegen",
|
||
}
|
||
|
||
return render(request, "stiftung/rentmeister_form.html", context)
|
||
|
||
|
||
@login_required
|
||
def rentmeister_edit(request, pk):
|
||
"""Bearbeite einen bestehenden Rentmeister"""
|
||
from stiftung.forms import RentmeisterForm
|
||
from stiftung.models import Rentmeister
|
||
|
||
rentmeister = get_object_or_404(Rentmeister, pk=pk)
|
||
|
||
if request.method == "POST":
|
||
form = RentmeisterForm(request.POST, instance=rentmeister)
|
||
if form.is_valid():
|
||
rentmeister = form.save()
|
||
messages.success(
|
||
request,
|
||
f"Rentmeister {rentmeister.get_full_name()} wurde erfolgreich aktualisiert.",
|
||
)
|
||
return redirect("stiftung:rentmeister_detail", pk=rentmeister.pk)
|
||
else:
|
||
form = RentmeisterForm(instance=rentmeister)
|
||
|
||
context = {
|
||
"form": form,
|
||
"rentmeister": rentmeister,
|
||
"title": f"{rentmeister.get_full_name()} bearbeiten",
|
||
"submit_text": "Änderungen speichern",
|
||
}
|
||
|
||
return render(request, "stiftung/rentmeister_form.html", context)
|
||
|
||
|
||
@login_required
|
||
def konto_create(request):
|
||
"""Erstelle ein neues Stiftungskonto"""
|
||
from stiftung.forms import StiftungsKontoForm
|
||
|
||
if request.method == "POST":
|
||
form = StiftungsKontoForm(request.POST)
|
||
if form.is_valid():
|
||
konto = form.save()
|
||
messages.success(
|
||
request, f"Konto {konto.kontoname} wurde erfolgreich angelegt."
|
||
)
|
||
return redirect("stiftung:konto_list")
|
||
else:
|
||
form = StiftungsKontoForm()
|
||
|
||
context = {
|
||
"form": form,
|
||
"title": "Neues Konto anlegen",
|
||
"submit_text": "Konto anlegen",
|
||
}
|
||
|
||
return render(request, "stiftung/konto_form.html", context)
|
||
|
||
|
||
@login_required
|
||
def konto_edit(request, pk):
|
||
"""Bearbeite ein bestehendes Stiftungskonto"""
|
||
from stiftung.forms import StiftungsKontoForm
|
||
from stiftung.models import StiftungsKonto
|
||
|
||
konto = get_object_or_404(StiftungsKonto, pk=pk)
|
||
|
||
if request.method == "POST":
|
||
form = StiftungsKontoForm(request.POST, instance=konto)
|
||
if form.is_valid():
|
||
konto = form.save()
|
||
messages.success(
|
||
request, f"Konto {konto.kontoname} wurde erfolgreich aktualisiert."
|
||
)
|
||
return redirect("stiftung:konto_list")
|
||
else:
|
||
form = StiftungsKontoForm(instance=konto)
|
||
|
||
context = {
|
||
"form": form,
|
||
"konto": konto,
|
||
"title": f"Konto {konto.kontoname} bearbeiten",
|
||
"submit_text": "Änderungen speichern",
|
||
}
|
||
|
||
return render(request, "stiftung/konto_form.html", context)
|
||
|
||
|
||
@login_required
|
||
def konto_detail(request, pk):
|
||
"""Zeige Details eines Stiftungskontos"""
|
||
from django.db import models
|
||
from django.db.models import Count, Max, Q, Sum
|
||
|
||
from stiftung.models import BankTransaction, StiftungsKonto
|
||
|
||
konto = get_object_or_404(StiftungsKonto, pk=pk)
|
||
|
||
# Get transaction statistics
|
||
transactions = BankTransaction.objects.filter(konto=konto)
|
||
transaction_stats = transactions.aggregate(
|
||
total_count=Count("id"),
|
||
total_eingang=Sum("betrag", filter=Q(betrag__gt=0)),
|
||
total_ausgang=Sum("betrag", filter=Q(betrag__lt=0)),
|
||
last_transaction_date=Max("datum"),
|
||
)
|
||
|
||
# Recent transactions
|
||
recent_transactions = transactions.order_by("-datum", "-importiert_am")[:10]
|
||
|
||
context = {
|
||
"konto": konto,
|
||
"transaction_stats": transaction_stats,
|
||
"recent_transactions": recent_transactions,
|
||
}
|
||
|
||
return render(request, "stiftung/konto_detail.html", context)
|
||
|
||
|
||
@login_required
|
||
def verwaltungskosten_create(request):
|
||
"""Erstelle neue Verwaltungskosten"""
|
||
from stiftung.forms import VerwaltungskostenForm
|
||
from stiftung.models import Rentmeister
|
||
|
||
# Check if we're coming from a specific Rentmeister
|
||
rentmeister_id = request.GET.get("rentmeister")
|
||
initial_data = {}
|
||
redirect_url = "stiftung:verwaltungskosten_list"
|
||
|
||
if rentmeister_id:
|
||
try:
|
||
rentmeister = Rentmeister.objects.get(pk=rentmeister_id)
|
||
initial_data["rentmeister"] = rentmeister
|
||
redirect_url = "stiftung:rentmeister_detail"
|
||
except Rentmeister.DoesNotExist:
|
||
pass
|
||
|
||
if request.method == "POST":
|
||
form = VerwaltungskostenForm(request.POST)
|
||
if form.is_valid():
|
||
kosten = form.save()
|
||
messages.success(
|
||
request,
|
||
f'Verwaltungskosten "{kosten.bezeichnung}" wurden erfolgreich angelegt.',
|
||
)
|
||
if rentmeister_id:
|
||
return redirect(redirect_url, pk=rentmeister_id)
|
||
return redirect("stiftung:verwaltungskosten_list")
|
||
else:
|
||
form = VerwaltungskostenForm(initial=initial_data)
|
||
|
||
context = {
|
||
"form": form,
|
||
"title": "Neue Verwaltungskosten anlegen",
|
||
"submit_text": "Kosten anlegen",
|
||
}
|
||
|
||
return render(request, "stiftung/verwaltungskosten_form.html", context)
|
||
|
||
|
||
@login_required
|
||
def verwaltungskosten_edit(request, pk):
|
||
"""Bearbeite bestehende Verwaltungskosten"""
|
||
from stiftung.forms import VerwaltungskostenForm
|
||
from stiftung.models import DokumentDatei, Verwaltungskosten
|
||
|
||
verwaltungskosten = get_object_or_404(Verwaltungskosten, pk=pk)
|
||
|
||
if request.method == "POST":
|
||
form = VerwaltungskostenForm(request.POST, instance=verwaltungskosten)
|
||
if form.is_valid():
|
||
verwaltungskosten = form.save()
|
||
messages.success(
|
||
request,
|
||
f'Verwaltungskosten "{verwaltungskosten.bezeichnung}" wurden erfolgreich aktualisiert.',
|
||
)
|
||
return redirect("stiftung:verwaltungskosten_detail", pk=pk)
|
||
else:
|
||
form = VerwaltungskostenForm(instance=verwaltungskosten)
|
||
|
||
# Verknüpfte DMS-Dokumente
|
||
dms_dokumente = DokumentDatei.objects.filter(verwaltungskosten=verwaltungskosten).order_by("erstellt_am")
|
||
|
||
context = {
|
||
"form": form,
|
||
"verwaltungskosten": verwaltungskosten,
|
||
"dms_dokumente": dms_dokumente,
|
||
"title": f"Verwaltungskosten bearbeiten: {verwaltungskosten.bezeichnung}",
|
||
"submit_text": "Änderungen speichern",
|
||
}
|
||
|
||
return render(request, "stiftung/verwaltungskosten_form.html", context)
|
||
|
||
|
||
@login_required
|
||
def verwaltungskosten_delete(request, pk):
|
||
"""Lösche Verwaltungskosten"""
|
||
from stiftung.models import Verwaltungskosten
|
||
|
||
verwaltungskosten = get_object_or_404(Verwaltungskosten, pk=pk)
|
||
|
||
if request.method == "POST":
|
||
bezeichnung = verwaltungskosten.bezeichnung
|
||
|
||
# Log the deletion
|
||
from stiftung.audit import log_action
|
||
log_action(
|
||
request=request,
|
||
action="delete",
|
||
entity_type="verwaltungskosten",
|
||
entity_id=str(verwaltungskosten.pk),
|
||
entity_name=bezeichnung,
|
||
description=f'Verwaltungskosten "{bezeichnung}" wurden gelöscht',
|
||
)
|
||
|
||
verwaltungskosten.delete()
|
||
messages.success(
|
||
request,
|
||
f'Verwaltungskosten "{bezeichnung}" wurden erfolgreich gelöscht.',
|
||
)
|
||
return redirect("stiftung:verwaltungskosten_list")
|
||
|
||
context = {
|
||
"verwaltungskosten": verwaltungskosten,
|
||
"title": f"Verwaltungskosten löschen: {verwaltungskosten.bezeichnung}",
|
||
}
|
||
|
||
return render(request, "stiftung/verwaltungskosten_delete.html", context)
|
||
|
||
|
||
@login_required
|
||
def mark_expense_paid(request):
|
||
"""Markiere eine Ausgabe als bezahlt"""
|
||
if request.method == "POST":
|
||
expense_id = request.POST.get("expense_id")
|
||
if expense_id:
|
||
try:
|
||
from stiftung.models import Verwaltungskosten
|
||
|
||
expense = Verwaltungskosten.objects.get(pk=expense_id)
|
||
old_status = expense.status
|
||
expense.status = "bezahlt"
|
||
expense.save()
|
||
|
||
# Log the status change
|
||
from stiftung.audit import log_action
|
||
|
||
log_action(
|
||
request=request,
|
||
action="update",
|
||
entity_type="verwaltungskosten",
|
||
entity_id=str(expense.pk),
|
||
entity_name=expense.bezeichnung,
|
||
description=f'Ausgaben-Status geändert von "{old_status}" zu "bezahlt"',
|
||
changes={"status": {"old": old_status, "new": "bezahlt"}},
|
||
)
|
||
|
||
messages.success(
|
||
request,
|
||
f'Ausgabe "{expense.bezeichnung}" wurde als bezahlt markiert.',
|
||
)
|
||
return redirect(
|
||
"stiftung:rentmeister_ausgaben", pk=expense.rentmeister.pk
|
||
)
|
||
except Verwaltungskosten.DoesNotExist:
|
||
messages.error(request, "Ausgabe nicht gefunden.")
|
||
|
||
return redirect("stiftung:verwaltungskosten_list")
|
||
|
||
|
||
# =============================================================================
|
||
# ADMINISTRATION VIEWS
|
||
# =============================================================================
|
||
|
||
|