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:
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user