Skip to content

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.pyApiClient

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.