- Introduced new API endpoints for managing report definitions, including listing, creating, and updating reports. - Updated the frontend to include a dedicated section for configuring reports, enhancing user navigation and experience. - Modified existing components to link to the new report settings, ensuring seamless access to report functionalities. - Improved the report catalog API to support multiple definitions per profile and added validation for report limits. - Updated documentation and tests to reflect the new features and ensure proper functionality.
325 lines
11 KiB
Python
325 lines
11 KiB
Python
"""
|
||
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}"'},
|
||
)
|