diff --git a/python/functions/metabase/__init__.py b/python/functions/metabase/__init__.py index a7744901..5a6fd3e4 100644 --- a/python/functions/metabase/__init__.py +++ b/python/functions/metabase/__init__.py @@ -1,15 +1,35 @@ 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 -from .dashboards import metabase_list_dashboards, metabase_get_dashboard, metabase_create_dashboard, metabase_update_dashboard, metabase_delete_dashboard +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 .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 .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 +from .maintenance import metabase_fix_null_ratio, metabase_pair_n_n1_columns +from .metabase_mbql_validate import metabase_mbql_validate +from .metabase_update_dashboard_safe import metabase_update_dashboard_safe __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_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_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", + "metabase_get_permission_graph", "metabase_update_permission_graph", + "metabase_get_collection_graph", "metabase_update_collection_graph", "metabase_setup", + "metabase_fix_null_ratio", + "metabase_pair_n_n1_columns", + "metabase_mbql_validate", + "metabase_update_dashboard_safe", ] diff --git a/python/functions/metabase/cards.py b/python/functions/metabase/cards.py index de22cd3e..fef958be 100644 --- a/python/functions/metabase/cards.py +++ b/python/functions/metabase/cards.py @@ -225,3 +225,132 @@ def metabase_execute_query( "max-results-bare-rows": max_results, } return client.request("POST", "/api/dataset", json=body) + + +def metabase_copy_card( + client: MetabaseClient, + card_id: int, + name: str | None = None, + collection_id: int | None = None, + description: str | None = None, +) -> dict: + """Crea una copia de una card/pregunta existente en Metabase. + + Endpoint: POST /api/card/:id/copy. Usa el endpoint nativo de Metabase para + duplicar la card, copiando dataset_query, display y visualization_settings. + Los campos name, collection_id y description se pueden sobrescribir via body. + + Args: + client: Cliente autenticado. + card_id: ID de la card a copiar. + name: Nombre para la copia. None = Metabase asigna "Copy of ". + collection_id: Coleccion destino. None = misma coleccion que el original. + description: Descripcion de la copia. None = misma que el original. + + Returns: + Dict con la card nueva creada por Metabase. Incluye el campo `id` + asignado a la copia y todos los campos heredados del original. + + Example: + >>> copy = metabase_copy_card(client, 42) + >>> print(copy["id"], copy["name"]) # "Copy of ..." + >>> # Copiar a otra coleccion con nombre propio: + >>> copy = metabase_copy_card(client, 42, name="Revenue Q2", collection_id=7) + """ + body: dict = {} + if name is not None: + body["name"] = name + if collection_id is not None: + body["collection_id"] = collection_id + if description is not None: + body["description"] = description + return client.request("POST", f"/api/card/{card_id}/copy", json=body or None) + + +def metabase_move_card( + client: MetabaseClient, + card_id: int, + collection_id: int | None, +) -> dict: + """Mueve una card/pregunta a otra coleccion. + + Wrapper thin sobre PUT /api/card/:id que solo actualiza collection_id. + Equivalente a metabase_update_card(client, card_id, collection_id=...) pero + con intencion explicita y soporte para mover a root con None. + + Endpoint: PUT /api/card/:id. + + Args: + client: Cliente autenticado. + card_id: ID de la card a mover. + collection_id: ID de la coleccion destino. None mueve a "Our analytics" (root). + + Returns: + Dict con la card actualizada, incluyendo el nuevo collection_id. + + Example: + >>> card = metabase_move_card(client, 42, collection_id=7) + >>> print(card["collection_id"]) # 7 + >>> # Mover a root: + >>> card = metabase_move_card(client, 42, collection_id=None) + """ + return client.request("PUT", f"/api/card/{card_id}", json={"collection_id": collection_id}) + + +def metabase_create_card_raw(client: MetabaseClient, payload: dict) -> dict: + """Crea una card en Metabase con payload completo ya construido por el caller. + + Version raw de metabase_create_card. El caller es responsable de construir + el payload completo antes de llamar a esta funcion — no se realiza ninguna + validacion ni transformacion local. Util para flujos "Metabase as code" + donde el YAML define todos los campos de la card tal como los espera la API. + + Endpoint: POST /api/card. + + El payload minimo necesita: + - name (str): nombre de la card. + - dataset_query (dict): query SQL nativa o MBQL. + - display (str): tipo de visualizacion (table, bar, scalar, etc.). + + Campos opcionales que esta funcion preserva (a diferencia de metabase_create_card): + - visualization_settings (dict): configuracion detallada del grafico. + - parameters (list[dict]): parametros de la query con template tags. + - parameter_mappings (list[dict]): mapeo de parametros a dashboard filters. + - type (str): "question" (default), "model", "metric". + - collection_id (int): ID de coleccion destino. + - description (str): descripcion de la card. + - archived (bool): estado de archivo inicial. + - enable_embedding (bool): habilitar embedding publico. + - embedding_params (dict): configuracion de embedding. + + Si Metabase devuelve 4xx/5xx, httpx lanza HTTPStatusError sin capturar. + + Args: + client: Cliente autenticado con sesion activa. + payload: Dict con el payload completo de la card tal como lo espera + la API de Metabase. Se envia sin modificaciones. + + Returns: + Dict con la card recien creada. Incluye el campo `id` asignado por + Metabase y todos los campos normalizados (display, dataset_query, + visualization_settings, created_at, etc.). + + Example: + >>> card = metabase_create_card_raw(client, { + ... "name": "Revenue by Month", + ... "dataset_query": { + ... "database": 1, + ... "type": "native", + ... "native": {"query": "SELECT date_trunc('month', created_at), SUM(total) FROM orders GROUP BY 1"}, + ... }, + ... "display": "line", + ... "visualization_settings": { + ... "graph.x_axis.title_text": "Month", + ... "graph.y_axis.title_text": "Revenue", + ... }, + ... "description": "Monthly revenue trend", + ... "collection_id": 5, + ... }) + >>> print(card["id"]) # ID asignado por Metabase + """ + return client.request("POST", "/api/card", json=payload) diff --git a/python/functions/metabase/client.py b/python/functions/metabase/client.py index a3b4ceaf..9dc9e7f7 100644 --- a/python/functions/metabase/client.py +++ b/python/functions/metabase/client.py @@ -12,16 +12,19 @@ class MetabaseClient: _http: Cliente httpx reutilizable con headers de auth. """ - def __init__(self, base_url: str, token: str) -> None: + def __init__(self, base_url: str, token: str, timeout: float = 120.0) -> None: self.base_url = base_url.rstrip("/") self.token = token + # API keys de Metabase empiezan por "mb_" y usan X-API-KEY. + # Session tokens usan X-Metabase-Session. + auth_header = "X-API-KEY" if token.startswith("mb_") else "X-Metabase-Session" self._http = httpx.Client( base_url=self.base_url, headers={ "Content-Type": "application/json", - "X-Metabase-Session": token, + auth_header: token, }, - timeout=30.0, + timeout=timeout, ) def request(self, method: str, path: str, **kwargs) -> dict | list | None: diff --git a/python/functions/metabase/collections.py b/python/functions/metabase/collections.py new file mode 100644 index 00000000..62510e79 --- /dev/null +++ b/python/functions/metabase/collections.py @@ -0,0 +1,39 @@ +"""CRUD y operaciones sobre collections de Metabase.""" + +from .client import MetabaseClient + + +def metabase_move_collection( + client: MetabaseClient, + collection_id: int, + parent_id: int | None, +) -> dict: + """Mueve una collection (sub-arbol completo) a otro padre. + + Endpoint: PUT /api/collection/:id con {parent_id: ...}. + parent_id=None mueve la collection a la raiz ("Our analytics"). + + Metabase reubica la collection y todo su sub-arbol (colecciones hijas, + cards, dashboards, documents) atomicamente. + + Args: + client: Cliente autenticado. + collection_id: ID de la collection a mover. + parent_id: ID de la collection padre destino. None = raiz. + + Returns: + Collection actualizada con el nuevo parent_id y location. + + Example: + >>> col = metabase_move_collection(client, 12, parent_id=3) + >>> print(col["location"]) # "/3/" + + >>> # Mover a raiz: + >>> col = metabase_move_collection(client, 12, parent_id=None) + >>> print(col["location"]) # "/" + """ + return client.request( + "PUT", + f"/api/collection/{collection_id}", + json={"parent_id": parent_id}, + ) diff --git a/python/functions/metabase/dashboards.py b/python/functions/metabase/dashboards.py index f298c7ae..d7df8bab 100644 --- a/python/functions/metabase/dashboards.py +++ b/python/functions/metabase/dashboards.py @@ -141,3 +141,162 @@ def metabase_delete_dashboard(client: MetabaseClient, dashboard_id: int) -> None >>> # Preferir: metabase_update_dashboard(client, 1, archived=True) """ client.request("DELETE", f"/api/dashboard/{dashboard_id}") + + +def metabase_copy_dashboard( + client: MetabaseClient, + dashboard_id: int, + name: str | None = None, + collection_id: int | None = None, + description: str | None = None, + is_deep_copy: bool = False, +) -> dict: + """Crea una copia de un dashboard existente en Metabase. + + Endpoint: POST /api/dashboard/:id/copy. Usa el endpoint nativo de Metabase para + duplicar el dashboard junto con su layout de dashcards y parametros. + Con is_deep_copy=True tambien clona las cards referenciadas. + + Args: + client: Cliente autenticado. + dashboard_id: ID del dashboard a copiar. + name: Nombre para la copia. None = Metabase asigna "Copy of ". + collection_id: Coleccion destino. None = misma coleccion que el original. + description: Descripcion de la copia. None = misma que el original. + is_deep_copy: Si True, clona tambien todas las cards referenciadas por el + dashboard (deep copy). Si False, la copia referencia las cards originales. + + Returns: + Dict con el dashboard nuevo creado por Metabase. Incluye el campo `id` + asignado a la copia y el layout de dashcards copiado. + + Example: + >>> copy = metabase_copy_dashboard(client, 1) + >>> print(copy["id"], copy["name"]) # "Copy of ..." + >>> # Deep copy a otra coleccion: + >>> copy = metabase_copy_dashboard(client, 1, name="Sales Q2", collection_id=7, is_deep_copy=True) + """ + body: dict = {"is_deep_copy": is_deep_copy} + if name is not None: + body["name"] = name + if collection_id is not None: + body["collection_id"] = collection_id + if description is not None: + body["description"] = description + return client.request("POST", f"/api/dashboard/{dashboard_id}/copy", json=body) + + +def metabase_move_dashboard( + client: MetabaseClient, + dashboard_id: int, + collection_id: int | None, +) -> dict: + """Mueve un dashboard a otra coleccion. + + Wrapper thin sobre PUT /api/dashboard/:id que solo actualiza collection_id. + Equivalente a metabase_update_dashboard(client, id, collection_id=...) pero + con intencion explicita y soporte para mover a root con None. + + Endpoint: PUT /api/dashboard/:id. + + Args: + client: Cliente autenticado. + dashboard_id: ID del dashboard a mover. + collection_id: ID de la coleccion destino. None mueve a "Our analytics" (root). + + Returns: + Dict con el dashboard actualizado, incluyendo el nuevo collection_id. + + Example: + >>> dash = metabase_move_dashboard(client, 1, collection_id=7) + >>> print(dash["collection_id"]) # 7 + >>> # Mover a root: + >>> dash = metabase_move_dashboard(client, 1, collection_id=None) + """ + return client.request("PUT", f"/api/dashboard/{dashboard_id}", json={"collection_id": collection_id}) + + +def metabase_create_dashboard_raw(client: MetabaseClient, payload: dict) -> dict: + """Crea un dashboard en Metabase con payload completo ya construido por el caller. + + Version raw de metabase_create_dashboard. El caller es responsable de + construir el payload completo — no se realiza validacion ni transformacion + local. Util para flujos "Metabase as code" donde el YAML define todos los + campos del dashboard tal como los espera la API. + + COMPORTAMIENTO EN DOS PASOS (limitacion de la API de Metabase): + El endpoint POST /api/dashboard NO acepta `dashcards` en el body inicial; + solo crea el dashboard vacio. Para añadir cards es necesario un PUT posterior. + Esta funcion maneja esa limitacion automaticamente: + + 1. Si el payload contiene `dashcards`, se extraen antes del POST. + 2. POST /api/dashboard crea el dashboard vacio (sin dashcards). + 3. Si habia dashcards, PUT /api/dashboard/:id con {"dashcards": [...]} + las añade al dashboard recien creado. + 4. Retorna la respuesta del PUT (con dashcards pobladas), o la del POST + si el payload original no contenia dashcards. + + Endpoint inicial: POST /api/dashboard. + Endpoint secundario (condicional): PUT /api/dashboard/:id. + + El payload puede incluir: + - name (str, requerido): nombre del dashboard. + - description (str): descripcion. + - collection_id (int): ID de coleccion destino. + - parameters (list[dict]): filtros/parametros del dashboard. + - dashcards (list[dict]): cards posicionadas. Cada dashcard necesita: + id (int negativo para nuevas, ej: -1, -2), + card_id (int), size_x (int), size_y (int), col (int), row (int). + Campos opcionales: visualization_settings, parameter_mappings. + - tabs (list[dict]): pestañas del dashboard. + - enable_embedding (bool): habilitar embedding publico. + - embedding_params (dict): configuracion de embedding. + + Si Metabase devuelve 4xx/5xx en cualquier paso, httpx lanza HTTPStatusError. + + Args: + client: Cliente autenticado con sesion activa. + payload: Dict con el payload completo del dashboard tal como lo espera + la API de Metabase. Puede incluir dashcards. + + Returns: + Dict con el dashboard creado. Si habia dashcards, la respuesta es la + del PUT final e incluye el campo `dashcards` con las cards posicionadas. + Si no habia dashcards, es la respuesta del POST inicial. + + Example: + >>> dash = metabase_create_dashboard_raw(client, { + ... "name": "Sales Overview", + ... "description": "KPIs de ventas mensuales", + ... "collection_id": 5, + ... "parameters": [], + ... "dashcards": [ + ... { + ... "id": -1, + ... "card_id": 42, + ... "size_x": 6, + ... "size_y": 4, + ... "col": 0, + ... "row": 0, + ... "visualization_settings": {}, + ... "parameter_mappings": [], + ... }, + ... ], + ... }) + >>> print(dash["id"]) # ID asignado por Metabase + >>> print(len(dash["dashcards"])) # 1 + """ + body = {k: v for k, v in payload.items() if k != "dashcards"} + dashcards = payload.get("dashcards") + + created = client.request("POST", "/api/dashboard", json=body) + + if dashcards: + dashboard_id = created["id"] + return client.request( + "PUT", + f"/api/dashboard/{dashboard_id}", + json={"dashcards": dashcards}, + ) + + return created diff --git a/python/functions/metabase/documents.py b/python/functions/metabase/documents.py new file mode 100644 index 00000000..2348aae0 --- /dev/null +++ b/python/functions/metabase/documents.py @@ -0,0 +1,319 @@ +"""CRUD de documents de Metabase (feature reciente, Metabase 0.57+). + +Un document es un texto editable en Metabase (tipo Notion) serializado como +ProseMirror JSON (content_type: application/json+vnd.prose-mirror). Permite +embeber cards, smart links y flex containers. + +Nodos 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 + +Marks: + - bold, italic, strike, code, link (attrs.href), underline, + highlight, subscript, textStyle +""" + +from .client import MetabaseClient + + +def metabase_list_documents(client: MetabaseClient) -> list[dict]: + """Lista documents de Metabase. + + Endpoint: GET /api/document. Retorna `{"items": [...]}` segun la API. + + Args: + client: Cliente autenticado. + + Returns: + Lista de dicts con: id, name, collection_id, archived, created_at, + updated_at, creator_id, content_type, entity_id. + + Example: + >>> docs = metabase_list_documents(client) + >>> for d in docs: + ... print(d["id"], d["name"]) + """ + resp = client.request("GET", "/api/document") + if isinstance(resp, dict) and "items" in resp: + return resp["items"] + return resp or [] + + +def metabase_get_document(client: MetabaseClient, document_id: int) -> dict: + """Obtiene un document completo con su contenido ProseMirror. + + Endpoint: GET /api/document/:id. + + Args: + client: Cliente autenticado. + document_id: ID del document. + + Returns: + Dict con: id, name, document (ProseMirror tree), collection_id, + archived, creator, created_at, updated_at, entity_id, content_type. + + Example: + >>> doc = metabase_get_document(client, 1) + >>> print(doc["name"]) + >>> tree = doc["document"] # {"type": "doc", "content": [...]} + """ + return client.request("GET", f"/api/document/{document_id}") + + +def metabase_create_document( + client: MetabaseClient, + name: str, + document: dict, + collection_id: int = 0, +) -> dict: + """Crea un document nuevo. + + Endpoint: POST /api/document. + + Args: + client: Cliente autenticado. + name: Titulo del document (1-254 chars, no blank). + document: Arbol ProseMirror JSON. Formato minimo: + {"type": "doc", "content": [{"type": "paragraph", "content": [...]}]} + O cadena vacia "" si se quiere arrancar en blanco. + collection_id: ID de coleccion destino. 0 = root. + + Returns: + Document creado con su id asignado. + + Example: + >>> doc = metabase_create_document(client, "Notas", { + ... "type": "doc", + ... "content": [{ + ... "type": "paragraph", + ... "content": [{"type": "text", "text": "Hola mundo"}] + ... }] + ... }) + """ + body: dict = {"name": name, "document": document} + if collection_id > 0: + body["collection_id"] = collection_id + return client.request("POST", "/api/document", json=body) + + +def metabase_update_document( + client: MetabaseClient, + document_id: int, + **fields, +) -> dict: + """Actualiza un document. Solo se envian los campos pasados. + + Endpoint: PUT /api/document/:id. + + Campos tipicos: name, document, collection_id, archived. + + Args: + client: Cliente autenticado. + document_id: ID del document a actualizar. + **fields: Campos a modificar. + + Returns: + Document actualizado. + + Example: + >>> metabase_update_document(client, 1, name="Nuevo titulo") + >>> metabase_update_document(client, 1, document={"type":"doc","content":[...]}) + """ + return client.request("PUT", f"/api/document/{document_id}", json=fields) + + +def metabase_archive_document(client: MetabaseClient, document_id: int) -> dict: + """Archiva un document (equivalente a PUT con archived=True). + + Metabase exige archive previo para poder eliminar. + + Args: + client: Cliente autenticado. + document_id: ID del document. + + Returns: + Document con archived=True. + """ + return client.request("PUT", f"/api/document/{document_id}", json={"archived": True}) + + +def metabase_list_document_comments( + client: MetabaseClient, + document_id: int, + *, + include_resolved: bool = True, + include_deleted: bool = False, +) -> list[dict]: + """Lista los comentarios de un document. + + Endpoint: GET /api/comment?target_type=document&target_id=:id. + + Un comentario puede estar anclado a un bloque especifico via + `child_target_id` (UUID que matchea `attrs._id` de un parrafo) o al + document entero si `child_target_id` es null. Las respuestas tipo thread + cuelgan via `parent_comment_id`. + + Args: + client: Cliente autenticado. + document_id: ID del document. + include_resolved: si False, filtra comentarios con is_resolved=True. + include_deleted: si False, filtra comentarios con deleted_at no null. + + Returns: + Lista de dicts con: id, content (arbol ProseMirror), creator, creator_id, + target_type, target_id, child_target_id, parent_comment_id, is_resolved, + deleted_at, reactions, created_at, updated_at. + + Example: + >>> comments = metabase_list_document_comments(client, 29) + >>> for c in comments: + ... text = _comment_plaintext(c["content"]) + ... print(f'{c["creator"]["common_name"]}: {text}') + """ + resp = client.request( + "GET", + "/api/comment", + params={"target_type": "document", "target_id": document_id}, + ) + items = resp.get("comments", []) if isinstance(resp, dict) else [] + if not include_resolved: + items = [c for c in items if not c.get("is_resolved")] + if not include_deleted: + items = [c for c in items if c.get("deleted_at") is None] + return items + + +def metabase_create_document_comment( + client: MetabaseClient, + document_id: int, + content: dict, + *, + child_target_id: str | None = None, + parent_comment_id: int | None = None, +) -> dict: + """Crea un comentario en un document. + + Endpoint: POST /api/comment. + + Args: + client: Cliente autenticado. + document_id: ID del document donde comentar. + content: Arbol ProseMirror del comentario. Ejemplo minimo: + {"type":"doc","content":[{"type":"paragraph","content":[ + {"type":"text","text":"mi comentario"}]}]} + child_target_id: UUID del bloque del document al que se ancla el + comentario (matchea `attrs._id` de un parrafo). None = a nivel doc. + parent_comment_id: id del comentario al que se responde. None = top-level. + + Returns: + Comentario creado. + """ + body: dict = { + "target_type": "document", + "target_id": document_id, + "content": content, + } + if child_target_id is not None: + body["child_target_id"] = child_target_id + if parent_comment_id is not None: + body["parent_comment_id"] = parent_comment_id + return client.request("POST", "/api/comment", json=body) + + +def metabase_resolve_document_comment(client: MetabaseClient, comment_id: int) -> dict: + """Marca un comentario como resuelto (is_resolved=True). + + Endpoint: PUT /api/comment/:id. + """ + return client.request("PUT", f"/api/comment/{comment_id}", json={"is_resolved": True}) + + +def metabase_delete_document(client: MetabaseClient, document_id: int) -> None: + """Elimina un document. REQUIERE archivarlo primero. + + Endpoint: DELETE /api/document/:id. Si el document no esta archivado, + Metabase responde: "Document must be archived before it can be deleted." + + Args: + client: Cliente autenticado. + document_id: ID del document a eliminar. + """ + client.request("DELETE", f"/api/document/{document_id}") + + +def metabase_move_document( + client: MetabaseClient, + document_id: int, + collection_id: int | None, +) -> dict: + """Mueve un document a otra coleccion. + + Thin wrapper sobre metabase_update_document: envia solo collection_id. + Endpoint: PUT /api/document/:id con {collection_id: ...}. + + Args: + client: Cliente autenticado. + document_id: ID del document a mover. + collection_id: ID de coleccion destino. None mueve a la raiz ("Our analytics"). + + Returns: + Document actualizado con el nuevo collection_id. + + Example: + >>> doc = metabase_move_document(client, 42, collection_id=7) + >>> print(doc["collection_id"]) # 7 + >>> # Mover a raiz: + >>> doc = metabase_move_document(client, 42, collection_id=None) + """ + return client.request( + "PUT", + f"/api/document/{document_id}", + json={"collection_id": collection_id}, + ) + + +def metabase_copy_document( + client: MetabaseClient, + document_id: int, + name: str | None = None, + collection_id: int | None = None, +) -> dict: + """Copia un document (Metabase no tiene endpoint nativo). + + 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. + + Args: + client: Cliente autenticado. + document_id: ID del document a copiar. + name: Nombre del nuevo document. None = "{original} (copia)". + 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) + """ + 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) diff --git a/python/functions/metabase/maintenance.py b/python/functions/metabase/maintenance.py new file mode 100644 index 00000000..b2952477 --- /dev/null +++ b/python/functions/metabase/maintenance.py @@ -0,0 +1,419 @@ +"""Mantenimiento y reparacion de cards MBQL de Metabase.""" + +import copy +import time +import uuid + +from .client import MetabaseClient + + +# --------------------------------------------------------------------------- +# Helpers internos compartidos +# --------------------------------------------------------------------------- + + +def _new_uuid() -> str: + return str(uuid.uuid4()) + + +def _field_name_of(node) -> tuple[str | None, str | None]: + """Extrae (name, kind) de un node ['field'|'expression', meta, 'name'].""" + if isinstance(node, list) and len(node) >= 3 and node[0] in ("field", "expression"): + nm = node[-1] + if isinstance(nm, str): + return nm, node[0] + return None, None + + +# --------------------------------------------------------------------------- +# metabase_fix_null_ratio +# --------------------------------------------------------------------------- + + +def _analyze_stage_null_ratio(stage: dict) -> tuple[dict, dict]: + """Detecta slots vulnerables al patron SUM(a-b)/SUM(b) en una stage MBQL. + + Devuelve: + vulnerable: {slot_diff: (slot_a, slot_b, expr_name)} + subtractions: {expr_name: (op_a_name, op_b_name)} + """ + subtractions = {} + for e in stage.get("expressions", []) or []: + if not (isinstance(e, list) and len(e) == 4 and e[0] == "-"): + continue + meta = e[1] if isinstance(e[1], dict) else {} + name = meta.get("lib/expression-name") + if not name: + continue + a_name, a_kind = _field_name_of(e[2]) + b_name, b_kind = _field_name_of(e[3]) + if a_name and b_name and a_kind == "field" and b_kind == "field": + subtractions[name] = (a_name, b_name) + + aggs = stage.get("aggregation", []) or [] + func_counts: dict[str, int] = {} + sum_field_to_slot: dict[str, str] = {} + sum_expr_to_slot: dict[str, str] = {} + for agg in aggs: + if not isinstance(agg, list) or not agg: + continue + func = agg[0] + func_counts[func] = func_counts.get(func, 0) + 1 + slot = func if func_counts[func] == 1 else f"{func}_{func_counts[func]}" + if func == "sum" and len(agg) >= 3: + operand = agg[2] + nm, kind = _field_name_of(operand) + if kind == "field" and nm: + sum_field_to_slot[nm] = slot + elif kind == "expression" and nm: + sum_expr_to_slot[nm] = slot + + vulnerable = {} + for expr_name, slot_diff in sum_expr_to_slot.items(): + if expr_name not in subtractions: + continue + op_a, op_b = subtractions[expr_name] + slot_a = sum_field_to_slot.get(op_a) + slot_b = sum_field_to_slot.get(op_b) + if slot_a and slot_b: + vulnerable[slot_diff] = (slot_a, slot_b, expr_name) + return vulnerable, subtractions + + +def _rewrite_field_refs(node, slot_map: dict): + """Reemplaza recursivamente [field, meta, slot] donde slot in slot_map + por [-, new_meta, [field, ..., slot_a], [field, ..., slot_b]].""" + if isinstance(node, list): + if ( + len(node) >= 3 + and node[0] == "field" + and isinstance(node[-1], str) + and node[-1] in slot_map + ): + slot_a, slot_b, _ = slot_map[node[-1]] + base_type = ( + node[1].get("base-type", "type/Decimal") + if isinstance(node[1], dict) + else "type/Decimal" + ) + return [ + "-", + {"lib/uuid": _new_uuid()}, + ["field", {"base-type": base_type, "lib/uuid": _new_uuid()}, slot_a], + ["field", {"base-type": base_type, "lib/uuid": _new_uuid()}, slot_b], + ] + return [_rewrite_field_refs(x, slot_map) for x in node] + return node + + +def _fix_card_query(dq: dict) -> list[str]: + """Aplica el fix in-place al dataset_query. Devuelve lista de cambios.""" + stages = dq.get("stages", []) + changes = [] + for si, stage in enumerate(stages): + vulnerable, subtractions = _analyze_stage_null_ratio(stage) + if not vulnerable: + continue + + preagg_names = set(subtractions.keys()) + new_exprs = [] + for e in stage.get("expressions", []) or []: + if ( + isinstance(e, list) + and len(e) == 4 + and e[0] == "-" + and isinstance(e[1], dict) + and e[1].get("lib/expression-name") in preagg_names + ): + new_exprs.append(e) + else: + new_exprs.append(_rewrite_field_refs(e, vulnerable)) + stage["expressions"] = new_exprs + + for sj in range(si + 1, len(stages)): + for key in ("expressions", "aggregation", "breakout", "filters", "order-by", "fields"): + if key in stages[sj]: + stages[sj][key] = [ + _rewrite_field_refs(x, vulnerable) for x in stages[sj][key] + ] + + for slot, (sa, sb, ename) in vulnerable.items(): + changes.append(f"stage[{si}] {slot}=sum({ename!r}) -> ({sa} - {sb})") + return changes + + +def metabase_fix_null_ratio( + client: MetabaseClient, + *, + dry_run: bool = True, + card_ids: list[int] | None = None, +) -> dict: + """Detecta y repara el patron SUM(a-b)/SUM(b) en cards MBQL de Metabase. + + El patron vulnerable ocurre cuando una agregacion computa SUM(expr_resta) + donde expr_resta es una resta pre-agg de dos campos. Si alguna fila tiene + NULL, SUM(A-B) != SUM(A) - SUM(B). El fix reescribe las referencias + post-agg al slot diferencia para usar (SUM(A) - SUM(B)) en su lugar. + + Solo procesa cards MBQL activas (type='query', no archivadas). Las cards + SQL nativas o modelos se omiten silenciosamente. + + Args: + client: Cliente Metabase autenticado. + dry_run: Si True (default), escanea y reporta sin modificar nada. + Si False, aplica el fix via PUT /api/card/:id. + card_ids: Lista de IDs a procesar. None = todas las cards MBQL activas. + + Returns: + dict con campos: + scanned (int): cards MBQL evaluadas. + affected (int): cards donde se detecto el patron vulnerable. + fixed (int): cards efectivamente actualizadas (0 si dry_run=True). + errors (list[dict]): lista de {card_id, error} para fallos en PUT. + + Example: + >>> from metabase import MetabaseClient, metabase_fix_null_ratio + >>> c = MetabaseClient("https://metabase.example.com", "mb_apikey") + >>> report = metabase_fix_null_ratio(c, dry_run=True) + >>> print(report) + {'scanned': 312, 'affected': 4, 'fixed': 0, 'errors': []} + >>> # Para aplicar: + >>> report = metabase_fix_null_ratio(c, dry_run=False) + """ + all_cards = client.request("GET", "/api/card") + + if card_ids is not None: + card_id_set = set(card_ids) + all_cards = [c for c in all_cards if c.get("id") in card_id_set] + + mbql_cards = [ + c for c in all_cards + if not c.get("archived", False) + and isinstance(c.get("dataset_query"), dict) + and c["dataset_query"].get("type") == "query" + and isinstance(c["dataset_query"].get("query", {}).get("stages") if "query" in c["dataset_query"] else c["dataset_query"].get("stages"), list) + ] + + # Metabase MBQL puede tener stages en dataset_query.query.stages (legacy) + # o en dataset_query.stages (v2). Normalizar: + def _get_stages(dq: dict) -> list | None: + if isinstance(dq.get("stages"), list): + return dq["stages"] + q = dq.get("query", {}) + if isinstance(q.get("stages"), list): + return q["stages"] + return None + + affected_cards = [] + scanned = 0 + for card in all_cards: + if card_ids is not None and card.get("id") not in set(card_ids): + continue + if card.get("archived", False): + continue + dq = card.get("dataset_query") + if not isinstance(dq, dict): + continue + stages = _get_stages(dq) + if stages is None: + continue + scanned += 1 + dq_copy = copy.deepcopy(dq) + # Operar sobre el stages del objeto correcto + target = dq_copy if isinstance(dq_copy.get("stages"), list) else dq_copy.get("query", {}) + changes = _fix_card_query(target) + if changes: + affected_cards.append((card, dq_copy, changes)) + + fixed = 0 + errors: list[dict] = [] + + if not dry_run: + for card, dq_fixed, _changes in affected_cards: + try: + client.request("PUT", f"/api/card/{card['id']}", json={"dataset_query": dq_fixed}) + fixed += 1 + except Exception as exc: + errors.append({"card_id": card["id"], "error": str(exc)[:200]}) + time.sleep(0.05) + + return { + "scanned": scanned, + "affected": len(affected_cards), + "fixed": fixed, + "errors": errors, + } + + +# --------------------------------------------------------------------------- +# metabase_pair_n_n1_columns +# --------------------------------------------------------------------------- + + +def _slot_for_sum_field(stage: dict, target_field_name: str) -> str | None: + """Devuelve el slot MBQL (ej: 'sum', 'sum_4') de sum(target_field) en la stage.""" + aggs = stage.get("aggregation", []) or [] + func_counts: dict[str, int] = {} + for agg in aggs: + if not isinstance(agg, list) or not agg: + continue + func = agg[0] + func_counts[func] = func_counts.get(func, 0) + 1 + slot = func if func_counts[func] == 1 else f"{func}_{func_counts[func]}" + if func == "sum" and len(agg) >= 3: + nm, kind = _field_name_of(agg[2]) + if kind == "field" and nm == target_field_name: + return slot + return None + + +def _find_paired_slots(dq: dict, base_name: str) -> tuple[str | None, str | None]: + """Busca (slot_base, slot_n1) para sum(base_name) y sum(base_name_1) en el MBQL.""" + for stage in (dq.get("stages") or []): + if not stage.get("aggregation"): + continue + slot_n = _slot_for_sum_field(stage, base_name) + slot_n1 = _slot_for_sum_field(stage, f"{base_name}_1") + if slot_n and slot_n1: + return slot_n, slot_n1 + return None, None + + +def _reorder_table_columns( + cols: list[dict], + slot_n: str, + slot_n1: str, +) -> tuple[list[dict], bool, str]: + """Habilita slot_n1 y lo reubica inmediatamente despues de slot_n. + + Returns: + (new_cols, changed, reason) + """ + cols = [dict(c) for c in cols] + idx_n = next((i for i, c in enumerate(cols) if c.get("name") == slot_n), -1) + if idx_n < 0: + return cols, False, "slot_n no presente en table.columns" + + idx_n1 = next((i for i, c in enumerate(cols) if c.get("name") == slot_n1), -1) + + # Ya en posicion correcta y habilitada: no hay cambio + if idx_n1 == idx_n + 1 and cols[idx_n1].get("enabled") is True: + return cols, False, "ya en la posicion correcta y habilitado" + + if idx_n1 < 0: + entry: dict = {"name": slot_n1, "enabled": True} + else: + entry = cols.pop(idx_n1) + entry["enabled"] = True + if idx_n1 < idx_n: + idx_n -= 1 + + insert_at = idx_n + 1 + cols.insert(insert_at, entry) + return cols, True, "reubicado y habilitado" + + +def metabase_pair_n_n1_columns( + client: MetabaseClient, + *, + dry_run: bool = True, + card_ids: list[int] | None = None, + base_field: str = "Valor_vendido", +) -> dict: + """Habilita y posiciona la columna _1 junto a su par en cards tabla/pivot de Metabase. + + Para cards con display 'table' o 'pivot' que contienen agregaciones + SUM(base_field) y SUM(base_field_1), busca la columna base_field_1 en + visualization_settings.table.columns, la habilita (enabled=True) y la + reubica inmediatamente despues de base_field para comparacion visual. + + Solo procesa cards con display 'table' o 'pivot' que tengan ambos slots + y tengan table.columns definido en visualization_settings. + + Args: + client: Cliente Metabase autenticado. + dry_run: Si True (default), escanea y reporta sin modificar nada. + Si False, aplica el cambio via PUT /api/card/:id. + card_ids: Lista de IDs a procesar. None = todas las cards activas. + base_field: Nombre del campo base MBQL (sin sufijo _1). Por defecto + 'Valor_vendido'. La funcion buscara sum(base_field) y + sum(base_field_1) en las agregaciones. + + Returns: + dict con campos: + scanned (int): cards con display table/pivot evaluadas. + affected (int): cards donde se encontro el par y habia que mover. + fixed (int): cards efectivamente actualizadas (0 si dry_run=True). + skipped (int): cards ya correctas o sin table.columns. + errors (list[dict]): lista de {card_id, error} para fallos en PUT. + + Example: + >>> from metabase import MetabaseClient, metabase_pair_n_n1_columns + >>> c = MetabaseClient("https://metabase.example.com", "mb_apikey") + >>> report = metabase_pair_n_n1_columns(c, dry_run=True) + >>> print(report) + {'scanned': 45, 'affected': 3, 'fixed': 0, 'skipped': 42, 'errors': []} + >>> # Con campo personalizado: + >>> report = metabase_pair_n_n1_columns(c, dry_run=False, base_field="Importe") + """ + all_cards = client.request("GET", "/api/card") + + tabular_displays = {"table", "pivot"} + scanned = 0 + skipped = 0 + to_update: list[tuple[dict, list[dict], str, str]] = [] + + for card in all_cards: + if card_ids is not None and card.get("id") not in set(card_ids): + continue + if card.get("archived", False): + continue + if card.get("display") not in tabular_displays: + continue + dq = card.get("dataset_query") + if not isinstance(dq, dict): + continue + + slot_n, slot_n1 = _find_paired_slots(dq, base_field) + if not (slot_n and slot_n1): + continue + + scanned += 1 + vs = card.get("visualization_settings") or {} + cols = vs.get("table.columns") + if not isinstance(cols, list): + skipped += 1 + continue + + new_cols, changed, _reason = _reorder_table_columns(cols, slot_n, slot_n1) + if not changed: + skipped += 1 + continue + + to_update.append((card, new_cols, slot_n, slot_n1)) + + fixed = 0 + errors: list[dict] = [] + + if not dry_run: + for card, new_cols, _slot_n, _slot_n1 in to_update: + new_vs = copy.deepcopy(card.get("visualization_settings") or {}) + new_vs["table.columns"] = new_cols + try: + client.request( + "PUT", + f"/api/card/{card['id']}", + json={"visualization_settings": new_vs}, + ) + fixed += 1 + except Exception as exc: + errors.append({"card_id": card["id"], "error": str(exc)[:200]}) + time.sleep(0.05) + + return { + "scanned": scanned, + "affected": len(to_update), + "fixed": fixed, + "skipped": skipped, + "errors": errors, + } diff --git a/python/functions/metabase/metabase_add_membership.md b/python/functions/metabase/metabase_add_membership.md new file mode 100644 index 00000000..45c572da --- /dev/null +++ b/python/functions/metabase/metabase_add_membership.md @@ -0,0 +1,46 @@ +--- +name: metabase_add_membership +kind: function +lang: py +domain: infra +version: "1.0.0" +purity: impure +signature: "def metabase_add_membership(client: MetabaseClient, user_id: int, group_id: int, is_group_manager: bool = False) -> list[dict]" +description: "Añade un usuario a un Permission Group de Metabase. Endpoint: POST /api/permissions/membership. Retorna la lista completa de membresías del grupo tras la operación." +tags: [metabase, permissions, membership, groups, users, api, python] +uses_functions: [] +uses_types: [] +returns: [] +returns_optional: false +error_type: "error_go_core" +imports: [] +params: + - name: client + desc: "instancia autenticada de MetabaseClient con permisos de superusuario" + - name: user_id + desc: "ID numérico del usuario a añadir al grupo" + - name: group_id + desc: "ID numérico del grupo destino" + - name: is_group_manager + desc: "si True, el usuario obtiene rol de manager del grupo (puede gestionar sus miembros)" +output: "list[dict]: lista de todas las membresías actuales del grupo tras la operación. Cada elemento contiene membership_id, user_id, group_id, is_group_manager" +tested: false +tests: [] +test_file_path: "" +file_path: "python/functions/metabase/permissions.py" +--- + +## Ejemplo + +```python +# Añadir usuario como miembro regular +members = metabase_add_membership(client, user_id=5, group_id=3) +print(len(members), "miembros en el grupo") + +# Añadir como manager del grupo +members = metabase_add_membership(client, user_id=5, group_id=3, is_group_manager=True) +``` + +## Notas + +Metabase responde con la lista completa de membresías del grupo (no solo la nueva). Lanza HTTPStatusError 400 si el usuario ya es miembro del grupo. El `membership_id` de la entrada creada está en el elemento nuevo de la lista retornada. diff --git a/python/functions/metabase/metabase_archive_document.md b/python/functions/metabase/metabase_archive_document.md new file mode 100644 index 00000000..7fe65be1 --- /dev/null +++ b/python/functions/metabase/metabase_archive_document.md @@ -0,0 +1,34 @@ +--- +name: metabase_archive_document +kind: function +lang: py +domain: infra +version: "1.0.0" +purity: impure +signature: "def metabase_archive_document(client: MetabaseClient, document_id: int) -> dict" +description: "Archiva un document (PUT archived=True). Prerequisito para poder eliminarlo." +tags: [metabase, document, archive, 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: document_id + desc: "ID del document a archivar" +output: "dict: document con archived=True" +tested: false +tests: [] +test_file_path: "" +file_path: "python/functions/metabase/documents.py" +--- + +## Ejemplo + +```python +metabase_archive_document(client, 1) +metabase_delete_document(client, 1) # ahora si puede eliminarse +``` diff --git a/python/functions/metabase/metabase_copy_card.md b/python/functions/metabase/metabase_copy_card.md new file mode 100644 index 00000000..93856c53 --- /dev/null +++ b/python/functions/metabase/metabase_copy_card.md @@ -0,0 +1,51 @@ +--- +name: metabase_copy_card +kind: function +lang: py +domain: infra +version: "1.0.0" +purity: impure +signature: "def metabase_copy_card(client: MetabaseClient, card_id: int, name: str | None = None, collection_id: int | None = None, description: str | None = None) -> dict" +description: "Crea una copia de una card/pregunta en Metabase via el endpoint nativo POST /api/card/:id/copy. Permite sobrescribir nombre, coleccion y descripcion en la copia." +tags: [metabase, card, question, copy, duplicate, collection, 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 a copiar" + - name: name + desc: "nombre para la copia; None = Metabase asigna 'Copy of '" + - name: collection_id + desc: "ID de la coleccion destino; None = misma coleccion que el original" + - name: description + desc: "descripcion de la copia; None = hereda la del original" +output: "dict: objeto card nueva creada por Metabase, con el id asignado y todos los campos heredados del original" +tested: false +tests: [] +test_file_path: "" +file_path: "python/functions/metabase/cards.py" +--- + +## Ejemplo + +```python +# Copia simple (nombre automatico) +copy = metabase_copy_card(client, 42) +print(copy["id"], copy["name"]) # "Copy of ..." + +# Copia a otra coleccion con nombre propio +copy = metabase_copy_card(client, 42, name="Revenue Q2", collection_id=7) +print(copy["collection_id"]) # 7 +``` + +## Notas + +Usa el endpoint nativo de Metabase que copia dataset_query, display y +visualization_settings. Los campos opcionales del body se omiten si son None +para que Metabase aplique sus defaults (herencia del original). diff --git a/python/functions/metabase/metabase_copy_dashboard.md b/python/functions/metabase/metabase_copy_dashboard.md new file mode 100644 index 00000000..75316479 --- /dev/null +++ b/python/functions/metabase/metabase_copy_dashboard.md @@ -0,0 +1,54 @@ +--- +name: metabase_copy_dashboard +kind: function +lang: py +domain: infra +version: "1.0.0" +purity: impure +signature: "def metabase_copy_dashboard(client: MetabaseClient, dashboard_id: int, name: str | None = None, collection_id: int | None = None, description: str | None = None, is_deep_copy: bool = False) -> dict" +description: "Crea una copia de un dashboard en Metabase via POST /api/dashboard/:id/copy. Con is_deep_copy=True tambien clona las cards referenciadas." +tags: [metabase, dashboard, copy, duplicate, collection, deep_copy, 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: dashboard_id + desc: "ID del dashboard a copiar" + - name: name + desc: "nombre para la copia; None = Metabase asigna 'Copy of '" + - name: collection_id + desc: "ID de la coleccion destino; None = misma coleccion que el original" + - name: description + desc: "descripcion de la copia; None = hereda la del original" + - name: is_deep_copy + desc: "si True, clona tambien todas las cards referenciadas (deep copy); si False, la copia referencia las cards originales" +output: "dict: objeto dashboard nuevo creado por Metabase, con id asignado y layout de dashcards copiado" +tested: false +tests: [] +test_file_path: "" +file_path: "python/functions/metabase/dashboards.py" +--- + +## Ejemplo + +```python +# Copia simple (referencia cards originales) +copy = metabase_copy_dashboard(client, 1) +print(copy["id"], copy["name"]) # "Copy of ..." + +# Deep copy a otra coleccion con nombre propio +copy = metabase_copy_dashboard(client, 1, name="Sales Q2", collection_id=7, is_deep_copy=True) +print(copy["collection_id"]) # 7 +``` + +## Notas + +`is_deep_copy=True` hace que Metabase clone tambien las cards internas del +dashboard, util para crear instancias completamente independientes. Con +`is_deep_copy=False` (default), las dashcards del clon apuntan a las mismas +cards que el original — cambios en esas cards afectan ambos dashboards. diff --git a/python/functions/metabase/metabase_copy_document.md b/python/functions/metabase/metabase_copy_document.md new file mode 100644 index 00000000..67b5efe3 --- /dev/null +++ b/python/functions/metabase/metabase_copy_document.md @@ -0,0 +1,52 @@ +--- +name: metabase_copy_document +kind: function +lang: py +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] +uses_types: [] +returns: [] +returns_optional: false +error_type: "error_go_core" +imports: [httpx] +params: + - name: client + desc: "instancia autenticada de MetabaseClient" + - name: document_id + desc: "ID del document original a copiar" + - name: name + desc: "nombre del nuevo document; None usa '{original} (copia)'" + - 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" +tested: false +tests: [] +test_file_path: "" +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 +copy = metabase_copy_document(client, 42, name="Backup Q1", collection_id=5) +``` + +## 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. + +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. diff --git a/python/functions/metabase/metabase_create_card_raw.md b/python/functions/metabase/metabase_create_card_raw.md new file mode 100644 index 00000000..edd7b1d4 --- /dev/null +++ b/python/functions/metabase/metabase_create_card_raw.md @@ -0,0 +1,62 @@ +--- +name: metabase_create_card_raw +kind: function +lang: py +domain: infra +version: "1.0.0" +purity: impure +signature: "def metabase_create_card_raw(client: MetabaseClient, payload: dict) -> dict" +description: "Crea una card en Metabase enviando el payload completo sin modificaciones. Version raw para flujos Metabase-as-code donde el caller construye el body entero. Endpoint: POST /api/card." +tags: [metabase, card, question, create, api, python, raw, as-code] +uses_functions: [metabase_auth_py_infra] +uses_types: [] +returns: [] +returns_optional: false +error_type: "error_go_core" +imports: [httpx] +params: + - name: client + desc: "instancia autenticada de MetabaseClient con sesion activa" + - name: payload + desc: "dict con el payload completo de la card tal como lo espera la API de Metabase; campos minimos: name, dataset_query, display; campos opcionales preservados: visualization_settings, parameters, parameter_mappings, type, collection_id, description, archived, enable_embedding, embedding_params" +output: "dict: objeto card recien creado con id asignado por Metabase y todos los campos normalizados (display, dataset_query, visualization_settings, created_at, etc.)" +tested: false +tests: [] +test_file_path: "" +file_path: "python/functions/metabase/cards.py" +--- + +## Ejemplo + +```python +card = metabase_create_card_raw(client, { + "name": "Revenue by Month", + "dataset_query": { + "database": 1, + "type": "native", + "native": {"query": "SELECT date_trunc('month', created_at), SUM(total) FROM orders GROUP BY 1"}, + }, + "display": "line", + "visualization_settings": { + "graph.x_axis.title_text": "Month", + "graph.y_axis.title_text": "Revenue", + }, + "description": "Monthly revenue trend", + "collection_id": 5, +}) +print(card["id"]) # ID asignado por Metabase +``` + +## Notas + +No se escriben tests automaticos porque requiere una instancia real de Metabase. +Los intentos de mockear `client.request` quedan fuera del alcance del registry +por ahora — la validacion se hace en integracion contra un entorno Metabase real. + +A diferencia de `metabase_create_card`, esta funcion no descarta ni transforma +ningun campo del payload: lo envia tal cual al endpoint. Esto permite pasar +`visualization_settings`, `parameters`, `embedding_params`, `type`, etc. sin +que la funcion los filtre. + +Si Metabase devuelve 4xx/5xx, httpx lanza `HTTPStatusError` sin capturar. +El caller debe manejar errores si necesita recuperacion. diff --git a/python/functions/metabase/metabase_create_dashboard_raw.md b/python/functions/metabase/metabase_create_dashboard_raw.md new file mode 100644 index 00000000..c739a093 --- /dev/null +++ b/python/functions/metabase/metabase_create_dashboard_raw.md @@ -0,0 +1,77 @@ +--- +name: metabase_create_dashboard_raw +kind: function +lang: py +domain: infra +version: "1.0.0" +purity: impure +signature: "def metabase_create_dashboard_raw(client: MetabaseClient, payload: dict) -> dict" +description: "Crea un dashboard en Metabase enviando el payload completo sin modificaciones. Maneja automaticamente la limitacion de la API que no acepta dashcards en el POST inicial: si el payload contiene dashcards, hace POST para crear el dashboard y luego PUT para añadir las cards. Endpoint: POST /api/dashboard (+ PUT condicional)." +tags: [metabase, dashboard, create, api, python, raw, as-code, dashcards] +uses_functions: [metabase_auth_py_infra] +uses_types: [] +returns: [] +returns_optional: false +error_type: "error_go_core" +imports: [httpx] +params: + - name: client + desc: "instancia autenticada de MetabaseClient con sesion activa" + - name: payload + desc: "dict con el payload completo del dashboard tal como lo espera la API de Metabase; campos soportados: name (requerido), description, collection_id, parameters, tabs, enable_embedding, embedding_params; dashcards (list[dict]): si presente, se extrae del body POST y se añade en un PUT posterior con id negativo para cards nuevas" +output: "dict: objeto dashboard creado; si habia dashcards en el payload, retorna la respuesta del PUT final con el campo dashcards poblado; si no habia dashcards, retorna la respuesta del POST inicial" +tested: false +tests: [] +test_file_path: "" +file_path: "python/functions/metabase/dashboards.py" +--- + +## Ejemplo + +```python +# Sin dashcards (solo POST) +dash = metabase_create_dashboard_raw(client, { + "name": "Sales Overview", + "description": "KPIs de ventas mensuales", + "collection_id": 5, + "parameters": [], +}) +print(dash["id"]) + +# Con dashcards (POST + PUT automatico) +dash = metabase_create_dashboard_raw(client, { + "name": "Sales Overview", + "description": "KPIs de ventas mensuales", + "collection_id": 5, + "parameters": [], + "dashcards": [ + { + "id": -1, + "card_id": 42, + "size_x": 6, + "size_y": 4, + "col": 0, + "row": 0, + "visualization_settings": {}, + "parameter_mappings": [], + }, + ], +}) +print(dash["id"]) +print(len(dash["dashcards"])) # 1 +``` + +## Notas + +No se escriben tests automaticos porque requiere una instancia real de Metabase. +Los intentos de mockear `client.request` quedan fuera del alcance del registry +por ahora — la validacion se hace en integracion contra un entorno Metabase real. + +La API de Metabase (al menos hasta v0.49) ignora el campo `dashcards` en el +POST inicial de creacion de dashboard. Esta funcion absorbe esa limitacion +internamente: extrae las dashcards del payload antes del POST y las envia en +un PUT separado usando el id que Metabase asigno al nuevo dashboard. + +Si Metabase devuelve 4xx/5xx en cualquier paso, httpx lanza `HTTPStatusError` +sin capturar. Si el POST tiene exito pero el PUT falla, el dashboard queda +creado pero vacio — el caller debe manejar este caso si necesita atomicidad. diff --git a/python/functions/metabase/metabase_create_document.md b/python/functions/metabase/metabase_create_document.md new file mode 100644 index 00000000..d61d0cc7 --- /dev/null +++ b/python/functions/metabase/metabase_create_document.md @@ -0,0 +1,49 @@ +--- +name: metabase_create_document +kind: function +lang: py +domain: infra +version: "1.0.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." +tags: [metabase, document, create, api, prosemirror, 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: "titulo del document (1-254 caracteres, no blank)" + - name: document + desc: "arbol ProseMirror JSON: {type: 'doc', content: [...]}, o '' para arrancar vacio" + - name: collection_id + desc: "ID de coleccion destino (0 = root)" +output: "dict: document recien creado con id, entity_id y metadata" +tested: false +tests: [] +test_file_path: "" +file_path: "python/functions/metabase/documents.py" +--- + +## Ejemplo + +```python +doc = metabase_create_document(client, "Notas", { + "type": "doc", + "content": [ + {"type": "paragraph", "content": [{"type": "text", "text": "Hola"}]} + ] +}) +print(doc["id"]) +``` + +## 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. diff --git a/python/functions/metabase/metabase_create_document_comment.md b/python/functions/metabase/metabase_create_document_comment.md new file mode 100644 index 00000000..ba0f8ecc --- /dev/null +++ b/python/functions/metabase/metabase_create_document_comment.md @@ -0,0 +1,59 @@ +--- +name: metabase_create_document_comment +kind: function +lang: py +domain: infra +version: "1.0.0" +purity: impure +signature: "def metabase_create_document_comment(client: MetabaseClient, document_id: int, content: dict, *, child_target_id: str | None = None, parent_comment_id: int | None = None) -> dict" +description: "Crea un comentario en un document. Soporta anclaje a bloque concreto (via UUID de _id) y respuestas en thread (via parent_comment_id). Endpoint: POST /api/comment." +tags: [metabase, document, comments, 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: document_id + desc: "ID del document donde crear el comentario" + - name: content + desc: "arbol ProseMirror del comentario: {type: doc, content: [...]}" + - name: child_target_id + desc: "UUID de bloque al que se ancla el comentario (matchea attrs._id de un parrafo). None = comentario a nivel doc" + - name: parent_comment_id + desc: "ID del comentario al que se responde. None = comentario top-level" +output: "dict: comentario creado con id, created_at, creator, reactions=[], is_resolved=False" +tested: false +tests: [] +test_file_path: "" +file_path: "python/functions/metabase/documents.py" +--- + +## Ejemplo + +```python +# Comentario top-level +metabase_create_document_comment(client, 29, { + "type": "doc", + "content": [{"type": "paragraph", "content": [ + {"type": "text", "text": "Deberiamos anadir un paso para configurar Slack"} + ]}] +}) + +# Respuesta en thread +metabase_create_document_comment(client, 29, content=reply_tree, + parent_comment_id=1) + +# Anclado a un bloque concreto del documento +metabase_create_document_comment(client, 29, content=tree, + child_target_id="48f9a7a4-79a0-a282-03a1-ffe2f76b9106") +``` + +## Notas + +`target_type` se fija internamente a `"document"` (unico valor aceptado por la API en v0.59). + +El `content` sigue el mismo schema ProseMirror que los documents (ver whitelist en `metabase_validate_document_payload`). diff --git a/python/functions/metabase/metabase_create_group.md b/python/functions/metabase/metabase_create_group.md new file mode 100644 index 00000000..bba21dfe --- /dev/null +++ b/python/functions/metabase/metabase_create_group.md @@ -0,0 +1,39 @@ +--- +name: metabase_create_group +kind: function +lang: py +domain: infra +version: "1.0.0" +purity: impure +signature: "def metabase_create_group(client: MetabaseClient, name: str) -> dict" +description: "Crea un nuevo Permission Group en Metabase. El nombre debe ser unico. Retorna el grupo creado con su id asignado. Endpoint: POST /api/permissions/group." +tags: [metabase, permissions, group, 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 con permisos de superusuario" + - name: name + desc: "nombre del grupo, debe ser unico en la instancia Metabase" +output: "dict: grupo creado con id y name" +tested: false +tests: [] +test_file_path: "" +file_path: "python/functions/metabase/permissions.py" +--- + +## Ejemplo + +```python +group = metabase_create_group(client, "Analytics Team") +print(group["id"], group["name"]) +``` + +## Notas + +Error 400 si ya existe un grupo con ese nombre. +Para asignar usuarios al grupo recien creado usar la API de memberships (POST /api/permissions/membership). diff --git a/python/functions/metabase/metabase_delete_document.md b/python/functions/metabase/metabase_delete_document.md new file mode 100644 index 00000000..00d42c18 --- /dev/null +++ b/python/functions/metabase/metabase_delete_document.md @@ -0,0 +1,38 @@ +--- +name: metabase_delete_document +kind: function +lang: py +domain: infra +version: "1.0.0" +purity: impure +signature: "def metabase_delete_document(client: MetabaseClient, document_id: int) -> None" +description: "Elimina un document. Requiere archivado previo (Metabase rechaza DELETE si archived=False)." +tags: [metabase, document, delete, 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: document_id + desc: "ID del document a eliminar" +output: "None" +tested: false +tests: [] +test_file_path: "" +file_path: "python/functions/metabase/documents.py" +--- + +## Ejemplo + +```python +metabase_archive_document(client, 1) +metabase_delete_document(client, 1) +``` + +## Notas + +Si se llama sin archivar antes, Metabase responde: `"Document must be archived before it can be deleted."` diff --git a/python/functions/metabase/metabase_delete_group.md b/python/functions/metabase/metabase_delete_group.md new file mode 100644 index 00000000..219c35fe --- /dev/null +++ b/python/functions/metabase/metabase_delete_group.md @@ -0,0 +1,47 @@ +--- +name: metabase_delete_group +kind: function +lang: py +domain: infra +version: "1.0.0" +purity: impure +signature: "def metabase_delete_group(client: MetabaseClient, group_id: int) -> None" +description: "Elimina permanentemente un Permission Group de Metabase. IRREVERSIBLE. No borra los usuarios, solo el grupo. Endpoint: DELETE /api/permissions/group/:id." +tags: [metabase, permissions, group, 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 con permisos de superusuario" + - name: group_id + desc: "ID numerico del grupo a eliminar. ADVERTENCIA: no pasar id=1 (All Users) ni id=2 (Administrators)" +output: "None: retorna None en caso de exito (204 No Content)" +tested: false +tests: [] +test_file_path: "" +file_path: "python/functions/metabase/permissions.py" +--- + +## Ejemplo + +```python +metabase_delete_group(client, 5) +# CUIDADO: no pasar group_id=1 (All Users) ni group_id=2 (Administrators) +``` + +## Notas + +**OPERACION IRREVERSIBLE.** El grupo se elimina permanentemente sin posibilidad de recuperacion. + +Grupos especiales del sistema que NO deben borrarse: +- `id=1`: "All Users" — todos los usuarios de Metabase pertenecen automaticamente a este grupo. +- `id=2`: "Administrators" — grupo de administradores del sistema. + +Esta funcion NO valida ni bloquea el borrado de esos IDs. Es responsabilidad del caller verificar que no se pasen IDs protegidos antes de invocar esta funcion. + +Al borrar un grupo, los usuarios que pertenecian a el no se eliminan, solo pierden la membresia. +Error 404 si el grupo no existe. diff --git a/python/functions/metabase/metabase_delete_membership.md b/python/functions/metabase/metabase_delete_membership.md new file mode 100644 index 00000000..fc8069a7 --- /dev/null +++ b/python/functions/metabase/metabase_delete_membership.md @@ -0,0 +1,41 @@ +--- +name: metabase_delete_membership +kind: function +lang: py +domain: infra +version: "1.0.0" +purity: impure +signature: "def metabase_delete_membership(client: MetabaseClient, membership_id: int) -> None" +description: "Elimina una membresía de grupo en Metabase por su membership_id. Endpoint: DELETE /api/permissions/membership/:id. NO acepta user_id+group_id — requiere el membership_id exacto." +tags: [metabase, permissions, membership, groups, users, 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 con permisos de superusuario" + - name: membership_id + desc: "ID numérico de la membresía a eliminar. Obtenerse vía metabase_list_memberships — no es el user_id ni el group_id" +output: "None: sin valor de retorno. Lanza HTTPStatusError 404 si la membresía no existe" +tested: false +tests: [] +test_file_path: "" +file_path: "python/functions/metabase/permissions.py" +--- + +## Ejemplo + +```python +# Primero obtener el membership_id via list_memberships +all_memberships = metabase_list_memberships(client) +group_members = all_memberships.get("3", []) +membership_id = next(m["membership_id"] for m in group_members if m["user_id"] == 5) +metabase_delete_membership(client, membership_id) +``` + +## Notas + +La API de Metabase no expone DELETE por user_id+group_id. El `membership_id` es distinto de ambos y debe obtenerse vía `metabase_list_memberships`. Esta función no bloquea el borrado de membresías en grupos del sistema (All Users, Administrators) — es responsabilidad del caller verificarlo. diff --git a/python/functions/metabase/metabase_fix_null_ratio.md b/python/functions/metabase/metabase_fix_null_ratio.md new file mode 100644 index 00000000..c2267cd8 --- /dev/null +++ b/python/functions/metabase/metabase_fix_null_ratio.md @@ -0,0 +1,61 @@ +--- +name: metabase_fix_null_ratio +kind: function +lang: py +domain: infra +version: "1.0.0" +purity: impure +signature: "def metabase_fix_null_ratio(client: MetabaseClient, *, dry_run: bool = True, card_ids: list[int] | None = None) -> dict" +description: "Detecta y repara el patron vulnerable SUM(a-b)/SUM(b) en cards MBQL de Metabase. Cuando una resta pre-agg tiene operandos con NULL, SUM(A-B)!=SUM(A)-SUM(B). El fix reescribe las referencias post-agg al slot diferencia para usar (SUM(A)-SUM(B)) en su lugar. Soporta dry_run para escanear sin modificar." +tags: [metabase, maintenance, batch, mbql, fix, null, ratio, aggregation, python] +uses_functions: [metabase_list_cards_py_infra] +uses_types: [] +returns: [] +returns_optional: false +error_type: "error_go_core" +imports: [copy, time, uuid, httpx] +params: + - name: client + desc: "instancia autenticada de MetabaseClient con acceso a GET /api/card y PUT /api/card/:id" + - name: dry_run + desc: "si True (default) solo escanea y reporta sin modificar ninguna card; si False aplica el fix via PUT" + - name: card_ids + desc: "lista de IDs especificos a procesar; None = todas las cards MBQL activas no archivadas" +output: "dict con scanned (cards MBQL evaluadas), affected (cards con el patron detectado), fixed (cards modificadas, 0 en dry_run), errors (lista de {card_id, error} para fallos en PUT)" +tested: false +tests: [] +test_file_path: "" +file_path: "python/functions/metabase/maintenance.py" +--- + +## Ejemplo + +```python +from metabase import MetabaseClient, metabase_fix_null_ratio + +c = MetabaseClient("https://metabase.example.com", "mb_apikey") + +# Escanear sin modificar +report = metabase_fix_null_ratio(c, dry_run=True) +print(report) +# {'scanned': 312, 'affected': 4, 'fixed': 0, 'errors': []} + +# Aplicar solo a cards especificas +report = metabase_fix_null_ratio(c, dry_run=False, card_ids=[101, 205, 307]) +print(report) +# {'scanned': 3, 'affected': 2, 'fixed': 2, 'errors': []} +``` + +## Notas + +El patron vulnerable ocurre en cards MBQL con esta estructura: +- expressions: `["-", meta, ["field", m, "campo_a"], ["field", m, "campo_b"]]` con nombre `Diferencia_X` +- aggregation: `["sum", meta, ["expression", m, "Diferencia_X"]]` → slot `sum_N` +- aggregation: `["sum", meta, ["field", m, "campo_a"]]` → slot `sum_A` +- aggregation: `["sum", meta, ["field", m, "campo_b"]]` → slot `sum_B` + +El fix reemplaza referencias a `sum_N` en stages posteriores por `(sum_A - sum_B)`. +Las restas pre-agg originales (definiciones en expressions[]) no se tocan. + +Solo procesa cards con `dataset_query.type == "query"` y stages MBQL. +Las cards SQL nativas se omiten silenciosamente. Rate-limit: 50ms entre PUTs. diff --git a/python/functions/metabase/metabase_get_collection_graph.md b/python/functions/metabase/metabase_get_collection_graph.md new file mode 100644 index 00000000..47b7fe06 --- /dev/null +++ b/python/functions/metabase/metabase_get_collection_graph.md @@ -0,0 +1,45 @@ +--- +name: metabase_get_collection_graph +kind: function +lang: py +domain: infra +version: "1.0.0" +purity: impure +signature: "def metabase_get_collection_graph(client: MetabaseClient, namespace: str | None = None) -> dict" +description: "Obtiene el grafo de permisos de colecciones de Metabase. Endpoint: GET /api/collection/graph. Soporta namespace opcional para snippet collections. El campo revision es crítico para concurrency control en el PUT posterior." +tags: [metabase, permissions, collections, graph, access-control, api, python] +uses_functions: [] +uses_types: [] +returns: [] +returns_optional: false +error_type: "error_go_core" +imports: [] +params: + - name: client + desc: "instancia autenticada de MetabaseClient con permisos de superusuario" + - name: namespace + desc: "namespace opcional: 'snippets' para snippet collections, None para colecciones regulares" +output: "dict: grafo con revision (int) y groups (group_id -> collection_id -> 'read' | 'write' | 'none'). El campo revision es obligatorio para el PUT." +tested: false +tests: [] +test_file_path: "" +file_path: "python/functions/metabase/permissions.py" +--- + +## Ejemplo + +```python +# Colecciones regulares +graph = metabase_get_collection_graph(client) +print("revision:", graph["revision"]) +for group_id, colls in graph["groups"].items(): + for coll_id, access in colls.items(): + print(f"group={group_id} collection={coll_id}: {access}") + +# Snippet collections +snippet_graph = metabase_get_collection_graph(client, namespace="snippets") +``` + +## Notas + +Siempre hacer GET fresco justo antes del PUT. El `revision` es el mecanismo de concurrency control nativo de Metabase — ver `metabase_update_collection_graph` para el patrón completo. Los niveles de acceso son `"read"`, `"write"` y `"none"`. diff --git a/python/functions/metabase/metabase_get_document.md b/python/functions/metabase/metabase_get_document.md new file mode 100644 index 00000000..9c47afc6 --- /dev/null +++ b/python/functions/metabase/metabase_get_document.md @@ -0,0 +1,38 @@ +--- +name: metabase_get_document +kind: function +lang: py +domain: infra +version: "1.0.0" +purity: impure +signature: "def metabase_get_document(client: MetabaseClient, document_id: int) -> dict" +description: "Obtiene un document completo con su arbol ProseMirror (campo document). Endpoint: GET /api/document/:id." +tags: [metabase, document, get, 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: document_id + desc: "ID del document a obtener" +output: "dict: objeto document con name, document (ProseMirror tree), collection_id, archived, creator, created_at, updated_at, entity_id, content_type" +tested: false +tests: [] +test_file_path: "" +file_path: "python/functions/metabase/documents.py" +--- + +## Ejemplo + +```python +doc = metabase_get_document(client, 1) +tree = doc["document"] # {"type": "doc", "content": [...]} +``` + +## Notas + +`content_type` siempre es `application/json+vnd.prose-mirror`. diff --git a/python/functions/metabase/metabase_get_group.md b/python/functions/metabase/metabase_get_group.md new file mode 100644 index 00000000..0910e5f0 --- /dev/null +++ b/python/functions/metabase/metabase_get_group.md @@ -0,0 +1,41 @@ +--- +name: metabase_get_group +kind: function +lang: py +domain: infra +version: "1.0.0" +purity: impure +signature: "def metabase_get_group(client: MetabaseClient, group_id: int) -> dict" +description: "Obtiene un Permission Group de Metabase por ID, incluyendo la lista completa de miembros con sus datos. Endpoint: GET /api/permissions/group/:id." +tags: [metabase, permissions, group, get, members, api, python] +uses_functions: [] +uses_types: [] +returns: [] +returns_optional: false +error_type: "error_go_core" +imports: [] +params: + - name: client + desc: "instancia autenticada de MetabaseClient con permisos de superusuario" + - name: group_id + desc: "ID numerico del grupo a consultar" +output: "dict: grupo con id, name y members (lista con user_id, email, first_name, last_name, membership_id)" +tested: false +tests: [] +test_file_path: "" +file_path: "python/functions/metabase/permissions.py" +--- + +## Ejemplo + +```python +group = metabase_get_group(client, 3) +print(group["name"]) +for m in group["members"]: + print(m["email"], m["user_id"]) +``` + +## Notas + +A diferencia de `metabase_list_groups`, este endpoint retorna la lista completa de miembros del grupo. +Error 404 si el grupo no existe. diff --git a/python/functions/metabase/metabase_get_permission_graph.md b/python/functions/metabase/metabase_get_permission_graph.md new file mode 100644 index 00000000..e2f4020e --- /dev/null +++ b/python/functions/metabase/metabase_get_permission_graph.md @@ -0,0 +1,39 @@ +--- +name: metabase_get_permission_graph +kind: function +lang: py +domain: infra +version: "1.0.0" +purity: impure +signature: "def metabase_get_permission_graph(client: MetabaseClient) -> dict" +description: "Obtiene el grafo de permisos de datos (databases/schemas/tables) de Metabase. Endpoint: GET /api/permissions/graph. El campo revision es crítico para concurrency control en el PUT posterior." +tags: [metabase, permissions, graph, databases, schemas, access-control, api, python] +uses_functions: [] +uses_types: [] +returns: [] +returns_optional: false +error_type: "error_go_core" +imports: [] +params: + - name: client + desc: "instancia autenticada de MetabaseClient con permisos de superusuario" +output: "dict: grafo completo con revision (int) y groups (group_id -> db_id -> {schemas, native}). El campo revision es obligatorio para el PUT." +tested: false +tests: [] +test_file_path: "" +file_path: "python/functions/metabase/permissions.py" +--- + +## Ejemplo + +```python +graph = metabase_get_permission_graph(client) +print("revision:", graph["revision"]) +for group_id, dbs in graph["groups"].items(): + for db_id, perms in dbs.items(): + print(f"group={group_id} db={db_id}: {perms}") +``` + +## Notas + +Siempre hacer GET fresco justo antes del PUT. El `revision` es el mecanismo de concurrency control nativo de Metabase — ver `metabase_update_permission_graph` para el patrón completo. diff --git a/python/functions/metabase/metabase_list_document_comments.md b/python/functions/metabase/metabase_list_document_comments.md new file mode 100644 index 00000000..d446a1ad --- /dev/null +++ b/python/functions/metabase/metabase_list_document_comments.md @@ -0,0 +1,51 @@ +--- +name: metabase_list_document_comments +kind: function +lang: py +domain: infra +version: "1.0.0" +purity: impure +signature: "def metabase_list_document_comments(client: MetabaseClient, document_id: int, *, include_resolved: bool = True, include_deleted: bool = False) -> list[dict]" +description: "Lista los comentarios de un document (threads, anclajes por bloque UUID, reacciones). Endpoint no documentado: GET /api/comment?target_type=document&target_id=:id." +tags: [metabase, document, comments, 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: document_id + desc: "ID del document del que listar comentarios" + - name: include_resolved + desc: "si False, filtra comentarios con is_resolved=True" + - name: include_deleted + desc: "si False, filtra comentarios soft-deleted (deleted_at != null)" +output: "list[dict]: cada dict con id, content (arbol ProseMirror), creator, creator_id, target_type, target_id, child_target_id (UUID de bloque anclado o null), parent_comment_id (para threads), is_resolved, deleted_at, reactions, created_at, updated_at" +tested: false +tests: [] +test_file_path: "" +file_path: "python/functions/metabase/documents.py" +--- + +## Ejemplo + +```python +comments = metabase_list_document_comments(client, 29, include_resolved=False) +for c in comments: + author = c["creator"]["common_name"] + # content es ProseMirror — aplanar si quieres texto plano + text = "".join(n["text"] for n in _walk(c["content"]) if n.get("type") == "text") + anchor = c["child_target_id"] or "doc" + print(f'{author} @ {anchor}: {text}') +``` + +## Notas + +**Endpoint no documentado** en la API publica de Metabase — descubierto inspeccionando trafico. target_type solo acepta el enum `"document"` en v0.59. + +**Anclaje por bloque**: cuando un usuario comenta sobre un parrafo concreto en la UI, Metabase inyecta un `attrs._id` UUID en ese parrafo y guarda ese UUID en `child_target_id`. Si editas el parrafo vía YAML sin preservar el `_id`, el comentario queda huerfano. + +**Threads**: los comentarios que responden a otros tienen `parent_comment_id` apuntando al padre. diff --git a/python/functions/metabase/metabase_list_documents.md b/python/functions/metabase/metabase_list_documents.md new file mode 100644 index 00000000..52463677 --- /dev/null +++ b/python/functions/metabase/metabase_list_documents.md @@ -0,0 +1,37 @@ +--- +name: metabase_list_documents +kind: function +lang: py +domain: infra +version: "1.0.0" +purity: impure +signature: "def metabase_list_documents(client: MetabaseClient) -> list[dict]" +description: "Lista documents (texto ProseMirror tipo Notion, feature 0.57+). Endpoint: GET /api/document. Desenrolla {items: [...]}." +tags: [metabase, document, list, 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" +output: "list[dict]: cada dict con id, name, collection_id, archived, created_at, updated_at, creator_id, content_type, entity_id" +tested: false +tests: [] +test_file_path: "" +file_path: "python/functions/metabase/documents.py" +--- + +## Ejemplo + +```python +docs = metabase_list_documents(client) +for d in docs: + print(d["id"], d["name"]) +``` + +## Notas + +Metabase devuelve `{"items": [...]}`. Esta funcion desenrolla para retornar la lista directamente. diff --git a/python/functions/metabase/metabase_list_groups.md b/python/functions/metabase/metabase_list_groups.md new file mode 100644 index 00000000..df0448f9 --- /dev/null +++ b/python/functions/metabase/metabase_list_groups.md @@ -0,0 +1,38 @@ +--- +name: metabase_list_groups +kind: function +lang: py +domain: infra +version: "1.0.0" +purity: impure +signature: "def metabase_list_groups(client: MetabaseClient) -> list[dict]" +description: "Lista todos los Permission Groups de Metabase con su member_count. Requiere superusuario. Endpoint: GET /api/permissions/group." +tags: [metabase, permissions, group, 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 con permisos de superusuario" +output: "list[dict]: lista de grupos con id, name y member_count" +tested: false +tests: [] +test_file_path: "" +file_path: "python/functions/metabase/permissions.py" +--- + +## Ejemplo + +```python +groups = metabase_list_groups(client) +for g in groups: + print(g["id"], g["name"], g["member_count"]) +``` + +## Notas + +Incluye los grupos especiales del sistema: "All Users" (id=1) y "Administrators" (id=2). +Requiere que el cliente este autenticado como superusuario. diff --git a/python/functions/metabase/metabase_list_memberships.md b/python/functions/metabase/metabase_list_memberships.md new file mode 100644 index 00000000..b4180f21 --- /dev/null +++ b/python/functions/metabase/metabase_list_memberships.md @@ -0,0 +1,38 @@ +--- +name: metabase_list_memberships +kind: function +lang: py +domain: infra +version: "1.0.0" +purity: impure +signature: "def metabase_list_memberships(client: MetabaseClient) -> dict[str, list[dict]]" +description: "Lista todas las membresías de grupos de Metabase. Endpoint: GET /api/permissions/membership. Retorna dict con group_id (str) como clave y lista de membresías como valor." +tags: [metabase, permissions, membership, groups, users, api, python] +uses_functions: [] +uses_types: [] +returns: [] +returns_optional: false +error_type: "error_go_core" +imports: [] +params: + - name: client + desc: "instancia autenticada de MetabaseClient con permisos de superusuario" +output: "dict[str, list[dict]]: mapa de group_id (str) a lista de membresías, cada una con membership_id, user_id, group_id, is_group_manager" +tested: false +tests: [] +test_file_path: "" +file_path: "python/functions/metabase/permissions.py" +--- + +## Ejemplo + +```python +memberships = metabase_list_memberships(client) +for group_id, members in memberships.items(): + for m in members: + print(m["user_id"], m["membership_id"], m["is_group_manager"]) +``` + +## Notas + +La respuesta nativa de Metabase es un dict indexado por group_id como string, no una lista plana. Para buscar la membresía de un usuario en un grupo específico, hay que iterar `memberships.get(str(group_id), [])`. diff --git a/python/functions/metabase/metabase_mbql_validate.md b/python/functions/metabase/metabase_mbql_validate.md new file mode 100644 index 00000000..f41e1329 --- /dev/null +++ b/python/functions/metabase/metabase_mbql_validate.md @@ -0,0 +1,76 @@ +--- +name: metabase_mbql_validate +kind: function +lang: py +domain: core +version: "1.0.0" +purity: pure +signature: "def metabase_mbql_validate(dataset_query: dict) -> list[str]" +description: "Valida la estructura de un dataset_query MBQL sin I/O. Detecta UUIDs duplicados, stage mixing (aggregations + expressions que referencian slots en la misma stage), slot refs rotas (sum_X inexistente), case structures invalidas y name collisions en expressions. Retorna lista de errores, vacia si el query es valido." +tags: [metabase, mbql, validation, pure, query, dataset_query] +uses_functions: [] +uses_types: [] +params: + - name: dataset_query + desc: "Dict completo del dataset_query MBQL tal como lo devuelve GET /api/card/:id. Debe tener clave 'stages' con lista de stage dicts. Cada stage puede tener 'expressions', 'aggregation', 'filters'." +output: "Lista de strings con errores encontrados. Lista vacia si el query supera todos los checks. Cada error incluye la ubicacion (stage[N]) y descripcion del problema." +returns: [] +returns_optional: false +error_type: "" +imports: [] +tested: true +tests: + - "DQ valido retorna lista vacia" + - "UUID duplicado genera error" + - "Stage mixing con slot refs genera error" + - "Slot sum_99 inexistente genera error" + - "Case con casos no pares genera error" + - "Name collision en expressions genera error" + - "stages ausente devuelve error de estructura" +test_file_path: "python/functions/metabase/test_metabase_mbql_validate.py" +file_path: "python/functions/metabase/metabase_mbql_validate.py" +--- + +## Checks implementados + +### 1. UUIDs duplicados +Metabase requiere que cada `lib/uuid` sea unico globalmente dentro del dataset_query. Un UUID repetido (por ejemplo al copiar-pegar un nodo MBQL) causa errores silenciosos o 400 en la API. + +### 2. Stage mixing +Si una stage tiene `aggregation` y `expressions`, las expressions NO deben referenciar los slot names generados por las aggregations (`sum`, `avg`, `sum_1`, etc.). Esas references deben ir en la stage siguiente. Si estan en la misma stage, Metabase retorna 500. + +### 3. Slot refs rotas +Una expression `["field", {sin base-type}, "sum_X"]` referencia la X-esima aggregation de tipo sum. Si X >= cantidad de sums en la stage, el slot no existe y la query falla. + +### 4. Case structure +Los nodos `["case", meta, cases]` deben tener `cases` como lista de pares `[cond, result]`. Una estructura malformada (e.g., lista de un solo elemento) causa errores de parsing en Metabase. + +### 5. Name collision +Dos `expressions` con el mismo `lib/expression-name` en la misma stage generan conflictos de alias en la query SQL generada. + +## Ejemplo + +```python +import sys +sys.path.insert(0, '/home/lucas/fn_registry/python/functions') +from metabase import MetabaseClient +from metabase.metabase_mbql_validate import metabase_mbql_validate + +client = MetabaseClient('https://metabase.example.com', 'token...') +card = client.request('GET', '/api/card/5705') + +errors = metabase_mbql_validate(card['dataset_query']) +if errors: + for e in errors: + print(f'ERROR: {e}') +else: + print('Query valida') +``` + +## Notas + +Funcion 100% pura: sin I/O, sin estado mutable, determinista. Solo stdlib Python. + +Los slots reconocidos como aggregation slots son: `sum`, `avg`, `count`, `min`, `max`, `distinct`, `cum-sum`, `cum-count`, `share`, `stddev` (y sus variantes `_N`). + +Un field con `base-type` en su metadata NO se considera slot ref — es una referencia a columna real. Solo los fields sin `base-type` se tratan como slots de aggregation. diff --git a/python/functions/metabase/metabase_mbql_validate.py b/python/functions/metabase/metabase_mbql_validate.py new file mode 100644 index 00000000..61e9551f --- /dev/null +++ b/python/functions/metabase/metabase_mbql_validate.py @@ -0,0 +1,236 @@ +"""Validacion estatica de dataset_query MBQL antes de enviarlo a Metabase.""" + +from __future__ import annotations + +import collections +import re +from typing import Any + + +def metabase_mbql_validate(dataset_query: dict) -> list[str]: + """Valida la estructura de un dataset_query MBQL sin hacer I/O. + + Detecta los errores mas comunes que causan respuestas 400/500 de la API de + Metabase, permitiendo corregirlos antes del round-trip. + + Checks realizados: + 1. UUIDs duplicados: cualquier ``lib/uuid`` que aparezca mas de una vez en + el arbol MBQL. Metabase los requiere unicos globalmente por query. + 2. Stage mixing: stages que tienen tanto ``aggregation`` como ``expressions`` + donde las expressions referencian slot names (``sum``, ``sum_N``, etc.) + generados por aggregations. Esas expressions deben ir en la stage siguiente. + 3. Slot refs rotos: expressions que referencian ``sum_X`` deben tener X menor + que la cantidad de sums en la stage previa (o misma). + 4. Case structure: nodos ``["case", meta, cases]`` deben tener ``cases`` + como lista de pares ``[cond, result]``. + 5. Name collision: dos expressions con el mismo ``lib/expression-name`` en + la misma stage. + + Args: + dataset_query: Dict con la estructura completa del dataset_query MBQL + tal como lo devuelve GET /api/card/:id o lo construye el caller. + Debe tener clave ``stages`` (lista de stage dicts). Si no tiene + ``stages``, se devuelve error de estructura. + + Returns: + Lista de strings describiendo errores encontrados. Lista vacia si el + dataset_query es valido segun todos los checks. + + Example: + >>> errors = metabase_mbql_validate(card["dataset_query"]) + >>> if errors: + ... for e in errors: + ... print(e) + ... else: + ... print("Query valida") + """ + errors: list[str] = [] + + stages = dataset_query.get("stages") + if not isinstance(stages, list): + errors.append("dataset_query.stages ausente o no es lista") + return errors + + # ---- Check 1: UUIDs duplicados ------------------------------------------ + uuid_locations: list[tuple[str, str]] = [] + _collect_uuids(dataset_query, root="", out=uuid_locations) + uuid_counter: dict[str, list[str]] = collections.defaultdict(list) + for uid, path in uuid_locations: + uuid_counter[uid].append(path) + for uid, paths in uuid_counter.items(): + if len(paths) > 1: + errors.append( + f"Duplicate lib/uuid '{uid}' aparece {len(paths)} veces: " + + ", ".join(paths[:3]) + + ("..." if len(paths) > 3 else "") + ) + + # ---- Checks por stage --------------------------------------------------- + for si, stage in enumerate(stages): + if not isinstance(stage, dict): + continue + tag = f"stage[{si}]" + + expressions: list[Any] = stage.get("expressions") or [] + aggregations: list[Any] = stage.get("aggregation") or [] + + # Check 5: name collision en expressions + expr_names: list[str] = [] + for expr in expressions: + name = _expr_name(expr) + if name: + if name in expr_names: + errors.append( + f"{tag} tiene dos expressions con mismo " + f"lib/expression-name '{name}'" + ) + else: + expr_names.append(name) + + # Check 2: stage mixing + if aggregations and expressions: + for expr in expressions: + slot_refs = _find_slot_refs(expr) + if slot_refs: + ename = _expr_name(expr) or "?" + errors.append( + f"{tag} mezcla aggregations con expressions " + f"post-agg que referencian slot names " + f"({', '.join(repr(s) for s in slot_refs)}) " + f"en expression '{ename}'. " + f"Mover esas expressions a la stage siguiente." + ) + + # Check 3: slot refs rotos + # Contar sums en aggregations de esta stage + sum_count = sum(1 for agg in aggregations if _agg_is_sum(agg)) + for expr in expressions: + for slot in _find_slot_refs(expr): + m = re.match(r"sum(?:_(\d+))?$", slot, re.IGNORECASE) + if m: + idx = int(m.group(1)) if m.group(1) else 0 + if idx >= sum_count: + ename = _expr_name(expr) or "?" + errors.append( + f"{tag} expression '{ename}' referencia " + f"'{slot}' que no existe " + f"(solo hay {sum_count} sum(s) en aggregation)" + ) + + # Check 4: case structure + for expr in expressions: + _check_case_structure(expr, tag, errors) + + return errors + + +# --------------------------------------------------------------------------- +# Helpers privados +# --------------------------------------------------------------------------- + + +def _collect_uuids( + obj: Any, + root: str, + out: list[tuple[str, str]], +) -> None: + """Recorre obj recursivamente y añade (uuid, path) a out.""" + if isinstance(obj, dict): + if "lib/uuid" in obj: + out.append((obj["lib/uuid"], root)) + for k, v in obj.items(): + _collect_uuids(v, f"{root}.{k}" if root else k, out) + elif isinstance(obj, list): + for i, item in enumerate(obj): + _collect_uuids(item, f"{root}[{i}]", out) + + +def _expr_name(expr: Any) -> str | None: + """Extrae lib/expression-name del segundo elemento de un nodo MBQL.""" + if isinstance(expr, list) and len(expr) >= 2 and isinstance(expr[1], dict): + return expr[1].get("lib/expression-name") + return None + + +# Patron de slot name: word chars, puede terminar en _N +_SLOT_RE = re.compile(r"^[a-zA-Z_][a-zA-Z0-9_]*(?:_\d+)?$") +# Slots que corresponden a aggregation functions conocidas +_AGG_SLOTS = { + "sum", "avg", "count", "min", "max", + "distinct", "cum-sum", "cum-count", "share", "stddev", +} + + +def _find_slot_refs(obj: Any) -> list[str]: + """Devuelve lista de slot names encontrados en refs tipo ["field", meta, slot].""" + slots: list[str] = [] + _collect_slot_refs(obj, slots) + return slots + + +def _collect_slot_refs(obj: Any, out: list[str]) -> None: + if isinstance(obj, list): + if ( + len(obj) == 3 + and obj[0] == "field" + and isinstance(obj[1], dict) + and isinstance(obj[2], str) + and not obj[1].get("base-type") # field sin base-type = slot ref + and _is_slot_name(obj[2]) + ): + out.append(obj[2]) + else: + for item in obj: + _collect_slot_refs(item, out) + elif isinstance(obj, dict): + for v in obj.values(): + _collect_slot_refs(v, out) + + +def _is_slot_name(s: str) -> bool: + """Devuelve True si s parece un slot name de aggregation.""" + # Slot: nombre sin espacio que es una funcion de agg o variant con sufijo _N + base = re.sub(r"_\d+$", "", s) + return base in _AGG_SLOTS + + +def _agg_is_sum(agg: Any) -> bool: + """Retorna True si el nodo aggregation es de tipo sum.""" + if isinstance(agg, list) and len(agg) >= 1: + return str(agg[0]).lower() == "sum" + return False + + +def _check_case_structure(expr: Any, tag: str, errors: list[str]) -> None: + """Valida recursivamente nodos case dentro de una expression.""" + if not isinstance(expr, list): + return + if expr and expr[0] == "case": + ename = _expr_name(expr) or "?" + # Esperado: ["case", meta, [[cond, result], ...]] + if len(expr) < 3: + errors.append( + f"{tag} expression '{ename}': case con menos de 3 elementos" + ) + return + cases = expr[2] + if not isinstance(cases, list): + errors.append( + f"{tag} expression '{ename}': tercer elemento de case " + f"debe ser lista de pares, got {type(cases).__name__}" + ) + return + for i, pair in enumerate(cases): + if not (isinstance(pair, list) and len(pair) == 2): + errors.append( + f"{tag} expression '{ename}': case[{i}] no es par " + f"[cond, result], got {pair!r}" + ) + # Recursar en ramas + for pair in cases: + if isinstance(pair, list): + for node in pair: + _check_case_structure(node, tag, errors) + else: + for item in expr: + _check_case_structure(item, tag, errors) diff --git a/python/functions/metabase/metabase_move_card.md b/python/functions/metabase/metabase_move_card.md new file mode 100644 index 00000000..1766ca41 --- /dev/null +++ b/python/functions/metabase/metabase_move_card.md @@ -0,0 +1,46 @@ +--- +name: metabase_move_card +kind: function +lang: py +domain: infra +version: "1.0.0" +purity: impure +signature: "def metabase_move_card(client: MetabaseClient, card_id: int, collection_id: int | None) -> dict" +description: "Mueve una card/pregunta a otra coleccion via PUT /api/card/:id. Wrapper thin que solo actualiza collection_id. collection_id=None mueve a 'Our analytics' (root)." +tags: [metabase, card, question, move, collection, 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 a mover" + - name: collection_id + desc: "ID de la coleccion destino; None mueve a 'Our analytics' (root)" +output: "dict: objeto card actualizado con el nuevo collection_id" +tested: false +tests: [] +test_file_path: "" +file_path: "python/functions/metabase/cards.py" +--- + +## Ejemplo + +```python +# Mover a una coleccion especifica +card = metabase_move_card(client, 42, collection_id=7) +print(card["collection_id"]) # 7 + +# Mover a root ("Our analytics") +card = metabase_move_card(client, 42, collection_id=None) +``` + +## Notas + +Wrapper con intencion explicita sobre metabase_update_card. Envia solo +`{collection_id: ...}` en el body para no sobreescribir otros campos. +Pasar `collection_id=None` es el mecanismo de Metabase para mover a root. diff --git a/python/functions/metabase/metabase_move_collection.md b/python/functions/metabase/metabase_move_collection.md new file mode 100644 index 00000000..d79421cc --- /dev/null +++ b/python/functions/metabase/metabase_move_collection.md @@ -0,0 +1,50 @@ +--- +name: metabase_move_collection +kind: function +lang: py +domain: infra +version: "1.0.0" +purity: impure +signature: "def metabase_move_collection(client: MetabaseClient, collection_id: int, parent_id: int | None) -> dict" +description: "Mueve una collection (sub-arbol completo) a otro padre. Endpoint: PUT /api/collection/:id con {parent_id: ...}." +tags: [metabase, collection, move, tree, 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: collection_id + desc: "ID de la collection a mover" + - name: parent_id + desc: "ID de la collection padre destino; None mueve a la raiz (Our analytics)" +output: "dict: collection actualizada con nuevo parent_id y location actualizado" +tested: false +tests: [] +test_file_path: "" +file_path: "python/functions/metabase/collections.py" +--- + +## Ejemplo + +```python +# Mover collection 12 dentro de collection 3 +col = metabase_move_collection(client, 12, parent_id=3) +print(col["location"]) # "/3/" + +# Mover a raiz +col = metabase_move_collection(client, 12, parent_id=None) +print(col["location"]) # "/" +``` + +## Notas + +Metabase reubica atomicamente la collection y todo su sub-arbol: colecciones +hijas, cards, dashboards y documents contenidos en ellas. El campo `location` +de la respuesta refleja la nueva ruta en formato `"/parent_id/"` o `"/"` para +la raiz. + +Para mover un document individual usar `metabase_move_document`. diff --git a/python/functions/metabase/metabase_move_dashboard.md b/python/functions/metabase/metabase_move_dashboard.md new file mode 100644 index 00000000..b74e5a34 --- /dev/null +++ b/python/functions/metabase/metabase_move_dashboard.md @@ -0,0 +1,47 @@ +--- +name: metabase_move_dashboard +kind: function +lang: py +domain: infra +version: "1.0.0" +purity: impure +signature: "def metabase_move_dashboard(client: MetabaseClient, dashboard_id: int, collection_id: int | None) -> dict" +description: "Mueve un dashboard a otra coleccion via PUT /api/dashboard/:id. Wrapper thin que solo actualiza collection_id. collection_id=None mueve a 'Our analytics' (root)." +tags: [metabase, dashboard, move, collection, 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: dashboard_id + desc: "ID del dashboard a mover" + - name: collection_id + desc: "ID de la coleccion destino; None mueve a 'Our analytics' (root)" +output: "dict: objeto dashboard actualizado con el nuevo collection_id" +tested: false +tests: [] +test_file_path: "" +file_path: "python/functions/metabase/dashboards.py" +--- + +## Ejemplo + +```python +# Mover a una coleccion especifica +dash = metabase_move_dashboard(client, 1, collection_id=7) +print(dash["collection_id"]) # 7 + +# Mover a root ("Our analytics") +dash = metabase_move_dashboard(client, 1, collection_id=None) +``` + +## Notas + +Wrapper con intencion explicita sobre metabase_update_dashboard. Envia solo +`{collection_id: ...}` en el body para no sobreescribir dashcards ni otros +campos del dashboard. Pasar `collection_id=None` es el mecanismo de Metabase +para mover a root. diff --git a/python/functions/metabase/metabase_move_document.md b/python/functions/metabase/metabase_move_document.md new file mode 100644 index 00000000..869d9c17 --- /dev/null +++ b/python/functions/metabase/metabase_move_document.md @@ -0,0 +1,45 @@ +--- +name: metabase_move_document +kind: function +lang: py +domain: infra +version: "1.0.0" +purity: impure +signature: "def metabase_move_document(client: MetabaseClient, document_id: int, collection_id: int | None) -> dict" +description: "Mueve un document a otra coleccion. Thin wrapper sobre PUT /api/document/:id enviando solo collection_id." +tags: [metabase, document, move, collection, 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: document_id + desc: "ID del document a mover" + - name: collection_id + desc: "ID de coleccion destino; None mueve a la raiz (Our analytics)" +output: "dict: document actualizado con el nuevo collection_id" +tested: false +tests: [] +test_file_path: "" +file_path: "python/functions/metabase/documents.py" +--- + +## Ejemplo + +```python +# Mover a coleccion 7 +doc = metabase_move_document(client, 42, collection_id=7) +print(doc["collection_id"]) # 7 + +# Mover a raiz +doc = metabase_move_document(client, 42, collection_id=None) +``` + +## Notas + +Equivale a `metabase_update_document(client, document_id, collection_id=collection_id)` pero con +firma explicita para mayor legibilidad en codigo de migracion/reorganizacion. diff --git a/python/functions/metabase/metabase_pair_n_n1_columns.md b/python/functions/metabase/metabase_pair_n_n1_columns.md new file mode 100644 index 00000000..e56fe5d0 --- /dev/null +++ b/python/functions/metabase/metabase_pair_n_n1_columns.md @@ -0,0 +1,66 @@ +--- +name: metabase_pair_n_n1_columns +kind: function +lang: py +domain: infra +version: "1.0.0" +purity: impure +signature: "def metabase_pair_n_n1_columns(client: MetabaseClient, *, dry_run: bool = True, card_ids: list[int] | None = None, base_field: str = 'Valor_vendido') -> dict" +description: "Para cards Metabase con display table/pivot que agregan SUM(base_field) y SUM(base_field_1), habilita la columna base_field_1 en visualization_settings.table.columns y la posiciona inmediatamente despues de base_field para comparacion visual. Soporta dry_run y campo base configurable." +tags: [metabase, maintenance, batch, visualization, columns, table, pivot, python] +uses_functions: [metabase_list_cards_py_infra] +uses_types: [] +returns: [] +returns_optional: false +error_type: "error_go_core" +imports: [copy, time, httpx] +params: + - name: client + desc: "instancia autenticada de MetabaseClient con acceso a GET /api/card y PUT /api/card/:id" + - name: dry_run + desc: "si True (default) solo escanea y reporta sin modificar ninguna card; si False aplica el cambio via PUT" + - name: card_ids + desc: "lista de IDs especificos a procesar; None = todas las cards activas con display table o pivot" + - name: base_field + desc: "nombre del campo MBQL base (sin sufijo _1); la funcion busca sum(base_field) y sum(base_field_1) en las agregaciones; por defecto 'Valor_vendido'" +output: "dict con scanned (cards table/pivot con par detectado), affected (cards con columna a mover), fixed (cards modificadas, 0 en dry_run), skipped (cards ya correctas o sin table.columns), errors (lista de {card_id, error} para fallos en PUT)" +tested: false +tests: [] +test_file_path: "" +file_path: "python/functions/metabase/maintenance.py" +--- + +## Ejemplo + +```python +from metabase import MetabaseClient, metabase_pair_n_n1_columns + +c = MetabaseClient("https://metabase.example.com", "mb_apikey") + +# Escanear sin modificar (campo por defecto: Valor_vendido) +report = metabase_pair_n_n1_columns(c, dry_run=True) +print(report) +# {'scanned': 45, 'affected': 3, 'fixed': 0, 'skipped': 42, 'errors': []} + +# Aplicar con campo personalizado +report = metabase_pair_n_n1_columns(c, dry_run=False, base_field="Importe") +print(report) +# {'scanned': 12, 'affected': 2, 'fixed': 2, 'skipped': 10, 'errors': []} + +# Solo cards especificas +report = metabase_pair_n_n1_columns(c, dry_run=False, card_ids=[42, 99]) +``` + +## Notas + +Requisitos para que una card sea candidata: +1. display == "table" o "pivot" +2. dataset_query tiene aggregation con sum(base_field) y sum(base_field_1) +3. visualization_settings.table.columns es una lista + +La funcion detecta los slots MBQL dinamicamente (ej: "sum", "sum_3") contando +el orden de aparicion de cada funcion de agregacion en el array. Luego busca +esos slots en table.columns por el campo "name" y reordena. + +Si base_field_1 no aparece en table.columns se inserta como nueva entrada +`{"name": slot_n1, "enabled": True}`. Rate-limit: 50ms entre PUTs. diff --git a/python/functions/metabase/metabase_resolve_document_comment.md b/python/functions/metabase/metabase_resolve_document_comment.md new file mode 100644 index 00000000..557f434f --- /dev/null +++ b/python/functions/metabase/metabase_resolve_document_comment.md @@ -0,0 +1,40 @@ +--- +name: metabase_resolve_document_comment +kind: function +lang: py +domain: infra +version: "1.0.0" +purity: impure +signature: "def metabase_resolve_document_comment(client: MetabaseClient, comment_id: int) -> dict" +description: "Marca un comentario como resuelto (is_resolved=True). Los comentarios resueltos se ocultan en la UI pero siguen consultables via metabase_list_document_comments(include_resolved=True). Endpoint: PUT /api/comment/:id." +tags: [metabase, document, comments, resolve, 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: comment_id + desc: "ID del comentario a marcar como resuelto" +output: "dict: comentario actualizado con is_resolved=True" +tested: false +tests: [] +test_file_path: "" +file_path: "python/functions/metabase/documents.py" +--- + +## Ejemplo + +```python +open_comments = metabase_list_document_comments(client, 29, include_resolved=False) +for c in open_comments: + if "obsoleto" in _plaintext(c["content"]): + metabase_resolve_document_comment(client, c["id"]) +``` + +## Notas + +El endpoint `PUT /api/comment/:id` acepta cualquier campo actualizable (content, is_resolved, etc.); esta funcion solo envia `is_resolved=True` para mantener contrato estrecho. Para editar contenido usar `PUT /api/comment/:id` directo via client. diff --git a/python/functions/metabase/metabase_update_collection_graph.md b/python/functions/metabase/metabase_update_collection_graph.md new file mode 100644 index 00000000..2fe15318 --- /dev/null +++ b/python/functions/metabase/metabase_update_collection_graph.md @@ -0,0 +1,64 @@ +--- +name: metabase_update_collection_graph +kind: function +lang: py +domain: infra +version: "1.0.0" +purity: impure +signature: "def metabase_update_collection_graph(client: MetabaseClient, graph: dict, namespace: str | None = None) -> dict" +description: "Actualiza el grafo de permisos de colecciones en Metabase. Endpoint: PUT /api/collection/graph. El campo revision en el graph es obligatorio — el servidor rechaza con 409 si no coincide con el actual. Soporta namespace para snippet collections." +tags: [metabase, permissions, collections, graph, access-control, 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 con permisos de superusuario" + - name: graph + desc: "dict con el grafo completo incluyendo el campo revision actual. Debe obtenerse vía metabase_get_collection_graph justo antes de modificar" + - name: namespace + desc: "namespace opcional: 'snippets' para snippet collections, None para colecciones regulares. Debe coincidir con el namespace usado en el GET" +output: "dict: nuevo grafo tras la actualización con revision incrementado" +tested: false +tests: [] +test_file_path: "" +file_path: "python/functions/metabase/permissions.py" +--- + +## Ejemplo + +```python +graph = metabase_get_collection_graph(client) +# Dar acceso write al grupo 3 sobre la colección 5 +graph["groups"]["3"]["5"] = "write" +updated = metabase_update_collection_graph(client, graph) +print("nueva revision:", updated["revision"]) + +# Para snippet collections: +graph = metabase_get_collection_graph(client, namespace="snippets") +graph["groups"]["3"]["root"] = "write" +updated = metabase_update_collection_graph(client, graph, namespace="snippets") +``` + +## Control de concurrencia por revision + +El campo `graph["revision"]` es el mecanismo de optimistic locking nativo de Metabase. + +**Patrón obligatorio:** + +1. `graph = metabase_get_collection_graph(client)` — GET fresco +2. Modificar `graph["groups"][group_id][collection_id] = "read" | "write" | "none"` +3. `graph = metabase_update_collection_graph(client, graph)` — PUT con revision + +**Nunca cachear el graph.** Si otro proceso modificó el graph entre el GET y el PUT, Metabase devuelve HTTP 409 Conflict y el caller debe reintentar desde el GET. + +### Niveles de acceso para colecciones + +- `"write"` — el grupo puede ver y editar contenido de la colección +- `"read"` — el grupo puede ver pero no editar +- `"none"` — sin acceso (la colección es invisible para el grupo) + +El campo `"root"` como collection_id hace referencia a la colección raíz "Our analytics". diff --git a/python/functions/metabase/metabase_update_dashboard_safe.md b/python/functions/metabase/metabase_update_dashboard_safe.md new file mode 100644 index 00000000..bdb50453 --- /dev/null +++ b/python/functions/metabase/metabase_update_dashboard_safe.md @@ -0,0 +1,95 @@ +--- +name: metabase_update_dashboard_safe +kind: function +lang: py +domain: infra +version: "1.0.0" +purity: impure +signature: "def metabase_update_dashboard_safe(client: MetabaseClient, dashboard_id: int, *, dashcards_update: list[dict] | None = None, dashcards_add: list[dict] | None = None, dashcards_remove: list[int] | None = None, extra_fields: dict | None = None) -> dict" +description: "Wrapper sobre PUT /api/dashboard/:id que maneja los tres gotchas documentados: strip del campo '.card' denormalizado (evita 413), inclusion obligatoria de 'tabs' (evita 500 FK violation) y asignacion de IDs negativos a dashcards nuevos. Soporta reemplazo completo (dashcards_update), operaciones incrementales (add/remove) y actualizacion de campos extra del dashboard." +tags: [metabase, dashboard, dashcard, api, wrapper, safe, put] +uses_functions: + - metabase_get_dashboard_py_infra +uses_types: [] +params: + - name: client + desc: "MetabaseClient autenticado con sesion activa." + - name: dashboard_id + desc: "ID entero del dashboard a actualizar." + - name: dashcards_update + desc: "Lista completa de dashcards que reemplaza el estado actual (full replace). Si se da, dashcards_add y dashcards_remove se ignoran. Cada dict puede incluir o no el campo 'card' — se strippea automaticamente." + - name: dashcards_add + desc: "Dashcards a añadir sobre los existentes. No deben incluir 'id' — la funcion asigna IDs negativos (-1, -2, ...). Se ignora si dashcards_update esta presente." + - name: dashcards_remove + desc: "Lista de IDs de dashcards existentes a eliminar del dashboard. Se aplica sobre existentes antes de añadir dashcards_add." + - name: extra_fields + desc: "Campos adicionales del dashboard a actualizar junto con las cards: name, description, parameters, archived, collection_id, etc." +output: "Dict con resumen: {'added': [negative_ids_assigned], 'updated': count_existentes_conservados, 'removed': count_eliminados, 'response': dict_respuesta_PUT}" +returns: [] +returns_optional: false +error_type: "error_go_core" +imports: + - httpx +tested: false +tests: [] +test_file_path: "" +file_path: "python/functions/metabase/metabase_update_dashboard_safe.py" +--- + +## Gotchas que maneja + +### 1. 413 Payload Too Large — campo `.card` denormalizado +GET /api/dashboard/:id devuelve cada dashcard con un campo `card` que contiene el objeto completo de la question (dataset_query, visualization_settings, result_metadata, etc.). Ese campo puede pesar varios KB por dashcard. PUT /api/dashboard/:id lo rechaza con 413 si se incluye. + +Esta funcion hace strip automatico, conservando solo los campos aceptados por la API: +`id`, `card_id`, `dashboard_tab_id`, `col`, `row`, `size_x`, `size_y`, `parameter_mappings`, `visualization_settings`, `series`, `action_id`, `inline_parameters`. + +### 2. 500 FK violation — tabs ausentes +Si el body del PUT no incluye `tabs`, Metabase interpreta que se deben borrar todas las tabs. Los dashcards que referencian esas tabs via `dashboard_tab_id` quedan con FK dangling y se produce 500 Internal Server Error. + +Esta funcion siempre hace GET del estado actual y re-incluye `current_tabs` en el body. + +### 3. IDs negativos para dashcards nuevos +Los dashcards nuevos deben tener `id` negativo temporal (-1, -2, ...) en el payload del PUT. Sin el campo `id`, Metabase los ignora. Esta funcion asigna IDs negativos automaticamente a cualquier dashcard sin `id` en `dashcards_add` o en `dashcards_update`. + +## Ejemplo + +```python +from metabase import MetabaseClient, metabase_update_dashboard_safe + +client = MetabaseClient('https://metabase.example.com', 'token...') + +# Añadir una card al dashboard +result = metabase_update_dashboard_safe( + client, + dashboard_id=42, + dashcards_add=[{ + "card_id": 100, + "col": 0, + "row": 0, + "size_x": 6, + "size_y": 4, + "parameter_mappings": [], + "visualization_settings": {}, + }], +) +print(result["added"]) # [-1] +print(result["updated"]) # N dashcards existentes conservados + +# Reemplazar todos los dashcards y cambiar nombre +dash = client.request("GET", "/api/dashboard/42") +cards = dash["dashcards"] +cards.append({"card_id": 200, "col": 6, "row": 0, "size_x": 6, "size_y": 4}) +result = metabase_update_dashboard_safe( + client, + dashboard_id=42, + dashcards_update=cards, + extra_fields={"name": "Dashboard actualizado"}, +) +``` + +## Notas + +Realiza siempre 2 requests (GET + PUT). Para operaciones de solo metadata sin tocar cards (ej. cambiar nombre), usar `metabase_update_dashboard` directamente que solo hace PUT. + +En caso de 413 o 500, el mensaje de error incluye diagnostico del gotcha mas probable para facilitar debugging. diff --git a/python/functions/metabase/metabase_update_dashboard_safe.py b/python/functions/metabase/metabase_update_dashboard_safe.py new file mode 100644 index 00000000..25d67eef --- /dev/null +++ b/python/functions/metabase/metabase_update_dashboard_safe.py @@ -0,0 +1,212 @@ +"""Wrapper seguro sobre PUT /api/dashboard/:id que maneja los gotchas conocidos.""" + +from __future__ import annotations + +import httpx + +from .client import MetabaseClient + + +# Campos permitidos en un dashcard al enviar a la API de Metabase. +# Cualquier otro campo (especialmente 'card' denormalizado) causa 413. +_DASHCARD_ALLOWED_KEYS = { + "id", + "card_id", + "dashboard_tab_id", + "col", + "row", + "size_x", + "size_y", + "parameter_mappings", + "visualization_settings", + "series", + "action_id", + "inline_parameters", +} + + +def _strip_dashcard(dc: dict) -> dict: + """Elimina campos denormalizados de un dashcard, conservando solo los permitidos. + + El campo ``card`` contiene el objeto completo de la question (con dataset_query, + visualization_settings, etc.) y puede superar facilmente el limite de payload. + Metabase lo añade en las respuestas GET pero lo rechaza (413) en PUT. + + Args: + dc: Dict de dashcard tal como lo devuelve GET /api/dashboard/:id. + + Returns: + Dict con solo los campos que acepta PUT /api/dashboard/:id. + """ + return {k: v for k, v in dc.items() if k in _DASHCARD_ALLOWED_KEYS} + + +def metabase_update_dashboard_safe( + client: MetabaseClient, + dashboard_id: int, + *, + dashcards_update: list[dict] | None = None, + dashcards_add: list[dict] | None = None, + dashcards_remove: list[int] | None = None, + extra_fields: dict | None = None, +) -> dict: + """Actualiza un dashboard de Metabase manejando los tres gotchas conocidos. + + Gotchas que maneja automaticamente: + 1. **413 Payload Too Large**: strippea el campo ``.card`` denormalizado de + cada dashcard antes de enviar. Metabase incluye ese objeto en GET pero + lo rechaza en PUT. + 2. **500 FK violation (tabs)**: siempre incluye el array ``tabs`` actual en + el body del PUT. Si se omite, Metabase borra las tabs y viola FK + constraints de dashcards que las referencian. + 3. **IDs negativos para dashcards nuevos**: los dashcards sin ``id`` + (nuevos) reciben IDs negativos temporales (-1, -2, ...) que Metabase + reemplaza con IDs reales en la respuesta. + + Flujo: + 1. GET /api/dashboard/:id — obtiene estado actual (dashcards + tabs). + 2. Construye la lista final de dashcards: + - Si ``dashcards_update`` dado: usarla directamente (tras strip). + - Si ``dashcards_add`` y/o ``dashcards_remove``: aplicar sobre existentes. + - Sin ninguno: mantener existentes sin cambios. + 3. Strip de campos denormalizados en todos los dashcards. + 4. Asignar IDs negativos a dashcards nuevos (sin ``id``). + 5. PUT /api/dashboard/:id con ``{dashcards, tabs, **extra_fields}``. + 6. Devolver resumen de operacion. + + Args: + client: Cliente autenticado con sesion activa. + dashboard_id: ID del dashboard a actualizar. + dashcards_update: Lista completa de dashcards que REEMPLAZA el estado + actual. Si se da, ``dashcards_add`` y ``dashcards_remove`` se ignoran. + dashcards_add: Dashcards a añadir sobre los existentes. Cada item + no debe incluir ``id`` — la funcion asigna IDs negativos. + dashcards_remove: Lista de IDs de dashcards existentes a eliminar. + extra_fields: Campos adicionales del dashboard a actualizar + (name, description, parameters, archived, collection_id, etc.). + + Returns: + Dict con resumen de la operacion:: + + { + "added": [list of temp negative ids assigned], + "updated": int, # dashcards existentes conservados + "removed": int, # dashcards eliminados + "response": dict, # respuesta raw de PUT /api/dashboard/:id + } + + Raises: + httpx.HTTPStatusError: Si la API devuelve 4xx/5xx con mensaje de + diagnostico indicando que gotcha probablemente se activo. + + Example: + >>> # Añadir una card al dashboard + >>> result = metabase_update_dashboard_safe( + ... client, + ... dashboard_id=42, + ... dashcards_add=[{ + ... "card_id": 100, + ... "col": 0, + ... "row": 0, + ... "size_x": 6, + ... "size_y": 4, + ... "parameter_mappings": [], + ... "visualization_settings": {}, + ... }], + ... ) + >>> print(result["added"]) # [-1] + + >>> # Reemplazar todos los dashcards y cambiar el nombre + >>> result = metabase_update_dashboard_safe( + ... client, + ... dashboard_id=42, + ... dashcards_update=existing_dashcards, + ... extra_fields={"name": "Nuevo nombre"}, + ... ) + >>> print(result["updated"]) + """ + # ---- Paso 1: GET estado actual ------------------------------------------ + current = client.request("GET", f"/api/dashboard/{dashboard_id}") + current_dashcards: list[dict] = current.get("dashcards") or [] + current_tabs: list[dict] = current.get("tabs") or [] + + # ---- Paso 2: Construir lista final de dashcards ------------------------- + removed_count = 0 + added_ids: list[int] = [] + + if dashcards_update is not None: + # Reemplazo completo: usar tal cual (tras strip) + final_dashcards = list(dashcards_update) + # Contabilizar: existentes que siguen presentes vs removidos + existing_ids = {dc.get("id") for dc in current_dashcards if dc.get("id")} + final_pos_ids = {dc.get("id") for dc in final_dashcards if dc.get("id") and dc.get("id", 0) > 0} + removed_count = len(existing_ids - final_pos_ids) + else: + # Operacion incremental: partir de existentes + remove_set: set[int] = set(dashcards_remove or []) + final_dashcards = [dc for dc in current_dashcards if dc.get("id") not in remove_set] + removed_count = len(remove_set) + + if dashcards_add: + final_dashcards = list(final_dashcards) + list(dashcards_add) + + # ---- Paso 3 & 4: Strip + asignar IDs negativos -------------------------- + cleaned: list[dict] = [] + next_neg_id = -1 + existing_count = 0 + + for dc in final_dashcards: + dc_clean = _strip_dashcard(dc) + + if "id" not in dc_clean or dc_clean.get("id") is None: + # Dashcard nuevo sin id: asignar negativo + dc_clean["id"] = next_neg_id + added_ids.append(next_neg_id) + next_neg_id -= 1 + elif isinstance(dc_clean.get("id"), int) and dc_clean["id"] < 0: + # Ya tiene id negativo (caller lo asigno): registrar + added_ids.append(dc_clean["id"]) + else: + existing_count += 1 + + cleaned.append(dc_clean) + + # ---- Paso 5: Construir body y PUT ---------------------------------------- + body: dict = { + "dashcards": cleaned, + "tabs": current_tabs, + } + if extra_fields: + body.update(extra_fields) + + try: + response = client.request("PUT", f"/api/dashboard/{dashboard_id}", json=body) + except httpx.HTTPStatusError as exc: + status = exc.response.status_code + if status == 413: + raise httpx.HTTPStatusError( + f"413 Payload Too Large al actualizar dashboard {dashboard_id}. " + "Probable causa: dashcard con campo '.card' denormalizado sin strippear. " + "Revisar que dashcards_update no contenga el objeto 'card' completo.", + request=exc.request, + response=exc.response, + ) from exc + if status == 500: + raise httpx.HTTPStatusError( + f"500 Internal Server Error al actualizar dashboard {dashboard_id}. " + "Posibles causas: (a) tabs FK violation — el body no incluyo 'tabs' " + "(no deberia ocurrir con esta funcion); " + "(b) dashcard referencia tab_id inexistente; " + "(c) error interno de Metabase. " + f"Body enviado tenia {len(cleaned)} dashcards y {len(current_tabs)} tabs.", + request=exc.request, + response=exc.response, + ) from exc + raise + + return { + "added": added_ids, + "updated": existing_count, + "removed": removed_count, + "response": response, + } diff --git a/python/functions/metabase/metabase_update_document.md b/python/functions/metabase/metabase_update_document.md new file mode 100644 index 00000000..19ce8655 --- /dev/null +++ b/python/functions/metabase/metabase_update_document.md @@ -0,0 +1,45 @@ +--- +name: metabase_update_document +kind: function +lang: py +domain: infra +version: "1.0.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." +tags: [metabase, document, update, 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: document_id + desc: "ID del document a actualizar" + - name: fields + desc: "kwargs con campos a modificar: name, document (arbol ProseMirror), collection_id, archived" +output: "dict: document actualizado" +tested: false +tests: [] +test_file_path: "" +file_path: "python/functions/metabase/documents.py" +--- + +## Ejemplo + +```python +# Renombrar +metabase_update_document(client, 1, name="Nuevo titulo") + +# Reemplazar contenido completo +metabase_update_document(client, 1, document={ + "type": "doc", + "content": [{"type": "paragraph", "content": [{"type": "text", "text": "Nuevo"}]}] +}) + +# Mover a coleccion +metabase_update_document(client, 1, collection_id=5) +``` diff --git a/python/functions/metabase/metabase_update_group.md b/python/functions/metabase/metabase_update_group.md new file mode 100644 index 00000000..7bcd042f --- /dev/null +++ b/python/functions/metabase/metabase_update_group.md @@ -0,0 +1,42 @@ +--- +name: metabase_update_group +kind: function +lang: py +domain: infra +version: "1.0.0" +purity: impure +signature: "def metabase_update_group(client: MetabaseClient, group_id: int, name: str) -> dict" +description: "Renombra un Permission Group en Metabase. La API solo permite modificar el nombre del grupo. Endpoint: PUT /api/permissions/group/:id." +tags: [metabase, permissions, group, update, rename, api, python] +uses_functions: [] +uses_types: [] +returns: [] +returns_optional: false +error_type: "error_go_core" +imports: [] +params: + - name: client + desc: "instancia autenticada de MetabaseClient con permisos de superusuario" + - name: group_id + desc: "ID numerico del grupo a renombrar" + - name: name + desc: "nuevo nombre del grupo" +output: "dict: grupo actualizado con id y name" +tested: false +tests: [] +test_file_path: "" +file_path: "python/functions/metabase/permissions.py" +--- + +## Ejemplo + +```python +group = metabase_update_group(client, 3, "Data Team") +print(group["name"]) # "Data Team" +``` + +## Notas + +La API de Metabase para grupos solo expone el campo `name` como modificable via PUT. +Para cambiar miembros usar la API de memberships (/api/permissions/membership). +Error 404 si el grupo no existe. diff --git a/python/functions/metabase/metabase_update_permission_graph.md b/python/functions/metabase/metabase_update_permission_graph.md new file mode 100644 index 00000000..e1dd5e3a --- /dev/null +++ b/python/functions/metabase/metabase_update_permission_graph.md @@ -0,0 +1,73 @@ +--- +name: metabase_update_permission_graph +kind: function +lang: py +domain: infra +version: "1.0.0" +purity: impure +signature: "def metabase_update_permission_graph(client: MetabaseClient, graph: dict) -> dict" +description: "Actualiza el grafo de permisos de datos en Metabase. Endpoint: PUT /api/permissions/graph. El campo revision en el graph es obligatorio — el servidor rechaza con 409 si no coincide con el actual." +tags: [metabase, permissions, graph, databases, schemas, access-control, 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 con permisos de superusuario" + - name: graph + desc: "dict con el grafo completo incluyendo el campo revision actual. Debe obtenerse vía metabase_get_permission_graph justo antes de modificar" +output: "dict: nuevo grafo tras la actualización con revision incrementado" +tested: false +tests: [] +test_file_path: "" +file_path: "python/functions/metabase/permissions.py" +--- + +## Ejemplo + +```python +graph = metabase_get_permission_graph(client) +# Dar acceso completo al grupo 3 sobre la database 1 +graph["groups"]["3"]["1"] = {"schemas": "all", "native": "write"} +updated = metabase_update_permission_graph(client, graph) +print("nueva revision:", updated["revision"]) +``` + +## Control de concurrencia por revision + +El campo `graph["revision"]` es el mecanismo de optimistic locking nativo de Metabase. + +**Patrón obligatorio:** + +1. `graph = metabase_get_permission_graph(client)` — GET fresco +2. Modificar `graph["groups"][group_id][db_id] = ...` — editar en memoria +3. `graph = metabase_update_permission_graph(client, graph)` — PUT con revision + +**Nunca cachear el graph.** Si otro proceso modificó el graph entre el GET y el PUT, Metabase devuelve HTTP 409 Conflict y el caller debe reintentar desde el GET. + +### Estructura de permisos por database + +```python +# Acceso completo con SQL nativo +graph["groups"]["3"]["1"] = {"schemas": "all", "native": "write"} + +# Solo lectura, sin SQL nativo +graph["groups"]["3"]["1"] = {"schemas": "all", "native": "none"} + +# Sin acceso +graph["groups"]["3"]["1"] = {"schemas": "none", "native": "none"} + +# Acceso granular por schema/tabla +graph["groups"]["3"]["1"] = { + "schemas": { + "public": { + "orders": {"read": "all"}, + "users": {"read": "none"}, + } + }, + "native": "none", +} +``` diff --git a/python/functions/metabase/metabase_validate_card_payload.md b/python/functions/metabase/metabase_validate_card_payload.md new file mode 100644 index 00000000..7ae42a9a --- /dev/null +++ b/python/functions/metabase/metabase_validate_card_payload.md @@ -0,0 +1,70 @@ +--- +name: metabase_validate_card_payload +kind: function +lang: py +domain: infra +version: "1.0.0" +purity: pure +signature: "def metabase_validate_card_payload(payload: dict) -> list[str]" +description: "Valida la estructura de un payload de card de Metabase sin necesidad de red. Recorre todos los checks y acumula todos los issues en vez de abortar al primero. Retorna lista vacia si el payload es valido." +tags: [metabase, validation, card, pure, pre-flight, structural] +uses_functions: [] +uses_types: [] +returns: [] +returns_optional: false +error_type: "" +imports: [] +params: + - name: payload + desc: "dict con los campos de la card a validar: name, display, dataset_query, type (opcional), visualization_settings (opcional), parameters (opcional), archived (opcional)" +output: "lista de strings describiendo cada issue estructural encontrado; lista vacia indica payload valido listo para enviarse a POST/PUT /api/card" +tested: true +tests: + - "card valido retorna lista vacia" + - "card display invalido" + - "card display ausente" + - "card name ausente" + - "card name vacio" + - "card dataset query ausente" + - "card dataset query sin database" + - "card nativa sin sql" + - "card nativa mbql5" + - "card type invalido" + - "card type valido" + - "card visualization settings no dict" + - "card parameters no list" + - "card archived no bool" + - "card acumula multiples errores" +test_file_path: "python/functions/metabase/validation_test.py" +file_path: "python/functions/metabase/validation.py" +--- + +## Ejemplo + +```python +issues = metabase_validate_card_payload({ + "name": "Revenue by Month", + "display": "line", + "dataset_query": { + "database": 1, + "type": "native", + "native": {"query": "SELECT date_trunc('month', created_at), SUM(total) FROM orders GROUP BY 1"}, + }, +}) +if issues: + print("Payload invalido:", issues) +else: + metabase_update_card(client, card_id, **payload) +``` + +## Notas + +Displays validos: scalar, table, line, bar, pie, area, row, funnel, smartscalar, gauge, progress, combo, pivot, map, scatter, waterfall, sankey, object. + +Tipos validos (campo `type`): question, model, metric. + +Soporta dos formatos de SQL nativo: +- Legacy: `dataset_query.native.query` +- MBQL5: `dataset_query.stages[0].native` + +No aborta al primer error — recolecta todos los issues para que el caller pueda mostrarlos todos de una vez. diff --git a/python/functions/metabase/metabase_validate_dashboard_payload.md b/python/functions/metabase/metabase_validate_dashboard_payload.md new file mode 100644 index 00000000..3a32d755 --- /dev/null +++ b/python/functions/metabase/metabase_validate_dashboard_payload.md @@ -0,0 +1,63 @@ +--- +name: metabase_validate_dashboard_payload +kind: function +lang: py +domain: infra +version: "1.0.0" +purity: pure +signature: "def metabase_validate_dashboard_payload(payload: dict, known_card_ids: set[int]) -> list[str]" +description: "Valida la estructura de un payload de dashboard de Metabase sin red. Verifica campos obligatorios, bounds de dashcards, referencias a cards conocidas y solapamientos entre dashcards. Acumula todos los issues." +tags: [metabase, validation, dashboard, dashcard, overlap, pure, pre-flight, structural] +uses_functions: [] +uses_types: [] +returns: [] +returns_optional: false +error_type: "" +imports: [] +params: + - name: payload + desc: "dict con los campos del dashboard a validar: name, dashcards (opcional), tabs (opcional), parameters (opcional)" + - name: known_card_ids + desc: "conjunto de IDs enteros de cards que existen en Metabase; las dashcards con card_id entero deben referenciar un ID de este conjunto. Pasar set vacio si no se quiere verificar referencias." +output: "lista de strings describiendo cada issue encontrado; lista vacia indica payload valido listo para enviarse a PUT /api/dashboard/:id" +tested: true +tests: + - "dashboard valido sin dashcards" + - "dashboard valido con dashcards" + - "dashboard card id desconocido" + - "dashboard card virtual null permitido" + - "dashboard dashcards solapadas" + - "dashboard dashcards adyacentes no solapan" + - "dashboard col fuera de bounds" + - "dashboard col mas size x excede grid" + - "dashboard size y fuera de bounds" + - "dashboard name ausente" + - "dashboard tabs invalidos" + - "dashboard parameters no list" +test_file_path: "python/functions/metabase/validation_test.py" +file_path: "python/functions/metabase/validation.py" +--- + +## Ejemplo + +```python +known_ids = {c["id"] for c in metabase_list_cards(client)} +issues = metabase_validate_dashboard_payload(dashboard_payload, known_card_ids=known_ids) +if issues: + print("Dashboard invalido:") + for issue in issues: + print(f" - {issue}") +else: + metabase_update_dashboard(client, dashboard_id, **dashboard_payload) +``` + +## Notas + +Bounds del grid de Metabase: +- `col` in [0, 23], `row` >= 0 +- `size_x` in [1, 24], `size_y` in [1, 100] +- `col + size_x <= 24` (no exceder el ancho del grid) + +Deteccion de solapamientos: O(n^2) sobre pares de dashcards. Optimo para dashboards tipicos (< 50 cards). + +Dashcards con `card_id = null` son virtuales (texto, headings, iframes) y se permiten sin verificar contra known_card_ids. diff --git a/python/functions/metabase/metabase_validate_document_payload.md b/python/functions/metabase/metabase_validate_document_payload.md new file mode 100644 index 00000000..ad49c0e3 --- /dev/null +++ b/python/functions/metabase/metabase_validate_document_payload.md @@ -0,0 +1,57 @@ +--- +name: metabase_validate_document_payload +kind: function +lang: py +domain: infra +version: "1.0.0" +purity: pure +signature: "def metabase_validate_document_payload(payload: dict, known_card_slugs: set[str] | None = None) -> list[str]" +description: "Valida un arbol ProseMirror contra la whitelist de nodos y marks que el editor TipTap de Metabase renderiza. Detecta nodos desconocidos que la API acepta pero el frontend descarta silenciosamente." +tags: [metabase, document, prosemirror, validate, pure, python] +uses_functions: [] +uses_types: [] +returns: [] +returns_optional: false +error_type: "" +imports: [] +params: + - name: payload + desc: "payload del document (name, document=arbol ProseMirror, archived)" + - name: known_card_slugs + desc: "set de slugs de cards del index para validar cardEmbed.attrs.card (None = skip)" +output: "list[str]: warnings describiendo nodos/marks no soportados o violaciones del schema. Lista vacia = payload renderizable" +tested: true +tests: [test_document_valido_minimo, test_document_nodo_desconocido_callout, test_document_mark_desconocido_underline, test_document_heading_level_invalido, test_document_cardEmbed_sin_id_ni_slug, test_document_flexContainer_demasiados_hijos, test_document_kitchen_sink_valido] +test_file_path: "python/functions/metabase/validation_test.py" +file_path: "python/functions/metabase/validation.py" +--- + +## Ejemplo + +```python +issues = metabase_validate_document_payload({ + "name": "Notas", + "document": { + "type": "doc", + "content": [ + {"type": "callout", "content": [...]} # ← no soportado por TipTap + ], + }, +}) +# → ["document.content[0]: nodo 'callout' no soportado..."] +``` + +## Notas + +**Whitelist de nodos** (derivada de inspeccionar el bundle de Metabase v0.59): +`doc, paragraph, text, heading, bulletList, orderedList, listItem, blockquote, codeBlock, horizontalRule, hardBreak, cardEmbed, flexContainer, smartLink, resizeNode, mention` + +**Whitelist de marks:** `bold, italic, strike, code, link` + +Nodos comunes de ProseMirror que la API acepta pero el editor **no renderiza** (el resultado es un documento vacio o incompleto en la UI): `callout, taskList, taskItem, details, table, image, iframe`. Marks equivalentes: `underline, highlight, subscript, textStyle`. + +Restricciones estructurales adicionales: +- `heading.attrs.level` ∈ [1, 6] +- `flexContainer` acepta 1-3 hijos, solo `cardEmbed` o `supportingText` +- `flexContainer.attrs.columnWidths` debe tener el mismo largo que `content` +- `cardEmbed.attrs` requiere `id` (int) o `card` (slug del index) diff --git a/python/functions/metabase/metabase_validate_sql.md b/python/functions/metabase/metabase_validate_sql.md new file mode 100644 index 00000000..9e019f51 --- /dev/null +++ b/python/functions/metabase/metabase_validate_sql.md @@ -0,0 +1,58 @@ +--- +name: metabase_validate_sql +kind: function +lang: py +domain: infra +version: "1.0.0" +purity: impure +signature: "def metabase_validate_sql(client: MetabaseClient, database_id: int, sql: str, max_rows: int = 0) -> dict" +description: "Valida sintaxis y referencias de una query SQL ejecutandola contra Metabase via POST /api/dataset. Captura tanto errores HTTP como errores embebidos en el body (Metabase a veces devuelve 200 con status failed). Retorna ok, error y rows_returned." +tags: [metabase, validation, sql, pre-flight, impure, query, syntax-check] +uses_functions: [metabase_execute_query_py_infra, metabase_auth_py_infra] +uses_types: [] +returns: [] +returns_optional: false +error_type: "error_go_core" +imports: [httpx] +params: + - name: client + desc: "instancia autenticada de MetabaseClient con sesion activa" + - name: database_id + desc: "ID de la base de datos en Metabase contra la que ejecutar el SQL" + - name: sql + desc: "sentencia SQL a validar (SELECT, WITH, etc.) — se ejecuta realmente contra la BD" + - name: max_rows + desc: "limite de filas a retornar durante la validacion para minimizar carga (0 = default de Metabase, tipicamente 2000)" +output: "dict con tres claves: ok (bool) indica si la query se ejecuto sin error; error (str|None) mensaje de error si ok=False; rows_returned (int) numero de filas devueltas si ok=True" +tested: false +tests: [] +test_file_path: "" +file_path: "python/functions/metabase/validation.py" +--- + +## Ejemplo + +```python +result = metabase_validate_sql(client, database_id=1, sql="SELECT id FROM orders LIMIT 1") +if not result["ok"]: + print(f"SQL invalido: {result['error']}") +else: + print(f"SQL valido, {result['rows_returned']} filas retornadas") +``` + +## Notas + +No tiene tests automatizados porque requiere una instancia Metabase corriendo con una base de datos real. Para testear manualmente: + +```python +client = metabase_auth("http://localhost:3000", "admin@example.com", "pass") +result = metabase_validate_sql(client, 1, "SELECT * FROM tabla_inexistente") +# result["ok"] == False, result["error"] == "Table 'tabla_inexistente' not found" +``` + +Comportamiento ante errores: +- `httpx.HTTPStatusError` (4xx/5xx): extrae el campo `error` o `message` del JSON del response de Metabase. +- Body con `status: "failed"` (Metabase devuelve 200 en algunos errores de query): captura el campo `error` del body. +- Cualquier otra excepcion: convierte a string y la incluye en el campo `error`. + +Usa `metabase_execute_query` internamente, que mapea a POST /api/dataset. diff --git a/python/functions/metabase/permissions.py b/python/functions/metabase/permissions.py new file mode 100644 index 00000000..d89ec899 --- /dev/null +++ b/python/functions/metabase/permissions.py @@ -0,0 +1,369 @@ +"""CRUD de Permission Groups de Metabase.""" + +from .client import MetabaseClient + + +def metabase_list_groups(client: MetabaseClient) -> list[dict]: + """Lista todos los Permission Groups de Metabase. + + Endpoint: GET /api/permissions/group. Requiere superusuario. + + Args: + client: Cliente autenticado con permisos admin. + + Returns: + Lista de dicts, cada uno con: id, name, member_count. + + Example: + >>> groups = metabase_list_groups(client) + >>> for g in groups: + ... print(g["id"], g["name"], g["member_count"]) + """ + return client.request("GET", "/api/permissions/group") + + +def metabase_get_group(client: MetabaseClient, group_id: int) -> dict: + """Obtiene un Permission Group por su ID, incluyendo la lista completa de miembros. + + Endpoint: GET /api/permissions/group/:id. Requiere superusuario. + + Args: + client: Cliente autenticado con permisos admin. + group_id: ID numerico del grupo. + + Returns: + Dict con: id, name, members (lista de dicts con user_id, email, + first_name, last_name, membership_id). + + Raises: + httpx.HTTPStatusError: 404 si el grupo no existe. + + Example: + >>> group = metabase_get_group(client, 3) + >>> print(group["name"]) + >>> for m in group["members"]: + ... print(m["email"]) + """ + return client.request("GET", f"/api/permissions/group/{group_id}") + + +def metabase_create_group(client: MetabaseClient, name: str) -> dict: + """Crea un nuevo Permission Group en Metabase. + + Endpoint: POST /api/permissions/group. Requiere superusuario. + + Args: + client: Cliente autenticado con permisos admin. + name: Nombre del grupo. Debe ser unico. + + Returns: + Dict con el grupo creado: id, name. + + Raises: + httpx.HTTPStatusError: 400 si el nombre ya existe. + + Example: + >>> group = metabase_create_group(client, "Analytics Team") + >>> print(group["id"], group["name"]) + """ + return client.request("POST", "/api/permissions/group", json={"name": name}) + + +def metabase_update_group(client: MetabaseClient, group_id: int, name: str) -> dict: + """Renombra un Permission Group existente en Metabase. + + Endpoint: PUT /api/permissions/group/:id. Requiere superusuario. + La API solo permite modificar el nombre del grupo. + + Args: + client: Cliente autenticado con permisos admin. + group_id: ID numerico del grupo a renombrar. + name: Nuevo nombre del grupo. + + Returns: + Dict con el grupo actualizado: id, name. + + Raises: + httpx.HTTPStatusError: 404 si el grupo no existe. + + Example: + >>> group = metabase_update_group(client, 3, "Data Team") + >>> print(group["name"]) + """ + return client.request("PUT", f"/api/permissions/group/{group_id}", json={"name": name}) + + +def metabase_delete_group(client: MetabaseClient, group_id: int) -> None: + """Elimina permanentemente un Permission Group de Metabase. + + Endpoint: DELETE /api/permissions/group/:id. IRREVERSIBLE. + Requiere superusuario. + + Los grupos especiales del sistema no deben borrarse: + - id=1: "All Users" (todos los usuarios pertenecen a este grupo) + - id=2: "Administrators" + Esta funcion NO bloquea el borrado de esos IDs — es responsabilidad + del caller verificar que no se pasen IDs protegidos. + + Args: + client: Cliente autenticado con permisos admin. + group_id: ID numerico del grupo a eliminar. + + Raises: + httpx.HTTPStatusError: 404 si el grupo no existe. + + Example: + >>> metabase_delete_group(client, 5) + >>> # CUIDADO: no pasar group_id=1 (All Users) ni group_id=2 (Administrators) + """ + client.request("DELETE", f"/api/permissions/group/{group_id}") + + +# --- Memberships --- + + +def metabase_list_memberships(client: MetabaseClient) -> dict[str, list[dict]]: + """Lista todas las membresías de grupos de Metabase. + + Endpoint: GET /api/permissions/membership. Requiere superusuario. + + La respuesta nativa de Metabase es un dict con group_id (str) como clave, + y una lista de membresías como valor — no una lista plana. + + Args: + client: Cliente autenticado con permisos admin. + + Returns: + Dict mapeando group_id (str) a lista de dicts, cada uno con: + membership_id, user_id, group_id, is_group_manager. + + Example: + >>> memberships = metabase_list_memberships(client) + >>> for group_id, members in memberships.items(): + ... for m in members: + ... print(m["user_id"], m["membership_id"], m["is_group_manager"]) + """ + return client.request("GET", "/api/permissions/membership") + + +def metabase_add_membership( + client: MetabaseClient, + user_id: int, + group_id: int, + is_group_manager: bool = False, +) -> list[dict]: + """Añade un usuario a un Permission Group de Metabase. + + Endpoint: POST /api/permissions/membership. Requiere superusuario. + + Args: + client: Cliente autenticado con permisos admin. + user_id: ID del usuario a añadir al grupo. + group_id: ID del grupo destino. + is_group_manager: Si True, el usuario es manager del grupo. + + Returns: + Lista de dicts con todas las membresias actuales del grupo tras la operacion. + Cada elemento tiene: membership_id, user_id, group_id, is_group_manager. + + Raises: + httpx.HTTPStatusError: 400 si el usuario ya es miembro del grupo. + + Example: + >>> members = metabase_add_membership(client, user_id=5, group_id=3) + >>> print(len(members), "miembros en el grupo") + >>> # Como manager: + >>> members = metabase_add_membership(client, user_id=5, group_id=3, is_group_manager=True) + """ + body = { + "user_id": user_id, + "group_id": group_id, + "is_group_manager": is_group_manager, + } + return client.request("POST", "/api/permissions/membership", json=body) + + +def metabase_delete_membership(client: MetabaseClient, membership_id: int) -> None: + """Elimina una membresía de grupo en Metabase por su membership_id. + + Endpoint: DELETE /api/permissions/membership/:id. Requiere superusuario. + + IMPORTANTE: No se borra por user_id + group_id. Hay que conocer el + membership_id exacto, que se obtiene via metabase_list_memberships. + + Args: + client: Cliente autenticado con permisos admin. + membership_id: ID de la membresía a eliminar (no el user_id ni group_id). + + Raises: + httpx.HTTPStatusError: 404 si la membresía no existe. + + Example: + >>> # Primero obtener el membership_id + >>> all_memberships = metabase_list_memberships(client) + >>> group_members = all_memberships.get("3", []) + >>> membership_id = next(m["membership_id"] for m in group_members if m["user_id"] == 5) + >>> metabase_delete_membership(client, membership_id) + """ + client.request("DELETE", f"/api/permissions/membership/{membership_id}") + + +# --- Data Permission Graph --- + + +def metabase_get_permission_graph(client: MetabaseClient) -> dict: + """Obtiene el grafo de permisos de datos (databases/schemas/tables) de Metabase. + + Endpoint: GET /api/permissions/graph. Requiere superusuario. + + El campo `revision` es CRITICO para concurrency control: el servidor rechaza + PUT /api/permissions/graph si el revision no coincide con el actual (HTTP 409). + Siempre traer el graph fresco antes de modificar. + + Args: + client: Cliente autenticado con permisos admin. + + Returns: + Dict con: + - revision (int): numero de revision actual. Obligatorio para el PUT. + - groups (dict): mapa group_id -> db_id -> permisos. Estructura por db: + - schemas: "all" | "none" | dict de schema -> tabla -> permisos + - native: "write" | "read" | "none" (acceso a SQL nativo) + + Example: + >>> graph = metabase_get_permission_graph(client) + >>> print("revision:", graph["revision"]) + >>> for group_id, dbs in graph["groups"].items(): + ... for db_id, perms in dbs.items(): + ... print(f"group={group_id} db={db_id}: {perms}") + """ + return client.request("GET", "/api/permissions/graph") + + +def metabase_update_permission_graph(client: MetabaseClient, graph: dict) -> dict: + """Actualiza el grafo de permisos de datos en Metabase. + + Endpoint: PUT /api/permissions/graph. Requiere superusuario. + + ## Control de concurrencia por revision + + El campo `graph["revision"]` es obligatorio y debe ser el valor actual del + servidor. Si otro proceso modifico el graph entre tu GET y este PUT, Metabase + devuelve HTTP 409 Conflict. El patron correcto es: + + 1. graph = metabase_get_permission_graph(client) # GET fresco + 2. Modificar graph["groups"][group_id][db_id] = ... # editar en memoria + 3. graph = metabase_update_permission_graph(client, graph) # PUT con revision + + Nunca cachear el graph — siempre hacer GET justo antes del PUT. + + Args: + client: Cliente autenticado con permisos admin. + graph: Dict con el graph completo incluyendo el campo `revision` actual. + Obtenerlo via metabase_get_permission_graph antes de modificar. + + Returns: + Dict con el nuevo graph tras la actualizacion, con `revision` incrementado. + + Raises: + httpx.HTTPStatusError: 409 si el revision en el body no coincide con el actual. + + Example: + >>> graph = metabase_get_permission_graph(client) + >>> # Dar acceso completo al grupo 3 sobre la database 1 + >>> graph["groups"]["3"]["1"] = {"schemas": "all", "native": "write"} + >>> updated = metabase_update_permission_graph(client, graph) + >>> print("nueva revision:", updated["revision"]) + """ + return client.request("PUT", "/api/permissions/graph", json=graph) + + +# --- Collection Permission Graph --- + + +def metabase_get_collection_graph( + client: MetabaseClient, + namespace: str | None = None, +) -> dict: + """Obtiene el grafo de permisos de colecciones de Metabase. + + Endpoint: GET /api/collection/graph. Requiere superusuario. + + El campo `revision` es CRITICO para concurrency control: el servidor rechaza + PUT si el revision no coincide con el actual. + + Args: + client: Cliente autenticado con permisos admin. + namespace: Namespace opcional. "snippets" para snippet collections. + None = colecciones regulares. + + Returns: + Dict con: + - revision (int): numero de revision actual. Obligatorio para el PUT. + - groups (dict): mapa group_id -> collection_id -> nivel de acceso. + Nivel de acceso: "read" | "write" | "none". + + Example: + >>> graph = metabase_get_collection_graph(client) + >>> print("revision:", graph["revision"]) + >>> for group_id, colls in graph["groups"].items(): + ... for coll_id, access in colls.items(): + ... print(f"group={group_id} collection={coll_id}: {access}") + >>> # Snippet collections: + >>> snippet_graph = metabase_get_collection_graph(client, namespace="snippets") + """ + params = {} + if namespace is not None: + params["namespace"] = namespace + return client.request("GET", "/api/collection/graph", params=params or None) + + +def metabase_update_collection_graph( + client: MetabaseClient, + graph: dict, + namespace: str | None = None, +) -> dict: + """Actualiza el grafo de permisos de colecciones en Metabase. + + Endpoint: PUT /api/collection/graph. Requiere superusuario. + + ## Control de concurrencia por revision + + El campo `graph["revision"]` es obligatorio y debe ser el valor actual del + servidor. Si otro proceso modifico el graph entre tu GET y este PUT, Metabase + devuelve HTTP 409 Conflict. El patron correcto es: + + 1. graph = metabase_get_collection_graph(client) # GET fresco + 2. Modificar graph["groups"][group_id][collection_id] = ... # editar en memoria + 3. graph = metabase_update_collection_graph(client, graph) # PUT con revision + + Nunca cachear el graph — siempre hacer GET justo antes del PUT. + + Args: + client: Cliente autenticado con permisos admin. + graph: Dict con el graph completo incluyendo el campo `revision` actual. + Obtenerlo via metabase_get_collection_graph antes de modificar. + namespace: Namespace opcional. "snippets" para snippet collections. + None = colecciones regulares. + + Returns: + Dict con el nuevo graph tras la actualizacion, con `revision` incrementado. + + Raises: + httpx.HTTPStatusError: 409 si el revision en el body no coincide con el actual. + + Example: + >>> graph = metabase_get_collection_graph(client) + >>> # Dar acceso write al grupo 3 sobre la coleccion 5 + >>> graph["groups"]["3"]["5"] = "write" + >>> updated = metabase_update_collection_graph(client, graph) + >>> print("nueva revision:", updated["revision"]) + >>> # Para snippet collections: + >>> graph = metabase_get_collection_graph(client, namespace="snippets") + >>> graph["groups"]["3"]["root"] = "write" + >>> updated = metabase_update_collection_graph(client, graph, namespace="snippets") + """ + params = {} + if namespace is not None: + params["namespace"] = namespace + return client.request("PUT", "/api/collection/graph", json=graph, params=params or None) diff --git a/python/functions/metabase/test_metabase_mbql_validate.py b/python/functions/metabase/test_metabase_mbql_validate.py new file mode 100644 index 00000000..35f341de --- /dev/null +++ b/python/functions/metabase/test_metabase_mbql_validate.py @@ -0,0 +1,275 @@ +"""Tests para metabase_mbql_validate.""" + +import sys +import os + +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..")) + +from metabase.metabase_mbql_validate import metabase_mbql_validate + + +# --------------------------------------------------------------------------- +# DQ valido: estructura simplificada basada en card 5705 post-fix +# --------------------------------------------------------------------------- + +VALID_DQ = { + "lib/type": "mbql/query", + "database": 6, + "stages": [ + { + "lib/type": "mbql.stage/mbql", + "source-card": 100, + "aggregation": [ + [ + "sum", + {"lib/uuid": "agg-uuid-1"}, + [ + "field", + {"base-type": "type/Decimal", "lib/uuid": "field-uuid-1"}, + "Cantidad", + ], + ] + ], + "expressions": [ + [ + "-", + { + "lib/uuid": "expr-uuid-1", + "lib/expression-name": "Diferencia_Cantidad", + }, + [ + "field", + {"base-type": "type/Decimal", "lib/uuid": "field-uuid-2"}, + "Valor_A", + ], + [ + "field", + {"base-type": "type/Decimal", "lib/uuid": "field-uuid-3"}, + "Valor_B", + ], + ] + ], + }, + { + "lib/type": "mbql.stage/mbql", + "expressions": [ + [ + "case", + { + "lib/uuid": "case-uuid-1", + "lib/expression-name": "Valor medio de venta", + }, + [ + [ + [ + "=", + {"lib/uuid": "cond-uuid-1"}, + [ + "field", + { + "base-type": "type/Integer", + "lib/uuid": "field-uuid-4", + }, + "Tickets", + ], + 0, + ], + 0, + ], + [ + [ + "!=", + {"lib/uuid": "cond-uuid-2"}, + [ + "field", + { + "base-type": "type/Integer", + "lib/uuid": "field-uuid-5", + }, + "Tickets", + ], + 0, + ], + [ + "/", + {"lib/uuid": "div-uuid-1"}, + [ + "field", + { + "base-type": "type/Decimal", + "lib/uuid": "field-uuid-6", + }, + "Valor_vendido", + ], + [ + "field", + { + "base-type": "type/Integer", + "lib/uuid": "field-uuid-7", + }, + "Tickets", + ], + ], + ], + ], + ] + ], + }, + ], +} + + +def test_valid_dq_returns_no_errors(): + """DQ valido retorna lista vacia.""" + errors = metabase_mbql_validate(VALID_DQ) + assert errors == [], f"Esperaba 0 errores, got: {errors}" + + +# --------------------------------------------------------------------------- +# Check 1: UUID duplicado +# --------------------------------------------------------------------------- + + +def test_duplicate_uuid_detected(): + """UUID repetido en el arbol MBQL genera error.""" + dq = { + "stages": [ + { + "lib/type": "mbql.stage/mbql", + "expressions": [ + [ + "-", + {"lib/uuid": "dup-uuid-001", "lib/expression-name": "ExprA"}, + ["field", {"base-type": "type/Decimal", "lib/uuid": "dup-uuid-001"}, "CampoX"], + ["field", {"base-type": "type/Decimal", "lib/uuid": "unique-uuid-002"}, "CampoY"], + ] + ], + } + ] + } + errors = metabase_mbql_validate(dq) + assert any("dup-uuid-001" in e for e in errors), f"Esperaba error de UUID duplicado, got: {errors}" + + +# --------------------------------------------------------------------------- +# Check 2: Stage mixing — expressions referencian slots de aggregation +# --------------------------------------------------------------------------- + + +def test_stage_mixing_detected(): + """Stage con aggregations y expressions que referencian slots generados.""" + dq = { + "stages": [ + { + "lib/type": "mbql.stage/mbql", + "aggregation": [ + ["sum", {"lib/uuid": "agg1"}, ["field", {"base-type": "type/Decimal", "lib/uuid": "f1"}, "Precio"]] + ], + "expressions": [ + [ + "*", + {"lib/uuid": "expr2", "lib/expression-name": "DobleSum"}, + # field sin base-type y nombre 'sum' = slot ref de aggregation + ["field", {"lib/uuid": "ref-slot"}, "sum"], + 2, + ] + ], + } + ] + } + errors = metabase_mbql_validate(dq) + assert any("mezcla" in e.lower() or "mixing" in e.lower() or "stage" in e.lower() for e in errors), \ + f"Esperaba error de stage mixing, got: {errors}" + + +# --------------------------------------------------------------------------- +# Check 3: Slot ref rota — sum_99 inexistente +# --------------------------------------------------------------------------- + + +def test_broken_slot_ref_detected(): + """Expression que referencia sum_99 cuando solo hay 1 sum genera error.""" + dq = { + "stages": [ + { + "lib/type": "mbql.stage/mbql", + "aggregation": [ + ["sum", {"lib/uuid": "agg-s1"}, ["field", {"base-type": "type/Decimal", "lib/uuid": "f-s1"}, "Precio"]] + ], + "expressions": [ + [ + "+", + {"lib/uuid": "expr-broken", "lib/expression-name": "SumRota"}, + # sum_99 no existe (solo hay 1 sum = sum_0) + ["field", {"lib/uuid": "ref-broken"}, "sum_99"], + 1, + ] + ], + } + ] + } + errors = metabase_mbql_validate(dq) + assert any("sum_99" in e for e in errors), f"Esperaba error de slot ref roto, got: {errors}" + + +# --------------------------------------------------------------------------- +# Check 4: Case con estructura invalida +# --------------------------------------------------------------------------- + + +def test_case_malformed_cases_not_pairs(): + """Case con casos que no son pares [cond, result] genera error.""" + dq = { + "stages": [ + { + "lib/type": "mbql.stage/mbql", + "expressions": [ + [ + "case", + {"lib/uuid": "case-bad", "lib/expression-name": "CaseMalo"}, + # lista con un solo elemento (no par) + [["solo_elemento"]], + ] + ], + } + ] + } + errors = metabase_mbql_validate(dq) + assert any("case" in e.lower() for e in errors), f"Esperaba error de case structure, got: {errors}" + + +# --------------------------------------------------------------------------- +# Check 5: Name collision en expressions +# --------------------------------------------------------------------------- + + +def test_name_collision_detected(): + """Dos expressions con mismo lib/expression-name en la misma stage generan error.""" + dq = { + "stages": [ + { + "lib/type": "mbql.stage/mbql", + "expressions": [ + ["-", {"lib/uuid": "e1", "lib/expression-name": "MiCalculo"}, + ["field", {"base-type": "type/Decimal", "lib/uuid": "f1"}, "A"], + ["field", {"base-type": "type/Decimal", "lib/uuid": "f2"}, "B"]], + ["+", {"lib/uuid": "e2", "lib/expression-name": "MiCalculo"}, + ["field", {"base-type": "type/Decimal", "lib/uuid": "f3"}, "C"], + 1], + ], + } + ] + } + errors = metabase_mbql_validate(dq) + assert any("MiCalculo" in e for e in errors), f"Esperaba error de name collision, got: {errors}" + + +# --------------------------------------------------------------------------- +# Check: stages ausente +# --------------------------------------------------------------------------- + + +def test_missing_stages_returns_error(): + """dataset_query sin 'stages' devuelve error de estructura.""" + errors = metabase_mbql_validate({"database": 1}) + assert any("stages" in e.lower() for e in errors), f"Esperaba error de stages ausente, got: {errors}" diff --git a/python/functions/metabase/validation.py b/python/functions/metabase/validation.py new file mode 100644 index 00000000..9b6e5487 --- /dev/null +++ b/python/functions/metabase/validation.py @@ -0,0 +1,505 @@ +"""Validacion estructural de cards y dashboards de Metabase antes de pusharlos a la API.""" + +import httpx + +from .client import MetabaseClient +from .cards import metabase_execute_query + + +_VALID_DISPLAYS = { + "scalar", "table", "line", "bar", "pie", "area", "row", "funnel", + "smartscalar", "gauge", "progress", "combo", "pivot", "map", "scatter", + "waterfall", "sankey", "object", +} + +_VALID_TYPES = {"question", "model", "metric"} + + +def metabase_validate_card_payload(payload: dict) -> list[str]: + """Valida la estructura de un payload de card antes de enviarlo a Metabase. + + Comprueba invariantes estructurales sin necesidad de red. Recorre todos los + checks y acumula todos los issues en lugar de abortar al primero. + + Args: + payload: dict con los campos de la card a validar (name, display, + dataset_query, type, visualization_settings, parameters, archived). + + Returns: + Lista de strings describiendo cada issue encontrado. Lista vacia = payload valido. + + Example: + >>> issues = metabase_validate_card_payload({"name": "Revenue", "display": "bar", + ... "dataset_query": {"database": 1, "type": "native", + ... "native": {"query": "SELECT 1"}}}) + >>> assert issues == [] + """ + issues: list[str] = [] + + # --- name --- + name = payload.get("name") + if name is None: + issues.append("campo 'name' ausente") + elif not isinstance(name, str) or not name.strip(): + issues.append("campo 'name' debe ser un string no vacio") + + # --- display --- + display = payload.get("display") + if display is None: + issues.append("campo 'display' ausente") + elif display not in _VALID_DISPLAYS: + validos = ", ".join(sorted(_VALID_DISPLAYS)) + issues.append(f"display '{display}' invalido (validos: {validos})") + + # --- type (opcional) --- + card_type = payload.get("type") + if card_type is not None and card_type not in _VALID_TYPES: + validos = ", ".join(sorted(_VALID_TYPES)) + issues.append(f"type '{card_type}' invalido (validos: {validos})") + + # --- dataset_query --- + dq = payload.get("dataset_query") + if dq is None: + issues.append("campo 'dataset_query' ausente") + elif not isinstance(dq, dict): + issues.append("campo 'dataset_query' debe ser un dict") + else: + # database presente + if "database" not in dq: + issues.append("'dataset_query.database' ausente") + + # deteccion de query nativa + query_type = payload.get("query_type") or dq.get("type", "") + is_native = query_type == "native" + + # Tambien chequear si stages[0] tiene clave "native" (formato MBQL5) + stages = dq.get("stages", []) + has_mbql5_native = ( + isinstance(stages, list) + and len(stages) > 0 + and isinstance(stages[0], dict) + and "native" in stages[0] + ) + + if is_native or has_mbql5_native: + # Formato legacy: dataset_query.native.query + legacy_sql = None + native_block = dq.get("native") + if isinstance(native_block, dict): + legacy_sql = native_block.get("query") + + # Formato MBQL5: dataset_query.stages[0].native + mbql5_sql = None + if has_mbql5_native: + mbql5_sql = stages[0].get("native") + + legacy_ok = isinstance(legacy_sql, str) and legacy_sql.strip() + mbql5_ok = isinstance(mbql5_sql, str) and mbql5_sql.strip() + + if not legacy_ok and not mbql5_ok: + issues.append( + "query nativa sin SQL: falta 'dataset_query.native.query' " + "(legacy) o 'dataset_query.stages[0].native' (MBQL5)" + ) + + # --- visualization_settings (opcional) --- + vs = payload.get("visualization_settings") + if vs is not None and not isinstance(vs, dict): + issues.append("'visualization_settings' debe ser un dict") + + # --- parameters (opcional) --- + params = payload.get("parameters") + if params is not None and not isinstance(params, list): + issues.append("'parameters' debe ser una list") + + # --- archived (opcional) --- + archived = payload.get("archived") + if archived is not None and not isinstance(archived, bool): + issues.append("'archived' debe ser bool") + + return issues + + +def metabase_validate_dashboard_payload( + payload: dict, + known_card_ids: set[int], +) -> list[str]: + """Valida la estructura de un payload de dashboard antes de enviarlo a Metabase. + + Verifica campos obligatorios, bounds de dashcards, referencias a cards y + solapamientos entre dashcards. Acumula todos los issues sin abortar. + + Args: + payload: dict con los campos del dashboard (name, dashcards, tabs, parameters). + known_card_ids: conjunto de IDs de cards conocidos; las dashcards con + card_id entero deben referenciar un ID de este conjunto. + + Returns: + Lista de strings describiendo cada issue encontrado. Lista vacia = payload valido. + + Example: + >>> issues = metabase_validate_dashboard_payload( + ... {"name": "KPIs", "dashcards": []}, + ... known_card_ids={1, 2, 3}, + ... ) + >>> assert issues == [] + """ + issues: list[str] = [] + + # --- name --- + name = payload.get("name") + if name is None: + issues.append("campo 'name' ausente") + elif not isinstance(name, str) or not name.strip(): + issues.append("campo 'name' debe ser un string no vacio") + + # --- dashcards (opcional, pero si esta, debe ser list) --- + dashcards = payload.get("dashcards") + if dashcards is not None: + if not isinstance(dashcards, list): + issues.append("'dashcards' debe ser una list") + else: + valid_rects: list[tuple[int, int, int, int, int]] = [] # (idx, row, col, sx, sy) + + for i, dc in enumerate(dashcards): + if not isinstance(dc, dict): + issues.append(f"dashcard[{i}] debe ser un dict") + continue + + # card_id: si es int debe estar en known_card_ids; null es virtual (ok) + card_id = dc.get("card_id") + if card_id is not None: + if not isinstance(card_id, int): + issues.append(f"dashcard[{i}].card_id debe ser int o null") + elif card_id not in known_card_ids: + issues.append( + f"dashcard[{i}].card_id={card_id} no existe en las cards conocidas" + ) + + # Campos de posicion y tamanio + missing = [f for f in ("row", "col", "size_x", "size_y") if f not in dc] + if missing: + issues.append( + f"dashcard[{i}] falta campos de layout: {', '.join(missing)}" + ) + continue + + row = dc["row"] + col = dc["col"] + size_x = dc["size_x"] + size_y = dc["size_y"] + + for fname, val in (("row", row), ("col", col), ("size_x", size_x), ("size_y", size_y)): + if not isinstance(val, int): + issues.append(f"dashcard[{i}].{fname} debe ser int") + + if not isinstance(row, int) or not isinstance(col, int) or \ + not isinstance(size_x, int) or not isinstance(size_y, int): + continue # ya reportado arriba + + # Bounds + if row < 0: + issues.append(f"dashcard[{i}].row={row} debe ser >= 0") + if not 0 <= col <= 23: + issues.append(f"dashcard[{i}].col={col} debe estar en [0, 23]") + if not 1 <= size_x <= 24: + issues.append(f"dashcard[{i}].size_x={size_x} debe estar en [1, 24]") + if not 1 <= size_y <= 100: + issues.append(f"dashcard[{i}].size_y={size_y} debe estar en [1, 100]") + if col + size_x > 24: + issues.append( + f"dashcard[{i}] excede el ancho del grid: col={col} + size_x={size_x} = {col + size_x} > 24" + ) + + valid_rects.append((i, row, col, size_x, size_y)) + + # Deteccion de solapamientos O(n^2) — dashboards tipicos tienen < 50 cards + for a in range(len(valid_rects)): + for b in range(a + 1, len(valid_rects)): + ia, ra, ca, sxa, sya = valid_rects[a] + ib, rb, cb, sxb, syb = valid_rects[b] + + # Rectangulos: [ca, ca+sxa) x [ra, ra+sya) y [cb, cb+sxb) x [rb, rb+syb) + overlap_x = ca < cb + sxb and cb < ca + sxa + overlap_y = ra < rb + syb and rb < ra + sya + + if overlap_x and overlap_y: + issues.append( + f"dashcards en posiciones (row={ra},col={ca},{sxa}x{sya}) " + f"y (row={rb},col={cb},{sxb}x{syb}) solapan" + ) + + # --- tabs (opcional) --- + tabs = payload.get("tabs") + if tabs is not None: + if not isinstance(tabs, list): + issues.append("'tabs' debe ser una list") + else: + for i, tab in enumerate(tabs): + if not isinstance(tab, dict): + issues.append(f"tabs[{i}] debe ser un dict") + continue + if "id" not in tab: + issues.append(f"tabs[{i}] falta campo 'id'") + if "name" not in tab: + issues.append(f"tabs[{i}] falta campo 'name'") + + # --- parameters (opcional) --- + params = payload.get("parameters") + if params is not None and not isinstance(params, list): + issues.append("'parameters' debe ser una list") + + return issues + + +# ---------------------------------------------------------------- Documents + +# Nodos ProseMirror que el editor TipTap de Metabase (v0.59) sabe renderizar. +# Si un documento contiene nodos fuera de esta whitelist, el backend los acepta +# pero el frontend silenciosamente descarta contenido y el doc aparece vacio o +# incompleto. +_VALID_DOC_NODES = { + "doc", "paragraph", "text", "heading", + "bulletList", "orderedList", "listItem", + "blockquote", "codeBlock", "horizontalRule", "hardBreak", + "cardEmbed", "flexContainer", "smartLink", "resizeNode", "mention", +} + +_VALID_DOC_MARKS = {"bold", "italic", "strike", "code", "link"} + +# Rangos de attrs.level en headings +_HEADING_LEVELS = {1, 2, 3, 4, 5, 6} + + +def metabase_validate_document_payload( + payload: dict, + known_card_slugs: set[str] | None = None, +) -> list[str]: + """Valida un payload de document antes de pusharlo a Metabase. + + El editor TipTap de Metabase solo renderiza un subconjunto concreto de + nodos y marks ProseMirror. La API *acepta* cualquier arbol, pero el + frontend silenciosamente descarta lo que no conoce. Este validador + rechaza (como warning) cualquier nodo o mark fuera de la whitelist. + + Comprueba tambien restricciones estructurales: + - `heading.attrs.level` en [1, 6]. + - `flexContainer` solo contiene `cardEmbed` o `supportingText`, + y como maximo 3 hijos. + - `cardEmbed.attrs` debe resolverse a un card real (por `id` o por + `card` slug si el caller pasa `known_card_slugs`). + + Args: + payload: dict del document listo para POST/PUT (con `name` y `document`). + known_card_slugs: set de slugs conocidos en el index (para validar + `cardEmbed.attrs.card`). None = skip check. + + Returns: + Lista de warnings. Lista vacia = payload renderizable. + """ + issues: list[str] = [] + + # --- name --- + name = payload.get("name") + if name is None: + issues.append("campo 'name' ausente") + elif not isinstance(name, str) or not name.strip(): + issues.append("campo 'name' debe ser un string no vacio") + elif len(name) > 254: + issues.append(f"'name' excede 254 chars ({len(name)})") + + # --- archived --- + archived = payload.get("archived") + if archived is not None and not isinstance(archived, bool): + issues.append("'archived' debe ser bool") + + # --- document (arbol ProseMirror) --- + tree = payload.get("document") + if tree is None: + issues.append("campo 'document' ausente") + return issues + if tree == "": + # Document vacio es valido (Metabase lo acepta) + return issues + if not isinstance(tree, dict): + issues.append(f"'document' debe ser dict o string vacia, no {type(tree).__name__}") + return issues + + if tree.get("type") != "doc": + issues.append(f"'document.type' debe ser 'doc', no '{tree.get('type')}'") + + # Walk recursivo acumulando issues con path + _walk_doc_node(tree, "document", issues, known_card_slugs or set()) + + return issues + + +def _walk_doc_node( + node: dict, + path: str, + issues: list[str], + known_card_slugs: set[str], +) -> None: + """Valida un nodo ProseMirror y desciende por sus hijos.""" + if not isinstance(node, dict): + issues.append(f"{path}: nodo no es dict ({type(node).__name__})") + return + + ntype = node.get("type") + if not isinstance(ntype, str): + issues.append(f"{path}: campo 'type' ausente o no string") + return + + if ntype not in _VALID_DOC_NODES: + issues.append( + f"{path}: nodo '{ntype}' no soportado por el editor de Metabase. " + f"Validos: {sorted(_VALID_DOC_NODES)}" + ) + # Seguir igualmente — puede haber issues mas adentro + + # --- Validaciones especificas por tipo --- + attrs = node.get("attrs") or {} + + if ntype == "heading": + level = attrs.get("level") + if level not in _HEADING_LEVELS: + issues.append(f"{path}: heading.level={level!r} debe estar en {sorted(_HEADING_LEVELS)}") + + if ntype == "cardEmbed": + # cardEmbed requiere o bien attrs.id (int) o attrs.card (slug del index) + cid = attrs.get("id") + cslug = attrs.get("card") + if cid is None and cslug is None: + issues.append(f"{path}: cardEmbed sin 'attrs.id' ni 'attrs.card'") + elif cid is not None and not isinstance(cid, int): + issues.append(f"{path}: cardEmbed.attrs.id debe ser int, no {type(cid).__name__}") + elif cslug is not None: + if not isinstance(cslug, str): + issues.append(f"{path}: cardEmbed.attrs.card debe ser string slug") + elif known_card_slugs and cslug not in known_card_slugs: + issues.append( + f"{path}: cardEmbed.attrs.card='{cslug}' no existe en el index " + f"(conocidos: {sorted(known_card_slugs)[:10]}...)" + ) + + if ntype == "flexContainer": + children = node.get("content") or [] + if not isinstance(children, list): + issues.append(f"{path}: flexContainer.content debe ser lista") + else: + if not 1 <= len(children) <= 3: + issues.append( + f"{path}: flexContainer debe tener 1-3 hijos (tiene {len(children)})" + ) + for i, ch in enumerate(children): + ct = ch.get("type") if isinstance(ch, dict) else None + if ct not in ("cardEmbed", "supportingText"): + issues.append( + f"{path}.content[{i}]: flexContainer solo acepta 'cardEmbed' " + f"o 'supportingText' como hijos (tiene '{ct}')" + ) + cw = attrs.get("columnWidths") + if cw is not None: + if not isinstance(cw, list) or not all(isinstance(x, (int, float)) for x in cw): + issues.append(f"{path}: flexContainer.attrs.columnWidths debe ser lista de numeros") + elif isinstance(children, list) and len(cw) != len(children): + issues.append( + f"{path}: columnWidths tiene {len(cw)} valores pero hay {len(children)} hijos" + ) + + if ntype == "smartLink": + # smartLink necesita entityId (id numerico del card en Metabase) + if attrs.get("entityId") is None: + issues.append(f"{path}: smartLink sin 'attrs.entityId'") + + if ntype == "text": + if not isinstance(node.get("text"), str): + issues.append(f"{path}: text sin campo 'text' string") + # Validar marks + marks = node.get("marks") or [] + if not isinstance(marks, list): + issues.append(f"{path}: 'marks' debe ser lista") + else: + for i, m in enumerate(marks): + if not isinstance(m, dict): + issues.append(f"{path}.marks[{i}]: mark no es dict") + continue + mt = m.get("type") + if mt not in _VALID_DOC_MARKS: + issues.append( + f"{path}.marks[{i}]: mark '{mt}' no soportado. " + f"Validos: {sorted(_VALID_DOC_MARKS)}" + ) + + # --- Recursion sobre content --- + content = node.get("content") + if isinstance(content, list): + for i, child in enumerate(content): + _walk_doc_node(child, f"{path}.content[{i}]", issues, known_card_slugs) + + +def metabase_validate_sql( + client: MetabaseClient, + database_id: int, + sql: str, + max_rows: int = 0, +) -> dict: + """Valida sintaxis y referencias de SQL ejecutandolo contra Metabase. + + Ejecuta la query via POST /api/dataset con LIMIT implicito (max_rows=1 si + el caller no especifica nada, para minimizar carga). Captura tanto errores + HTTP como errores embebidos en el body (Metabase a veces devuelve 200 + status failed). + + Args: + client: instancia autenticada de MetabaseClient. + database_id: ID de la base de datos donde ejecutar el SQL. + sql: sentencia SQL a validar (SELECT, WITH, etc.). + max_rows: limite de filas a retornar para la validacion (0 = default de Metabase). + + Returns: + dict con: + ok (bool): True si la query se ejecuto sin errores. + error (str|None): mensaje de error si ok=False, None si ok=True. + rows_returned (int): numero de filas devueltas si ok=True. + + Example: + >>> result = metabase_validate_sql(client, 1, "SELECT id FROM orders LIMIT 1") + >>> if not result["ok"]: + ... print("SQL invalido:", result["error"]) + """ + try: + response = metabase_execute_query(client, database_id, sql, max_rows) + except httpx.HTTPStatusError as exc: + # Intentar extraer mensaje del body JSON de Metabase + error_msg = _extract_metabase_error(exc) + return {"ok": False, "error": error_msg, "rows_returned": 0} + except Exception as exc: + return {"ok": False, "error": str(exc), "rows_returned": 0} + + # Metabase puede devolver 200 con status: "failed" en el body + status = response.get("status") if isinstance(response, dict) else None + if status == "failed": + error_msg = response.get("error") or "query fallida (sin mensaje)" + return {"ok": False, "error": error_msg, "rows_returned": 0} + + # Contar filas retornadas + rows_returned = 0 + if isinstance(response, dict): + data = response.get("data", {}) + if isinstance(data, dict): + rows = data.get("rows", []) + if isinstance(rows, list): + rows_returned = len(rows) + + return {"ok": True, "error": None, "rows_returned": rows_returned} + + +def _extract_metabase_error(exc: httpx.HTTPStatusError) -> str: + """Extrae el mensaje de error legible del response de Metabase.""" + try: + body = exc.response.json() + if isinstance(body, dict): + return body.get("error") or body.get("message") or str(exc) + except Exception: + pass + return str(exc) diff --git a/python/functions/metabase/validation_test.py b/python/functions/metabase/validation_test.py new file mode 100644 index 00000000..7d9b2927 --- /dev/null +++ b/python/functions/metabase/validation_test.py @@ -0,0 +1,389 @@ +"""Tests para metabase_validate_card_payload y metabase_validate_dashboard_payload.""" + +import sys + +sys.path.insert(0, "/home/lucas/fn_registry/python/functions") + +from metabase.validation import ( + metabase_validate_card_payload, + metabase_validate_dashboard_payload, +) + + +# --------------------------------------------------------------------------- +# metabase_validate_card_payload +# --------------------------------------------------------------------------- + +def _base_card() -> dict: + return { + "name": "Revenue by Month", + "display": "line", + "dataset_query": { + "database": 1, + "type": "native", + "native": {"query": "SELECT 1"}, + }, + } + + +def test_card_valido_retorna_lista_vacia(): + issues = metabase_validate_card_payload(_base_card()) + assert issues == [], f"Se esperaba lista vacia, got: {issues}" + + +def test_card_display_invalido(): + payload = _base_card() + payload["display"] = "foobar" + issues = metabase_validate_card_payload(payload) + assert any("display" in i and "foobar" in i for i in issues), issues + + +def test_card_display_ausente(): + payload = _base_card() + del payload["display"] + issues = metabase_validate_card_payload(payload) + assert any("display" in i and "ausente" in i for i in issues), issues + + +def test_card_name_ausente(): + payload = _base_card() + del payload["name"] + issues = metabase_validate_card_payload(payload) + assert any("name" in i and "ausente" in i for i in issues), issues + + +def test_card_name_vacio(): + payload = _base_card() + payload["name"] = " " + issues = metabase_validate_card_payload(payload) + assert any("name" in i for i in issues), issues + + +def test_card_dataset_query_ausente(): + payload = _base_card() + del payload["dataset_query"] + issues = metabase_validate_card_payload(payload) + assert any("dataset_query" in i and "ausente" in i for i in issues), issues + + +def test_card_dataset_query_sin_database(): + payload = _base_card() + del payload["dataset_query"]["database"] + issues = metabase_validate_card_payload(payload) + assert any("database" in i for i in issues), issues + + +def test_card_nativa_sin_sql(): + payload = _base_card() + payload["dataset_query"]["native"]["query"] = "" + issues = metabase_validate_card_payload(payload) + assert any("SQL" in i or "native" in i for i in issues), issues + + +def test_card_nativa_mbql5(): + """Acepta SQL en stages[0].native (formato MBQL5).""" + payload = { + "name": "MBQL5 card", + "display": "table", + "dataset_query": { + "database": 2, + "stages": [{"native": "SELECT 1"}], + }, + } + issues = metabase_validate_card_payload(payload) + assert issues == [], f"Se esperaba lista vacia, got: {issues}" + + +def test_card_type_invalido(): + payload = _base_card() + payload["type"] = "unknown_type" + issues = metabase_validate_card_payload(payload) + assert any("type" in i and "unknown_type" in i for i in issues), issues + + +def test_card_type_valido(): + for t in ("question", "model", "metric"): + payload = _base_card() + payload["type"] = t + issues = metabase_validate_card_payload(payload) + assert issues == [], f"type={t!r} no deberia dar issues: {issues}" + + +def test_card_visualization_settings_no_dict(): + payload = _base_card() + payload["visualization_settings"] = "no es un dict" + issues = metabase_validate_card_payload(payload) + assert any("visualization_settings" in i for i in issues), issues + + +def test_card_parameters_no_list(): + payload = _base_card() + payload["parameters"] = {"not": "a list"} + issues = metabase_validate_card_payload(payload) + assert any("parameters" in i for i in issues), issues + + +def test_card_archived_no_bool(): + payload = _base_card() + payload["archived"] = "yes" + issues = metabase_validate_card_payload(payload) + assert any("archived" in i for i in issues), issues + + +def test_card_acumula_multiples_errores(): + """Un payload con varios errores debe retornar todos los issues, no solo el primero.""" + payload = { + "display": "invalid_display", + "dataset_query": "not a dict", + "archived": "yes", + } + issues = metabase_validate_card_payload(payload) + assert len(issues) >= 3, f"Se esperaban >= 3 issues, got {len(issues)}: {issues}" + + +# --------------------------------------------------------------------------- +# metabase_validate_dashboard_payload +# --------------------------------------------------------------------------- + +def _base_dashboard() -> dict: + return {"name": "My Dashboard"} + + +def test_dashboard_valido_sin_dashcards(): + issues = metabase_validate_dashboard_payload(_base_dashboard(), known_card_ids=set()) + assert issues == [], issues + + +def test_dashboard_valido_con_dashcards(): + payload = { + "name": "KPIs", + "dashcards": [ + {"card_id": 1, "row": 0, "col": 0, "size_x": 6, "size_y": 4}, + {"card_id": 2, "row": 0, "col": 6, "size_x": 6, "size_y": 4}, + ], + } + issues = metabase_validate_dashboard_payload(payload, known_card_ids={1, 2}) + assert issues == [], issues + + +def test_dashboard_card_id_desconocido(): + payload = { + "name": "Dashboard", + "dashcards": [{"card_id": 999, "row": 0, "col": 0, "size_x": 6, "size_y": 4}], + } + issues = metabase_validate_dashboard_payload(payload, known_card_ids={1, 2}) + assert any("999" in i for i in issues), issues + + +def test_dashboard_card_virtual_null_permitido(): + """card_id null = dashcard virtual (texto/heading), siempre permitido.""" + payload = { + "name": "Dashboard", + "dashcards": [{"card_id": None, "row": 0, "col": 0, "size_x": 6, "size_y": 2}], + } + issues = metabase_validate_dashboard_payload(payload, known_card_ids=set()) + assert issues == [], issues + + +def test_dashboard_dashcards_solapadas(): + payload = { + "name": "Dashboard", + "dashcards": [ + {"card_id": 1, "row": 0, "col": 0, "size_x": 6, "size_y": 4}, + {"card_id": 2, "row": 2, "col": 2, "size_x": 4, "size_y": 4}, + ], + } + issues = metabase_validate_dashboard_payload(payload, known_card_ids={1, 2}) + assert any("solapan" in i for i in issues), issues + + +def test_dashboard_dashcards_adyacentes_no_solapan(): + """Dos dashcards que se tocan en el borde NO solapan.""" + payload = { + "name": "Dashboard", + "dashcards": [ + {"card_id": 1, "row": 0, "col": 0, "size_x": 6, "size_y": 4}, + {"card_id": 2, "row": 0, "col": 6, "size_x": 6, "size_y": 4}, + ], + } + issues = metabase_validate_dashboard_payload(payload, known_card_ids={1, 2}) + assert not any("solapan" in i for i in issues), issues + + +def test_dashboard_col_fuera_de_bounds(): + payload = { + "name": "Dashboard", + "dashcards": [{"card_id": 1, "row": 0, "col": 25, "size_x": 1, "size_y": 1}], + } + issues = metabase_validate_dashboard_payload(payload, known_card_ids={1}) + assert any("col" in i for i in issues), issues + + +def test_dashboard_col_mas_size_x_excede_grid(): + payload = { + "name": "Dashboard", + "dashcards": [{"card_id": 1, "row": 0, "col": 20, "size_x": 6, "size_y": 4}], + } + issues = metabase_validate_dashboard_payload(payload, known_card_ids={1}) + assert any("24" in i for i in issues), issues + + +def test_dashboard_size_y_fuera_de_bounds(): + payload = { + "name": "Dashboard", + "dashcards": [{"card_id": 1, "row": 0, "col": 0, "size_x": 6, "size_y": 0}], + } + issues = metabase_validate_dashboard_payload(payload, known_card_ids={1}) + assert any("size_y" in i for i in issues), issues + + +def test_dashboard_name_ausente(): + issues = metabase_validate_dashboard_payload({}, known_card_ids=set()) + assert any("name" in i and "ausente" in i for i in issues), issues + + +def test_dashboard_tabs_invalidos(): + payload = {"name": "Dashboard", "tabs": [{"name": "Tab1"}]} # falta id + issues = metabase_validate_dashboard_payload(payload, known_card_ids=set()) + assert any("id" in i for i in issues), issues + + +def test_dashboard_parameters_no_list(): + payload = {"name": "Dashboard", "parameters": "not a list"} + issues = metabase_validate_dashboard_payload(payload, known_card_ids=set()) + assert any("parameters" in i for i in issues), issues + + +# --------------------------------------------------------------------------- +# metabase_validate_document_payload +# --------------------------------------------------------------------------- + +from metabase.validation import metabase_validate_document_payload + + +def _base_doc(content): + return {"name": "Notas", "document": {"type": "doc", "content": content}} + + +def test_document_valido_minimo(): + issues = metabase_validate_document_payload(_base_doc([ + {"type": "paragraph", "content": [{"type": "text", "text": "hola"}]} + ])) + assert issues == [], issues + + +def test_document_name_ausente(): + issues = metabase_validate_document_payload({"document": {"type": "doc", "content": []}}) + assert any("name" in i and "ausente" in i for i in issues), issues + + +def test_document_nodo_desconocido_callout(): + issues = metabase_validate_document_payload(_base_doc([ + {"type": "callout", "content": [{"type": "paragraph", "content": [{"type": "text", "text": "x"}]}]} + ])) + assert any("callout" in i and "no soportado" in i for i in issues), issues + + +def test_document_nodo_desconocido_taskList(): + issues = metabase_validate_document_payload(_base_doc([ + {"type": "taskList", "content": []} + ])) + assert any("taskList" in i for i in issues), issues + + +def test_document_mark_desconocido_underline(): + issues = metabase_validate_document_payload(_base_doc([ + {"type": "paragraph", "content": [ + {"type": "text", "marks": [{"type": "underline"}], "text": "x"} + ]} + ])) + assert any("underline" in i and "no soportado" in i for i in issues), issues + + +def test_document_heading_level_invalido(): + issues = metabase_validate_document_payload(_base_doc([ + {"type": "heading", "attrs": {"level": 9}, "content": [{"type": "text", "text": "x"}]} + ])) + assert any("level" in i for i in issues), issues + + +def test_document_cardEmbed_sin_id_ni_slug(): + issues = metabase_validate_document_payload(_base_doc([ + {"type": "cardEmbed", "attrs": {}} + ])) + assert any("cardEmbed" in i and "id" in i for i in issues), issues + + +def test_document_cardEmbed_con_id_valido(): + issues = metabase_validate_document_payload(_base_doc([ + {"type": "cardEmbed", "attrs": {"id": 42}} + ])) + assert issues == [], issues + + +def test_document_cardEmbed_slug_desconocido(): + issues = metabase_validate_document_payload( + _base_doc([{"type": "cardEmbed", "attrs": {"card": "inventado"}}]), + known_card_slugs={"real_one", "real_two"}, + ) + assert any("inventado" in i for i in issues), issues + + +def test_document_flexContainer_demasiados_hijos(): + children = [{"type": "cardEmbed", "attrs": {"id": 1}} for _ in range(4)] + issues = metabase_validate_document_payload(_base_doc([ + {"type": "flexContainer", "attrs": {"columnWidths": [25, 25, 25, 25]}, "content": children} + ])) + assert any("1-3" in i for i in issues), issues + + +def test_document_flexContainer_hijo_invalido(): + issues = metabase_validate_document_payload(_base_doc([ + {"type": "flexContainer", "attrs": {"columnWidths": [100]}, "content": [ + {"type": "paragraph", "content": [{"type": "text", "text": "x"}]} + ]} + ])) + assert any("flexContainer solo acepta" in i for i in issues), issues + + +def test_document_flexContainer_columnWidths_mismatch(): + issues = metabase_validate_document_payload(_base_doc([ + {"type": "flexContainer", "attrs": {"columnWidths": [50, 50]}, "content": [ + {"type": "cardEmbed", "attrs": {"id": 1}} + ]} + ])) + assert any("columnWidths" in i for i in issues), issues + + +def test_document_vacio_string_es_valido(): + issues = metabase_validate_document_payload({"name": "vacio", "document": ""}) + assert issues == [], issues + + +def test_document_kitchen_sink_valido(): + """Simula el kitchen_sink real y debe pasar sin issues.""" + content = [ + {"type": "heading", "attrs": {"level": 1}, "content": [{"type": "text", "text": "T"}]}, + {"type": "paragraph", "content": [ + {"type": "text", "text": "a "}, + {"type": "text", "marks": [{"type": "bold"}], "text": "b"}, + {"type": "text", "marks": [{"type": "link", "attrs": {"href": "https://x"}}], "text": "l"}, + ]}, + {"type": "bulletList", "content": [ + {"type": "listItem", "content": [ + {"type": "paragraph", "content": [{"type": "text", "text": "i"}]} + ]} + ]}, + {"type": "blockquote", "content": [ + {"type": "paragraph", "content": [{"type": "text", "text": "q"}]} + ]}, + {"type": "codeBlock", "attrs": {"language": "py"}, "content": [{"type": "text", "text": "x=1"}]}, + {"type": "horizontalRule"}, + {"type": "cardEmbed", "attrs": {"id": 1}}, + {"type": "flexContainer", "attrs": {"columnWidths": [50, 50]}, "content": [ + {"type": "cardEmbed", "attrs": {"id": 1}}, + {"type": "cardEmbed", "attrs": {"id": 2}}, + ]}, + ] + issues = metabase_validate_document_payload(_base_doc(content)) + assert issues == [], issues diff --git a/python/pyproject.toml b/python/pyproject.toml index 16318a86..d25c4720 100644 --- a/python/pyproject.toml +++ b/python/pyproject.toml @@ -13,6 +13,7 @@ dependencies = [ "openpyxl>=3.1.5", "pypdf>=6.10.0", "python-docx>=1.2.0", + "pyyaml>=6.0.3", "xlrd>=2.0.2", ] diff --git a/python/uv.lock b/python/uv.lock index 71ffb998..087b9eae 100644 --- a/python/uv.lock +++ b/python/uv.lock @@ -252,6 +252,7 @@ dependencies = [ { name = "openpyxl" }, { name = "pypdf" }, { name = "python-docx" }, + { name = "pyyaml" }, { name = "xlrd" }, ] @@ -270,6 +271,7 @@ requires-dist = [ { name = "openpyxl", specifier = ">=3.1.5" }, { name = "pypdf", specifier = ">=6.10.0" }, { name = "python-docx", specifier = ">=1.2.0" }, + { name = "pyyaml", specifier = ">=6.0.3" }, { name = "xlrd", specifier = ">=2.0.2" }, ] @@ -865,6 +867,52 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d0/00/1e03a4989fa5795da308cd774f05b704ace555a70f9bf9d3be057b680bcf/python_docx-1.2.0-py3-none-any.whl", hash = "sha256:3fd478f3250fbbbfd3b94fe1e985955737c145627498896a8a6bf81f4baf66c7", size = 252987, upload-time = "2025-06-16T20:46:22.506Z" }, ] +[[package]] +name = "pyyaml" +version = "6.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/33/422b98d2195232ca1826284a76852ad5a86fe23e31b009c9886b2d0fb8b2/pyyaml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196", size = 182063, upload-time = "2025-09-25T21:32:11.445Z" }, + { url = "https://files.pythonhosted.org/packages/89/a0/6cf41a19a1f2f3feab0e9c0b74134aa2ce6849093d5517a0c550fe37a648/pyyaml-6.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0", size = 173973, upload-time = "2025-09-25T21:32:12.492Z" }, + { url = "https://files.pythonhosted.org/packages/ed/23/7a778b6bd0b9a8039df8b1b1d80e2e2ad78aa04171592c8a5c43a56a6af4/pyyaml-6.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28", size = 775116, upload-time = "2025-09-25T21:32:13.652Z" }, + { url = "https://files.pythonhosted.org/packages/65/30/d7353c338e12baef4ecc1b09e877c1970bd3382789c159b4f89d6a70dc09/pyyaml-6.0.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c", size = 844011, upload-time = "2025-09-25T21:32:15.21Z" }, + { url = "https://files.pythonhosted.org/packages/8b/9d/b3589d3877982d4f2329302ef98a8026e7f4443c765c46cfecc8858c6b4b/pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc", size = 807870, upload-time = "2025-09-25T21:32:16.431Z" }, + { url = "https://files.pythonhosted.org/packages/05/c0/b3be26a015601b822b97d9149ff8cb5ead58c66f981e04fedf4e762f4bd4/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e", size = 761089, upload-time = "2025-09-25T21:32:17.56Z" }, + { url = "https://files.pythonhosted.org/packages/be/8e/98435a21d1d4b46590d5459a22d88128103f8da4c2d4cb8f14f2a96504e1/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea", size = 790181, upload-time = "2025-09-25T21:32:18.834Z" }, + { url = "https://files.pythonhosted.org/packages/74/93/7baea19427dcfbe1e5a372d81473250b379f04b1bd3c4c5ff825e2327202/pyyaml-6.0.3-cp312-cp312-win32.whl", hash = "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5", size = 137658, upload-time = "2025-09-25T21:32:20.209Z" }, + { url = "https://files.pythonhosted.org/packages/86/bf/899e81e4cce32febab4fb42bb97dcdf66bc135272882d1987881a4b519e9/pyyaml-6.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b", size = 154003, upload-time = "2025-09-25T21:32:21.167Z" }, + { url = "https://files.pythonhosted.org/packages/1a/08/67bd04656199bbb51dbed1439b7f27601dfb576fb864099c7ef0c3e55531/pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd", size = 140344, upload-time = "2025-09-25T21:32:22.617Z" }, + { url = "https://files.pythonhosted.org/packages/d1/11/0fd08f8192109f7169db964b5707a2f1e8b745d4e239b784a5a1dd80d1db/pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8", size = 181669, upload-time = "2025-09-25T21:32:23.673Z" }, + { url = "https://files.pythonhosted.org/packages/b1/16/95309993f1d3748cd644e02e38b75d50cbc0d9561d21f390a76242ce073f/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1", size = 173252, upload-time = "2025-09-25T21:32:25.149Z" }, + { url = "https://files.pythonhosted.org/packages/50/31/b20f376d3f810b9b2371e72ef5adb33879b25edb7a6d072cb7ca0c486398/pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c", size = 767081, upload-time = "2025-09-25T21:32:26.575Z" }, + { url = "https://files.pythonhosted.org/packages/49/1e/a55ca81e949270d5d4432fbbd19dfea5321eda7c41a849d443dc92fd1ff7/pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5", size = 841159, upload-time = "2025-09-25T21:32:27.727Z" }, + { url = "https://files.pythonhosted.org/packages/74/27/e5b8f34d02d9995b80abcef563ea1f8b56d20134d8f4e5e81733b1feceb2/pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6", size = 801626, upload-time = "2025-09-25T21:32:28.878Z" }, + { url = "https://files.pythonhosted.org/packages/f9/11/ba845c23988798f40e52ba45f34849aa8a1f2d4af4b798588010792ebad6/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6", size = 753613, upload-time = "2025-09-25T21:32:30.178Z" }, + { url = "https://files.pythonhosted.org/packages/3d/e0/7966e1a7bfc0a45bf0a7fb6b98ea03fc9b8d84fa7f2229e9659680b69ee3/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be", size = 794115, upload-time = "2025-09-25T21:32:31.353Z" }, + { url = "https://files.pythonhosted.org/packages/de/94/980b50a6531b3019e45ddeada0626d45fa85cbe22300844a7983285bed3b/pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26", size = 137427, upload-time = "2025-09-25T21:32:32.58Z" }, + { url = "https://files.pythonhosted.org/packages/97/c9/39d5b874e8b28845e4ec2202b5da735d0199dbe5b8fb85f91398814a9a46/pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c", size = 154090, upload-time = "2025-09-25T21:32:33.659Z" }, + { url = "https://files.pythonhosted.org/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", size = 140246, upload-time = "2025-09-25T21:32:34.663Z" }, + { url = "https://files.pythonhosted.org/packages/9d/8c/f4bd7f6465179953d3ac9bc44ac1a8a3e6122cf8ada906b4f96c60172d43/pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac", size = 181814, upload-time = "2025-09-25T21:32:35.712Z" }, + { url = "https://files.pythonhosted.org/packages/bd/9c/4d95bb87eb2063d20db7b60faa3840c1b18025517ae857371c4dd55a6b3a/pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310", size = 173809, upload-time = "2025-09-25T21:32:36.789Z" }, + { url = "https://files.pythonhosted.org/packages/92/b5/47e807c2623074914e29dabd16cbbdd4bf5e9b2db9f8090fa64411fc5382/pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7", size = 766454, upload-time = "2025-09-25T21:32:37.966Z" }, + { url = "https://files.pythonhosted.org/packages/02/9e/e5e9b168be58564121efb3de6859c452fccde0ab093d8438905899a3a483/pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788", size = 836355, upload-time = "2025-09-25T21:32:39.178Z" }, + { url = "https://files.pythonhosted.org/packages/88/f9/16491d7ed2a919954993e48aa941b200f38040928474c9e85ea9e64222c3/pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5", size = 794175, upload-time = "2025-09-25T21:32:40.865Z" }, + { url = "https://files.pythonhosted.org/packages/dd/3f/5989debef34dc6397317802b527dbbafb2b4760878a53d4166579111411e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764", size = 755228, upload-time = "2025-09-25T21:32:42.084Z" }, + { url = "https://files.pythonhosted.org/packages/d7/ce/af88a49043cd2e265be63d083fc75b27b6ed062f5f9fd6cdc223ad62f03e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35", size = 789194, upload-time = "2025-09-25T21:32:43.362Z" }, + { url = "https://files.pythonhosted.org/packages/23/20/bb6982b26a40bb43951265ba29d4c246ef0ff59c9fdcdf0ed04e0687de4d/pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac", size = 156429, upload-time = "2025-09-25T21:32:57.844Z" }, + { url = "https://files.pythonhosted.org/packages/f4/f4/a4541072bb9422c8a883ab55255f918fa378ecf083f5b85e87fc2b4eda1b/pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3", size = 143912, upload-time = "2025-09-25T21:32:59.247Z" }, + { url = "https://files.pythonhosted.org/packages/7c/f9/07dd09ae774e4616edf6cda684ee78f97777bdd15847253637a6f052a62f/pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3", size = 189108, upload-time = "2025-09-25T21:32:44.377Z" }, + { url = "https://files.pythonhosted.org/packages/4e/78/8d08c9fb7ce09ad8c38ad533c1191cf27f7ae1effe5bb9400a46d9437fcf/pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba", size = 183641, upload-time = "2025-09-25T21:32:45.407Z" }, + { url = "https://files.pythonhosted.org/packages/7b/5b/3babb19104a46945cf816d047db2788bcaf8c94527a805610b0289a01c6b/pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c", size = 831901, upload-time = "2025-09-25T21:32:48.83Z" }, + { url = "https://files.pythonhosted.org/packages/8b/cc/dff0684d8dc44da4d22a13f35f073d558c268780ce3c6ba1b87055bb0b87/pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702", size = 861132, upload-time = "2025-09-25T21:32:50.149Z" }, + { url = "https://files.pythonhosted.org/packages/b1/5e/f77dc6b9036943e285ba76b49e118d9ea929885becb0a29ba8a7c75e29fe/pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c", size = 839261, upload-time = "2025-09-25T21:32:51.808Z" }, + { url = "https://files.pythonhosted.org/packages/ce/88/a9db1376aa2a228197c58b37302f284b5617f56a5d959fd1763fb1675ce6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065", size = 805272, upload-time = "2025-09-25T21:32:52.941Z" }, + { url = "https://files.pythonhosted.org/packages/da/92/1446574745d74df0c92e6aa4a7b0b3130706a4142b2d1a5869f2eaa423c6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65", size = 829923, upload-time = "2025-09-25T21:32:54.537Z" }, + { url = "https://files.pythonhosted.org/packages/f0/7a/1c7270340330e575b92f397352af856a8c06f230aa3e76f86b39d01b416a/pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9", size = 174062, upload-time = "2025-09-25T21:32:55.767Z" }, + { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" }, +] + [[package]] name = "requests" version = "2.33.1"