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 cb392a48ee
commit cf70385bca
9 changed files with 516 additions and 71 deletions
+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)