- Introduced the `viz_bundle` block type to the report profile schema, allowing for the inclusion of bundled visualizations in PDF reports. - Updated the `build_structured_report_pdf` function to handle `VizBundleBlock` and append its content to the report. - Enhanced the report catalog API to include details for the new `viz_bundle` block type. - Added configuration editors for various visualization bundles in the frontend settings page. - Updated tests to validate the new `viz_bundle` functionality and ensure proper handling of report profiles. - Bumped application version to reflect these enhancements.
131 lines
4.9 KiB
Python
131 lines
4.9 KiB
Python
"""
|
||
PDF-Bericht aus ReportProfilePayload: ReportLab für Text/Layout, Matplotlib für Chart-Payloads.
|
||
"""
|
||
from __future__ import annotations
|
||
|
||
import io
|
||
import logging
|
||
from typing import Any
|
||
from xml.sax.saxutils import escape
|
||
|
||
from reportlab.lib.pagesizes import A4
|
||
from reportlab.lib.styles import getSampleStyleSheet
|
||
from reportlab.lib.units import mm
|
||
from reportlab.platypus import Image as RLImage
|
||
from reportlab.platypus import Paragraph, SimpleDocTemplate, Spacer
|
||
|
||
from db import get_cursor, get_db
|
||
from report_chart_fetch import fetch_chart_payload
|
||
from report_chart_plotting import chart_payload_to_png
|
||
from report_profile_schema import (
|
||
AiInsightBlock,
|
||
ChartBlock,
|
||
ReportProfilePayload,
|
||
SectionBlock,
|
||
VizBundleBlock,
|
||
)
|
||
from report_viz_bundle_pdf import append_viz_bundle_to_story
|
||
|
||
logger = logging.getLogger(__name__)
|
||
|
||
_CONTENT_TRUNCATE = 12000
|
||
|
||
|
||
def _insight_text(profile_id: str, insight_id: str | None) -> tuple[str, str]:
|
||
"""Returns (heading, body_text)."""
|
||
if not insight_id:
|
||
return (
|
||
"KI-Auswertung",
|
||
"(Noch keine Auswahl — in einer späteren Version kannst du hier eine gespeicherte KI-Analyse "
|
||
"verknüpfen.)",
|
||
)
|
||
try:
|
||
with get_db() as conn:
|
||
cur = get_cursor(conn)
|
||
cur.execute(
|
||
"SELECT scope, content, created FROM ai_insights WHERE id = %s AND profile_id = %s",
|
||
(insight_id, profile_id),
|
||
)
|
||
row = cur.fetchone()
|
||
if not row:
|
||
return ("KI-Auswertung", "Eintrag nicht gefunden oder keine Berechtigung.")
|
||
scope = row.get("scope") or "Analyse"
|
||
content = row.get("content") or ""
|
||
if len(content) > _CONTENT_TRUNCATE:
|
||
content = content[:_CONTENT_TRUNCATE] + "\n\n[… gekürzt …]"
|
||
created = row.get("created")
|
||
sub = f"{scope}" + (f" · {created}" if created else "")
|
||
return (sub, content)
|
||
except Exception as e:
|
||
logger.warning("report pdf insight load failed: %s", e)
|
||
return ("KI-Auswertung", "Fehler beim Laden des Eintrags.")
|
||
|
||
|
||
def build_structured_report_pdf(
|
||
*,
|
||
profile_id: str,
|
||
profile_name: str,
|
||
payload: ReportProfilePayload,
|
||
) -> bytes:
|
||
"""Vollständiges PDF als Bytes (A4)."""
|
||
buf = io.BytesIO()
|
||
doc = SimpleDocTemplate(
|
||
buf,
|
||
pagesize=A4,
|
||
leftMargin=14 * mm,
|
||
rightMargin=14 * mm,
|
||
topMargin=16 * mm,
|
||
bottomMargin=16 * mm,
|
||
)
|
||
styles = getSampleStyleSheet()
|
||
story: list[Any] = []
|
||
|
||
title = (payload.document_title or "").strip() or f"{profile_name} – Bericht"
|
||
story.append(Paragraph(escape(title), styles["Title"]))
|
||
story.append(Spacer(1, 6 * mm))
|
||
|
||
for block in payload.blocks:
|
||
if isinstance(block, SectionBlock):
|
||
story.append(Spacer(1, 4 * mm))
|
||
story.append(Paragraph(escape(block.title), styles["Heading2"]))
|
||
story.append(Spacer(1, 2 * mm))
|
||
elif isinstance(block, VizBundleBlock):
|
||
append_viz_bundle_to_story(story, styles, profile_id, block.bundle_id, block.config)
|
||
elif isinstance(block, ChartBlock):
|
||
try:
|
||
chart = fetch_chart_payload(block.chart_id, profile_id, block.window_days)
|
||
except Exception as e:
|
||
logger.warning("chart fetch %s: %s", block.chart_id, e)
|
||
story.append(Paragraph(f"Diagramm {block.chart_id}: Fehler bei Daten.", styles["Normal"]))
|
||
continue
|
||
meta = chart.get("metadata") or {}
|
||
if meta.get("confidence") == "insufficient":
|
||
msg = meta.get("message") or "Nicht genug Daten"
|
||
story.append(Paragraph(f"<i>{block.chart_id}</i>: {msg}", styles["Normal"]))
|
||
story.append(Spacer(1, 3 * mm))
|
||
continue
|
||
try:
|
||
png = chart_payload_to_png(chart)
|
||
img_buf = io.BytesIO(png)
|
||
iw = 170 * mm
|
||
ih = 85 * mm
|
||
story.append(RLImage(img_buf, width=iw, height=ih))
|
||
except Exception as e:
|
||
logger.warning("chart render %s: %s", block.chart_id, e)
|
||
story.append(Paragraph(f"Diagramm {block.chart_id}: Darstellung fehlgeschlagen.", styles["Normal"]))
|
||
story.append(Spacer(1, 4 * mm))
|
||
elif isinstance(block, AiInsightBlock):
|
||
heading, body = _insight_text(profile_id, block.insight_id)
|
||
if block.title.strip():
|
||
story.append(Paragraph(escape(block.title), styles["Heading3"]))
|
||
else:
|
||
story.append(Paragraph(escape(heading), styles["Heading3"]))
|
||
for para in body.split("\n\n"):
|
||
p = (para or "").strip()
|
||
if p:
|
||
story.append(Paragraph(escape(p), styles["BodyText"]))
|
||
story.append(Spacer(1, 4 * mm))
|
||
|
||
doc.build(story)
|
||
return buf.getvalue()
|