- Introduced the `report_export` widget to the dashboard, allowing users to generate structured PDF reports. - Updated widget configuration to include `report_export` in the allowed widgets and added validation for its configuration. - Enhanced the widget catalog with details for the new `report_export` entry. - Implemented API endpoints for managing report profiles and generating PDFs. - Added frontend components for configuring and displaying report settings. - Updated tests to ensure proper validation and functionality of the new report generation features. - Bumped application version to reflect the addition of the new widget and related functionalities.
157 lines
5.3 KiB
Python
157 lines
5.3 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 (
|
|
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."""
|
|
return {
|
|
"catalog_version": 1,
|
|
"charts": CHART_CATALOG_FOR_API,
|
|
"block_types": [
|
|
{"id": "section", "title": "Überschrift"},
|
|
{"id": "chart", "title": "Diagramm"},
|
|
{"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}"'},
|
|
)
|