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:
2026-04-14 19:03:19 +02:00
parent 4299482b75
commit 58539f45c9
9 changed files with 516 additions and 71 deletions
+80
View File
@@ -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)
+126 -37
View File
@@ -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.