mitai-jinkendo/backend/routers/reports.py
Lars 3ab5dae130
All checks were successful
Deploy Development / deploy (push) Successful in 1m1s
Build Test / pytest-backend (push) Successful in 5s
Build Test / lint-backend (push) Successful in 0s
Build Test / build-frontend (push) Successful in 20s
feat: add viz_bundle support to report generation and enhance schema
- 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.
2026-04-29 11:46:34 +02:00

167 lines
5.9 KiB
Python

"""
Strukturierter PDF-Bericht (Profil v1): GET/PUT Profil, Katalog, PDF-Erzeugung.
Trennung vom Dashboard-Layout; Daten aus data_layer wie /api/charts.
PDF-Zähler: data_export (wie andere Exporte).
"""
from __future__ import annotations
import logging
from datetime import datetime
from fastapi import APIRouter, Depends, HTTPException
from fastapi.responses import Response
from psycopg2.extras import Json
from auth import check_feature_access, increment_feature_usage, require_auth
from db import get_cursor, get_db
from feature_logger import log_feature_usage
from report_chart_fetch import CHART_CATALOG_FOR_API
from report_pdf_render import build_structured_report_pdf
from report_profile_schema import (
ALLOWED_VIZ_BUNDLE_IDS,
ReportProfilePayload,
default_report_profile_dict,
parse_report_profile,
)
router = APIRouter(prefix="/api/reports", tags=["reports"])
logger = logging.getLogger(__name__)
@router.get("/catalog")
def get_reports_catalog(session: dict = Depends(require_auth)):
"""Metadaten für UI: verfügbare Diagramme und Blocktypen."""
viz_titles = {
"body_history_viz": "Körper (Verlauf-Bundle)",
"nutrition_history_viz": "Ernährung (Verlauf-Bundle)",
"fitness_history_viz": "Fitness (Verlauf-Bundle)",
"recovery_history_viz": "Erholung (Verlauf-Bundle)",
"history_overview_viz": "Gesamtübersicht (Korrelationen)",
}
return {
"catalog_version": 2,
"charts": CHART_CATALOG_FOR_API,
"viz_bundles": [{"id": bid, "title": viz_titles.get(bid, bid)} for bid in sorted(ALLOWED_VIZ_BUNDLE_IDS)],
"block_types": [
{"id": "section", "title": "Überschrift"},
{"id": "viz_bundle", "title": "Verlauf-Bundle (KPIs & Charts)"},
{"id": "chart", "title": "Einzel-Diagramm (Legacy)"},
{"id": "ai_insight", "title": "KI-Auswertung"},
],
}
def _fetch_payload_row(profile_id: str) -> dict | None:
with get_db() as conn:
cur = get_cursor(conn)
cur.execute("SELECT payload FROM report_profiles WHERE profile_id = %s", (profile_id,))
row = cur.fetchone()
if not row:
return None
p = row.get("payload")
return p if isinstance(p, dict) else None
def _profile_display_name(profile_id: str) -> str:
with get_db() as conn:
cur = get_cursor(conn)
cur.execute("SELECT name FROM profiles WHERE id = %s", (profile_id,))
row = cur.fetchone()
if not row:
return "Profil"
return (row.get("name") or "Profil").strip() or "Profil"
@router.get("/profile")
def get_report_profile(session: dict = Depends(require_auth)):
pid = session["profile_id"]
raw = _fetch_payload_row(pid)
if raw is None:
return {"stored": False, "profile": default_report_profile_dict()}
try:
parse_report_profile(raw)
except Exception as e:
logger.warning("report profile invalid for %s: %s", pid, e)
return {"stored": False, "profile": default_report_profile_dict(), "previous_invalid": True}
return {"stored": True, "profile": raw}
@router.put("/profile")
def put_report_profile(body: dict, session: dict = Depends(require_auth)):
pid = session["profile_id"]
try:
parsed = ReportProfilePayload.model_validate(body)
except Exception as e:
raise HTTPException(status_code=400, detail=str(e))
with get_db() as conn:
cur = get_cursor(conn)
cur.execute(
"""
INSERT INTO report_profiles (profile_id, payload, updated_at)
VALUES (%s, %s, CURRENT_TIMESTAMP)
ON CONFLICT (profile_id) DO UPDATE SET
payload = EXCLUDED.payload,
updated_at = CURRENT_TIMESTAMP
""",
(pid, Json(parsed.to_stored_dict())),
)
conn.commit()
return {"ok": True, "profile": parsed.to_stored_dict()}
@router.delete("/profile")
def delete_report_profile(session: dict = Depends(require_auth)):
"""Zurück auf Code-Standard (kein DB-Eintrag)."""
pid = session["profile_id"]
with get_db() as conn:
cur = get_cursor(conn)
cur.execute("DELETE FROM report_profiles WHERE profile_id = %s", (pid,))
conn.commit()
return {"ok": True, "profile": default_report_profile_dict()}
@router.post("/generate-pdf")
def generate_structured_report_pdf(session: dict = Depends(require_auth)):
pid = session["profile_id"]
access = check_feature_access(pid, "data_export")
log_feature_usage(pid, "data_export", access, "report_generate_pdf")
if not access["allowed"]:
logger.warning(
"[FEATURE-LIMIT] report pdf blocked: %s used=%s limit=%s",
pid,
access.get("used"),
access.get("limit"),
)
raise HTTPException(
status_code=403,
detail=(
"Limit erreicht: Daten-Export nicht möglich "
f"({access.get('used')}/{access.get('limit')})."
),
)
raw = _fetch_payload_row(pid)
try:
payload = parse_report_profile(raw)
except Exception as e:
raise HTTPException(status_code=400, detail=f"Berichtsprofil ungültig: {e}")
name = _profile_display_name(pid)
try:
pdf_bytes = build_structured_report_pdf(profile_id=pid, profile_name=name, payload=payload)
except Exception as e:
logger.exception("report pdf build failed")
raise HTTPException(status_code=500, detail=f"PDF-Erzeugung fehlgeschlagen: {e}")
increment_feature_usage(pid, "data_export")
safe_name = "".join(c for c in name if c.isalnum() or c in (" ", "-", "_")).strip() or "profil"
fn = f"mitai-bericht-{safe_name.replace(' ', '-')}-{datetime.now().strftime('%Y-%m-%d')}.pdf"
return Response(
content=pdf_bytes,
media_type="application/pdf",
headers={"Content-Disposition": f'attachment; filename="{fn}"'},
)