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:
2026-04-13 23:31:42 +02:00
parent e42c59de16
commit 4300f1242d
53 changed files with 5102 additions and 5 deletions
+22 -2
View File
@@ -1,15 +1,35 @@
from .client import MetabaseClient
from .users import metabase_list_users, metabase_get_user, metabase_create_user, metabase_update_user, metabase_deactivate_user
from .cards import metabase_list_cards, metabase_get_card, metabase_create_card, metabase_update_card, metabase_delete_card, metabase_execute_card, metabase_execute_query
from .dashboards import metabase_list_dashboards, metabase_get_dashboard, metabase_create_dashboard, metabase_update_dashboard, metabase_delete_dashboard
from .cards import metabase_list_cards, metabase_get_card, metabase_create_card, metabase_update_card, metabase_delete_card, metabase_execute_card, metabase_execute_query, metabase_copy_card, metabase_move_card
from .dashboards import metabase_list_dashboards, metabase_get_dashboard, metabase_create_dashboard, metabase_update_dashboard, metabase_delete_dashboard, metabase_copy_dashboard, metabase_move_dashboard
from .databases import metabase_list_databases, metabase_add_database, metabase_get_database
from .documents import metabase_list_documents, metabase_get_document, metabase_create_document, metabase_update_document, metabase_archive_document, metabase_delete_document, metabase_list_document_comments, metabase_create_document_comment, metabase_resolve_document_comment, metabase_move_document, metabase_copy_document
from .collections import metabase_move_collection
from .permissions import metabase_list_groups, metabase_get_group, metabase_create_group, metabase_update_group, metabase_delete_group, metabase_list_memberships, metabase_add_membership, metabase_delete_membership, metabase_get_permission_graph, metabase_update_permission_graph, metabase_get_collection_graph, metabase_update_collection_graph
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_update_dashboard_safe import metabase_update_dashboard_safe
__all__ = [
"MetabaseClient",
"metabase_list_users", "metabase_get_user", "metabase_create_user", "metabase_update_user", "metabase_deactivate_user",
"metabase_list_cards", "metabase_get_card", "metabase_create_card", "metabase_update_card", "metabase_delete_card", "metabase_execute_card", "metabase_execute_query",
"metabase_copy_card", "metabase_move_card",
"metabase_list_dashboards", "metabase_get_dashboard", "metabase_create_dashboard", "metabase_update_dashboard", "metabase_delete_dashboard",
"metabase_copy_dashboard", "metabase_move_dashboard",
"metabase_list_databases", "metabase_add_database", "metabase_get_database",
"metabase_list_documents", "metabase_get_document", "metabase_create_document", "metabase_update_document", "metabase_archive_document", "metabase_delete_document",
"metabase_list_document_comments", "metabase_create_document_comment", "metabase_resolve_document_comment",
"metabase_move_document", "metabase_copy_document",
"metabase_move_collection",
"metabase_list_groups", "metabase_get_group", "metabase_create_group", "metabase_update_group", "metabase_delete_group",
"metabase_list_memberships", "metabase_add_membership", "metabase_delete_membership",
"metabase_get_permission_graph", "metabase_update_permission_graph",
"metabase_get_collection_graph", "metabase_update_collection_graph",
"metabase_setup",
"metabase_fix_null_ratio",
"metabase_pair_n_n1_columns",
"metabase_mbql_validate",
"metabase_update_dashboard_safe",
]
+129
View File
@@ -225,3 +225,132 @@ def metabase_execute_query(
"max-results-bare-rows": max_results,
}
return client.request("POST", "/api/dataset", json=body)
def metabase_copy_card(
client: MetabaseClient,
card_id: int,
name: str | None = None,
collection_id: int | None = None,
description: str | None = None,
) -> dict:
"""Crea una copia de una card/pregunta existente en Metabase.
Endpoint: POST /api/card/:id/copy. Usa el endpoint nativo de Metabase para
duplicar la card, copiando dataset_query, display y visualization_settings.
Los campos name, collection_id y description se pueden sobrescribir via body.
Args:
client: Cliente autenticado.
card_id: ID de la card a copiar.
name: Nombre para la copia. None = Metabase asigna "Copy of <nombre>".
collection_id: Coleccion destino. None = misma coleccion que el original.
description: Descripcion de la copia. None = misma que el original.
Returns:
Dict con la card nueva creada por Metabase. Incluye el campo `id`
asignado a la copia y todos los campos heredados del original.
Example:
>>> copy = metabase_copy_card(client, 42)
>>> print(copy["id"], copy["name"]) # "Copy of ..."
>>> # Copiar a otra coleccion con nombre propio:
>>> copy = metabase_copy_card(client, 42, name="Revenue Q2", collection_id=7)
"""
body: dict = {}
if name is not None:
body["name"] = name
if collection_id is not None:
body["collection_id"] = collection_id
if description is not None:
body["description"] = description
return client.request("POST", f"/api/card/{card_id}/copy", json=body or None)
def metabase_move_card(
client: MetabaseClient,
card_id: int,
collection_id: int | None,
) -> dict:
"""Mueve una card/pregunta a otra coleccion.
Wrapper thin sobre PUT /api/card/:id que solo actualiza collection_id.
Equivalente a metabase_update_card(client, card_id, collection_id=...) pero
con intencion explicita y soporte para mover a root con None.
Endpoint: PUT /api/card/:id.
Args:
client: Cliente autenticado.
card_id: ID de la card a mover.
collection_id: ID de la coleccion destino. None mueve a "Our analytics" (root).
Returns:
Dict con la card actualizada, incluyendo el nuevo collection_id.
Example:
>>> card = metabase_move_card(client, 42, collection_id=7)
>>> print(card["collection_id"]) # 7
>>> # Mover a root:
>>> card = metabase_move_card(client, 42, collection_id=None)
"""
return client.request("PUT", f"/api/card/{card_id}", json={"collection_id": collection_id})
def metabase_create_card_raw(client: MetabaseClient, payload: dict) -> dict:
"""Crea una card en Metabase con payload completo ya construido por el caller.
Version raw de metabase_create_card. El caller es responsable de construir
el payload completo antes de llamar a esta funcion — no se realiza ninguna
validacion ni transformacion local. Util para flujos "Metabase as code"
donde el YAML define todos los campos de la card tal como los espera la API.
Endpoint: POST /api/card.
El payload minimo necesita:
- name (str): nombre de la card.
- dataset_query (dict): query SQL nativa o MBQL.
- display (str): tipo de visualizacion (table, bar, scalar, etc.).
Campos opcionales que esta funcion preserva (a diferencia de metabase_create_card):
- visualization_settings (dict): configuracion detallada del grafico.
- parameters (list[dict]): parametros de la query con template tags.
- parameter_mappings (list[dict]): mapeo de parametros a dashboard filters.
- type (str): "question" (default), "model", "metric".
- collection_id (int): ID de coleccion destino.
- description (str): descripcion de la card.
- archived (bool): estado de archivo inicial.
- enable_embedding (bool): habilitar embedding publico.
- embedding_params (dict): configuracion de embedding.
Si Metabase devuelve 4xx/5xx, httpx lanza HTTPStatusError sin capturar.
Args:
client: Cliente autenticado con sesion activa.
payload: Dict con el payload completo de la card tal como lo espera
la API de Metabase. Se envia sin modificaciones.
Returns:
Dict con la card recien creada. Incluye el campo `id` asignado por
Metabase y todos los campos normalizados (display, dataset_query,
visualization_settings, created_at, etc.).
Example:
>>> card = metabase_create_card_raw(client, {
... "name": "Revenue by Month",
... "dataset_query": {
... "database": 1,
... "type": "native",
... "native": {"query": "SELECT date_trunc('month', created_at), SUM(total) FROM orders GROUP BY 1"},
... },
... "display": "line",
... "visualization_settings": {
... "graph.x_axis.title_text": "Month",
... "graph.y_axis.title_text": "Revenue",
... },
... "description": "Monthly revenue trend",
... "collection_id": 5,
... })
>>> print(card["id"]) # ID asignado por Metabase
"""
return client.request("POST", "/api/card", json=payload)
+6 -3
View File
@@ -12,16 +12,19 @@ class MetabaseClient:
_http: Cliente httpx reutilizable con headers de auth.
"""
def __init__(self, base_url: str, token: str) -> None:
def __init__(self, base_url: str, token: str, timeout: float = 120.0) -> None:
self.base_url = base_url.rstrip("/")
self.token = token
# API keys de Metabase empiezan por "mb_" y usan X-API-KEY.
# Session tokens usan X-Metabase-Session.
auth_header = "X-API-KEY" if token.startswith("mb_") else "X-Metabase-Session"
self._http = httpx.Client(
base_url=self.base_url,
headers={
"Content-Type": "application/json",
"X-Metabase-Session": token,
auth_header: token,
},
timeout=30.0,
timeout=timeout,
)
def request(self, method: str, path: str, **kwargs) -> dict | list | None:
+39
View File
@@ -0,0 +1,39 @@
"""CRUD y operaciones sobre collections de Metabase."""
from .client import MetabaseClient
def metabase_move_collection(
client: MetabaseClient,
collection_id: int,
parent_id: int | None,
) -> dict:
"""Mueve una collection (sub-arbol completo) a otro padre.
Endpoint: PUT /api/collection/:id con {parent_id: ...}.
parent_id=None mueve la collection a la raiz ("Our analytics").
Metabase reubica la collection y todo su sub-arbol (colecciones hijas,
cards, dashboards, documents) atomicamente.
Args:
client: Cliente autenticado.
collection_id: ID de la collection a mover.
parent_id: ID de la collection padre destino. None = raiz.
Returns:
Collection actualizada con el nuevo parent_id y location.
Example:
>>> col = metabase_move_collection(client, 12, parent_id=3)
>>> print(col["location"]) # "/3/"
>>> # Mover a raiz:
>>> col = metabase_move_collection(client, 12, parent_id=None)
>>> print(col["location"]) # "/"
"""
return client.request(
"PUT",
f"/api/collection/{collection_id}",
json={"parent_id": parent_id},
)
+159
View File
@@ -141,3 +141,162 @@ def metabase_delete_dashboard(client: MetabaseClient, dashboard_id: int) -> None
>>> # Preferir: metabase_update_dashboard(client, 1, archived=True)
"""
client.request("DELETE", f"/api/dashboard/{dashboard_id}")
def metabase_copy_dashboard(
client: MetabaseClient,
dashboard_id: int,
name: str | None = None,
collection_id: int | None = None,
description: str | None = None,
is_deep_copy: bool = False,
) -> dict:
"""Crea una copia de un dashboard existente en Metabase.
Endpoint: POST /api/dashboard/:id/copy. Usa el endpoint nativo de Metabase para
duplicar el dashboard junto con su layout de dashcards y parametros.
Con is_deep_copy=True tambien clona las cards referenciadas.
Args:
client: Cliente autenticado.
dashboard_id: ID del dashboard a copiar.
name: Nombre para la copia. None = Metabase asigna "Copy of <nombre>".
collection_id: Coleccion destino. None = misma coleccion que el original.
description: Descripcion de la copia. None = misma que el original.
is_deep_copy: Si True, clona tambien todas las cards referenciadas por el
dashboard (deep copy). Si False, la copia referencia las cards originales.
Returns:
Dict con el dashboard nuevo creado por Metabase. Incluye el campo `id`
asignado a la copia y el layout de dashcards copiado.
Example:
>>> copy = metabase_copy_dashboard(client, 1)
>>> print(copy["id"], copy["name"]) # "Copy of ..."
>>> # Deep copy a otra coleccion:
>>> copy = metabase_copy_dashboard(client, 1, name="Sales Q2", collection_id=7, is_deep_copy=True)
"""
body: dict = {"is_deep_copy": is_deep_copy}
if name is not None:
body["name"] = name
if collection_id is not None:
body["collection_id"] = collection_id
if description is not None:
body["description"] = description
return client.request("POST", f"/api/dashboard/{dashboard_id}/copy", json=body)
def metabase_move_dashboard(
client: MetabaseClient,
dashboard_id: int,
collection_id: int | None,
) -> dict:
"""Mueve un dashboard a otra coleccion.
Wrapper thin sobre PUT /api/dashboard/:id que solo actualiza collection_id.
Equivalente a metabase_update_dashboard(client, id, collection_id=...) pero
con intencion explicita y soporte para mover a root con None.
Endpoint: PUT /api/dashboard/:id.
Args:
client: Cliente autenticado.
dashboard_id: ID del dashboard a mover.
collection_id: ID de la coleccion destino. None mueve a "Our analytics" (root).
Returns:
Dict con el dashboard actualizado, incluyendo el nuevo collection_id.
Example:
>>> dash = metabase_move_dashboard(client, 1, collection_id=7)
>>> print(dash["collection_id"]) # 7
>>> # Mover a root:
>>> dash = metabase_move_dashboard(client, 1, collection_id=None)
"""
return client.request("PUT", f"/api/dashboard/{dashboard_id}", json={"collection_id": collection_id})
def metabase_create_dashboard_raw(client: MetabaseClient, payload: dict) -> dict:
"""Crea un dashboard en Metabase con payload completo ya construido por el caller.
Version raw de metabase_create_dashboard. El caller es responsable de
construir el payload completo — no se realiza validacion ni transformacion
local. Util para flujos "Metabase as code" donde el YAML define todos los
campos del dashboard tal como los espera la API.
COMPORTAMIENTO EN DOS PASOS (limitacion de la API de Metabase):
El endpoint POST /api/dashboard NO acepta `dashcards` en el body inicial;
solo crea el dashboard vacio. Para añadir cards es necesario un PUT posterior.
Esta funcion maneja esa limitacion automaticamente:
1. Si el payload contiene `dashcards`, se extraen antes del POST.
2. POST /api/dashboard crea el dashboard vacio (sin dashcards).
3. Si habia dashcards, PUT /api/dashboard/:id con {"dashcards": [...]}
las añade al dashboard recien creado.
4. Retorna la respuesta del PUT (con dashcards pobladas), o la del POST
si el payload original no contenia dashcards.
Endpoint inicial: POST /api/dashboard.
Endpoint secundario (condicional): PUT /api/dashboard/:id.
El payload puede incluir:
- name (str, requerido): nombre del dashboard.
- description (str): descripcion.
- collection_id (int): ID de coleccion destino.
- parameters (list[dict]): filtros/parametros del dashboard.
- dashcards (list[dict]): cards posicionadas. Cada dashcard necesita:
id (int negativo para nuevas, ej: -1, -2),
card_id (int), size_x (int), size_y (int), col (int), row (int).
Campos opcionales: visualization_settings, parameter_mappings.
- tabs (list[dict]): pestañas del dashboard.
- enable_embedding (bool): habilitar embedding publico.
- embedding_params (dict): configuracion de embedding.
Si Metabase devuelve 4xx/5xx en cualquier paso, httpx lanza HTTPStatusError.
Args:
client: Cliente autenticado con sesion activa.
payload: Dict con el payload completo del dashboard tal como lo espera
la API de Metabase. Puede incluir dashcards.
Returns:
Dict con el dashboard creado. Si habia dashcards, la respuesta es la
del PUT final e incluye el campo `dashcards` con las cards posicionadas.
Si no habia dashcards, es la respuesta del POST inicial.
Example:
>>> dash = metabase_create_dashboard_raw(client, {
... "name": "Sales Overview",
... "description": "KPIs de ventas mensuales",
... "collection_id": 5,
... "parameters": [],
... "dashcards": [
... {
... "id": -1,
... "card_id": 42,
... "size_x": 6,
... "size_y": 4,
... "col": 0,
... "row": 0,
... "visualization_settings": {},
... "parameter_mappings": [],
... },
... ],
... })
>>> print(dash["id"]) # ID asignado por Metabase
>>> print(len(dash["dashcards"])) # 1
"""
body = {k: v for k, v in payload.items() if k != "dashcards"}
dashcards = payload.get("dashcards")
created = client.request("POST", "/api/dashboard", json=body)
if dashcards:
dashboard_id = created["id"]
return client.request(
"PUT",
f"/api/dashboard/{dashboard_id}",
json={"dashcards": dashcards},
)
return created
+319
View File
@@ -0,0 +1,319 @@
"""CRUD de documents de Metabase (feature reciente, Metabase 0.57+).
Un document es un texto editable en Metabase (tipo Notion) serializado como
ProseMirror JSON (content_type: application/json+vnd.prose-mirror). Permite
embeber cards, smart links y flex containers.
Nodos ProseMirror soportados (observados en Metabase v0.59):
- doc, paragraph, heading (attrs.level 1-6), text
- bulletList, orderedList, listItem
- blockquote, codeBlock (attrs.language), horizontalRule, hardBreak
- cardEmbed (attrs.id — card_id), smartLink (attrs.entityId),
flexContainer (attrs.columnWidths), resizeNode, mention
- image, iframe, table/tableRow/tableCell, callout, taskList/taskItem, details
Marks:
- bold, italic, strike, code, link (attrs.href), underline,
highlight, subscript, textStyle
"""
from .client import MetabaseClient
def metabase_list_documents(client: MetabaseClient) -> list[dict]:
"""Lista documents de Metabase.
Endpoint: GET /api/document. Retorna `{"items": [...]}` segun la API.
Args:
client: Cliente autenticado.
Returns:
Lista de dicts con: id, name, collection_id, archived, created_at,
updated_at, creator_id, content_type, entity_id.
Example:
>>> docs = metabase_list_documents(client)
>>> for d in docs:
... print(d["id"], d["name"])
"""
resp = client.request("GET", "/api/document")
if isinstance(resp, dict) and "items" in resp:
return resp["items"]
return resp or []
def metabase_get_document(client: MetabaseClient, document_id: int) -> dict:
"""Obtiene un document completo con su contenido ProseMirror.
Endpoint: GET /api/document/:id.
Args:
client: Cliente autenticado.
document_id: ID del document.
Returns:
Dict con: id, name, document (ProseMirror tree), collection_id,
archived, creator, created_at, updated_at, entity_id, content_type.
Example:
>>> doc = metabase_get_document(client, 1)
>>> print(doc["name"])
>>> tree = doc["document"] # {"type": "doc", "content": [...]}
"""
return client.request("GET", f"/api/document/{document_id}")
def metabase_create_document(
client: MetabaseClient,
name: str,
document: dict,
collection_id: int = 0,
) -> dict:
"""Crea un document nuevo.
Endpoint: POST /api/document.
Args:
client: Cliente autenticado.
name: Titulo del document (1-254 chars, no blank).
document: Arbol ProseMirror JSON. Formato minimo:
{"type": "doc", "content": [{"type": "paragraph", "content": [...]}]}
O cadena vacia "" si se quiere arrancar en blanco.
collection_id: ID de coleccion destino. 0 = root.
Returns:
Document creado con su id asignado.
Example:
>>> doc = metabase_create_document(client, "Notas", {
... "type": "doc",
... "content": [{
... "type": "paragraph",
... "content": [{"type": "text", "text": "Hola mundo"}]
... }]
... })
"""
body: dict = {"name": name, "document": document}
if collection_id > 0:
body["collection_id"] = collection_id
return client.request("POST", "/api/document", json=body)
def metabase_update_document(
client: MetabaseClient,
document_id: int,
**fields,
) -> dict:
"""Actualiza un document. Solo se envian los campos pasados.
Endpoint: PUT /api/document/:id.
Campos tipicos: name, document, collection_id, archived.
Args:
client: Cliente autenticado.
document_id: ID del document a actualizar.
**fields: Campos a modificar.
Returns:
Document actualizado.
Example:
>>> metabase_update_document(client, 1, name="Nuevo titulo")
>>> metabase_update_document(client, 1, document={"type":"doc","content":[...]})
"""
return client.request("PUT", f"/api/document/{document_id}", json=fields)
def metabase_archive_document(client: MetabaseClient, document_id: int) -> dict:
"""Archiva un document (equivalente a PUT con archived=True).
Metabase exige archive previo para poder eliminar.
Args:
client: Cliente autenticado.
document_id: ID del document.
Returns:
Document con archived=True.
"""
return client.request("PUT", f"/api/document/{document_id}", json={"archived": True})
def metabase_list_document_comments(
client: MetabaseClient,
document_id: int,
*,
include_resolved: bool = True,
include_deleted: bool = False,
) -> list[dict]:
"""Lista los comentarios de un document.
Endpoint: GET /api/comment?target_type=document&target_id=:id.
Un comentario puede estar anclado a un bloque especifico via
`child_target_id` (UUID que matchea `attrs._id` de un parrafo) o al
document entero si `child_target_id` es null. Las respuestas tipo thread
cuelgan via `parent_comment_id`.
Args:
client: Cliente autenticado.
document_id: ID del document.
include_resolved: si False, filtra comentarios con is_resolved=True.
include_deleted: si False, filtra comentarios con deleted_at no null.
Returns:
Lista de dicts con: id, content (arbol ProseMirror), creator, creator_id,
target_type, target_id, child_target_id, parent_comment_id, is_resolved,
deleted_at, reactions, created_at, updated_at.
Example:
>>> comments = metabase_list_document_comments(client, 29)
>>> for c in comments:
... text = _comment_plaintext(c["content"])
... print(f'{c["creator"]["common_name"]}: {text}')
"""
resp = client.request(
"GET",
"/api/comment",
params={"target_type": "document", "target_id": document_id},
)
items = resp.get("comments", []) if isinstance(resp, dict) else []
if not include_resolved:
items = [c for c in items if not c.get("is_resolved")]
if not include_deleted:
items = [c for c in items if c.get("deleted_at") is None]
return items
def metabase_create_document_comment(
client: MetabaseClient,
document_id: int,
content: dict,
*,
child_target_id: str | None = None,
parent_comment_id: int | None = None,
) -> dict:
"""Crea un comentario en un document.
Endpoint: POST /api/comment.
Args:
client: Cliente autenticado.
document_id: ID del document donde comentar.
content: Arbol ProseMirror del comentario. Ejemplo minimo:
{"type":"doc","content":[{"type":"paragraph","content":[
{"type":"text","text":"mi comentario"}]}]}
child_target_id: UUID del bloque del document al que se ancla el
comentario (matchea `attrs._id` de un parrafo). None = a nivel doc.
parent_comment_id: id del comentario al que se responde. None = top-level.
Returns:
Comentario creado.
"""
body: dict = {
"target_type": "document",
"target_id": document_id,
"content": content,
}
if child_target_id is not None:
body["child_target_id"] = child_target_id
if parent_comment_id is not None:
body["parent_comment_id"] = parent_comment_id
return client.request("POST", "/api/comment", json=body)
def metabase_resolve_document_comment(client: MetabaseClient, comment_id: int) -> dict:
"""Marca un comentario como resuelto (is_resolved=True).
Endpoint: PUT /api/comment/:id.
"""
return client.request("PUT", f"/api/comment/{comment_id}", json={"is_resolved": True})
def metabase_delete_document(client: MetabaseClient, document_id: int) -> None:
"""Elimina un document. REQUIERE archivarlo primero.
Endpoint: DELETE /api/document/:id. Si el document no esta archivado,
Metabase responde: "Document must be archived before it can be deleted."
Args:
client: Cliente autenticado.
document_id: ID del document a eliminar.
"""
client.request("DELETE", f"/api/document/{document_id}")
def metabase_move_document(
client: MetabaseClient,
document_id: int,
collection_id: int | None,
) -> dict:
"""Mueve un document a otra coleccion.
Thin wrapper sobre metabase_update_document: envia solo collection_id.
Endpoint: PUT /api/document/:id con {collection_id: ...}.
Args:
client: Cliente autenticado.
document_id: ID del document a mover.
collection_id: ID de coleccion destino. None mueve a la raiz ("Our analytics").
Returns:
Document actualizado con el nuevo collection_id.
Example:
>>> doc = metabase_move_document(client, 42, collection_id=7)
>>> print(doc["collection_id"]) # 7
>>> # Mover a raiz:
>>> doc = metabase_move_document(client, 42, collection_id=None)
"""
return client.request(
"PUT",
f"/api/document/{document_id}",
json={"collection_id": collection_id},
)
def metabase_copy_document(
client: MetabaseClient,
document_id: int,
name: str | None = None,
collection_id: int | None = None,
) -> dict:
"""Copia un document (Metabase no tiene endpoint nativo).
Obtiene el document original con metabase_get_document, luego crea uno
nuevo con metabase_create_document clonando el contenido ProseMirror.
Si name=None usa "{original_name} (copia)".
Si collection_id=None copia a la misma coleccion del original.
Realiza 2 requests HTTP: GET /api/document/:id + POST /api/document.
Args:
client: Cliente autenticado.
document_id: ID del document a copiar.
name: Nombre del nuevo document. None = "{original} (copia)".
collection_id: Coleccion destino. None = misma coleccion del original.
Returns:
Document nuevo recien creado con su id asignado.
Example:
>>> copy = metabase_copy_document(client, 42)
>>> print(copy["name"]) # "Mi documento (copia)"
>>> print(copy["id"]) # nuevo ID
>>> # Clonar a otra coleccion con nombre personalizado:
>>> copy = metabase_copy_document(client, 42, name="Backup Q1", collection_id=5)
"""
original = metabase_get_document(client, document_id)
new_name = name if name is not None else f"{original['name']} (copia)"
dest_collection = collection_id if collection_id is not None else original.get("collection_id", 0)
doc_content = original.get("document", "")
body: dict = {"name": new_name, "document": doc_content}
if dest_collection:
body["collection_id"] = dest_collection
return client.request("POST", "/api/document", json=body)
+419
View File
@@ -0,0 +1,419 @@
"""Mantenimiento y reparacion de cards MBQL de Metabase."""
import copy
import time
import uuid
from .client import MetabaseClient
# ---------------------------------------------------------------------------
# Helpers internos compartidos
# ---------------------------------------------------------------------------
def _new_uuid() -> str:
return str(uuid.uuid4())
def _field_name_of(node) -> tuple[str | None, str | None]:
"""Extrae (name, kind) de un node ['field'|'expression', meta, 'name']."""
if isinstance(node, list) and len(node) >= 3 and node[0] in ("field", "expression"):
nm = node[-1]
if isinstance(nm, str):
return nm, node[0]
return None, None
# ---------------------------------------------------------------------------
# metabase_fix_null_ratio
# ---------------------------------------------------------------------------
def _analyze_stage_null_ratio(stage: dict) -> tuple[dict, dict]:
"""Detecta slots vulnerables al patron SUM(a-b)/SUM(b) en una stage MBQL.
Devuelve:
vulnerable: {slot_diff: (slot_a, slot_b, expr_name)}
subtractions: {expr_name: (op_a_name, op_b_name)}
"""
subtractions = {}
for e in stage.get("expressions", []) or []:
if not (isinstance(e, list) and len(e) == 4 and e[0] == "-"):
continue
meta = e[1] if isinstance(e[1], dict) else {}
name = meta.get("lib/expression-name")
if not name:
continue
a_name, a_kind = _field_name_of(e[2])
b_name, b_kind = _field_name_of(e[3])
if a_name and b_name and a_kind == "field" and b_kind == "field":
subtractions[name] = (a_name, b_name)
aggs = stage.get("aggregation", []) or []
func_counts: dict[str, int] = {}
sum_field_to_slot: dict[str, str] = {}
sum_expr_to_slot: dict[str, str] = {}
for agg in aggs:
if not isinstance(agg, list) or not agg:
continue
func = agg[0]
func_counts[func] = func_counts.get(func, 0) + 1
slot = func if func_counts[func] == 1 else f"{func}_{func_counts[func]}"
if func == "sum" and len(agg) >= 3:
operand = agg[2]
nm, kind = _field_name_of(operand)
if kind == "field" and nm:
sum_field_to_slot[nm] = slot
elif kind == "expression" and nm:
sum_expr_to_slot[nm] = slot
vulnerable = {}
for expr_name, slot_diff in sum_expr_to_slot.items():
if expr_name not in subtractions:
continue
op_a, op_b = subtractions[expr_name]
slot_a = sum_field_to_slot.get(op_a)
slot_b = sum_field_to_slot.get(op_b)
if slot_a and slot_b:
vulnerable[slot_diff] = (slot_a, slot_b, expr_name)
return vulnerable, subtractions
def _rewrite_field_refs(node, slot_map: dict):
"""Reemplaza recursivamente [field, meta, slot] donde slot in slot_map
por [-, new_meta, [field, ..., slot_a], [field, ..., slot_b]]."""
if isinstance(node, list):
if (
len(node) >= 3
and node[0] == "field"
and isinstance(node[-1], str)
and node[-1] in slot_map
):
slot_a, slot_b, _ = slot_map[node[-1]]
base_type = (
node[1].get("base-type", "type/Decimal")
if isinstance(node[1], dict)
else "type/Decimal"
)
return [
"-",
{"lib/uuid": _new_uuid()},
["field", {"base-type": base_type, "lib/uuid": _new_uuid()}, slot_a],
["field", {"base-type": base_type, "lib/uuid": _new_uuid()}, slot_b],
]
return [_rewrite_field_refs(x, slot_map) for x in node]
return node
def _fix_card_query(dq: dict) -> list[str]:
"""Aplica el fix in-place al dataset_query. Devuelve lista de cambios."""
stages = dq.get("stages", [])
changes = []
for si, stage in enumerate(stages):
vulnerable, subtractions = _analyze_stage_null_ratio(stage)
if not vulnerable:
continue
preagg_names = set(subtractions.keys())
new_exprs = []
for e in stage.get("expressions", []) or []:
if (
isinstance(e, list)
and len(e) == 4
and e[0] == "-"
and isinstance(e[1], dict)
and e[1].get("lib/expression-name") in preagg_names
):
new_exprs.append(e)
else:
new_exprs.append(_rewrite_field_refs(e, vulnerable))
stage["expressions"] = new_exprs
for sj in range(si + 1, len(stages)):
for key in ("expressions", "aggregation", "breakout", "filters", "order-by", "fields"):
if key in stages[sj]:
stages[sj][key] = [
_rewrite_field_refs(x, vulnerable) for x in stages[sj][key]
]
for slot, (sa, sb, ename) in vulnerable.items():
changes.append(f"stage[{si}] {slot}=sum({ename!r}) -> ({sa} - {sb})")
return changes
def metabase_fix_null_ratio(
client: MetabaseClient,
*,
dry_run: bool = True,
card_ids: list[int] | None = None,
) -> dict:
"""Detecta y repara el patron SUM(a-b)/SUM(b) en cards MBQL de Metabase.
El patron vulnerable ocurre cuando una agregacion computa SUM(expr_resta)
donde expr_resta es una resta pre-agg de dos campos. Si alguna fila tiene
NULL, SUM(A-B) != SUM(A) - SUM(B). El fix reescribe las referencias
post-agg al slot diferencia para usar (SUM(A) - SUM(B)) en su lugar.
Solo procesa cards MBQL activas (type='query', no archivadas). Las cards
SQL nativas o modelos se omiten silenciosamente.
Args:
client: Cliente Metabase autenticado.
dry_run: Si True (default), escanea y reporta sin modificar nada.
Si False, aplica el fix via PUT /api/card/:id.
card_ids: Lista de IDs a procesar. None = todas las cards MBQL activas.
Returns:
dict con campos:
scanned (int): cards MBQL evaluadas.
affected (int): cards donde se detecto el patron vulnerable.
fixed (int): cards efectivamente actualizadas (0 si dry_run=True).
errors (list[dict]): lista de {card_id, error} para fallos en PUT.
Example:
>>> from metabase import MetabaseClient, metabase_fix_null_ratio
>>> c = MetabaseClient("https://metabase.example.com", "mb_apikey")
>>> report = metabase_fix_null_ratio(c, dry_run=True)
>>> print(report)
{'scanned': 312, 'affected': 4, 'fixed': 0, 'errors': []}
>>> # Para aplicar:
>>> report = metabase_fix_null_ratio(c, dry_run=False)
"""
all_cards = client.request("GET", "/api/card")
if card_ids is not None:
card_id_set = set(card_ids)
all_cards = [c for c in all_cards if c.get("id") in card_id_set]
mbql_cards = [
c for c in all_cards
if not c.get("archived", False)
and isinstance(c.get("dataset_query"), dict)
and c["dataset_query"].get("type") == "query"
and isinstance(c["dataset_query"].get("query", {}).get("stages") if "query" in c["dataset_query"] else c["dataset_query"].get("stages"), list)
]
# Metabase MBQL puede tener stages en dataset_query.query.stages (legacy)
# o en dataset_query.stages (v2). Normalizar:
def _get_stages(dq: dict) -> list | None:
if isinstance(dq.get("stages"), list):
return dq["stages"]
q = dq.get("query", {})
if isinstance(q.get("stages"), list):
return q["stages"]
return None
affected_cards = []
scanned = 0
for card in all_cards:
if card_ids is not None and card.get("id") not in set(card_ids):
continue
if card.get("archived", False):
continue
dq = card.get("dataset_query")
if not isinstance(dq, dict):
continue
stages = _get_stages(dq)
if stages is None:
continue
scanned += 1
dq_copy = copy.deepcopy(dq)
# Operar sobre el stages del objeto correcto
target = dq_copy if isinstance(dq_copy.get("stages"), list) else dq_copy.get("query", {})
changes = _fix_card_query(target)
if changes:
affected_cards.append((card, dq_copy, changes))
fixed = 0
errors: list[dict] = []
if not dry_run:
for card, dq_fixed, _changes in affected_cards:
try:
client.request("PUT", f"/api/card/{card['id']}", json={"dataset_query": dq_fixed})
fixed += 1
except Exception as exc:
errors.append({"card_id": card["id"], "error": str(exc)[:200]})
time.sleep(0.05)
return {
"scanned": scanned,
"affected": len(affected_cards),
"fixed": fixed,
"errors": errors,
}
# ---------------------------------------------------------------------------
# metabase_pair_n_n1_columns
# ---------------------------------------------------------------------------
def _slot_for_sum_field(stage: dict, target_field_name: str) -> str | None:
"""Devuelve el slot MBQL (ej: 'sum', 'sum_4') de sum(target_field) en la stage."""
aggs = stage.get("aggregation", []) or []
func_counts: dict[str, int] = {}
for agg in aggs:
if not isinstance(agg, list) or not agg:
continue
func = agg[0]
func_counts[func] = func_counts.get(func, 0) + 1
slot = func if func_counts[func] == 1 else f"{func}_{func_counts[func]}"
if func == "sum" and len(agg) >= 3:
nm, kind = _field_name_of(agg[2])
if kind == "field" and nm == target_field_name:
return slot
return None
def _find_paired_slots(dq: dict, base_name: str) -> tuple[str | None, str | None]:
"""Busca (slot_base, slot_n1) para sum(base_name) y sum(base_name_1) en el MBQL."""
for stage in (dq.get("stages") or []):
if not stage.get("aggregation"):
continue
slot_n = _slot_for_sum_field(stage, base_name)
slot_n1 = _slot_for_sum_field(stage, f"{base_name}_1")
if slot_n and slot_n1:
return slot_n, slot_n1
return None, None
def _reorder_table_columns(
cols: list[dict],
slot_n: str,
slot_n1: str,
) -> tuple[list[dict], bool, str]:
"""Habilita slot_n1 y lo reubica inmediatamente despues de slot_n.
Returns:
(new_cols, changed, reason)
"""
cols = [dict(c) for c in cols]
idx_n = next((i for i, c in enumerate(cols) if c.get("name") == slot_n), -1)
if idx_n < 0:
return cols, False, "slot_n no presente en table.columns"
idx_n1 = next((i for i, c in enumerate(cols) if c.get("name") == slot_n1), -1)
# Ya en posicion correcta y habilitada: no hay cambio
if idx_n1 == idx_n + 1 and cols[idx_n1].get("enabled") is True:
return cols, False, "ya en la posicion correcta y habilitado"
if idx_n1 < 0:
entry: dict = {"name": slot_n1, "enabled": True}
else:
entry = cols.pop(idx_n1)
entry["enabled"] = True
if idx_n1 < idx_n:
idx_n -= 1
insert_at = idx_n + 1
cols.insert(insert_at, entry)
return cols, True, "reubicado y habilitado"
def metabase_pair_n_n1_columns(
client: MetabaseClient,
*,
dry_run: bool = True,
card_ids: list[int] | None = None,
base_field: str = "Valor_vendido",
) -> dict:
"""Habilita y posiciona la columna _1 junto a su par en cards tabla/pivot de Metabase.
Para cards con display 'table' o 'pivot' que contienen agregaciones
SUM(base_field) y SUM(base_field_1), busca la columna base_field_1 en
visualization_settings.table.columns, la habilita (enabled=True) y la
reubica inmediatamente despues de base_field para comparacion visual.
Solo procesa cards con display 'table' o 'pivot' que tengan ambos slots
y tengan table.columns definido en visualization_settings.
Args:
client: Cliente Metabase autenticado.
dry_run: Si True (default), escanea y reporta sin modificar nada.
Si False, aplica el cambio via PUT /api/card/:id.
card_ids: Lista de IDs a procesar. None = todas las cards activas.
base_field: Nombre del campo base MBQL (sin sufijo _1). Por defecto
'Valor_vendido'. La funcion buscara sum(base_field) y
sum(base_field_1) en las agregaciones.
Returns:
dict con campos:
scanned (int): cards con display table/pivot evaluadas.
affected (int): cards donde se encontro el par y habia que mover.
fixed (int): cards efectivamente actualizadas (0 si dry_run=True).
skipped (int): cards ya correctas o sin table.columns.
errors (list[dict]): lista de {card_id, error} para fallos en PUT.
Example:
>>> from metabase import MetabaseClient, metabase_pair_n_n1_columns
>>> c = MetabaseClient("https://metabase.example.com", "mb_apikey")
>>> report = metabase_pair_n_n1_columns(c, dry_run=True)
>>> print(report)
{'scanned': 45, 'affected': 3, 'fixed': 0, 'skipped': 42, 'errors': []}
>>> # Con campo personalizado:
>>> report = metabase_pair_n_n1_columns(c, dry_run=False, base_field="Importe")
"""
all_cards = client.request("GET", "/api/card")
tabular_displays = {"table", "pivot"}
scanned = 0
skipped = 0
to_update: list[tuple[dict, list[dict], str, str]] = []
for card in all_cards:
if card_ids is not None and card.get("id") not in set(card_ids):
continue
if card.get("archived", False):
continue
if card.get("display") not in tabular_displays:
continue
dq = card.get("dataset_query")
if not isinstance(dq, dict):
continue
slot_n, slot_n1 = _find_paired_slots(dq, base_field)
if not (slot_n and slot_n1):
continue
scanned += 1
vs = card.get("visualization_settings") or {}
cols = vs.get("table.columns")
if not isinstance(cols, list):
skipped += 1
continue
new_cols, changed, _reason = _reorder_table_columns(cols, slot_n, slot_n1)
if not changed:
skipped += 1
continue
to_update.append((card, new_cols, slot_n, slot_n1))
fixed = 0
errors: list[dict] = []
if not dry_run:
for card, new_cols, _slot_n, _slot_n1 in to_update:
new_vs = copy.deepcopy(card.get("visualization_settings") or {})
new_vs["table.columns"] = new_cols
try:
client.request(
"PUT",
f"/api/card/{card['id']}",
json={"visualization_settings": new_vs},
)
fixed += 1
except Exception as exc:
errors.append({"card_id": card["id"], "error": str(exc)[:200]})
time.sleep(0.05)
return {
"scanned": scanned,
"affected": len(to_update),
"fixed": fixed,
"skipped": skipped,
"errors": errors,
}
@@ -0,0 +1,46 @@
---
name: metabase_add_membership
kind: function
lang: py
domain: infra
version: "1.0.0"
purity: impure
signature: "def metabase_add_membership(client: MetabaseClient, user_id: int, group_id: int, is_group_manager: bool = False) -> list[dict]"
description: "Añade un usuario a un Permission Group de Metabase. Endpoint: POST /api/permissions/membership. Retorna la lista completa de membresías del grupo tras la operación."
tags: [metabase, permissions, membership, groups, users, api, python]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: "error_go_core"
imports: []
params:
- name: client
desc: "instancia autenticada de MetabaseClient con permisos de superusuario"
- name: user_id
desc: "ID numérico del usuario a añadir al grupo"
- name: group_id
desc: "ID numérico del grupo destino"
- name: is_group_manager
desc: "si True, el usuario obtiene rol de manager del grupo (puede gestionar sus miembros)"
output: "list[dict]: lista de todas las membresías actuales del grupo tras la operación. Cada elemento contiene membership_id, user_id, group_id, is_group_manager"
tested: false
tests: []
test_file_path: ""
file_path: "python/functions/metabase/permissions.py"
---
## Ejemplo
```python
# Añadir usuario como miembro regular
members = metabase_add_membership(client, user_id=5, group_id=3)
print(len(members), "miembros en el grupo")
# Añadir como manager del grupo
members = metabase_add_membership(client, user_id=5, group_id=3, is_group_manager=True)
```
## Notas
Metabase responde con la lista completa de membresías del grupo (no solo la nueva). Lanza HTTPStatusError 400 si el usuario ya es miembro del grupo. El `membership_id` de la entrada creada está en el elemento nuevo de la lista retornada.
@@ -0,0 +1,34 @@
---
name: metabase_archive_document
kind: function
lang: py
domain: infra
version: "1.0.0"
purity: impure
signature: "def metabase_archive_document(client: MetabaseClient, document_id: int) -> dict"
description: "Archiva un document (PUT archived=True). Prerequisito para poder eliminarlo."
tags: [metabase, document, archive, api, python]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: "error_go_core"
imports: [httpx]
params:
- name: client
desc: "instancia autenticada de MetabaseClient"
- name: document_id
desc: "ID del document a archivar"
output: "dict: document con archived=True"
tested: false
tests: []
test_file_path: ""
file_path: "python/functions/metabase/documents.py"
---
## Ejemplo
```python
metabase_archive_document(client, 1)
metabase_delete_document(client, 1) # ahora si puede eliminarse
```
@@ -0,0 +1,51 @@
---
name: metabase_copy_card
kind: function
lang: py
domain: infra
version: "1.0.0"
purity: impure
signature: "def metabase_copy_card(client: MetabaseClient, card_id: int, name: str | None = None, collection_id: int | None = None, description: str | None = None) -> dict"
description: "Crea una copia de una card/pregunta en Metabase via el endpoint nativo POST /api/card/:id/copy. Permite sobrescribir nombre, coleccion y descripcion en la copia."
tags: [metabase, card, question, copy, duplicate, collection, api, python]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: "error_go_core"
imports: [httpx]
params:
- name: client
desc: "instancia autenticada de MetabaseClient"
- name: card_id
desc: "ID de la card a copiar"
- name: name
desc: "nombre para la copia; None = Metabase asigna 'Copy of <nombre>'"
- name: collection_id
desc: "ID de la coleccion destino; None = misma coleccion que el original"
- name: description
desc: "descripcion de la copia; None = hereda la del original"
output: "dict: objeto card nueva creada por Metabase, con el id asignado y todos los campos heredados del original"
tested: false
tests: []
test_file_path: ""
file_path: "python/functions/metabase/cards.py"
---
## Ejemplo
```python
# Copia simple (nombre automatico)
copy = metabase_copy_card(client, 42)
print(copy["id"], copy["name"]) # "Copy of ..."
# Copia a otra coleccion con nombre propio
copy = metabase_copy_card(client, 42, name="Revenue Q2", collection_id=7)
print(copy["collection_id"]) # 7
```
## Notas
Usa el endpoint nativo de Metabase que copia dataset_query, display y
visualization_settings. Los campos opcionales del body se omiten si son None
para que Metabase aplique sus defaults (herencia del original).
@@ -0,0 +1,54 @@
---
name: metabase_copy_dashboard
kind: function
lang: py
domain: infra
version: "1.0.0"
purity: impure
signature: "def metabase_copy_dashboard(client: MetabaseClient, dashboard_id: int, name: str | None = None, collection_id: int | None = None, description: str | None = None, is_deep_copy: bool = False) -> dict"
description: "Crea una copia de un dashboard en Metabase via POST /api/dashboard/:id/copy. Con is_deep_copy=True tambien clona las cards referenciadas."
tags: [metabase, dashboard, copy, duplicate, collection, deep_copy, api, python]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: "error_go_core"
imports: [httpx]
params:
- name: client
desc: "instancia autenticada de MetabaseClient"
- name: dashboard_id
desc: "ID del dashboard a copiar"
- name: name
desc: "nombre para la copia; None = Metabase asigna 'Copy of <nombre>'"
- name: collection_id
desc: "ID de la coleccion destino; None = misma coleccion que el original"
- name: description
desc: "descripcion de la copia; None = hereda la del original"
- name: is_deep_copy
desc: "si True, clona tambien todas las cards referenciadas (deep copy); si False, la copia referencia las cards originales"
output: "dict: objeto dashboard nuevo creado por Metabase, con id asignado y layout de dashcards copiado"
tested: false
tests: []
test_file_path: ""
file_path: "python/functions/metabase/dashboards.py"
---
## Ejemplo
```python
# Copia simple (referencia cards originales)
copy = metabase_copy_dashboard(client, 1)
print(copy["id"], copy["name"]) # "Copy of ..."
# Deep copy a otra coleccion con nombre propio
copy = metabase_copy_dashboard(client, 1, name="Sales Q2", collection_id=7, is_deep_copy=True)
print(copy["collection_id"]) # 7
```
## Notas
`is_deep_copy=True` hace que Metabase clone tambien las cards internas del
dashboard, util para crear instancias completamente independientes. Con
`is_deep_copy=False` (default), las dashcards del clon apuntan a las mismas
cards que el original — cambios en esas cards afectan ambos dashboards.
@@ -0,0 +1,52 @@
---
name: metabase_copy_document
kind: function
lang: py
domain: infra
version: "1.0.0"
purity: impure
signature: "def metabase_copy_document(client: MetabaseClient, document_id: int, name: str | None = None, collection_id: int | None = None) -> dict"
description: "Copia un document clonando su contenido ProseMirror. Metabase no tiene endpoint nativo; realiza GET + POST internamente."
tags: [metabase, document, copy, clone, prosemirror, api, python]
uses_functions: [metabase_get_document_py_infra, metabase_create_document_py_infra]
uses_types: []
returns: []
returns_optional: false
error_type: "error_go_core"
imports: [httpx]
params:
- name: client
desc: "instancia autenticada de MetabaseClient"
- name: document_id
desc: "ID del document original a copiar"
- name: name
desc: "nombre del nuevo document; None usa '{original} (copia)'"
- name: collection_id
desc: "coleccion destino; None copia a la misma coleccion del original"
output: "dict: document nuevo recien creado con id asignado y metadata completa"
tested: false
tests: []
test_file_path: ""
file_path: "python/functions/metabase/documents.py"
---
## Ejemplo
```python
# Copia simple con nombre automatico a la misma coleccion
copy = metabase_copy_document(client, 42)
print(copy["name"]) # "Mi documento (copia)"
print(copy["id"]) # nuevo ID
# Clonar a otra coleccion con nombre personalizado
copy = metabase_copy_document(client, 42, name="Backup Q1", collection_id=5)
```
## Notas
Realiza 2 requests HTTP: `GET /api/document/:id` para obtener el original y
`POST /api/document` para crear la copia con el mismo arbol ProseMirror.
Metabase no tiene endpoint `POST /api/document/:id/copy` — esta funcion implementa
la copia en cliente. Los `cardEmbed` del documento original apuntaran a los mismos
cards embebidos; no se duplican los cards embebidos.
@@ -0,0 +1,62 @@
---
name: metabase_create_card_raw
kind: function
lang: py
domain: infra
version: "1.0.0"
purity: impure
signature: "def metabase_create_card_raw(client: MetabaseClient, payload: dict) -> dict"
description: "Crea una card en Metabase enviando el payload completo sin modificaciones. Version raw para flujos Metabase-as-code donde el caller construye el body entero. Endpoint: POST /api/card."
tags: [metabase, card, question, create, api, python, raw, as-code]
uses_functions: [metabase_auth_py_infra]
uses_types: []
returns: []
returns_optional: false
error_type: "error_go_core"
imports: [httpx]
params:
- name: client
desc: "instancia autenticada de MetabaseClient con sesion activa"
- name: payload
desc: "dict con el payload completo de la card tal como lo espera la API de Metabase; campos minimos: name, dataset_query, display; campos opcionales preservados: visualization_settings, parameters, parameter_mappings, type, collection_id, description, archived, enable_embedding, embedding_params"
output: "dict: objeto card recien creado con id asignado por Metabase y todos los campos normalizados (display, dataset_query, visualization_settings, created_at, etc.)"
tested: false
tests: []
test_file_path: ""
file_path: "python/functions/metabase/cards.py"
---
## Ejemplo
```python
card = metabase_create_card_raw(client, {
"name": "Revenue by Month",
"dataset_query": {
"database": 1,
"type": "native",
"native": {"query": "SELECT date_trunc('month', created_at), SUM(total) FROM orders GROUP BY 1"},
},
"display": "line",
"visualization_settings": {
"graph.x_axis.title_text": "Month",
"graph.y_axis.title_text": "Revenue",
},
"description": "Monthly revenue trend",
"collection_id": 5,
})
print(card["id"]) # ID asignado por Metabase
```
## Notas
No se escriben tests automaticos porque requiere una instancia real de Metabase.
Los intentos de mockear `client.request` quedan fuera del alcance del registry
por ahora — la validacion se hace en integracion contra un entorno Metabase real.
A diferencia de `metabase_create_card`, esta funcion no descarta ni transforma
ningun campo del payload: lo envia tal cual al endpoint. Esto permite pasar
`visualization_settings`, `parameters`, `embedding_params`, `type`, etc. sin
que la funcion los filtre.
Si Metabase devuelve 4xx/5xx, httpx lanza `HTTPStatusError` sin capturar.
El caller debe manejar errores si necesita recuperacion.
@@ -0,0 +1,77 @@
---
name: metabase_create_dashboard_raw
kind: function
lang: py
domain: infra
version: "1.0.0"
purity: impure
signature: "def metabase_create_dashboard_raw(client: MetabaseClient, payload: dict) -> dict"
description: "Crea un dashboard en Metabase enviando el payload completo sin modificaciones. Maneja automaticamente la limitacion de la API que no acepta dashcards en el POST inicial: si el payload contiene dashcards, hace POST para crear el dashboard y luego PUT para añadir las cards. Endpoint: POST /api/dashboard (+ PUT condicional)."
tags: [metabase, dashboard, create, api, python, raw, as-code, dashcards]
uses_functions: [metabase_auth_py_infra]
uses_types: []
returns: []
returns_optional: false
error_type: "error_go_core"
imports: [httpx]
params:
- name: client
desc: "instancia autenticada de MetabaseClient con sesion activa"
- name: payload
desc: "dict con el payload completo del dashboard tal como lo espera la API de Metabase; campos soportados: name (requerido), description, collection_id, parameters, tabs, enable_embedding, embedding_params; dashcards (list[dict]): si presente, se extrae del body POST y se añade en un PUT posterior con id negativo para cards nuevas"
output: "dict: objeto dashboard creado; si habia dashcards en el payload, retorna la respuesta del PUT final con el campo dashcards poblado; si no habia dashcards, retorna la respuesta del POST inicial"
tested: false
tests: []
test_file_path: ""
file_path: "python/functions/metabase/dashboards.py"
---
## Ejemplo
```python
# Sin dashcards (solo POST)
dash = metabase_create_dashboard_raw(client, {
"name": "Sales Overview",
"description": "KPIs de ventas mensuales",
"collection_id": 5,
"parameters": [],
})
print(dash["id"])
# Con dashcards (POST + PUT automatico)
dash = metabase_create_dashboard_raw(client, {
"name": "Sales Overview",
"description": "KPIs de ventas mensuales",
"collection_id": 5,
"parameters": [],
"dashcards": [
{
"id": -1,
"card_id": 42,
"size_x": 6,
"size_y": 4,
"col": 0,
"row": 0,
"visualization_settings": {},
"parameter_mappings": [],
},
],
})
print(dash["id"])
print(len(dash["dashcards"])) # 1
```
## Notas
No se escriben tests automaticos porque requiere una instancia real de Metabase.
Los intentos de mockear `client.request` quedan fuera del alcance del registry
por ahora — la validacion se hace en integracion contra un entorno Metabase real.
La API de Metabase (al menos hasta v0.49) ignora el campo `dashcards` en el
POST inicial de creacion de dashboard. Esta funcion absorbe esa limitacion
internamente: extrae las dashcards del payload antes del POST y las envia en
un PUT separado usando el id que Metabase asigno al nuevo dashboard.
Si Metabase devuelve 4xx/5xx en cualquier paso, httpx lanza `HTTPStatusError`
sin capturar. Si el POST tiene exito pero el PUT falla, el dashboard queda
creado pero vacio — el caller debe manejar este caso si necesita atomicidad.
@@ -0,0 +1,49 @@
---
name: metabase_create_document
kind: function
lang: py
domain: infra
version: "1.0.0"
purity: impure
signature: "def metabase_create_document(client: MetabaseClient, name: str, document: dict, collection_id: int = 0) -> dict"
description: "Crea un document nuevo con contenido ProseMirror. Endpoint: POST /api/document. Soporta cardEmbed, smartLink, flexContainer, callout, taskList y demas nodos custom de Metabase."
tags: [metabase, document, create, api, prosemirror, python]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: "error_go_core"
imports: [httpx]
params:
- name: client
desc: "instancia autenticada de MetabaseClient"
- name: name
desc: "titulo del document (1-254 caracteres, no blank)"
- name: document
desc: "arbol ProseMirror JSON: {type: 'doc', content: [...]}, o '' para arrancar vacio"
- name: collection_id
desc: "ID de coleccion destino (0 = root)"
output: "dict: document recien creado con id, entity_id y metadata"
tested: false
tests: []
test_file_path: ""
file_path: "python/functions/metabase/documents.py"
---
## Ejemplo
```python
doc = metabase_create_document(client, "Notas", {
"type": "doc",
"content": [
{"type": "paragraph", "content": [{"type": "text", "text": "Hola"}]}
]
})
print(doc["id"])
```
## Notas
Nodos custom de Metabase observados (v0.59): `cardEmbed` (attrs.id=card_id), `smartLink` (attrs.entityId), `flexContainer` (attrs.columnWidths), `resizeNode`, `mention`. Marks estandar + `underline`, `highlight`, `subscript`, `textStyle`.
Cuando embebes un card via `cardEmbed`, Metabase crea una copia interna del card con `document_id` apuntando al document — no referencia el card original.
@@ -0,0 +1,59 @@
---
name: metabase_create_document_comment
kind: function
lang: py
domain: infra
version: "1.0.0"
purity: impure
signature: "def metabase_create_document_comment(client: MetabaseClient, document_id: int, content: dict, *, child_target_id: str | None = None, parent_comment_id: int | None = None) -> dict"
description: "Crea un comentario en un document. Soporta anclaje a bloque concreto (via UUID de _id) y respuestas en thread (via parent_comment_id). Endpoint: POST /api/comment."
tags: [metabase, document, comments, create, api, python]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: "error_go_core"
imports: [httpx]
params:
- name: client
desc: "instancia autenticada de MetabaseClient"
- name: document_id
desc: "ID del document donde crear el comentario"
- name: content
desc: "arbol ProseMirror del comentario: {type: doc, content: [...]}"
- name: child_target_id
desc: "UUID de bloque al que se ancla el comentario (matchea attrs._id de un parrafo). None = comentario a nivel doc"
- name: parent_comment_id
desc: "ID del comentario al que se responde. None = comentario top-level"
output: "dict: comentario creado con id, created_at, creator, reactions=[], is_resolved=False"
tested: false
tests: []
test_file_path: ""
file_path: "python/functions/metabase/documents.py"
---
## Ejemplo
```python
# Comentario top-level
metabase_create_document_comment(client, 29, {
"type": "doc",
"content": [{"type": "paragraph", "content": [
{"type": "text", "text": "Deberiamos anadir un paso para configurar Slack"}
]}]
})
# Respuesta en thread
metabase_create_document_comment(client, 29, content=reply_tree,
parent_comment_id=1)
# Anclado a un bloque concreto del documento
metabase_create_document_comment(client, 29, content=tree,
child_target_id="48f9a7a4-79a0-a282-03a1-ffe2f76b9106")
```
## Notas
`target_type` se fija internamente a `"document"` (unico valor aceptado por la API en v0.59).
El `content` sigue el mismo schema ProseMirror que los documents (ver whitelist en `metabase_validate_document_payload`).
@@ -0,0 +1,39 @@
---
name: metabase_create_group
kind: function
lang: py
domain: infra
version: "1.0.0"
purity: impure
signature: "def metabase_create_group(client: MetabaseClient, name: str) -> dict"
description: "Crea un nuevo Permission Group en Metabase. El nombre debe ser unico. Retorna el grupo creado con su id asignado. Endpoint: POST /api/permissions/group."
tags: [metabase, permissions, group, create, api, python]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: "error_go_core"
imports: []
params:
- name: client
desc: "instancia autenticada de MetabaseClient con permisos de superusuario"
- name: name
desc: "nombre del grupo, debe ser unico en la instancia Metabase"
output: "dict: grupo creado con id y name"
tested: false
tests: []
test_file_path: ""
file_path: "python/functions/metabase/permissions.py"
---
## Ejemplo
```python
group = metabase_create_group(client, "Analytics Team")
print(group["id"], group["name"])
```
## Notas
Error 400 si ya existe un grupo con ese nombre.
Para asignar usuarios al grupo recien creado usar la API de memberships (POST /api/permissions/membership).
@@ -0,0 +1,38 @@
---
name: metabase_delete_document
kind: function
lang: py
domain: infra
version: "1.0.0"
purity: impure
signature: "def metabase_delete_document(client: MetabaseClient, document_id: int) -> None"
description: "Elimina un document. Requiere archivado previo (Metabase rechaza DELETE si archived=False)."
tags: [metabase, document, delete, api, python]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: "error_go_core"
imports: [httpx]
params:
- name: client
desc: "instancia autenticada de MetabaseClient"
- name: document_id
desc: "ID del document a eliminar"
output: "None"
tested: false
tests: []
test_file_path: ""
file_path: "python/functions/metabase/documents.py"
---
## Ejemplo
```python
metabase_archive_document(client, 1)
metabase_delete_document(client, 1)
```
## Notas
Si se llama sin archivar antes, Metabase responde: `"Document must be archived before it can be deleted."`
@@ -0,0 +1,47 @@
---
name: metabase_delete_group
kind: function
lang: py
domain: infra
version: "1.0.0"
purity: impure
signature: "def metabase_delete_group(client: MetabaseClient, group_id: int) -> None"
description: "Elimina permanentemente un Permission Group de Metabase. IRREVERSIBLE. No borra los usuarios, solo el grupo. Endpoint: DELETE /api/permissions/group/:id."
tags: [metabase, permissions, group, delete, api, python]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: "error_go_core"
imports: []
params:
- name: client
desc: "instancia autenticada de MetabaseClient con permisos de superusuario"
- name: group_id
desc: "ID numerico del grupo a eliminar. ADVERTENCIA: no pasar id=1 (All Users) ni id=2 (Administrators)"
output: "None: retorna None en caso de exito (204 No Content)"
tested: false
tests: []
test_file_path: ""
file_path: "python/functions/metabase/permissions.py"
---
## Ejemplo
```python
metabase_delete_group(client, 5)
# CUIDADO: no pasar group_id=1 (All Users) ni group_id=2 (Administrators)
```
## Notas
**OPERACION IRREVERSIBLE.** El grupo se elimina permanentemente sin posibilidad de recuperacion.
Grupos especiales del sistema que NO deben borrarse:
- `id=1`: "All Users" — todos los usuarios de Metabase pertenecen automaticamente a este grupo.
- `id=2`: "Administrators" — grupo de administradores del sistema.
Esta funcion NO valida ni bloquea el borrado de esos IDs. Es responsabilidad del caller verificar que no se pasen IDs protegidos antes de invocar esta funcion.
Al borrar un grupo, los usuarios que pertenecian a el no se eliminan, solo pierden la membresia.
Error 404 si el grupo no existe.
@@ -0,0 +1,41 @@
---
name: metabase_delete_membership
kind: function
lang: py
domain: infra
version: "1.0.0"
purity: impure
signature: "def metabase_delete_membership(client: MetabaseClient, membership_id: int) -> None"
description: "Elimina una membresía de grupo en Metabase por su membership_id. Endpoint: DELETE /api/permissions/membership/:id. NO acepta user_id+group_id — requiere el membership_id exacto."
tags: [metabase, permissions, membership, groups, users, delete, api, python]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: "error_go_core"
imports: []
params:
- name: client
desc: "instancia autenticada de MetabaseClient con permisos de superusuario"
- name: membership_id
desc: "ID numérico de la membresía a eliminar. Obtenerse vía metabase_list_memberships — no es el user_id ni el group_id"
output: "None: sin valor de retorno. Lanza HTTPStatusError 404 si la membresía no existe"
tested: false
tests: []
test_file_path: ""
file_path: "python/functions/metabase/permissions.py"
---
## Ejemplo
```python
# Primero obtener el membership_id via list_memberships
all_memberships = metabase_list_memberships(client)
group_members = all_memberships.get("3", [])
membership_id = next(m["membership_id"] for m in group_members if m["user_id"] == 5)
metabase_delete_membership(client, membership_id)
```
## Notas
La API de Metabase no expone DELETE por user_id+group_id. El `membership_id` es distinto de ambos y debe obtenerse vía `metabase_list_memberships`. Esta función no bloquea el borrado de membresías en grupos del sistema (All Users, Administrators) — es responsabilidad del caller verificarlo.
@@ -0,0 +1,61 @@
---
name: metabase_fix_null_ratio
kind: function
lang: py
domain: infra
version: "1.0.0"
purity: impure
signature: "def metabase_fix_null_ratio(client: MetabaseClient, *, dry_run: bool = True, card_ids: list[int] | None = None) -> dict"
description: "Detecta y repara el patron vulnerable SUM(a-b)/SUM(b) en cards MBQL de Metabase. Cuando una resta pre-agg tiene operandos con NULL, SUM(A-B)!=SUM(A)-SUM(B). El fix reescribe las referencias post-agg al slot diferencia para usar (SUM(A)-SUM(B)) en su lugar. Soporta dry_run para escanear sin modificar."
tags: [metabase, maintenance, batch, mbql, fix, null, ratio, aggregation, python]
uses_functions: [metabase_list_cards_py_infra]
uses_types: []
returns: []
returns_optional: false
error_type: "error_go_core"
imports: [copy, time, uuid, httpx]
params:
- name: client
desc: "instancia autenticada de MetabaseClient con acceso a GET /api/card y PUT /api/card/:id"
- name: dry_run
desc: "si True (default) solo escanea y reporta sin modificar ninguna card; si False aplica el fix via PUT"
- name: card_ids
desc: "lista de IDs especificos a procesar; None = todas las cards MBQL activas no archivadas"
output: "dict con scanned (cards MBQL evaluadas), affected (cards con el patron detectado), fixed (cards modificadas, 0 en dry_run), errors (lista de {card_id, error} para fallos en PUT)"
tested: false
tests: []
test_file_path: ""
file_path: "python/functions/metabase/maintenance.py"
---
## Ejemplo
```python
from metabase import MetabaseClient, metabase_fix_null_ratio
c = MetabaseClient("https://metabase.example.com", "mb_apikey")
# Escanear sin modificar
report = metabase_fix_null_ratio(c, dry_run=True)
print(report)
# {'scanned': 312, 'affected': 4, 'fixed': 0, 'errors': []}
# Aplicar solo a cards especificas
report = metabase_fix_null_ratio(c, dry_run=False, card_ids=[101, 205, 307])
print(report)
# {'scanned': 3, 'affected': 2, 'fixed': 2, 'errors': []}
```
## Notas
El patron vulnerable ocurre en cards MBQL con esta estructura:
- expressions: `["-", meta, ["field", m, "campo_a"], ["field", m, "campo_b"]]` con nombre `Diferencia_X`
- aggregation: `["sum", meta, ["expression", m, "Diferencia_X"]]` → slot `sum_N`
- aggregation: `["sum", meta, ["field", m, "campo_a"]]` → slot `sum_A`
- aggregation: `["sum", meta, ["field", m, "campo_b"]]` → slot `sum_B`
El fix reemplaza referencias a `sum_N` en stages posteriores por `(sum_A - sum_B)`.
Las restas pre-agg originales (definiciones en expressions[]) no se tocan.
Solo procesa cards con `dataset_query.type == "query"` y stages MBQL.
Las cards SQL nativas se omiten silenciosamente. Rate-limit: 50ms entre PUTs.
@@ -0,0 +1,45 @@
---
name: metabase_get_collection_graph
kind: function
lang: py
domain: infra
version: "1.0.0"
purity: impure
signature: "def metabase_get_collection_graph(client: MetabaseClient, namespace: str | None = None) -> dict"
description: "Obtiene el grafo de permisos de colecciones de Metabase. Endpoint: GET /api/collection/graph. Soporta namespace opcional para snippet collections. El campo revision es crítico para concurrency control en el PUT posterior."
tags: [metabase, permissions, collections, graph, access-control, api, python]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: "error_go_core"
imports: []
params:
- name: client
desc: "instancia autenticada de MetabaseClient con permisos de superusuario"
- name: namespace
desc: "namespace opcional: 'snippets' para snippet collections, None para colecciones regulares"
output: "dict: grafo con revision (int) y groups (group_id -> collection_id -> 'read' | 'write' | 'none'). El campo revision es obligatorio para el PUT."
tested: false
tests: []
test_file_path: ""
file_path: "python/functions/metabase/permissions.py"
---
## Ejemplo
```python
# Colecciones regulares
graph = metabase_get_collection_graph(client)
print("revision:", graph["revision"])
for group_id, colls in graph["groups"].items():
for coll_id, access in colls.items():
print(f"group={group_id} collection={coll_id}: {access}")
# Snippet collections
snippet_graph = metabase_get_collection_graph(client, namespace="snippets")
```
## Notas
Siempre hacer GET fresco justo antes del PUT. El `revision` es el mecanismo de concurrency control nativo de Metabase — ver `metabase_update_collection_graph` para el patrón completo. Los niveles de acceso son `"read"`, `"write"` y `"none"`.
@@ -0,0 +1,38 @@
---
name: metabase_get_document
kind: function
lang: py
domain: infra
version: "1.0.0"
purity: impure
signature: "def metabase_get_document(client: MetabaseClient, document_id: int) -> dict"
description: "Obtiene un document completo con su arbol ProseMirror (campo document). Endpoint: GET /api/document/:id."
tags: [metabase, document, get, api, python]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: "error_go_core"
imports: [httpx]
params:
- name: client
desc: "instancia autenticada de MetabaseClient"
- name: document_id
desc: "ID del document a obtener"
output: "dict: objeto document con name, document (ProseMirror tree), collection_id, archived, creator, created_at, updated_at, entity_id, content_type"
tested: false
tests: []
test_file_path: ""
file_path: "python/functions/metabase/documents.py"
---
## Ejemplo
```python
doc = metabase_get_document(client, 1)
tree = doc["document"] # {"type": "doc", "content": [...]}
```
## Notas
`content_type` siempre es `application/json+vnd.prose-mirror`.
@@ -0,0 +1,41 @@
---
name: metabase_get_group
kind: function
lang: py
domain: infra
version: "1.0.0"
purity: impure
signature: "def metabase_get_group(client: MetabaseClient, group_id: int) -> dict"
description: "Obtiene un Permission Group de Metabase por ID, incluyendo la lista completa de miembros con sus datos. Endpoint: GET /api/permissions/group/:id."
tags: [metabase, permissions, group, get, members, api, python]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: "error_go_core"
imports: []
params:
- name: client
desc: "instancia autenticada de MetabaseClient con permisos de superusuario"
- name: group_id
desc: "ID numerico del grupo a consultar"
output: "dict: grupo con id, name y members (lista con user_id, email, first_name, last_name, membership_id)"
tested: false
tests: []
test_file_path: ""
file_path: "python/functions/metabase/permissions.py"
---
## Ejemplo
```python
group = metabase_get_group(client, 3)
print(group["name"])
for m in group["members"]:
print(m["email"], m["user_id"])
```
## Notas
A diferencia de `metabase_list_groups`, este endpoint retorna la lista completa de miembros del grupo.
Error 404 si el grupo no existe.
@@ -0,0 +1,39 @@
---
name: metabase_get_permission_graph
kind: function
lang: py
domain: infra
version: "1.0.0"
purity: impure
signature: "def metabase_get_permission_graph(client: MetabaseClient) -> dict"
description: "Obtiene el grafo de permisos de datos (databases/schemas/tables) de Metabase. Endpoint: GET /api/permissions/graph. El campo revision es crítico para concurrency control en el PUT posterior."
tags: [metabase, permissions, graph, databases, schemas, access-control, api, python]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: "error_go_core"
imports: []
params:
- name: client
desc: "instancia autenticada de MetabaseClient con permisos de superusuario"
output: "dict: grafo completo con revision (int) y groups (group_id -> db_id -> {schemas, native}). El campo revision es obligatorio para el PUT."
tested: false
tests: []
test_file_path: ""
file_path: "python/functions/metabase/permissions.py"
---
## Ejemplo
```python
graph = metabase_get_permission_graph(client)
print("revision:", graph["revision"])
for group_id, dbs in graph["groups"].items():
for db_id, perms in dbs.items():
print(f"group={group_id} db={db_id}: {perms}")
```
## Notas
Siempre hacer GET fresco justo antes del PUT. El `revision` es el mecanismo de concurrency control nativo de Metabase — ver `metabase_update_permission_graph` para el patrón completo.
@@ -0,0 +1,51 @@
---
name: metabase_list_document_comments
kind: function
lang: py
domain: infra
version: "1.0.0"
purity: impure
signature: "def metabase_list_document_comments(client: MetabaseClient, document_id: int, *, include_resolved: bool = True, include_deleted: bool = False) -> list[dict]"
description: "Lista los comentarios de un document (threads, anclajes por bloque UUID, reacciones). Endpoint no documentado: GET /api/comment?target_type=document&target_id=:id."
tags: [metabase, document, comments, api, python]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: "error_go_core"
imports: [httpx]
params:
- name: client
desc: "instancia autenticada de MetabaseClient"
- name: document_id
desc: "ID del document del que listar comentarios"
- name: include_resolved
desc: "si False, filtra comentarios con is_resolved=True"
- name: include_deleted
desc: "si False, filtra comentarios soft-deleted (deleted_at != null)"
output: "list[dict]: cada dict con id, content (arbol ProseMirror), creator, creator_id, target_type, target_id, child_target_id (UUID de bloque anclado o null), parent_comment_id (para threads), is_resolved, deleted_at, reactions, created_at, updated_at"
tested: false
tests: []
test_file_path: ""
file_path: "python/functions/metabase/documents.py"
---
## Ejemplo
```python
comments = metabase_list_document_comments(client, 29, include_resolved=False)
for c in comments:
author = c["creator"]["common_name"]
# content es ProseMirror — aplanar si quieres texto plano
text = "".join(n["text"] for n in _walk(c["content"]) if n.get("type") == "text")
anchor = c["child_target_id"] or "doc"
print(f'{author} @ {anchor}: {text}')
```
## Notas
**Endpoint no documentado** en la API publica de Metabase — descubierto inspeccionando trafico. target_type solo acepta el enum `"document"` en v0.59.
**Anclaje por bloque**: cuando un usuario comenta sobre un parrafo concreto en la UI, Metabase inyecta un `attrs._id` UUID en ese parrafo y guarda ese UUID en `child_target_id`. Si editas el parrafo vía YAML sin preservar el `_id`, el comentario queda huerfano.
**Threads**: los comentarios que responden a otros tienen `parent_comment_id` apuntando al padre.
@@ -0,0 +1,37 @@
---
name: metabase_list_documents
kind: function
lang: py
domain: infra
version: "1.0.0"
purity: impure
signature: "def metabase_list_documents(client: MetabaseClient) -> list[dict]"
description: "Lista documents (texto ProseMirror tipo Notion, feature 0.57+). Endpoint: GET /api/document. Desenrolla {items: [...]}."
tags: [metabase, document, list, api, python]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: "error_go_core"
imports: [httpx]
params:
- name: client
desc: "instancia autenticada de MetabaseClient"
output: "list[dict]: cada dict con id, name, collection_id, archived, created_at, updated_at, creator_id, content_type, entity_id"
tested: false
tests: []
test_file_path: ""
file_path: "python/functions/metabase/documents.py"
---
## Ejemplo
```python
docs = metabase_list_documents(client)
for d in docs:
print(d["id"], d["name"])
```
## Notas
Metabase devuelve `{"items": [...]}`. Esta funcion desenrolla para retornar la lista directamente.
@@ -0,0 +1,38 @@
---
name: metabase_list_groups
kind: function
lang: py
domain: infra
version: "1.0.0"
purity: impure
signature: "def metabase_list_groups(client: MetabaseClient) -> list[dict]"
description: "Lista todos los Permission Groups de Metabase con su member_count. Requiere superusuario. Endpoint: GET /api/permissions/group."
tags: [metabase, permissions, group, list, api, python]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: "error_go_core"
imports: []
params:
- name: client
desc: "instancia autenticada de MetabaseClient con permisos de superusuario"
output: "list[dict]: lista de grupos con id, name y member_count"
tested: false
tests: []
test_file_path: ""
file_path: "python/functions/metabase/permissions.py"
---
## Ejemplo
```python
groups = metabase_list_groups(client)
for g in groups:
print(g["id"], g["name"], g["member_count"])
```
## Notas
Incluye los grupos especiales del sistema: "All Users" (id=1) y "Administrators" (id=2).
Requiere que el cliente este autenticado como superusuario.
@@ -0,0 +1,38 @@
---
name: metabase_list_memberships
kind: function
lang: py
domain: infra
version: "1.0.0"
purity: impure
signature: "def metabase_list_memberships(client: MetabaseClient) -> dict[str, list[dict]]"
description: "Lista todas las membresías de grupos de Metabase. Endpoint: GET /api/permissions/membership. Retorna dict con group_id (str) como clave y lista de membresías como valor."
tags: [metabase, permissions, membership, groups, users, api, python]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: "error_go_core"
imports: []
params:
- name: client
desc: "instancia autenticada de MetabaseClient con permisos de superusuario"
output: "dict[str, list[dict]]: mapa de group_id (str) a lista de membresías, cada una con membership_id, user_id, group_id, is_group_manager"
tested: false
tests: []
test_file_path: ""
file_path: "python/functions/metabase/permissions.py"
---
## Ejemplo
```python
memberships = metabase_list_memberships(client)
for group_id, members in memberships.items():
for m in members:
print(m["user_id"], m["membership_id"], m["is_group_manager"])
```
## Notas
La respuesta nativa de Metabase es un dict indexado por group_id como string, no una lista plana. Para buscar la membresía de un usuario en un grupo específico, hay que iterar `memberships.get(str(group_id), [])`.
@@ -0,0 +1,76 @@
---
name: metabase_mbql_validate
kind: function
lang: py
domain: core
version: "1.0.0"
purity: pure
signature: "def metabase_mbql_validate(dataset_query: dict) -> list[str]"
description: "Valida la estructura de un dataset_query MBQL sin I/O. Detecta UUIDs duplicados, stage mixing (aggregations + expressions que referencian slots en la misma stage), slot refs rotas (sum_X inexistente), case structures invalidas y name collisions en expressions. Retorna lista de errores, vacia si el query es valido."
tags: [metabase, mbql, validation, pure, query, dataset_query]
uses_functions: []
uses_types: []
params:
- name: dataset_query
desc: "Dict completo del dataset_query MBQL tal como lo devuelve GET /api/card/:id. Debe tener clave 'stages' con lista de stage dicts. Cada stage puede tener 'expressions', 'aggregation', 'filters'."
output: "Lista de strings con errores encontrados. Lista vacia si el query supera todos los checks. Cada error incluye la ubicacion (stage[N]) y descripcion del problema."
returns: []
returns_optional: false
error_type: ""
imports: []
tested: true
tests:
- "DQ valido retorna lista vacia"
- "UUID duplicado genera error"
- "Stage mixing con slot refs genera error"
- "Slot sum_99 inexistente genera error"
- "Case con casos no pares genera error"
- "Name collision en expressions genera error"
- "stages ausente devuelve error de estructura"
test_file_path: "python/functions/metabase/test_metabase_mbql_validate.py"
file_path: "python/functions/metabase/metabase_mbql_validate.py"
---
## Checks implementados
### 1. UUIDs duplicados
Metabase requiere que cada `lib/uuid` sea unico globalmente dentro del dataset_query. Un UUID repetido (por ejemplo al copiar-pegar un nodo MBQL) causa errores silenciosos o 400 en la API.
### 2. Stage mixing
Si una stage tiene `aggregation` y `expressions`, las expressions NO deben referenciar los slot names generados por las aggregations (`sum`, `avg`, `sum_1`, etc.). Esas references deben ir en la stage siguiente. Si estan en la misma stage, Metabase retorna 500.
### 3. Slot refs rotas
Una expression `["field", {sin base-type}, "sum_X"]` referencia la X-esima aggregation de tipo sum. Si X >= cantidad de sums en la stage, el slot no existe y la query falla.
### 4. Case structure
Los nodos `["case", meta, cases]` deben tener `cases` como lista de pares `[cond, result]`. Una estructura malformada (e.g., lista de un solo elemento) causa errores de parsing en Metabase.
### 5. Name collision
Dos `expressions` con el mismo `lib/expression-name` en la misma stage generan conflictos de alias en la query SQL generada.
## Ejemplo
```python
import sys
sys.path.insert(0, '/home/lucas/fn_registry/python/functions')
from metabase import MetabaseClient
from metabase.metabase_mbql_validate import metabase_mbql_validate
client = MetabaseClient('https://metabase.example.com', 'token...')
card = client.request('GET', '/api/card/5705')
errors = metabase_mbql_validate(card['dataset_query'])
if errors:
for e in errors:
print(f'ERROR: {e}')
else:
print('Query valida')
```
## Notas
Funcion 100% pura: sin I/O, sin estado mutable, determinista. Solo stdlib Python.
Los slots reconocidos como aggregation slots son: `sum`, `avg`, `count`, `min`, `max`, `distinct`, `cum-sum`, `cum-count`, `share`, `stddev` (y sus variantes `_N`).
Un field con `base-type` en su metadata NO se considera slot ref — es una referencia a columna real. Solo los fields sin `base-type` se tratan como slots de aggregation.
@@ -0,0 +1,236 @@
"""Validacion estatica de dataset_query MBQL antes de enviarlo a Metabase."""
from __future__ import annotations
import collections
import re
from typing import Any
def metabase_mbql_validate(dataset_query: dict) -> list[str]:
"""Valida la estructura de un dataset_query MBQL sin hacer I/O.
Detecta los errores mas comunes que causan respuestas 400/500 de la API de
Metabase, permitiendo corregirlos antes del round-trip.
Checks realizados:
1. UUIDs duplicados: cualquier ``lib/uuid`` que aparezca mas de una vez en
el arbol MBQL. Metabase los requiere unicos globalmente por query.
2. Stage mixing: stages que tienen tanto ``aggregation`` como ``expressions``
donde las expressions referencian slot names (``sum``, ``sum_N``, etc.)
generados por aggregations. Esas expressions deben ir en la stage siguiente.
3. Slot refs rotos: expressions que referencian ``sum_X`` deben tener X menor
que la cantidad de sums en la stage previa (o misma).
4. Case structure: nodos ``["case", meta, cases]`` deben tener ``cases``
como lista de pares ``[cond, result]``.
5. Name collision: dos expressions con el mismo ``lib/expression-name`` en
la misma stage.
Args:
dataset_query: Dict con la estructura completa del dataset_query MBQL
tal como lo devuelve GET /api/card/:id o lo construye el caller.
Debe tener clave ``stages`` (lista de stage dicts). Si no tiene
``stages``, se devuelve error de estructura.
Returns:
Lista de strings describiendo errores encontrados. Lista vacia si el
dataset_query es valido segun todos los checks.
Example:
>>> errors = metabase_mbql_validate(card["dataset_query"])
>>> if errors:
... for e in errors:
... print(e)
... else:
... print("Query valida")
"""
errors: list[str] = []
stages = dataset_query.get("stages")
if not isinstance(stages, list):
errors.append("dataset_query.stages ausente o no es lista")
return errors
# ---- Check 1: UUIDs duplicados ------------------------------------------
uuid_locations: list[tuple[str, str]] = []
_collect_uuids(dataset_query, root="", out=uuid_locations)
uuid_counter: dict[str, list[str]] = collections.defaultdict(list)
for uid, path in uuid_locations:
uuid_counter[uid].append(path)
for uid, paths in uuid_counter.items():
if len(paths) > 1:
errors.append(
f"Duplicate lib/uuid '{uid}' aparece {len(paths)} veces: "
+ ", ".join(paths[:3])
+ ("..." if len(paths) > 3 else "")
)
# ---- Checks por stage ---------------------------------------------------
for si, stage in enumerate(stages):
if not isinstance(stage, dict):
continue
tag = f"stage[{si}]"
expressions: list[Any] = stage.get("expressions") or []
aggregations: list[Any] = stage.get("aggregation") or []
# Check 5: name collision en expressions
expr_names: list[str] = []
for expr in expressions:
name = _expr_name(expr)
if name:
if name in expr_names:
errors.append(
f"{tag} tiene dos expressions con mismo "
f"lib/expression-name '{name}'"
)
else:
expr_names.append(name)
# Check 2: stage mixing
if aggregations and expressions:
for expr in expressions:
slot_refs = _find_slot_refs(expr)
if slot_refs:
ename = _expr_name(expr) or "?"
errors.append(
f"{tag} mezcla aggregations con expressions "
f"post-agg que referencian slot names "
f"({', '.join(repr(s) for s in slot_refs)}) "
f"en expression '{ename}'. "
f"Mover esas expressions a la stage siguiente."
)
# Check 3: slot refs rotos
# Contar sums en aggregations de esta stage
sum_count = sum(1 for agg in aggregations if _agg_is_sum(agg))
for expr in expressions:
for slot in _find_slot_refs(expr):
m = re.match(r"sum(?:_(\d+))?$", slot, re.IGNORECASE)
if m:
idx = int(m.group(1)) if m.group(1) else 0
if idx >= sum_count:
ename = _expr_name(expr) or "?"
errors.append(
f"{tag} expression '{ename}' referencia "
f"'{slot}' que no existe "
f"(solo hay {sum_count} sum(s) en aggregation)"
)
# Check 4: case structure
for expr in expressions:
_check_case_structure(expr, tag, errors)
return errors
# ---------------------------------------------------------------------------
# Helpers privados
# ---------------------------------------------------------------------------
def _collect_uuids(
obj: Any,
root: str,
out: list[tuple[str, str]],
) -> None:
"""Recorre obj recursivamente y añade (uuid, path) a out."""
if isinstance(obj, dict):
if "lib/uuid" in obj:
out.append((obj["lib/uuid"], root))
for k, v in obj.items():
_collect_uuids(v, f"{root}.{k}" if root else k, out)
elif isinstance(obj, list):
for i, item in enumerate(obj):
_collect_uuids(item, f"{root}[{i}]", out)
def _expr_name(expr: Any) -> str | None:
"""Extrae lib/expression-name del segundo elemento de un nodo MBQL."""
if isinstance(expr, list) and len(expr) >= 2 and isinstance(expr[1], dict):
return expr[1].get("lib/expression-name")
return None
# Patron de slot name: word chars, puede terminar en _N
_SLOT_RE = re.compile(r"^[a-zA-Z_][a-zA-Z0-9_]*(?:_\d+)?$")
# Slots que corresponden a aggregation functions conocidas
_AGG_SLOTS = {
"sum", "avg", "count", "min", "max",
"distinct", "cum-sum", "cum-count", "share", "stddev",
}
def _find_slot_refs(obj: Any) -> list[str]:
"""Devuelve lista de slot names encontrados en refs tipo ["field", meta, slot]."""
slots: list[str] = []
_collect_slot_refs(obj, slots)
return slots
def _collect_slot_refs(obj: Any, out: list[str]) -> None:
if isinstance(obj, list):
if (
len(obj) == 3
and obj[0] == "field"
and isinstance(obj[1], dict)
and isinstance(obj[2], str)
and not obj[1].get("base-type") # field sin base-type = slot ref
and _is_slot_name(obj[2])
):
out.append(obj[2])
else:
for item in obj:
_collect_slot_refs(item, out)
elif isinstance(obj, dict):
for v in obj.values():
_collect_slot_refs(v, out)
def _is_slot_name(s: str) -> bool:
"""Devuelve True si s parece un slot name de aggregation."""
# Slot: nombre sin espacio que es una funcion de agg o variant con sufijo _N
base = re.sub(r"_\d+$", "", s)
return base in _AGG_SLOTS
def _agg_is_sum(agg: Any) -> bool:
"""Retorna True si el nodo aggregation es de tipo sum."""
if isinstance(agg, list) and len(agg) >= 1:
return str(agg[0]).lower() == "sum"
return False
def _check_case_structure(expr: Any, tag: str, errors: list[str]) -> None:
"""Valida recursivamente nodos case dentro de una expression."""
if not isinstance(expr, list):
return
if expr and expr[0] == "case":
ename = _expr_name(expr) or "?"
# Esperado: ["case", meta, [[cond, result], ...]]
if len(expr) < 3:
errors.append(
f"{tag} expression '{ename}': case con menos de 3 elementos"
)
return
cases = expr[2]
if not isinstance(cases, list):
errors.append(
f"{tag} expression '{ename}': tercer elemento de case "
f"debe ser lista de pares, got {type(cases).__name__}"
)
return
for i, pair in enumerate(cases):
if not (isinstance(pair, list) and len(pair) == 2):
errors.append(
f"{tag} expression '{ename}': case[{i}] no es par "
f"[cond, result], got {pair!r}"
)
# Recursar en ramas
for pair in cases:
if isinstance(pair, list):
for node in pair:
_check_case_structure(node, tag, errors)
else:
for item in expr:
_check_case_structure(item, tag, errors)
@@ -0,0 +1,46 @@
---
name: metabase_move_card
kind: function
lang: py
domain: infra
version: "1.0.0"
purity: impure
signature: "def metabase_move_card(client: MetabaseClient, card_id: int, collection_id: int | None) -> dict"
description: "Mueve una card/pregunta a otra coleccion via PUT /api/card/:id. Wrapper thin que solo actualiza collection_id. collection_id=None mueve a 'Our analytics' (root)."
tags: [metabase, card, question, move, collection, api, python]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: "error_go_core"
imports: [httpx]
params:
- name: client
desc: "instancia autenticada de MetabaseClient"
- name: card_id
desc: "ID de la card a mover"
- name: collection_id
desc: "ID de la coleccion destino; None mueve a 'Our analytics' (root)"
output: "dict: objeto card actualizado con el nuevo collection_id"
tested: false
tests: []
test_file_path: ""
file_path: "python/functions/metabase/cards.py"
---
## Ejemplo
```python
# Mover a una coleccion especifica
card = metabase_move_card(client, 42, collection_id=7)
print(card["collection_id"]) # 7
# Mover a root ("Our analytics")
card = metabase_move_card(client, 42, collection_id=None)
```
## Notas
Wrapper con intencion explicita sobre metabase_update_card. Envia solo
`{collection_id: ...}` en el body para no sobreescribir otros campos.
Pasar `collection_id=None` es el mecanismo de Metabase para mover a root.
@@ -0,0 +1,50 @@
---
name: metabase_move_collection
kind: function
lang: py
domain: infra
version: "1.0.0"
purity: impure
signature: "def metabase_move_collection(client: MetabaseClient, collection_id: int, parent_id: int | None) -> dict"
description: "Mueve una collection (sub-arbol completo) a otro padre. Endpoint: PUT /api/collection/:id con {parent_id: ...}."
tags: [metabase, collection, move, tree, api, python]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: "error_go_core"
imports: [httpx]
params:
- name: client
desc: "instancia autenticada de MetabaseClient"
- name: collection_id
desc: "ID de la collection a mover"
- name: parent_id
desc: "ID de la collection padre destino; None mueve a la raiz (Our analytics)"
output: "dict: collection actualizada con nuevo parent_id y location actualizado"
tested: false
tests: []
test_file_path: ""
file_path: "python/functions/metabase/collections.py"
---
## Ejemplo
```python
# Mover collection 12 dentro de collection 3
col = metabase_move_collection(client, 12, parent_id=3)
print(col["location"]) # "/3/"
# Mover a raiz
col = metabase_move_collection(client, 12, parent_id=None)
print(col["location"]) # "/"
```
## Notas
Metabase reubica atomicamente la collection y todo su sub-arbol: colecciones
hijas, cards, dashboards y documents contenidos en ellas. El campo `location`
de la respuesta refleja la nueva ruta en formato `"/parent_id/"` o `"/"` para
la raiz.
Para mover un document individual usar `metabase_move_document`.
@@ -0,0 +1,47 @@
---
name: metabase_move_dashboard
kind: function
lang: py
domain: infra
version: "1.0.0"
purity: impure
signature: "def metabase_move_dashboard(client: MetabaseClient, dashboard_id: int, collection_id: int | None) -> dict"
description: "Mueve un dashboard a otra coleccion via PUT /api/dashboard/:id. Wrapper thin que solo actualiza collection_id. collection_id=None mueve a 'Our analytics' (root)."
tags: [metabase, dashboard, move, collection, api, python]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: "error_go_core"
imports: [httpx]
params:
- name: client
desc: "instancia autenticada de MetabaseClient"
- name: dashboard_id
desc: "ID del dashboard a mover"
- name: collection_id
desc: "ID de la coleccion destino; None mueve a 'Our analytics' (root)"
output: "dict: objeto dashboard actualizado con el nuevo collection_id"
tested: false
tests: []
test_file_path: ""
file_path: "python/functions/metabase/dashboards.py"
---
## Ejemplo
```python
# Mover a una coleccion especifica
dash = metabase_move_dashboard(client, 1, collection_id=7)
print(dash["collection_id"]) # 7
# Mover a root ("Our analytics")
dash = metabase_move_dashboard(client, 1, collection_id=None)
```
## Notas
Wrapper con intencion explicita sobre metabase_update_dashboard. Envia solo
`{collection_id: ...}` en el body para no sobreescribir dashcards ni otros
campos del dashboard. Pasar `collection_id=None` es el mecanismo de Metabase
para mover a root.
@@ -0,0 +1,45 @@
---
name: metabase_move_document
kind: function
lang: py
domain: infra
version: "1.0.0"
purity: impure
signature: "def metabase_move_document(client: MetabaseClient, document_id: int, collection_id: int | None) -> dict"
description: "Mueve un document a otra coleccion. Thin wrapper sobre PUT /api/document/:id enviando solo collection_id."
tags: [metabase, document, move, collection, api, python]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: "error_go_core"
imports: [httpx]
params:
- name: client
desc: "instancia autenticada de MetabaseClient"
- name: document_id
desc: "ID del document a mover"
- name: collection_id
desc: "ID de coleccion destino; None mueve a la raiz (Our analytics)"
output: "dict: document actualizado con el nuevo collection_id"
tested: false
tests: []
test_file_path: ""
file_path: "python/functions/metabase/documents.py"
---
## Ejemplo
```python
# Mover a coleccion 7
doc = metabase_move_document(client, 42, collection_id=7)
print(doc["collection_id"]) # 7
# Mover a raiz
doc = metabase_move_document(client, 42, collection_id=None)
```
## Notas
Equivale a `metabase_update_document(client, document_id, collection_id=collection_id)` pero con
firma explicita para mayor legibilidad en codigo de migracion/reorganizacion.
@@ -0,0 +1,66 @@
---
name: metabase_pair_n_n1_columns
kind: function
lang: py
domain: infra
version: "1.0.0"
purity: impure
signature: "def metabase_pair_n_n1_columns(client: MetabaseClient, *, dry_run: bool = True, card_ids: list[int] | None = None, base_field: str = 'Valor_vendido') -> dict"
description: "Para cards Metabase con display table/pivot que agregan SUM(base_field) y SUM(base_field_1), habilita la columna base_field_1 en visualization_settings.table.columns y la posiciona inmediatamente despues de base_field para comparacion visual. Soporta dry_run y campo base configurable."
tags: [metabase, maintenance, batch, visualization, columns, table, pivot, python]
uses_functions: [metabase_list_cards_py_infra]
uses_types: []
returns: []
returns_optional: false
error_type: "error_go_core"
imports: [copy, time, httpx]
params:
- name: client
desc: "instancia autenticada de MetabaseClient con acceso a GET /api/card y PUT /api/card/:id"
- name: dry_run
desc: "si True (default) solo escanea y reporta sin modificar ninguna card; si False aplica el cambio via PUT"
- name: card_ids
desc: "lista de IDs especificos a procesar; None = todas las cards activas con display table o pivot"
- name: base_field
desc: "nombre del campo MBQL base (sin sufijo _1); la funcion busca sum(base_field) y sum(base_field_1) en las agregaciones; por defecto 'Valor_vendido'"
output: "dict con scanned (cards table/pivot con par detectado), affected (cards con columna a mover), fixed (cards modificadas, 0 en dry_run), skipped (cards ya correctas o sin table.columns), errors (lista de {card_id, error} para fallos en PUT)"
tested: false
tests: []
test_file_path: ""
file_path: "python/functions/metabase/maintenance.py"
---
## Ejemplo
```python
from metabase import MetabaseClient, metabase_pair_n_n1_columns
c = MetabaseClient("https://metabase.example.com", "mb_apikey")
# Escanear sin modificar (campo por defecto: Valor_vendido)
report = metabase_pair_n_n1_columns(c, dry_run=True)
print(report)
# {'scanned': 45, 'affected': 3, 'fixed': 0, 'skipped': 42, 'errors': []}
# Aplicar con campo personalizado
report = metabase_pair_n_n1_columns(c, dry_run=False, base_field="Importe")
print(report)
# {'scanned': 12, 'affected': 2, 'fixed': 2, 'skipped': 10, 'errors': []}
# Solo cards especificas
report = metabase_pair_n_n1_columns(c, dry_run=False, card_ids=[42, 99])
```
## Notas
Requisitos para que una card sea candidata:
1. display == "table" o "pivot"
2. dataset_query tiene aggregation con sum(base_field) y sum(base_field_1)
3. visualization_settings.table.columns es una lista
La funcion detecta los slots MBQL dinamicamente (ej: "sum", "sum_3") contando
el orden de aparicion de cada funcion de agregacion en el array. Luego busca
esos slots en table.columns por el campo "name" y reordena.
Si base_field_1 no aparece en table.columns se inserta como nueva entrada
`{"name": slot_n1, "enabled": True}`. Rate-limit: 50ms entre PUTs.
@@ -0,0 +1,40 @@
---
name: metabase_resolve_document_comment
kind: function
lang: py
domain: infra
version: "1.0.0"
purity: impure
signature: "def metabase_resolve_document_comment(client: MetabaseClient, comment_id: int) -> dict"
description: "Marca un comentario como resuelto (is_resolved=True). Los comentarios resueltos se ocultan en la UI pero siguen consultables via metabase_list_document_comments(include_resolved=True). Endpoint: PUT /api/comment/:id."
tags: [metabase, document, comments, resolve, api, python]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: "error_go_core"
imports: [httpx]
params:
- name: client
desc: "instancia autenticada de MetabaseClient"
- name: comment_id
desc: "ID del comentario a marcar como resuelto"
output: "dict: comentario actualizado con is_resolved=True"
tested: false
tests: []
test_file_path: ""
file_path: "python/functions/metabase/documents.py"
---
## Ejemplo
```python
open_comments = metabase_list_document_comments(client, 29, include_resolved=False)
for c in open_comments:
if "obsoleto" in _plaintext(c["content"]):
metabase_resolve_document_comment(client, c["id"])
```
## Notas
El endpoint `PUT /api/comment/:id` acepta cualquier campo actualizable (content, is_resolved, etc.); esta funcion solo envia `is_resolved=True` para mantener contrato estrecho. Para editar contenido usar `PUT /api/comment/:id` directo via client.
@@ -0,0 +1,64 @@
---
name: metabase_update_collection_graph
kind: function
lang: py
domain: infra
version: "1.0.0"
purity: impure
signature: "def metabase_update_collection_graph(client: MetabaseClient, graph: dict, namespace: str | None = None) -> dict"
description: "Actualiza el grafo de permisos de colecciones en Metabase. Endpoint: PUT /api/collection/graph. El campo revision en el graph es obligatorio — el servidor rechaza con 409 si no coincide con el actual. Soporta namespace para snippet collections."
tags: [metabase, permissions, collections, graph, access-control, update, api, python]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: "error_go_core"
imports: []
params:
- name: client
desc: "instancia autenticada de MetabaseClient con permisos de superusuario"
- name: graph
desc: "dict con el grafo completo incluyendo el campo revision actual. Debe obtenerse vía metabase_get_collection_graph justo antes de modificar"
- name: namespace
desc: "namespace opcional: 'snippets' para snippet collections, None para colecciones regulares. Debe coincidir con el namespace usado en el GET"
output: "dict: nuevo grafo tras la actualización con revision incrementado"
tested: false
tests: []
test_file_path: ""
file_path: "python/functions/metabase/permissions.py"
---
## Ejemplo
```python
graph = metabase_get_collection_graph(client)
# Dar acceso write al grupo 3 sobre la colección 5
graph["groups"]["3"]["5"] = "write"
updated = metabase_update_collection_graph(client, graph)
print("nueva revision:", updated["revision"])
# Para snippet collections:
graph = metabase_get_collection_graph(client, namespace="snippets")
graph["groups"]["3"]["root"] = "write"
updated = metabase_update_collection_graph(client, graph, namespace="snippets")
```
## Control de concurrencia por revision
El campo `graph["revision"]` es el mecanismo de optimistic locking nativo de Metabase.
**Patrón obligatorio:**
1. `graph = metabase_get_collection_graph(client)` — GET fresco
2. Modificar `graph["groups"][group_id][collection_id] = "read" | "write" | "none"`
3. `graph = metabase_update_collection_graph(client, graph)` — PUT con revision
**Nunca cachear el graph.** Si otro proceso modificó el graph entre el GET y el PUT, Metabase devuelve HTTP 409 Conflict y el caller debe reintentar desde el GET.
### Niveles de acceso para colecciones
- `"write"` — el grupo puede ver y editar contenido de la colección
- `"read"` — el grupo puede ver pero no editar
- `"none"` — sin acceso (la colección es invisible para el grupo)
El campo `"root"` como collection_id hace referencia a la colección raíz "Our analytics".
@@ -0,0 +1,95 @@
---
name: metabase_update_dashboard_safe
kind: function
lang: py
domain: infra
version: "1.0.0"
purity: impure
signature: "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"
description: "Wrapper sobre PUT /api/dashboard/:id que maneja los tres gotchas documentados: strip del campo '.card' denormalizado (evita 413), inclusion obligatoria de 'tabs' (evita 500 FK violation) y asignacion de IDs negativos a dashcards nuevos. Soporta reemplazo completo (dashcards_update), operaciones incrementales (add/remove) y actualizacion de campos extra del dashboard."
tags: [metabase, dashboard, dashcard, api, wrapper, safe, put]
uses_functions:
- metabase_get_dashboard_py_infra
uses_types: []
params:
- name: client
desc: "MetabaseClient autenticado con sesion activa."
- name: dashboard_id
desc: "ID entero del dashboard a actualizar."
- name: dashcards_update
desc: "Lista completa de dashcards que reemplaza el estado actual (full replace). Si se da, dashcards_add y dashcards_remove se ignoran. Cada dict puede incluir o no el campo 'card' — se strippea automaticamente."
- name: dashcards_add
desc: "Dashcards a añadir sobre los existentes. No deben incluir 'id' — la funcion asigna IDs negativos (-1, -2, ...). Se ignora si dashcards_update esta presente."
- name: dashcards_remove
desc: "Lista de IDs de dashcards existentes a eliminar del dashboard. Se aplica sobre existentes antes de añadir dashcards_add."
- name: extra_fields
desc: "Campos adicionales del dashboard a actualizar junto con las cards: name, description, parameters, archived, collection_id, etc."
output: "Dict con resumen: {'added': [negative_ids_assigned], 'updated': count_existentes_conservados, 'removed': count_eliminados, 'response': dict_respuesta_PUT}"
returns: []
returns_optional: false
error_type: "error_go_core"
imports:
- httpx
tested: false
tests: []
test_file_path: ""
file_path: "python/functions/metabase/metabase_update_dashboard_safe.py"
---
## Gotchas que maneja
### 1. 413 Payload Too Large — campo `.card` denormalizado
GET /api/dashboard/:id devuelve cada dashcard con un campo `card` que contiene el objeto completo de la question (dataset_query, visualization_settings, result_metadata, etc.). Ese campo puede pesar varios KB por dashcard. PUT /api/dashboard/:id lo rechaza con 413 si se incluye.
Esta funcion hace strip automatico, conservando solo los campos aceptados por la API:
`id`, `card_id`, `dashboard_tab_id`, `col`, `row`, `size_x`, `size_y`, `parameter_mappings`, `visualization_settings`, `series`, `action_id`, `inline_parameters`.
### 2. 500 FK violation — tabs ausentes
Si el body del PUT no incluye `tabs`, Metabase interpreta que se deben borrar todas las tabs. Los dashcards que referencian esas tabs via `dashboard_tab_id` quedan con FK dangling y se produce 500 Internal Server Error.
Esta funcion siempre hace GET del estado actual y re-incluye `current_tabs` en el body.
### 3. IDs negativos para dashcards nuevos
Los dashcards nuevos deben tener `id` negativo temporal (-1, -2, ...) en el payload del PUT. Sin el campo `id`, Metabase los ignora. Esta funcion asigna IDs negativos automaticamente a cualquier dashcard sin `id` en `dashcards_add` o en `dashcards_update`.
## Ejemplo
```python
from metabase import MetabaseClient, metabase_update_dashboard_safe
client = MetabaseClient('https://metabase.example.com', 'token...')
# 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]
print(result["updated"]) # N dashcards existentes conservados
# Reemplazar todos los dashcards y cambiar nombre
dash = client.request("GET", "/api/dashboard/42")
cards = dash["dashcards"]
cards.append({"card_id": 200, "col": 6, "row": 0, "size_x": 6, "size_y": 4})
result = metabase_update_dashboard_safe(
client,
dashboard_id=42,
dashcards_update=cards,
extra_fields={"name": "Dashboard actualizado"},
)
```
## Notas
Realiza siempre 2 requests (GET + PUT). Para operaciones de solo metadata sin tocar cards (ej. cambiar nombre), usar `metabase_update_dashboard` directamente que solo hace PUT.
En caso de 413 o 500, el mensaje de error incluye diagnostico del gotcha mas probable para facilitar debugging.
@@ -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,
}
@@ -0,0 +1,45 @@
---
name: metabase_update_document
kind: function
lang: py
domain: infra
version: "1.0.0"
purity: impure
signature: "def metabase_update_document(client: MetabaseClient, document_id: int, **fields) -> dict"
description: "Actualiza un document. Solo envia los campos pasados. Endpoint: PUT /api/document/:id."
tags: [metabase, document, update, api, python]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: "error_go_core"
imports: [httpx]
params:
- name: client
desc: "instancia autenticada de MetabaseClient"
- name: document_id
desc: "ID del document a actualizar"
- name: fields
desc: "kwargs con campos a modificar: name, document (arbol ProseMirror), collection_id, archived"
output: "dict: document actualizado"
tested: false
tests: []
test_file_path: ""
file_path: "python/functions/metabase/documents.py"
---
## Ejemplo
```python
# Renombrar
metabase_update_document(client, 1, name="Nuevo titulo")
# Reemplazar contenido completo
metabase_update_document(client, 1, document={
"type": "doc",
"content": [{"type": "paragraph", "content": [{"type": "text", "text": "Nuevo"}]}]
})
# Mover a coleccion
metabase_update_document(client, 1, collection_id=5)
```
@@ -0,0 +1,42 @@
---
name: metabase_update_group
kind: function
lang: py
domain: infra
version: "1.0.0"
purity: impure
signature: "def metabase_update_group(client: MetabaseClient, group_id: int, name: str) -> dict"
description: "Renombra un Permission Group en Metabase. La API solo permite modificar el nombre del grupo. Endpoint: PUT /api/permissions/group/:id."
tags: [metabase, permissions, group, update, rename, api, python]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: "error_go_core"
imports: []
params:
- name: client
desc: "instancia autenticada de MetabaseClient con permisos de superusuario"
- name: group_id
desc: "ID numerico del grupo a renombrar"
- name: name
desc: "nuevo nombre del grupo"
output: "dict: grupo actualizado con id y name"
tested: false
tests: []
test_file_path: ""
file_path: "python/functions/metabase/permissions.py"
---
## Ejemplo
```python
group = metabase_update_group(client, 3, "Data Team")
print(group["name"]) # "Data Team"
```
## Notas
La API de Metabase para grupos solo expone el campo `name` como modificable via PUT.
Para cambiar miembros usar la API de memberships (/api/permissions/membership).
Error 404 si el grupo no existe.
@@ -0,0 +1,73 @@
---
name: metabase_update_permission_graph
kind: function
lang: py
domain: infra
version: "1.0.0"
purity: impure
signature: "def metabase_update_permission_graph(client: MetabaseClient, graph: dict) -> dict"
description: "Actualiza el grafo de permisos de datos en Metabase. Endpoint: PUT /api/permissions/graph. El campo revision en el graph es obligatorio — el servidor rechaza con 409 si no coincide con el actual."
tags: [metabase, permissions, graph, databases, schemas, access-control, update, api, python]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: "error_go_core"
imports: []
params:
- name: client
desc: "instancia autenticada de MetabaseClient con permisos de superusuario"
- name: graph
desc: "dict con el grafo completo incluyendo el campo revision actual. Debe obtenerse vía metabase_get_permission_graph justo antes de modificar"
output: "dict: nuevo grafo tras la actualización con revision incrementado"
tested: false
tests: []
test_file_path: ""
file_path: "python/functions/metabase/permissions.py"
---
## Ejemplo
```python
graph = metabase_get_permission_graph(client)
# Dar acceso completo al grupo 3 sobre la database 1
graph["groups"]["3"]["1"] = {"schemas": "all", "native": "write"}
updated = metabase_update_permission_graph(client, graph)
print("nueva revision:", updated["revision"])
```
## Control de concurrencia por revision
El campo `graph["revision"]` es el mecanismo de optimistic locking nativo de Metabase.
**Patrón obligatorio:**
1. `graph = metabase_get_permission_graph(client)` — GET fresco
2. Modificar `graph["groups"][group_id][db_id] = ...` — editar en memoria
3. `graph = metabase_update_permission_graph(client, graph)` — PUT con revision
**Nunca cachear el graph.** Si otro proceso modificó el graph entre el GET y el PUT, Metabase devuelve HTTP 409 Conflict y el caller debe reintentar desde el GET.
### Estructura de permisos por database
```python
# Acceso completo con SQL nativo
graph["groups"]["3"]["1"] = {"schemas": "all", "native": "write"}
# Solo lectura, sin SQL nativo
graph["groups"]["3"]["1"] = {"schemas": "all", "native": "none"}
# Sin acceso
graph["groups"]["3"]["1"] = {"schemas": "none", "native": "none"}
# Acceso granular por schema/tabla
graph["groups"]["3"]["1"] = {
"schemas": {
"public": {
"orders": {"read": "all"},
"users": {"read": "none"},
}
},
"native": "none",
}
```
@@ -0,0 +1,70 @@
---
name: metabase_validate_card_payload
kind: function
lang: py
domain: infra
version: "1.0.0"
purity: pure
signature: "def metabase_validate_card_payload(payload: dict) -> list[str]"
description: "Valida la estructura de un payload de card de Metabase sin necesidad de red. Recorre todos los checks y acumula todos los issues en vez de abortar al primero. Retorna lista vacia si el payload es valido."
tags: [metabase, validation, card, pure, pre-flight, structural]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: ""
imports: []
params:
- name: payload
desc: "dict con los campos de la card a validar: name, display, dataset_query, type (opcional), visualization_settings (opcional), parameters (opcional), archived (opcional)"
output: "lista de strings describiendo cada issue estructural encontrado; lista vacia indica payload valido listo para enviarse a POST/PUT /api/card"
tested: true
tests:
- "card valido retorna lista vacia"
- "card display invalido"
- "card display ausente"
- "card name ausente"
- "card name vacio"
- "card dataset query ausente"
- "card dataset query sin database"
- "card nativa sin sql"
- "card nativa mbql5"
- "card type invalido"
- "card type valido"
- "card visualization settings no dict"
- "card parameters no list"
- "card archived no bool"
- "card acumula multiples errores"
test_file_path: "python/functions/metabase/validation_test.py"
file_path: "python/functions/metabase/validation.py"
---
## Ejemplo
```python
issues = metabase_validate_card_payload({
"name": "Revenue by Month",
"display": "line",
"dataset_query": {
"database": 1,
"type": "native",
"native": {"query": "SELECT date_trunc('month', created_at), SUM(total) FROM orders GROUP BY 1"},
},
})
if issues:
print("Payload invalido:", issues)
else:
metabase_update_card(client, card_id, **payload)
```
## Notas
Displays validos: scalar, table, line, bar, pie, area, row, funnel, smartscalar, gauge, progress, combo, pivot, map, scatter, waterfall, sankey, object.
Tipos validos (campo `type`): question, model, metric.
Soporta dos formatos de SQL nativo:
- Legacy: `dataset_query.native.query`
- MBQL5: `dataset_query.stages[0].native`
No aborta al primer error — recolecta todos los issues para que el caller pueda mostrarlos todos de una vez.
@@ -0,0 +1,63 @@
---
name: metabase_validate_dashboard_payload
kind: function
lang: py
domain: infra
version: "1.0.0"
purity: pure
signature: "def metabase_validate_dashboard_payload(payload: dict, known_card_ids: set[int]) -> list[str]"
description: "Valida la estructura de un payload de dashboard de Metabase sin red. Verifica campos obligatorios, bounds de dashcards, referencias a cards conocidas y solapamientos entre dashcards. Acumula todos los issues."
tags: [metabase, validation, dashboard, dashcard, overlap, pure, pre-flight, structural]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: ""
imports: []
params:
- name: payload
desc: "dict con los campos del dashboard a validar: name, dashcards (opcional), tabs (opcional), parameters (opcional)"
- name: known_card_ids
desc: "conjunto de IDs enteros de cards que existen en Metabase; las dashcards con card_id entero deben referenciar un ID de este conjunto. Pasar set vacio si no se quiere verificar referencias."
output: "lista de strings describiendo cada issue encontrado; lista vacia indica payload valido listo para enviarse a PUT /api/dashboard/:id"
tested: true
tests:
- "dashboard valido sin dashcards"
- "dashboard valido con dashcards"
- "dashboard card id desconocido"
- "dashboard card virtual null permitido"
- "dashboard dashcards solapadas"
- "dashboard dashcards adyacentes no solapan"
- "dashboard col fuera de bounds"
- "dashboard col mas size x excede grid"
- "dashboard size y fuera de bounds"
- "dashboard name ausente"
- "dashboard tabs invalidos"
- "dashboard parameters no list"
test_file_path: "python/functions/metabase/validation_test.py"
file_path: "python/functions/metabase/validation.py"
---
## Ejemplo
```python
known_ids = {c["id"] for c in metabase_list_cards(client)}
issues = metabase_validate_dashboard_payload(dashboard_payload, known_card_ids=known_ids)
if issues:
print("Dashboard invalido:")
for issue in issues:
print(f" - {issue}")
else:
metabase_update_dashboard(client, dashboard_id, **dashboard_payload)
```
## Notas
Bounds del grid de Metabase:
- `col` in [0, 23], `row` >= 0
- `size_x` in [1, 24], `size_y` in [1, 100]
- `col + size_x <= 24` (no exceder el ancho del grid)
Deteccion de solapamientos: O(n^2) sobre pares de dashcards. Optimo para dashboards tipicos (< 50 cards).
Dashcards con `card_id = null` son virtuales (texto, headings, iframes) y se permiten sin verificar contra known_card_ids.
@@ -0,0 +1,57 @@
---
name: metabase_validate_document_payload
kind: function
lang: py
domain: infra
version: "1.0.0"
purity: pure
signature: "def metabase_validate_document_payload(payload: dict, known_card_slugs: set[str] | None = None) -> list[str]"
description: "Valida un arbol ProseMirror contra la whitelist de nodos y marks que el editor TipTap de Metabase renderiza. Detecta nodos desconocidos que la API acepta pero el frontend descarta silenciosamente."
tags: [metabase, document, prosemirror, validate, pure, python]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: ""
imports: []
params:
- name: payload
desc: "payload del document (name, document=arbol ProseMirror, archived)"
- name: known_card_slugs
desc: "set de slugs de cards del index para validar cardEmbed.attrs.card (None = skip)"
output: "list[str]: warnings describiendo nodos/marks no soportados o violaciones del schema. Lista vacia = payload renderizable"
tested: true
tests: [test_document_valido_minimo, test_document_nodo_desconocido_callout, test_document_mark_desconocido_underline, test_document_heading_level_invalido, test_document_cardEmbed_sin_id_ni_slug, test_document_flexContainer_demasiados_hijos, test_document_kitchen_sink_valido]
test_file_path: "python/functions/metabase/validation_test.py"
file_path: "python/functions/metabase/validation.py"
---
## Ejemplo
```python
issues = metabase_validate_document_payload({
"name": "Notas",
"document": {
"type": "doc",
"content": [
{"type": "callout", "content": [...]} # ← no soportado por TipTap
],
},
})
# → ["document.content[0]: nodo 'callout' no soportado..."]
```
## Notas
**Whitelist de nodos** (derivada de inspeccionar el bundle de Metabase v0.59):
`doc, paragraph, text, heading, bulletList, orderedList, listItem, blockquote, codeBlock, horizontalRule, hardBreak, cardEmbed, flexContainer, smartLink, resizeNode, mention`
**Whitelist de marks:** `bold, italic, strike, code, link`
Nodos comunes de ProseMirror que la API acepta pero el editor **no renderiza** (el resultado es un documento vacio o incompleto en la UI): `callout, taskList, taskItem, details, table, image, iframe`. Marks equivalentes: `underline, highlight, subscript, textStyle`.
Restricciones estructurales adicionales:
- `heading.attrs.level` ∈ [1, 6]
- `flexContainer` acepta 1-3 hijos, solo `cardEmbed` o `supportingText`
- `flexContainer.attrs.columnWidths` debe tener el mismo largo que `content`
- `cardEmbed.attrs` requiere `id` (int) o `card` (slug del index)
@@ -0,0 +1,58 @@
---
name: metabase_validate_sql
kind: function
lang: py
domain: infra
version: "1.0.0"
purity: impure
signature: "def metabase_validate_sql(client: MetabaseClient, database_id: int, sql: str, max_rows: int = 0) -> dict"
description: "Valida sintaxis y referencias de una query SQL ejecutandola contra Metabase via POST /api/dataset. Captura tanto errores HTTP como errores embebidos en el body (Metabase a veces devuelve 200 con status failed). Retorna ok, error y rows_returned."
tags: [metabase, validation, sql, pre-flight, impure, query, syntax-check]
uses_functions: [metabase_execute_query_py_infra, metabase_auth_py_infra]
uses_types: []
returns: []
returns_optional: false
error_type: "error_go_core"
imports: [httpx]
params:
- name: client
desc: "instancia autenticada de MetabaseClient con sesion activa"
- name: database_id
desc: "ID de la base de datos en Metabase contra la que ejecutar el SQL"
- name: sql
desc: "sentencia SQL a validar (SELECT, WITH, etc.) — se ejecuta realmente contra la BD"
- name: max_rows
desc: "limite de filas a retornar durante la validacion para minimizar carga (0 = default de Metabase, tipicamente 2000)"
output: "dict con tres claves: ok (bool) indica si la query se ejecuto sin error; error (str|None) mensaje de error si ok=False; rows_returned (int) numero de filas devueltas si ok=True"
tested: false
tests: []
test_file_path: ""
file_path: "python/functions/metabase/validation.py"
---
## Ejemplo
```python
result = metabase_validate_sql(client, database_id=1, sql="SELECT id FROM orders LIMIT 1")
if not result["ok"]:
print(f"SQL invalido: {result['error']}")
else:
print(f"SQL valido, {result['rows_returned']} filas retornadas")
```
## Notas
No tiene tests automatizados porque requiere una instancia Metabase corriendo con una base de datos real. Para testear manualmente:
```python
client = metabase_auth("http://localhost:3000", "admin@example.com", "pass")
result = metabase_validate_sql(client, 1, "SELECT * FROM tabla_inexistente")
# result["ok"] == False, result["error"] == "Table 'tabla_inexistente' not found"
```
Comportamiento ante errores:
- `httpx.HTTPStatusError` (4xx/5xx): extrae el campo `error` o `message` del JSON del response de Metabase.
- Body con `status: "failed"` (Metabase devuelve 200 en algunos errores de query): captura el campo `error` del body.
- Cualquier otra excepcion: convierte a string y la incluye en el campo `error`.
Usa `metabase_execute_query` internamente, que mapea a POST /api/dataset.
+369
View File
@@ -0,0 +1,369 @@
"""CRUD de Permission Groups de Metabase."""
from .client import MetabaseClient
def metabase_list_groups(client: MetabaseClient) -> list[dict]:
"""Lista todos los Permission Groups de Metabase.
Endpoint: GET /api/permissions/group. Requiere superusuario.
Args:
client: Cliente autenticado con permisos admin.
Returns:
Lista de dicts, cada uno con: id, name, member_count.
Example:
>>> groups = metabase_list_groups(client)
>>> for g in groups:
... print(g["id"], g["name"], g["member_count"])
"""
return client.request("GET", "/api/permissions/group")
def metabase_get_group(client: MetabaseClient, group_id: int) -> dict:
"""Obtiene un Permission Group por su ID, incluyendo la lista completa de miembros.
Endpoint: GET /api/permissions/group/:id. Requiere superusuario.
Args:
client: Cliente autenticado con permisos admin.
group_id: ID numerico del grupo.
Returns:
Dict con: id, name, members (lista de dicts con user_id, email,
first_name, last_name, membership_id).
Raises:
httpx.HTTPStatusError: 404 si el grupo no existe.
Example:
>>> group = metabase_get_group(client, 3)
>>> print(group["name"])
>>> for m in group["members"]:
... print(m["email"])
"""
return client.request("GET", f"/api/permissions/group/{group_id}")
def metabase_create_group(client: MetabaseClient, name: str) -> dict:
"""Crea un nuevo Permission Group en Metabase.
Endpoint: POST /api/permissions/group. Requiere superusuario.
Args:
client: Cliente autenticado con permisos admin.
name: Nombre del grupo. Debe ser unico.
Returns:
Dict con el grupo creado: id, name.
Raises:
httpx.HTTPStatusError: 400 si el nombre ya existe.
Example:
>>> group = metabase_create_group(client, "Analytics Team")
>>> print(group["id"], group["name"])
"""
return client.request("POST", "/api/permissions/group", json={"name": name})
def metabase_update_group(client: MetabaseClient, group_id: int, name: str) -> dict:
"""Renombra un Permission Group existente en Metabase.
Endpoint: PUT /api/permissions/group/:id. Requiere superusuario.
La API solo permite modificar el nombre del grupo.
Args:
client: Cliente autenticado con permisos admin.
group_id: ID numerico del grupo a renombrar.
name: Nuevo nombre del grupo.
Returns:
Dict con el grupo actualizado: id, name.
Raises:
httpx.HTTPStatusError: 404 si el grupo no existe.
Example:
>>> group = metabase_update_group(client, 3, "Data Team")
>>> print(group["name"])
"""
return client.request("PUT", f"/api/permissions/group/{group_id}", json={"name": name})
def metabase_delete_group(client: MetabaseClient, group_id: int) -> None:
"""Elimina permanentemente un Permission Group de Metabase.
Endpoint: DELETE /api/permissions/group/:id. IRREVERSIBLE.
Requiere superusuario.
Los grupos especiales del sistema no deben borrarse:
- id=1: "All Users" (todos los usuarios pertenecen a este grupo)
- id=2: "Administrators"
Esta funcion NO bloquea el borrado de esos IDs — es responsabilidad
del caller verificar que no se pasen IDs protegidos.
Args:
client: Cliente autenticado con permisos admin.
group_id: ID numerico del grupo a eliminar.
Raises:
httpx.HTTPStatusError: 404 si el grupo no existe.
Example:
>>> metabase_delete_group(client, 5)
>>> # CUIDADO: no pasar group_id=1 (All Users) ni group_id=2 (Administrators)
"""
client.request("DELETE", f"/api/permissions/group/{group_id}")
# --- Memberships ---
def metabase_list_memberships(client: MetabaseClient) -> dict[str, list[dict]]:
"""Lista todas las membresías de grupos de Metabase.
Endpoint: GET /api/permissions/membership. Requiere superusuario.
La respuesta nativa de Metabase es un dict con group_id (str) como clave,
y una lista de membresías como valor — no una lista plana.
Args:
client: Cliente autenticado con permisos admin.
Returns:
Dict mapeando group_id (str) a lista de dicts, cada uno con:
membership_id, user_id, group_id, is_group_manager.
Example:
>>> memberships = metabase_list_memberships(client)
>>> for group_id, members in memberships.items():
... for m in members:
... print(m["user_id"], m["membership_id"], m["is_group_manager"])
"""
return client.request("GET", "/api/permissions/membership")
def metabase_add_membership(
client: MetabaseClient,
user_id: int,
group_id: int,
is_group_manager: bool = False,
) -> list[dict]:
"""Añade un usuario a un Permission Group de Metabase.
Endpoint: POST /api/permissions/membership. Requiere superusuario.
Args:
client: Cliente autenticado con permisos admin.
user_id: ID del usuario a añadir al grupo.
group_id: ID del grupo destino.
is_group_manager: Si True, el usuario es manager del grupo.
Returns:
Lista de dicts con todas las membresias actuales del grupo tras la operacion.
Cada elemento tiene: membership_id, user_id, group_id, is_group_manager.
Raises:
httpx.HTTPStatusError: 400 si el usuario ya es miembro del grupo.
Example:
>>> members = metabase_add_membership(client, user_id=5, group_id=3)
>>> print(len(members), "miembros en el grupo")
>>> # Como manager:
>>> members = metabase_add_membership(client, user_id=5, group_id=3, is_group_manager=True)
"""
body = {
"user_id": user_id,
"group_id": group_id,
"is_group_manager": is_group_manager,
}
return client.request("POST", "/api/permissions/membership", json=body)
def metabase_delete_membership(client: MetabaseClient, membership_id: int) -> None:
"""Elimina una membresía de grupo en Metabase por su membership_id.
Endpoint: DELETE /api/permissions/membership/:id. Requiere superusuario.
IMPORTANTE: No se borra por user_id + group_id. Hay que conocer el
membership_id exacto, que se obtiene via metabase_list_memberships.
Args:
client: Cliente autenticado con permisos admin.
membership_id: ID de la membresía a eliminar (no el user_id ni group_id).
Raises:
httpx.HTTPStatusError: 404 si la membresía no existe.
Example:
>>> # Primero obtener el membership_id
>>> all_memberships = metabase_list_memberships(client)
>>> group_members = all_memberships.get("3", [])
>>> membership_id = next(m["membership_id"] for m in group_members if m["user_id"] == 5)
>>> metabase_delete_membership(client, membership_id)
"""
client.request("DELETE", f"/api/permissions/membership/{membership_id}")
# --- Data Permission Graph ---
def metabase_get_permission_graph(client: MetabaseClient) -> dict:
"""Obtiene el grafo de permisos de datos (databases/schemas/tables) de Metabase.
Endpoint: GET /api/permissions/graph. Requiere superusuario.
El campo `revision` es CRITICO para concurrency control: el servidor rechaza
PUT /api/permissions/graph si el revision no coincide con el actual (HTTP 409).
Siempre traer el graph fresco antes de modificar.
Args:
client: Cliente autenticado con permisos admin.
Returns:
Dict con:
- revision (int): numero de revision actual. Obligatorio para el PUT.
- groups (dict): mapa group_id -> db_id -> permisos. Estructura por db:
- schemas: "all" | "none" | dict de schema -> tabla -> permisos
- native: "write" | "read" | "none" (acceso a SQL nativo)
Example:
>>> graph = metabase_get_permission_graph(client)
>>> print("revision:", graph["revision"])
>>> for group_id, dbs in graph["groups"].items():
... for db_id, perms in dbs.items():
... print(f"group={group_id} db={db_id}: {perms}")
"""
return client.request("GET", "/api/permissions/graph")
def metabase_update_permission_graph(client: MetabaseClient, graph: dict) -> dict:
"""Actualiza el grafo de permisos de datos en Metabase.
Endpoint: PUT /api/permissions/graph. Requiere superusuario.
## Control de concurrencia por revision
El campo `graph["revision"]` es obligatorio y debe ser el valor actual del
servidor. Si otro proceso modifico el graph entre tu GET y este PUT, Metabase
devuelve HTTP 409 Conflict. El patron correcto es:
1. graph = metabase_get_permission_graph(client) # GET fresco
2. Modificar graph["groups"][group_id][db_id] = ... # editar en memoria
3. graph = metabase_update_permission_graph(client, graph) # PUT con revision
Nunca cachear el graph — siempre hacer GET justo antes del PUT.
Args:
client: Cliente autenticado con permisos admin.
graph: Dict con el graph completo incluyendo el campo `revision` actual.
Obtenerlo via metabase_get_permission_graph antes de modificar.
Returns:
Dict con el nuevo graph tras la actualizacion, con `revision` incrementado.
Raises:
httpx.HTTPStatusError: 409 si el revision en el body no coincide con el actual.
Example:
>>> graph = metabase_get_permission_graph(client)
>>> # Dar acceso completo al grupo 3 sobre la database 1
>>> graph["groups"]["3"]["1"] = {"schemas": "all", "native": "write"}
>>> updated = metabase_update_permission_graph(client, graph)
>>> print("nueva revision:", updated["revision"])
"""
return client.request("PUT", "/api/permissions/graph", json=graph)
# --- Collection Permission Graph ---
def metabase_get_collection_graph(
client: MetabaseClient,
namespace: str | None = None,
) -> dict:
"""Obtiene el grafo de permisos de colecciones de Metabase.
Endpoint: GET /api/collection/graph. Requiere superusuario.
El campo `revision` es CRITICO para concurrency control: el servidor rechaza
PUT si el revision no coincide con el actual.
Args:
client: Cliente autenticado con permisos admin.
namespace: Namespace opcional. "snippets" para snippet collections.
None = colecciones regulares.
Returns:
Dict con:
- revision (int): numero de revision actual. Obligatorio para el PUT.
- groups (dict): mapa group_id -> collection_id -> nivel de acceso.
Nivel de acceso: "read" | "write" | "none".
Example:
>>> graph = metabase_get_collection_graph(client)
>>> print("revision:", graph["revision"])
>>> for group_id, colls in graph["groups"].items():
... for coll_id, access in colls.items():
... print(f"group={group_id} collection={coll_id}: {access}")
>>> # Snippet collections:
>>> snippet_graph = metabase_get_collection_graph(client, namespace="snippets")
"""
params = {}
if namespace is not None:
params["namespace"] = namespace
return client.request("GET", "/api/collection/graph", params=params or None)
def metabase_update_collection_graph(
client: MetabaseClient,
graph: dict,
namespace: str | None = None,
) -> dict:
"""Actualiza el grafo de permisos de colecciones en Metabase.
Endpoint: PUT /api/collection/graph. Requiere superusuario.
## Control de concurrencia por revision
El campo `graph["revision"]` es obligatorio y debe ser el valor actual del
servidor. Si otro proceso modifico el graph entre tu GET y este PUT, Metabase
devuelve HTTP 409 Conflict. El patron correcto es:
1. graph = metabase_get_collection_graph(client) # GET fresco
2. Modificar graph["groups"][group_id][collection_id] = ... # editar en memoria
3. graph = metabase_update_collection_graph(client, graph) # PUT con revision
Nunca cachear el graph — siempre hacer GET justo antes del PUT.
Args:
client: Cliente autenticado con permisos admin.
graph: Dict con el graph completo incluyendo el campo `revision` actual.
Obtenerlo via metabase_get_collection_graph antes de modificar.
namespace: Namespace opcional. "snippets" para snippet collections.
None = colecciones regulares.
Returns:
Dict con el nuevo graph tras la actualizacion, con `revision` incrementado.
Raises:
httpx.HTTPStatusError: 409 si el revision en el body no coincide con el actual.
Example:
>>> graph = metabase_get_collection_graph(client)
>>> # Dar acceso write al grupo 3 sobre la coleccion 5
>>> graph["groups"]["3"]["5"] = "write"
>>> updated = metabase_update_collection_graph(client, graph)
>>> print("nueva revision:", updated["revision"])
>>> # Para snippet collections:
>>> graph = metabase_get_collection_graph(client, namespace="snippets")
>>> graph["groups"]["3"]["root"] = "write"
>>> updated = metabase_update_collection_graph(client, graph, namespace="snippets")
"""
params = {}
if namespace is not None:
params["namespace"] = namespace
return client.request("PUT", "/api/collection/graph", json=graph, params=params or None)
@@ -0,0 +1,275 @@
"""Tests para metabase_mbql_validate."""
import sys
import os
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
from metabase.metabase_mbql_validate import metabase_mbql_validate
# ---------------------------------------------------------------------------
# DQ valido: estructura simplificada basada en card 5705 post-fix
# ---------------------------------------------------------------------------
VALID_DQ = {
"lib/type": "mbql/query",
"database": 6,
"stages": [
{
"lib/type": "mbql.stage/mbql",
"source-card": 100,
"aggregation": [
[
"sum",
{"lib/uuid": "agg-uuid-1"},
[
"field",
{"base-type": "type/Decimal", "lib/uuid": "field-uuid-1"},
"Cantidad",
],
]
],
"expressions": [
[
"-",
{
"lib/uuid": "expr-uuid-1",
"lib/expression-name": "Diferencia_Cantidad",
},
[
"field",
{"base-type": "type/Decimal", "lib/uuid": "field-uuid-2"},
"Valor_A",
],
[
"field",
{"base-type": "type/Decimal", "lib/uuid": "field-uuid-3"},
"Valor_B",
],
]
],
},
{
"lib/type": "mbql.stage/mbql",
"expressions": [
[
"case",
{
"lib/uuid": "case-uuid-1",
"lib/expression-name": "Valor medio de venta",
},
[
[
[
"=",
{"lib/uuid": "cond-uuid-1"},
[
"field",
{
"base-type": "type/Integer",
"lib/uuid": "field-uuid-4",
},
"Tickets",
],
0,
],
0,
],
[
[
"!=",
{"lib/uuid": "cond-uuid-2"},
[
"field",
{
"base-type": "type/Integer",
"lib/uuid": "field-uuid-5",
},
"Tickets",
],
0,
],
[
"/",
{"lib/uuid": "div-uuid-1"},
[
"field",
{
"base-type": "type/Decimal",
"lib/uuid": "field-uuid-6",
},
"Valor_vendido",
],
[
"field",
{
"base-type": "type/Integer",
"lib/uuid": "field-uuid-7",
},
"Tickets",
],
],
],
],
]
],
},
],
}
def test_valid_dq_returns_no_errors():
"""DQ valido retorna lista vacia."""
errors = metabase_mbql_validate(VALID_DQ)
assert errors == [], f"Esperaba 0 errores, got: {errors}"
# ---------------------------------------------------------------------------
# Check 1: UUID duplicado
# ---------------------------------------------------------------------------
def test_duplicate_uuid_detected():
"""UUID repetido en el arbol MBQL genera error."""
dq = {
"stages": [
{
"lib/type": "mbql.stage/mbql",
"expressions": [
[
"-",
{"lib/uuid": "dup-uuid-001", "lib/expression-name": "ExprA"},
["field", {"base-type": "type/Decimal", "lib/uuid": "dup-uuid-001"}, "CampoX"],
["field", {"base-type": "type/Decimal", "lib/uuid": "unique-uuid-002"}, "CampoY"],
]
],
}
]
}
errors = metabase_mbql_validate(dq)
assert any("dup-uuid-001" in e for e in errors), f"Esperaba error de UUID duplicado, got: {errors}"
# ---------------------------------------------------------------------------
# Check 2: Stage mixing — expressions referencian slots de aggregation
# ---------------------------------------------------------------------------
def test_stage_mixing_detected():
"""Stage con aggregations y expressions que referencian slots generados."""
dq = {
"stages": [
{
"lib/type": "mbql.stage/mbql",
"aggregation": [
["sum", {"lib/uuid": "agg1"}, ["field", {"base-type": "type/Decimal", "lib/uuid": "f1"}, "Precio"]]
],
"expressions": [
[
"*",
{"lib/uuid": "expr2", "lib/expression-name": "DobleSum"},
# field sin base-type y nombre 'sum' = slot ref de aggregation
["field", {"lib/uuid": "ref-slot"}, "sum"],
2,
]
],
}
]
}
errors = metabase_mbql_validate(dq)
assert any("mezcla" in e.lower() or "mixing" in e.lower() or "stage" in e.lower() for e in errors), \
f"Esperaba error de stage mixing, got: {errors}"
# ---------------------------------------------------------------------------
# Check 3: Slot ref rota — sum_99 inexistente
# ---------------------------------------------------------------------------
def test_broken_slot_ref_detected():
"""Expression que referencia sum_99 cuando solo hay 1 sum genera error."""
dq = {
"stages": [
{
"lib/type": "mbql.stage/mbql",
"aggregation": [
["sum", {"lib/uuid": "agg-s1"}, ["field", {"base-type": "type/Decimal", "lib/uuid": "f-s1"}, "Precio"]]
],
"expressions": [
[
"+",
{"lib/uuid": "expr-broken", "lib/expression-name": "SumRota"},
# sum_99 no existe (solo hay 1 sum = sum_0)
["field", {"lib/uuid": "ref-broken"}, "sum_99"],
1,
]
],
}
]
}
errors = metabase_mbql_validate(dq)
assert any("sum_99" in e for e in errors), f"Esperaba error de slot ref roto, got: {errors}"
# ---------------------------------------------------------------------------
# Check 4: Case con estructura invalida
# ---------------------------------------------------------------------------
def test_case_malformed_cases_not_pairs():
"""Case con casos que no son pares [cond, result] genera error."""
dq = {
"stages": [
{
"lib/type": "mbql.stage/mbql",
"expressions": [
[
"case",
{"lib/uuid": "case-bad", "lib/expression-name": "CaseMalo"},
# lista con un solo elemento (no par)
[["solo_elemento"]],
]
],
}
]
}
errors = metabase_mbql_validate(dq)
assert any("case" in e.lower() for e in errors), f"Esperaba error de case structure, got: {errors}"
# ---------------------------------------------------------------------------
# Check 5: Name collision en expressions
# ---------------------------------------------------------------------------
def test_name_collision_detected():
"""Dos expressions con mismo lib/expression-name en la misma stage generan error."""
dq = {
"stages": [
{
"lib/type": "mbql.stage/mbql",
"expressions": [
["-", {"lib/uuid": "e1", "lib/expression-name": "MiCalculo"},
["field", {"base-type": "type/Decimal", "lib/uuid": "f1"}, "A"],
["field", {"base-type": "type/Decimal", "lib/uuid": "f2"}, "B"]],
["+", {"lib/uuid": "e2", "lib/expression-name": "MiCalculo"},
["field", {"base-type": "type/Decimal", "lib/uuid": "f3"}, "C"],
1],
],
}
]
}
errors = metabase_mbql_validate(dq)
assert any("MiCalculo" in e for e in errors), f"Esperaba error de name collision, got: {errors}"
# ---------------------------------------------------------------------------
# Check: stages ausente
# ---------------------------------------------------------------------------
def test_missing_stages_returns_error():
"""dataset_query sin 'stages' devuelve error de estructura."""
errors = metabase_mbql_validate({"database": 1})
assert any("stages" in e.lower() for e in errors), f"Esperaba error de stages ausente, got: {errors}"
+505
View File
@@ -0,0 +1,505 @@
"""Validacion estructural de cards y dashboards de Metabase antes de pusharlos a la API."""
import httpx
from .client import MetabaseClient
from .cards import metabase_execute_query
_VALID_DISPLAYS = {
"scalar", "table", "line", "bar", "pie", "area", "row", "funnel",
"smartscalar", "gauge", "progress", "combo", "pivot", "map", "scatter",
"waterfall", "sankey", "object",
}
_VALID_TYPES = {"question", "model", "metric"}
def metabase_validate_card_payload(payload: dict) -> list[str]:
"""Valida la estructura de un payload de card antes de enviarlo a Metabase.
Comprueba invariantes estructurales sin necesidad de red. Recorre todos los
checks y acumula todos los issues en lugar de abortar al primero.
Args:
payload: dict con los campos de la card a validar (name, display,
dataset_query, type, visualization_settings, parameters, archived).
Returns:
Lista de strings describiendo cada issue encontrado. Lista vacia = payload valido.
Example:
>>> issues = metabase_validate_card_payload({"name": "Revenue", "display": "bar",
... "dataset_query": {"database": 1, "type": "native",
... "native": {"query": "SELECT 1"}}})
>>> assert issues == []
"""
issues: list[str] = []
# --- name ---
name = payload.get("name")
if name is None:
issues.append("campo 'name' ausente")
elif not isinstance(name, str) or not name.strip():
issues.append("campo 'name' debe ser un string no vacio")
# --- display ---
display = payload.get("display")
if display is None:
issues.append("campo 'display' ausente")
elif display not in _VALID_DISPLAYS:
validos = ", ".join(sorted(_VALID_DISPLAYS))
issues.append(f"display '{display}' invalido (validos: {validos})")
# --- type (opcional) ---
card_type = payload.get("type")
if card_type is not None and card_type not in _VALID_TYPES:
validos = ", ".join(sorted(_VALID_TYPES))
issues.append(f"type '{card_type}' invalido (validos: {validos})")
# --- dataset_query ---
dq = payload.get("dataset_query")
if dq is None:
issues.append("campo 'dataset_query' ausente")
elif not isinstance(dq, dict):
issues.append("campo 'dataset_query' debe ser un dict")
else:
# database presente
if "database" not in dq:
issues.append("'dataset_query.database' ausente")
# deteccion de query nativa
query_type = payload.get("query_type") or dq.get("type", "")
is_native = query_type == "native"
# Tambien chequear si stages[0] tiene clave "native" (formato MBQL5)
stages = dq.get("stages", [])
has_mbql5_native = (
isinstance(stages, list)
and len(stages) > 0
and isinstance(stages[0], dict)
and "native" in stages[0]
)
if is_native or has_mbql5_native:
# Formato legacy: dataset_query.native.query
legacy_sql = None
native_block = dq.get("native")
if isinstance(native_block, dict):
legacy_sql = native_block.get("query")
# Formato MBQL5: dataset_query.stages[0].native
mbql5_sql = None
if has_mbql5_native:
mbql5_sql = stages[0].get("native")
legacy_ok = isinstance(legacy_sql, str) and legacy_sql.strip()
mbql5_ok = isinstance(mbql5_sql, str) and mbql5_sql.strip()
if not legacy_ok and not mbql5_ok:
issues.append(
"query nativa sin SQL: falta 'dataset_query.native.query' "
"(legacy) o 'dataset_query.stages[0].native' (MBQL5)"
)
# --- visualization_settings (opcional) ---
vs = payload.get("visualization_settings")
if vs is not None and not isinstance(vs, dict):
issues.append("'visualization_settings' debe ser un dict")
# --- parameters (opcional) ---
params = payload.get("parameters")
if params is not None and not isinstance(params, list):
issues.append("'parameters' debe ser una list")
# --- archived (opcional) ---
archived = payload.get("archived")
if archived is not None and not isinstance(archived, bool):
issues.append("'archived' debe ser bool")
return issues
def metabase_validate_dashboard_payload(
payload: dict,
known_card_ids: set[int],
) -> list[str]:
"""Valida la estructura de un payload de dashboard antes de enviarlo a Metabase.
Verifica campos obligatorios, bounds de dashcards, referencias a cards y
solapamientos entre dashcards. Acumula todos los issues sin abortar.
Args:
payload: dict con los campos del dashboard (name, dashcards, tabs, parameters).
known_card_ids: conjunto de IDs de cards conocidos; las dashcards con
card_id entero deben referenciar un ID de este conjunto.
Returns:
Lista de strings describiendo cada issue encontrado. Lista vacia = payload valido.
Example:
>>> issues = metabase_validate_dashboard_payload(
... {"name": "KPIs", "dashcards": []},
... known_card_ids={1, 2, 3},
... )
>>> assert issues == []
"""
issues: list[str] = []
# --- name ---
name = payload.get("name")
if name is None:
issues.append("campo 'name' ausente")
elif not isinstance(name, str) or not name.strip():
issues.append("campo 'name' debe ser un string no vacio")
# --- dashcards (opcional, pero si esta, debe ser list) ---
dashcards = payload.get("dashcards")
if dashcards is not None:
if not isinstance(dashcards, list):
issues.append("'dashcards' debe ser una list")
else:
valid_rects: list[tuple[int, int, int, int, int]] = [] # (idx, row, col, sx, sy)
for i, dc in enumerate(dashcards):
if not isinstance(dc, dict):
issues.append(f"dashcard[{i}] debe ser un dict")
continue
# card_id: si es int debe estar en known_card_ids; null es virtual (ok)
card_id = dc.get("card_id")
if card_id is not None:
if not isinstance(card_id, int):
issues.append(f"dashcard[{i}].card_id debe ser int o null")
elif card_id not in known_card_ids:
issues.append(
f"dashcard[{i}].card_id={card_id} no existe en las cards conocidas"
)
# Campos de posicion y tamanio
missing = [f for f in ("row", "col", "size_x", "size_y") if f not in dc]
if missing:
issues.append(
f"dashcard[{i}] falta campos de layout: {', '.join(missing)}"
)
continue
row = dc["row"]
col = dc["col"]
size_x = dc["size_x"]
size_y = dc["size_y"]
for fname, val in (("row", row), ("col", col), ("size_x", size_x), ("size_y", size_y)):
if not isinstance(val, int):
issues.append(f"dashcard[{i}].{fname} debe ser int")
if not isinstance(row, int) or not isinstance(col, int) or \
not isinstance(size_x, int) or not isinstance(size_y, int):
continue # ya reportado arriba
# Bounds
if row < 0:
issues.append(f"dashcard[{i}].row={row} debe ser >= 0")
if not 0 <= col <= 23:
issues.append(f"dashcard[{i}].col={col} debe estar en [0, 23]")
if not 1 <= size_x <= 24:
issues.append(f"dashcard[{i}].size_x={size_x} debe estar en [1, 24]")
if not 1 <= size_y <= 100:
issues.append(f"dashcard[{i}].size_y={size_y} debe estar en [1, 100]")
if col + size_x > 24:
issues.append(
f"dashcard[{i}] excede el ancho del grid: col={col} + size_x={size_x} = {col + size_x} > 24"
)
valid_rects.append((i, row, col, size_x, size_y))
# Deteccion de solapamientos O(n^2) — dashboards tipicos tienen < 50 cards
for a in range(len(valid_rects)):
for b in range(a + 1, len(valid_rects)):
ia, ra, ca, sxa, sya = valid_rects[a]
ib, rb, cb, sxb, syb = valid_rects[b]
# Rectangulos: [ca, ca+sxa) x [ra, ra+sya) y [cb, cb+sxb) x [rb, rb+syb)
overlap_x = ca < cb + sxb and cb < ca + sxa
overlap_y = ra < rb + syb and rb < ra + sya
if overlap_x and overlap_y:
issues.append(
f"dashcards en posiciones (row={ra},col={ca},{sxa}x{sya}) "
f"y (row={rb},col={cb},{sxb}x{syb}) solapan"
)
# --- tabs (opcional) ---
tabs = payload.get("tabs")
if tabs is not None:
if not isinstance(tabs, list):
issues.append("'tabs' debe ser una list")
else:
for i, tab in enumerate(tabs):
if not isinstance(tab, dict):
issues.append(f"tabs[{i}] debe ser un dict")
continue
if "id" not in tab:
issues.append(f"tabs[{i}] falta campo 'id'")
if "name" not in tab:
issues.append(f"tabs[{i}] falta campo 'name'")
# --- parameters (opcional) ---
params = payload.get("parameters")
if params is not None and not isinstance(params, list):
issues.append("'parameters' debe ser una list")
return issues
# ---------------------------------------------------------------- Documents
# Nodos ProseMirror que el editor TipTap de Metabase (v0.59) sabe renderizar.
# Si un documento contiene nodos fuera de esta whitelist, el backend los acepta
# pero el frontend silenciosamente descarta contenido y el doc aparece vacio o
# incompleto.
_VALID_DOC_NODES = {
"doc", "paragraph", "text", "heading",
"bulletList", "orderedList", "listItem",
"blockquote", "codeBlock", "horizontalRule", "hardBreak",
"cardEmbed", "flexContainer", "smartLink", "resizeNode", "mention",
}
_VALID_DOC_MARKS = {"bold", "italic", "strike", "code", "link"}
# Rangos de attrs.level en headings
_HEADING_LEVELS = {1, 2, 3, 4, 5, 6}
def metabase_validate_document_payload(
payload: dict,
known_card_slugs: set[str] | None = None,
) -> list[str]:
"""Valida un payload de document antes de pusharlo a Metabase.
El editor TipTap de Metabase solo renderiza un subconjunto concreto de
nodos y marks ProseMirror. La API *acepta* cualquier arbol, pero el
frontend silenciosamente descarta lo que no conoce. Este validador
rechaza (como warning) cualquier nodo o mark fuera de la whitelist.
Comprueba tambien restricciones estructurales:
- `heading.attrs.level` en [1, 6].
- `flexContainer` solo contiene `cardEmbed` o `supportingText`,
y como maximo 3 hijos.
- `cardEmbed.attrs` debe resolverse a un card real (por `id` o por
`card` slug si el caller pasa `known_card_slugs`).
Args:
payload: dict del document listo para POST/PUT (con `name` y `document`).
known_card_slugs: set de slugs conocidos en el index (para validar
`cardEmbed.attrs.card`). None = skip check.
Returns:
Lista de warnings. Lista vacia = payload renderizable.
"""
issues: list[str] = []
# --- name ---
name = payload.get("name")
if name is None:
issues.append("campo 'name' ausente")
elif not isinstance(name, str) or not name.strip():
issues.append("campo 'name' debe ser un string no vacio")
elif len(name) > 254:
issues.append(f"'name' excede 254 chars ({len(name)})")
# --- archived ---
archived = payload.get("archived")
if archived is not None and not isinstance(archived, bool):
issues.append("'archived' debe ser bool")
# --- document (arbol ProseMirror) ---
tree = payload.get("document")
if tree is None:
issues.append("campo 'document' ausente")
return issues
if tree == "":
# Document vacio es valido (Metabase lo acepta)
return issues
if not isinstance(tree, dict):
issues.append(f"'document' debe ser dict o string vacia, no {type(tree).__name__}")
return issues
if tree.get("type") != "doc":
issues.append(f"'document.type' debe ser 'doc', no '{tree.get('type')}'")
# Walk recursivo acumulando issues con path
_walk_doc_node(tree, "document", issues, known_card_slugs or set())
return issues
def _walk_doc_node(
node: dict,
path: str,
issues: list[str],
known_card_slugs: set[str],
) -> None:
"""Valida un nodo ProseMirror y desciende por sus hijos."""
if not isinstance(node, dict):
issues.append(f"{path}: nodo no es dict ({type(node).__name__})")
return
ntype = node.get("type")
if not isinstance(ntype, str):
issues.append(f"{path}: campo 'type' ausente o no string")
return
if ntype not in _VALID_DOC_NODES:
issues.append(
f"{path}: nodo '{ntype}' no soportado por el editor de Metabase. "
f"Validos: {sorted(_VALID_DOC_NODES)}"
)
# Seguir igualmente — puede haber issues mas adentro
# --- Validaciones especificas por tipo ---
attrs = node.get("attrs") or {}
if ntype == "heading":
level = attrs.get("level")
if level not in _HEADING_LEVELS:
issues.append(f"{path}: heading.level={level!r} debe estar en {sorted(_HEADING_LEVELS)}")
if ntype == "cardEmbed":
# cardEmbed requiere o bien attrs.id (int) o attrs.card (slug del index)
cid = attrs.get("id")
cslug = attrs.get("card")
if cid is None and cslug is None:
issues.append(f"{path}: cardEmbed sin 'attrs.id' ni 'attrs.card'")
elif cid is not None and not isinstance(cid, int):
issues.append(f"{path}: cardEmbed.attrs.id debe ser int, no {type(cid).__name__}")
elif cslug is not None:
if not isinstance(cslug, str):
issues.append(f"{path}: cardEmbed.attrs.card debe ser string slug")
elif known_card_slugs and cslug not in known_card_slugs:
issues.append(
f"{path}: cardEmbed.attrs.card='{cslug}' no existe en el index "
f"(conocidos: {sorted(known_card_slugs)[:10]}...)"
)
if ntype == "flexContainer":
children = node.get("content") or []
if not isinstance(children, list):
issues.append(f"{path}: flexContainer.content debe ser lista")
else:
if not 1 <= len(children) <= 3:
issues.append(
f"{path}: flexContainer debe tener 1-3 hijos (tiene {len(children)})"
)
for i, ch in enumerate(children):
ct = ch.get("type") if isinstance(ch, dict) else None
if ct not in ("cardEmbed", "supportingText"):
issues.append(
f"{path}.content[{i}]: flexContainer solo acepta 'cardEmbed' "
f"o 'supportingText' como hijos (tiene '{ct}')"
)
cw = attrs.get("columnWidths")
if cw is not None:
if not isinstance(cw, list) or not all(isinstance(x, (int, float)) for x in cw):
issues.append(f"{path}: flexContainer.attrs.columnWidths debe ser lista de numeros")
elif isinstance(children, list) and len(cw) != len(children):
issues.append(
f"{path}: columnWidths tiene {len(cw)} valores pero hay {len(children)} hijos"
)
if ntype == "smartLink":
# smartLink necesita entityId (id numerico del card en Metabase)
if attrs.get("entityId") is None:
issues.append(f"{path}: smartLink sin 'attrs.entityId'")
if ntype == "text":
if not isinstance(node.get("text"), str):
issues.append(f"{path}: text sin campo 'text' string")
# Validar marks
marks = node.get("marks") or []
if not isinstance(marks, list):
issues.append(f"{path}: 'marks' debe ser lista")
else:
for i, m in enumerate(marks):
if not isinstance(m, dict):
issues.append(f"{path}.marks[{i}]: mark no es dict")
continue
mt = m.get("type")
if mt not in _VALID_DOC_MARKS:
issues.append(
f"{path}.marks[{i}]: mark '{mt}' no soportado. "
f"Validos: {sorted(_VALID_DOC_MARKS)}"
)
# --- Recursion sobre content ---
content = node.get("content")
if isinstance(content, list):
for i, child in enumerate(content):
_walk_doc_node(child, f"{path}.content[{i}]", issues, known_card_slugs)
def metabase_validate_sql(
client: MetabaseClient,
database_id: int,
sql: str,
max_rows: int = 0,
) -> dict:
"""Valida sintaxis y referencias de SQL ejecutandolo contra Metabase.
Ejecuta la query via POST /api/dataset con LIMIT implicito (max_rows=1 si
el caller no especifica nada, para minimizar carga). Captura tanto errores
HTTP como errores embebidos en el body (Metabase a veces devuelve 200 + status failed).
Args:
client: instancia autenticada de MetabaseClient.
database_id: ID de la base de datos donde ejecutar el SQL.
sql: sentencia SQL a validar (SELECT, WITH, etc.).
max_rows: limite de filas a retornar para la validacion (0 = default de Metabase).
Returns:
dict con:
ok (bool): True si la query se ejecuto sin errores.
error (str|None): mensaje de error si ok=False, None si ok=True.
rows_returned (int): numero de filas devueltas si ok=True.
Example:
>>> result = metabase_validate_sql(client, 1, "SELECT id FROM orders LIMIT 1")
>>> if not result["ok"]:
... print("SQL invalido:", result["error"])
"""
try:
response = metabase_execute_query(client, database_id, sql, max_rows)
except httpx.HTTPStatusError as exc:
# Intentar extraer mensaje del body JSON de Metabase
error_msg = _extract_metabase_error(exc)
return {"ok": False, "error": error_msg, "rows_returned": 0}
except Exception as exc:
return {"ok": False, "error": str(exc), "rows_returned": 0}
# Metabase puede devolver 200 con status: "failed" en el body
status = response.get("status") if isinstance(response, dict) else None
if status == "failed":
error_msg = response.get("error") or "query fallida (sin mensaje)"
return {"ok": False, "error": error_msg, "rows_returned": 0}
# Contar filas retornadas
rows_returned = 0
if isinstance(response, dict):
data = response.get("data", {})
if isinstance(data, dict):
rows = data.get("rows", [])
if isinstance(rows, list):
rows_returned = len(rows)
return {"ok": True, "error": None, "rows_returned": rows_returned}
def _extract_metabase_error(exc: httpx.HTTPStatusError) -> str:
"""Extrae el mensaje de error legible del response de Metabase."""
try:
body = exc.response.json()
if isinstance(body, dict):
return body.get("error") or body.get("message") or str(exc)
except Exception:
pass
return str(exc)
@@ -0,0 +1,389 @@
"""Tests para metabase_validate_card_payload y metabase_validate_dashboard_payload."""
import sys
sys.path.insert(0, "/home/lucas/fn_registry/python/functions")
from metabase.validation import (
metabase_validate_card_payload,
metabase_validate_dashboard_payload,
)
# ---------------------------------------------------------------------------
# metabase_validate_card_payload
# ---------------------------------------------------------------------------
def _base_card() -> dict:
return {
"name": "Revenue by Month",
"display": "line",
"dataset_query": {
"database": 1,
"type": "native",
"native": {"query": "SELECT 1"},
},
}
def test_card_valido_retorna_lista_vacia():
issues = metabase_validate_card_payload(_base_card())
assert issues == [], f"Se esperaba lista vacia, got: {issues}"
def test_card_display_invalido():
payload = _base_card()
payload["display"] = "foobar"
issues = metabase_validate_card_payload(payload)
assert any("display" in i and "foobar" in i for i in issues), issues
def test_card_display_ausente():
payload = _base_card()
del payload["display"]
issues = metabase_validate_card_payload(payload)
assert any("display" in i and "ausente" in i for i in issues), issues
def test_card_name_ausente():
payload = _base_card()
del payload["name"]
issues = metabase_validate_card_payload(payload)
assert any("name" in i and "ausente" in i for i in issues), issues
def test_card_name_vacio():
payload = _base_card()
payload["name"] = " "
issues = metabase_validate_card_payload(payload)
assert any("name" in i for i in issues), issues
def test_card_dataset_query_ausente():
payload = _base_card()
del payload["dataset_query"]
issues = metabase_validate_card_payload(payload)
assert any("dataset_query" in i and "ausente" in i for i in issues), issues
def test_card_dataset_query_sin_database():
payload = _base_card()
del payload["dataset_query"]["database"]
issues = metabase_validate_card_payload(payload)
assert any("database" in i for i in issues), issues
def test_card_nativa_sin_sql():
payload = _base_card()
payload["dataset_query"]["native"]["query"] = ""
issues = metabase_validate_card_payload(payload)
assert any("SQL" in i or "native" in i for i in issues), issues
def test_card_nativa_mbql5():
"""Acepta SQL en stages[0].native (formato MBQL5)."""
payload = {
"name": "MBQL5 card",
"display": "table",
"dataset_query": {
"database": 2,
"stages": [{"native": "SELECT 1"}],
},
}
issues = metabase_validate_card_payload(payload)
assert issues == [], f"Se esperaba lista vacia, got: {issues}"
def test_card_type_invalido():
payload = _base_card()
payload["type"] = "unknown_type"
issues = metabase_validate_card_payload(payload)
assert any("type" in i and "unknown_type" in i for i in issues), issues
def test_card_type_valido():
for t in ("question", "model", "metric"):
payload = _base_card()
payload["type"] = t
issues = metabase_validate_card_payload(payload)
assert issues == [], f"type={t!r} no deberia dar issues: {issues}"
def test_card_visualization_settings_no_dict():
payload = _base_card()
payload["visualization_settings"] = "no es un dict"
issues = metabase_validate_card_payload(payload)
assert any("visualization_settings" in i for i in issues), issues
def test_card_parameters_no_list():
payload = _base_card()
payload["parameters"] = {"not": "a list"}
issues = metabase_validate_card_payload(payload)
assert any("parameters" in i for i in issues), issues
def test_card_archived_no_bool():
payload = _base_card()
payload["archived"] = "yes"
issues = metabase_validate_card_payload(payload)
assert any("archived" in i for i in issues), issues
def test_card_acumula_multiples_errores():
"""Un payload con varios errores debe retornar todos los issues, no solo el primero."""
payload = {
"display": "invalid_display",
"dataset_query": "not a dict",
"archived": "yes",
}
issues = metabase_validate_card_payload(payload)
assert len(issues) >= 3, f"Se esperaban >= 3 issues, got {len(issues)}: {issues}"
# ---------------------------------------------------------------------------
# metabase_validate_dashboard_payload
# ---------------------------------------------------------------------------
def _base_dashboard() -> dict:
return {"name": "My Dashboard"}
def test_dashboard_valido_sin_dashcards():
issues = metabase_validate_dashboard_payload(_base_dashboard(), known_card_ids=set())
assert issues == [], issues
def test_dashboard_valido_con_dashcards():
payload = {
"name": "KPIs",
"dashcards": [
{"card_id": 1, "row": 0, "col": 0, "size_x": 6, "size_y": 4},
{"card_id": 2, "row": 0, "col": 6, "size_x": 6, "size_y": 4},
],
}
issues = metabase_validate_dashboard_payload(payload, known_card_ids={1, 2})
assert issues == [], issues
def test_dashboard_card_id_desconocido():
payload = {
"name": "Dashboard",
"dashcards": [{"card_id": 999, "row": 0, "col": 0, "size_x": 6, "size_y": 4}],
}
issues = metabase_validate_dashboard_payload(payload, known_card_ids={1, 2})
assert any("999" in i for i in issues), issues
def test_dashboard_card_virtual_null_permitido():
"""card_id null = dashcard virtual (texto/heading), siempre permitido."""
payload = {
"name": "Dashboard",
"dashcards": [{"card_id": None, "row": 0, "col": 0, "size_x": 6, "size_y": 2}],
}
issues = metabase_validate_dashboard_payload(payload, known_card_ids=set())
assert issues == [], issues
def test_dashboard_dashcards_solapadas():
payload = {
"name": "Dashboard",
"dashcards": [
{"card_id": 1, "row": 0, "col": 0, "size_x": 6, "size_y": 4},
{"card_id": 2, "row": 2, "col": 2, "size_x": 4, "size_y": 4},
],
}
issues = metabase_validate_dashboard_payload(payload, known_card_ids={1, 2})
assert any("solapan" in i for i in issues), issues
def test_dashboard_dashcards_adyacentes_no_solapan():
"""Dos dashcards que se tocan en el borde NO solapan."""
payload = {
"name": "Dashboard",
"dashcards": [
{"card_id": 1, "row": 0, "col": 0, "size_x": 6, "size_y": 4},
{"card_id": 2, "row": 0, "col": 6, "size_x": 6, "size_y": 4},
],
}
issues = metabase_validate_dashboard_payload(payload, known_card_ids={1, 2})
assert not any("solapan" in i for i in issues), issues
def test_dashboard_col_fuera_de_bounds():
payload = {
"name": "Dashboard",
"dashcards": [{"card_id": 1, "row": 0, "col": 25, "size_x": 1, "size_y": 1}],
}
issues = metabase_validate_dashboard_payload(payload, known_card_ids={1})
assert any("col" in i for i in issues), issues
def test_dashboard_col_mas_size_x_excede_grid():
payload = {
"name": "Dashboard",
"dashcards": [{"card_id": 1, "row": 0, "col": 20, "size_x": 6, "size_y": 4}],
}
issues = metabase_validate_dashboard_payload(payload, known_card_ids={1})
assert any("24" in i for i in issues), issues
def test_dashboard_size_y_fuera_de_bounds():
payload = {
"name": "Dashboard",
"dashcards": [{"card_id": 1, "row": 0, "col": 0, "size_x": 6, "size_y": 0}],
}
issues = metabase_validate_dashboard_payload(payload, known_card_ids={1})
assert any("size_y" in i for i in issues), issues
def test_dashboard_name_ausente():
issues = metabase_validate_dashboard_payload({}, known_card_ids=set())
assert any("name" in i and "ausente" in i for i in issues), issues
def test_dashboard_tabs_invalidos():
payload = {"name": "Dashboard", "tabs": [{"name": "Tab1"}]} # falta id
issues = metabase_validate_dashboard_payload(payload, known_card_ids=set())
assert any("id" in i for i in issues), issues
def test_dashboard_parameters_no_list():
payload = {"name": "Dashboard", "parameters": "not a list"}
issues = metabase_validate_dashboard_payload(payload, known_card_ids=set())
assert any("parameters" in i for i in issues), issues
# ---------------------------------------------------------------------------
# metabase_validate_document_payload
# ---------------------------------------------------------------------------
from metabase.validation import metabase_validate_document_payload
def _base_doc(content):
return {"name": "Notas", "document": {"type": "doc", "content": content}}
def test_document_valido_minimo():
issues = metabase_validate_document_payload(_base_doc([
{"type": "paragraph", "content": [{"type": "text", "text": "hola"}]}
]))
assert issues == [], issues
def test_document_name_ausente():
issues = metabase_validate_document_payload({"document": {"type": "doc", "content": []}})
assert any("name" in i and "ausente" in i for i in issues), issues
def test_document_nodo_desconocido_callout():
issues = metabase_validate_document_payload(_base_doc([
{"type": "callout", "content": [{"type": "paragraph", "content": [{"type": "text", "text": "x"}]}]}
]))
assert any("callout" in i and "no soportado" in i for i in issues), issues
def test_document_nodo_desconocido_taskList():
issues = metabase_validate_document_payload(_base_doc([
{"type": "taskList", "content": []}
]))
assert any("taskList" in i for i in issues), issues
def test_document_mark_desconocido_underline():
issues = metabase_validate_document_payload(_base_doc([
{"type": "paragraph", "content": [
{"type": "text", "marks": [{"type": "underline"}], "text": "x"}
]}
]))
assert any("underline" in i and "no soportado" in i for i in issues), issues
def test_document_heading_level_invalido():
issues = metabase_validate_document_payload(_base_doc([
{"type": "heading", "attrs": {"level": 9}, "content": [{"type": "text", "text": "x"}]}
]))
assert any("level" in i for i in issues), issues
def test_document_cardEmbed_sin_id_ni_slug():
issues = metabase_validate_document_payload(_base_doc([
{"type": "cardEmbed", "attrs": {}}
]))
assert any("cardEmbed" in i and "id" in i for i in issues), issues
def test_document_cardEmbed_con_id_valido():
issues = metabase_validate_document_payload(_base_doc([
{"type": "cardEmbed", "attrs": {"id": 42}}
]))
assert issues == [], issues
def test_document_cardEmbed_slug_desconocido():
issues = metabase_validate_document_payload(
_base_doc([{"type": "cardEmbed", "attrs": {"card": "inventado"}}]),
known_card_slugs={"real_one", "real_two"},
)
assert any("inventado" in i for i in issues), issues
def test_document_flexContainer_demasiados_hijos():
children = [{"type": "cardEmbed", "attrs": {"id": 1}} for _ in range(4)]
issues = metabase_validate_document_payload(_base_doc([
{"type": "flexContainer", "attrs": {"columnWidths": [25, 25, 25, 25]}, "content": children}
]))
assert any("1-3" in i for i in issues), issues
def test_document_flexContainer_hijo_invalido():
issues = metabase_validate_document_payload(_base_doc([
{"type": "flexContainer", "attrs": {"columnWidths": [100]}, "content": [
{"type": "paragraph", "content": [{"type": "text", "text": "x"}]}
]}
]))
assert any("flexContainer solo acepta" in i for i in issues), issues
def test_document_flexContainer_columnWidths_mismatch():
issues = metabase_validate_document_payload(_base_doc([
{"type": "flexContainer", "attrs": {"columnWidths": [50, 50]}, "content": [
{"type": "cardEmbed", "attrs": {"id": 1}}
]}
]))
assert any("columnWidths" in i for i in issues), issues
def test_document_vacio_string_es_valido():
issues = metabase_validate_document_payload({"name": "vacio", "document": ""})
assert issues == [], issues
def test_document_kitchen_sink_valido():
"""Simula el kitchen_sink real y debe pasar sin issues."""
content = [
{"type": "heading", "attrs": {"level": 1}, "content": [{"type": "text", "text": "T"}]},
{"type": "paragraph", "content": [
{"type": "text", "text": "a "},
{"type": "text", "marks": [{"type": "bold"}], "text": "b"},
{"type": "text", "marks": [{"type": "link", "attrs": {"href": "https://x"}}], "text": "l"},
]},
{"type": "bulletList", "content": [
{"type": "listItem", "content": [
{"type": "paragraph", "content": [{"type": "text", "text": "i"}]}
]}
]},
{"type": "blockquote", "content": [
{"type": "paragraph", "content": [{"type": "text", "text": "q"}]}
]},
{"type": "codeBlock", "attrs": {"language": "py"}, "content": [{"type": "text", "text": "x=1"}]},
{"type": "horizontalRule"},
{"type": "cardEmbed", "attrs": {"id": 1}},
{"type": "flexContainer", "attrs": {"columnWidths": [50, 50]}, "content": [
{"type": "cardEmbed", "attrs": {"id": 1}},
{"type": "cardEmbed", "attrs": {"id": 2}},
]},
]
issues = metabase_validate_document_payload(_base_doc(content))
assert issues == [], issues
+1
View File
@@ -13,6 +13,7 @@ dependencies = [
"openpyxl>=3.1.5",
"pypdf>=6.10.0",
"python-docx>=1.2.0",
"pyyaml>=6.0.3",
"xlrd>=2.0.2",
]
+48
View File
@@ -252,6 +252,7 @@ dependencies = [
{ name = "openpyxl" },
{ name = "pypdf" },
{ name = "python-docx" },
{ name = "pyyaml" },
{ name = "xlrd" },
]
@@ -270,6 +271,7 @@ requires-dist = [
{ name = "openpyxl", specifier = ">=3.1.5" },
{ name = "pypdf", specifier = ">=6.10.0" },
{ name = "python-docx", specifier = ">=1.2.0" },
{ name = "pyyaml", specifier = ">=6.0.3" },
{ name = "xlrd", specifier = ">=2.0.2" },
]
@@ -865,6 +867,52 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/d0/00/1e03a4989fa5795da308cd774f05b704ace555a70f9bf9d3be057b680bcf/python_docx-1.2.0-py3-none-any.whl", hash = "sha256:3fd478f3250fbbbfd3b94fe1e985955737c145627498896a8a6bf81f4baf66c7", size = 252987, upload-time = "2025-06-16T20:46:22.506Z" },
]
[[package]]
name = "pyyaml"
version = "6.0.3"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/d1/33/422b98d2195232ca1826284a76852ad5a86fe23e31b009c9886b2d0fb8b2/pyyaml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196", size = 182063, upload-time = "2025-09-25T21:32:11.445Z" },
{ url = "https://files.pythonhosted.org/packages/89/a0/6cf41a19a1f2f3feab0e9c0b74134aa2ce6849093d5517a0c550fe37a648/pyyaml-6.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0", size = 173973, upload-time = "2025-09-25T21:32:12.492Z" },
{ url = "https://files.pythonhosted.org/packages/ed/23/7a778b6bd0b9a8039df8b1b1d80e2e2ad78aa04171592c8a5c43a56a6af4/pyyaml-6.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28", size = 775116, upload-time = "2025-09-25T21:32:13.652Z" },
{ url = "https://files.pythonhosted.org/packages/65/30/d7353c338e12baef4ecc1b09e877c1970bd3382789c159b4f89d6a70dc09/pyyaml-6.0.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c", size = 844011, upload-time = "2025-09-25T21:32:15.21Z" },
{ url = "https://files.pythonhosted.org/packages/8b/9d/b3589d3877982d4f2329302ef98a8026e7f4443c765c46cfecc8858c6b4b/pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc", size = 807870, upload-time = "2025-09-25T21:32:16.431Z" },
{ url = "https://files.pythonhosted.org/packages/05/c0/b3be26a015601b822b97d9149ff8cb5ead58c66f981e04fedf4e762f4bd4/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e", size = 761089, upload-time = "2025-09-25T21:32:17.56Z" },
{ url = "https://files.pythonhosted.org/packages/be/8e/98435a21d1d4b46590d5459a22d88128103f8da4c2d4cb8f14f2a96504e1/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea", size = 790181, upload-time = "2025-09-25T21:32:18.834Z" },
{ url = "https://files.pythonhosted.org/packages/74/93/7baea19427dcfbe1e5a372d81473250b379f04b1bd3c4c5ff825e2327202/pyyaml-6.0.3-cp312-cp312-win32.whl", hash = "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5", size = 137658, upload-time = "2025-09-25T21:32:20.209Z" },
{ url = "https://files.pythonhosted.org/packages/86/bf/899e81e4cce32febab4fb42bb97dcdf66bc135272882d1987881a4b519e9/pyyaml-6.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b", size = 154003, upload-time = "2025-09-25T21:32:21.167Z" },
{ url = "https://files.pythonhosted.org/packages/1a/08/67bd04656199bbb51dbed1439b7f27601dfb576fb864099c7ef0c3e55531/pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd", size = 140344, upload-time = "2025-09-25T21:32:22.617Z" },
{ url = "https://files.pythonhosted.org/packages/d1/11/0fd08f8192109f7169db964b5707a2f1e8b745d4e239b784a5a1dd80d1db/pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8", size = 181669, upload-time = "2025-09-25T21:32:23.673Z" },
{ url = "https://files.pythonhosted.org/packages/b1/16/95309993f1d3748cd644e02e38b75d50cbc0d9561d21f390a76242ce073f/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1", size = 173252, upload-time = "2025-09-25T21:32:25.149Z" },
{ url = "https://files.pythonhosted.org/packages/50/31/b20f376d3f810b9b2371e72ef5adb33879b25edb7a6d072cb7ca0c486398/pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c", size = 767081, upload-time = "2025-09-25T21:32:26.575Z" },
{ url = "https://files.pythonhosted.org/packages/49/1e/a55ca81e949270d5d4432fbbd19dfea5321eda7c41a849d443dc92fd1ff7/pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5", size = 841159, upload-time = "2025-09-25T21:32:27.727Z" },
{ url = "https://files.pythonhosted.org/packages/74/27/e5b8f34d02d9995b80abcef563ea1f8b56d20134d8f4e5e81733b1feceb2/pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6", size = 801626, upload-time = "2025-09-25T21:32:28.878Z" },
{ url = "https://files.pythonhosted.org/packages/f9/11/ba845c23988798f40e52ba45f34849aa8a1f2d4af4b798588010792ebad6/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6", size = 753613, upload-time = "2025-09-25T21:32:30.178Z" },
{ url = "https://files.pythonhosted.org/packages/3d/e0/7966e1a7bfc0a45bf0a7fb6b98ea03fc9b8d84fa7f2229e9659680b69ee3/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be", size = 794115, upload-time = "2025-09-25T21:32:31.353Z" },
{ url = "https://files.pythonhosted.org/packages/de/94/980b50a6531b3019e45ddeada0626d45fa85cbe22300844a7983285bed3b/pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26", size = 137427, upload-time = "2025-09-25T21:32:32.58Z" },
{ url = "https://files.pythonhosted.org/packages/97/c9/39d5b874e8b28845e4ec2202b5da735d0199dbe5b8fb85f91398814a9a46/pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c", size = 154090, upload-time = "2025-09-25T21:32:33.659Z" },
{ url = "https://files.pythonhosted.org/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", size = 140246, upload-time = "2025-09-25T21:32:34.663Z" },
{ url = "https://files.pythonhosted.org/packages/9d/8c/f4bd7f6465179953d3ac9bc44ac1a8a3e6122cf8ada906b4f96c60172d43/pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac", size = 181814, upload-time = "2025-09-25T21:32:35.712Z" },
{ url = "https://files.pythonhosted.org/packages/bd/9c/4d95bb87eb2063d20db7b60faa3840c1b18025517ae857371c4dd55a6b3a/pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310", size = 173809, upload-time = "2025-09-25T21:32:36.789Z" },
{ url = "https://files.pythonhosted.org/packages/92/b5/47e807c2623074914e29dabd16cbbdd4bf5e9b2db9f8090fa64411fc5382/pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7", size = 766454, upload-time = "2025-09-25T21:32:37.966Z" },
{ url = "https://files.pythonhosted.org/packages/02/9e/e5e9b168be58564121efb3de6859c452fccde0ab093d8438905899a3a483/pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788", size = 836355, upload-time = "2025-09-25T21:32:39.178Z" },
{ url = "https://files.pythonhosted.org/packages/88/f9/16491d7ed2a919954993e48aa941b200f38040928474c9e85ea9e64222c3/pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5", size = 794175, upload-time = "2025-09-25T21:32:40.865Z" },
{ url = "https://files.pythonhosted.org/packages/dd/3f/5989debef34dc6397317802b527dbbafb2b4760878a53d4166579111411e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764", size = 755228, upload-time = "2025-09-25T21:32:42.084Z" },
{ url = "https://files.pythonhosted.org/packages/d7/ce/af88a49043cd2e265be63d083fc75b27b6ed062f5f9fd6cdc223ad62f03e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35", size = 789194, upload-time = "2025-09-25T21:32:43.362Z" },
{ url = "https://files.pythonhosted.org/packages/23/20/bb6982b26a40bb43951265ba29d4c246ef0ff59c9fdcdf0ed04e0687de4d/pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac", size = 156429, upload-time = "2025-09-25T21:32:57.844Z" },
{ url = "https://files.pythonhosted.org/packages/f4/f4/a4541072bb9422c8a883ab55255f918fa378ecf083f5b85e87fc2b4eda1b/pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3", size = 143912, upload-time = "2025-09-25T21:32:59.247Z" },
{ url = "https://files.pythonhosted.org/packages/7c/f9/07dd09ae774e4616edf6cda684ee78f97777bdd15847253637a6f052a62f/pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3", size = 189108, upload-time = "2025-09-25T21:32:44.377Z" },
{ url = "https://files.pythonhosted.org/packages/4e/78/8d08c9fb7ce09ad8c38ad533c1191cf27f7ae1effe5bb9400a46d9437fcf/pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba", size = 183641, upload-time = "2025-09-25T21:32:45.407Z" },
{ url = "https://files.pythonhosted.org/packages/7b/5b/3babb19104a46945cf816d047db2788bcaf8c94527a805610b0289a01c6b/pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c", size = 831901, upload-time = "2025-09-25T21:32:48.83Z" },
{ url = "https://files.pythonhosted.org/packages/8b/cc/dff0684d8dc44da4d22a13f35f073d558c268780ce3c6ba1b87055bb0b87/pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702", size = 861132, upload-time = "2025-09-25T21:32:50.149Z" },
{ url = "https://files.pythonhosted.org/packages/b1/5e/f77dc6b9036943e285ba76b49e118d9ea929885becb0a29ba8a7c75e29fe/pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c", size = 839261, upload-time = "2025-09-25T21:32:51.808Z" },
{ url = "https://files.pythonhosted.org/packages/ce/88/a9db1376aa2a228197c58b37302f284b5617f56a5d959fd1763fb1675ce6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065", size = 805272, upload-time = "2025-09-25T21:32:52.941Z" },
{ url = "https://files.pythonhosted.org/packages/da/92/1446574745d74df0c92e6aa4a7b0b3130706a4142b2d1a5869f2eaa423c6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65", size = 829923, upload-time = "2025-09-25T21:32:54.537Z" },
{ url = "https://files.pythonhosted.org/packages/f0/7a/1c7270340330e575b92f397352af856a8c06f230aa3e76f86b39d01b416a/pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9", size = 174062, upload-time = "2025-09-25T21:32:55.767Z" },
{ url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" },
]
[[package]]
name = "requests"
version = "2.33.1"