Implement modular report system with 6 report types and composer UI
Some checks failed
CI/CD Pipeline / test (push) Has been cancelled
CI/CD Pipeline / deploy (push) Has been cancelled
Code Quality / quality (push) Has been cancelled

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>
This commit is contained in:
SysAdmin Agent
2026-03-14 20:55:31 +00:00
parent 042114b1e7
commit faeb7c1073
15 changed files with 1081 additions and 439 deletions

View File

@@ -59,7 +59,7 @@ from stiftung.forms import (
@login_required
def bericht_list(request):
"""List available reports"""
"""List available reports with modular report builder"""
# Get available years from data
jahre = sorted(
set(
@@ -69,7 +69,7 @@ def bericht_list(request):
reverse=True,
)
# Statistics for overview tiles (removed legacy Person and Verpachtung)
# Statistics for overview tiles
total_destinataere = Destinataer.objects.count()
total_laendereien = Land.objects.count()
total_verpachtungen = LandVerpachtung.objects.count()
@@ -82,10 +82,25 @@ def bericht_list(request):
"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 (
@@ -138,7 +153,7 @@ def _jahresbericht_context(jahr):
total_ausgaben = total_ausgaben_foerderung + total_verwaltungskosten
netto = total_einnahmen - total_ausgaben
return {
context = {
"jahr": jahr,
"title": f"Jahresbericht {jahr}",
"foerderungen": foerderungen,
@@ -157,9 +172,14 @@ def _jahresbericht_context(jahr):
"total_einnahmen": total_einnahmen,
"total_ausgaben": total_ausgaben,
"netto": netto,
# Rückwärtskompatibilität
"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
@@ -181,24 +201,276 @@ def jahresbericht_generate_redirect(request):
@login_required
def jahresbericht_pdf(request, jahr):
"""Phase 4: PDF-Export des Jahresberichts."""
from django.http import HttpResponse
"""Phase 4: PDF-Export des Jahresberichts via PDFGenerator."""
from django.template.loader import render_to_string
from weasyprint import HTML
from stiftung.utils.pdf_generator import pdf_generator
context = _jahresbericht_context(jahr)
# Render HTML
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")
)
# Generate PDF
pdf = HTML(string=html_string).write_pdf()
# Create response
response = HttpResponse(pdf, content_type="application/pdf")
response["Content-Disposition"] = f'attachment; filename="jahresbericht_{jahr}.pdf"'
# =============================================================================
# MODULARE BERICHTE Berichts-Baukasten
# =============================================================================
return response
# 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