Files
fn_registry/python/functions/metabase/documents.py
T
egutierrez cf70385bca 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>
2026-04-14 19:03:19 +02:00

409 lines
13 KiB
Python

"""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 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.
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.
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,
*,
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).
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.
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:
>>> from metabase.documents import prosemirror_card_embed
>>> doc = metabase_create_document(client, "Reporte", {
... "type": "doc",
... "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
return client.request("POST", "/api/document", json=body)
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)
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 usando el endpoint nativo de Metabase.
Endpoint: POST /api/document/:id/copy.
Args:
client: Cliente autenticado.
document_id: ID del document a copiar.
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, name="Backup Q1", collection_id=5)
>>> print(copy["id"])
"""
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)