Implement modular report system with 6 report types and composer UI
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:
@@ -29,6 +29,10 @@ from .finanzen import ( # noqa: F401
|
||||
jahresbericht_generate,
|
||||
jahresbericht_generate_redirect,
|
||||
jahresbericht_pdf,
|
||||
bericht_zusammenstellen,
|
||||
bericht_vorlage,
|
||||
BERICHT_SEKTIONEN,
|
||||
BERICHT_VORLAGEN,
|
||||
geschaeftsfuehrung,
|
||||
konto_list,
|
||||
verwaltungskosten_list,
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user