Files
taller-wox/app/frontend.py
farentsen a062b45c51 feat: initial implementation taller-wox.fitlabs.dev
Portal FastAPI + 5 endpoints REST para Bootcamp Agentic AI con
watsonx Orchestrate (FactorIT). Single container, Coolify-ready.

- Landing brandeado FIT con formulario de registro (honeypot anti-bot)
- Tokens itsdangerous para descargas (24h expiry)
- 5 endpoints API: historical/available procedures, member-insights,
  schedule, generate-report (Jinja2 + Plotly)
- SQLite con upsert-on-email para leads + log de descargas
- Admin endpoints (HTTP Basic): leads.json, leads.csv, stats
- 23 tests pytest pasando
- Dockerfile listo para Coolify con volúmenes persistentes
  (/app/leads.db, /app/app/data/reports_output, /app/material)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 03:04:28 +00:00

137 lines
4.4 KiB
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 log_download, upsert_lead
from app.security import (
create_download_token,
is_honeypot_filled,
verify_download_token,
)
router = APIRouter()
_TEMPLATES_DIR = Path(__file__).parent / "templates"
templates = Jinja2Templates(directory=str(_TEMPLATES_DIR))
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}
@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(...),
website: str | None = Form(default=""),
):
if consentimiento != "on":
raise HTTPException(status_code=400, detail="Consentimiento requerido")
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",
)