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.
This commit is contained in:
@@ -0,0 +1,212 @@
|
||||
"""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,
|
||||
}
|
||||
Reference in New Issue
Block a user