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>
2911 lines
100 KiB
Markdown
2911 lines
100 KiB
Markdown
# Portal taller-wox.fitlabs.dev — Implementation Plan
|
||
|
||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||
|
||
**Goal:** Build a FastAPI service that serves a branded FactorIT landing page + lead-capture registration + gated download of 2 ZIPs, plus 5 REST endpoints consumed live by IBM watsonx Orchestrate agents during the workshop on 2026-05-14.
|
||
|
||
**Architecture:** Single FastAPI service in one Docker container deployed on Coolify. SQLite for leads, file-based CSVs/JSON loaded in memory at boot, Jinja2 + Plotly for the report endpoint, `itsdangerous` for download tokens. HTTPS via Let's Encrypt (auto from Coolify). Three persistent volumes: `leads.db`, `app/data/reports_output/`, `material/`.
|
||
|
||
**Tech Stack:** Python 3.11 · FastAPI · Uvicorn · SQLite · pandas · Jinja2 · Plotly · itsdangerous · pydantic-settings · pytest · Docker
|
||
|
||
**Source of truth for design decisions:** `docs/superpowers/specs/2026-05-12-taller-wox-design.md`. When the original SPEC (`SPEC_taller_wox_fitlabs.md`) and this design disagree, the design wins.
|
||
|
||
---
|
||
|
||
## File Structure
|
||
|
||
Files this plan creates (locked decomposition):
|
||
|
||
```
|
||
app/
|
||
├── __init__.py # empty, marks package
|
||
├── main.py # FastAPI app, CORS, static mount, router includes, startup
|
||
├── config.py # Settings via pydantic-settings, env vars
|
||
├── db.py # SQLite init + schema + helpers (upsert_lead, log_download, list_leads, stats)
|
||
├── security.py # token sign/verify (itsdangerous), basic auth dep, honeypot check
|
||
├── frontend.py # router: GET /, GET /descargas, POST /register, GET /download/{filename}
|
||
├── benefits_api.py # router: endpoints 1-4 (historical, available, member-insights, schedule)
|
||
├── reports_api.py # router: endpoint 5 (generate-report) + the StaticFiles mount for /api/reports/output
|
||
├── admin.py # router: /admin/leads.json, /admin/leads.csv, /admin/stats
|
||
├── data/
|
||
│ ├── historical_procedures.csv # ~50 rows, synthetic
|
||
│ ├── available_procedures.csv # ~28 rows, synthetic
|
||
│ ├── member_insights.json # static, dates re-calibrated to 2026
|
||
│ ├── schedule_response.txt # static text
|
||
│ ├── combined_email.txt # for endpoint 5
|
||
│ ├── provider_email.txt # for endpoint 5
|
||
│ ├── aetna_email.txt # for endpoint 5
|
||
│ ├── aetna_claim_review_summary.csv # for endpoint 5, ~10 rows
|
||
│ └── reports_output/ # generated HTMLs land here (persistent volume in prod)
|
||
│ └── .gitkeep
|
||
└── templates/
|
||
├── base.html # shared layout: header, footer, fonts, palette
|
||
├── index.html # landing + inline registration form
|
||
├── descargas.html # post-registration with 2 download cards
|
||
└── report.html # wrapper for endpoint 5 generated reports
|
||
|
||
static/
|
||
├── css/styles.css # FIT palette, hero, cards, form, responsive
|
||
├── img/
|
||
│ ├── LogoFIT.png # existing, compressed copy goes here
|
||
│ └── favicon.ico # placeholder generated from LogoFIT
|
||
└── js/app.js # honeypot bot detection (light), smooth scroll, form UX
|
||
|
||
tests/
|
||
├── __init__.py
|
||
├── conftest.py # pytest fixtures: TestClient, temp SQLite, env vars
|
||
├── test_db.py
|
||
├── test_security.py
|
||
├── test_frontend.py
|
||
├── test_benefits_api.py
|
||
├── test_reports_api.py
|
||
└── test_admin.py
|
||
|
||
material/ # empty in repo (gitignored); Felipe uploads zips to volume in prod
|
||
└── .gitkeep
|
||
|
||
Dockerfile
|
||
requirements.txt
|
||
.env.example
|
||
README.md
|
||
```
|
||
|
||
**Responsibility per file:**
|
||
- `config.py` owns env-var loading. No other module reads `os.environ` directly.
|
||
- `db.py` owns the SQLite connection lifecycle and schema. No SQL strings outside this file.
|
||
- `security.py` owns token serialization and the basic-auth dependency. No router does its own crypto.
|
||
- Each router file (`frontend.py`, `benefits_api.py`, `reports_api.py`, `admin.py`) only knows its own routes and depends on `db`, `security`, `config` as needed.
|
||
- `main.py` is the only place that wires routers, middleware, and static mounts. Touching `main.py` should be rare after Task 2.
|
||
|
||
---
|
||
|
||
## Task ordering rationale
|
||
|
||
Tasks 1-4 are foundation (project skeleton, config, DB, security). Tasks 5-9 are the workshop API (the critical path for Thursday — must work end-to-end first). Tasks 10-13 are the frontend (visible but lower risk; you can ship the API without it for last-minute use). Task 14 is admin (lowest priority). Task 15 is Dockerization + deploy notes.
|
||
|
||
If time runs out, the order above is also the priority order to abandon from the bottom up.
|
||
|
||
---
|
||
|
||
## Task 1: Project bootstrap
|
||
|
||
**Files:**
|
||
- Create: `requirements.txt`
|
||
- Create: `.env.example`
|
||
- Create: `app/__init__.py` (empty)
|
||
- Create: `app/main.py`
|
||
- Create: `tests/__init__.py` (empty)
|
||
- Create: `tests/conftest.py`
|
||
- Create: `tests/test_smoke.py`
|
||
|
||
- [ ] **Step 1: Write `requirements.txt`**
|
||
|
||
```text
|
||
fastapi==0.115.0
|
||
uvicorn[standard]==0.32.0
|
||
pydantic==2.9.2
|
||
pydantic-settings==2.6.1
|
||
python-multipart==0.0.12
|
||
itsdangerous==2.2.0
|
||
jinja2==3.1.4
|
||
pandas==2.2.3
|
||
plotly==5.24.1
|
||
pytest==8.3.3
|
||
httpx==0.27.2
|
||
```
|
||
|
||
`httpx` is needed for FastAPI's `TestClient`. `python-multipart` for form parsing. Pinned versions for reproducibility.
|
||
|
||
- [ ] **Step 2: Write `.env.example`**
|
||
|
||
```bash
|
||
# Secret for signing download tokens. Use `python -c "import secrets; print(secrets.token_urlsafe(48))"` to generate
|
||
SECRET_KEY=replace-me-with-a-long-random-string
|
||
|
||
# HTTP Basic auth for /admin/*
|
||
ADMIN_USER=admin
|
||
ADMIN_PASS=replace-me
|
||
|
||
# Download token expiration in hours
|
||
TOKEN_EXPIRY_HOURS=24
|
||
|
||
# Public base URL (used in report URLs and elsewhere)
|
||
BASE_URL=https://taller-wox.fitlabs.dev
|
||
|
||
# Path where Felipe uploads the 2 zips (mounted volume in Coolify, ./material in dev)
|
||
MATERIAL_DIR=./material
|
||
|
||
# Path where SQLite file lives (mounted file in Coolify, ./leads.db in dev)
|
||
DB_PATH=./leads.db
|
||
|
||
# Where generated reports are written (mounted dir in Coolify)
|
||
REPORTS_OUTPUT_DIR=./app/data/reports_output
|
||
```
|
||
|
||
- [ ] **Step 3: Write `app/main.py` (minimal)**
|
||
|
||
```python
|
||
from fastapi import FastAPI
|
||
from fastapi.middleware.cors import CORSMiddleware
|
||
|
||
app = FastAPI(title="taller-wox.fitlabs.dev", version="1.0.0")
|
||
|
||
app.add_middleware(
|
||
CORSMiddleware,
|
||
allow_origins=["*"],
|
||
allow_credentials=False,
|
||
allow_methods=["GET", "POST"],
|
||
allow_headers=["*"],
|
||
)
|
||
|
||
|
||
@app.get("/health")
|
||
def health():
|
||
return {"status": "ok"}
|
||
```
|
||
|
||
- [ ] **Step 4: Write `tests/conftest.py`**
|
||
|
||
```python
|
||
import os
|
||
import tempfile
|
||
from pathlib import Path
|
||
|
||
import pytest
|
||
|
||
# Set test env BEFORE importing the app
|
||
os.environ["SECRET_KEY"] = "test-secret-key-only-for-tests-not-secure"
|
||
os.environ["ADMIN_USER"] = "testadmin"
|
||
os.environ["ADMIN_PASS"] = "testpass"
|
||
os.environ["TOKEN_EXPIRY_HOURS"] = "24"
|
||
os.environ["BASE_URL"] = "http://testserver"
|
||
|
||
_tmp = Path(tempfile.mkdtemp(prefix="taller-wox-test-"))
|
||
os.environ["DB_PATH"] = str(_tmp / "test_leads.db")
|
||
os.environ["MATERIAL_DIR"] = str(_tmp / "material")
|
||
os.environ["REPORTS_OUTPUT_DIR"] = str(_tmp / "reports_output")
|
||
(_tmp / "material").mkdir(parents=True, exist_ok=True)
|
||
(_tmp / "reports_output").mkdir(parents=True, exist_ok=True)
|
||
|
||
|
||
@pytest.fixture
|
||
def client():
|
||
from fastapi.testclient import TestClient
|
||
from app.main import app
|
||
return TestClient(app)
|
||
```
|
||
|
||
- [ ] **Step 5: Write `tests/test_smoke.py`**
|
||
|
||
```python
|
||
def test_health(client):
|
||
response = client.get("/health")
|
||
assert response.status_code == 200
|
||
assert response.json() == {"status": "ok"}
|
||
```
|
||
|
||
- [ ] **Step 6: Install deps and run the test**
|
||
|
||
```bash
|
||
python3.11 -m venv .venv
|
||
source .venv/bin/activate
|
||
pip install -r requirements.txt
|
||
pytest tests/test_smoke.py -v
|
||
```
|
||
|
||
Expected: `1 passed`.
|
||
|
||
- [ ] **Step 7: Verify dev server starts**
|
||
|
||
```bash
|
||
uvicorn app.main:app --reload --port 8000
|
||
```
|
||
|
||
In another terminal:
|
||
```bash
|
||
curl http://localhost:8000/health
|
||
```
|
||
|
||
Expected output: `{"status":"ok"}`. Stop the server with Ctrl+C.
|
||
|
||
- [ ] **Step 8: Commit**
|
||
|
||
```bash
|
||
git add requirements.txt .env.example app/ tests/
|
||
git commit -m "feat(bootstrap): minimal FastAPI app with health endpoint and test harness"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 2: Settings via pydantic-settings
|
||
|
||
**Files:**
|
||
- Create: `app/config.py`
|
||
- Modify: `app/main.py`
|
||
- Create: `tests/test_config.py`
|
||
|
||
- [ ] **Step 1: Write the failing test**
|
||
|
||
```python
|
||
# tests/test_config.py
|
||
from app.config import get_settings
|
||
|
||
|
||
def test_settings_loaded_from_env():
|
||
settings = get_settings()
|
||
assert settings.secret_key == "test-secret-key-only-for-tests-not-secure"
|
||
assert settings.admin_user == "testadmin"
|
||
assert settings.admin_pass == "testpass"
|
||
assert settings.token_expiry_hours == 24
|
||
assert settings.base_url == "http://testserver"
|
||
assert settings.db_path.endswith("test_leads.db")
|
||
assert settings.material_dir.endswith("material")
|
||
assert settings.reports_output_dir.endswith("reports_output")
|
||
```
|
||
|
||
- [ ] **Step 2: Run test to verify it fails**
|
||
|
||
```bash
|
||
pytest tests/test_config.py -v
|
||
```
|
||
|
||
Expected: `ModuleNotFoundError: No module named 'app.config'`.
|
||
|
||
- [ ] **Step 3: Implement `app/config.py`**
|
||
|
||
```python
|
||
from functools import lru_cache
|
||
|
||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||
|
||
|
||
class Settings(BaseSettings):
|
||
secret_key: str
|
||
admin_user: str
|
||
admin_pass: str
|
||
token_expiry_hours: int = 24
|
||
base_url: str = "https://taller-wox.fitlabs.dev"
|
||
db_path: str = "./leads.db"
|
||
material_dir: str = "./material"
|
||
reports_output_dir: str = "./app/data/reports_output"
|
||
|
||
model_config = SettingsConfigDict(env_file=".env", case_sensitive=False)
|
||
|
||
|
||
@lru_cache
|
||
def get_settings() -> Settings:
|
||
return Settings()
|
||
```
|
||
|
||
- [ ] **Step 4: Run test to verify it passes**
|
||
|
||
```bash
|
||
pytest tests/test_config.py -v
|
||
```
|
||
|
||
Expected: `1 passed`.
|
||
|
||
- [ ] **Step 5: Wire settings into `app/main.py`**
|
||
|
||
Replace `app/main.py` with:
|
||
|
||
```python
|
||
from fastapi import FastAPI
|
||
from fastapi.middleware.cors import CORSMiddleware
|
||
|
||
from app.config import get_settings
|
||
|
||
settings = get_settings()
|
||
|
||
app = FastAPI(title="taller-wox.fitlabs.dev", version="1.0.0")
|
||
|
||
app.add_middleware(
|
||
CORSMiddleware,
|
||
allow_origins=["*"],
|
||
allow_credentials=False,
|
||
allow_methods=["GET", "POST"],
|
||
allow_headers=["*"],
|
||
)
|
||
|
||
|
||
@app.get("/health")
|
||
def health():
|
||
return {"status": "ok", "base_url": settings.base_url}
|
||
```
|
||
|
||
- [ ] **Step 6: Re-run smoke test**
|
||
|
||
```bash
|
||
pytest -v
|
||
```
|
||
|
||
Expected: `2 passed`.
|
||
|
||
- [ ] **Step 7: Commit**
|
||
|
||
```bash
|
||
git add app/config.py app/main.py tests/test_config.py
|
||
git commit -m "feat(config): typed settings via pydantic-settings"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 3: SQLite layer (db.py)
|
||
|
||
**Files:**
|
||
- Create: `app/db.py`
|
||
- Create: `tests/test_db.py`
|
||
|
||
- [ ] **Step 1: Write failing tests**
|
||
|
||
```python
|
||
# tests/test_db.py
|
||
from datetime import datetime
|
||
|
||
import pytest
|
||
|
||
from app.db import (
|
||
init_db,
|
||
upsert_lead,
|
||
get_lead_by_email,
|
||
log_download,
|
||
list_leads,
|
||
stats,
|
||
)
|
||
|
||
|
||
@pytest.fixture(autouse=True)
|
||
def fresh_db(tmp_path, monkeypatch):
|
||
"""Each test gets its own DB file so they don't bleed into each other."""
|
||
db_file = tmp_path / "test.db"
|
||
monkeypatch.setenv("DB_PATH", str(db_file))
|
||
from app import config
|
||
config.get_settings.cache_clear()
|
||
init_db()
|
||
yield
|
||
config.get_settings.cache_clear()
|
||
|
||
|
||
def test_upsert_new_lead_creates_row():
|
||
lead_id = upsert_lead(
|
||
nombre="Felipe",
|
||
email="felipe@factorit.com",
|
||
empresa="FactorIT",
|
||
ip="1.2.3.4",
|
||
user_agent="pytest",
|
||
consent=True,
|
||
)
|
||
assert lead_id > 0
|
||
lead = get_lead_by_email("felipe@factorit.com")
|
||
assert lead["nombre"] == "Felipe"
|
||
assert lead["empresa"] == "FactorIT"
|
||
assert lead["times_registered"] == 1
|
||
|
||
|
||
def test_upsert_duplicate_email_increments_times_registered():
|
||
upsert_lead(
|
||
nombre="Felipe",
|
||
email="felipe@factorit.com",
|
||
empresa="FactorIT",
|
||
ip="1.2.3.4",
|
||
user_agent="pytest",
|
||
consent=True,
|
||
)
|
||
upsert_lead(
|
||
nombre="Felipe Arentsen",
|
||
email="felipe@factorit.com",
|
||
empresa="FactorIT Chile",
|
||
ip="5.6.7.8",
|
||
user_agent="pytest-2",
|
||
consent=True,
|
||
)
|
||
lead = get_lead_by_email("felipe@factorit.com")
|
||
assert lead["times_registered"] == 2
|
||
# latest data wins
|
||
assert lead["nombre"] == "Felipe Arentsen"
|
||
assert lead["empresa"] == "FactorIT Chile"
|
||
|
||
|
||
def test_log_download_records_event():
|
||
log_download(
|
||
lead_email="felipe@factorit.com",
|
||
filename="taller-wox-tecnico.zip",
|
||
ip="1.2.3.4",
|
||
)
|
||
s = stats()
|
||
assert s["total_downloads"] == 1
|
||
assert s["downloads_por_archivo"]["taller-wox-tecnico.zip"] == 1
|
||
|
||
|
||
def test_list_leads_paginates():
|
||
for i in range(5):
|
||
upsert_lead(
|
||
nombre=f"User {i}",
|
||
email=f"user{i}@test.com",
|
||
empresa=f"Co {i}",
|
||
ip="1.2.3.4",
|
||
user_agent="pytest",
|
||
consent=True,
|
||
)
|
||
page = list_leads(limit=2, offset=0)
|
||
assert len(page) == 2
|
||
page2 = list_leads(limit=2, offset=2)
|
||
assert len(page2) == 2
|
||
assert page[0]["email"] != page2[0]["email"]
|
||
|
||
|
||
def test_stats_top_empresas():
|
||
for empresa in ["ACME", "ACME", "ACME", "Globex", "Globex", "Initech"]:
|
||
upsert_lead(
|
||
nombre="X",
|
||
email=f"{empresa}-{datetime.utcnow().timestamp()}@x.com",
|
||
empresa=empresa,
|
||
ip="1.2.3.4",
|
||
user_agent="pytest",
|
||
consent=True,
|
||
)
|
||
s = stats()
|
||
assert s["total_leads"] == 6
|
||
assert s["top_5_empresas"][0] == {"empresa": "ACME", "count": 3}
|
||
```
|
||
|
||
- [ ] **Step 2: Run tests to verify they fail**
|
||
|
||
```bash
|
||
pytest tests/test_db.py -v
|
||
```
|
||
|
||
Expected: `ModuleNotFoundError` or `ImportError` on `app.db`.
|
||
|
||
- [ ] **Step 3: Implement `app/db.py`**
|
||
|
||
```python
|
||
import sqlite3
|
||
from contextlib import contextmanager
|
||
from pathlib import Path
|
||
from typing import Iterator
|
||
|
||
from app.config import get_settings
|
||
|
||
SCHEMA = """
|
||
CREATE TABLE IF NOT EXISTS leads (
|
||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||
nombre TEXT NOT NULL,
|
||
email TEXT NOT NULL UNIQUE,
|
||
empresa TEXT NOT NULL,
|
||
ip TEXT,
|
||
user_agent TEXT,
|
||
consent INTEGER NOT NULL DEFAULT 0,
|
||
times_registered INTEGER NOT NULL DEFAULT 1,
|
||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||
last_seen TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||
);
|
||
|
||
CREATE TABLE IF NOT EXISTS downloads (
|
||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||
lead_email TEXT NOT NULL,
|
||
filename TEXT NOT NULL,
|
||
ip TEXT,
|
||
downloaded_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||
);
|
||
|
||
CREATE INDEX IF NOT EXISTS idx_downloads_email ON downloads(lead_email);
|
||
CREATE INDEX IF NOT EXISTS idx_downloads_filename ON downloads(filename);
|
||
"""
|
||
|
||
|
||
@contextmanager
|
||
def _conn() -> Iterator[sqlite3.Connection]:
|
||
settings = get_settings()
|
||
Path(settings.db_path).parent.mkdir(parents=True, exist_ok=True)
|
||
conn = sqlite3.connect(settings.db_path)
|
||
conn.row_factory = sqlite3.Row
|
||
try:
|
||
yield conn
|
||
conn.commit()
|
||
finally:
|
||
conn.close()
|
||
|
||
|
||
def init_db() -> None:
|
||
with _conn() as conn:
|
||
conn.executescript(SCHEMA)
|
||
|
||
|
||
def upsert_lead(
|
||
nombre: str,
|
||
email: str,
|
||
empresa: str,
|
||
ip: str | None,
|
||
user_agent: str | None,
|
||
consent: bool,
|
||
) -> int:
|
||
"""Insert a new lead, or update the existing row if email already exists.
|
||
|
||
Returns the lead's id. Increments times_registered on duplicates.
|
||
"""
|
||
with _conn() as conn:
|
||
cur = conn.execute(
|
||
"""
|
||
INSERT INTO leads (nombre, email, empresa, ip, user_agent, consent)
|
||
VALUES (?, ?, ?, ?, ?, ?)
|
||
ON CONFLICT(email) DO UPDATE SET
|
||
nombre = excluded.nombre,
|
||
empresa = excluded.empresa,
|
||
ip = excluded.ip,
|
||
user_agent = excluded.user_agent,
|
||
consent = excluded.consent,
|
||
times_registered = times_registered + 1,
|
||
last_seen = CURRENT_TIMESTAMP
|
||
RETURNING id
|
||
""",
|
||
(nombre, email, empresa, ip, user_agent, 1 if consent else 0),
|
||
)
|
||
return cur.fetchone()["id"]
|
||
|
||
|
||
def get_lead_by_email(email: str) -> dict | None:
|
||
with _conn() as conn:
|
||
row = conn.execute(
|
||
"SELECT * FROM leads WHERE email = ?", (email,)
|
||
).fetchone()
|
||
return dict(row) if row else None
|
||
|
||
|
||
def log_download(lead_email: str, filename: str, ip: str | None) -> None:
|
||
with _conn() as conn:
|
||
conn.execute(
|
||
"INSERT INTO downloads (lead_email, filename, ip) VALUES (?, ?, ?)",
|
||
(lead_email, filename, ip),
|
||
)
|
||
|
||
|
||
def list_leads(limit: int = 100, offset: int = 0) -> list[dict]:
|
||
with _conn() as conn:
|
||
rows = conn.execute(
|
||
"SELECT * FROM leads ORDER BY id ASC LIMIT ? OFFSET ?",
|
||
(limit, offset),
|
||
).fetchall()
|
||
return [dict(r) for r in rows]
|
||
|
||
|
||
def stats() -> dict:
|
||
with _conn() as conn:
|
||
total_leads = conn.execute("SELECT COUNT(*) AS c FROM leads").fetchone()["c"]
|
||
total_downloads = conn.execute("SELECT COUNT(*) AS c FROM downloads").fetchone()["c"]
|
||
per_file = {
|
||
r["filename"]: r["c"]
|
||
for r in conn.execute(
|
||
"SELECT filename, COUNT(*) AS c FROM downloads GROUP BY filename"
|
||
).fetchall()
|
||
}
|
||
top_empresas = [
|
||
{"empresa": r["empresa"], "count": r["c"]}
|
||
for r in conn.execute(
|
||
"SELECT empresa, COUNT(*) AS c FROM leads GROUP BY empresa ORDER BY c DESC LIMIT 5"
|
||
).fetchall()
|
||
]
|
||
return {
|
||
"total_leads": total_leads,
|
||
"total_downloads": total_downloads,
|
||
"downloads_por_archivo": per_file,
|
||
"top_5_empresas": top_empresas,
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 4: Run tests to verify they pass**
|
||
|
||
```bash
|
||
pytest tests/test_db.py -v
|
||
```
|
||
|
||
Expected: `5 passed`.
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
git add app/db.py tests/test_db.py
|
||
git commit -m "feat(db): sqlite schema + lead/download helpers with upsert-on-email"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 4: Security (tokens, basic auth, honeypot)
|
||
|
||
**Files:**
|
||
- Create: `app/security.py`
|
||
- Create: `tests/test_security.py`
|
||
|
||
- [ ] **Step 1: Write failing tests**
|
||
|
||
```python
|
||
# tests/test_security.py
|
||
import time
|
||
|
||
import pytest
|
||
from itsdangerous import BadSignature, SignatureExpired
|
||
|
||
from app.security import (
|
||
create_download_token,
|
||
verify_download_token,
|
||
is_honeypot_filled,
|
||
)
|
||
|
||
|
||
def test_token_roundtrip():
|
||
token = create_download_token(email="x@y.com", nombre="X")
|
||
data = verify_download_token(token)
|
||
assert data["email"] == "x@y.com"
|
||
assert data["nombre"] == "X"
|
||
|
||
|
||
def test_token_tampering_raises():
|
||
token = create_download_token(email="x@y.com", nombre="X")
|
||
tampered = token[:-3] + "AAA"
|
||
with pytest.raises(BadSignature):
|
||
verify_download_token(tampered)
|
||
|
||
|
||
def test_token_expiry(monkeypatch):
|
||
monkeypatch.setenv("TOKEN_EXPIRY_HOURS", "0") # expire immediately
|
||
from app import config
|
||
config.get_settings.cache_clear()
|
||
token = create_download_token(email="x@y.com", nombre="X")
|
||
time.sleep(1.1)
|
||
with pytest.raises(SignatureExpired):
|
||
verify_download_token(token)
|
||
config.get_settings.cache_clear()
|
||
|
||
|
||
def test_honeypot_empty_returns_false():
|
||
assert is_honeypot_filled("") is False
|
||
assert is_honeypot_filled(None) is False
|
||
|
||
|
||
def test_honeypot_filled_returns_true():
|
||
assert is_honeypot_filled("https://spam.com") is True
|
||
assert is_honeypot_filled("anything") is True
|
||
```
|
||
|
||
- [ ] **Step 2: Run tests to verify they fail**
|
||
|
||
```bash
|
||
pytest tests/test_security.py -v
|
||
```
|
||
|
||
Expected: `ImportError` on `app.security`.
|
||
|
||
- [ ] **Step 3: Implement `app/security.py`**
|
||
|
||
```python
|
||
from fastapi import Depends, HTTPException, status
|
||
from fastapi.security import HTTPBasic, HTTPBasicCredentials
|
||
from itsdangerous import URLSafeTimedSerializer
|
||
import secrets
|
||
|
||
from app.config import get_settings
|
||
|
||
|
||
_basic = HTTPBasic()
|
||
|
||
|
||
def _serializer() -> URLSafeTimedSerializer:
|
||
return URLSafeTimedSerializer(get_settings().secret_key, salt="download")
|
||
|
||
|
||
def create_download_token(email: str, nombre: str) -> str:
|
||
return _serializer().dumps({"email": email, "nombre": nombre})
|
||
|
||
|
||
def verify_download_token(token: str) -> dict:
|
||
"""Returns {'email': ..., 'nombre': ...}. Raises SignatureExpired or BadSignature."""
|
||
settings = get_settings()
|
||
max_age_seconds = max(1, settings.token_expiry_hours * 3600)
|
||
return _serializer().loads(token, max_age=max_age_seconds)
|
||
|
||
|
||
def is_honeypot_filled(value: str | None) -> bool:
|
||
return bool(value)
|
||
|
||
|
||
def require_admin(credentials: HTTPBasicCredentials = Depends(_basic)) -> str:
|
||
"""FastAPI dependency: enforces HTTP Basic auth against env-configured admin creds."""
|
||
settings = get_settings()
|
||
user_ok = secrets.compare_digest(credentials.username, settings.admin_user)
|
||
pass_ok = secrets.compare_digest(credentials.password, settings.admin_pass)
|
||
if not (user_ok and pass_ok):
|
||
raise HTTPException(
|
||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||
detail="Invalid credentials",
|
||
headers={"WWW-Authenticate": "Basic"},
|
||
)
|
||
return credentials.username
|
||
```
|
||
|
||
- [ ] **Step 4: Run tests to verify they pass**
|
||
|
||
```bash
|
||
pytest tests/test_security.py -v
|
||
```
|
||
|
||
Expected: `5 passed`.
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
git add app/security.py tests/test_security.py
|
||
git commit -m "feat(security): signed download tokens, basic-auth dep, honeypot check"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 5: Static datasets for the workshop API
|
||
|
||
This task creates the data files the 5 endpoints read at boot. Coherence between files matters (see design doc §6).
|
||
|
||
**Files:**
|
||
- Create: `app/data/member_insights.json`
|
||
- Create: `app/data/schedule_response.txt`
|
||
- Create: `app/data/combined_email.txt`
|
||
- Create: `app/data/provider_email.txt`
|
||
- Create: `app/data/aetna_email.txt`
|
||
- Create: `app/data/aetna_claim_review_summary.csv`
|
||
- Create: `app/data/historical_procedures.csv`
|
||
- Create: `app/data/available_procedures.csv`
|
||
- Create: `app/data/reports_output/.gitkeep` (empty)
|
||
|
||
- [ ] **Step 1: Write `app/data/member_insights.json`**
|
||
|
||
Dates re-calibrated for today (2026-05-12):
|
||
|
||
```json
|
||
{
|
||
"result": {
|
||
"member": {
|
||
"name": "Charlie Smith",
|
||
"date_of_birth": "2013-03-04",
|
||
"plan": "Gold PPO",
|
||
"member_id": "CS-001-2024"
|
||
},
|
||
"medical_plan": {
|
||
"name": "Gold PPO",
|
||
"deductible": 1500,
|
||
"deductible_met": 850,
|
||
"out_of_pocket_max": 6000,
|
||
"out_of_pocket_met": 1200,
|
||
"coinsurance": "20%",
|
||
"primary_care_copay": 25,
|
||
"specialist_copay": 50,
|
||
"emergency_room_copay": 250
|
||
},
|
||
"pharmacy_plan": {
|
||
"tier_1_copay": 10,
|
||
"tier_2_copay": 30,
|
||
"tier_3_copay": 60,
|
||
"tier_4_coinsurance": "30%",
|
||
"mail_order_available": true
|
||
},
|
||
"mental_health": {
|
||
"covered_visits_per_year": 20,
|
||
"visits_used": 4,
|
||
"telehealth": true,
|
||
"in_network_providers": "https://taller-wox.fitlabs.dev/docs/mental-health-providers"
|
||
},
|
||
"wellness": {
|
||
"gym_reimbursement_annual": 300,
|
||
"gym_reimbursement_used": 150,
|
||
"annual_checkup_covered": true,
|
||
"preventive_care_100_percent": true,
|
||
"flu_shot_covered": true
|
||
},
|
||
"tax_documents": {
|
||
"form_1095_available": true,
|
||
"form_1095_url": "https://taller-wox.fitlabs.dev/docs/1095-2025.pdf",
|
||
"instructions": "Tu formulario 1095 está disponible en el portal del afiliado bajo Documentos > Impuestos. Si no lo puedes acceder, llama al 1-800-FIT-CARE."
|
||
},
|
||
"overdue_procedures": [
|
||
{
|
||
"procedure": "Annual Physical Exam",
|
||
"last_date": "2024-05-15",
|
||
"recommended_frequency_months": 12,
|
||
"due_since_months": 12,
|
||
"priority": "high"
|
||
},
|
||
{
|
||
"procedure": "Dental Cleaning",
|
||
"last_date": "2025-01-10",
|
||
"recommended_frequency_months": 6,
|
||
"due_since_months": 10,
|
||
"priority": "medium"
|
||
},
|
||
{
|
||
"procedure": "Vision Exam",
|
||
"last_date": "2023-08-22",
|
||
"recommended_frequency_months": 24,
|
||
"due_since_months": 8,
|
||
"priority": "medium"
|
||
},
|
||
{
|
||
"procedure": "Blood Test - Cholesterol Panel",
|
||
"last_date": "2024-11-04",
|
||
"recommended_frequency_months": 12,
|
||
"due_since_months": 6,
|
||
"priority": "low"
|
||
}
|
||
]
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Write `app/data/schedule_response.txt`**
|
||
|
||
Single line (no JSON wrapper — endpoint adds that):
|
||
|
||
```text
|
||
Para agendar una cita médica, sigue estos pasos: 1) Confirma con el afiliado el día y hora preferidos, y el tipo de procedimiento. 2) Verifica que el procedimiento esté cubierto por su plan (Gold PPO en este caso). 3) Llama al sistema de agendamiento de FIT Care al 1-800-FIT-CARE o entra al portal en https://taller-wox.fitlabs.dev/agenda. 4) Indica el procedimiento, el proveedor preferido (City Hospital, Green Valley Clinic, Sunrise Health o Regional Medical Center) y la fecha. 5) Confirma la cita y registra el número de confirmación. 6) Envía recordatorios automáticos al afiliado 24h y 1h antes. Recuerda: si el procedimiento requiere autorización previa, gestiónala antes de confirmar la cita.
|
||
```
|
||
|
||
- [ ] **Step 3: Write `app/data/combined_email.txt`**
|
||
|
||
```text
|
||
From: dr.martinez@cityhospital.com
|
||
Subject: Follow-up after appointment
|
||
|
||
Hi Charlie,
|
||
|
||
Following our visit last week, I'm recommending a follow-up CT scan
|
||
to confirm the diagnosis. Please schedule within the next 2 weeks.
|
||
|
||
Best,
|
||
Dr. Martinez
|
||
|
||
---
|
||
|
||
From: charlie.smith@gmail.com
|
||
Subject: Re: Follow-up
|
||
|
||
Thanks Dr. Martinez. Will the CT scan be covered by my Gold PPO plan?
|
||
Also, can I get a copy of the lab results from last visit?
|
||
|
||
Charlie
|
||
```
|
||
|
||
- [ ] **Step 4: Write `app/data/provider_email.txt`**
|
||
|
||
```text
|
||
Patient presented with bilateral lower quadrant tenderness, WBC 14k,
|
||
CRP elevated. Differential includes appendicitis vs diverticulitis.
|
||
Recommending CT abdomen/pelvis with contrast STAT.
|
||
CPT 74177 ordered. Pre-auth obtained: AUTH-2024-8821.
|
||
Patient stable, NPO since midnight. Will reassess in 2h.
|
||
```
|
||
|
||
- [ ] **Step 5: Write `app/data/aetna_email.txt`**
|
||
|
||
```text
|
||
Claim #AET-2024-9912 has been processed.
|
||
Service date: 2024-04-15
|
||
Provider: City Hospital
|
||
Total billed: $1,847.50
|
||
Plan allowance: $1,200.00
|
||
Plan paid: $960.00
|
||
Patient responsibility (20% coinsurance after deductible): $240.00
|
||
EOB available at member portal.
|
||
```
|
||
|
||
- [ ] **Step 6: Write `app/data/aetna_claim_review_summary.csv`**
|
||
|
||
```csv
|
||
Date,CPT_Code,Description,Charged_Amount,Allowed_Amount,Plan_Paid,Patient_Responsibility
|
||
2024-04-15,74177,CT Abdomen/Pelvis w/contrast,1847.50,1200.00,960.00,240.00
|
||
2024-03-22,99213,Office visit established patient,185.00,120.00,96.00,24.00
|
||
2024-02-10,80061,Lipid panel,82.00,55.00,44.00,11.00
|
||
2024-01-18,99395,Preventive visit adult,310.00,250.00,250.00,0.00
|
||
2023-12-05,90686,Influenza vaccine,55.00,40.00,40.00,0.00
|
||
2023-11-12,87880,Strep A direct test,42.00,28.00,22.40,5.60
|
||
2023-10-08,71046,Chest X-ray 2 views,210.00,140.00,112.00,28.00
|
||
2023-09-22,93000,Electrocardiogram complete,138.00,90.00,72.00,18.00
|
||
2023-08-14,99214,Office visit established mod complexity,250.00,170.00,136.00,34.00
|
||
2023-07-03,36415,Venipuncture,18.00,12.00,9.60,2.40
|
||
```
|
||
|
||
- [ ] **Step 7: Write `app/data/historical_procedures.csv`**
|
||
|
||
50 rows. Charlie Smith's rows MUST line up with `member_insights.json`'s `overdue_procedures` (matching `last_date`).
|
||
|
||
Columns (exact order): `member_name,relationship,age,gender,procedure,procedure_type,location,date,in_network,member_plan,accepted_plans,cost_facility,cost_physician,cost_anesthesia,cost_medication,total_cost,facility_rating,notes`
|
||
|
||
Write this exact CSV (Charlie Smith rows first 4 — coherent with `member_insights.json`; rest are filler):
|
||
|
||
```csv
|
||
member_name,relationship,age,gender,procedure,procedure_type,location,date,in_network,member_plan,accepted_plans,cost_facility,cost_physician,cost_anesthesia,cost_medication,total_cost,facility_rating,notes
|
||
Charlie Smith,Self,13,Male,Annual Physical Exam,preventive,City Hospital,2024-05-15,true,Gold PPO,"Gold PPO, Medicare Advantage",80.00,180.00,0.00,5.00,265.00,4.7,"Annual Physical Exam performed at City Hospital."
|
||
Charlie Smith,Self,13,Male,Dental Cleaning,preventive,Green Valley Clinic,2025-01-10,true,Gold PPO,"Gold PPO, Family Plan - Silver EPO",60.00,120.00,0.00,0.00,180.00,4.5,"Dental Cleaning performed at Green Valley Clinic."
|
||
Charlie Smith,Self,13,Male,Vision Exam,preventive,Sunrise Health,2023-08-22,true,Gold PPO,"Gold PPO, Bronze HDHP",40.00,110.00,0.00,0.00,150.00,4.3,"Vision Exam performed at Sunrise Health."
|
||
Charlie Smith,Self,13,Male,Blood Test,diagnostic,City Hospital,2024-11-04,true,Gold PPO,"Gold PPO, Medicare Advantage",25.00,55.00,0.00,2.50,82.50,4.7,"Cholesterol Panel performed at City Hospital."
|
||
Alice Smith,Spouse,42,Female,Annual Physical Exam,preventive,Regional Medical Center,2024-09-18,true,Gold PPO,"Gold PPO, Family Plan - Silver EPO",95.00,210.00,0.00,4.50,309.50,4.6,"Annual Physical Exam performed at Regional Medical Center."
|
||
Alice Smith,Spouse,42,Female,MRI,diagnostic,City Hospital,2023-11-02,true,Gold PPO,"Gold PPO, Medicare Advantage",1450.00,820.00,0.00,12.00,2282.00,4.7,"Lumbar MRI performed at City Hospital."
|
||
Alice Smith,Spouse,42,Female,Dental Cleaning,preventive,Green Valley Clinic,2024-08-12,true,Gold PPO,"Gold PPO, Family Plan - Silver EPO",65.00,125.00,0.00,0.00,190.00,4.5,"Dental Cleaning performed at Green Valley Clinic."
|
||
Bob Johnson,Self,55,Male,Appendectomy,surgery,Regional Medical Center,2024-02-04,true,Family Plan - Silver EPO,"Family Plan - Silver EPO, Gold PPO",8200.00,5400.00,2100.00,180.00,15880.00,4.6,"Laparoscopic appendectomy performed at Regional Medical Center."
|
||
Bob Johnson,Self,55,Male,Annual Physical Exam,preventive,Regional Medical Center,2024-10-22,false,Family Plan - Silver EPO,"Gold PPO",120.00,260.00,0.00,6.00,386.00,4.6,"Annual Physical Exam performed at Regional Medical Center."
|
||
Bob Johnson,Self,55,Male,CT Scan,diagnostic,City Hospital,2024-02-03,true,Family Plan - Silver EPO,"Gold PPO, Medicare Advantage",1600.00,750.00,0.00,8.00,2358.00,4.7,"Pre-op CT abdomen/pelvis at City Hospital."
|
||
Diana Roberts,Mother,67,Female,Annual Physical Exam,preventive,Sunrise Health,2024-07-09,true,Gold PPO,"Gold PPO, Bronze HDHP",95.00,220.00,0.00,5.00,320.00,4.3,"Annual Physical Exam performed at Sunrise Health."
|
||
Diana Roberts,Mother,67,Female,Vision Exam,preventive,Sunrise Health,2024-10-14,true,Gold PPO,"Gold PPO, Bronze HDHP",40.00,115.00,0.00,0.00,155.00,4.3,"Vision Exam performed at Sunrise Health."
|
||
Diana Roberts,Mother,67,Female,Blood Test,diagnostic,City Hospital,2025-02-18,true,Gold PPO,"Gold PPO, Medicare Advantage",28.00,60.00,0.00,3.00,91.00,4.7,"Cholesterol Panel performed at City Hospital."
|
||
Diana Roberts,Mother,67,Female,MRI,diagnostic,Regional Medical Center,2023-06-30,true,Gold PPO,"Gold PPO, Family Plan - Silver EPO",1520.00,840.00,0.00,10.00,2370.00,4.6,"Knee MRI performed at Regional Medical Center."
|
||
Ethan Smith,Son,11,Male,Annual Physical Exam,preventive,City Hospital,2025-04-02,true,Gold PPO,"Gold PPO, Medicare Advantage",70.00,170.00,0.00,4.00,244.00,4.7,"Annual Physical Exam performed at City Hospital."
|
||
Ethan Smith,Son,11,Male,Dental Cleaning,preventive,Green Valley Clinic,2025-03-15,true,Gold PPO,"Gold PPO, Family Plan - Silver EPO",55.00,115.00,0.00,0.00,170.00,4.5,"Dental Cleaning performed at Green Valley Clinic."
|
||
Ethan Smith,Son,11,Male,Vision Exam,preventive,Sunrise Health,2024-12-08,true,Gold PPO,"Gold PPO, Bronze HDHP",35.00,105.00,0.00,0.00,140.00,4.3,"Vision Exam performed at Sunrise Health."
|
||
Ethan Smith,Son,11,Male,X Ray,diagnostic,City Hospital,2024-09-21,true,Gold PPO,"Gold PPO, Medicare Advantage",105.00,135.00,0.00,2.00,242.00,4.7,"Wrist X Ray performed at City Hospital."
|
||
Alice Smith,Spouse,42,Female,Blood Test,diagnostic,City Hospital,2024-04-19,true,Gold PPO,"Gold PPO, Medicare Advantage",30.00,58.00,0.00,3.50,91.50,4.7,"Lipid panel at City Hospital."
|
||
Alice Smith,Spouse,42,Female,Vision Exam,preventive,Sunrise Health,2024-06-11,true,Gold PPO,"Gold PPO, Bronze HDHP",38.00,108.00,0.00,0.00,146.00,4.3,"Vision Exam performed at Sunrise Health."
|
||
Bob Johnson,Self,55,Male,Dental Cleaning,preventive,Green Valley Clinic,2024-12-03,true,Family Plan - Silver EPO,"Family Plan - Silver EPO, Gold PPO",60.00,120.00,0.00,0.00,180.00,4.5,"Dental Cleaning performed at Green Valley Clinic."
|
||
Bob Johnson,Self,55,Male,Blood Test,diagnostic,Regional Medical Center,2025-01-22,true,Family Plan - Silver EPO,"Family Plan - Silver EPO, Gold PPO",28.00,55.00,0.00,3.00,86.00,4.6,"A1C panel performed at Regional Medical Center."
|
||
Charlie Smith,Self,13,Male,X Ray,diagnostic,City Hospital,2025-03-28,true,Gold PPO,"Gold PPO, Medicare Advantage",110.00,140.00,0.00,2.00,252.00,4.7,"Ankle X Ray performed at City Hospital."
|
||
Frank Lee,Father,72,Male,CT Scan,diagnostic,Regional Medical Center,2024-08-29,false,Bronze HDHP,"Gold PPO, Medicare Advantage",1750.00,820.00,0.00,9.00,2579.00,4.6,"Head CT performed at Regional Medical Center."
|
||
Frank Lee,Father,72,Male,Annual Physical Exam,preventive,Sunrise Health,2024-11-19,true,Bronze HDHP,"Gold PPO, Bronze HDHP",95.00,225.00,0.00,5.50,325.50,4.3,"Annual Physical Exam performed at Sunrise Health."
|
||
Frank Lee,Father,72,Male,Vision Exam,preventive,Sunrise Health,2024-09-02,true,Bronze HDHP,"Gold PPO, Bronze HDHP",42.00,118.00,0.00,0.00,160.00,4.3,"Vision Exam performed at Sunrise Health."
|
||
Frank Lee,Father,72,Male,Blood Test,diagnostic,City Hospital,2025-02-25,true,Bronze HDHP,"Gold PPO, Medicare Advantage",30.00,58.00,0.00,4.00,92.00,4.7,"Comprehensive metabolic panel at City Hospital."
|
||
Grace Wong,Daughter,16,Female,Annual Physical Exam,preventive,Green Valley Clinic,2025-02-11,true,Gold PPO,"Gold PPO, Family Plan - Silver EPO",75.00,175.00,0.00,4.00,254.00,4.5,"Annual Physical Exam performed at Green Valley Clinic."
|
||
Grace Wong,Daughter,16,Female,Dental Cleaning,preventive,Green Valley Clinic,2024-11-30,true,Gold PPO,"Gold PPO, Family Plan - Silver EPO",58.00,118.00,0.00,0.00,176.00,4.5,"Dental Cleaning performed at Green Valley Clinic."
|
||
Grace Wong,Daughter,16,Female,Vision Exam,preventive,Sunrise Health,2025-01-26,true,Gold PPO,"Gold PPO, Bronze HDHP",36.00,106.00,0.00,0.00,142.00,4.3,"Vision Exam performed at Sunrise Health."
|
||
Henry Park,Self,38,Male,MRI,diagnostic,City Hospital,2024-07-15,true,Family Plan - Silver EPO,"Gold PPO, Family Plan - Silver EPO",1500.00,830.00,0.00,11.00,2341.00,4.7,"Shoulder MRI performed at City Hospital."
|
||
Henry Park,Self,38,Male,Annual Physical Exam,preventive,Regional Medical Center,2024-08-08,true,Family Plan - Silver EPO,"Gold PPO, Family Plan - Silver EPO",90.00,210.00,0.00,5.00,305.00,4.6,"Annual Physical Exam performed at Regional Medical Center."
|
||
Henry Park,Self,38,Male,X Ray,diagnostic,City Hospital,2024-07-10,true,Family Plan - Silver EPO,"Gold PPO, Medicare Advantage",120.00,145.00,0.00,2.50,267.50,4.7,"Shoulder X Ray performed at City Hospital."
|
||
Henry Park,Self,38,Male,Dental Cleaning,preventive,Green Valley Clinic,2024-10-04,true,Family Plan - Silver EPO,"Gold PPO, Family Plan - Silver EPO",62.00,122.00,0.00,0.00,184.00,4.5,"Dental Cleaning performed at Green Valley Clinic."
|
||
Isabel Cruz,Mother,69,Female,Appendectomy,surgery,City Hospital,2024-03-12,true,Gold PPO,"Gold PPO, Medicare Advantage",9100.00,5800.00,2200.00,195.00,17295.00,4.7,"Open appendectomy performed at City Hospital."
|
||
Isabel Cruz,Mother,69,Female,CT Scan,diagnostic,City Hospital,2024-03-11,true,Gold PPO,"Gold PPO, Medicare Advantage",1620.00,780.00,0.00,8.50,2408.50,4.7,"Pre-op CT abdomen/pelvis at City Hospital."
|
||
Isabel Cruz,Mother,69,Female,Annual Physical Exam,preventive,Sunrise Health,2024-06-22,true,Gold PPO,"Gold PPO, Bronze HDHP",95.00,225.00,0.00,5.50,325.50,4.3,"Annual Physical Exam performed at Sunrise Health."
|
||
Isabel Cruz,Mother,69,Female,Blood Test,diagnostic,City Hospital,2025-01-30,true,Gold PPO,"Gold PPO, Medicare Advantage",32.00,62.00,0.00,3.50,97.50,4.7,"Comprehensive metabolic panel at City Hospital."
|
||
Jack Miller,Self,29,Male,X Ray,diagnostic,Sunrise Health,2024-05-04,true,Bronze HDHP,"Gold PPO, Bronze HDHP",95.00,130.00,0.00,2.00,227.00,4.3,"Knee X Ray performed at Sunrise Health."
|
||
Jack Miller,Self,29,Male,Annual Physical Exam,preventive,Sunrise Health,2025-02-06,true,Bronze HDHP,"Gold PPO, Bronze HDHP",85.00,200.00,0.00,4.50,289.50,4.3,"Annual Physical Exam performed at Sunrise Health."
|
||
Jack Miller,Self,29,Male,Dental Cleaning,preventive,Green Valley Clinic,2024-10-17,true,Bronze HDHP,"Family Plan - Silver EPO",58.00,118.00,0.00,0.00,176.00,4.5,"Dental Cleaning performed at Green Valley Clinic."
|
||
Karen Davis,Spouse,46,Female,MRI,diagnostic,Regional Medical Center,2024-04-09,true,Gold PPO,"Gold PPO, Family Plan - Silver EPO",1480.00,810.00,0.00,11.00,2301.00,4.6,"Brain MRI performed at Regional Medical Center."
|
||
Karen Davis,Spouse,46,Female,Annual Physical Exam,preventive,Regional Medical Center,2024-11-25,true,Gold PPO,"Gold PPO, Family Plan - Silver EPO",100.00,225.00,0.00,5.50,330.50,4.6,"Annual Physical Exam performed at Regional Medical Center."
|
||
Karen Davis,Spouse,46,Female,Dental Cleaning,preventive,Green Valley Clinic,2024-09-30,true,Gold PPO,"Gold PPO, Family Plan - Silver EPO",60.00,120.00,0.00,0.00,180.00,4.5,"Dental Cleaning performed at Green Valley Clinic."
|
||
Liam O'Brien,Son,9,Male,Annual Physical Exam,preventive,City Hospital,2025-03-05,true,Gold PPO,"Gold PPO, Medicare Advantage",70.00,170.00,0.00,4.00,244.00,4.7,"Annual Physical Exam performed at City Hospital."
|
||
Liam O'Brien,Son,9,Male,Vision Exam,preventive,Sunrise Health,2024-12-19,true,Gold PPO,"Gold PPO, Bronze HDHP",35.00,105.00,0.00,0.00,140.00,4.3,"Vision Exam performed at Sunrise Health."
|
||
Liam O'Brien,Son,9,Male,Dental Cleaning,preventive,Green Valley Clinic,2025-04-11,true,Gold PPO,"Gold PPO, Family Plan - Silver EPO",55.00,115.00,0.00,0.00,170.00,4.5,"Dental Cleaning performed at Green Valley Clinic."
|
||
Maria Lopez,Daughter,19,Female,Annual Physical Exam,preventive,Green Valley Clinic,2025-01-08,true,Family Plan - Silver EPO,"Gold PPO, Family Plan - Silver EPO",80.00,185.00,0.00,4.50,269.50,4.5,"Annual Physical Exam performed at Green Valley Clinic."
|
||
Maria Lopez,Daughter,19,Female,Blood Test,diagnostic,City Hospital,2025-02-14,true,Family Plan - Silver EPO,"Gold PPO, Medicare Advantage",28.00,55.00,0.00,3.00,86.00,4.7,"Iron panel performed at City Hospital."
|
||
Maria Lopez,Daughter,19,Female,Vision Exam,preventive,Sunrise Health,2024-08-20,true,Family Plan - Silver EPO,"Gold PPO, Bronze HDHP",38.00,108.00,0.00,0.00,146.00,4.3,"Vision Exam performed at Sunrise Health."
|
||
```
|
||
|
||
- [ ] **Step 8: Write `app/data/available_procedures.csv`**
|
||
|
||
28 rows covering MRI, CT Scan, X Ray, Annual Physical Exam, Appendectomy, Dental Cleaning, Vision Exam, Blood Test, Angioplasty, Ultrasound across 4 locations:
|
||
|
||
Columns: `procedure,location,facility_rating,distance_miles,gold_ppo_plan_accepted,silver_epo_plan_accepted,accepted_plans,cost_facility,cost_physician,cost_anesthesia,cost_medication,total_cost`
|
||
|
||
```csv
|
||
procedure,location,facility_rating,distance_miles,gold_ppo_plan_accepted,silver_epo_plan_accepted,accepted_plans,cost_facility,cost_physician,cost_anesthesia,cost_medication,total_cost
|
||
MRI,City Hospital,4.7,5.2,true,true,"Gold PPO, Family Plan - Silver EPO, Medicare Advantage",1450.00,820.00,0.00,12.00,2282.00
|
||
MRI,Regional Medical Center,4.6,12.6,true,true,"Gold PPO, Family Plan - Silver EPO",1380.00,790.00,0.00,11.00,2181.00
|
||
MRI,Green Valley Clinic,4.5,8.4,true,false,"Gold PPO, Bronze HDHP",1520.00,860.00,0.00,12.50,2392.50
|
||
MRI,Sunrise Health,4.3,15.1,true,false,"Gold PPO, Bronze HDHP",1610.00,880.00,0.00,13.00,2503.00
|
||
CT Scan,City Hospital,4.7,5.2,true,true,"Gold PPO, Family Plan - Silver EPO, Medicare Advantage",1600.00,750.00,0.00,8.00,2358.00
|
||
CT Scan,Regional Medical Center,4.6,12.6,true,true,"Gold PPO, Family Plan - Silver EPO",1540.00,720.00,0.00,7.50,2267.50
|
||
CT Scan,Sunrise Health,4.3,15.1,true,false,"Gold PPO, Bronze HDHP",1750.00,820.00,0.00,9.00,2579.00
|
||
X Ray,City Hospital,4.7,5.2,true,true,"Gold PPO, Family Plan - Silver EPO, Medicare Advantage",110.00,140.00,0.00,2.00,252.00
|
||
X Ray,Green Valley Clinic,4.5,8.4,true,true,"Gold PPO, Family Plan - Silver EPO, Bronze HDHP",95.00,125.00,0.00,1.50,221.50
|
||
X Ray,Sunrise Health,4.3,15.1,true,false,"Gold PPO, Bronze HDHP",90.00,120.00,0.00,1.50,211.50
|
||
X Ray,Regional Medical Center,4.6,12.6,true,true,"Gold PPO, Family Plan - Silver EPO",100.00,130.00,0.00,1.50,231.50
|
||
Annual Physical Exam,City Hospital,4.7,5.2,true,true,"Gold PPO, Family Plan - Silver EPO, Medicare Advantage",80.00,180.00,0.00,5.00,265.00
|
||
Annual Physical Exam,Green Valley Clinic,4.5,8.4,true,true,"Gold PPO, Family Plan - Silver EPO, Bronze HDHP",75.00,170.00,0.00,4.50,249.50
|
||
Annual Physical Exam,Sunrise Health,4.3,15.1,true,false,"Gold PPO, Bronze HDHP",85.00,200.00,0.00,4.50,289.50
|
||
Annual Physical Exam,Regional Medical Center,4.6,12.6,true,true,"Gold PPO, Family Plan - Silver EPO",90.00,210.00,0.00,5.00,305.00
|
||
Appendectomy,City Hospital,4.7,5.2,true,true,"Gold PPO, Family Plan - Silver EPO, Medicare Advantage",9100.00,5800.00,2200.00,195.00,17295.00
|
||
Appendectomy,Regional Medical Center,4.6,12.6,true,true,"Gold PPO, Family Plan - Silver EPO",8200.00,5400.00,2100.00,180.00,15880.00
|
||
Dental Cleaning,Green Valley Clinic,4.5,8.4,true,true,"Gold PPO, Family Plan - Silver EPO, Bronze HDHP",58.00,118.00,0.00,0.00,176.00
|
||
Dental Cleaning,City Hospital,4.7,5.2,true,true,"Gold PPO, Family Plan - Silver EPO",60.00,120.00,0.00,0.00,180.00
|
||
Dental Cleaning,Sunrise Health,4.3,15.1,true,false,"Gold PPO, Bronze HDHP",55.00,115.00,0.00,0.00,170.00
|
||
Vision Exam,Sunrise Health,4.3,15.1,true,false,"Gold PPO, Bronze HDHP",35.00,105.00,0.00,0.00,140.00
|
||
Vision Exam,Green Valley Clinic,4.5,8.4,true,true,"Gold PPO, Family Plan - Silver EPO, Bronze HDHP",38.00,108.00,0.00,0.00,146.00
|
||
Vision Exam,City Hospital,4.7,5.2,true,true,"Gold PPO, Family Plan - Silver EPO",40.00,110.00,0.00,0.00,150.00
|
||
Blood Test,City Hospital,4.7,5.2,true,true,"Gold PPO, Family Plan - Silver EPO, Medicare Advantage",25.00,55.00,0.00,2.50,82.50
|
||
Blood Test,Regional Medical Center,4.6,12.6,true,true,"Gold PPO, Family Plan - Silver EPO",28.00,55.00,0.00,3.00,86.00
|
||
Angioplasty,City Hospital,4.7,5.2,true,true,"Gold PPO, Family Plan - Silver EPO, Medicare Advantage",9432.80,4774.57,1894.37,834.80,16936.54
|
||
Angioplasty,Regional Medical Center,4.6,12.6,true,true,"Gold PPO, Family Plan - Silver EPO",8920.00,4520.00,1850.00,810.00,16100.00
|
||
Ultrasound,City Hospital,4.7,5.2,true,true,"Gold PPO, Family Plan - Silver EPO, Medicare Advantage",380.00,290.00,0.00,3.00,673.00
|
||
```
|
||
|
||
- [ ] **Step 9: Create `app/data/reports_output/.gitkeep`**
|
||
|
||
Empty file. Run:
|
||
```bash
|
||
mkdir -p app/data/reports_output && touch app/data/reports_output/.gitkeep
|
||
```
|
||
|
||
- [ ] **Step 10: Sanity-check the CSVs load with pandas**
|
||
|
||
Run a quick REPL check:
|
||
```bash
|
||
python3.11 -c "
|
||
import pandas as pd
|
||
h = pd.read_csv('app/data/historical_procedures.csv')
|
||
a = pd.read_csv('app/data/available_procedures.csv')
|
||
c = pd.read_csv('app/data/aetna_claim_review_summary.csv')
|
||
print('historical:', len(h), 'rows,', list(h.columns)[:5], '...')
|
||
print('available:', len(a), 'rows,', list(a.columns)[:5], '...')
|
||
print('aetna:', len(c), 'rows,', list(c.columns)[:5], '...')
|
||
charlie = h[h['member_name'] == 'Charlie Smith']
|
||
print('Charlie rows:', len(charlie))
|
||
assert len(charlie) >= 4, 'Charlie should have ≥4 rows (matches member_insights overdue list)'
|
||
"
|
||
```
|
||
|
||
Expected: `historical: 50 rows`, `available: 28 rows`, `aetna: 10 rows`, `Charlie rows: 5`.
|
||
|
||
- [ ] **Step 11: Commit**
|
||
|
||
```bash
|
||
git add app/data/
|
||
git commit -m "feat(data): synthetic datasets for the 5 workshop endpoints"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 6: Endpoints 3 (member-insights) and 4 (schedule)
|
||
|
||
These are static — no logic. Implementing them first builds confidence in the routing setup.
|
||
|
||
**Files:**
|
||
- Create: `app/benefits_api.py`
|
||
- Modify: `app/main.py`
|
||
- Create: `tests/test_benefits_api.py`
|
||
|
||
- [ ] **Step 1: Write failing tests**
|
||
|
||
```python
|
||
# tests/test_benefits_api.py
|
||
def test_member_insights_returns_static_object(client):
|
||
response = client.get("/api/member-insights")
|
||
assert response.status_code == 200
|
||
body = response.json()
|
||
assert "result" in body
|
||
assert body["result"]["member"]["name"] == "Charlie Smith"
|
||
assert body["result"]["member"]["plan"] == "Gold PPO"
|
||
assert len(body["result"]["overdue_procedures"]) == 4
|
||
|
||
|
||
def test_schedule_returns_text_wrapped(client):
|
||
response = client.get("/api/schedule")
|
||
assert response.status_code == 200
|
||
body = response.json()
|
||
assert "result" in body
|
||
assert "agendar una cita" in body["result"].lower()
|
||
assert "1-800-FIT-CARE" in body["result"]
|
||
```
|
||
|
||
- [ ] **Step 2: Run tests to verify they fail**
|
||
|
||
```bash
|
||
pytest tests/test_benefits_api.py -v
|
||
```
|
||
|
||
Expected: 404 (routes don't exist yet).
|
||
|
||
- [ ] **Step 3: Implement `app/benefits_api.py`**
|
||
|
||
```python
|
||
import json
|
||
from pathlib import Path
|
||
|
||
from fastapi import APIRouter
|
||
|
||
router = APIRouter(prefix="/api", tags=["benefits"])
|
||
|
||
_DATA_DIR = Path(__file__).parent / "data"
|
||
|
||
with (_DATA_DIR / "member_insights.json").open() as f:
|
||
_MEMBER_INSIGHTS = json.load(f)
|
||
|
||
_SCHEDULE_TEXT = (_DATA_DIR / "schedule_response.txt").read_text(encoding="utf-8").strip()
|
||
|
||
|
||
@router.get("/member-insights")
|
||
def member_insights():
|
||
return _MEMBER_INSIGHTS
|
||
|
||
|
||
@router.get("/schedule")
|
||
def schedule():
|
||
return {"result": _SCHEDULE_TEXT}
|
||
```
|
||
|
||
- [ ] **Step 4: Wire router into `app/main.py`**
|
||
|
||
Add to imports:
|
||
```python
|
||
from app import benefits_api
|
||
```
|
||
|
||
Add after `app.add_middleware(...)`:
|
||
```python
|
||
app.include_router(benefits_api.router)
|
||
```
|
||
|
||
- [ ] **Step 5: Run tests to verify they pass**
|
||
|
||
```bash
|
||
pytest tests/test_benefits_api.py -v
|
||
```
|
||
|
||
Expected: `2 passed`.
|
||
|
||
- [ ] **Step 6: Commit**
|
||
|
||
```bash
|
||
git add app/benefits_api.py app/main.py tests/test_benefits_api.py
|
||
git commit -m "feat(api): endpoints 3 and 4 - member-insights and schedule"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 7: Endpoint 1 (historical-procedures)
|
||
|
||
Adds the filter + group_by logic. Shared with endpoint 2 in Task 8.
|
||
|
||
**Files:**
|
||
- Modify: `app/benefits_api.py`
|
||
- Modify: `tests/test_benefits_api.py`
|
||
|
||
- [ ] **Step 1: Write failing tests**
|
||
|
||
Append to `tests/test_benefits_api.py`:
|
||
|
||
```python
|
||
def test_historical_procedures_no_filter_returns_all(client):
|
||
response = client.post(
|
||
"/api/historical-procedures",
|
||
json={"filters": "[]", "group_by": "[]"},
|
||
)
|
||
assert response.status_code == 200
|
||
body = response.json()
|
||
assert "result" in body
|
||
assert len(body["result"]) == 50
|
||
|
||
|
||
def test_historical_procedures_filter_equals(client):
|
||
response = client.post(
|
||
"/api/historical-procedures",
|
||
json={
|
||
"filters": '[{"column": "member_name", "operator": "equals", "value": "Charlie Smith"}]',
|
||
"group_by": "[]",
|
||
},
|
||
)
|
||
assert response.status_code == 200
|
||
rows = response.json()["result"]
|
||
assert len(rows) == 5 # Charlie Smith has 5 rows in the dataset
|
||
assert all(r["member_name"] == "Charlie Smith" for r in rows)
|
||
|
||
|
||
def test_historical_procedures_filter_contains(client):
|
||
response = client.post(
|
||
"/api/historical-procedures",
|
||
json={
|
||
"filters": '[{"column": "procedure", "operator": "contains", "value": "mri"}]',
|
||
"group_by": "[]",
|
||
},
|
||
)
|
||
assert response.status_code == 200
|
||
rows = response.json()["result"]
|
||
assert len(rows) > 0
|
||
assert all("MRI" in r["procedure"] for r in rows)
|
||
|
||
|
||
def test_historical_procedures_filter_gt(client):
|
||
response = client.post(
|
||
"/api/historical-procedures",
|
||
json={
|
||
"filters": '[{"column": "total_cost", "operator": "gt", "value": 5000}]',
|
||
"group_by": "[]",
|
||
},
|
||
)
|
||
assert response.status_code == 200
|
||
rows = response.json()["result"]
|
||
assert len(rows) > 0
|
||
assert all(r["total_cost"] > 5000 for r in rows)
|
||
|
||
|
||
def test_historical_procedures_group_by(client):
|
||
response = client.post(
|
||
"/api/historical-procedures",
|
||
json={"filters": "[]", "group_by": '["relationship"]'},
|
||
)
|
||
assert response.status_code == 200
|
||
rows = response.json()["result"]
|
||
assert len(rows) > 0
|
||
# group_by should leave only group key + aggregated numeric cols
|
||
assert "relationship" in rows[0]
|
||
assert "age" in rows[0]
|
||
```
|
||
|
||
- [ ] **Step 2: Run tests — expect failures**
|
||
|
||
```bash
|
||
pytest tests/test_benefits_api.py -v
|
||
```
|
||
|
||
Expected: 4 new tests fail (404).
|
||
|
||
- [ ] **Step 3: Implement filter+group_by helper and route**
|
||
|
||
Replace `app/benefits_api.py` with:
|
||
|
||
```python
|
||
import json
|
||
from pathlib import Path
|
||
|
||
import pandas as pd
|
||
from fastapi import APIRouter, HTTPException
|
||
|
||
router = APIRouter(prefix="/api", tags=["benefits"])
|
||
|
||
_DATA_DIR = Path(__file__).parent / "data"
|
||
|
||
with (_DATA_DIR / "member_insights.json").open() as f:
|
||
_MEMBER_INSIGHTS = json.load(f)
|
||
|
||
_SCHEDULE_TEXT = (_DATA_DIR / "schedule_response.txt").read_text(encoding="utf-8").strip()
|
||
|
||
_HISTORICAL = pd.read_csv(_DATA_DIR / "historical_procedures.csv")
|
||
|
||
|
||
_OPS = {
|
||
"equals": lambda s, v: s == v,
|
||
"ne": lambda s, v: s != v,
|
||
"contains": lambda s, v: s.astype(str).str.contains(str(v), case=False, na=False),
|
||
"gt": lambda s, v: s > v,
|
||
"lt": lambda s, v: s < v,
|
||
"ge": lambda s, v: s >= v,
|
||
"le": lambda s, v: s <= v,
|
||
}
|
||
|
||
|
||
def _apply_filters_and_group(df: pd.DataFrame, filters_raw: str, group_by_raw: str) -> list[dict]:
|
||
try:
|
||
filters = json.loads(filters_raw) if filters_raw else []
|
||
group_by = json.loads(group_by_raw) if group_by_raw else []
|
||
except json.JSONDecodeError as exc:
|
||
raise HTTPException(status_code=400, detail=f"Invalid JSON in filters or group_by: {exc}")
|
||
|
||
result = df.copy()
|
||
for f in filters:
|
||
col, op, val = f.get("column"), f.get("operator"), f.get("value")
|
||
if col not in result.columns:
|
||
raise HTTPException(status_code=400, detail=f"Unknown column: {col}")
|
||
if op not in _OPS:
|
||
raise HTTPException(status_code=400, detail=f"Unsupported operator: {op}")
|
||
result = result[_OPS[op](result[col], val)]
|
||
|
||
if group_by:
|
||
missing = [c for c in group_by if c not in result.columns]
|
||
if missing:
|
||
raise HTTPException(status_code=400, detail=f"Unknown group_by columns: {missing}")
|
||
numeric_cols = result.select_dtypes(include="number").columns.tolist()
|
||
result = result.groupby(group_by)[numeric_cols].mean().reset_index()
|
||
|
||
return result.to_dict(orient="records")
|
||
|
||
|
||
@router.get("/member-insights")
|
||
def member_insights():
|
||
return _MEMBER_INSIGHTS
|
||
|
||
|
||
@router.get("/schedule")
|
||
def schedule():
|
||
return {"result": _SCHEDULE_TEXT}
|
||
|
||
|
||
@router.post("/historical-procedures")
|
||
def historical_procedures(payload: dict):
|
||
rows = _apply_filters_and_group(
|
||
_HISTORICAL,
|
||
payload.get("filters", "[]"),
|
||
payload.get("group_by", "[]"),
|
||
)
|
||
return {"result": rows}
|
||
```
|
||
|
||
- [ ] **Step 4: Run tests — expect pass**
|
||
|
||
```bash
|
||
pytest tests/test_benefits_api.py -v
|
||
```
|
||
|
||
Expected: `7 passed`.
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
git add app/benefits_api.py tests/test_benefits_api.py
|
||
git commit -m "feat(api): endpoint 1 - historical-procedures with filter+group_by"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 8: Endpoint 2 (available-procedures)
|
||
|
||
Reuses the helper from Task 7.
|
||
|
||
**Files:**
|
||
- Modify: `app/benefits_api.py`
|
||
- Modify: `tests/test_benefits_api.py`
|
||
|
||
- [ ] **Step 1: Write failing tests**
|
||
|
||
Append to `tests/test_benefits_api.py`:
|
||
|
||
```python
|
||
def test_available_procedures_no_filter(client):
|
||
response = client.post(
|
||
"/api/available-procedures",
|
||
json={"filters": "[]", "group_by": "[]"},
|
||
)
|
||
assert response.status_code == 200
|
||
rows = response.json()["result"]
|
||
assert len(rows) == 28
|
||
|
||
|
||
def test_available_procedures_filter_mri(client):
|
||
response = client.post(
|
||
"/api/available-procedures",
|
||
json={
|
||
"filters": '[{"column": "procedure", "operator": "contains", "value": "MRI"}]',
|
||
"group_by": "[]",
|
||
},
|
||
)
|
||
rows = response.json()["result"]
|
||
assert len(rows) >= 3
|
||
assert all(r["procedure"] == "MRI" for r in rows)
|
||
|
||
|
||
def test_available_procedures_group_by_procedure(client):
|
||
response = client.post(
|
||
"/api/available-procedures",
|
||
json={"filters": "[]", "group_by": '["procedure"]'},
|
||
)
|
||
rows = response.json()["result"]
|
||
assert len(rows) > 0
|
||
assert "procedure" in rows[0]
|
||
assert "total_cost" in rows[0] # numeric column should be averaged
|
||
```
|
||
|
||
- [ ] **Step 2: Run tests — expect 404**
|
||
|
||
```bash
|
||
pytest tests/test_benefits_api.py -v
|
||
```
|
||
|
||
Expected: 3 new tests fail (404).
|
||
|
||
- [ ] **Step 3: Add the route to `app/benefits_api.py`**
|
||
|
||
After the `_HISTORICAL = ...` line, add:
|
||
```python
|
||
_AVAILABLE = pd.read_csv(_DATA_DIR / "available_procedures.csv")
|
||
```
|
||
|
||
Append at the end of the file:
|
||
```python
|
||
@router.post("/available-procedures")
|
||
def available_procedures(payload: dict):
|
||
rows = _apply_filters_and_group(
|
||
_AVAILABLE,
|
||
payload.get("filters", "[]"),
|
||
payload.get("group_by", "[]"),
|
||
)
|
||
return {"result": rows}
|
||
```
|
||
|
||
- [ ] **Step 4: Run tests — expect pass**
|
||
|
||
```bash
|
||
pytest tests/test_benefits_api.py -v
|
||
```
|
||
|
||
Expected: `10 passed`.
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
git add app/benefits_api.py tests/test_benefits_api.py
|
||
git commit -m "feat(api): endpoint 2 - available-procedures (reuses filter helper)"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 9: Endpoint 5 (generate-report)
|
||
|
||
The trickiest. Generates HTML with Jinja2 + Plotly, writes to disk, returns public URL.
|
||
|
||
**Files:**
|
||
- Create: `app/reports_api.py`
|
||
- Create: `app/templates/report.html`
|
||
- Modify: `app/main.py`
|
||
- Create: `tests/test_reports_api.py`
|
||
|
||
- [ ] **Step 1: Write `app/templates/report.html`**
|
||
|
||
```html
|
||
<!DOCTYPE html>
|
||
<html lang="es">
|
||
<head>
|
||
<meta charset="UTF-8">
|
||
<title>Care Report</title>
|
||
<script src="https://cdn.plot.ly/plotly-latest.min.js"></script>
|
||
<style>
|
||
body { font-family: 'Inter', system-ui, sans-serif; max-width: 960px; margin: 40px auto; padding: 20px; color: #1A1A1A; }
|
||
h2 { color: #0A1F44; border-bottom: 3px solid #FF7A00; padding-bottom: 8px; }
|
||
table { width: 100%; border-collapse: collapse; margin: 16px 0; }
|
||
th { background: #0A1F44; color: white; padding: 10px; text-align: left; }
|
||
td { padding: 8px; border-bottom: 1px solid #D8DEE5; }
|
||
.eyebrow { color: #00B5D8; font-weight: 700; }
|
||
footer { margin-top: 40px; color: #5A6473; font-size: 13px; }
|
||
</style>
|
||
</head>
|
||
<body>
|
||
<header><p class="eyebrow">FACTORIT · FIT</p></header>
|
||
{{ content | safe }}
|
||
<footer>Generado por AskReporting · taller-wox.fitlabs.dev</footer>
|
||
</body>
|
||
</html>
|
||
```
|
||
|
||
- [ ] **Step 2: Write failing tests**
|
||
|
||
```python
|
||
# tests/test_reports_api.py
|
||
import json
|
||
import os
|
||
from pathlib import Path
|
||
|
||
|
||
def test_generate_report_with_care_report_preset(client):
|
||
response = client.post(
|
||
"/api/reports/generate-report",
|
||
json={"layout_config": json.dumps(["care_report"])},
|
||
)
|
||
assert response.status_code == 200
|
||
body = response.json()
|
||
assert "public_url" in body
|
||
assert body["public_url"].endswith(".html")
|
||
|
||
# Get the report id and verify the file was written
|
||
report_id = body["public_url"].rsplit("/", 1)[-1]
|
||
output_dir = Path(os.environ["REPORTS_OUTPUT_DIR"])
|
||
assert (output_dir / report_id).exists()
|
||
content = (output_dir / report_id).read_text()
|
||
assert "Care Report" in content
|
||
assert "Customer Overview" in content
|
||
assert "Claim Review Summary" in content
|
||
assert "<table" in content
|
||
|
||
|
||
def test_generate_report_with_custom_layout(client):
|
||
layout = [
|
||
{"element_type": "header", "parameters": {"title": "Quarterly Recap"}},
|
||
{
|
||
"element_type": "table",
|
||
"parameters": {
|
||
"csv_file": "./data/aetna_claim_review_summary.csv",
|
||
"title": "Claims",
|
||
},
|
||
},
|
||
]
|
||
response = client.post(
|
||
"/api/reports/generate-report",
|
||
json={"layout_config": json.dumps(layout)},
|
||
)
|
||
assert response.status_code == 200
|
||
report_id = response.json()["public_url"].rsplit("/", 1)[-1]
|
||
output_dir = Path(os.environ["REPORTS_OUTPUT_DIR"])
|
||
content = (output_dir / report_id).read_text()
|
||
assert "Quarterly Recap" in content
|
||
assert "Claims" in content
|
||
|
||
|
||
def test_generate_report_served_as_static(client):
|
||
response = client.post(
|
||
"/api/reports/generate-report",
|
||
json={"layout_config": json.dumps(["care_report"])},
|
||
)
|
||
report_id = response.json()["public_url"].rsplit("/", 1)[-1]
|
||
# The static mount should serve it
|
||
served = client.get(f"/api/reports/output/{report_id}")
|
||
assert served.status_code == 200
|
||
assert "text/html" in served.headers["content-type"]
|
||
```
|
||
|
||
- [ ] **Step 3: Run tests — expect 404**
|
||
|
||
```bash
|
||
pytest tests/test_reports_api.py -v
|
||
```
|
||
|
||
Expected: routes don't exist, 404s.
|
||
|
||
- [ ] **Step 4: Implement `app/reports_api.py`**
|
||
|
||
```python
|
||
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:
|
||
"""Resolve a path provided by the agent. The spec sends them like './data/foo.txt'."""
|
||
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")
|
||
# Render text with line breaks. No LLM dependency — show raw + a synthesized summary heading.
|
||
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:
|
||
# Use the CSV to build a bar chart of charged vs allowed vs patient_responsibility per CPT
|
||
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=False)
|
||
|
||
|
||
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": _render_header,
|
||
"overview": _render_overview,
|
||
"claim_review_chart": lambda _params: _render_claim_chart(),
|
||
"table": _render_table,
|
||
}
|
||
|
||
|
||
@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}")
|
||
|
||
# Expand preset strings
|
||
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}"}
|
||
```
|
||
|
||
- [ ] **Step 5: Wire router and static mount into `app/main.py`**
|
||
|
||
Add imports:
|
||
```python
|
||
from pathlib import Path
|
||
|
||
from fastapi.staticfiles import StaticFiles
|
||
|
||
from app import reports_api
|
||
```
|
||
|
||
After the existing `app.include_router(...)`, add:
|
||
```python
|
||
app.include_router(reports_api.router)
|
||
|
||
# Serve generated reports as static files
|
||
_reports_dir = Path(settings.reports_output_dir)
|
||
_reports_dir.mkdir(parents=True, exist_ok=True)
|
||
app.mount(
|
||
"/api/reports/output",
|
||
StaticFiles(directory=str(_reports_dir)),
|
||
name="reports_output",
|
||
)
|
||
```
|
||
|
||
- [ ] **Step 6: Run tests — expect pass**
|
||
|
||
```bash
|
||
pytest tests/test_reports_api.py -v
|
||
```
|
||
|
||
Expected: `3 passed`.
|
||
|
||
- [ ] **Step 7: Quick manual smoke**
|
||
|
||
```bash
|
||
uvicorn app.main:app --reload --port 8000
|
||
```
|
||
|
||
In another terminal:
|
||
```bash
|
||
curl -X POST http://localhost:8000/api/reports/generate-report \
|
||
-H "Content-Type: application/json" \
|
||
-d '{"layout_config":"[\"care_report\"]"}'
|
||
```
|
||
|
||
Expected: JSON with a `public_url` ending in `.html`. Open the URL in a browser to verify the report renders with the chart.
|
||
|
||
- [ ] **Step 8: Commit**
|
||
|
||
```bash
|
||
git add app/reports_api.py app/templates/report.html app/main.py tests/test_reports_api.py
|
||
git commit -m "feat(api): endpoint 5 - generate-report with jinja2 + plotly"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 10: Frontend templates and CSS (no backend routes yet)
|
||
|
||
This task produces the HTML/CSS skeleton. Backend routes that serve them come in Task 11.
|
||
|
||
**Files:**
|
||
- Create: `app/templates/base.html`
|
||
- Create: `app/templates/index.html`
|
||
- Create: `app/templates/descargas.html`
|
||
- Create: `static/css/styles.css`
|
||
- Create: `static/js/app.js`
|
||
- Create: `static/img/.gitkeep`
|
||
|
||
- [ ] **Step 1: Compress the existing logo**
|
||
|
||
```bash
|
||
mkdir -p static/img
|
||
# Copy first; compression is a nice-to-have, the PNG works as-is
|
||
cp images/LogoFIT.png static/img/LogoFIT.png
|
||
```
|
||
|
||
(If you have `pngquant` or similar installed, run `pngquant --quality=70-85 -o static/img/LogoFIT.png --force static/img/LogoFIT.png` to compress. Otherwise skip — the 971 KB PNG is acceptable for Thursday.)
|
||
|
||
- [ ] **Step 2: Write `app/templates/base.html`**
|
||
|
||
```html
|
||
<!DOCTYPE html>
|
||
<html lang="es">
|
||
<head>
|
||
<meta charset="UTF-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||
<title>{% block title %}Bootcamp Agentic AI — watsonx Orchestrate | FactorIT{% endblock %}</title>
|
||
<meta name="description" content="Construye tu primer agente de IA en 4 horas con IBM watsonx Orchestrate. Material completo del bootcamp para descarga.">
|
||
|
||
<meta property="og:title" content="Bootcamp Agentic AI — watsonx Orchestrate | FactorIT">
|
||
<meta property="og:description" content="Construye tu primer agente de IA en 4 horas con IBM watsonx Orchestrate.">
|
||
<meta property="og:url" content="https://taller-wox.fitlabs.dev/">
|
||
|
||
<link rel="icon" href="/static/img/LogoFIT.png">
|
||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;600;700;800&display=swap" rel="stylesheet">
|
||
<link rel="stylesheet" href="/static/css/styles.css">
|
||
</head>
|
||
<body>
|
||
<header class="site-header">
|
||
<a class="brand" href="/"><img src="/static/img/LogoFIT.png" alt="FactorIT" height="32"></a>
|
||
<span class="powered-by">powered by <strong>IBM watsonx Orchestrate</strong></span>
|
||
</header>
|
||
|
||
{% block content %}{% endblock %}
|
||
|
||
<footer class="site-footer">
|
||
<div class="footer-inner">
|
||
<p><strong>FactorIT · FIT</strong> — Material del bootcamp · taller-wox.fitlabs.dev</p>
|
||
<p><a href="https://github.com/leozangulo/agentic-bootcamp" target="_blank" rel="noopener">github.com/leozangulo/agentic-bootcamp</a></p>
|
||
<p class="copyright">© 2026 FactorIT — Todos los derechos reservados</p>
|
||
</div>
|
||
</footer>
|
||
|
||
<script src="/static/js/app.js" defer></script>
|
||
</body>
|
||
</html>
|
||
```
|
||
|
||
- [ ] **Step 3: Write `app/templates/index.html`**
|
||
|
||
```html
|
||
{% extends "base.html" %}
|
||
|
||
{% block content %}
|
||
{% if error %}
|
||
<div class="banner banner-error" role="alert">{{ error }}</div>
|
||
{% endif %}
|
||
|
||
<section class="hero">
|
||
<div class="hero-inner">
|
||
<p class="eyebrow">FACTORIT · FIT</p>
|
||
<h1>Bootcamp Agentic AI con watsonx Orchestrate</h1>
|
||
<p class="subtitle">Construye tu primer agente de IA en 4 horas.</p>
|
||
<a class="btn btn-primary" href="#descargas">Acceder al material →</a>
|
||
</div>
|
||
</section>
|
||
|
||
<section class="cards-section">
|
||
<h2 class="section-title">¿Qué vas a construir?</h2>
|
||
<div class="cards">
|
||
<article class="card"><div class="icon">⚙️</div><h3>Tu primer agente</h3><p>Conecta una API real a un agente conversacional sin escribir código.</p></article>
|
||
<article class="card"><div class="icon">📚</div><h3>Multi-agente con RAG</h3><p>Compón agentes especializados con base de conocimiento documental.</p></article>
|
||
<article class="card"><div class="icon">📊</div><h3>Reportes y APIs</h3><p>Genera reportes ejecutivos invocando endpoints en vivo.</p></article>
|
||
</div>
|
||
</section>
|
||
|
||
<section class="stats-section">
|
||
<h2 class="section-title">El taller en números</h2>
|
||
<div class="stats">
|
||
<div class="stat"><span class="num">4h</span><span class="label">Duración</span></div>
|
||
<div class="stat"><span class="num">6</span><span class="label">Módulos</span></div>
|
||
<div class="stat"><span class="num">0</span><span class="label">Líneas de código</span></div>
|
||
<div class="stat"><span class="num">100%</span><span class="label">Hands-on</span></div>
|
||
</div>
|
||
</section>
|
||
|
||
<section id="descargas" class="form-section">
|
||
<h2 class="section-title">Descarga todo el material</h2>
|
||
<p class="form-intro">Registra tus datos para acceder al kit completo del bootcamp.</p>
|
||
<form class="register-form" method="post" action="/register" autocomplete="on">
|
||
<div class="field">
|
||
<label for="nombre">Nombre completo</label>
|
||
<input type="text" id="nombre" name="nombre" minlength="2" maxlength="80" required>
|
||
</div>
|
||
<div class="field">
|
||
<label for="email">Email corporativo</label>
|
||
<input type="email" id="email" name="email" required>
|
||
</div>
|
||
<div class="field">
|
||
<label for="empresa">Empresa</label>
|
||
<input type="text" id="empresa" name="empresa" minlength="2" maxlength="100" required>
|
||
</div>
|
||
<div class="field field-checkbox">
|
||
<input type="checkbox" id="consentimiento" name="consentimiento" required>
|
||
<label for="consentimiento">Acepto que FactorIT use mis datos para enviarme información del bootcamp y comunicaciones futuras. No spam — solo lo importante.</label>
|
||
</div>
|
||
<!-- honeypot — invisible to humans, attractive to bots -->
|
||
<div class="hp-field" aria-hidden="true">
|
||
<label for="website">Website</label>
|
||
<input type="text" id="website" name="website" tabindex="-1" autocomplete="off">
|
||
</div>
|
||
<button type="submit" class="btn btn-primary btn-large">Acceder al material</button>
|
||
</form>
|
||
</section>
|
||
{% endblock %}
|
||
```
|
||
|
||
- [ ] **Step 4: Write `app/templates/descargas.html`**
|
||
|
||
```html
|
||
{% extends "base.html" %}
|
||
{% block title %}Material del bootcamp — {{ nombre }}{% endblock %}
|
||
|
||
{% block content %}
|
||
<section class="descargas-section">
|
||
<h1 class="hello">¡Hola {{ nombre }}!</h1>
|
||
<p class="hello-sub">Acá tienes todo el material del bootcamp.</p>
|
||
|
||
<div class="download-cards">
|
||
{% for d in downloads %}
|
||
<article class="download-card">
|
||
<div class="dl-icon">{{ d.icon }}</div>
|
||
<h2>{{ d.title }}</h2>
|
||
<p>{{ d.description }}</p>
|
||
<p class="size">{{ d.size_mb }} MB</p>
|
||
{% if d.available %}
|
||
<a class="btn btn-primary" href="/download/{{ d.filename }}?token={{ token }}">Descargar</a>
|
||
{% else %}
|
||
<span class="btn btn-disabled" title="Archivo aún no disponible">No disponible</span>
|
||
{% endif %}
|
||
</article>
|
||
{% endfor %}
|
||
</div>
|
||
</section>
|
||
{% endblock %}
|
||
```
|
||
|
||
- [ ] **Step 5: Write `static/css/styles.css`**
|
||
|
||
```css
|
||
:root {
|
||
--navy: #0A1F44;
|
||
--blue: #1E4FA8;
|
||
--cyan: #00B5D8;
|
||
--orange: #FF7A00;
|
||
--orange-hover: #E66A00;
|
||
--cream: #F5F7FA;
|
||
--text: #1A1A1A;
|
||
--muted: #5A6473;
|
||
--border: #D8DEE5;
|
||
}
|
||
|
||
* { box-sizing: border-box; }
|
||
html { scroll-behavior: smooth; }
|
||
body { margin: 0; font-family: 'Inter', system-ui, sans-serif; color: var(--text); background: white; line-height: 1.6; }
|
||
|
||
.site-header {
|
||
display: flex; align-items: center; justify-content: space-between;
|
||
padding: 16px 32px; background: white; border-bottom: 1px solid var(--border);
|
||
}
|
||
.site-header .brand img { display: block; }
|
||
.powered-by { color: var(--muted); font-size: 13px; }
|
||
.powered-by strong { color: var(--navy); }
|
||
|
||
.hero {
|
||
background: linear-gradient(135deg, var(--navy) 0%, var(--blue) 100%);
|
||
color: white; padding: 80px 32px; min-height: 80vh; display: flex; align-items: center;
|
||
}
|
||
.hero-inner { max-width: 800px; margin: 0 auto; text-align: center; }
|
||
.eyebrow { color: var(--cyan); font-weight: 700; letter-spacing: 2px; margin: 0 0 16px; }
|
||
.hero h1 { font-size: clamp(32px, 5vw, 56px); margin: 0 0 16px; font-weight: 800; line-height: 1.15; }
|
||
.subtitle { font-size: 20px; color: var(--cream); font-style: italic; margin: 0 0 32px; }
|
||
|
||
.btn { display: inline-block; padding: 14px 28px; border-radius: 8px; font-weight: 700; text-decoration: none; cursor: pointer; border: 0; font-size: 16px; transition: transform 0.15s ease, background 0.15s ease; }
|
||
.btn-primary { background: var(--orange); color: white; }
|
||
.btn-primary:hover { background: var(--orange-hover); transform: translateY(-1px); }
|
||
.btn-large { padding: 18px 36px; font-size: 18px; }
|
||
.btn-disabled { background: var(--border); color: var(--muted); cursor: not-allowed; }
|
||
|
||
.section-title { text-align: center; font-size: 32px; color: var(--navy); margin: 0 0 32px; }
|
||
|
||
.cards-section, .stats-section, .form-section, .descargas-section {
|
||
padding: 64px 32px; max-width: 1100px; margin: 0 auto;
|
||
}
|
||
.cards-section { background: var(--cream); max-width: none; }
|
||
.cards-section .section-title, .cards-section .cards { max-width: 1100px; margin-left: auto; margin-right: auto; }
|
||
|
||
.cards { display: grid; grid-template-columns: repeat(auto-fit, minmax(260px, 1fr)); gap: 24px; }
|
||
.card { background: white; padding: 32px; border-radius: 12px; box-shadow: 0 2px 8px rgba(10, 31, 68, 0.08); transition: transform 0.2s ease, box-shadow 0.2s ease; }
|
||
.card:hover { transform: translateY(-4px); box-shadow: 0 8px 24px rgba(10, 31, 68, 0.14); }
|
||
.card .icon { font-size: 36px; margin-bottom: 12px; }
|
||
.card h3 { color: var(--navy); margin: 0 0 8px; }
|
||
.card p { color: var(--muted); margin: 0; }
|
||
|
||
.stats { display: grid; grid-template-columns: repeat(auto-fit, minmax(160px, 1fr)); gap: 24px; text-align: center; }
|
||
.stat { padding: 24px; }
|
||
.stat .num { display: block; font-size: 56px; font-weight: 800; color: var(--orange); line-height: 1; }
|
||
.stat .label { display: block; color: var(--muted); margin-top: 8px; font-size: 14px; text-transform: uppercase; letter-spacing: 1px; }
|
||
|
||
.form-section { background: white; max-width: 600px; }
|
||
.form-intro { text-align: center; color: var(--muted); margin: 0 0 32px; }
|
||
.register-form { display: flex; flex-direction: column; gap: 20px; }
|
||
.field label { display: block; font-weight: 600; margin-bottom: 6px; color: var(--navy); }
|
||
.field input[type="text"], .field input[type="email"] {
|
||
width: 100%; padding: 12px 14px; border: 2px solid var(--border); border-radius: 8px; font-size: 16px; font-family: inherit;
|
||
}
|
||
.field input:focus { outline: none; border-color: var(--cyan); }
|
||
.field-checkbox { display: flex; gap: 12px; align-items: flex-start; }
|
||
.field-checkbox input { margin-top: 4px; }
|
||
.field-checkbox label { font-weight: 400; color: var(--muted); font-size: 14px; }
|
||
.hp-field { position: absolute; left: -10000px; top: auto; width: 1px; height: 1px; overflow: hidden; }
|
||
|
||
.banner { padding: 16px 32px; text-align: center; }
|
||
.banner-error { background: #FFF1F0; color: #B0211A; border-bottom: 2px solid #B0211A; }
|
||
|
||
.descargas-section { text-align: center; }
|
||
.hello { color: var(--navy); font-size: 40px; margin: 0 0 8px; }
|
||
.hello-sub { color: var(--muted); margin: 0 0 48px; }
|
||
.download-cards { display: grid; grid-template-columns: repeat(auto-fit, minmax(320px, 1fr)); gap: 32px; }
|
||
.download-card { background: var(--cream); padding: 40px; border-radius: 16px; border: 2px solid transparent; transition: border-color 0.2s ease, transform 0.2s ease; }
|
||
.download-card:hover { border-color: var(--cyan); transform: translateY(-2px); }
|
||
.download-card .dl-icon { font-size: 56px; margin-bottom: 16px; }
|
||
.download-card h2 { color: var(--navy); margin: 0 0 8px; font-size: 24px; }
|
||
.download-card p { color: var(--muted); margin: 0 0 8px; }
|
||
.download-card .size { font-size: 13px; color: var(--muted); font-weight: 600; }
|
||
|
||
.site-footer { background: var(--navy); color: white; padding: 32px; margin-top: 64px; }
|
||
.footer-inner { max-width: 1100px; margin: 0 auto; text-align: center; }
|
||
.footer-inner a { color: var(--cyan); text-decoration: none; }
|
||
.copyright { color: rgba(255, 255, 255, 0.5); font-size: 13px; margin-top: 16px; }
|
||
|
||
@media (max-width: 768px) {
|
||
.hero { min-height: 70vh; padding: 48px 24px; }
|
||
.cards-section, .stats-section, .form-section, .descargas-section { padding: 48px 24px; }
|
||
.download-card { padding: 24px; }
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 6: Write `static/js/app.js` (minimal)**
|
||
|
||
```javascript
|
||
// Smooth scroll for in-page anchors is already handled by html { scroll-behavior: smooth }.
|
||
// Light enhancement: fade-in sections as they enter the viewport.
|
||
|
||
document.addEventListener('DOMContentLoaded', () => {
|
||
const sections = document.querySelectorAll('.cards-section, .stats-section, .form-section');
|
||
if (!('IntersectionObserver' in window)) return;
|
||
const observer = new IntersectionObserver((entries) => {
|
||
entries.forEach(entry => {
|
||
if (entry.isIntersecting) {
|
||
entry.target.style.opacity = '1';
|
||
entry.target.style.transform = 'translateY(0)';
|
||
observer.unobserve(entry.target);
|
||
}
|
||
});
|
||
}, { threshold: 0.1 });
|
||
|
||
sections.forEach(s => {
|
||
s.style.opacity = '0';
|
||
s.style.transform = 'translateY(20px)';
|
||
s.style.transition = 'opacity 0.6s ease, transform 0.6s ease';
|
||
observer.observe(s);
|
||
});
|
||
});
|
||
```
|
||
|
||
- [ ] **Step 7: Create gitkeep for static/img**
|
||
|
||
```bash
|
||
mkdir -p static/img && touch static/img/.gitkeep
|
||
```
|
||
|
||
- [ ] **Step 8: Commit (no tests yet — wired up in Task 11)**
|
||
|
||
```bash
|
||
git add app/templates/ static/
|
||
git commit -m "feat(frontend): templates (base, index, descargas) + FIT-branded CSS + light JS"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 11: Frontend routes (landing, register, descargas, download)
|
||
|
||
**Files:**
|
||
- Create: `app/frontend.py`
|
||
- Modify: `app/main.py`
|
||
- Create: `tests/test_frontend.py`
|
||
|
||
- [ ] **Step 1: Write failing tests**
|
||
|
||
```python
|
||
# tests/test_frontend.py
|
||
import os
|
||
from pathlib import Path
|
||
|
||
|
||
def test_landing_renders(client):
|
||
response = client.get("/")
|
||
assert response.status_code == 200
|
||
assert "text/html" in response.headers["content-type"]
|
||
assert "Bootcamp Agentic AI" in response.text
|
||
assert 'name="email"' in response.text
|
||
assert 'name="website"' in response.text # honeypot present
|
||
|
||
|
||
def test_register_valid_form_redirects_with_token(client):
|
||
response = client.post(
|
||
"/register",
|
||
data={
|
||
"nombre": "Felipe Arentsen",
|
||
"email": "felipe@factorit.com",
|
||
"empresa": "FactorIT",
|
||
"consentimiento": "on",
|
||
},
|
||
follow_redirects=False,
|
||
)
|
||
assert response.status_code == 303
|
||
location = response.headers["location"]
|
||
assert location.startswith("/descargas?token=")
|
||
|
||
|
||
def test_register_missing_consent_fails(client):
|
||
response = client.post(
|
||
"/register",
|
||
data={
|
||
"nombre": "Felipe",
|
||
"email": "f@x.com",
|
||
"empresa": "X",
|
||
},
|
||
follow_redirects=False,
|
||
)
|
||
assert response.status_code == 422 or response.status_code == 400
|
||
|
||
|
||
def test_register_honeypot_filled_silently_drops(client):
|
||
response = client.post(
|
||
"/register",
|
||
data={
|
||
"nombre": "Bot",
|
||
"email": "bot@spam.com",
|
||
"empresa": "Spam Inc",
|
||
"consentimiento": "on",
|
||
"website": "https://spam.com",
|
||
},
|
||
follow_redirects=False,
|
||
)
|
||
# Honeypot: return a believable redirect but DON'T persist
|
||
assert response.status_code == 303
|
||
# Verify nothing was saved
|
||
from app.db import get_lead_by_email
|
||
assert get_lead_by_email("bot@spam.com") is None
|
||
|
||
|
||
def test_register_duplicate_email_reissues_token(client):
|
||
for _ in range(2):
|
||
client.post(
|
||
"/register",
|
||
data={
|
||
"nombre": "Felipe",
|
||
"email": "dup@factorit.com",
|
||
"empresa": "FactorIT",
|
||
"consentimiento": "on",
|
||
},
|
||
follow_redirects=False,
|
||
)
|
||
from app.db import get_lead_by_email
|
||
lead = get_lead_by_email("dup@factorit.com")
|
||
assert lead["times_registered"] == 2
|
||
|
||
|
||
def test_descargas_with_invalid_token_redirects_home(client):
|
||
response = client.get("/descargas?token=invalid-junk", follow_redirects=False)
|
||
assert response.status_code == 307
|
||
assert response.headers["location"].startswith("/?error=")
|
||
|
||
|
||
def test_descargas_with_valid_token_renders(client):
|
||
reg = client.post(
|
||
"/register",
|
||
data={
|
||
"nombre": "Maria",
|
||
"email": "maria@test.com",
|
||
"empresa": "Test Co",
|
||
"consentimiento": "on",
|
||
},
|
||
follow_redirects=False,
|
||
)
|
||
token = reg.headers["location"].split("token=")[1]
|
||
response = client.get(f"/descargas?token={token}")
|
||
assert response.status_code == 200
|
||
assert "¡Hola Maria!" in response.text
|
||
assert "Material técnico" in response.text
|
||
assert "Material funcional" in response.text
|
||
|
||
|
||
def test_download_with_valid_token_serves_file(client, tmp_path):
|
||
# Put a fake zip in the configured material dir
|
||
material_dir = Path(os.environ["MATERIAL_DIR"])
|
||
material_dir.mkdir(parents=True, exist_ok=True)
|
||
(material_dir / "taller-wox-tecnico.zip").write_bytes(b"PK\x03\x04 fake zip")
|
||
|
||
reg = client.post(
|
||
"/register",
|
||
data={
|
||
"nombre": "Test",
|
||
"email": "test-dl@test.com",
|
||
"empresa": "TC",
|
||
"consentimiento": "on",
|
||
},
|
||
follow_redirects=False,
|
||
)
|
||
token = reg.headers["location"].split("token=")[1]
|
||
response = client.get(f"/download/taller-wox-tecnico.zip?token={token}")
|
||
assert response.status_code == 200
|
||
assert response.content == b"PK\x03\x04 fake zip"
|
||
assert "attachment" in response.headers["content-disposition"]
|
||
|
||
|
||
def test_download_with_invalid_filename_returns_404(client):
|
||
reg = client.post(
|
||
"/register",
|
||
data={
|
||
"nombre": "Test",
|
||
"email": "bad-fn@test.com",
|
||
"empresa": "TC",
|
||
"consentimiento": "on",
|
||
},
|
||
follow_redirects=False,
|
||
)
|
||
token = reg.headers["location"].split("token=")[1]
|
||
response = client.get(f"/download/../../etc/passwd?token={token}")
|
||
assert response.status_code == 404
|
||
```
|
||
|
||
- [ ] **Step 2: Run tests — expect 404s on all routes**
|
||
|
||
```bash
|
||
pytest tests/test_frontend.py -v
|
||
```
|
||
|
||
Expected: failures because routes don't exist yet.
|
||
|
||
- [ ] **Step 3: Implement `app/frontend.py`**
|
||
|
||
```python
|
||
from pathlib import Path
|
||
|
||
from fastapi import APIRouter, Form, HTTPException, Request
|
||
from fastapi.responses import FileResponse, HTMLResponse, RedirectResponse
|
||
from fastapi.templating import Jinja2Templates
|
||
from itsdangerous import BadSignature, SignatureExpired
|
||
from pydantic import EmailStr
|
||
|
||
from app.config import get_settings
|
||
from app.db import init_db, upsert_lead, log_download
|
||
from app.security import (
|
||
create_download_token,
|
||
verify_download_token,
|
||
is_honeypot_filled,
|
||
)
|
||
|
||
router = APIRouter()
|
||
|
||
_TEMPLATES_DIR = Path(__file__).parent / "templates"
|
||
templates = Jinja2Templates(directory=str(_TEMPLATES_DIR))
|
||
|
||
# Whitelist of downloadable filenames — guards against path traversal
|
||
DOWNLOADS = [
|
||
{
|
||
"filename": "taller-wox-tecnico.zip",
|
||
"icon": "🧩",
|
||
"title": "Material técnico",
|
||
"description": "Specs OpenAPI, configs y artefactos para importar a watsonx Orchestrate.",
|
||
},
|
||
{
|
||
"filename": "taller-wox-funcional.zip",
|
||
"icon": "📚",
|
||
"title": "Material funcional",
|
||
"description": "Manual paso a paso del bootcamp y deck de slides.",
|
||
},
|
||
]
|
||
ALLOWED_FILENAMES = {d["filename"] for d in DOWNLOADS}
|
||
|
||
|
||
# Ensure DB exists at startup (called from main.py)
|
||
def ensure_db_initialized() -> None:
|
||
init_db()
|
||
|
||
|
||
@router.get("/", response_class=HTMLResponse)
|
||
def landing(request: Request, error: str | None = None):
|
||
error_msg = None
|
||
if error == "token-invalido":
|
||
error_msg = "El link expiró o es inválido. Regístrate de nuevo para descargar el material."
|
||
return templates.TemplateResponse(
|
||
"index.html",
|
||
{"request": request, "error": error_msg},
|
||
)
|
||
|
||
|
||
@router.post("/register")
|
||
def register(
|
||
request: Request,
|
||
nombre: str = Form(..., min_length=2, max_length=80),
|
||
email: EmailStr = Form(...),
|
||
empresa: str = Form(..., min_length=2, max_length=100),
|
||
consentimiento: str = Form(...), # presence required
|
||
website: str | None = Form(default=""), # honeypot
|
||
):
|
||
if consentimiento != "on":
|
||
raise HTTPException(status_code=400, detail="Consentimiento requerido")
|
||
|
||
# Honeypot filled → fake-success without persisting
|
||
if is_honeypot_filled(website):
|
||
fake_token = create_download_token(email="honeypot@discarded.local", nombre="x")
|
||
return RedirectResponse(url=f"/descargas?token={fake_token}", status_code=303)
|
||
|
||
client_ip = request.client.host if request.client else None
|
||
user_agent = request.headers.get("user-agent")
|
||
|
||
upsert_lead(
|
||
nombre=nombre,
|
||
email=str(email),
|
||
empresa=empresa,
|
||
ip=client_ip,
|
||
user_agent=user_agent,
|
||
consent=True,
|
||
)
|
||
|
||
token = create_download_token(email=str(email), nombre=nombre)
|
||
return RedirectResponse(url=f"/descargas?token={token}", status_code=303)
|
||
|
||
|
||
@router.get("/descargas", response_class=HTMLResponse)
|
||
def descargas(request: Request, token: str | None = None):
|
||
if not token:
|
||
return RedirectResponse(url="/?error=token-invalido", status_code=307)
|
||
try:
|
||
data = verify_download_token(token)
|
||
except (SignatureExpired, BadSignature):
|
||
return RedirectResponse(url="/?error=token-invalido", status_code=307)
|
||
|
||
settings = get_settings()
|
||
material_dir = Path(settings.material_dir)
|
||
downloads_view = []
|
||
for d in DOWNLOADS:
|
||
path = material_dir / d["filename"]
|
||
downloads_view.append({
|
||
**d,
|
||
"available": path.exists(),
|
||
"size_mb": round(path.stat().st_size / (1024 * 1024), 1) if path.exists() else 0,
|
||
})
|
||
|
||
return templates.TemplateResponse(
|
||
"descargas.html",
|
||
{
|
||
"request": request,
|
||
"nombre": data.get("nombre", "amig@"),
|
||
"token": token,
|
||
"downloads": downloads_view,
|
||
},
|
||
)
|
||
|
||
|
||
@router.get("/download/{filename}")
|
||
def download(request: Request, filename: str, token: str | None = None):
|
||
if not token:
|
||
raise HTTPException(status_code=401, detail="Missing token")
|
||
try:
|
||
data = verify_download_token(token)
|
||
except (SignatureExpired, BadSignature):
|
||
raise HTTPException(status_code=401, detail="Invalid or expired token")
|
||
|
||
if filename not in ALLOWED_FILENAMES:
|
||
raise HTTPException(status_code=404, detail="File not found")
|
||
|
||
path = Path(get_settings().material_dir) / filename
|
||
if not path.exists():
|
||
raise HTTPException(status_code=404, detail="File not available yet")
|
||
|
||
client_ip = request.client.host if request.client else None
|
||
log_download(lead_email=data["email"], filename=filename, ip=client_ip)
|
||
|
||
return FileResponse(
|
||
path=str(path),
|
||
filename=filename,
|
||
media_type="application/zip",
|
||
)
|
||
```
|
||
|
||
- [ ] **Step 4: Wire static mount + frontend router + DB init into `app/main.py`**
|
||
|
||
Replace `app/main.py` with:
|
||
|
||
```python
|
||
from pathlib import Path
|
||
|
||
from fastapi import FastAPI
|
||
from fastapi.middleware.cors import CORSMiddleware
|
||
from fastapi.staticfiles import StaticFiles
|
||
|
||
from app import benefits_api, frontend, reports_api
|
||
from app.config import get_settings
|
||
from app.db import init_db
|
||
|
||
settings = get_settings()
|
||
|
||
app = FastAPI(title="taller-wox.fitlabs.dev", version="1.0.0")
|
||
|
||
app.add_middleware(
|
||
CORSMiddleware,
|
||
allow_origins=["*"],
|
||
allow_credentials=False,
|
||
allow_methods=["GET", "POST"],
|
||
allow_headers=["*"],
|
||
)
|
||
|
||
# Static assets (CSS, JS, images)
|
||
_STATIC_DIR = Path(__file__).parent.parent / "static"
|
||
app.mount("/static", StaticFiles(directory=str(_STATIC_DIR)), name="static")
|
||
|
||
# Routers
|
||
app.include_router(frontend.router)
|
||
app.include_router(benefits_api.router)
|
||
app.include_router(reports_api.router)
|
||
|
||
# Generated reports served as static
|
||
_reports_dir = Path(settings.reports_output_dir)
|
||
_reports_dir.mkdir(parents=True, exist_ok=True)
|
||
app.mount(
|
||
"/api/reports/output",
|
||
StaticFiles(directory=str(_reports_dir)),
|
||
name="reports_output",
|
||
)
|
||
|
||
|
||
@app.on_event("startup")
|
||
def on_startup() -> None:
|
||
init_db()
|
||
|
||
|
||
@app.get("/health")
|
||
def health():
|
||
return {"status": "ok", "base_url": settings.base_url}
|
||
```
|
||
|
||
- [ ] **Step 5: Run tests — expect pass**
|
||
|
||
```bash
|
||
pytest tests/test_frontend.py -v
|
||
```
|
||
|
||
Expected: `9 passed`.
|
||
|
||
- [ ] **Step 6: Full test suite check**
|
||
|
||
```bash
|
||
pytest -v
|
||
```
|
||
|
||
Expected: all tests pass.
|
||
|
||
- [ ] **Step 7: Manual smoke (browser)**
|
||
|
||
```bash
|
||
uvicorn app.main:app --reload --port 8000
|
||
```
|
||
|
||
Open `http://localhost:8000/`. Verify:
|
||
- Landing renders with FIT branding (navy hero, orange CTA)
|
||
- Form has all 4 fields + invisible honeypot
|
||
- Submit redirects to `/descargas?token=...`
|
||
- Descargas page shows 2 cards (both marked "No disponible" since material/ is empty in dev)
|
||
- Hitting `/descargas?token=garbage` redirects home with banner
|
||
|
||
Stop the server.
|
||
|
||
- [ ] **Step 8: Commit**
|
||
|
||
```bash
|
||
git add app/frontend.py app/main.py tests/test_frontend.py
|
||
git commit -m "feat(frontend): landing, registration with honeypot, descargas with token, gated download"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 12: Admin endpoints
|
||
|
||
**Files:**
|
||
- Create: `app/admin.py`
|
||
- Modify: `app/main.py`
|
||
- Create: `tests/test_admin.py`
|
||
|
||
- [ ] **Step 1: Write failing tests**
|
||
|
||
```python
|
||
# tests/test_admin.py
|
||
import base64
|
||
|
||
|
||
def _auth_header(user: str = "testadmin", password: str = "testpass") -> dict:
|
||
token = base64.b64encode(f"{user}:{password}".encode()).decode()
|
||
return {"Authorization": f"Basic {token}"}
|
||
|
||
|
||
def test_admin_leads_requires_auth(client):
|
||
response = client.get("/admin/leads.json")
|
||
assert response.status_code == 401
|
||
|
||
|
||
def test_admin_leads_with_bad_credentials_rejected(client):
|
||
response = client.get("/admin/leads.json", headers=_auth_header(password="wrong"))
|
||
assert response.status_code == 401
|
||
|
||
|
||
def test_admin_leads_json_returns_list(client):
|
||
client.post(
|
||
"/register",
|
||
data={"nombre": "A", "email": "a@a.com", "empresa": "ACo", "consentimiento": "on"},
|
||
)
|
||
response = client.get("/admin/leads.json", headers=_auth_header())
|
||
assert response.status_code == 200
|
||
body = response.json()
|
||
assert isinstance(body, list)
|
||
assert any(l["email"] == "a@a.com" for l in body)
|
||
|
||
|
||
def test_admin_leads_csv_returns_csv(client):
|
||
client.post(
|
||
"/register",
|
||
data={"nombre": "B", "email": "b@b.com", "empresa": "BCo", "consentimiento": "on"},
|
||
)
|
||
response = client.get("/admin/leads.csv", headers=_auth_header())
|
||
assert response.status_code == 200
|
||
assert "text/csv" in response.headers["content-type"]
|
||
body = response.text
|
||
# UTF-8 BOM so Excel respects accents
|
||
assert body.startswith("")
|
||
assert "b@b.com" in body
|
||
assert "nombre,email,empresa" in body
|
||
|
||
|
||
def test_admin_stats_returns_counts(client):
|
||
client.post(
|
||
"/register",
|
||
data={"nombre": "C", "email": "c@c.com", "empresa": "CCo", "consentimiento": "on"},
|
||
)
|
||
response = client.get("/admin/stats", headers=_auth_header())
|
||
assert response.status_code == 200
|
||
body = response.json()
|
||
assert "total_leads" in body
|
||
assert "total_downloads" in body
|
||
assert "downloads_por_archivo" in body
|
||
assert "top_5_empresas" in body
|
||
assert body["total_leads"] >= 1
|
||
```
|
||
|
||
- [ ] **Step 2: Run tests — expect 404**
|
||
|
||
```bash
|
||
pytest tests/test_admin.py -v
|
||
```
|
||
|
||
Expected: all fail (no /admin routes yet).
|
||
|
||
- [ ] **Step 3: Implement `app/admin.py`**
|
||
|
||
```python
|
||
import csv
|
||
import io
|
||
|
||
from fastapi import APIRouter, Depends, Query
|
||
from fastapi.responses import Response
|
||
|
||
from app.db import list_leads, stats
|
||
from app.security import require_admin
|
||
|
||
router = APIRouter(prefix="/admin", tags=["admin"])
|
||
|
||
|
||
@router.get("/leads.json")
|
||
def admin_leads_json(
|
||
_user: str = Depends(require_admin),
|
||
limit: int = Query(100, ge=1, le=1000),
|
||
offset: int = Query(0, ge=0),
|
||
):
|
||
return list_leads(limit=limit, offset=offset)
|
||
|
||
|
||
@router.get("/leads.csv")
|
||
def admin_leads_csv(_user: str = Depends(require_admin)):
|
||
rows = list_leads(limit=10_000, offset=0)
|
||
buf = io.StringIO()
|
||
if rows:
|
||
writer = csv.DictWriter(buf, fieldnames=list(rows[0].keys()))
|
||
writer.writeheader()
|
||
writer.writerows(rows)
|
||
else:
|
||
buf.write("(no leads)\n")
|
||
# UTF-8 BOM so Excel respects accents
|
||
payload = "" + buf.getvalue()
|
||
return Response(
|
||
content=payload,
|
||
media_type="text/csv; charset=utf-8",
|
||
headers={"Content-Disposition": 'attachment; filename="leads.csv"'},
|
||
)
|
||
|
||
|
||
@router.get("/stats")
|
||
def admin_stats(_user: str = Depends(require_admin)):
|
||
return stats()
|
||
```
|
||
|
||
- [ ] **Step 4: Include the router in `app/main.py`**
|
||
|
||
Add to imports:
|
||
```python
|
||
from app import admin
|
||
```
|
||
|
||
After the existing `app.include_router(reports_api.router)`:
|
||
```python
|
||
app.include_router(admin.router)
|
||
```
|
||
|
||
- [ ] **Step 5: Run tests — expect pass**
|
||
|
||
```bash
|
||
pytest tests/test_admin.py -v
|
||
```
|
||
|
||
Expected: `5 passed`.
|
||
|
||
- [ ] **Step 6: Commit**
|
||
|
||
```bash
|
||
git add app/admin.py app/main.py tests/test_admin.py
|
||
git commit -m "feat(admin): leads.json, leads.csv, stats behind basic auth"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 13: Dockerfile, README, gitkeeps
|
||
|
||
**Files:**
|
||
- Create: `Dockerfile`
|
||
- Create: `README.md`
|
||
- Create: `material/.gitkeep`
|
||
|
||
- [ ] **Step 1: Write `Dockerfile`**
|
||
|
||
```dockerfile
|
||
FROM python:3.11-slim
|
||
|
||
WORKDIR /app
|
||
|
||
# System deps for pandas / plotly (minimal)
|
||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||
&& rm -rf /var/lib/apt/lists/*
|
||
|
||
COPY requirements.txt .
|
||
RUN pip install --no-cache-dir -r requirements.txt
|
||
|
||
COPY app/ ./app/
|
||
COPY static/ ./static/
|
||
|
||
# Runtime dirs — overlaid by Coolify persistent volumes in prod
|
||
RUN mkdir -p app/data/reports_output material
|
||
|
||
EXPOSE 8000
|
||
|
||
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
|
||
```
|
||
|
||
- [ ] **Step 2: Write `README.md`**
|
||
|
||
```markdown
|
||
# taller-wox.fitlabs.dev
|
||
|
||
Portal + API para el "Bootcamp Agentic AI con watsonx Orchestrate" de FactorIT.
|
||
|
||
- **Landing público:** `/`
|
||
- **Registro + descargas:** `/register`, `/descargas?token=…`, `/download/{file}?token=…`
|
||
- **API del taller** (consumida por agentes de watsonx Orchestrate):
|
||
- `POST /api/historical-procedures`
|
||
- `POST /api/available-procedures`
|
||
- `GET /api/member-insights`
|
||
- `GET /api/schedule`
|
||
- `POST /api/reports/generate-report`
|
||
- **Admin** (HTTP Basic): `/admin/leads.json`, `/admin/leads.csv`, `/admin/stats`
|
||
|
||
## Local dev
|
||
|
||
```bash
|
||
python3.11 -m venv .venv
|
||
source .venv/bin/activate
|
||
pip install -r requirements.txt
|
||
cp .env.example .env
|
||
# edit .env if needed
|
||
uvicorn app.main:app --reload --port 8000
|
||
```
|
||
|
||
Visit `http://localhost:8000/`.
|
||
|
||
Run tests:
|
||
```bash
|
||
pytest -v
|
||
```
|
||
|
||
## Deploy en Coolify
|
||
|
||
1. Crear nueva aplicación → tipo "Dockerfile" → conectar al repo.
|
||
2. Apuntar `taller-wox.fitlabs.dev` al servidor desde la UI; HTTPS automático con Let's Encrypt.
|
||
3. **Declarar volúmenes persistentes** (UI → Storage):
|
||
- `/app/leads.db` (bind file)
|
||
- `/app/app/data/reports_output` (bind dir)
|
||
- `/app/material` (bind dir)
|
||
4. **Configurar variables de entorno** (UI → Environment):
|
||
- `SECRET_KEY` (string aleatorio largo, ej. `python -c "import secrets; print(secrets.token_urlsafe(48))"`)
|
||
- `ADMIN_USER`, `ADMIN_PASS`
|
||
- `BASE_URL=https://taller-wox.fitlabs.dev`
|
||
- `TOKEN_EXPIRY_HOURS=24`
|
||
5. Subir los 2 ZIPs (`taller-wox-tecnico.zip`, `taller-wox-funcional.zip`) al volumen `/app/material` vía SFTP o el file manager de Coolify. **No requiere redeploy** — los archivos se sirven directo del volumen.
|
||
6. Deploy → Coolify buildea la imagen y arranca el container.
|
||
|
||
### Workflow de actualizaciones
|
||
- **Código** → `git push` → Coolify rebuildea + redeploy.
|
||
- **ZIPs descargables** → subir directo al volumen `/app/material/` → sin redeploy.
|
||
- **Copy de las cards de descarga** → editar `app/frontend.py` (lista `DOWNLOADS`) → push + redeploy.
|
||
|
||
## Spec y diseño
|
||
- Spec original: `SPEC_taller_wox_fitlabs.md`
|
||
- Diseño validado (autoridad): `docs/superpowers/specs/2026-05-12-taller-wox-design.md`
|
||
- Plan de implementación: `docs/superpowers/plans/2026-05-12-taller-wox.md`
|
||
|
||
## Estructura
|
||
|
||
```
|
||
app/
|
||
main.py FastAPI bootstrap
|
||
config.py env vars
|
||
db.py SQLite
|
||
security.py tokens + basic auth
|
||
frontend.py / · /register · /descargas · /download
|
||
benefits_api.py endpoints 1-4
|
||
reports_api.py endpoint 5
|
||
admin.py /admin/*
|
||
data/ datasets de los endpoints
|
||
templates/ jinja2
|
||
static/ CSS, JS, imágenes
|
||
material/ ZIPs descargables (volumen persistente en prod)
|
||
tests/ pytest
|
||
Dockerfile
|
||
```
|
||
```
|
||
|
||
- [ ] **Step 3: Create gitkeep for material/**
|
||
|
||
```bash
|
||
mkdir -p material && touch material/.gitkeep
|
||
```
|
||
|
||
- [ ] **Step 4: Test that `docker build` works**
|
||
|
||
```bash
|
||
docker build -t taller-wox .
|
||
```
|
||
|
||
Expected: clean build, no errors. (If you don't have Docker locally, skip this step — Coolify will catch any build issues.)
|
||
|
||
- [ ] **Step 5: Smoke test in Docker locally (optional)**
|
||
|
||
```bash
|
||
docker run --rm -p 8000:8000 \
|
||
-e SECRET_KEY=local-dev-key \
|
||
-e ADMIN_USER=admin \
|
||
-e ADMIN_PASS=admin \
|
||
-e BASE_URL=http://localhost:8000 \
|
||
taller-wox
|
||
```
|
||
|
||
Visit `http://localhost:8000/health` — should return ok. Stop with Ctrl+C.
|
||
|
||
- [ ] **Step 6: Commit**
|
||
|
||
```bash
|
||
git add Dockerfile README.md material/
|
||
git commit -m "feat(deploy): Dockerfile, README with Coolify instructions, material/ gitkeep"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 14: End-to-end smoke check against SPEC §8
|
||
|
||
Before declaring done, run through the acceptance criteria from the design doc §9. This is a manual checklist task, not code.
|
||
|
||
- [ ] **Step 1: Full test suite green**
|
||
|
||
```bash
|
||
pytest -v
|
||
```
|
||
|
||
Expected: all tests pass.
|
||
|
||
- [ ] **Step 2: Local server up**
|
||
|
||
```bash
|
||
uvicorn app.main:app --reload --port 8000
|
||
```
|
||
|
||
- [ ] **Step 3: Frontend smoke (curl + browser)**
|
||
|
||
```bash
|
||
curl -s http://localhost:8000/ | head -30
|
||
```
|
||
Expected: HTML with "Bootcamp Agentic AI".
|
||
|
||
```bash
|
||
curl -s -X POST http://localhost:8000/register \
|
||
-d "nombre=Test&email=test@test.com&empresa=ACME&consentimiento=on" \
|
||
-i | head -10
|
||
```
|
||
Expected: `HTTP/1.1 303 See Other` and `location: /descargas?token=...`.
|
||
|
||
In a browser, open `http://localhost:8000/` and complete the form. Verify redirect to `/descargas` with personalized greeting and 2 cards.
|
||
|
||
- [ ] **Step 4: API smoke**
|
||
|
||
```bash
|
||
curl -s -X POST http://localhost:8000/api/historical-procedures \
|
||
-H "Content-Type: application/json" \
|
||
-d '{"filters":"[]","group_by":"[]"}' | python3 -m json.tool | head -20
|
||
|
||
curl -s -X POST http://localhost:8000/api/historical-procedures \
|
||
-H "Content-Type: application/json" \
|
||
-d '{"filters":"[{\"column\":\"member_name\",\"operator\":\"equals\",\"value\":\"Charlie Smith\"}]","group_by":"[]"}' | python3 -c "import json,sys; d=json.load(sys.stdin); print('Charlie rows:', len(d['result']))"
|
||
|
||
curl -s http://localhost:8000/api/member-insights | python3 -c "import json,sys; d=json.load(sys.stdin); print('overdue:', len(d['result']['overdue_procedures']))"
|
||
|
||
curl -s http://localhost:8000/api/schedule | python3 -m json.tool
|
||
|
||
curl -s -X POST http://localhost:8000/api/reports/generate-report \
|
||
-H "Content-Type: application/json" \
|
||
-d '{"layout_config":"[\"care_report\"]"}' | python3 -m json.tool
|
||
```
|
||
|
||
Expected:
|
||
- historical_procedures: 50 records, then 5 Charlie rows
|
||
- member_insights: 4 overdue procedures
|
||
- schedule: text starting "Para agendar una cita médica"
|
||
- generate-report: `public_url` ending in `.html`. Open the URL in browser → verify chart + table render.
|
||
|
||
- [ ] **Step 5: Admin smoke**
|
||
|
||
```bash
|
||
curl -s -u admin:admin http://localhost:8000/admin/stats | python3 -m json.tool
|
||
curl -s -u admin:admin http://localhost:8000/admin/leads.csv | head -3
|
||
```
|
||
|
||
Expected: stats JSON with counts, CSV with BOM + headers + the test lead from step 3.
|
||
|
||
- [ ] **Step 6: Stop server**
|
||
|
||
Ctrl+C.
|
||
|
||
- [ ] **Step 7: Push to remote (Coolify auto-deploys)**
|
||
|
||
```bash
|
||
git push origin master
|
||
```
|
||
|
||
Coolify watches the repo and rebuilds automatically. Verify in the Coolify UI that the deploy succeeded.
|
||
|
||
- [ ] **Step 8: Production smoke**
|
||
|
||
After Coolify reports the deploy is live:
|
||
|
||
```bash
|
||
curl -sI https://taller-wox.fitlabs.dev/health
|
||
# Expect 200 + valid HTTPS (no -k flag needed)
|
||
|
||
curl -s https://taller-wox.fitlabs.dev/api/member-insights | python3 -m json.tool | head -10
|
||
# Expect the member object
|
||
```
|
||
|
||
- [ ] **Step 9: Upload the 2 ZIPs to the volume**
|
||
|
||
Felipe: upload `taller-wox-tecnico.zip` and `taller-wox-funcional.zip` to the `/app/material/` volume via Coolify's file manager or SFTP.
|
||
|
||
Verify in browser at `https://taller-wox.fitlabs.dev/` → register → confirm the 2 download cards now show real file sizes and downloads work.
|
||
|
||
- [ ] **Step 10: Watsonx end-to-end (the real test)**
|
||
|
||
Import `openapi-tools-spec.json` in a fresh watsonx Orchestrate agent. Run these prompts:
|
||
|
||
- "¿Estoy atrasado en algún chequeo?" → must call `/api/member-insights`, return the 4 overdue procedures.
|
||
- "¿Cuánto cuesta una resonancia magnética?" → must call `/api/available-procedures` with a contains filter on `procedure`, return MRI results.
|
||
- "Create a care report" (with `openapi-tools-report.json` imported) → must call `/api/reports/generate-report` and return a URL the agent shows back.
|
||
|
||
If all three work, you are done.
|
||
|
||
- [ ] **Step 11: Final commit (notes from smoke testing, if any)**
|
||
|
||
If you discovered any issues during smoke testing and fixed them, commit those fixes here. Otherwise, no commit needed.
|
||
|
||
---
|
||
|
||
## Self-review (run after writing the plan)
|
||
|
||
Confirmed during writing of this plan:
|
||
|
||
**1. Spec coverage:**
|
||
- Design §2 Architecture → Tasks 1, 11 (main.py wiring) ✓
|
||
- Design §3.1 Landing visual → Task 10 (templates) ✓
|
||
- Design §3.2 Form with honeypot + duplicates → Tasks 4, 11 ✓
|
||
- Design §3.3 Descargas with 2 cards → Tasks 10, 11 ✓
|
||
- Design §3.4 Download with whitelist → Task 11 ✓
|
||
- Design §3.5 The 5 API endpoints → Tasks 6, 7, 8, 9 ✓
|
||
- Design §3.6 Admin → Task 12 ✓
|
||
- Design §4.1 SQLite schema with UNIQUE email + times_registered → Task 3 ✓
|
||
- Design §4.2 Persistent volumes → Task 13 README ✓
|
||
- Design §5 Dockerfile + Coolify config → Task 13 ✓
|
||
- Design §6 Synthetic datasets → Task 5 ✓
|
||
- Design §9 Acceptance criteria → Task 14 ✓
|
||
|
||
**2. Placeholder scan:** No TBDs, no "implement later", no vague "handle edge cases" without specifics. All code blocks are complete and runnable. ✓
|
||
|
||
**3. Type consistency:** `upsert_lead`, `get_lead_by_email`, `log_download`, `list_leads`, `stats` are referenced consistently across tasks 3, 11, 12. `create_download_token`/`verify_download_token` referenced consistently across tasks 4 and 11. `require_admin` referenced consistently across tasks 4 and 12. ✓
|
||
|
||
---
|
||
|
||
## Execution Handoff
|
||
|
||
**Plan complete and saved to `docs/superpowers/plans/2026-05-12-taller-wox.md`. Two execution options:**
|
||
|
||
**1. Subagent-Driven (recommended)** — I dispatch a fresh subagent per task, review between tasks, fast iteration. Good for 48h timeline because tasks run in parallel where possible and you get a clean review checkpoint between each.
|
||
|
||
**2. Inline Execution** — Execute tasks in this session using `superpowers:executing-plans`, batch execution with checkpoints for review. More predictable, single context.
|
||
|
||
**Which approach?**
|