9a28d08e38
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.
213 lines
8.1 KiB
Python
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,
|
|
}
|