diff --git a/.claude/commands/meta_bigq.md b/.claude/commands/meta_bigq.md index 189b70ef..acf0a03f 100644 --- a/.claude/commands/meta_bigq.md +++ b/.claude/commands/meta_bigq.md @@ -109,6 +109,177 @@ metabase_update_dashboard(client, dash["id"], dashcards=[ **Filtros de list_dashboards:** `all`, `mine`, `archived` +### Dashboards — helpers compositivos (añadir KPIs a dashboard existente) + +Helpers para el flujo tipico "anadir N cards (KPI) al final de un tab existente reusando los mismos filtros que otro card vecino". Evitan los gotchas: replicar `parameter_mappings`, calcular `row` libre, escapado raro de `column_settings`, generacion de `lib/uuid` en MBQL. + +```python +from metabase import ( + metabase_mbql_from_source_card, + metabase_copy_dashcard_mappings, + metabase_dashboard_next_row, + metabase_dashboard_append_row, + metabase_viz_column_format, + metabase_smartscalar_anothercolumn_viz, +) +``` + +#### `metabase_mbql_from_source_card` + +Construye `dataset_query` MBQL sobre una saved-card (`source-card`), con aggregations + joins + filters + breakouts + segunda stage de expressions. Genera `lib/uuid` automatico en cada nodo. + +```python +dq = metabase_mbql_from_source_card( + database_id=6, + source_card_id=5305, + aggregations=[ + {"op": "sum", "field": "PrecioVenta", "base_type": "type/Decimal"}, + {"op": "sum", "field": "PrecioCompra", "base_type": "type/Decimal"}, + {"op": "sum", "field": "PrecioTasas", "base_type": "type/Float"}, + ], + joins=[ + {"alias": "Centros - idCentro", "source_card_id": 4076, + "fields": "none", "local_field": "idCentro", "local_base_type": "type/Text", + "foreign_field_id": 17316, "foreign_base_type": "type/Text"}, + ], + filters=[["not-empty", {}, ["field", {"base-type": "type/Text"}, + "Centros - idCentro__Companies__name"]]], + expressions=[ + {"name": "MasadeMargen", "expr": + {"op": "-", "args": [{"field": "sum"}, + {"op": "+", "args": [{"field": "sum_2"}, {"field": "sum_3", "base_type": "type/Float"}]}]}}, + {"name": "Margen", "expr": + {"op": "coalesce", "args": [ + {"op": "/", "args": [ + {"op": "-", "args": [{"field": "sum"}, + {"op": "+", "args": [{"field": "sum_2"}, {"field": "sum_3", "base_type": "type/Float"}]}]}, + {"field": "sum"}]}, + 0]}}, + ], +) +``` + +Ops soportadas en expressions: `+`, `-`, `*`, `/`, `coalesce`, `case`. Referencia a otra expresion en la misma stage: `{"ref": "Margen"}`. Aliases de aggregations son posicionales: `sum`, `sum_2`, `sum_3`... (orden = declaracion). + +#### `metabase_copy_dashcard_mappings` + +Copia los `parameter_mappings` de un dashcard "donante" a un card nuevo. Devuelve lista lista para pegar en `dashcards_add`. + +```python +mappings = metabase_copy_dashcard_mappings( + client, + dashboard_id=734, + source_card_id=9918, # card donante con 18 filtros mapeados + dest_card_id=9947, # card destino nueva +) +# Devuelve [{"parameter_id","card_id","target"}, ...] con card_id=9947 +``` + +#### `metabase_dashboard_next_row` + +Calcula el primer `row` libre al final de un tab. + +```python +row = metabase_dashboard_next_row(client, dashboard_id=734, tab_id=191) +# row=12 si el ultimo card termina en row+size_y=12 +# tab_id=0 → dashboards sin tabs +``` + +#### `metabase_dashboard_append_row` + +Combo: append N cards en una fila horizontal al final del tab, copiando mappings de un donante. Una sola llamada hace `next_row` + grid math + `copy_mappings` + `update_dashboard_safe`. + +```python +metabase_dashboard_append_row( + client, + dashboard_id=734, + tab_id=191, + card_ids=[9947, 9948, 9949], + height=4, + donor_card_id=9918, # mismos 18 filtros del dashboard + grid_width=24, # default Metabase v0.59 +) +# Coloca 3 cards de size_x=8 en row=next, cols 0/8/16, con mappings copiados +``` + +#### `metabase_viz_column_format` + +Construye una entrada de `column_settings` con la clave JSON-escaped (`'["name","Margen"]'`) sin tener que recordar el formato exacto. + +```python +metabase_viz_column_format("Margen", number_style="percent", decimals=2) +# {'["name","Margen"]': {"number_style": "percent", "decimals": 2}} + +metabase_viz_column_format("MasadeMargen", number_style="currency", + currency="EUR", decimals=0, currency_in_header=False) +# {'["name","MasadeMargen"]': {...}} +``` + +Mergea varios resultados en `column_settings` de las visualization_settings. + +#### `metabase_smartscalar_anothercolumn_viz` + +Construye `visualization_settings` completo para `display=smartscalar` con comparativa tipo `anotherColumn` (compara dos columnas de la misma fila — no requiere breakout temporal). + +```python +viz = metabase_smartscalar_anothercolumn_viz( + main_column="Margen", + compare_column="Margen_N1", + label="vs N-1", + number_style="percent", + decimals=2, +) +# Setear en /api/card via PUT visualization_settings=viz +``` + +**⚠ Gotcha smartscalar Metabase v0.59:** el visualization solo acepta `type: "anotherColumn"` cuando la query NO produce filas multiples. Si Metabase muestra el error *"Agrupa solo por un campo de tiempo para ver como ha cambiado con el tiempo"*, hace falta un **breakout temporal** en la MBQL (ej. `breakouts=[{"field":"fecha","base_type":"type/Date","temporal_unit":"month"}]`) y usar el comparison `previousValue` en lugar de `anotherColumn`. Alternativa: `metabase_smartscalar_kpi_sql` + `metabase_smartscalar_kpi_payload` (patron 2-row nativo) si la card es SQL nativo. + +#### Patron canonico — anadir 3 KPI cards a tab existente + +```python +import os, sys +sys.path.insert(0, "python/functions") +from metabase import ( + MetabaseClient, metabase_create_card, metabase_mbql_from_source_card, + metabase_dashboard_append_row, metabase_viz_column_format, + metabase_smartscalar_anothercolumn_viz, +) + +c = MetabaseClient("https://reports.autingo.es", os.environ["MB_API_KEY"]) + +# 1) MBQL reusando una saved-card como source +def query(): + return metabase_mbql_from_source_card( + database_id=6, source_card_id=5305, + aggregations=[ + {"op":"sum","field":"PrecioVenta","base_type":"type/Decimal"}, + {"op":"sum","field":"PrecioCompra","base_type":"type/Decimal"}, + {"op":"sum","field":"PrecioTasas","base_type":"type/Float"}, + ], + # joins/filters/expressions ... + ) + +# 2) Crear cards +card1 = metabase_create_card(c, "Masa de Margen", query(), + display="scalar", collection_id=500) +viz1 = {"scalar.field": "MasadeMargen", + "column_settings": metabase_viz_column_format( + "MasadeMargen", number_style="currency", currency="EUR", decimals=0)} +c._http.request("PUT", f"/api/card/{card1['id']}", json={"visualization_settings": viz1}) + +card2 = metabase_create_card(c, "Margen", query(), display="smartscalar", collection_id=500) +viz2 = metabase_smartscalar_anothercolumn_viz( + main_column="Margen", compare_column="Margen_N1", number_style="percent", decimals=2) +c._http.request("PUT", f"/api/card/{card2['id']}", json={"visualization_settings": viz2}) + +# 3) Append fila al tab con mappings copiados del donante +metabase_dashboard_append_row( + c, dashboard_id=734, tab_id=191, + card_ids=[card1["id"], card2["id"]], + height=4, donor_card_id=9918, +) +``` + ### Documents (ProseMirror) Los "documents" son páginas narrativas editables con texto rico y cards embebidas. **No hay helpers en fn_registry todavía** — usa el endpoint REST directamente a través de `client._http`. diff --git a/.mcp.json b/.mcp.json index e4e39e52..f84624b5 100644 --- a/.mcp.json +++ b/.mcp.json @@ -1,10 +1,10 @@ { "mcpServers": { "registry": { - "command": "/home/lucas/fn_registry/apps/registry_mcp/registry_mcp", + "command": "/home/egutierrez/fn_registry/apps/registry_mcp/registry_mcp", "args": ["--enable-run", "--enable-write"], "env": { - "FN_REGISTRY_ROOT": "/home/lucas/fn_registry" + "FN_REGISTRY_ROOT": "/home/egutierrez/fn_registry" } } } diff --git a/dev/issues/0085-registry-call-standardization-and-usage-tracking.md b/dev/issues/0085-registry-call-standardization-and-usage-tracking.md new file mode 100644 index 00000000..1a32b343 --- /dev/null +++ b/dev/issues/0085-registry-call-standardization-and-usage-tracking.md @@ -0,0 +1,141 @@ +--- +id: 0085 +title: Estandarizar llamadas a funciones del registry desde Claude + app de monitorizacion de uso +status: pending +priority: high +created: 2026-05-13 +related: [0068, 0069] +--- + +## Contexto + +Claude actualmente invoca funciones del registry de formas heterogeneas y sin trazabilidad: + +| Patron de invocacion | Frecuencia esta sesion (meta_bigq) | Trazabilidad | +|---|---|---| +| Heredoc Python inline (`python/.venv/bin/python3 - <<'PYEOF' ... PYEOF`) | ~15 veces | ninguna (queda en transcript) | +| Bash inline con `sqlite3 registry.db "..."` | 1 (violando regla MCP-first) | ninguna | +| `./fn run ` CLI | 0 | log en stdout, no persistido | +| `mcp__registry__fn_run` MCP tool | 0 | mensaje tool-result | +| `mcp__registry__fn_search/show/code` | 0 (deberia haber sido obligatorio) | ninguna | +| Imports directos `from metabase import ...` en heredoc | en cada heredoc | ninguna | +| `client._http.request(...)` directo saltando funciones del registry | varias veces (para PUT custom como result_metadata) | ninguna | + +Consecuencias: +- **No sabemos que funciones del registry usa Claude realmente**. Hay ~1200 funciones indexadas pero solo unas pocas se usan en cada sesion. Imposible decidir cuales deprecar, cuales mejorar, cuales son criticas. +- **Cada sesion reinventa boilerplate**. Patrones repetitivos (refresh `result_metadata`, dispatch `dimension` vs `variable` mapping, batch param config) se reescriben inline. Si se extraen como funciones del registry nadie lo nota porque no hay metricas de "esto se repite mucho". +- **CLAUDE.md tiene reglas (MCP-first, registry-first) que se violan silenciosamente**. Sin telemetria, la regla es aspiracional. +- **No hay datos para que el bucle reactivo mejore el registry**. El analizador/mejorador deberian saber "esta funcion se uso 50 veces, esta 0 veces, esta fallo 8 veces" — info que ahora mismo no existe. + +## Objetivo + +1. **Estandarizar** como Claude invoca funciones del registry (un patron canonico por caso de uso). +2. **Instrumentar** todas las invocaciones para que dejen rastro en una BD local. +3. **App `claude_call_monitor`** (TUI o web) que muestra uso por funcion, latencia, errores, patrones repetidos, violaciones de reglas. +4. **Feedback al registry**: marcar funciones "huerfanas", proponer extracciones cuando un mismo bloque inline se repite N veces, sugerir helpers nuevos. + +## Diseno + +### Fase 1 — Estandarizacion de invocaciones + +Tres patrones canonicos. Cada uno con su tool de entrada y formato de log. + +| Caso de uso | Patron canonico | Cuando usar | +|---|---|---| +| **Inspeccion del registry** (buscar, leer, ver dependencias) | `mcp__registry__fn_search/show/code/uses` | SIEMPRE. Reemplaza `sqlite3 registry.db "..."` inline. CLAUDE.md ya lo exige; ahora se hace cumplir via lint del log | +| **Ejecucion de pipeline/funcion 1-shot** | `mcp__registry__fn_run [args]` o `./fn run [args]` | Cuando hay UNA funcion/pipeline a lanzar con sus args. Salida estructurada | +| **Composicion ad-hoc multi-funcion** | Heredoc Python via Bash, importando del registry | Cuando hay logica intermedia (loops, conditionals, dispatch). Esta sesion casi todo el trabajo cae aqui | + +Para composiciones que se repiten: extraer a `python/functions/pipelines/` o a una funcion del registry. Decision basada en datos del monitor (ver fase 3). + +### Fase 2 — Instrumentacion + +Hook + libreria que captura cada invocacion. Stack propuesto: + +**2a. Hook en Bash tool**: parsea cada comando Bash. Si contiene heredoc Python `python/.venv/bin/python3` o invoca `./fn run` o `mcp__registry__fn_*`, captura: +- timestamp_start, timestamp_end, duration_ms +- session_id (del log de Claude Code) +- tool_used (Bash heredoc / fn_run / mcp_fn_X / mcp_fn_search / sqlite_direct / etc.) +- functions_imported (parse `from import `) +- functions_called (mejor esfuerzo: regex sobre el codigo del heredoc + para fn_run el id explicito) +- success / exit_code / error_snippet +- patron_detected (refresh_metadata, build_mappings, etc — clasificadores configurables) + +Implementacion: hook `PostToolUse` en `~/.claude/settings.json` que llama a un binario Go que escribe en `~/.claude/projects//call_monitor.db` (SQLite). + +**2b. Wrapper Python**: `from registry_telemetry import wrap` que parchea las funciones del paquete `metabase`/`bigquery`/etc al importarse en heredoc. Cada llamada se loguea (function_id, args_hash, duration, success). Solo se activa si env var `FN_TELEMETRY=1` (no romper otros usos). + +**2c. Hook directo en MCP `registry`**: si el server MCP esta bajo nuestro control, anadir logging en cada tool call (mas confiable que parsear bash). + +Las 3 fuentes se cruzan: si una sesion tiene Bash heredoc usando `metabase_get_dashboard` pero no aparece en MCP logs, es violacion (deberia haber usado `mcp__registry__fn_show` para inspeccionar antes). + +### Fase 3 — App `claude_call_monitor` + +App standalone en `apps/claude_call_monitor/` o `projects/fn_monitoring/apps/claude_call_monitor/`. Stack: +- Backend Go (sirve datos de `call_monitor.db` + agregados) +- Frontend React + Mantine (consume `@fn_library`) o TUI con `cpp/framework` ImGui — segun preferencia + +Vistas minimas: + +1. **Top funciones por uso** — tabla rankada: `function_id, calls_24h, calls_7d, mean_duration_ms, error_rate, last_used_at`. Filtros por dominio/lang/purity. +2. **Funciones huerfanas** — listado de funciones del registry con `calls_30d = 0`. Cruzado con `fn doctor unused` para distinguir "nunca usada" vs "no usada por Claude pero si por humanos". +3. **Patrones repetidos** — clusterizacion de heredocs Python por similitud. Detecta cuando un bloque inline se repite N veces → proposal automatico para extraer a funcion. +4. **Violaciones de regla** — usos de `sqlite3 registry.db` directo, uso de `Centros_ISO_Limpio` cuando deberia ser via card snippet, etc. Reglas configurables en YAML. +5. **Sesion view** — timeline de una sesion (Claude Code session_id) con todas las llamadas, errores, duraciones. Util para post-mortem. +6. **Health score** — score 0-100 por sesion: ratio de invocaciones canonicas vs ad-hoc, errores, repeticiones. Telemetria para mejorar prompts del agente. + +### Fase 4 — Feedback al registry + +Hooks de salida del monitor: + +- **Proposals automaticas**: cuando un patron inline se repite >5 veces en distintas sesiones, se crea proposal `new_function` en `registry.db` con evidencia (lista de session_ids + snippet representativo). El humano (o `fn-mejorador`) decide si aprobar. +- **Deprecation candidates**: funciones con `calls_90d = 0` y sin `uses_functions` upstream → proposal `deprecate_function`. +- **Performance regressions**: funciones cuyo `mean_duration_ms` crece >50% entre semanas → flag al humano. + +## Implementacion por pasos + +| Paso | Tarea | Sub-issue | +|---|---|---| +| 1 | Migracion `call_monitor.db` schema (`calls`, `sessions`, `patterns`, `violations`) | 0085a | +| 2 | Hook Bash `PostToolUse` que parsea comandos y escribe a `calls` | 0085b | +| 3 | Wrapper Python opcional con `FN_TELEMETRY=1` | 0085c | +| 4 | App `claude_call_monitor` skeleton (Go API + frontend) | 0085d | +| 5 | Vistas: top usage, huerfanas, sesiones | 0085e | +| 6 | Clusterizacion de heredocs + deteccion de patrones | 0085f | +| 7 | Reglas de violacion configurables (YAML) | 0085g | +| 8 | Pipeline `fn-monitor proposal` que crea proposals automaticas en `registry.db` desde patrones detectados | 0085h | +| 9 | `e2e_checks` para la propia app del monitor | 0085i | +| 10 | Documentacion en CLAUDE.md: patrones canonicos + como leer el monitor | 0085j | + +## Criterios de exito + +- Todas las invocaciones de funciones del registry desde Claude quedan registradas (>95% cobertura medida cruzando 3 fuentes). +- App `claude_call_monitor` muestra top-20 funciones usadas por Claude en los ultimos 7 dias con metricas reales. +- Se detectan al menos 5 patrones repetidos como candidatos a extraccion (con evidencia trazable). +- Se identifican >50 funciones huerfanas para decision (deprecar/promover/dejar). +- CLAUDE.md tiene seccion "Como llamar a funciones del registry" con los 3 patrones canonicos + tabla "cuando usar cual". +- El bucle reactivo (0068) tiene un nuevo input: assertions sobre uso de funciones → proposals. + +## Anti-patrones a prohibir explicitamente + +| Patron | Por que | Alternativa | +|---|---|---| +| `sqlite3 registry.db "SELECT ..."` para inspeccionar funciones | Salta MCP, no hay logging, FTS5 gotchas | `mcp__registry__fn_search "..."` | +| `python -c "import metabase; print(dir(metabase))"` para descubrir helpers | Salta el registry como fuente de verdad | `mcp__registry__fn_search "metabase"` + `mcp__registry__fn_show ` | +| Heredoc que reescribe logica que ya existe como funcion | Reinvento + perdida de capitalizacion | Primero `fn_search`, luego importar | +| `client._http.request(...)` directo cuando hay un wrapper del registry | Salta validacion y telemetria del wrapper | Usar la funcion del registry; si falta una, delegar a `fn-constructor` | +| Crear scripts en `temp/` o paths sueltos cuando es composicion repetida | Codigo se pierde, no se monitoriza | Si patron se repite → pipeline en `python/functions/pipelines/` | + +## Stakeholders + +- **Usuario humano (Lucas / Emanuel)**: revisa proposals automaticas, prioriza extracciones, decide deprecaciones. +- **Claude (agente principal)**: lee CLAUDE.md actualizado, usa patrones canonicos, recibe feedback de monitor en CLAUDE.md (top huerfanas, top errores). +- **fn-mejorador (fase 5 bucle reactivo)**: consume `call_monitor.db` para generar proposals con evidencia real de uso. +- **fn-orquestador (issue 0069)**: usa health score del monitor como criterio adicional de exito. + +## Notas + +- Esta issue no requiere refactorizar las 1200 funciones existentes — solo capturar como se invocan. +- El monitor empieza pasivo (solo loguea). En una fase posterior puede bloquear violaciones criticas (ej. `sqlite3 registry.db` directo aborta con mensaje + sugerencia). +- Datos sensibles: el `args_hash` se guarda pero los valores concretos NO. Para queries SQL que contienen secretos por accidente, mantener allowlist de redaccion. +- Compatible con el patron de `task_runs` del issue 0069 — comparten el concepto de "ejecucion trazable". diff --git a/python/functions/metabase/__init__.py b/python/functions/metabase/__init__.py index 7d1fa75b..31fd6c50 100644 --- a/python/functions/metabase/__init__.py +++ b/python/functions/metabase/__init__.py @@ -12,8 +12,14 @@ from .permissions import metabase_list_groups, metabase_get_group, metabase_crea from .setup import metabase_setup from .maintenance import metabase_fix_null_ratio, metabase_pair_n_n1_columns from .metabase_mbql_validate import metabase_mbql_validate +from .metabase_mbql_from_source_card import metabase_mbql_from_source_card from .metabase_update_dashboard_safe import metabase_update_dashboard_safe +from .metabase_copy_dashcard_mappings import metabase_copy_dashcard_mappings +from .metabase_dashboard_next_row import metabase_dashboard_next_row from .smartscalar import metabase_smartscalar_kpi_sql, metabase_smartscalar_dimension_tag, metabase_smartscalar_kpi_payload +from .metabase_viz_column_format import metabase_viz_column_format +from .metabase_smartscalar_anothercolumn_viz import metabase_smartscalar_anothercolumn_viz +from .metabase_dashboard_append_row import metabase_dashboard_append_row __all__ = [ "MetabaseClient", @@ -38,6 +44,12 @@ __all__ = [ "metabase_fix_null_ratio", "metabase_pair_n_n1_columns", "metabase_mbql_validate", + "metabase_mbql_from_source_card", "metabase_update_dashboard_safe", + "metabase_copy_dashcard_mappings", + "metabase_dashboard_next_row", "metabase_smartscalar_kpi_sql", "metabase_smartscalar_dimension_tag", "metabase_smartscalar_kpi_payload", + "metabase_viz_column_format", + "metabase_smartscalar_anothercolumn_viz", + "metabase_dashboard_append_row", ] diff --git a/python/functions/metabase/metabase_copy_dashcard_mappings.md b/python/functions/metabase/metabase_copy_dashcard_mappings.md new file mode 100644 index 00000000..7e08430d --- /dev/null +++ b/python/functions/metabase/metabase_copy_dashcard_mappings.md @@ -0,0 +1,70 @@ +--- +name: metabase_copy_dashcard_mappings +kind: function +lang: py +domain: infra +version: "1.0.0" +purity: impure +signature: "def metabase_copy_dashcard_mappings(client: MetabaseClient, *, dashboard_id: int, source_card_id: int, dest_card_id: int) -> list[dict]" +description: "Copia los parameter_mappings del primer dashcard con source_card_id al card destino (dest_card_id), devolviendo una lista nueva de mappings sin mutar el original. Util para replicar filtros de dashboard a cards nuevas sin copiar manualmente cada mapping." +tags: [metabase, dashboard, parameter-mappings, dashcard, copy, api, python] +uses_functions: + - metabase_get_dashboard_py_infra +uses_types: [] +returns: [] +returns_optional: false +error_type: "error_go_core" +imports: [] +params: + - name: client + desc: "MetabaseClient autenticado con sesion activa" + - name: dashboard_id + desc: "ID del dashboard que contiene los dashcards fuente y destino" + - name: source_card_id + desc: "card_id del dashcard del que se copian los parameter_mappings" + - name: dest_card_id + desc: "card_id que se asigna como card_id en cada mapping copiado" +output: "list[dict]: parameter_mappings adaptados al card destino, cada uno con {parameter_id, card_id: dest_card_id, target}. Lista vacia si el dashcard fuente no tiene mappings." +tested: false +tests: [] +test_file_path: "" +file_path: "python/functions/metabase/metabase_copy_dashcard_mappings.py" +--- + +## Ejemplo + +```python +from metabase import MetabaseClient, metabase_copy_dashcard_mappings + +client = MetabaseClient("https://metabase.example.com", "token...") + +# Copiar los 18 filtros de la card 42 a la card nueva 99 +mappings = metabase_copy_dashcard_mappings( + client, + dashboard_id=10, + source_card_id=42, + dest_card_id=99, +) + +# Usar los mappings al añadir el nuevo dashcard +new_dashcard = { + "id": -1, + "card_id": 99, + "col": 0, "row": 20, "size_x": 6, "size_y": 4, + "parameter_mappings": mappings, + "visualization_settings": {}, +} +``` + +## Notas + +- Localiza el **primer** dashcard con `card_id == source_card_id`. Si la misma card + aparece varias veces en el dashboard (e.g. en distintas tabs), solo se usan los + mappings del primero que encuentre. +- Lanza `ValueError` si `source_card_id` no aparece en el dashboard — falla rápido + en vez de devolver lista vacía silenciosamente. +- No muta ni el dashboard ni los mappings originales: cada dict del resultado es + construido desde cero con las tres claves que acepta la API + (`parameter_id`, `card_id`, `target`). +- Requiere 1 request HTTP (GET dashboard). Para aplicar los mappings al dashboard + usar `metabase_update_dashboard_safe` con `dashcards_add`. diff --git a/python/functions/metabase/metabase_copy_dashcard_mappings.py b/python/functions/metabase/metabase_copy_dashcard_mappings.py new file mode 100644 index 00000000..4fe074a0 --- /dev/null +++ b/python/functions/metabase/metabase_copy_dashcard_mappings.py @@ -0,0 +1,75 @@ +"""Copia parameter_mappings de un dashcard a otro dentro del mismo dashboard.""" + +from __future__ import annotations + +from .client import MetabaseClient +from .dashboards import metabase_get_dashboard + + +def metabase_copy_dashcard_mappings( + client: MetabaseClient, + *, + dashboard_id: int, + source_card_id: int, + dest_card_id: int, +) -> list[dict]: + """Copia los parameter_mappings del primer dashcard fuente al card destino. + + Obtiene el dashboard, localiza el primer dashcard cuyo card_id coincide con + source_card_id y devuelve una lista nueva de parameter_mappings con card_id + sustituido por dest_card_id. No muta el dashboard ni los mappings originales. + + Args: + client: Cliente autenticado con sesion activa. + dashboard_id: ID del dashboard que contiene ambas cards. + source_card_id: card_id del dashcard del que se copian los mappings. + dest_card_id: card_id que se asigna en los mappings copiados. + + Returns: + Lista de dicts con los parameter_mappings adaptados al card destino. + Cada elemento tiene: parameter_id, card_id (dest_card_id), target. + Retorna lista vacia si el dashcard fuente no tiene mappings. + + Raises: + ValueError: Si source_card_id no aparece en ninguna dashcard del dashboard. + + Example: + >>> mappings = metabase_copy_dashcard_mappings( + ... client, + ... dashboard_id=10, + ... source_card_id=42, + ... dest_card_id=99, + ... ) + >>> # Usar los mappings al añadir el nuevo dashcard al dashboard + >>> new_dashcard = { + ... "id": -1, + ... "card_id": 99, + ... "col": 0, "row": 20, "size_x": 6, "size_y": 4, + ... "parameter_mappings": mappings, + ... "visualization_settings": {}, + ... } + """ + dashboard = metabase_get_dashboard(client, dashboard_id) + dashcards: list[dict] = dashboard.get("dashcards") or [] + + source_dc: dict | None = None + for dc in dashcards: + if dc.get("card_id") == source_card_id: + source_dc = dc + break + + if source_dc is None: + raise ValueError( + f"card {source_card_id} not in dashboard {dashboard_id}" + ) + + source_mappings: list[dict] = source_dc.get("parameter_mappings") or [] + + return [ + { + "parameter_id": m["parameter_id"], + "card_id": dest_card_id, + "target": m["target"], + } + for m in source_mappings + ] diff --git a/python/functions/metabase/metabase_dashboard_append_row.md b/python/functions/metabase/metabase_dashboard_append_row.md new file mode 100644 index 00000000..001b873a --- /dev/null +++ b/python/functions/metabase/metabase_dashboard_append_row.md @@ -0,0 +1,103 @@ +--- +name: metabase_dashboard_append_row +kind: function +lang: py +domain: infra +version: "1.0.0" +purity: impure +signature: "def metabase_dashboard_append_row(client: MetabaseClient, *, dashboard_id: int, tab_id: int, card_ids: list[int], height: int = 4, donor_card_id: int = 0, grid_width: int = 24) -> dict" +description: "Añade N cards como fila horizontal al final de una tab de dashboard. Calcula la primera fila libre con metabase_dashboard_next_row, distribuye las cards con ancho uniforme (grid_width // N), copia parameter_mappings de un dashcard donante si se indica y llama a metabase_update_dashboard_safe. Util para añadir filas de KPIs con los filtros del dashboard ya conectados." +tags: [metabase, dashboard, dashcard, layout, row, append, parameter-mappings, api, python] +uses_functions: + - metabase_dashboard_next_row_py_infra + - metabase_copy_dashcard_mappings_py_infra + - metabase_update_dashboard_safe_py_infra +uses_types: [] +returns: [] +returns_optional: false +error_type: "error_go_core" +imports: [] +params: + - name: client + desc: "MetabaseClient autenticado con sesion activa" + - name: dashboard_id + desc: "ID del dashboard donde se añaden las cards" + - name: tab_id + desc: "ID de la tab donde se insertan las cards. Usar 0 para dashboards sin tabs o tab raiz" + - name: card_ids + desc: "Lista de IDs de cards a colocar como fila, de izquierda a derecha. No puede estar vacia" + - name: height + desc: "Altura en filas del grid para cada card. Default 4" + - name: donor_card_id + desc: "Si distinto de 0, copia los parameter_mappings de esa card a cada nueva card. Util para replicar los filtros de dashboard ya configurados. Default 0 (sin mappings)" + - name: grid_width + desc: "Anchura total del grid de Metabase. Default 24 (estandar). Cada card recibe grid_width // len(card_ids) columnas" +output: "Dict con el resumen de metabase_update_dashboard_safe: {'added': [negative_ids], 'updated': int, 'removed': int, 'response': dict_respuesta_PUT}" +tested: false +tests: [] +test_file_path: "" +file_path: "python/functions/metabase/metabase_dashboard_append_row.py" +--- + +## Por que existe + +Añadir una fila de KPIs a un dashboard de Metabase que ya tiene 18 filtros +requiere: (1) calcular la fila libre, (2) construir cada dashcard con su +``parameter_mappings`` copiado, y (3) enviar el PUT sin activar los gotchas +conocidos (413, 500 FK). Esta funcion compone las tres operaciones atomicas +del registry en un solo paso. + +## Ejemplo + +```python +from metabase import MetabaseClient, metabase_dashboard_append_row + +client = MetabaseClient("https://metabase.example.com", "token...") + +# Añadir 4 KPIs al final de la tab 7, copiando filtros de card 42 +result = metabase_dashboard_append_row( + client, + dashboard_id=10, + tab_id=7, + card_ids=[101, 102, 103, 104], + height=4, + donor_card_id=42, +) +print(result["added"]) # [-1, -2, -3, -4] IDs temporales asignados + +# Añadir 2 cards sin filtros en dashboard sin tabs +result = metabase_dashboard_append_row( + client, + dashboard_id=15, + tab_id=0, + card_ids=[200, 201], + height=6, +) + +# Añadir 1 card de ancho completo (24 columnas) +result = metabase_dashboard_append_row( + client, + dashboard_id=10, + tab_id=7, + card_ids=[300], + height=8, + donor_card_id=42, +) +``` + +## Notas + +- Realiza entre 2 y ``1 + 2*len(card_ids)`` requests HTTP: 1 GET para + ``next_row`` + (si ``donor_card_id != 0``) 1 GET por card para los + mappings (que comparten el GET del siguiente ``update_safe``) + 1 GET + 1 + PUT para ``update_dashboard_safe``. En practica ``metabase_get_dashboard`` + se llama 2-3 veces segun si hay donor. +- Si ``grid_width // len(card_ids)`` produce un cociente con resto, las + columnas sobrantes (al lado derecho) quedan vacias en el grid. Para + ajuste preciso, construir los dashcards manualmente y usar + ``metabase_update_dashboard_safe`` directamente. +- ``donor_card_id`` debe ser el ``card_id`` de una card que ya existe en el + dashboard (no el ID del dashcard). Lanza ``ValueError`` si no se + encuentra. +- Si ``tab_id != 0``, ``metabase_update_dashboard_safe`` conserva las tabs + del dashboard (evita 500 FK violation). diff --git a/python/functions/metabase/metabase_dashboard_append_row.py b/python/functions/metabase/metabase_dashboard_append_row.py new file mode 100644 index 00000000..30cb246a --- /dev/null +++ b/python/functions/metabase/metabase_dashboard_append_row.py @@ -0,0 +1,124 @@ +"""Añade N cards como fila horizontal al final de una tab de dashboard.""" + +from __future__ import annotations + +from .client import MetabaseClient +from .metabase_dashboard_next_row import metabase_dashboard_next_row +from .metabase_copy_dashcard_mappings import metabase_copy_dashcard_mappings +from .metabase_update_dashboard_safe import metabase_update_dashboard_safe + + +def metabase_dashboard_append_row( + client: MetabaseClient, + *, + dashboard_id: int, + tab_id: int, + card_ids: list[int], + height: int = 4, + donor_card_id: int = 0, + grid_width: int = 24, +) -> dict: + """Añade N cards como fila horizontal al final de una tab de dashboard. + + Calcula la primera fila libre de la tab indicada, distribuye las cards + horizontalmente con ancho uniforme (``grid_width // len(card_ids)``) y + opcionalmente copia los ``parameter_mappings`` de una card donante a cada + nueva card. Internamente delega en ``metabase_dashboard_next_row``, + ``metabase_copy_dashcard_mappings`` y ``metabase_update_dashboard_safe``. + + Args: + client: MetabaseClient autenticado con sesion activa. + dashboard_id: ID del dashboard donde se añaden las cards. + tab_id: ID de la tab donde se insertan las cards. Usar ``0`` para + dashboards sin tabs o para la tab raiz. + card_ids: Lista de IDs de cards a colocar como fila. El orden de + la lista determina el orden de izquierda a derecha. + height: Altura en filas del grid para cada card. Default ``4``. + donor_card_id: Si distinto de ``0``, copia los ``parameter_mappings`` + de esa card (que debe existir ya en el dashboard) a cada nueva + card. Util para replicar los 18 filtros de dashboard sin configurar + manualmente cada mapping. Default ``0`` (sin mappings). + grid_width: Anchura total del grid de Metabase. Default ``24`` + (estandar de Metabase). Cada card recibe + ``grid_width // len(card_ids)`` columnas. + + Returns: + Dict con el resumen de la operacion devuelto por + ``metabase_update_dashboard_safe``:: + + { + "added": [lista de IDs negativos asignados], + "updated": int, # dashcards existentes conservados + "removed": int, # dashcards eliminados (0 en este caso) + "response": dict, # respuesta raw de PUT /api/dashboard/:id + } + + Raises: + ValueError: Si ``card_ids`` esta vacio, o si ``donor_card_id != 0`` + y esa card no existe en el dashboard. + httpx.HTTPStatusError: Si la API de Metabase devuelve 4xx/5xx. + + Example: + >>> # Añadir 4 KPIs al final de la tab 7, copiando filtros de card 42 + >>> result = metabase_dashboard_append_row( + ... client, + ... dashboard_id=10, + ... tab_id=7, + ... card_ids=[101, 102, 103, 104], + ... height=4, + ... donor_card_id=42, + ... ) + >>> print(result["added"]) # [-1, -2, -3, -4] + + >>> # Sin filtros, 2 cards en dashboard sin tabs + >>> result = metabase_dashboard_append_row( + ... client, + ... dashboard_id=10, + ... tab_id=0, + ... card_ids=[200, 201], + ... height=6, + ... ) + """ + if not card_ids: + raise ValueError("card_ids no puede estar vacio") + + # 1. Calcular siguiente fila libre + next_row = metabase_dashboard_next_row( + client, dashboard_id=dashboard_id, tab_id=tab_id + ) + + # 2. Ancho de cada card + size_x = grid_width // len(card_ids) + + # 3. Construir dashcards + new_dashcards: list[dict] = [] + for i, cid in enumerate(card_ids): + # Copiar mappings del donor si se especifico + if donor_card_id != 0: + mappings = metabase_copy_dashcard_mappings( + client, + dashboard_id=dashboard_id, + source_card_id=donor_card_id, + dest_card_id=cid, + ) + else: + mappings = [] + + dashcard: dict = { + "card_id": cid, + "dashboard_tab_id": tab_id, + "row": next_row, + "col": i * size_x, + "size_x": size_x, + "size_y": height, + "parameter_mappings": mappings, + "visualization_settings": {}, + } + new_dashcards.append(dashcard) + + # 4. Actualizar el dashboard + return metabase_update_dashboard_safe( + client, + dashboard_id, + dashcards_add=new_dashcards, + ) diff --git a/python/functions/metabase/metabase_dashboard_next_row.md b/python/functions/metabase/metabase_dashboard_next_row.md new file mode 100644 index 00000000..c4928c82 --- /dev/null +++ b/python/functions/metabase/metabase_dashboard_next_row.md @@ -0,0 +1,62 @@ +--- +name: metabase_dashboard_next_row +kind: function +lang: py +domain: infra +version: "1.0.0" +purity: impure +signature: "def metabase_dashboard_next_row(client: MetabaseClient, *, dashboard_id: int, tab_id: int = 0) -> int" +description: "Calcula la primera fila libre al final de una tab de dashboard: max(dc.row + dc.size_y) entre las dashcards de esa tab. Retorna 0 si la tab esta vacia. Evita el boilerplate manual de max() al colocar nuevas cards al final del dashboard." +tags: [metabase, dashboard, layout, dashcard, row, tab, api, python] +uses_functions: + - metabase_get_dashboard_py_infra +uses_types: [] +returns: [] +returns_optional: false +error_type: "error_go_core" +imports: [] +params: + - name: client + desc: "MetabaseClient autenticado con sesion activa" + - name: dashboard_id + desc: "ID del dashboard cuyas filas se calculan" + - name: tab_id + desc: "ID de la tab cuya fila final se calcula. 0 (default) = dashcards sin dashboard_tab_id (dashboard sin pestanas o tab raiz)" +output: "int: indice de fila (0-indexed) donde colocar la siguiente card. 0 si la tab no tiene dashcards." +tested: false +tests: [] +test_file_path: "" +file_path: "python/functions/metabase/metabase_dashboard_next_row.py" +--- + +## Ejemplo + +```python +from metabase import MetabaseClient, metabase_dashboard_next_row + +client = MetabaseClient("https://metabase.example.com", "token...") + +# Dashboard sin tabs: siguiente fila libre en la raiz +next_row = metabase_dashboard_next_row(client, dashboard_id=10) + +new_dashcard = { + "id": -1, + "card_id": 99, + "col": 0, "row": next_row, "size_x": 6, "size_y": 4, + "parameter_mappings": [], + "visualization_settings": {}, +} + +# Dashboard con tabs: siguiente fila libre en tab 3 +next_row = metabase_dashboard_next_row(client, dashboard_id=10, tab_id=3) +``` + +## Notas + +- Cuando `tab_id == 0` se filtran dashcards donde `dashboard_tab_id` es falsy + (None, 0 o ausente). Esto cubre dashboards sin tabs y la "tab raiz". +- Requiere 1 request HTTP (GET dashboard). Si ya tienes el objeto dashboard + en memoria, es mas eficiente calcular directamente: + `max((dc["row"] + dc["size_y"] for dc in dashcards), default=0)`. +- La fila retornada es la inmediatamente disponible — no reserva espacio + ni verifica solapamiento de columnas. diff --git a/python/functions/metabase/metabase_dashboard_next_row.py b/python/functions/metabase/metabase_dashboard_next_row.py new file mode 100644 index 00000000..9da9d217 --- /dev/null +++ b/python/functions/metabase/metabase_dashboard_next_row.py @@ -0,0 +1,60 @@ +"""Calcula la siguiente fila libre al final de una tab de dashboard.""" + +from __future__ import annotations + +from .client import MetabaseClient +from .dashboards import metabase_get_dashboard + + +def metabase_dashboard_next_row( + client: MetabaseClient, + *, + dashboard_id: int, + tab_id: int = 0, +) -> int: + """Retorna la primera fila libre al final de una tab de dashboard. + + Obtiene el dashboard y filtra las dashcards que pertenecen a la tab + indicada. Devuelve max(dc["row"] + dc["size_y"]) entre esas cards, o 0 + si no hay ninguna dashcard en la tab. + + Cuando tab_id == 0 se consideran las dashcards sin dashboard_tab_id + (dashboards sin pestanas o pestaña raiz). + + Args: + client: Cliente autenticado con sesion activa. + dashboard_id: ID del dashboard a consultar. + tab_id: ID de la tab cuya fila final se calcula. 0 = dashcards sin tab + (dashboard sin pestanas o tab raiz). + + Returns: + Entero con la fila donde colocar la siguiente card (0-indexed). + 0 si la tab no tiene dashcards. + + Example: + >>> next_row = metabase_dashboard_next_row(client, dashboard_id=10) + >>> new_dashcard = { + ... "id": -1, + ... "card_id": 99, + ... "col": 0, "row": next_row, "size_x": 6, "size_y": 4, + ... "parameter_mappings": [], + ... "visualization_settings": {}, + ... } + + >>> # Dashboard con tabs + >>> next_row = metabase_dashboard_next_row( + ... client, dashboard_id=10, tab_id=3 + ... ) + """ + dashboard = metabase_get_dashboard(client, dashboard_id) + dashcards: list[dict] = dashboard.get("dashcards") or [] + + if tab_id == 0: + filtered = [dc for dc in dashcards if not dc.get("dashboard_tab_id")] + else: + filtered = [dc for dc in dashcards if dc.get("dashboard_tab_id") == tab_id] + + if not filtered: + return 0 + + return max(dc["row"] + dc["size_y"] for dc in filtered) diff --git a/python/functions/metabase/metabase_mbql_from_source_card.md b/python/functions/metabase/metabase_mbql_from_source_card.md new file mode 100644 index 00000000..474327a1 --- /dev/null +++ b/python/functions/metabase/metabase_mbql_from_source_card.md @@ -0,0 +1,142 @@ +--- +name: metabase_mbql_from_source_card +kind: function +lang: py +domain: metabase +version: "1.0.0" +purity: impure +signature: "def metabase_mbql_from_source_card(*, database_id: int, source_card_id: int, aggregations: list[dict], joins: list[dict] | None = None, filters: list[list] | None = None, breakouts: list[dict] | None = None, expressions: list[dict] | None = None) -> dict" +description: "Construye un dataset_query MBQL que envuelve una saved card (source-card) con agregaciones, joins opcionales, filtros, breakouts y una segunda etapa de expressions calculadas. Genera automaticamente todos los lib/uuid con uuid4. Elimina la necesidad de construir el JSON MBQL manualmente para el patron comun de agregar sobre una card guardada y anadir expresiones derivadas." +tags: [metabase, mbql, dataset_query, builder, query, aggregation, join, expression] +uses_functions: [] +uses_types: [] +params: + - name: database_id + desc: "ID numerico de la database de Metabase contra la que se ejecuta la query." + - name: source_card_id + desc: "ID de la card guardada que actua como fuente de datos (equivalente a 'pick a saved question' en el query builder de Metabase)." + - name: aggregations + desc: "Lista de specs de agregacion. Cada spec es un dict con 'op' (sum|count|avg|min|max|count-distinct), 'field' (nombre de columna en la source card) y 'base_type' opcional (default type/Decimal). Para count sin campo omitir 'field'." + - name: joins + desc: "Lista de specs de join. Cada spec incluye: alias (str), source_card_id (int), strategy (str, default 'left-join'), fields (str, opcional), local_field (str), local_base_type (str), foreign_field_id (int), foreign_base_type (str)." + - name: filters + desc: "Lista de clausulas MBQL de filtro en formato raw (arrays anidados). La funcion inyecta lib/uuid a cualquier dict que no lo tenga." + - name: breakouts + desc: "Lista de specs de breakout. Soporta campo por nombre (field, base_type, temporal_unit) o por ID numerico (field_id, base_type, join_alias)." + - name: expressions + desc: "Lista de specs de expression para la segunda etapa MBQL. Cada spec tiene 'name' y 'expr' (mini-DSL recursivo). Si se proporcionan, se crea una segunda stage MBQL con esas expressions." +output: "Dict con estructura completa del dataset_query MBQL: {lib/type, database, stages}. La primera stage tiene source-card, aggregation, joins, filters y breakout. La segunda stage (solo si hay expressions) contiene las expressions calculadas. Todos los dicts internos llevan lib/uuid unicos." +returns: [] +returns_optional: false +error_type: "error_py_core" +imports: [] +tested: true +tests: + - "source-card y database estan presentes en el output" + - "todos los dicts anidados tienen lib/uuid" + - "agregaciones producen forma correcta op+meta+field" + - "segunda stage existe solo cuando se pasan expressions" +test_file_path: "python/functions/metabase/test_metabase_mbql_from_source_card.py" +file_path: "python/functions/metabase/metabase_mbql_from_source_card.py" +--- + +## Mini-DSL para expressions + +El campo `expr` dentro de cada spec de expression acepta un grafo recursivo de nodos: + +| Nodo | Formato | Resultado MBQL | +|---|---|---| +| Field por nombre | `{"field": "PrecioVenta", "base_type": "type/Decimal"}` | `["field", {"base-type": ..., "lib/uuid": ...}, "PrecioVenta"]` | +| Operacion | `{"op": "+", "args": [nodo1, nodo2]}` | `["+", {"lib/uuid": ...}, nodo1_mbql, nodo2_mbql]` | +| Ref a expression | `{"ref": "Margen"}` | `["expression", {"base-type": "type/Float", ...}, "Margen"]` | +| Literal | `0`, `1.5`, `"texto"` | valor directo | + +Operaciones soportadas: `+`, `-`, `*`, `/`, `coalesce`, `case`, y cualquier funcion MBQL valida. + +## Ejemplo (card 9918 en produccion) + +```python +import sys +sys.path.insert(0, '/home/egutierrez/fn_registry/python/functions') +from metabase.metabase_mbql_from_source_card import metabase_mbql_from_source_card + +q = metabase_mbql_from_source_card( + database_id=6, + source_card_id=5305, + aggregations=[ + {"op": "sum", "field": "PrecioVenta", "base_type": "type/Decimal"}, + {"op": "sum", "field": "PrecioCompra", "base_type": "type/Decimal"}, + ], + joins=[ + { + "alias": "Centros - idCentro", + "source_card_id": 4076, + "fields": "none", + "local_field": "idCentro", + "local_base_type": "type/Text", + "foreign_field_id": 17316, + "foreign_base_type": "type/Text", + }, + ], + filters=[ + ["not-empty", {}, ["field", {"base-type": "type/Text"}, "Centros - idCentro__Companies__name"]], + ], + expressions=[ + { + "name": "Margen", + "expr": { + "op": "coalesce", + "args": [ + { + "op": "/", + "args": [ + {"op": "-", "args": [{"field": "sum"}, {"field": "sum_2"}]}, + {"field": "sum"}, + ], + }, + 0, + ], + }, + }, + ], +) + +# Verificar shape +assert q["stages"][0]["source-card"] == 5305 +assert q["database"] == 6 +assert len(q["stages"]) == 2 # stage0 con agg + stage1 con expressions +``` + +## Uso tipico con MetabaseClient + +```python +from metabase import MetabaseClient, metabase_mbql_from_source_card + +client = MetabaseClient('https://metabase.example.com', 'token...') + +q = metabase_mbql_from_source_card( + database_id=6, + source_card_id=5305, + aggregations=[ + {"op": "sum", "field": "Ventas", "base_type": "type/Decimal"}, + ], +) + +payload = { + "name": "Ventas totales por periodo", + "type": "question", + "display": "table", + "dataset_query": q, + "visualization_settings": {}, +} + +card = client.request("POST", "/api/card", json=payload) +print(card["id"]) +``` + +## Notas + +- Los `lib/uuid` se generan con `uuid.uuid4()` en cada llamada, por lo que dos invocaciones producen UUIDs distintos. Esto es correcto: Metabase requiere UUIDs unicos globalmente por query, no deterministicos. +- Los filtros se pasan en formato raw para maxima flexibilidad. La funcion solo inyecta `lib/uuid` a los dicts que no lo tengan. +- La segunda stage solo se crea cuando `expressions` es no-None y no vacio. Sin expressions, el output tiene una sola stage. +- `field` en un nodo de expression refiere siempre a nombre de columna (string). Para referenciar por ID numerico usar `{"field_id": N}` (solo en breakouts por ahora; en expressions usar `{"field": "nombre"}` con el nombre del slot de aggregation como `"sum"`, `"sum_2"`, etc.). diff --git a/python/functions/metabase/metabase_mbql_from_source_card.py b/python/functions/metabase/metabase_mbql_from_source_card.py new file mode 100644 index 00000000..9843eed8 --- /dev/null +++ b/python/functions/metabase/metabase_mbql_from_source_card.py @@ -0,0 +1,375 @@ +"""Constructor de dataset_query MBQL que envuelve una saved card con agregaciones, +joins, filtros y expressions en una segunda etapa.""" + +from __future__ import annotations + +import uuid +from typing import Any + + +# --------------------------------------------------------------------------- +# Helpers internos +# --------------------------------------------------------------------------- + + +def _uuid() -> str: + """Genera un UUID v4 fresco como string.""" + return str(uuid.uuid4()) + + +def _inject_uuids(obj: Any) -> Any: + """Recorre obj recursivamente e inyecta lib/uuid a dicts que no lo tengan.""" + if isinstance(obj, dict): + if "lib/uuid" not in obj: + obj["lib/uuid"] = _uuid() + for k, v in obj.items(): + if k != "lib/uuid": + obj[k] = _inject_uuids(v) + return obj + elif isinstance(obj, list): + return [_inject_uuids(item) for item in obj] + return obj + + +def _build_field_ref( + field_name: str, + base_type: str = "type/Decimal", +) -> list: + """Construye un nodo MBQL ["field", meta, field_name] para campo de source-card.""" + return [ + "field", + {"base-type": base_type, "lib/uuid": _uuid()}, + field_name, + ] + + +def _build_field_ref_by_id( + field_id: int, + base_type: str = "type/Text", + join_alias: str | None = None, +) -> list: + """Construye un nodo MBQL ["field", meta, field_id] para campo por ID numerico.""" + meta: dict = {"base-type": base_type, "lib/uuid": _uuid()} + if join_alias: + meta["join-alias"] = join_alias + return ["field", meta, field_id] + + +# --------------------------------------------------------------------------- +# Builders de aggregation +# --------------------------------------------------------------------------- + +_OPS_WITHOUT_FIELD = {"count"} + + +def _build_aggregation(agg_spec: dict) -> list: + """Convierte un spec de agregacion al formato MBQL. + + Formato input: + {"op": "sum", "field": "PrecioVenta", "base_type": "type/Decimal"} + {"op": "count"} + + Formato output (con campo): + ["sum", {"lib/uuid": ...}, ["field", {"base-type": "...", "lib/uuid": ...}, "PrecioVenta"]] + + Formato output (sin campo, ej. count): + ["count", {"lib/uuid": ...}] + """ + op = agg_spec["op"] + meta = {"lib/uuid": _uuid()} + + if op in _OPS_WITHOUT_FIELD or "field" not in agg_spec: + return [op, meta] + + field_node = _build_field_ref( + agg_spec["field"], + base_type=agg_spec.get("base_type", "type/Decimal"), + ) + return [op, meta, field_node] + + +# --------------------------------------------------------------------------- +# Builders de join +# --------------------------------------------------------------------------- + + +def _build_join(join_spec: dict) -> dict: + """Construye el dict de join MBQL a partir de un join_spec. + + join_spec esperado: + { + "alias": "Centros - idCentro", + "source_card_id": 4076, + "strategy": "left-join", # default + "fields": "none", # opcional + "local_field": "idCentro", + "local_base_type": "type/Text", + "foreign_field_id": 17316, + "foreign_base_type": "type/Text", + } + """ + alias = join_spec["alias"] + strategy = join_spec.get("strategy", "left-join") + + # Condicion de join: ["=", meta, local_field_ref, foreign_field_ref] + local_ref = _build_field_ref( + join_spec["local_field"], + base_type=join_spec.get("local_base_type", "type/Text"), + ) + foreign_ref = _build_field_ref_by_id( + join_spec["foreign_field_id"], + base_type=join_spec.get("foreign_base_type", "type/Text"), + join_alias=alias, + ) + condition_meta = {"lib/uuid": _uuid()} + condition = ["=", condition_meta, local_ref, foreign_ref] + + join: dict = { + "lib/type": "mbql/join", + "lib/uuid": _uuid(), + "alias": alias, + "strategy": strategy, + "stages": [ + { + "lib/type": "mbql.stage/mbql", + "source-card": join_spec["source_card_id"], + "lib/options": {"lib/uuid": _uuid()}, + } + ], + "conditions": [condition], + } + + if "fields" in join_spec: + join["fields"] = join_spec["fields"] + + return join + + +# --------------------------------------------------------------------------- +# Builders de breakout +# --------------------------------------------------------------------------- + + +def _build_breakout(breakout_spec: dict) -> list: + """Construye un nodo MBQL de breakout. + + Soporta: + {"field": "fecha", "base_type": "type/Date", "temporal_unit": "month"} + {"field_id": 156756, "base_type": "type/Text", "join_alias": "Centros - idCentro"} + """ + if "field_id" in breakout_spec: + meta: dict = { + "base-type": breakout_spec.get("base_type", "type/Text"), + "lib/uuid": _uuid(), + } + if "join_alias" in breakout_spec: + meta["join-alias"] = breakout_spec["join_alias"] + return ["field", meta, breakout_spec["field_id"]] + else: + meta = { + "base-type": breakout_spec.get("base_type", "type/Date"), + "lib/uuid": _uuid(), + } + if "temporal_unit" in breakout_spec: + meta["temporal-unit"] = breakout_spec["temporal_unit"] + return ["field", meta, breakout_spec["field"]] + + +# --------------------------------------------------------------------------- +# Builder de expressions (mini-DSL recursivo) +# --------------------------------------------------------------------------- + + +def _build_expr_node(node: Any, expr_name: str | None = None) -> Any: + """Traduce recursivamente un nodo del mini-DSL a formato MBQL. + + Primitivos soportados: + {"field": "nombre", "base_type": "type/Decimal"} → field ref por nombre + {"op": "sum_field", ...} → no aplica aqui + {"op": "+", "args": [...]} → operacion MBQL + {"ref": "NombreExpresion"} → expression ref + int | float | bool | str → literal + + Nodo raiz recibe expr_name para incluir lib/expression-name en el meta. + """ + if isinstance(node, (int, float, bool, str)) and not isinstance(node, bool): + return node + if isinstance(node, bool): + return node + + if not isinstance(node, dict): + return node + + # Referencia a otra expression nombrada en la misma stage + if "ref" in node: + ref_name = node["ref"] + base_type = node.get("base_type", "type/Float") + return [ + "expression", + { + "base-type": base_type, + "effective-type": base_type, + "lib/uuid": _uuid(), + }, + ref_name, + ] + + # Field ref por nombre + if "field" in node and "op" not in node: + return _build_field_ref( + node["field"], + base_type=node.get("base_type", "type/Decimal"), + ) + + # Operacion con args + if "op" in node: + op = node["op"] + args = node.get("args", []) + meta: dict = {"lib/uuid": _uuid()} + if expr_name is not None: + meta["lib/expression-name"] = expr_name + + translated_args = [_build_expr_node(a) for a in args] + return [op, meta] + translated_args + + # Fallback: retornar el nodo tal cual + return node + + +def _build_expression_entry(expr_spec: dict) -> list: + """Construye el nodo MBQL completo para una expression con nombre. + + expr_spec: + {"name": "Margen", "expr": {...mini-DSL...}} + + Retorna: + [op, {"lib/uuid": ..., "lib/expression-name": "Margen"}, ...args] + """ + name = expr_spec["name"] + expr_node = expr_spec["expr"] + + # Si el nodo raiz es una operacion, trasladar el expr_name al meta + if isinstance(expr_node, dict) and "op" in expr_node: + op = expr_node["op"] + args = expr_node.get("args", []) + meta: dict = { + "lib/uuid": _uuid(), + "lib/expression-name": name, + } + translated_args = [_build_expr_node(a) for a in args] + return [op, meta] + translated_args + + # Si es un field ref u otro primitivo, envolverlo + built = _build_expr_node(expr_node, expr_name=name) + if isinstance(built, list) and len(built) >= 2 and isinstance(built[1], dict): + built[1]["lib/expression-name"] = name + return built + + +# --------------------------------------------------------------------------- +# Funcion principal +# --------------------------------------------------------------------------- + + +def metabase_mbql_from_source_card( + *, + database_id: int, + source_card_id: int, + aggregations: list[dict], + joins: list[dict] | None = None, + filters: list[list] | None = None, + breakouts: list[dict] | None = None, + expressions: list[dict] | None = None, +) -> dict: + """Construye un dataset_query MBQL que envuelve una saved card (source-card). + + Genera automaticamente todos los lib/uuid necesarios. Soporta agregaciones, + joins, filtros, breakouts y una segunda etapa con expressions calculadas. + Elimina la necesidad de construir el JSON MBQL manualmente para el patron + comun "agregar sobre una card guardada y anadir expresiones derivadas". + + Args: + database_id: ID de la database de Metabase contra la que se ejecuta. + source_card_id: ID de la card guardada que actua como fuente de datos + (equivalente a "pick a saved question" en el query builder). + aggregations: Lista de specs de agregacion. Cada spec es un dict con + "op" (sum|count|avg|min|max|count-distinct), "field" (nombre de + columna en la source card) y "base_type" opcional (default + "type/Decimal"). Para count sin campo, omitir "field". + joins: Lista de specs de join. Cada spec incluye "alias", "source_card_id" + (card a joinear), "strategy" (default "left-join"), "fields" opcional, + "local_field" (campo en la etapa actual), "local_base_type", + "foreign_field_id" (ID numerico del campo en la card joineada) y + "foreign_base_type". + filters: Lista de clausulas MBQL de filtro en formato raw. La funcion + inyecta lib/uuid a cualquier dict que no lo tenga. Ejemplo: + [["not-empty", {}, ["field", {"base-type": "type/Text"}, "nombre"]]]. + breakouts: Lista de specs de breakout. Soporta campo por nombre (con + "field" y "base_type" opcionales y "temporal_unit" para fechas) o + por ID numerico (con "field_id", "base_type" y "join_alias" opcionales). + expressions: Lista de specs de expression para la segunda etapa MBQL. + Cada spec tiene "name" (nombre de la expresion) y "expr" (nodo del + mini-DSL recursivo). Nodos soportados: {"field": ..., "base_type": ...} + para referencias a campos, {"op": ..., "args": [...]} para operaciones + (+, -, *, /, coalesce, case, etc.), {"ref": ...} para referenciar + otra expression nombrada en la misma etapa, y literales numericos. + Si se proporcionan expressions, se crea una segunda stage MBQL. + + Returns: + Dict con la estructura completa del dataset_query MBQL: + { + "lib/type": "mbql/query", + "database": database_id, + "stages": [stage0, stage1?] # stage1 solo si hay expressions + } + Todos los dicts internos tienen lib/uuid unicos generados con uuid4. + + Example: + >>> q = metabase_mbql_from_source_card( + ... database_id=6, + ... source_card_id=5305, + ... aggregations=[{"op": "count"}], + ... ) + >>> q["stages"][0]["source-card"] + 5305 + >>> q["database"] + 6 + """ + # ---- Stage 0 ----------------------------------------------------------- + stage0: dict = { + "lib/type": "mbql.stage/mbql", + "source-card": source_card_id, + } + + # Agregaciones + agg_nodes = [_build_aggregation(a) for a in aggregations] + if agg_nodes: + stage0["aggregation"] = agg_nodes + + # Joins + if joins: + stage0["joins"] = [_build_join(j) for j in joins] + + # Filtros: inyectar uuids a dicts que no los tengan + if filters: + stage0["filters"] = [_inject_uuids(f) for f in filters] + + # Breakouts + if breakouts: + stage0["breakout"] = [_build_breakout(b) for b in breakouts] + + stages = [stage0] + + # ---- Stage 1 (solo si hay expressions) --------------------------------- + if expressions: + stage1: dict = { + "lib/type": "mbql.stage/mbql", + "expressions": [_build_expression_entry(e) for e in expressions], + } + stages.append(stage1) + + return { + "lib/type": "mbql/query", + "database": database_id, + "stages": stages, + } diff --git a/python/functions/metabase/metabase_smartscalar_anothercolumn_viz.md b/python/functions/metabase/metabase_smartscalar_anothercolumn_viz.md new file mode 100644 index 00000000..45725f16 --- /dev/null +++ b/python/functions/metabase/metabase_smartscalar_anothercolumn_viz.md @@ -0,0 +1,98 @@ +--- +name: metabase_smartscalar_anothercolumn_viz +kind: function +lang: py +domain: infra +version: "1.0.0" +purity: pure +signature: "def metabase_smartscalar_anothercolumn_viz(*, main_column: str, compare_column: str, label: str = 'vs N-1', number_style: str = 'percent', decimals: int = 2, currency: str = 'EUR', comparison_id: str = 'cmp_n1') -> dict" +description: "Construye el dict completo de visualization_settings para una card display=smartscalar con comparacion tipo anotherColumn (misma fila, columna diferente). Incluye scalar.field, scalar.comparisons y column_settings para ambas columnas con el mismo formato. Internamente reutiliza metabase_viz_column_format." +tags: [metabase, visualization, smartscalar, anothercolumn, scalar, comparison, pure, builder] +uses_functions: + - metabase_viz_column_format_py_infra +uses_types: [] +returns: [] +returns_optional: false +error_type: "" +imports: [] +params: + - name: main_column + desc: "Nombre de la columna mostrada como valor principal en el smartscalar. Debe coincidir exactamente con el nombre de columna en el resultado del query" + - name: compare_column + desc: "Nombre de la columna usada como valor de comparacion en la misma fila del query" + - name: label + desc: "Etiqueta mostrada bajo el delta de comparacion. Default 'vs N-1'" + - name: number_style + desc: "Estilo de formato para ambas columnas: 'percent', 'currency', 'decimal', '' (default Metabase). Default 'percent'" + - name: decimals + desc: "Numero de decimales para ambas columnas. Default 2" + - name: currency + desc: "Codigo ISO de moneda ('EUR', 'USD', ...). Solo relevante cuando number_style='currency'. Default 'EUR'" + - name: comparison_id + desc: "Identificador interno de la comparacion dentro del array scalar.comparisons. Default 'cmp_n1'" +output: "Dict con visualization_settings completo: {'scalar.field': str, 'scalar.comparisons': [{'id', 'type': 'anotherColumn', 'column', 'label'}], 'column_settings': {key_main: fmt, key_compare: fmt}}. Listo para usar como visualization_settings en el payload de la card." +tested: false +tests: [] +test_file_path: "" +file_path: "python/functions/metabase/metabase_smartscalar_anothercolumn_viz.py" +--- + +## Por que existe + +El patron ``anotherColumn`` de Metabase smartscalar muestra un delta entre +dos columnas de la misma fila (en lugar del patron ``previousValue`` que +usa dos filas temporales). Util cuando el query ya calcula el valor actual +y el valor comparativo directamente (tipico en queries con CTEs que calculan +n-1 en una sola pasada). + +La combinacion ``scalar.field`` + ``scalar.comparisons[anotherColumn]`` + +``column_settings`` para ambas columnas con formato identico requiere varios +campos que Metabase silenciosamente ignora si faltan o estan mal formateados. + +## Ejemplo + +```python +from metabase import metabase_smartscalar_anothercolumn_viz, metabase_create_card_raw + +# Smartscalar de margen (porcentaje) vs columna n-1 +viz = metabase_smartscalar_anothercolumn_viz( + main_column="Margen", + compare_column="MargenN1", + label="vs N-1", + number_style="percent", + decimals=2, +) + +# Smartscalar de venta (moneda) vs columna n-1 +viz = metabase_smartscalar_anothercolumn_viz( + main_column="Venta", + compare_column="VentaN1", + label="vs ano anterior", + number_style="currency", + decimals=0, + currency="EUR", +) + +# Integrar en un payload completo de card +payload = { + "name": "Margen actual", + "type": "question", + "display": "smartscalar", + "dataset_query": { ... }, + "visualization_settings": viz, +} +card = metabase_create_card_raw(client, payload) +``` + +## Notas + +- Aplica el mismo formato (``number_style``, ``decimals``, ``currency``) a + ambas columnas (``main_column`` y ``compare_column``). Si necesitas formatos + distintos, construye ``column_settings`` manualmente con + ``metabase_viz_column_format`` y monta el dict a mano. +- Cuando ``number_style="currency"`` se agrega automaticamente + ``currency_in_header=False`` en ambas columnas. +- El ``comparison_id`` solo necesita ser unico dentro del array + ``scalar.comparisons`` de esa card — no es un ID global. +- Para el patron de 2 filas temporales (``previousValue``) usar + ``metabase_smartscalar_kpi_payload`` en su lugar. diff --git a/python/functions/metabase/metabase_smartscalar_anothercolumn_viz.py b/python/functions/metabase/metabase_smartscalar_anothercolumn_viz.py new file mode 100644 index 00000000..5a9e88c8 --- /dev/null +++ b/python/functions/metabase/metabase_smartscalar_anothercolumn_viz.py @@ -0,0 +1,111 @@ +"""Construye visualization_settings para smartscalar con comparacion anotherColumn.""" + +from __future__ import annotations + +from .metabase_viz_column_format import metabase_viz_column_format + + +def metabase_smartscalar_anothercolumn_viz( + *, + main_column: str, + compare_column: str, + label: str = "vs N-1", + number_style: str = "percent", + decimals: int = 2, + currency: str = "EUR", + comparison_id: str = "cmp_n1", +) -> dict: + """Construye visualization_settings para smartscalar con comparacion anotherColumn. + + Genera el dict completo de ``visualization_settings`` para una card con + ``display=smartscalar`` que muestra ``main_column`` con una comparacion + de tipo ``anotherColumn`` apuntando a ``compare_column`` (ambas columnas + en la misma fila del query). + + Este patron es util cuando el query devuelve directamente el valor actual + y el valor de comparacion como columnas separadas (en lugar del patron de + 2 filas temporales de ``previousValue``). + + Args: + main_column: Nombre de la columna que se muestra como valor principal + en el smartscalar. Debe coincidir exactamente con el nombre de + columna en el resultado del query. + compare_column: Nombre de la columna que se usa como valor de + comparacion. Debe estar en el mismo resultado del query que + ``main_column``. + label: Etiqueta mostrada bajo el delta de comparacion. Default + ``"vs N-1"``. + number_style: Estilo de formato para ambas columnas. Valores: + ``"percent"``, ``"currency"``, ``"decimal"``, ``""`` (default + Metabase). Default ``"percent"``. + decimals: Numero de decimales para ambas columnas. Default ``2``. + currency: Codigo ISO de moneda (``"EUR"``, ``"USD"``, ...). Solo + relevante cuando ``number_style="currency"``. Default ``"EUR"``. + comparison_id: Identificador interno de la comparacion dentro del + array ``scalar.comparisons``. Debe ser unico por card. Default + ``"cmp_n1"``. + + Returns: + Dict con la estructura completa de ``visualization_settings``:: + + { + "scalar.field": "", + "scalar.comparisons": [ + {"id": "", "type": "anotherColumn", + "column": "", "label": "