diff --git a/python/functions/metabase/__init__.py b/python/functions/metabase/__init__.py index 5a6fd3e4..53a028ee 100644 --- a/python/functions/metabase/__init__.py +++ b/python/functions/metabase/__init__.py @@ -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", diff --git a/python/functions/metabase/cards.py b/python/functions/metabase/cards.py index fef958be..9c05c4ee 100644 --- a/python/functions/metabase/cards.py +++ b/python/functions/metabase/cards.py @@ -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__". + + 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) diff --git a/python/functions/metabase/dashboard_filters.py b/python/functions/metabase/dashboard_filters.py new file mode 100644 index 00000000..591c3ddd --- /dev/null +++ b/python/functions/metabase/dashboard_filters.py @@ -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}, + ) diff --git a/python/functions/metabase/documents.py b/python/functions/metabase/documents.py index 2348aae0..f89fd516 100644 --- a/python/functions/metabase/documents.py +++ b/python/functions/metabase/documents.py @@ -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": }} + ]} + + 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) diff --git a/python/functions/metabase/metabase_add_dashboard_filter.md b/python/functions/metabase/metabase_add_dashboard_filter.md new file mode 100644 index 00000000..8cc87a54 --- /dev/null +++ b/python/functions/metabase/metabase_add_dashboard_filter.md @@ -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`. diff --git a/python/functions/metabase/metabase_archive_snippet.md b/python/functions/metabase/metabase_archive_snippet.md new file mode 100644 index 00000000..5e1054d2 --- /dev/null +++ b/python/functions/metabase/metabase_archive_snippet.md @@ -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). diff --git a/python/functions/metabase/metabase_copy_document.md b/python/functions/metabase/metabase_copy_document.md index 67b5efe3..e0cc077d 100644 --- a/python/functions/metabase/metabase_copy_document.md +++ b/python/functions/metabase/metabase_copy_document.md @@ -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. diff --git a/python/functions/metabase/metabase_create_card.md b/python/functions/metabase/metabase_create_card.md index 2984ada5..675d2950 100644 --- a/python/functions/metabase/metabase_create_card.md +++ b/python/functions/metabase/metabase_create_card.md @@ -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__"`. diff --git a/python/functions/metabase/metabase_create_card_alert.md b/python/functions/metabase/metabase_create_card_alert.md new file mode 100644 index 00000000..fad65ee6 --- /dev/null +++ b/python/functions/metabase/metabase_create_card_alert.md @@ -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. diff --git a/python/functions/metabase/metabase_create_dashboard_subscription.md b/python/functions/metabase/metabase_create_dashboard_subscription.md new file mode 100644 index 00000000..b478e603 --- /dev/null +++ b/python/functions/metabase/metabase_create_dashboard_subscription.md @@ -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". diff --git a/python/functions/metabase/metabase_create_document.md b/python/functions/metabase/metabase_create_document.md index d61d0cc7..f6e6d9de 100644 --- a/python/functions/metabase/metabase_create_document.md +++ b/python/functions/metabase/metabase_create_document.md @@ -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. diff --git a/python/functions/metabase/metabase_create_model.md b/python/functions/metabase/metabase_create_model.md new file mode 100644 index 00000000..bd9971ed --- /dev/null +++ b/python/functions/metabase/metabase_create_model.md @@ -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__'." +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__"` 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. diff --git a/python/functions/metabase/metabase_create_snippet.md b/python/functions/metabase/metabase_create_snippet.md new file mode 100644 index 00000000..9e2b36a0 --- /dev/null +++ b/python/functions/metabase/metabase_create_snippet.md @@ -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. diff --git a/python/functions/metabase/metabase_delete_notification.md b/python/functions/metabase/metabase_delete_notification.md new file mode 100644 index 00000000..eb82bc45 --- /dev/null +++ b/python/functions/metabase/metabase_delete_notification.md @@ -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). diff --git a/python/functions/metabase/metabase_export_card.md b/python/functions/metabase/metabase_export_card.md new file mode 100644 index 00000000..2ccf81a1 --- /dev/null +++ b/python/functions/metabase/metabase_export_card.md @@ -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. diff --git a/python/functions/metabase/metabase_get_snippet.md b/python/functions/metabase/metabase_get_snippet.md new file mode 100644 index 00000000..41955111 --- /dev/null +++ b/python/functions/metabase/metabase_get_snippet.md @@ -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. diff --git a/python/functions/metabase/metabase_list_notifications.md b/python/functions/metabase/metabase_list_notifications.md new file mode 100644 index 00000000..0829083e --- /dev/null +++ b/python/functions/metabase/metabase_list_notifications.md @@ -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. diff --git a/python/functions/metabase/metabase_list_snippets.md b/python/functions/metabase/metabase_list_snippets.md new file mode 100644 index 00000000..c1b7a300 --- /dev/null +++ b/python/functions/metabase/metabase_list_snippets.md @@ -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. diff --git a/python/functions/metabase/metabase_update_document.md b/python/functions/metabase/metabase_update_document.md index 19ce8655..54a6a0d6 100644 --- a/python/functions/metabase/metabase_update_document.md +++ b/python/functions/metabase/metabase_update_document.md @@ -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. diff --git a/python/functions/metabase/metabase_update_notification.md b/python/functions/metabase/metabase_update_notification.md new file mode 100644 index 00000000..f3e7ede8 --- /dev/null +++ b/python/functions/metabase/metabase_update_notification.md @@ -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. diff --git a/python/functions/metabase/metabase_update_snippet.md b/python/functions/metabase/metabase_update_snippet.md new file mode 100644 index 00000000..8730c486 --- /dev/null +++ b/python/functions/metabase/metabase_update_snippet.md @@ -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. diff --git a/python/functions/metabase/notifications.py b/python/functions/metabase/notifications.py new file mode 100644 index 00000000..a64f190e --- /dev/null +++ b/python/functions/metabase/notifications.py @@ -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}") diff --git a/python/functions/metabase/prosemirror_card_embed.md b/python/functions/metabase/prosemirror_card_embed.md new file mode 100644 index 00000000..c762c38f --- /dev/null +++ b/python/functions/metabase/prosemirror_card_embed.md @@ -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. diff --git a/python/functions/metabase/snippets.py b/python/functions/metabase/snippets.py new file mode 100644 index 00000000..5b4d8761 --- /dev/null +++ b/python/functions/metabase/snippets.py @@ -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)