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:
@@ -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",
|
||||
]
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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},
|
||||
)
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
@@ -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.
|
||||
@@ -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}"
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user