Files
fn_registry/python/functions/metabase/metabase_update_dashboard_safe.py
egutierrez 4300f1242d feat(metabase): expansion de funciones Python — documents, collections, permissions, validation
Añade un conjunto amplio de funciones al paquete python/functions/metabase:
- Nuevos modulos: collections.py, documents.py, maintenance.py, permissions.py, validation.py (+ test).
- Ampliacion de cards.py, dashboards.py, client.py e __init__.py para exponer las nuevas operaciones.
- Funciones de documentos (create/get/update/delete/archive/copy/move + comentarios), grupos y memberships, permission/collection graphs, copy/move de cards y dashboards, validacion de MBQL/SQL y payloads, actualizacion segura de dashboards y fix_null_ratio.
- .md por funcion con frontmatter para que fn index los registre.
- Actualiza pyproject.toml y uv.lock con las dependencias resultantes.

Impacto: ampliamente mas cobertura de la API de Metabase desde el registry, reutilizable por apps y analisis. No toca Go ni frontend.
2026-04-13 23:31:42 +02:00

213 lines
8.1 KiB
Python

"""Wrapper seguro sobre PUT /api/dashboard/:id que maneja los gotchas conocidos."""
from __future__ import annotations
import httpx
from .client import MetabaseClient
# Campos permitidos en un dashcard al enviar a la API de Metabase.
# Cualquier otro campo (especialmente 'card' denormalizado) causa 413.
_DASHCARD_ALLOWED_KEYS = {
"id",
"card_id",
"dashboard_tab_id",
"col",
"row",
"size_x",
"size_y",
"parameter_mappings",
"visualization_settings",
"series",
"action_id",
"inline_parameters",
}
def _strip_dashcard(dc: dict) -> dict:
"""Elimina campos denormalizados de un dashcard, conservando solo los permitidos.
El campo ``card`` contiene el objeto completo de la question (con dataset_query,
visualization_settings, etc.) y puede superar facilmente el limite de payload.
Metabase lo añade en las respuestas GET pero lo rechaza (413) en PUT.
Args:
dc: Dict de dashcard tal como lo devuelve GET /api/dashboard/:id.
Returns:
Dict con solo los campos que acepta PUT /api/dashboard/:id.
"""
return {k: v for k, v in dc.items() if k in _DASHCARD_ALLOWED_KEYS}
def metabase_update_dashboard_safe(
client: MetabaseClient,
dashboard_id: int,
*,
dashcards_update: list[dict] | None = None,
dashcards_add: list[dict] | None = None,
dashcards_remove: list[int] | None = None,
extra_fields: dict | None = None,
) -> dict:
"""Actualiza un dashboard de Metabase manejando los tres gotchas conocidos.
Gotchas que maneja automaticamente:
1. **413 Payload Too Large**: strippea el campo ``.card`` denormalizado de
cada dashcard antes de enviar. Metabase incluye ese objeto en GET pero
lo rechaza en PUT.
2. **500 FK violation (tabs)**: siempre incluye el array ``tabs`` actual en
el body del PUT. Si se omite, Metabase borra las tabs y viola FK
constraints de dashcards que las referencian.
3. **IDs negativos para dashcards nuevos**: los dashcards sin ``id``
(nuevos) reciben IDs negativos temporales (-1, -2, ...) que Metabase
reemplaza con IDs reales en la respuesta.
Flujo:
1. GET /api/dashboard/:id — obtiene estado actual (dashcards + tabs).
2. Construye la lista final de dashcards:
- Si ``dashcards_update`` dado: usarla directamente (tras strip).
- Si ``dashcards_add`` y/o ``dashcards_remove``: aplicar sobre existentes.
- Sin ninguno: mantener existentes sin cambios.
3. Strip de campos denormalizados en todos los dashcards.
4. Asignar IDs negativos a dashcards nuevos (sin ``id``).
5. PUT /api/dashboard/:id con ``{dashcards, tabs, **extra_fields}``.
6. Devolver resumen de operacion.
Args:
client: Cliente autenticado con sesion activa.
dashboard_id: ID del dashboard a actualizar.
dashcards_update: Lista completa de dashcards que REEMPLAZA el estado
actual. Si se da, ``dashcards_add`` y ``dashcards_remove`` se ignoran.
dashcards_add: Dashcards a añadir sobre los existentes. Cada item
no debe incluir ``id`` — la funcion asigna IDs negativos.
dashcards_remove: Lista de IDs de dashcards existentes a eliminar.
extra_fields: Campos adicionales del dashboard a actualizar
(name, description, parameters, archived, collection_id, etc.).
Returns:
Dict con resumen de la operacion::
{
"added": [list of temp negative ids assigned],
"updated": int, # dashcards existentes conservados
"removed": int, # dashcards eliminados
"response": dict, # respuesta raw de PUT /api/dashboard/:id
}
Raises:
httpx.HTTPStatusError: Si la API devuelve 4xx/5xx con mensaje de
diagnostico indicando que gotcha probablemente se activo.
Example:
>>> # Añadir una card al dashboard
>>> result = metabase_update_dashboard_safe(
... client,
... dashboard_id=42,
... dashcards_add=[{
... "card_id": 100,
... "col": 0,
... "row": 0,
... "size_x": 6,
... "size_y": 4,
... "parameter_mappings": [],
... "visualization_settings": {},
... }],
... )
>>> print(result["added"]) # [-1]
>>> # Reemplazar todos los dashcards y cambiar el nombre
>>> result = metabase_update_dashboard_safe(
... client,
... dashboard_id=42,
... dashcards_update=existing_dashcards,
... extra_fields={"name": "Nuevo nombre"},
... )
>>> print(result["updated"])
"""
# ---- Paso 1: GET estado actual ------------------------------------------
current = client.request("GET", f"/api/dashboard/{dashboard_id}")
current_dashcards: list[dict] = current.get("dashcards") or []
current_tabs: list[dict] = current.get("tabs") or []
# ---- Paso 2: Construir lista final de dashcards -------------------------
removed_count = 0
added_ids: list[int] = []
if dashcards_update is not None:
# Reemplazo completo: usar tal cual (tras strip)
final_dashcards = list(dashcards_update)
# Contabilizar: existentes que siguen presentes vs removidos
existing_ids = {dc.get("id") for dc in current_dashcards if dc.get("id")}
final_pos_ids = {dc.get("id") for dc in final_dashcards if dc.get("id") and dc.get("id", 0) > 0}
removed_count = len(existing_ids - final_pos_ids)
else:
# Operacion incremental: partir de existentes
remove_set: set[int] = set(dashcards_remove or [])
final_dashcards = [dc for dc in current_dashcards if dc.get("id") not in remove_set]
removed_count = len(remove_set)
if dashcards_add:
final_dashcards = list(final_dashcards) + list(dashcards_add)
# ---- Paso 3 & 4: Strip + asignar IDs negativos --------------------------
cleaned: list[dict] = []
next_neg_id = -1
existing_count = 0
for dc in final_dashcards:
dc_clean = _strip_dashcard(dc)
if "id" not in dc_clean or dc_clean.get("id") is None:
# Dashcard nuevo sin id: asignar negativo
dc_clean["id"] = next_neg_id
added_ids.append(next_neg_id)
next_neg_id -= 1
elif isinstance(dc_clean.get("id"), int) and dc_clean["id"] < 0:
# Ya tiene id negativo (caller lo asigno): registrar
added_ids.append(dc_clean["id"])
else:
existing_count += 1
cleaned.append(dc_clean)
# ---- Paso 5: Construir body y PUT ----------------------------------------
body: dict = {
"dashcards": cleaned,
"tabs": current_tabs,
}
if extra_fields:
body.update(extra_fields)
try:
response = client.request("PUT", f"/api/dashboard/{dashboard_id}", json=body)
except httpx.HTTPStatusError as exc:
status = exc.response.status_code
if status == 413:
raise httpx.HTTPStatusError(
f"413 Payload Too Large al actualizar dashboard {dashboard_id}. "
"Probable causa: dashcard con campo '.card' denormalizado sin strippear. "
"Revisar que dashcards_update no contenga el objeto 'card' completo.",
request=exc.request,
response=exc.response,
) from exc
if status == 500:
raise httpx.HTTPStatusError(
f"500 Internal Server Error al actualizar dashboard {dashboard_id}. "
"Posibles causas: (a) tabs FK violation — el body no incluyo 'tabs' "
"(no deberia ocurrir con esta funcion); "
"(b) dashcard referencia tab_id inexistente; "
"(c) error interno de Metabase. "
f"Body enviado tenia {len(cleaned)} dashcards y {len(current_tabs)} tabs.",
request=exc.request,
response=exc.response,
) from exc
raise
return {
"added": added_ids,
"updated": existing_count,
"removed": removed_count,
"response": response,
}