""" Strukturierter PDF-Bericht: mehrere Definitionen pro Profil, Katalog, PDF-Erzeugung. PDF-Zähler: data_export (wie andere Exporte). """ from __future__ import annotations import logging from datetime import datetime from uuid import UUID from fastapi import APIRouter, Body, Depends, HTTPException from fastapi.responses import Response from pydantic import BaseModel, Field 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__) _MAX_REPORT_DEFINITIONS = 20 class CreateReportDefinitionBody(BaseModel): name: str = Field(default="Neuer Bericht", min_length=1, max_length=120) class UpdateReportDefinitionBody(BaseModel): name: str | None = Field(default=None, min_length=1, max_length=120) payload: dict | None = None class GeneratePdfRequest(BaseModel): definition_id: UUID | None = 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" def _row_to_definition(row: dict) -> dict: pl = row.get("payload") if not isinstance(pl, dict): pl = {} return { "id": str(row["id"]), "name": (row.get("name") or "Bericht").strip() or "Bericht", "sort_order": int(row.get("sort_order") or 0), "updated_at": row.get("updated_at").isoformat() if row.get("updated_at") else None, "payload": pl, } @router.get("/catalog") def get_reports_catalog(session: dict = Depends(require_auth)): """Metadaten für UI: verfügbare Diagramme, Bundles, Zeitraumgrenzen.""" 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": 3, "chart_days": {"min": 7, "max": 90}, "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"}, ], } @router.get("/definitions") def list_report_definitions(session: dict = Depends(require_auth)): pid = session["profile_id"] with get_db() as conn: cur = get_cursor(conn) cur.execute( """ SELECT id, name, sort_order, updated_at, payload FROM report_definitions WHERE profile_id = %s ORDER BY sort_order ASC, name ASC, updated_at DESC """, (pid,), ) rows = cur.fetchall() return {"definitions": [_row_to_definition(dict(r)) for r in rows]} @router.post("/definitions") def create_report_definition( body: CreateReportDefinitionBody | None = Body(default=None), session: dict = Depends(require_auth), ): req = body or CreateReportDefinitionBody() pid = session["profile_id"] with get_db() as conn: cur = get_cursor(conn) cur.execute( "SELECT COUNT(*) AS n FROM report_definitions WHERE profile_id = %s", (pid,), ) n = int((cur.fetchone() or {}).get("n") or 0) if n >= _MAX_REPORT_DEFINITIONS: raise HTTPException( status_code=400, detail=f"Maximal {_MAX_REPORT_DEFINITIONS} Berichte erlaubt.", ) payload_dict = default_report_profile_dict() payload_dict["document_title"] = "" with get_db() as conn: cur = get_cursor(conn) cur.execute( "SELECT COALESCE(MAX(sort_order), -1) + 1 AS next_so FROM report_definitions WHERE profile_id = %s", (pid,), ) next_so = int((cur.fetchone() or {}).get("next_so") or 0) cur.execute( """ INSERT INTO report_definitions (profile_id, name, payload, sort_order, updated_at) VALUES (%s, %s, %s, %s, CURRENT_TIMESTAMP) RETURNING id, name, sort_order, updated_at, payload """, (pid, req.name.strip(), Json(payload_dict), next_so), ) row = cur.fetchone() conn.commit() if not row: raise HTTPException(status_code=500, detail="Bericht konnte nicht angelegt werden.") return {"definition": _row_to_definition(dict(row))} @router.put("/definitions/{definition_id}") def update_report_definition( definition_id: UUID, body: UpdateReportDefinitionBody, session: dict = Depends(require_auth), ): pid = session["profile_id"] name = body.name.strip() if body.name else None parsed_payload: ReportProfilePayload | None = None if body.payload is not None: try: parsed_payload = ReportProfilePayload.model_validate(body.payload) except Exception as e: raise HTTPException(status_code=400, detail=str(e)) with get_db() as conn: cur = get_cursor(conn) if parsed_payload is not None and name is not None: cur.execute( """ UPDATE report_definitions SET name = %s, payload = %s, updated_at = CURRENT_TIMESTAMP WHERE id = %s AND profile_id = %s RETURNING id, name, sort_order, updated_at, payload """, (name, Json(parsed_payload.to_stored_dict()), str(definition_id), pid), ) elif parsed_payload is not None: cur.execute( """ UPDATE report_definitions SET payload = %s, updated_at = CURRENT_TIMESTAMP WHERE id = %s AND profile_id = %s RETURNING id, name, sort_order, updated_at, payload """, (Json(parsed_payload.to_stored_dict()), str(definition_id), pid), ) elif name is not None: cur.execute( """ UPDATE report_definitions SET name = %s, updated_at = CURRENT_TIMESTAMP WHERE id = %s AND profile_id = %s RETURNING id, name, sort_order, updated_at, payload """, (name, str(definition_id), pid), ) else: raise HTTPException(status_code=400, detail="Nichts zu aktualisieren (name oder payload fehlt).") row = cur.fetchone() conn.commit() if not row: raise HTTPException(status_code=404, detail="Bericht nicht gefunden.") return {"definition": _row_to_definition(dict(row))} @router.delete("/definitions/{definition_id}") def delete_report_definition(definition_id: UUID, session: dict = Depends(require_auth)): pid = session["profile_id"] with get_db() as conn: cur = get_cursor(conn) cur.execute( "DELETE FROM report_definitions WHERE id = %s AND profile_id = %s RETURNING id", (str(definition_id), pid), ) deleted = cur.fetchone() conn.commit() if not deleted: raise HTTPException(status_code=404, detail="Bericht nicht gefunden.") return {"ok": True} def _fetch_definition_payload(profile_id: str, definition_id: UUID | None) -> tuple[dict, str]: """Returns (raw_payload_dict, report_label_for_filename).""" with get_db() as conn: cur = get_cursor(conn) if definition_id is not None: cur.execute( """ SELECT payload, name FROM report_definitions WHERE id = %s AND profile_id = %s """, (str(definition_id), profile_id), ) row = cur.fetchone() if not row: raise HTTPException(status_code=404, detail="Bericht nicht gefunden.") pl = row.get("payload") label = (row.get("name") or "Bericht").strip() or "Bericht" if not isinstance(pl, dict): raise HTTPException(status_code=400, detail="Ungültige Berichtsdaten.") return pl, label cur.execute( """ SELECT payload, name FROM report_definitions WHERE profile_id = %s ORDER BY sort_order ASC, name ASC, updated_at DESC LIMIT 1 """, (profile_id,), ) row = cur.fetchone() if not row: raise HTTPException( status_code=400, detail="Kein Bericht angelegt — bitte unter Einstellungen › PDF-Berichte einen Bericht erstellen.", ) pl = row.get("payload") label = (row.get("name") or "Bericht").strip() or "Bericht" if not isinstance(pl, dict): raise HTTPException(status_code=400, detail="Ungültige Berichtsdaten.") return pl, label @router.post("/generate-pdf") def generate_structured_report_pdf( body: GeneratePdfRequest | None = Body(default=None), session: dict = Depends(require_auth), ): pid = session["profile_id"] req = body or GeneratePdfRequest() 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, report_label = _fetch_definition_payload(pid, req.definition_id) try: payload = parse_report_profile(raw) except Exception as e: raise HTTPException(status_code=400, detail=f"Berichtsprofil ungültig: {e}") profile_name = _profile_display_name(pid) try: pdf_bytes = build_structured_report_pdf(profile_id=pid, profile_name=profile_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") doc_title = (payload.document_title or "").strip() base_label = doc_title or report_label safe_name = "".join(c for c in base_label if c.isalnum() or c in (" ", "-", "_")).strip() or "bericht" 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}"'}, )