merge: quick/metabase-expansion-v2 — expansion Metabase: snippets, notifications, filters, export, ProseMirror

This commit is contained in:
2026-04-14 19:03:56 +02:00
24 changed files with 1631 additions and 75 deletions
+10 -4
View File
@@ -1,9 +1,12 @@
from .client import MetabaseClient
from .users import metabase_list_users, metabase_get_user, metabase_create_user, metabase_update_user, metabase_deactivate_user
from .cards import metabase_list_cards, metabase_get_card, metabase_create_card, metabase_update_card, metabase_delete_card, metabase_execute_card, metabase_execute_query, metabase_copy_card, metabase_move_card
from .cards import metabase_list_cards, metabase_get_card, metabase_create_card, metabase_update_card, metabase_delete_card, metabase_execute_card, metabase_execute_query, metabase_copy_card, metabase_move_card, metabase_export_card, metabase_create_model
from .dashboards import metabase_list_dashboards, metabase_get_dashboard, metabase_create_dashboard, metabase_update_dashboard, metabase_delete_dashboard, metabase_copy_dashboard, metabase_move_dashboard
from .databases import metabase_list_databases, metabase_add_database, metabase_get_database
from .documents import metabase_list_documents, metabase_get_document, metabase_create_document, metabase_update_document, metabase_archive_document, metabase_delete_document, metabase_list_document_comments, metabase_create_document_comment, metabase_resolve_document_comment, metabase_move_document, metabase_copy_document
from .documents import metabase_list_documents, metabase_get_document, metabase_create_document, metabase_update_document, metabase_archive_document, metabase_delete_document, metabase_list_document_comments, metabase_create_document_comment, metabase_resolve_document_comment, metabase_move_document, metabase_copy_document, prosemirror_card_embed
from .snippets import metabase_list_snippets, metabase_get_snippet, metabase_create_snippet, metabase_update_snippet, metabase_archive_snippet
from .notifications import metabase_list_notifications, metabase_create_card_alert, metabase_create_dashboard_subscription, metabase_update_notification, metabase_delete_notification
from .dashboard_filters import metabase_add_dashboard_filter
from .collections import metabase_move_collection
from .permissions import metabase_list_groups, metabase_get_group, metabase_create_group, metabase_update_group, metabase_delete_group, metabase_list_memberships, metabase_add_membership, metabase_delete_membership, metabase_get_permission_graph, metabase_update_permission_graph, metabase_get_collection_graph, metabase_update_collection_graph
from .setup import metabase_setup
@@ -15,13 +18,16 @@ __all__ = [
"MetabaseClient",
"metabase_list_users", "metabase_get_user", "metabase_create_user", "metabase_update_user", "metabase_deactivate_user",
"metabase_list_cards", "metabase_get_card", "metabase_create_card", "metabase_update_card", "metabase_delete_card", "metabase_execute_card", "metabase_execute_query",
"metabase_copy_card", "metabase_move_card",
"metabase_copy_card", "metabase_move_card", "metabase_export_card", "metabase_create_model",
"metabase_list_dashboards", "metabase_get_dashboard", "metabase_create_dashboard", "metabase_update_dashboard", "metabase_delete_dashboard",
"metabase_copy_dashboard", "metabase_move_dashboard",
"metabase_list_databases", "metabase_add_database", "metabase_get_database",
"metabase_list_documents", "metabase_get_document", "metabase_create_document", "metabase_update_document", "metabase_archive_document", "metabase_delete_document",
"metabase_list_document_comments", "metabase_create_document_comment", "metabase_resolve_document_comment",
"metabase_move_document", "metabase_copy_document",
"metabase_move_document", "metabase_copy_document", "prosemirror_card_embed",
"metabase_list_snippets", "metabase_get_snippet", "metabase_create_snippet", "metabase_update_snippet", "metabase_archive_snippet",
"metabase_list_notifications", "metabase_create_card_alert", "metabase_create_dashboard_subscription", "metabase_update_notification", "metabase_delete_notification",
"metabase_add_dashboard_filter",
"metabase_move_collection",
"metabase_list_groups", "metabase_get_group", "metabase_create_group", "metabase_update_group", "metabase_delete_group",
"metabase_list_memberships", "metabase_add_membership", "metabase_delete_membership",
+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)
@@ -0,0 +1,90 @@
"""Helpers para añadir filtros a dashboards de Metabase."""
import uuid as _uuid
from .client import MetabaseClient
from .metabase_update_dashboard_safe import metabase_update_dashboard_safe
def metabase_add_dashboard_filter(
client: MetabaseClient,
dashboard_id: int,
name: str,
slug: str,
filter_type: str,
card_mappings: list[dict],
*,
default: str | None = None,
) -> dict:
"""Añade un filtro a un dashboard y lo conecta a las cards indicadas.
Crea un parametro a nivel de dashboard y actualiza los parameter_mappings
de cada dashcard indicada. Usa metabase_update_dashboard_safe internamente.
Args:
client: Cliente autenticado.
dashboard_id: ID del dashboard.
name: Nombre visible del filtro (ej: "Fecha desde").
slug: Slug URL-friendly (ej: "fecha_desde").
filter_type: Tipo del filtro. Comunes: "string/=", "number/=",
"date/single", "date/range", "date/relative", "category".
card_mappings: Lista de dicts con la conexión filtro→card. Formato:
[{"card_id": 42, "template_tag": "fecha_desde"}, ...]
Cada dict indica qué card y qué template-tag SQL debe recibir el valor.
default: Valor por defecto del filtro (opcional).
Returns:
Dict con resultado de metabase_update_dashboard_safe.
Example:
>>> result = metabase_add_dashboard_filter(
... client, 887, "Fecha desde", "fecha_desde", "string/=",
... [{"card_id": 7711, "template_tag": "fecha_desde"},
... {"card_id": 7719, "template_tag": "fecha_desde"}],
... )
"""
from .dashboards import metabase_get_dashboard
# 1. Leer dashboard actual
dash = metabase_get_dashboard(client, dashboard_id)
current_params = dash.get("parameters", [])
current_dashcards = dash.get("dashcards", [])
# 2. Crear el parameter
param_id = _uuid.uuid4().hex[:8]
new_param = {
"id": param_id,
"type": filter_type,
"name": name,
"slug": slug,
}
if default is not None:
new_param["default"] = default
updated_params = current_params + [new_param]
# 3. Build card_id → template_tag mapping
tag_by_card = {m["card_id"]: m["template_tag"] for m in card_mappings}
# 4. Update dashcards with parameter_mappings
updated_dashcards = []
for dc in current_dashcards:
dc_card_id = dc.get("card_id")
if dc_card_id in tag_by_card:
mappings = list(dc.get("parameter_mappings", []))
mappings.append({
"parameter_id": param_id,
"card_id": dc_card_id,
"target": ["variable", ["template-tag", tag_by_card[dc_card_id]]],
})
dc_copy = {**dc, "parameter_mappings": mappings}
updated_dashcards.append(dc_copy)
else:
updated_dashcards.append(dc)
# 5. Update via safe function
return metabase_update_dashboard_safe(
client, dashboard_id,
dashcards_update=updated_dashcards,
extra_fields={"parameters": updated_params},
)
+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)
@@ -0,0 +1,76 @@
---
name: metabase_add_dashboard_filter
kind: function
lang: py
domain: infra
version: "1.0.0"
purity: impure
signature: "def metabase_add_dashboard_filter(client: MetabaseClient, dashboard_id: int, name: str, slug: str, filter_type: str, card_mappings: list[dict], *, default: str | None = None) -> dict"
description: "Añade un filtro a un dashboard de Metabase y lo conecta a las cards indicadas. Crea el parameter a nivel de dashboard y actualiza los parameter_mappings de cada dashcard."
tags: [metabase, dashboard, filter, parameter, create, api, python]
uses_functions:
- metabase_update_dashboard_safe_py_infra
- metabase_get_dashboard_py_infra
uses_types: []
returns: []
returns_optional: false
error_type: "error_go_core"
imports: [uuid]
params:
- name: client
desc: "instancia autenticada de MetabaseClient"
- name: dashboard_id
desc: "ID del dashboard al que se añade el filtro"
- name: name
desc: "nombre visible del filtro en la UI de Metabase (ej: 'Fecha desde')"
- name: slug
desc: "slug URL-friendly del filtro, unico dentro del dashboard (ej: 'fecha_desde')"
- name: filter_type
desc: "tipo del filtro: 'string/=', 'number/=', 'date/single', 'date/range', 'date/relative', 'category'"
- name: card_mappings
desc: "lista de dicts [{card_id: int, template_tag: str}] conectando el filtro a template-tags SQL de las cards"
- name: default
desc: "valor por defecto del filtro; None = sin default"
output: "dict con resultado de metabase_update_dashboard_safe: {added, updated, removed, response}"
tested: false
tests: []
test_file_path: ""
file_path: "python/functions/metabase/dashboard_filters.py"
---
## Ejemplo
```python
result = metabase_add_dashboard_filter(
client,
dashboard_id=887,
name="Fecha desde",
slug="fecha_desde",
filter_type="string/=",
card_mappings=[
{"card_id": 7711, "template_tag": "fecha_desde"},
{"card_id": 7719, "template_tag": "fecha_desde"},
],
)
# Con valor por defecto
result = metabase_add_dashboard_filter(
client,
dashboard_id=887,
name="Estado",
slug="estado",
filter_type="category",
card_mappings=[{"card_id": 7711, "template_tag": "estado"}],
default="activo",
)
```
## Notas
Realiza 2 requests internamente via metabase_update_dashboard_safe (GET + PUT).
El `param_id` se genera como UUID hex de 8 caracteres — formato que usa la UI de Metabase.
`card_mappings` solo conecta cards que ya existen como dashcards en el dashboard. Cards no presentes en el dashboard se ignoran silenciosamente.
Para filtros de tipo date, los valores en `default` deben seguir el formato de Metabase: "2024-01-01" para `date/single`, "2024-01-01~2024-12-31" para `date/range`.
@@ -0,0 +1,45 @@
---
name: metabase_archive_snippet
kind: function
lang: py
domain: infra
version: "1.0.0"
purity: impure
signature: "def metabase_archive_snippet(client: MetabaseClient, snippet_id: int) -> dict"
description: "Archiva un SQL snippet en Metabase. Wrapper sobre metabase_update_snippet con archived=True."
tags: [metabase, snippet, archive, api, python]
uses_functions: [metabase_update_snippet_py_infra]
uses_types: [MetabaseClient_go_infra]
returns: []
returns_optional: false
error_type: "error_go_core"
imports: [httpx]
params:
- name: client
desc: "instancia autenticada de MetabaseClient"
- name: snippet_id
desc: "ID numerico del snippet a archivar"
output: "dict: snippet con archived=True y updated_at actualizado"
tested: false
tests: []
test_file_path: ""
file_path: "python/functions/metabase/snippets.py"
---
## Ejemplo
```python
# Archivar snippet obsoleto
result = metabase_archive_snippet(client, 42)
print(result["archived"]) # True
# Listar snippets activos despues de archivar
active = metabase_list_snippets(client, archived=False)
assert all(not s["archived"] for s in active)
```
## Notas
Los snippets archivados no aparecen en el autocomplete de queries nativas en el editor de Metabase.
Las cards que ya referencian el snippet siguen funcionando correctamente despues de archivar.
Para desarchivar, usar metabase_update_snippet(client, snippet_id, archived=False).
@@ -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>"`.
@@ -0,0 +1,59 @@
---
name: metabase_create_card_alert
kind: function
lang: py
domain: infra
version: "1.0.0"
purity: impure
signature: "def metabase_create_card_alert(client: MetabaseClient, card_id: int, cron_schedule: str, recipients: list[dict], send_condition: str = 'has_result', send_once: bool = False) -> dict"
description: "Crea una alerta sobre los resultados de una card en Metabase. Envia email segun cron cuando la card cumple la condicion (has_result, goal_above, goal_below). Endpoint: POST /api/notification."
tags: [metabase, notification, alert, card, create, api, python]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: "error_go_core"
imports: []
params:
- name: client
desc: "instancia autenticada de MetabaseClient"
- name: card_id
desc: "ID de la card que dispara la alerta"
- name: cron_schedule
desc: "expresion cron de 5 campos (ej: '0 9 * * 1' = lunes 9am, '0 8 * * 1-5' = lun-vie 8am)"
- name: recipients
desc: "lista de destinatarios: usuario Metabase {'type': 'notification-recipient/user', 'user_id': N} o email externo {'type': 'notification-recipient/raw-value', 'details': {'email': 'x@y.com'}}"
- name: send_condition
desc: "condicion que dispara el envio: 'has_result' (tiene filas), 'goal_above' (supera goal), 'goal_below' (cae bajo goal)"
- name: send_once
desc: "si True, se envia una sola vez y se desactiva automaticamente"
output: "dict: notificacion creada con id, active, payload_type, payload, subscriptions, handlers, created_at"
tested: false
tests: []
test_file_path: ""
file_path: "python/functions/metabase/notifications.py"
---
## Ejemplo
```python
# Alerta: enviar email los lunes 9am si la card tiene resultados
alert = metabase_create_card_alert(
client,
card_id=7711,
cron_schedule="0 9 * * 1",
recipients=[
{"type": "notification-recipient/user", "user_id": 1},
{"type": "notification-recipient/raw-value", "details": {"email": "team@aurgi.com"}},
],
send_condition="has_result",
)
print(alert["id"], alert["active"])
```
## Notas
Requiere Metabase 0.57+. Reemplaza el antiguo /api/pulse.
El campo `subscriptions` acepta solo el tipo `notification-subscription/cron`.
Para alertas de goal configurar previamente el goal en la visualizacion de la card.
`send_once=True` es util para alertas puntuales que no deben repetirse.
@@ -0,0 +1,53 @@
---
name: metabase_create_dashboard_subscription
kind: function
lang: py
domain: infra
version: "1.0.0"
purity: impure
signature: "def metabase_create_dashboard_subscription(client: MetabaseClient, dashboard_id: int, cron_schedule: str, recipients: list[dict]) -> dict"
description: "Crea una suscripcion periodica a un dashboard de Metabase. Envia el dashboard completo por email segun el cron configurado. Endpoint: POST /api/notification."
tags: [metabase, notification, subscription, dashboard, create, api, python]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: "error_go_core"
imports: []
params:
- name: client
desc: "instancia autenticada de MetabaseClient"
- name: dashboard_id
desc: "ID del dashboard a enviar periodicamente"
- name: cron_schedule
desc: "expresion cron de 5 campos (ej: '0 8 * * 1-5' = lun-vie 8am, '0 9 * * 1' = lunes 9am)"
- name: recipients
desc: "lista de destinatarios: usuario Metabase {'type': 'notification-recipient/user', 'user_id': N} o email externo {'type': 'notification-recipient/raw-value', 'details': {'email': 'x@y.com'}}"
output: "dict: suscripcion creada con id, active, payload_type, payload, subscriptions, handlers, created_at"
tested: false
tests: []
test_file_path: ""
file_path: "python/functions/metabase/notifications.py"
---
## Ejemplo
```python
# Suscripcion: enviar dashboard de ventas cada lunes a las 8am
sub = metabase_create_dashboard_subscription(
client,
dashboard_id=42,
cron_schedule="0 8 * * 1",
recipients=[
{"type": "notification-recipient/user", "user_id": 5},
{"type": "notification-recipient/raw-value", "details": {"email": "gerencia@aurgi.com"}},
],
)
print(sub["id"], sub["payload"]["dashboard_id"])
```
## Notas
Requiere Metabase 0.57+. Reemplaza el antiguo /api/pulse.
El dashboard se envia completo con todas sus cards renderizadas.
Para suscripciones diarias laborales usar cron "0 8 * * 1-5".
@@ -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,70 @@
---
name: metabase_create_snippet
kind: function
lang: py
domain: infra
version: "1.0.0"
purity: impure
signature: "def metabase_create_snippet(client: MetabaseClient, name: str, content: str, description: str = \"\", collection_id: int = 0) -> dict"
description: "Crea un nuevo SQL snippet reutilizable en Metabase. El snippet se referencia en queries nativas con {{snippet: nombre}}."
tags: [metabase, snippet, create, api, python]
uses_functions: []
uses_types: [MetabaseClient_go_infra]
returns: []
returns_optional: false
error_type: "error_go_core"
imports: [httpx]
params:
- name: client
desc: "instancia autenticada de MetabaseClient"
- name: name
desc: "nombre del snippet; se usa en queries con la sintaxis {{snippet: nombre}}"
- name: content
desc: "SQL del snippet: puede ser una CTE completa, subquery o cualquier fragmento SQL reutilizable"
- name: description
desc: "descripcion opcional del proposito del snippet"
- name: collection_id
desc: "ID de la coleccion donde guardar el snippet; 0 para la raiz (Our analytics)"
output: "dict: snippet creado con id asignado por Metabase, name, content, description, collection_id, created_at"
tested: false
tests: []
test_file_path: ""
file_path: "python/functions/metabase/snippets.py"
---
## Ejemplo
```python
# Crear snippet con CTE reutilizable
snippet = metabase_create_snippet(
client,
"supply_orders_cte",
"""
WITH supply_full AS (
SELECT so.id, so.service_request_id,
sr.channel_id, ch.name AS channel_name
FROM supply_orders so
LEFT JOIN service_requests sr ON so.service_request_id = sr.id
LEFT JOIN channels ch ON sr.channel_id = ch.id
)
""",
description="CTE base de supply_orders con 6 JOINs para cruce con NAV",
)
print(snippet["id"], snippet["name"])
# Usar el snippet en una card
from metabase.cards import metabase_create_card
card = metabase_create_card(client, "Revenue", {
"database": 6,
"type": "native",
"native": {
"query": "{{snippet: supply_orders_cte}} SELECT channel_name, COUNT(*) FROM supply_full GROUP BY 1"
}
})
```
## Notas
El campo `name` debe ser unico en Metabase — el servidor retorna 400 si ya existe un snippet con ese nombre.
Si `collection_id` es 0 o no se provee, el snippet se guarda en la raiz.
Solo se envian al body los campos no vacios/cero para evitar conflictos con defaults del servidor.
@@ -0,0 +1,44 @@
---
name: metabase_delete_notification
kind: function
lang: py
domain: infra
version: "1.0.0"
purity: impure
signature: "def metabase_delete_notification(client: MetabaseClient, notification_id: int) -> None"
description: "Elimina una notificacion de Metabase (alerta de card o suscripcion de dashboard). Operacion irreversible. Endpoint: DELETE /api/notification/:id."
tags: [metabase, notification, delete, api, python]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: "error_go_core"
imports: []
params:
- name: client
desc: "instancia autenticada de MetabaseClient"
- name: notification_id
desc: "ID de la notificacion a eliminar"
output: "None"
tested: false
tests: []
test_file_path: ""
file_path: "python/functions/metabase/notifications.py"
---
## Ejemplo
```python
# Eliminar una alerta por su ID
metabase_delete_notification(client, 99)
# Patron: listar y eliminar todas las alertas de una card
alerts = metabase_list_notifications(client, card_id=7711)
for a in alerts:
metabase_delete_notification(client, a["id"])
```
## Notas
La operacion es irreversible. Para desactivar temporalmente usar metabase_update_notification con active=False.
Elimina tanto alertas de cards (notification/card) como suscripciones de dashboards (notification/dashboard).
@@ -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.
@@ -0,0 +1,41 @@
---
name: metabase_get_snippet
kind: function
lang: py
domain: infra
version: "1.0.0"
purity: impure
signature: "def metabase_get_snippet(client: MetabaseClient, snippet_id: int) -> dict"
description: "Obtiene un SQL snippet de Metabase por su ID. Endpoint: GET /api/native-query-snippet/:id."
tags: [metabase, snippet, get, api, python]
uses_functions: []
uses_types: [MetabaseClient_go_infra]
returns: []
returns_optional: false
error_type: "error_go_core"
imports: [httpx]
params:
- name: client
desc: "instancia autenticada de MetabaseClient"
- name: snippet_id
desc: "ID numerico del snippet a obtener"
output: "dict: snippet completo con id, name, content, description, collection_id, creator_id, archived, created_at, updated_at"
tested: false
tests: []
test_file_path: ""
file_path: "python/functions/metabase/snippets.py"
---
## Ejemplo
```python
snippet = metabase_get_snippet(client, 42)
print(snippet["name"])
print(snippet["content"])
print(snippet["description"])
```
## Notas
Retorna 404 si el snippet no existe.
Util para verificar el contenido actual antes de hacer un update.
@@ -0,0 +1,49 @@
---
name: metabase_list_notifications
kind: function
lang: py
domain: infra
version: "1.0.0"
purity: impure
signature: "def metabase_list_notifications(client: MetabaseClient, card_id: int = 0, dashboard_id: int = 0) -> list[dict]"
description: "Lista notificaciones de Metabase (alertas y suscripciones). Filtra opcionalmente por card_id o dashboard_id. Endpoint: GET /api/notification."
tags: [metabase, notification, list, api, python]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: "error_go_core"
imports: []
params:
- name: client
desc: "instancia autenticada de MetabaseClient"
- name: card_id
desc: "si > 0, filtra alertas de tipo notification/card asociadas a esta card"
- name: dashboard_id
desc: "si > 0, filtra suscripciones de tipo notification/dashboard asociadas a este dashboard"
output: "list[dict]: lista de notificaciones con id, active, payload_type, payload, subscriptions, handlers, created_at, updated_at"
tested: false
tests: []
test_file_path: ""
file_path: "python/functions/metabase/notifications.py"
---
## Ejemplo
```python
# Todas las notificaciones
notifs = metabase_list_notifications(client)
# Solo alertas de una card especifica
alerts = metabase_list_notifications(client, card_id=7711)
for a in alerts:
print(a["id"], a["payload_type"], a["active"])
# Solo suscripciones de un dashboard
subs = metabase_list_notifications(client, dashboard_id=42)
```
## Notas
Requiere Metabase 0.57+. En versiones anteriores usar /api/pulse (deprecado).
Sin filtros retorna todas las notificaciones accesibles por el usuario autenticado.
@@ -0,0 +1,45 @@
---
name: metabase_list_snippets
kind: function
lang: py
domain: infra
version: "1.0.0"
purity: impure
signature: "def metabase_list_snippets(client: MetabaseClient, archived: bool = False) -> list[dict]"
description: "Lista SQL snippets reutilizables de Metabase. Un snippet se referencia en queries con {{snippet: nombre}}."
tags: [metabase, snippet, list, api, python]
uses_functions: []
uses_types: [MetabaseClient_go_infra]
returns: []
returns_optional: false
error_type: "error_go_core"
imports: [httpx]
params:
- name: client
desc: "instancia autenticada de MetabaseClient"
- name: archived
desc: "si True, incluye snippets archivados (default False)"
output: "list[dict]: snippets con id, name, content, description, collection_id, creator_id, archived, created_at, updated_at"
tested: false
tests: []
test_file_path: ""
file_path: "python/functions/metabase/snippets.py"
---
## Ejemplo
```python
snippets = metabase_list_snippets(client)
for s in snippets:
print(s["name"], len(s["content"]))
# Incluir archivados
all_snippets = metabase_list_snippets(client, archived=True)
active = [s for s in all_snippets if not s["archived"]]
```
## Notas
Si `archived=True` agrega el query param `?archived=true`.
Retorna lista directa de todos los snippets accesibles.
Los snippets archivados no aparecen en el autocomplete de queries de Metabase.
@@ -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,64 @@
---
name: metabase_update_notification
kind: function
lang: py
domain: infra
version: "1.0.0"
purity: impure
signature: "def metabase_update_notification(client: MetabaseClient, notification_id: int, **fields) -> dict"
description: "Actualiza una notificacion existente de Metabase (alerta o suscripcion). Permite modificar active, handlers, subscriptions o payload. Endpoint: PUT /api/notification/:id."
tags: [metabase, notification, update, api, python]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: "error_go_core"
imports: []
params:
- name: client
desc: "instancia autenticada de MetabaseClient"
- name: notification_id
desc: "ID de la notificacion a modificar"
- name: "**fields"
desc: "campos a actualizar: active (bool), handlers (list[dict]), subscriptions (list[dict]), payload (dict)"
output: "dict: notificacion actualizada con id, active, payload_type, payload, subscriptions, handlers, updated_at"
tested: false
tests: []
test_file_path: ""
file_path: "python/functions/metabase/notifications.py"
---
## Ejemplo
```python
# Desactivar una alerta
updated = metabase_update_notification(client, 99, active=False)
print(updated["active"]) # False
# Cambiar destinatarios
updated = metabase_update_notification(
client,
99,
handlers=[{
"channel_type": "channel/email",
"recipients": [
{"type": "notification-recipient/user", "user_id": 2},
],
}],
)
# Cambiar cron a diario a las 7am
updated = metabase_update_notification(
client,
99,
subscriptions=[{
"type": "notification-subscription/cron",
"cron_schedule": "0 7 * * *",
}],
)
```
## Notas
Solo se envian los campos que se pasan como kwargs.
Para reactivar una alerta previamente desactivada usar active=True.
@@ -0,0 +1,56 @@
---
name: metabase_update_snippet
kind: function
lang: py
domain: infra
version: "1.0.0"
purity: impure
signature: "def metabase_update_snippet(client: MetabaseClient, snippet_id: int, **fields) -> dict"
description: "Actualiza campos de un SQL snippet en Metabase. Acepta name, content, description, collection_id, archived via **fields."
tags: [metabase, snippet, update, api, python]
uses_functions: []
uses_types: [MetabaseClient_go_infra]
returns: []
returns_optional: false
error_type: "error_go_core"
imports: [httpx]
params:
- name: client
desc: "instancia autenticada de MetabaseClient"
- name: snippet_id
desc: "ID numerico del snippet a actualizar"
- name: "**fields"
desc: "campos a modificar: name (str), content (str), description (str), collection_id (int), archived (bool)"
output: "dict: snippet actualizado con todos los campos incluyendo updated_at"
tested: false
tests: []
test_file_path: ""
file_path: "python/functions/metabase/snippets.py"
---
## Ejemplo
```python
# Actualizar solo el contenido SQL
updated = metabase_update_snippet(
client, 42,
content="WITH supply_full AS (SELECT * FROM supply_orders)",
description="Version simplificada sin JOINs",
)
print(updated["updated_at"])
# Renombrar
metabase_update_snippet(client, 42, name="supply_orders_simple_cte")
# Mover a otra coleccion
metabase_update_snippet(client, 42, collection_id=7)
# Archivar (o usar metabase_archive_snippet)
metabase_update_snippet(client, 42, archived=True)
```
## Notas
Solo los campos reconocidos (name, content, description, collection_id, archived) se incluyen en el body.
Campos desconocidos en **fields se ignoran silenciosamente para evitar errores 400.
Para archivar en una sola llamada, preferir metabase_archive_snippet.
+243
View File
@@ -0,0 +1,243 @@
"""CRUD de notificaciones de Metabase (alertas y suscripciones).
Dos tipos:
- Card alerts (notification/card): alertas cuando una card tiene resultados,
supera un goal, etc. Cron configurable.
- Dashboard subscriptions (notification/dashboard): envio periodico de un
dashboard completo por email/slack.
API: /api/notification (Metabase 0.57+, reemplaza el antiguo /api/pulse).
"""
from .client import MetabaseClient
def metabase_list_notifications(
client: MetabaseClient,
card_id: int = 0,
dashboard_id: int = 0,
) -> list[dict]:
"""Lista notificaciones activas de Metabase.
Endpoint: GET /api/notification.
Permite filtrar por card o por dashboard. Si no se pasan filtros,
retorna todas las notificaciones accesibles por el usuario autenticado.
Args:
client: Cliente autenticado.
card_id: Si > 0, filtra notificaciones de tipo notification/card
asociadas a esta card.
dashboard_id: Si > 0, filtra notificaciones de tipo
notification/dashboard asociadas a este dashboard.
Returns:
Lista de dicts con: id, active, payload_type, payload,
subscriptions, handlers, created_at, updated_at.
Example:
>>> # Todas las notificaciones
>>> notifs = metabase_list_notifications(client)
>>> # Solo alertas de una card especifica
>>> alerts = metabase_list_notifications(client, card_id=7711)
>>> for a in alerts:
... print(a["id"], a["payload_type"], a["active"])
"""
params: dict = {}
if card_id > 0:
params["card_id"] = card_id
if dashboard_id > 0:
params["dashboard_id"] = dashboard_id
return client.request("GET", "/api/notification", params=params)
def metabase_create_card_alert(
client: MetabaseClient,
card_id: int,
cron_schedule: str,
recipients: list[dict],
send_condition: str = "has_result",
send_once: bool = False,
) -> dict:
"""Crea una alerta sobre los resultados de una card.
Endpoint: POST /api/notification.
Envia un email cuando la card cumple la condicion especificada
(tiene resultados, supera un goal, etc.) segun el cron configurado.
Args:
client: Cliente autenticado.
card_id: ID de la card que dispara la alerta.
cron_schedule: Expresion cron de 5 campos (ej: "0 9 * * 1" = lunes 9am).
recipients: Lista de destinatarios. Cada dict puede ser:
- Usuario Metabase: {"type": "notification-recipient/user", "user_id": 1}
- Email externo: {"type": "notification-recipient/raw-value",
"details": {"email": "x@y.com"}}
send_condition: Condicion que dispara el envio:
"has_result" (tiene filas), "goal_above" (supera goal),
"goal_below" (cae bajo goal). Default: "has_result".
send_once: Si True, se envia una sola vez y se desactiva.
Default: False.
Returns:
Dict con la notificacion creada: id, active, payload_type,
payload, subscriptions, handlers, created_at.
Example:
>>> alert = metabase_create_card_alert(
... client,
... card_id=7711,
... cron_schedule="0 9 * * 1",
... recipients=[
... {"type": "notification-recipient/user", "user_id": 1},
... {"type": "notification-recipient/raw-value",
... "details": {"email": "team@aurgi.com"}},
... ],
... send_condition="has_result",
... )
>>> print(alert["id"], alert["active"])
"""
body = {
"payload_type": "notification/card",
"payload": {
"card_id": card_id,
"send_condition": send_condition,
"send_once": send_once,
},
"subscriptions": [
{
"type": "notification-subscription/cron",
"cron_schedule": cron_schedule,
}
],
"handlers": [
{
"channel_type": "channel/email",
"recipients": recipients,
}
],
"active": True,
}
return client.request("POST", "/api/notification", json=body)
def metabase_create_dashboard_subscription(
client: MetabaseClient,
dashboard_id: int,
cron_schedule: str,
recipients: list[dict],
) -> dict:
"""Crea una suscripcion periodica a un dashboard.
Endpoint: POST /api/notification.
Envia el dashboard completo por email segun el cron configurado.
Args:
client: Cliente autenticado.
dashboard_id: ID del dashboard a enviar.
cron_schedule: Expresion cron de 5 campos (ej: "0 8 * * 1-5" = lun-vie 8am).
recipients: Lista de destinatarios. Cada dict puede ser:
- Usuario Metabase: {"type": "notification-recipient/user", "user_id": 1}
- Email externo: {"type": "notification-recipient/raw-value",
"details": {"email": "x@y.com"}}
Returns:
Dict con la suscripcion creada: id, active, payload_type,
payload, subscriptions, handlers, created_at.
Example:
>>> sub = metabase_create_dashboard_subscription(
... client,
... dashboard_id=42,
... cron_schedule="0 8 * * 1",
... recipients=[
... {"type": "notification-recipient/user", "user_id": 5},
... ],
... )
>>> print(sub["id"], sub["payload"]["dashboard_id"])
"""
body = {
"payload_type": "notification/dashboard",
"payload": {
"dashboard_id": dashboard_id,
},
"subscriptions": [
{
"type": "notification-subscription/cron",
"cron_schedule": cron_schedule,
}
],
"handlers": [
{
"channel_type": "channel/email",
"recipients": recipients,
}
],
"active": True,
}
return client.request("POST", "/api/notification", json=body)
def metabase_update_notification(
client: MetabaseClient,
notification_id: int,
**fields,
) -> dict:
"""Actualiza una notificacion existente (alerta o suscripcion).
Endpoint: PUT /api/notification/:id.
Permite modificar campos como active, handlers, subscriptions,
payload, etc. Solo se envian los campos proporcionados.
Args:
client: Cliente autenticado.
notification_id: ID de la notificacion a modificar.
**fields: Campos a actualizar. Ejemplos:
active=False para desactivar.
handlers=[...] para cambiar destinatarios.
subscriptions=[...] para cambiar el cron.
Returns:
Dict con la notificacion actualizada: id, active, payload_type,
payload, subscriptions, handlers, updated_at.
Example:
>>> # Desactivar una alerta
>>> updated = metabase_update_notification(client, 99, active=False)
>>> # Cambiar destinatarios
>>> updated = metabase_update_notification(
... client,
... 99,
... handlers=[{
... "channel_type": "channel/email",
... "recipients": [
... {"type": "notification-recipient/user", "user_id": 2},
... ],
... }],
... )
>>> print(updated["active"])
"""
return client.request("PUT", f"/api/notification/{notification_id}", json=fields)
def metabase_delete_notification(
client: MetabaseClient,
notification_id: int,
) -> None:
"""Elimina una notificacion (alerta o suscripcion) de Metabase.
Endpoint: DELETE /api/notification/:id.
La operacion es irreversible. Afecta tanto a alertas de cards
como a suscripciones de dashboards.
Args:
client: Cliente autenticado.
notification_id: ID de la notificacion a eliminar.
Returns:
None.
Example:
>>> metabase_delete_notification(client, 99)
>>> # La notificacion ya no existe
"""
client.request("DELETE", f"/api/notification/{notification_id}")
@@ -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.
+170
View File
@@ -0,0 +1,170 @@
"""CRUD de SQL snippets de Metabase.
Un snippet es un fragmento SQL reutilizable que se referencia en queries
nativas con la sintaxis {{snippet: nombre}}. Ideal para CTEs comunes
que se repiten en multiples cards.
"""
from .client import MetabaseClient
def metabase_list_snippets(client: MetabaseClient, archived: bool = False) -> list[dict]:
"""Lista SQL snippets de Metabase.
Endpoint: GET /api/native-query-snippet.
Args:
client: Cliente autenticado.
archived: Si True, incluye snippets archivados.
Returns:
Lista de dicts con: id, name, content, description, collection_id,
creator_id, archived, created_at, updated_at.
Example:
>>> snippets = metabase_list_snippets(client)
>>> for s in snippets:
... print(s["name"], len(s["content"]))
"""
params = {}
if archived:
params["archived"] = "true"
return client.request("GET", "/api/native-query-snippet", params=params)
def metabase_get_snippet(client: MetabaseClient, snippet_id: int) -> dict:
"""Obtiene un SQL snippet de Metabase por su ID.
Endpoint: GET /api/native-query-snippet/:id.
Args:
client: Cliente autenticado.
snippet_id: ID numerico del snippet.
Returns:
Dict con campos completos del snippet: id, name, content, description,
collection_id, creator_id, archived, created_at, updated_at.
Raises:
httpx.HTTPStatusError: 404 si el snippet no existe.
Example:
>>> snippet = metabase_get_snippet(client, 42)
>>> print(snippet["name"], snippet["content"])
"""
return client.request("GET", f"/api/native-query-snippet/{snippet_id}")
def metabase_create_snippet(
client: MetabaseClient,
name: str,
content: str,
description: str = "",
collection_id: int = 0,
) -> dict:
"""Crea un nuevo SQL snippet en Metabase.
Endpoint: POST /api/native-query-snippet.
Args:
client: Cliente autenticado.
name: Nombre del snippet. Se usa en queries con {{snippet: nombre}}.
content: SQL del snippet. Puede ser una CTE completa, una subquery,
o cualquier fragmento SQL reutilizable.
description: Descripcion opcional del snippet.
collection_id: ID de la coleccion donde guardar el snippet.
Si es 0, se guarda en la raiz (Our analytics).
Returns:
Dict con el snippet creado, incluyendo el campo "id" asignado
por Metabase.
Raises:
httpx.HTTPStatusError: 400 si el nombre ya existe o el SQL es invalido.
Example:
>>> snippet = metabase_create_snippet(
... client,
... "supply_orders_cte",
... '''WITH supply_full AS (
... SELECT so.id, so.service_request_id
... FROM supply_orders so
... LEFT JOIN service_requests sr ON so.service_request_id = sr.id
... )''',
... description="CTE base de supply_orders con JOINs para cruce con NAV",
... )
>>> print(snippet["id"], snippet["name"])
>>> # Usar en una card:
>>> # "{{snippet: supply_orders_cte}} SELECT ... FROM supply_full"
"""
body: dict = {"name": name, "content": content}
if description:
body["description"] = description
if collection_id:
body["collection_id"] = collection_id
return client.request("POST", "/api/native-query-snippet", json=body)
def metabase_update_snippet(
client: MetabaseClient,
snippet_id: int,
**fields,
) -> dict:
"""Actualiza campos de un SQL snippet en Metabase.
Endpoint: PUT /api/native-query-snippet/:id.
Args:
client: Cliente autenticado.
snippet_id: ID numerico del snippet a actualizar.
**fields: Campos a modificar. Campos validos:
- name (str): Nuevo nombre del snippet.
- content (str): Nuevo SQL del snippet.
- description (str): Nueva descripcion.
- collection_id (int): Nueva coleccion.
- archived (bool): True para archivar, False para desarchivar.
Returns:
Dict con el snippet actualizado.
Raises:
httpx.HTTPStatusError: 404 si el snippet no existe.
httpx.HTTPStatusError: 400 si los campos son invalidos.
Example:
>>> updated = metabase_update_snippet(
... client, 42,
... content="WITH supply_full AS (SELECT * FROM supply_orders)",
... description="Version simplificada",
... )
>>> print(updated["updated_at"])
"""
valid_fields = {"name", "content", "description", "collection_id", "archived"}
body = {k: v for k, v in fields.items() if k in valid_fields}
return client.request("PUT", f"/api/native-query-snippet/{snippet_id}", json=body)
def metabase_archive_snippet(client: MetabaseClient, snippet_id: int) -> dict:
"""Archiva un SQL snippet en Metabase.
Wrapper sobre metabase_update_snippet con archived=True.
Los snippets archivados no aparecen en el autocomplete de queries
pero sus referencias existentes siguen funcionando.
Endpoint: PUT /api/native-query-snippet/:id.
Args:
client: Cliente autenticado.
snippet_id: ID numerico del snippet a archivar.
Returns:
Dict con el snippet archivado.
Raises:
httpx.HTTPStatusError: 404 si el snippet no existe.
Example:
>>> result = metabase_archive_snippet(client, 42)
>>> print(result["archived"]) # True
"""
return metabase_update_snippet(client, snippet_id, archived=True)