Apps Web — Fase 5¶
Bottle · Jinja2 · htmx · Alpine.js · Pico.css / Bulma · Keycloak OIDC
1. Arquitectura general¶
Las apps web de iris son microservicios Bottle independientes que comparten
infraestructura de autenticación y acceso a datos via webui_shared.
src/iris/applications/
├── webui_shared/ # Compartido por todas las apps
│ ├── auth.py # Middleware JWT + flujo OAuth2
│ └── http_client.py # Cliente HTTP para iris.api y PostgREST
├── genomx/ # Bioinformáticos — Pico.css + htmx
├── erm/ # Empleados internos — Pico.css + htmx
└── crm/ # Clientes B2B + pacientes — Bulma
Cada app es autónoma — tiene su propio app.py, sus propios templates
y su propia configuración de auth. Comparten solo webui_shared.
2. webui_shared¶
auth.py¶
Implementa el flujo OAuth2 authorization_code con Keycloak para apps Bottle.
AuthConfig¶
Objeto de configuración central de una app:
auth_config = AuthConfig(
iris_api_url="http://localhost:8001", # iris.api
keycloak_url="https://auth.iris-lib.click",
keycloak_realm="iris",
client_id="backapp-genomics",
client_secret="<secret>",
redirect_uri="http://localhost:8081/auth/callback",
allowed_roles=["bioinformatician", "iris_admin"], # roles permitidos globalmente
cookie_name="iris_token", # nombre de la cookie
cookie_secret="<secret-largo>", # para firmar cookies
)
require_auth — decorador de protección¶
@router.route("/dashboard")
@require_auth(auth_config)
def dashboard():
user = bottle.request.environ["iris_user"] # claims del JWT
return render("dashboard.html", user=user)
# Con roles específicos que sobreescriben allowed_roles global:
@router.route("/admin")
@require_auth(auth_config, roles=["iris_admin"])
def admin():
...
Flujo interno del decorador:
Request
→ leer cookie iris_token
→ si no hay cookie → redirect a Keycloak login
→ validar token via iris.api GET /auth/userinfo
→ si token inválido → intentar refresh via iris.api POST /auth/token
→ si refresh falla → redirect a Keycloak login
→ verificar role del token contra roles permitidos
→ inyectar claims en bottle.request.environ['iris_user']
→ ejecutar handler
handle_callback — intercambio de code por token¶
@app.route("/auth/callback")
def auth_callback():
from iris.applications.webui_shared.auth import handle_callback
code = bottle.request.query.get("code", "")
return handle_callback(auth_config, code, redirect_to="/")
Internamente:
1. Hace POST /auth/token a iris.api con grant_type=authorization_code
2. Guarda access_token y refresh_token en cookies firmadas
3. Redirige al destino
logout¶
@app.route("/logout")
def do_logout():
from iris.applications.webui_shared.auth import logout
return logout(auth_config, redirect_to="http://localhost:8081")
Borra las cookies locales y redirige al endpoint de logout de Keycloak.
Punto sensible — cookies en desarrollo vs producción¶
Las cookies se configuran con secure=True en producción (HTTPS obligatorio)
y secure=False en desarrollo local (HTTP). Esto es crítico — con secure=True
en HTTP el navegador no envía la cookie y el auth entra en loop infinito.
# Desarrollo local (HTTP)
bottle.response.set_cookie(..., secure=False, ...)
# Producción (HTTPS via Caddy)
bottle.response.set_cookie(..., secure=True, ...)
Controlar via variable de entorno:
_SECURE_COOKIES = os.getenv("IRIS_SECURE_COOKIES", "true").lower() == "true"
bottle.response.set_cookie(..., secure=_SECURE_COOKIES, ...)
http_client.py — ApiClient¶
Cliente HTTP sincrónico (httpx) para llamadas autenticadas:
api_client = ApiClient(
iris_api_url="http://localhost:8001",
postgrest_url="https://api.iris-lib.click",
)
# iris.api
user = api_client.get("/users/me", token)
api_client.post("/users/", token, data={...})
api_client.patch("/users/123", token, data={...})
api_client.delete("/users/123", token)
# PostgREST — RLS aplicado automáticamente via JWT
files = api_client.postgrest_get(
"/genomic_files",
token,
select="id,filename,org_id",
order="created_at.desc",
limit=50,
filters={"org_id": "eq.7"},
)
api_client.postgrest_post("/orders", token, data={...})
api_client.postgrest_patch(
"/genomic_files",
token,
data={"status": "reviewed"},
filters={"id": "eq.42"},
)
PostgREST aplica RLS automáticamente según el JWT — la app no necesita filtrar por org_id manualmente, el claim del token lo hace en la BD.
3. Estructura de una app Bottle¶
Cada app sigue el mismo patrón:
<app>/
├── app.py # Configuración, Jinja2 env, instancia Bottle, merge de routers
├── routes/
│ ├── __init__.py
│ ├── <seccion_1>.py # Cada sección es un Bottle() separado
│ └── <seccion_2>.py
├── templates/
│ ├── _base.html # Template base con nav y CSS
│ ├── index.html
│ └── <seccion>/
│ ├── index.html
│ └── _partial.html # htmx partials (prefijo _)
└── static/ # Assets estáticos (si los hay)
app.py — patrón estándar¶
# 1. Leer config del entorno
_CLIENT_SECRET = os.getenv("IRIS_<APP>_CLIENT_SECRET", "")
_APP_URL = os.getenv("IRIS_<APP>_APP_URL", "http://localhost:808X")
# 2. AuthConfig
auth_config = AuthConfig(
iris_api_url=os.getenv("IRIS_API_URL", "http://localhost:8001"),
...
redirect_uri=f"{_APP_URL}/auth/callback",
allowed_roles=[...],
)
# 3. ApiClient
api_client = ApiClient(
iris_api_url=...,
postgrest_url=...,
)
# 4. Jinja2
jinja_env = Environment(
loader=PackageLoader("iris.applications.<app>", package_path="templates"),
autoescape=select_autoescape(["html"]),
)
def render(template_name: str, **context) -> str:
user = bottle.request.environ.get("iris_user", {})
return jinja_env.get_template(template_name).render(user=user, **context)
# 5. App Bottle + rutas de auth
app = bottle.Bottle()
@app.route("/auth/callback")
def auth_callback(): ...
@app.route("/logout")
def do_logout(): ...
@app.route("/health")
def health(): ...
# 6. Merge de routers de cada sección
app.merge(seccion_1.router)
app.merge(seccion_2.router)
Routers por sección¶
Cada sección es un bottle.Bottle() independiente:
router = bottle.Bottle()
@router.route("/dashboard")
@require_auth(auth_config)
def dashboard():
token = bottle.request.get_cookie(
auth_config.cookie_name,
secret=auth_config.cookie_secret,
)
user = bottle.request.environ["iris_user"]
data = api_client.postgrest_get("/tabla", token)
return render("dashboard/index.html", data=data)
4. Templates — convenciones¶
htmx partials¶
Los partials tienen prefijo _ en el nombre del archivo:
templates/
dashboard/
index.html # página completa (extiende _base.html)
_metrics.html # partial — solo el fragmento HTML
_variants.html # partial
Un partial NO extiende _base.html — devuelve solo el fragmento:
<!-- _metrics.html -->
<article>
<h3>Métricas</h3>
{% if metrics %}
<ul>{% for m in metrics %}<li>{{ m }}</li>{% endfor %}</ul>
{% else %}
<p>Sin datos.</p>
{% endif %}
</article>
Invocado desde la página completa con htmx:
<div hx-get="/dashboard/metrics" hx-trigger="load" hx-target="this">
<span aria-busy="true">Cargando...</span>
</div>
Alpine.js para microestado local¶
<div x-data="{ path: '', chrom: 'chr1', start: '', end: '' }">
<input x-model="path" placeholder="Ruta VCF">
<input x-model="chrom" placeholder="Cromosoma">
<button
:hx-get="`/explorer/region?path=${path}&chrom=${chrom}&start=${start}&end=${end}`"
hx-target="#results"
>
Consultar
</button>
</div>
5. Apps concretas¶
BACKAPP genómica (genomx)¶
| Campo | Valor |
|---|---|
| Puerto dev | 8081 |
| URL prod | https://genomics.iris-lib.click |
| Roles | bioinformatician, iris_admin |
| CSS | Pico.css v2 (dark theme) |
| Unique | Única app que importa iris.genomics use cases directamente |
Secciones: explorer · dashboard · pipeline · corrections
La ruta /explorer/region y /explorer/preview usan fetch_region y
filter_variants directamente — sin pasar por iris.api:
from iris.applications.genomics.use_cases.fetch_region import fetch_region
variants = fetch_region(vcf_path, region=region)
return render("explorer/_variants_table.html", variants=variants)
BACKAPP ERM (erm)¶
| Campo | Valor |
|---|---|
| Puerto dev | 8082 |
| URL prod | https://erm.iris-lib.click |
| Roles | employee, iris_admin |
| CSS | Pico.css v2 (dark theme) |
Secciones: employees · orgs · ops · reports
Solo consume iris.api y PostgREST — no toca archivos genómicos.
iris_admin puede crear usuarios via POST /users/ en iris.api.
CRM (crm)¶
| Campo | Valor |
|---|---|
| Puerto dev | 8083 |
| URL prod | https://crm.iris-lib.click |
| Roles | org_member, patient |
| CSS | Bulma v1.0 |
Secciones: patients · org_portal · orders · results
Dos tipos de usuario con navegación diferente:
- patient → ve solo /patients/me y /results
- org_member → ve pacientes de su org, órdenes, resultados, panel de org
El RLS de PostgREST filtra automáticamente — la app no necesita lógica de filtrado por org o por paciente.
6. Variables de entorno por app¶
Compartidas (todas las apps)¶
IRIS_API_URL=http://localhost:8001 # iris.api FastAPI
IRIS_POSTGREST_URL=https://api.iris-lib.click
IRIS_KEYCLOAK_URL=https://auth.iris-lib.click
IRIS_KEYCLOAK_REALM=iris
IRIS_SECURE_COOKIES=false # true en producción (HTTPS)
Por app¶
# genomx
IRIS_GENOMICS_CLIENT_ID=backapp-genomics
IRIS_GENOMICS_CLIENT_SECRET=<secret>
IRIS_GENOMICS_APP_URL=http://localhost:8081
IRIS_GENOMICS_COOKIE_SECRET=<secret-largo>
# erm
IRIS_ERM_CLIENT_ID=backapp-erm
IRIS_ERM_CLIENT_SECRET=<secret>
IRIS_ERM_APP_URL=http://localhost:8082
IRIS_ERM_COOKIE_SECRET=<secret-largo>
# crm
IRIS_CRM_CLIENT_ID=crm
IRIS_CRM_CLIENT_SECRET=<secret>
IRIS_CRM_APP_URL=http://localhost:8083
IRIS_CRM_COOKIE_SECRET=<secret-largo>
7. Arranque en desarrollo¶
Cada app en una terminal separada:
# Terminal 1 — iris.api (requerido por todas las apps)
uv run uvicorn iris.applications.api.main:app --reload --port 8001
# Terminal 2 — BACKAPP genómica
env $(cat .env.genomics | xargs) uv run python3 -c "
import bottle
from iris.applications.genomx.app import app
bottle.run(app, host='0.0.0.0', port=8081, debug=True)
"
# Terminal 3 — BACKAPP ERM
env $(cat .env.erm | xargs) uv run python3 -c "
import bottle
from iris.applications.erm.app import app
bottle.run(app, host='0.0.0.0', port=8082, debug=True)
"
# Terminal 4 — CRM
env $(cat .env.crm | xargs) uv run python3 -c "
import bottle
from iris.applications.crm.app import app
bottle.run(app, host='0.0.0.0', port=8083, debug=True)
"
8. Puntos sensibles¶
Cookie secure en desarrollo — con HTTP local, secure=True hace que el
navegador no envíe la cookie, causando loop infinito de redirects. Siempre
usar secure=False en desarrollo y secure=True en producción.
Loop de redirects — ocurre cuando el token se guarda pero no se lee.
Causas comunes: secure=True en HTTP, cookie_secret diferente entre
escritura y lectura, o samesite incompatible con el navegador.
auth_config se instancia al importar — las variables de entorno deben
estar cargadas antes de importar app.py. Usar env $(cat .env | xargs)
al arrancar, o cargar .env con python-dotenv en el entrypoint.
bottle.Bottle() por sección — cada router es una instancia separada
mergeada en el app principal. Esto permite organizar rutas en módulos sin
conflictos de nombres.
Jinja2 PackageLoader — requiere que el paquete esté instalado o en el
PYTHONPATH. Con uv run y el proyecto en modo editable esto funciona
automáticamente. En producción asegurarse de instalar con uv pip install -e ..
PostgREST y RLS — la app no filtra datos manualmente. El JWT en el header
Authorization es suficiente para que PostgREST aplique las policies correctas.
Si una query devuelve vacío inesperadamente, verificar que el token tiene los
claims correctos (org_id, role, sub_role).
Restart de PostgREST tras DDL — cualquier CREATE TABLE, CREATE POLICY
o GRANT requiere systemctl restart postgrest-iris para que PostgREST
recargue el schema cache.