feat(metabase): expansion cards y documents — export, model, ProseMirror validation, copy nativo
Cards: export_card (CSV/XLSX/JSON), create_model (type=model para fuentes MBQL). Documents: prosemirror_card_embed helper (resizeNode envolviendo cardEmbed), validacion automatica contra whitelist TipTap antes de enviar, copy_document refactorizado al endpoint nativo POST /api/document/:id/copy. Docs: dataset_query legacy vs MBQL5, template-tags, whitelist de nodos. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -354,3 +354,83 @@ def metabase_create_card_raw(client: MetabaseClient, payload: dict) -> dict:
|
||||
>>> print(card["id"]) # ID asignado por Metabase
|
||||
"""
|
||||
return client.request("POST", "/api/card", json=payload)
|
||||
|
||||
|
||||
def metabase_export_card(
|
||||
client: MetabaseClient,
|
||||
card_id: int,
|
||||
format: str = "csv",
|
||||
) -> bytes:
|
||||
"""Exporta los resultados de una card en CSV, XLSX o JSON.
|
||||
|
||||
Endpoint: POST /api/card/:id/query/:format.
|
||||
|
||||
Args:
|
||||
client: Cliente autenticado.
|
||||
card_id: ID de la card.
|
||||
format: Formato de exportación: "csv", "xlsx" o "json".
|
||||
|
||||
Returns:
|
||||
bytes con el contenido del archivo exportado.
|
||||
|
||||
Example:
|
||||
>>> data = metabase_export_card(client, 42, format="csv")
|
||||
>>> with open("export.csv", "wb") as f:
|
||||
... f.write(data)
|
||||
"""
|
||||
resp = client._http.request("POST", f"/api/card/{card_id}/query/{format}")
|
||||
resp.raise_for_status()
|
||||
return resp.content
|
||||
|
||||
|
||||
def metabase_create_model(
|
||||
client: MetabaseClient,
|
||||
name: str,
|
||||
sql: str,
|
||||
database_id: int,
|
||||
collection_id: int = 0,
|
||||
description: str = "",
|
||||
) -> dict:
|
||||
"""Crea un modelo (card tipo model) que otras cards pueden referenciar.
|
||||
|
||||
Un modelo es una card con type="model". Otras cards MBQL pueden usarlo
|
||||
como fuente con source-table: "card__<model_id>".
|
||||
|
||||
Endpoint: POST /api/card con type="model".
|
||||
|
||||
Args:
|
||||
client: Cliente autenticado.
|
||||
name: Nombre del modelo.
|
||||
sql: Query SQL del modelo.
|
||||
database_id: ID de la database en Metabase.
|
||||
collection_id: Coleccion destino. 0 = root.
|
||||
description: Descripcion opcional.
|
||||
|
||||
Returns:
|
||||
Dict con el modelo creado (id, name, type="model").
|
||||
|
||||
Example:
|
||||
>>> model = metabase_create_model(client, "supply_orders_base",
|
||||
... "SELECT * FROM supply_orders WHERE ...", database_id=6)
|
||||
>>> # Usar en otra card MBQL:
|
||||
>>> card = metabase_create_card(client, "Revenue", {
|
||||
... "database": 6, "type": "query",
|
||||
... "query": {"source-table": f"card__{model['id']}"}
|
||||
... })
|
||||
"""
|
||||
body: dict = {
|
||||
"name": name,
|
||||
"type": "model",
|
||||
"dataset_query": {
|
||||
"database": database_id,
|
||||
"type": "native",
|
||||
"native": {"query": sql},
|
||||
},
|
||||
"display": "table",
|
||||
"visualization_settings": {},
|
||||
}
|
||||
if collection_id > 0:
|
||||
body["collection_id"] = collection_id
|
||||
if description:
|
||||
body["description"] = description
|
||||
return client.request("POST", "/api/card", json=body)
|
||||
|
||||
@@ -4,22 +4,91 @@ 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
|
||||
NODOS QUE RENDERIZAN (whitelist TipTap v0.59):
|
||||
doc, paragraph, text, heading (level 1-6),
|
||||
bulletList, orderedList, listItem,
|
||||
blockquote, codeBlock (attrs.language), horizontalRule, hardBreak,
|
||||
cardEmbed (attrs.id), flexContainer, smartLink (attrs.entityId),
|
||||
resizeNode, mention.
|
||||
|
||||
Marks:
|
||||
- bold, italic, strike, code, link (attrs.href), underline,
|
||||
highlight, subscript, textStyle
|
||||
NODOS QUE LA API ACEPTA PERO EL FRONTEND IGNORA (doc queda vacio):
|
||||
callout, taskList, taskItem, details, table, tableRow, tableCell,
|
||||
image, iframe.
|
||||
|
||||
MARKS QUE RENDERIZAN: bold, italic, strike, code, link (attrs.href).
|
||||
MARKS IGNORADOS: underline, highlight, subscript, textStyle.
|
||||
|
||||
IMPORTANTE — cardEmbed:
|
||||
Un cardEmbed desnudo renderiza pero queda muy pequeño (alto ~50px).
|
||||
Para que se vea correctamente, envolver en un resizeNode:
|
||||
|
||||
{"type": "resizeNode",
|
||||
"attrs": {"height": 400, "minHeight": 280},
|
||||
"content": [
|
||||
{"type": "cardEmbed", "attrs": {"id": <card_id>}}
|
||||
]}
|
||||
|
||||
Usar el helper `prosemirror_card_embed(card_id)` para generar esto
|
||||
automaticamente.
|
||||
"""
|
||||
|
||||
import uuid
|
||||
|
||||
from .client import MetabaseClient
|
||||
|
||||
|
||||
def prosemirror_card_embed(card_id: int, height: int = 400) -> dict:
|
||||
"""Genera un nodo cardEmbed envuelto en resizeNode listo para ProseMirror.
|
||||
|
||||
Un cardEmbed desnudo renderiza pero queda muy pequeño (~50px). Metabase
|
||||
espera que vaya dentro de un resizeNode con height/minHeight para que
|
||||
se vea con tamaño adecuado.
|
||||
|
||||
Args:
|
||||
card_id: ID de la card/pregunta de Metabase a embeber.
|
||||
height: Altura en pixeles del embed (default 400). minHeight se
|
||||
fija en 280 (lo que usa la UI de Metabase).
|
||||
|
||||
Returns:
|
||||
Dict ProseMirror: resizeNode > cardEmbed, insertable directamente
|
||||
en el array content de un document.
|
||||
|
||||
Example:
|
||||
>>> node = prosemirror_card_embed(7711, height=500)
|
||||
>>> doc = {"type": "doc", "content": [
|
||||
... {"type": "heading", "attrs": {"level": 1},
|
||||
... "content": [{"type": "text", "text": "Mi reporte"}]},
|
||||
... node,
|
||||
... ]}
|
||||
"""
|
||||
return {
|
||||
"type": "resizeNode",
|
||||
"attrs": {"height": height, "minHeight": 280},
|
||||
"content": [
|
||||
{
|
||||
"type": "cardEmbed",
|
||||
"attrs": {"id": card_id, "name": None, "_id": str(uuid.uuid4())},
|
||||
}
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def _validate_before_send(name: str, document: dict | str) -> None:
|
||||
"""Valida el payload del document antes de enviarlo. Raises ValueError."""
|
||||
if not document or document == "":
|
||||
return
|
||||
if not isinstance(document, dict):
|
||||
return
|
||||
from .validation import metabase_validate_document_payload
|
||||
|
||||
issues = metabase_validate_document_payload({"name": name, "document": document})
|
||||
if issues:
|
||||
raise ValueError(
|
||||
f"Document no renderizará correctamente en Metabase "
|
||||
f"({len(issues)} issues):\n" + "\n".join(f" - {i}" for i in issues)
|
||||
)
|
||||
|
||||
|
||||
def metabase_list_documents(client: MetabaseClient) -> list[dict]:
|
||||
"""Lista documents de Metabase.
|
||||
|
||||
@@ -69,11 +138,21 @@ def metabase_create_document(
|
||||
name: str,
|
||||
document: dict,
|
||||
collection_id: int = 0,
|
||||
*,
|
||||
validate: bool = True,
|
||||
) -> dict:
|
||||
"""Crea un document nuevo.
|
||||
|
||||
Endpoint: POST /api/document.
|
||||
|
||||
Valida el arbol ProseMirror ANTES de enviar (por defecto). Si el
|
||||
documento contiene nodos que la API acepta pero el frontend ignora
|
||||
(callout, taskList, image, etc.), lanza ValueError con los issues.
|
||||
Pasar validate=False para desactivar (uso bajo tu riesgo).
|
||||
|
||||
Para embeber cards, usar prosemirror_card_embed(card_id) que genera
|
||||
el nodo resizeNode > cardEmbed con la altura correcta.
|
||||
|
||||
Args:
|
||||
client: Cliente autenticado.
|
||||
name: Titulo del document (1-254 chars, no blank).
|
||||
@@ -81,19 +160,28 @@ def metabase_create_document(
|
||||
{"type": "doc", "content": [{"type": "paragraph", "content": [...]}]}
|
||||
O cadena vacia "" si se quiere arrancar en blanco.
|
||||
collection_id: ID de coleccion destino. 0 = root.
|
||||
validate: Si True (default), valida el ProseMirror antes de enviar.
|
||||
|
||||
Returns:
|
||||
Document creado con su id asignado.
|
||||
|
||||
Raises:
|
||||
ValueError: Si validate=True y el arbol ProseMirror contiene
|
||||
nodos o marks que el frontend de Metabase no renderiza.
|
||||
|
||||
Example:
|
||||
>>> doc = metabase_create_document(client, "Notas", {
|
||||
>>> from metabase.documents import prosemirror_card_embed
|
||||
>>> doc = metabase_create_document(client, "Reporte", {
|
||||
... "type": "doc",
|
||||
... "content": [{
|
||||
... "type": "paragraph",
|
||||
... "content": [{"type": "text", "text": "Hola mundo"}]
|
||||
... }]
|
||||
... "content": [
|
||||
... {"type": "heading", "attrs": {"level": 1},
|
||||
... "content": [{"type": "text", "text": "KPIs"}]},
|
||||
... prosemirror_card_embed(42, height=450),
|
||||
... ]
|
||||
... })
|
||||
"""
|
||||
if validate:
|
||||
_validate_before_send(name, document)
|
||||
body: dict = {"name": name, "document": document}
|
||||
if collection_id > 0:
|
||||
body["collection_id"] = collection_id
|
||||
@@ -103,26 +191,39 @@ def metabase_create_document(
|
||||
def metabase_update_document(
|
||||
client: MetabaseClient,
|
||||
document_id: int,
|
||||
*,
|
||||
validate: bool = True,
|
||||
**fields,
|
||||
) -> dict:
|
||||
"""Actualiza un document. Solo se envian los campos pasados.
|
||||
|
||||
Endpoint: PUT /api/document/:id.
|
||||
|
||||
Si se pasa el campo 'document', valida el arbol ProseMirror antes de
|
||||
enviar (por defecto). Pasar validate=False para desactivar.
|
||||
|
||||
Campos tipicos: name, document, collection_id, archived.
|
||||
|
||||
Args:
|
||||
client: Cliente autenticado.
|
||||
document_id: ID del document a actualizar.
|
||||
validate: Si True (default), valida el ProseMirror antes de enviar.
|
||||
**fields: Campos a modificar.
|
||||
|
||||
Returns:
|
||||
Document actualizado.
|
||||
|
||||
Raises:
|
||||
ValueError: Si validate=True y el campo 'document' contiene
|
||||
nodos o marks no soportados por el frontend de Metabase.
|
||||
|
||||
Example:
|
||||
>>> metabase_update_document(client, 1, name="Nuevo titulo")
|
||||
>>> metabase_update_document(client, 1, document={"type":"doc","content":[...]})
|
||||
"""
|
||||
if validate and "document" in fields:
|
||||
name = fields.get("name", f"document_{document_id}")
|
||||
_validate_before_send(name, fields["document"])
|
||||
return client.request("PUT", f"/api/document/{document_id}", json=fields)
|
||||
|
||||
|
||||
@@ -282,38 +383,26 @@ def metabase_copy_document(
|
||||
name: str | None = None,
|
||||
collection_id: int | None = None,
|
||||
) -> dict:
|
||||
"""Copia un document (Metabase no tiene endpoint nativo).
|
||||
"""Copia un document usando el endpoint nativo de Metabase.
|
||||
|
||||
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.
|
||||
Endpoint: POST /api/document/:id/copy.
|
||||
|
||||
Args:
|
||||
client: Cliente autenticado.
|
||||
document_id: ID del document a copiar.
|
||||
name: Nombre del nuevo document. None = "{original} (copia)".
|
||||
name: Nombre del nuevo document. None = Metabase asigna nombre automatico.
|
||||
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)
|
||||
>>> print(copy["id"])
|
||||
"""
|
||||
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)
|
||||
body: dict = {}
|
||||
if name is not None:
|
||||
body["name"] = name
|
||||
if collection_id is not None:
|
||||
body["collection_id"] = collection_id
|
||||
return client.request("POST", f"/api/document/{document_id}/copy", json=body)
|
||||
|
||||
@@ -6,9 +6,9 @@ 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]
|
||||
description: "Copia un document usando el endpoint nativo POST /api/document/:id/copy. Un solo request HTTP."
|
||||
tags: [metabase, document, copy, clone, api, python]
|
||||
uses_functions: []
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
@@ -20,7 +20,7 @@ params:
|
||||
- name: document_id
|
||||
desc: "ID del document original a copiar"
|
||||
- name: name
|
||||
desc: "nombre del nuevo document; None usa '{original} (copia)'"
|
||||
desc: "nombre del nuevo document; None = Metabase asigna nombre automatico"
|
||||
- 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"
|
||||
@@ -33,20 +33,18 @@ 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
|
||||
# Copia con nombre personalizado a otra coleccion
|
||||
copy = metabase_copy_document(client, 42, name="Backup Q1", collection_id=5)
|
||||
print(copy["id"]) # ID del nuevo document
|
||||
|
||||
# Copia a la misma coleccion con nombre automatico de Metabase
|
||||
copy = metabase_copy_document(client, 42)
|
||||
print(copy["name"]) # nombre asignado por Metabase
|
||||
```
|
||||
|
||||
## 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.
|
||||
Usa el endpoint nativo `POST /api/document/:id/copy` — un solo request HTTP.
|
||||
|
||||
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.
|
||||
Metabase asigna el nombre automaticamente si no se especifica `name`. El contenido
|
||||
ProseMirror (incluyendo cardEmbeds) se copia tal cual; las cards embebidas no se duplican.
|
||||
|
||||
@@ -43,7 +43,58 @@ card = metabase_create_card(client, "Revenue", {
|
||||
}, display="scalar")
|
||||
```
|
||||
|
||||
## Ejemplo con template-tags (filtros)
|
||||
|
||||
```python
|
||||
card = metabase_create_card(client, "Revenue filtrable", {
|
||||
"database": 6, "type": "native",
|
||||
"native": {
|
||||
"query": "SELECT * FROM orders WHERE 1=1 [[AND date >= {{fecha_desde}}]]",
|
||||
"template-tags": {
|
||||
"fecha_desde": {
|
||||
"name": "fecha_desde",
|
||||
"display-name": "Fecha desde",
|
||||
"id": "fecha_desde_tag",
|
||||
"type": "text"
|
||||
}
|
||||
}
|
||||
},
|
||||
}, display="table")
|
||||
```
|
||||
|
||||
## Formato dataset_query — IMPORTANTE
|
||||
|
||||
Hay DOS formatos y hay que saber cuál usar:
|
||||
|
||||
**Para CREAR cards (POST /api/card) — formato legacy:**
|
||||
```python
|
||||
{"database": id, "type": "native", "native": {"query": "SELECT ..."}}
|
||||
```
|
||||
|
||||
**Lo que DEVUELVE Metabase al LEER cards (GET /api/card/:id) — formato MBQL5:**
|
||||
```python
|
||||
{"lib/type": "mbql/query", "database": id,
|
||||
"stages": [{"lib/type": "mbql.stage/native", "native": "SELECT ..."}]}
|
||||
```
|
||||
|
||||
Son representaciones distintas de lo mismo. Al leer una card existente y querer extraer el SQL:
|
||||
```python
|
||||
card = metabase_get_card(client, 42)
|
||||
ds = card["dataset_query"]
|
||||
|
||||
# Formato MBQL5 (Metabase reciente)
|
||||
stages = ds.get("stages", [])
|
||||
if stages:
|
||||
sql = stages[0].get("native", "")
|
||||
|
||||
# Formato legacy
|
||||
else:
|
||||
sql = ds.get("native", {}).get("query", "")
|
||||
```
|
||||
|
||||
Al crear/actualizar cards, usar SIEMPRE el formato legacy — Metabase lo acepta y lo convierte internamente.
|
||||
|
||||
## Notas
|
||||
|
||||
dataset_query SQL nativo: `{"database": id, "type": "native", "native": {"query": "..."}}`
|
||||
dataset_query MBQL: `{"database": id, "type": "query", "query": {"source-table": id, ...}}`
|
||||
- `display` válidos: scalar, table, line, bar, pie, area, row, funnel, combo, pivot, map, scatter, waterfall, gauge, progress, smartscalar, sankey.
|
||||
- Para queries que se repiten como base de otras cards, considerar crear un "model" (type="model") y referenciar desde otras cards con `source-table: "card__<id>"`.
|
||||
|
||||
@@ -3,17 +3,17 @@ name: metabase_create_document
|
||||
kind: function
|
||||
lang: py
|
||||
domain: infra
|
||||
version: "1.0.0"
|
||||
version: "1.1.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."
|
||||
signature: "def metabase_create_document(client: MetabaseClient, name: str, document: dict, collection_id: int = 0, *, validate: bool = True) -> dict"
|
||||
description: "Crea un document con contenido ProseMirror. Valida el arbol contra la whitelist de nodos ANTES de enviar (evita documentos que la API acepta pero el frontend renderiza vacíos). Usar prosemirror_card_embed() para embeber cards."
|
||||
tags: [metabase, document, create, api, prosemirror, python]
|
||||
uses_functions: []
|
||||
uses_functions: [metabase_validate_document_payload_py_infra]
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: "error_go_core"
|
||||
imports: [httpx]
|
||||
imports: [httpx, uuid]
|
||||
params:
|
||||
- name: client
|
||||
desc: "instancia autenticada de MetabaseClient"
|
||||
@@ -23,6 +23,8 @@ params:
|
||||
desc: "arbol ProseMirror JSON: {type: 'doc', content: [...]}, o '' para arrancar vacio"
|
||||
- name: collection_id
|
||||
desc: "ID de coleccion destino (0 = root)"
|
||||
- name: validate
|
||||
desc: "si True (default), valida el ProseMirror antes de enviar. Lanza ValueError si hay nodos no soportados"
|
||||
output: "dict: document recien creado con id, entity_id y metadata"
|
||||
tested: false
|
||||
tests: []
|
||||
@@ -33,17 +35,49 @@ file_path: "python/functions/metabase/documents.py"
|
||||
## Ejemplo
|
||||
|
||||
```python
|
||||
doc = metabase_create_document(client, "Notas", {
|
||||
from metabase.documents import metabase_create_document, prosemirror_card_embed
|
||||
|
||||
doc = metabase_create_document(client, "Reporte Q1", {
|
||||
"type": "doc",
|
||||
"content": [
|
||||
{"type": "paragraph", "content": [{"type": "text", "text": "Hola"}]}
|
||||
{"type": "heading", "attrs": {"level": 1},
|
||||
"content": [{"type": "text", "text": "KPIs"}]},
|
||||
{"type": "paragraph",
|
||||
"content": [{"type": "text", "text": "Revenue por canal:"}]},
|
||||
prosemirror_card_embed(42, height=450),
|
||||
]
|
||||
})
|
||||
print(doc["id"])
|
||||
```
|
||||
|
||||
## Nodos ProseMirror — whitelist
|
||||
|
||||
**Renderizan correctamente** (TipTap v0.59):
|
||||
`doc, paragraph, text, heading, bulletList, orderedList, listItem, blockquote, codeBlock, horizontalRule, hardBreak, cardEmbed, flexContainer, smartLink, resizeNode, mention`
|
||||
|
||||
**La API acepta pero el frontend IGNORA** (resultado: documento vacío):
|
||||
`callout, taskList, taskItem, details, table, tableRow, tableCell, image, iframe`
|
||||
|
||||
**Marks que renderizan:** `bold, italic, strike, code, link`
|
||||
**Marks ignorados:** `underline, highlight, subscript, textStyle`
|
||||
|
||||
## cardEmbed — SIEMPRE envolver en resizeNode
|
||||
|
||||
Un `cardEmbed` desnudo renderiza pero queda con ~50px de alto. Metabase espera que vaya dentro de un `resizeNode`:
|
||||
|
||||
```python
|
||||
# MAL — card diminuta
|
||||
{"type": "cardEmbed", "attrs": {"id": 42}}
|
||||
|
||||
# BIEN — usar el helper
|
||||
from metabase.documents import prosemirror_card_embed
|
||||
prosemirror_card_embed(42, height=450)
|
||||
# Genera: {"type": "resizeNode", "attrs": {"height": 450, "minHeight": 280},
|
||||
# "content": [{"type": "cardEmbed", "attrs": {"id": 42, ...}}]}
|
||||
```
|
||||
|
||||
## 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.
|
||||
- La validación (`validate=True`) llama internamente a `metabase_validate_document_payload`. Si detecta nodos no soportados, lanza `ValueError` ANTES de hacer el POST — evita documentos que se ven vacíos.
|
||||
- Pasar `validate=False` solo si se está experimentando con nodos nuevos.
|
||||
- Para destacar texto, usar `blockquote` (NO `callout`).
|
||||
- Cuando embebes un card via `cardEmbed`, Metabase crea una referencia al card — el card debe existir.
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
---
|
||||
name: metabase_create_model
|
||||
kind: function
|
||||
lang: py
|
||||
domain: infra
|
||||
version: "1.0.0"
|
||||
purity: impure
|
||||
signature: "def metabase_create_model(client: MetabaseClient, name: str, sql: str, database_id: int, collection_id: int = 0, description: str = '') -> dict"
|
||||
description: "Crea un modelo de Metabase (card con type='model') que otras cards MBQL pueden usar como fuente via source-table: 'card__<id>'."
|
||||
tags: [metabase, model, card, 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: name
|
||||
desc: "nombre del modelo"
|
||||
- name: sql
|
||||
desc: "query SQL que define el modelo"
|
||||
- name: database_id
|
||||
desc: "ID de la database en Metabase donde vive la query"
|
||||
- name: collection_id
|
||||
desc: "ID de coleccion destino; 0 = root"
|
||||
- name: description
|
||||
desc: "descripcion opcional del modelo"
|
||||
output: "dict con el modelo creado: id, name, type='model', dataset_query y metadata completa"
|
||||
tested: false
|
||||
tests: []
|
||||
test_file_path: ""
|
||||
file_path: "python/functions/metabase/cards.py"
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```python
|
||||
# Crear modelo base
|
||||
model = metabase_create_model(
|
||||
client,
|
||||
name="supply_orders_base",
|
||||
sql="SELECT * FROM supply_orders WHERE status != 'cancelled'",
|
||||
database_id=6,
|
||||
collection_id=42,
|
||||
description="Ordenes de supply excluyendo canceladas",
|
||||
)
|
||||
print(model["id"]) # ej: 7820
|
||||
|
||||
# Usar el modelo como fuente en una card MBQL
|
||||
card = metabase_create_card(client, "Revenue por proveedor", {
|
||||
"database": 6,
|
||||
"type": "query",
|
||||
"query": {
|
||||
"source-table": f"card__{model['id']}",
|
||||
"aggregation": [["sum", ["field", "total", None]]],
|
||||
"breakout": [["field", "supplier_id", None]],
|
||||
},
|
||||
}, display="bar")
|
||||
```
|
||||
|
||||
## Notas
|
||||
|
||||
Un modelo es una card con `type="model"`. Metabase lo trata como una capa de abstraccion — las cards MBQL que lo referencian via `source-table: "card__<id>"` se benefician del schema inferido del modelo (tipos de columna, foreign keys, etc.).
|
||||
|
||||
A diferencia de `metabase_create_card`, esta funcion fuerza `type="model"` y siempre usa query SQL nativa. Para modelos MBQL o con configuracion avanzada (result_metadata, column types), usar `metabase_create_card_raw` con `type="model"` en el payload.
|
||||
@@ -0,0 +1,54 @@
|
||||
---
|
||||
name: metabase_export_card
|
||||
kind: function
|
||||
lang: py
|
||||
domain: infra
|
||||
version: "1.0.0"
|
||||
purity: impure
|
||||
signature: "def metabase_export_card(client: MetabaseClient, card_id: int, format: str = 'csv') -> bytes"
|
||||
description: "Exporta los resultados de una card de Metabase en CSV, XLSX o JSON. Endpoint: POST /api/card/:id/query/:format."
|
||||
tags: [metabase, card, export, csv, xlsx, 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 cuyos resultados se exportan"
|
||||
- name: format
|
||||
desc: "formato de exportacion: 'csv', 'xlsx' o 'json'. Default: 'csv'"
|
||||
output: "bytes con el contenido del archivo exportado listo para escribir a disco"
|
||||
tested: false
|
||||
tests: []
|
||||
test_file_path: ""
|
||||
file_path: "python/functions/metabase/cards.py"
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```python
|
||||
# Exportar a CSV
|
||||
data = metabase_export_card(client, 42, format="csv")
|
||||
with open("export.csv", "wb") as f:
|
||||
f.write(data)
|
||||
|
||||
# Exportar a Excel
|
||||
data = metabase_export_card(client, 42, format="xlsx")
|
||||
with open("export.xlsx", "wb") as f:
|
||||
f.write(data)
|
||||
|
||||
# Exportar a JSON
|
||||
import json
|
||||
data = metabase_export_card(client, 42, format="json")
|
||||
rows = json.loads(data)
|
||||
```
|
||||
|
||||
## Notas
|
||||
|
||||
Usa `client._http` directamente para acceder al objeto httpx.Client y obtener `.content` en bytes sin que el wrapper de `client.request` intente parsear la respuesta como JSON.
|
||||
|
||||
Para cards con queries parametrizadas, este endpoint no acepta parametros — ejecuta la query con los valores por defecto. Para pasar parametros, usar `metabase_execute_card` que devuelve JSON estructurado.
|
||||
@@ -3,12 +3,12 @@ name: metabase_update_document
|
||||
kind: function
|
||||
lang: py
|
||||
domain: infra
|
||||
version: "1.0.0"
|
||||
version: "1.1.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."
|
||||
signature: "def metabase_update_document(client: MetabaseClient, document_id: int, *, validate: bool = True, **fields) -> dict"
|
||||
description: "Actualiza un document. Solo envia los campos pasados. Si se pasa 'document', valida el ProseMirror antes de enviar (evita documentos vacíos por nodos no soportados)."
|
||||
tags: [metabase, document, update, api, python]
|
||||
uses_functions: []
|
||||
uses_functions: [metabase_validate_document_payload_py_infra]
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
@@ -19,6 +19,8 @@ params:
|
||||
desc: "instancia autenticada de MetabaseClient"
|
||||
- name: document_id
|
||||
desc: "ID del document a actualizar"
|
||||
- name: validate
|
||||
desc: "si True (default), valida el ProseMirror antes de enviar cuando se pasa 'document'"
|
||||
- name: fields
|
||||
desc: "kwargs con campos a modificar: name, document (arbol ProseMirror), collection_id, archived"
|
||||
output: "dict: document actualizado"
|
||||
@@ -31,15 +33,27 @@ file_path: "python/functions/metabase/documents.py"
|
||||
## Ejemplo
|
||||
|
||||
```python
|
||||
from metabase.documents import prosemirror_card_embed
|
||||
|
||||
# Renombrar
|
||||
metabase_update_document(client, 1, name="Nuevo titulo")
|
||||
|
||||
# Reemplazar contenido completo
|
||||
# Reemplazar contenido con card embebida
|
||||
metabase_update_document(client, 1, document={
|
||||
"type": "doc",
|
||||
"content": [{"type": "paragraph", "content": [{"type": "text", "text": "Nuevo"}]}]
|
||||
"content": [
|
||||
{"type": "heading", "attrs": {"level": 1},
|
||||
"content": [{"type": "text", "text": "Resumen"}]},
|
||||
prosemirror_card_embed(42, height=450),
|
||||
]
|
||||
})
|
||||
|
||||
# Mover a coleccion
|
||||
metabase_update_document(client, 1, collection_id=5)
|
||||
```
|
||||
|
||||
## Notas
|
||||
|
||||
- La validación es automática cuando se pasa `document=...`. Si contiene nodos que el frontend no renderiza (callout, taskList, etc.), lanza `ValueError` antes de enviar.
|
||||
- Usar `blockquote` en vez de `callout` para destacar texto.
|
||||
- Usar `prosemirror_card_embed(card_id)` en vez de `cardEmbed` desnudo.
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
---
|
||||
name: prosemirror_card_embed
|
||||
kind: function
|
||||
lang: py
|
||||
domain: infra
|
||||
version: "1.0.0"
|
||||
purity: pure
|
||||
signature: "def prosemirror_card_embed(card_id: int, height: int = 400) -> dict"
|
||||
description: "Genera un nodo ProseMirror cardEmbed envuelto en resizeNode con altura adecuada. Un cardEmbed desnudo renderiza con ~50px — este helper produce el formato que Metabase espera para que la card se vea bien."
|
||||
tags: [metabase, document, prosemirror, cardEmbed, resizeNode, pure, python]
|
||||
uses_functions: []
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: ""
|
||||
imports: [uuid]
|
||||
params:
|
||||
- name: card_id
|
||||
desc: "ID de la card/pregunta de Metabase a embeber"
|
||||
- name: height
|
||||
desc: "altura en pixeles del embed (default 400). minHeight se fija en 280"
|
||||
output: "dict ProseMirror: nodo resizeNode > cardEmbed, insertable en el array content de un document"
|
||||
tested: false
|
||||
tests: []
|
||||
test_file_path: ""
|
||||
file_path: "python/functions/metabase/documents.py"
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```python
|
||||
from metabase.documents import metabase_create_document, prosemirror_card_embed
|
||||
|
||||
doc = metabase_create_document(client, "Reporte", {
|
||||
"type": "doc",
|
||||
"content": [
|
||||
{"type": "heading", "attrs": {"level": 2},
|
||||
"content": [{"type": "text", "text": "Revenue"}]},
|
||||
prosemirror_card_embed(7711, height=500),
|
||||
{"type": "paragraph",
|
||||
"content": [{"type": "text", "text": "Datos actualizados."}]},
|
||||
]
|
||||
})
|
||||
```
|
||||
|
||||
## Por qué existe este helper
|
||||
|
||||
```python
|
||||
# MAL — cardEmbed desnudo: renderiza con ~50px de alto, ilegible
|
||||
{"type": "cardEmbed", "attrs": {"id": 42}}
|
||||
|
||||
# BIEN — prosemirror_card_embed: envuelto en resizeNode
|
||||
prosemirror_card_embed(42, height=450)
|
||||
# → {"type": "resizeNode", "attrs": {"height": 450, "minHeight": 280},
|
||||
# "content": [{"type": "cardEmbed", "attrs": {"id": 42, "_id": "uuid...", "name": null}}]}
|
||||
```
|
||||
|
||||
El formato con `resizeNode` es el que usa el editor de Metabase cuando un usuario inserta una card manualmente — sin él, la card se renderiza diminuta.
|
||||
Reference in New Issue
Block a user