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:
2026-05-13 03:01:44 +00:00
commit a062b45c51
57 changed files with 8035 additions and 0 deletions

129
app/reports_api.py Normal file
View 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}"}