mitai-jinkendo/backend/routers/reports.py
Lars ed2b457da3
All checks were successful
Deploy Development / deploy (push) Successful in 1m5s
Build Test / pytest-backend (push) Successful in 4s
Build Test / lint-backend (push) Successful in 0s
Build Test / build-frontend (push) Successful in 20s
feat: enhance report management and PDF generation capabilities
- 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.
2026-04-29 12:11:26 +02:00

325 lines
11 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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