58539f45c9
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>
409 lines
13 KiB
Python
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)
|