""" 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}"'}, )