feat: initial implementation taller-wox.fitlabs.dev
Portal FastAPI + 5 endpoints REST para Bootcamp Agentic AI con watsonx Orchestrate (FactorIT). Single container, Coolify-ready. - Landing brandeado FIT con formulario de registro (honeypot anti-bot) - Tokens itsdangerous para descargas (24h expiry) - 5 endpoints API: historical/available procedures, member-insights, schedule, generate-report (Jinja2 + Plotly) - SQLite con upsert-on-email para leads + log de descargas - Admin endpoints (HTTP Basic): leads.json, leads.csv, stats - 23 tests pytest pasando - Dockerfile listo para Coolify con volúmenes persistentes (/app/leads.db, /app/app/data/reports_output, /app/material) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
129
app/reports_api.py
Normal file
129
app/reports_api.py
Normal file
@@ -0,0 +1,129 @@
|
||||
import json
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
|
||||
import pandas as pd
|
||||
import plotly.express as px
|
||||
import plotly.io as pio
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from jinja2 import Environment, FileSystemLoader, select_autoescape
|
||||
|
||||
from app.config import get_settings
|
||||
|
||||
router = APIRouter(prefix="/api/reports", tags=["reports"])
|
||||
|
||||
_DATA_DIR = Path(__file__).parent / "data"
|
||||
_TEMPLATES_DIR = Path(__file__).parent / "templates"
|
||||
|
||||
_env = Environment(
|
||||
loader=FileSystemLoader(str(_TEMPLATES_DIR)),
|
||||
autoescape=select_autoescape(["html"]),
|
||||
)
|
||||
|
||||
|
||||
CARE_REPORT_PRESET = [
|
||||
{"element_type": "header", "parameters": {"title": "Care Report"}},
|
||||
{
|
||||
"element_type": "overview",
|
||||
"parameters": {
|
||||
"prompt": "Summarize this email exchange in 3 bullet points: ",
|
||||
"text_file": "./data/combined_email.txt",
|
||||
"title": "Customer Overview",
|
||||
},
|
||||
},
|
||||
{"element_type": "claim_review_chart", "parameters": {}},
|
||||
{
|
||||
"element_type": "table",
|
||||
"parameters": {
|
||||
"csv_file": "./data/aetna_claim_review_summary.csv",
|
||||
"title": "Claim Review Summary",
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
def _resolve_path(relative: str) -> Path:
|
||||
cleaned = relative.lstrip("./")
|
||||
if cleaned.startswith("data/"):
|
||||
cleaned = cleaned[len("data/"):]
|
||||
return _DATA_DIR / cleaned
|
||||
|
||||
|
||||
def _render_header(params: dict) -> str:
|
||||
title = params.get("title", "")
|
||||
return f"<h2 style='text-align:center;'>{title}</h2>"
|
||||
|
||||
|
||||
def _render_overview(params: dict) -> str:
|
||||
title = params.get("title", "Overview")
|
||||
text_file = _resolve_path(params.get("text_file", ""))
|
||||
if not text_file.exists():
|
||||
raise HTTPException(status_code=400, detail=f"text_file not found: {params.get('text_file')}")
|
||||
text = text_file.read_text(encoding="utf-8")
|
||||
paragraphs = "".join(f"<p>{line}</p>" for line in text.splitlines() if line.strip())
|
||||
return f"<h2>{title}</h2><div style='background:#F5F7FA;padding:16px;border-radius:8px;'>{paragraphs}</div>"
|
||||
|
||||
|
||||
def _render_claim_chart() -> str:
|
||||
df = pd.read_csv(_DATA_DIR / "aetna_claim_review_summary.csv")
|
||||
fig = px.bar(
|
||||
df,
|
||||
x="CPT_Code",
|
||||
y=["Charged_Amount", "Allowed_Amount", "Patient_Responsibility"],
|
||||
barmode="group",
|
||||
title="Claim Review by CPT Code",
|
||||
color_discrete_sequence=["#0A1F44", "#1E4FA8", "#FF7A00"],
|
||||
)
|
||||
return pio.to_html(fig, full_html=False, include_plotlyjs="cdn")
|
||||
|
||||
|
||||
def _render_table(params: dict) -> str:
|
||||
title = params.get("title", "")
|
||||
csv_file = _resolve_path(params.get("csv_file", ""))
|
||||
if not csv_file.exists():
|
||||
raise HTTPException(status_code=400, detail=f"csv_file not found: {params.get('csv_file')}")
|
||||
df = pd.read_csv(csv_file)
|
||||
return f"<h2>{title}</h2>" + df.to_html(index=False, classes="report-table", border=0)
|
||||
|
||||
|
||||
_RENDERERS = {
|
||||
"header": lambda params: _render_header(params),
|
||||
"overview": lambda params: _render_overview(params),
|
||||
"claim_review_chart": lambda _params: _render_claim_chart(),
|
||||
"table": lambda params: _render_table(params),
|
||||
}
|
||||
|
||||
|
||||
@router.post("/generate-report")
|
||||
def generate_report(payload: dict):
|
||||
try:
|
||||
layout = json.loads(payload["layout_config"])
|
||||
except (KeyError, json.JSONDecodeError) as exc:
|
||||
raise HTTPException(status_code=400, detail=f"Invalid layout_config: {exc}")
|
||||
|
||||
expanded: list[dict] = []
|
||||
for item in layout:
|
||||
if item == "care_report":
|
||||
expanded.extend(CARE_REPORT_PRESET)
|
||||
elif isinstance(item, dict):
|
||||
expanded.append(item)
|
||||
else:
|
||||
raise HTTPException(status_code=400, detail=f"Unknown layout item: {item!r}")
|
||||
|
||||
parts: list[str] = []
|
||||
for el in expanded:
|
||||
kind = el.get("element_type")
|
||||
if kind not in _RENDERERS:
|
||||
raise HTTPException(status_code=400, detail=f"Unknown element_type: {kind}")
|
||||
parts.append(_RENDERERS[kind](el.get("parameters", {})))
|
||||
|
||||
template = _env.get_template("report.html")
|
||||
html = template.render(content="\n".join(parts))
|
||||
|
||||
settings = get_settings()
|
||||
output_dir = Path(settings.reports_output_dir)
|
||||
output_dir.mkdir(parents=True, exist_ok=True)
|
||||
report_id = f"{uuid.uuid4().hex[:12]}.html"
|
||||
(output_dir / report_id).write_text(html, encoding="utf-8")
|
||||
|
||||
return {"public_url": f"{settings.base_url}/api/reports/output/{report_id}"}
|
||||
Reference in New Issue
Block a user