# functions

id: metabase_copy_document_py_infra
name: metabase_copy_document
kind: function
lang: py
domain: infra
version: 1.0.0
purity: impure
description: Copia un document usando el endpoint nativo POST /api/document/:id/copy. Un solo request HTTP.
tags: ["metabase","document","copy","clone","api","python"]
uses_functions: []
uses_types: []
returns: []
error_type: error_go_core
imports: ["httpx"]
tests: []
file_path: python/functions/metabase/documents.py
created_at: 2026-04-13T13:34:41Z
updated_at: 2026-04-14T15:24:30Z
props: []
emits: []
variant: []
content_hash: 415ae8c800f6884ac6548afcec9ca91a1e1ea0245712b5cb96f51f09dae0244c
signature:
  def metabase_copy_document(client: MetabaseClient, document_id: int, name: str | None = None, collection_id: int | None = None) -> dict

example:
  ```python
  # Copia con nombre personalizado a otra coleccion
  copy = metabase_copy_document(client, 42, name="Backup Q1", collection_id=5)
  print(copy["id"])  # ID del nuevo document

  # Copia a la misma coleccion con nombre automatico de Metabase
  copy = metabase_copy_document(client, 42)
  print(copy["name"])  # nombre asignado por Metabase
  ```

notes:
  Usa el endpoint nativo `POST /api/document/:id/copy` — un solo request HTTP.

  Metabase asigna el nombre automaticamente si no se especifica `name`. El contenido
  ProseMirror (incluyendo cardEmbeds) se copia tal cual; las cards embebidas no se duplican.

code:
  """CRUD de documents de Metabase (feature reciente, Metabase 0.57+).

  Un document es un texto editable en Metabase (tipo Notion) serializado como
  ProseMirror JSON (content_type: application/json+vnd.prose-mirror). Permite
  embeber cards, smart links y flex containers.

  NODOS QUE RENDERIZAN (whitelist TipTap v0.59):
    doc, paragraph, text, heading (level 1-6),
    bulletList, orderedList, listItem,
    blockquote, codeBlock (attrs.language), horizontalRule, hardBreak,
    cardEmbed (attrs.id), flexContainer, smartLink (attrs.entityId),
    resizeNode, mention.

  NODOS QUE LA API ACEPTA PERO EL FRONTEND IGNORA (doc queda vacio):
    callout, taskList, taskItem, details, table, tableRow, tableCell,
    image, iframe.

  MARKS QUE RENDERIZAN: bold, italic, strike, code, link (attrs.href).
  MARKS IGNORADOS: underline, highlight, subscript, textStyle.

  IMPORTANTE — cardEmbed:
    Un cardEmbed desnudo renderiza pero queda muy pequeño (alto ~50px).
    Para que se vea correctamente, envolver en un resizeNode:

      {"type": "resizeNode",
       "attrs": {"height": 400, "minHeight": 280},
       "content": [
           {"type": "cardEmbed", "attrs": {"id": <card_id>}}
       ]}

    Usar el helper `prosemirror_card_embed(card_id)` para generar esto
    automaticamente.
  """

  import uuid

  from .client import MetabaseClient


  def prosemirror_card_embed(card_id: int, height: int = 400) -> dict:
      """Genera un nodo cardEmbed envuelto en resizeNode listo para ProseMirror.

      Un cardEmbed desnudo renderiza pero queda muy pequeño (~50px). Metabase
      espera que vaya dentro de un resizeNode con height/minHeight para que
      se vea con tamaño adecuado.

      Args:
          card_id: ID de la card/pregunta de Metabase a embeber.
          height: Altura en pixeles del embed (default 400). minHeight se
                  fija en 280 (lo que usa la UI de Metabase).

      Returns:
          Dict ProseMirror: resizeNode > cardEmbed, insertable directamente
          en el array content de un document.

      Example:
          >>> node = prosemirror_card_embed(7711, height=500)
          >>> doc = {"type": "doc", "content": [
          ...     {"type": "heading", "attrs": {"level": 1},
          ...      "content": [{"type": "text", "text": "Mi reporte"}]},
          ...     node,
          ... ]}
      """
      return {
          "type": "resizeNode",
          "attrs": {"height": height, "minHeight": 280},
          "content": [
              {
                  "type": "cardEmbed",
                  "attrs": {"id": card_id, "name": None, "_id": str(uuid.uuid4())},
              }
          ],
      }


  def _validate_before_send(name: str, document: dict | str) -> None:
      """Valida el payload del document antes de enviarlo. Raises ValueError."""
      if not document or document == "":
          return
      if not isinstance(document, dict):
          return
      from .validation import metabase_validate_document_payload

      issues = metabase_validate_document_payload({"name": name, "document": document})
      if issues:
          raise ValueError(
              f"Document no renderizará correctamente en Metabase "
              f"({len(issues)} issues):\n" + "\n".join(f"  - {i}" for i in issues)
          )


  def metabase_list_documents(client: MetabaseClient) -> list[dict]:
      """Lista documents de Metabase.

      Endpoint: GET /api/document. Retorna `{"items": [...]}` segun la API.

      Args:
          client: Cliente autenticado.

      Returns:
          Lista de dicts con: id, name, collection_id, archived, created_at,
          updated_at, creator_id, content_type, entity_id.

      Example:
          >>> docs = metabase_list_documents(client)
          >>> for d in docs:
          ...     print(d["id"], d["name"])
      """
      resp = client.request("GET", "/api/document")
      if isinstance(resp, dict) and "items" in resp:
          return resp["items"]
      return resp or []


  def metabase_get_document(client: MetabaseClient, document_id: int) -> dict:
      """Obtiene un document completo con su contenido ProseMirror.

      Endpoint: GET /api/document/:id.

      Args:
          client: Cliente autenticado.
          document_id: ID del document.

      Returns:
          Dict con: id, name, document (ProseMirror tree), collection_id,
          archived, creator, created_at, updated_at, entity_id, content_type.

      Example:
          >>> doc = metabase_get_document(client, 1)
          >>> print(doc["name"])
          >>> tree = doc["document"]  # {"type": "doc", "content": [...]}
      """
      return client.request("GET", f"/api/document/{document_id}")


  def metabase_create_document(
      client: MetabaseClient,
      name: str,
      document: dict,
      collection_id: int = 0,
      *,
      validate: bool = True,
  ) -> dict:
      """Crea un document nuevo.

      Endpoint: POST /api/document.

      Valida el arbol ProseMirror ANTES de enviar (por defecto). Si el
      documento contiene nodos que la API acepta pero el frontend ignora
      (callout, taskList, image, etc.), lanza ValueError con los issues.
      Pasar validate=False para desactivar (uso bajo tu riesgo).

      Para embeber cards, usar prosemirror_card_embed(card_id) que genera
      el nodo resizeNode > cardEmbed con la altura correcta.

      Args:
          client: Cliente autenticado.
          name: Titulo del document (1-254 chars, no blank).
          document: Arbol ProseMirror JSON. Formato minimo:
              {"type": "doc", "content": [{"type": "paragraph", "content": [...]}]}
              O cadena vacia "" si se quiere arrancar en blanco.
          collection_id: ID de coleccion destino. 0 = root.
          validate: Si True (default), valida el ProseMirror antes de enviar.

      Returns:
          Document creado con su id asignado.

      Raises:
          ValueError: Si validate=True y el arbol ProseMirror contiene
              nodos o marks que el frontend de Metabase no renderiza.

      Example:
          >>> from metabase.documents import prosemirror_card_embed
          >>> doc = metabase_create_document(client, "Reporte", {
          ...     "type": "doc",
          ...     "content": [
          ...         {"type": "heading", "attrs": {"level": 1},
          ...          "content": [{"type": "text", "text": "KPIs"}]},
          ...         prosemirror_card_embed(42, height=450),
          ...     ]
          ... })
      """
      if validate:
          _validate_before_send(name, document)
      body: dict = {"name": name, "document": document}
      if collection_id > 0:
          body["collection_id"] = collection_id
      return client.request("POST", "/api/document", json=body)


  def metabase_update_document(
      client: MetabaseClient,
      document_id: int,
      *,
      validate: bool = True,
      **fields,
  ) -> dict:
      """Actualiza un document. Solo se envian los campos pasados.

      Endpoint: PUT /api/document/:id.

      Si se pasa el campo 'document', valida el arbol ProseMirror antes de
      enviar (por defecto). Pasar validate=False para desactivar.

      Campos tipicos: name, document, collection_id, archived.

      Args:
          client: Cliente autenticado.
          document_id: ID del document a actualizar.
          validate: Si True (default), valida el ProseMirror antes de enviar.
          **fields: Campos a modificar.

      Returns:
          Document actualizado.

      Raises:
          ValueError: Si validate=True y el campo 'document' contiene
              nodos o marks no soportados por el frontend de Metabase.

      Example:
          >>> metabase_update_document(client, 1, name="Nuevo titulo")
          >>> metabase_update_document(client, 1, document={"type":"doc","content":[...]})
      """
      if validate and "document" in fields:
          name = fields.get("name", f"document_{document_id}")
          _validate_before_send(name, fields["document"])
      return client.request("PUT", f"/api/document/{document_id}", json=fields)


  def metabase_archive_document(client: MetabaseClient, document_id: int) -> dict:
      """Archiva un document (equivalente a PUT con archived=True).

      Metabase exige archive previo para poder eliminar.

      Args:
          client: Cliente autenticado.
          document_id: ID del document.

      Returns:
          Document con archived=True.
      """
      return client.request("PUT", f"/api/document/{document_id}", json={"archived": True})


  def metabase_list_document_comments(
      client: MetabaseClient,
      document_id: int,
      *,
      include_resolved: bool = True,
      include_deleted: bool = False,
  ) -> list[dict]:
      """Lista los comentarios de un document.

      Endpoint: GET /api/comment?target_type=document&target_id=:id.

      Un comentario puede estar anclado a un bloque especifico via
      `child_target_id` (UUID que matchea `attrs._id` de un parrafo) o al
      document entero si `child_target_id` es null. Las respuestas tipo thread
      cuelgan via `parent_comment_id`.

      Args:
          client: Cliente autenticado.
          document_id: ID del document.
          include_resolved: si False, filtra comentarios con is_resolved=True.
          include_deleted: si False, filtra comentarios con deleted_at no null.

      Returns:
          Lista de dicts con: id, content (arbol ProseMirror), creator, creator_id,
          target_type, target_id, child_target_id, parent_comment_id, is_resolved,
          deleted_at, reactions, created_at, updated_at.

      Example:
          >>> comments = metabase_list_document_comments(client, 29)
          >>> for c in comments:
          ...     text = _comment_plaintext(c["content"])
          ...     print(f'{c["creator"]["common_name"]}: {text}')
      """
      resp = client.request(
          "GET",
          "/api/comment",
          params={"target_type": "document", "target_id": document_id},
      )
      items = resp.get("comments", []) if isinstance(resp, dict) else []
      if not include_resolved:
          items = [c for c in items if not c.get("is_resolved")]
      if not include_deleted:
          items = [c for c in items if c.get("deleted_at") is None]
      return items


  def metabase_create_document_comment(
      client: MetabaseClient,
      document_id: int,
      content: dict,
      *,
      child_target_id: str | None = None,
      parent_comment_id: int | None = None,
  ) -> dict:
      """Crea un comentario en un document.

      Endpoint: POST /api/comment.

      Args:
          client: Cliente autenticado.
          document_id: ID del document donde comentar.
          content: Arbol ProseMirror del comentario. Ejemplo minimo:
              {"type":"doc","content":[{"type":"paragraph","content":[
                  {"type":"text","text":"mi comentario"}]}]}
          child_target_id: UUID del bloque del document al que se ancla el
              comentario (matchea `attrs._id` de un parrafo). None = a nivel doc.
          parent_comment_id: id del comentario al que se responde. None = top-level.

      Returns:
          Comentario creado.
      """
      body: dict = {
          "target_type": "document",
          "target_id": document_id,
          "content": content,
      }
      if child_target_id is not None:
          body["child_target_id"] = child_target_id
      if parent_comment_id is not None:
          body["parent_comment_id"] = parent_comment_id
      return client.request("POST", "/api/comment", json=body)


  def metabase_resolve_document_comment(client: MetabaseClient, comment_id: int) -> dict:
      """Marca un comentario como resuelto (is_resolved=True).

      Endpoint: PUT /api/comment/:id.
      """
      return client.request("PUT", f"/api/comment/{comment_id}", json={"is_resolved": True})


  def metabase_delete_document(client: MetabaseClient, document_id: int) -> None:
      """Elimina un document. REQUIERE archivarlo primero.

      Endpoint: DELETE /api/document/:id. Si el document no esta archivado,
      Metabase responde: "Document must be archived before it can be deleted."

      Args:
          client: Cliente autenticado.
          document_id: ID del document a eliminar.
      """
      client.request("DELETE", f"/api/document/{document_id}")


  def metabase_move_document(
      client: MetabaseClient,
      document_id: int,
      collection_id: int | None,
  ) -> dict:
      """Mueve un document a otra coleccion.

      Thin wrapper sobre metabase_update_document: envia solo collection_id.
      Endpoint: PUT /api/document/:id con {collection_id: ...}.

      Args:
          client: Cliente autenticado.
          document_id: ID del document a mover.
          collection_id: ID de coleccion destino. None mueve a la raiz ("Our analytics").

      Returns:
          Document actualizado con el nuevo collection_id.

      Example:
          >>> doc = metabase_move_document(client, 42, collection_id=7)
          >>> print(doc["collection_id"])  # 7
          >>> # Mover a raiz:
          >>> doc = metabase_move_document(client, 42, collection_id=None)
      """
      return client.request(
          "PUT",
          f"/api/document/{document_id}",
          json={"collection_id": collection_id},
      )


  def metabase_copy_document(
      client: MetabaseClient,
      document_id: int,
      name: str | None = None,
      collection_id: int | None = None,
  ) -> dict:
      """Copia un document usando el endpoint nativo de Metabase.

      Endpoint: POST /api/document/:id/copy.

      Args:
          client: Cliente autenticado.
          document_id: ID del document a copiar.
          name: Nombre del nuevo document. None = Metabase asigna nombre automatico.
          collection_id: Coleccion destino. None = misma coleccion del original.

      Returns:
          Document nuevo recien creado con su id asignado.

      Example:
          >>> copy = metabase_copy_document(client, 42, name="Backup Q1", collection_id=5)
          >>> print(copy["id"])
      """
      body: dict = {}
      if name is not None:
          body["name"] = name
      if collection_id is not None:
          body["collection_id"] = collection_id
      return client.request("POST", f"/api/document/{document_id}/copy", json=body)

params_schema:
  {"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 = Metabase asigna nombre automatico"},{"name":"collection_id","desc":"coleccion destino; None copia a la misma coleccion del original"}],"output":"dict: document nuevo recien creado con id asignado y metadata completa"}


id: metabase_create_card_py_infra
name: metabase_create_card
kind: function
lang: py
domain: infra
version: 1.0.0
purity: impure
description: Crea una card/pregunta en Metabase con query SQL nativa o MBQL. Endpoint: POST /api/card.
tags: ["metabase","card","question","create","api","python"]
uses_functions: []
uses_types: []
returns: []
error_type: error_go_core
imports: ["httpx"]
tests: []
file_path: python/functions/metabase/cards.py
created_at: 2026-04-05T11:22:32Z
updated_at: 2026-04-14T15:24:30Z
props: []
emits: []
variant: []
content_hash: 7b0969ea4b92c2c04b2ec6787a276c5431fa892a5ce1f1269e02949e83999c07
signature:
  def metabase_create_card(client: MetabaseClient, name: str, dataset_query: dict, display: str = 'table', collection_id: int = 0, description: str = '') -> dict

example:
  ```python
  card = metabase_create_card(client, "Revenue filtrable", {
      "database": 6, "type": "native",
      "native": {
          "query": "SELECT * FROM orders WHERE 1=1 [[AND date >= {{fecha_desde}}]]",
          "template-tags": {
              "fecha_desde": {
                  "name": "fecha_desde",
                  "display-name": "Fecha desde",
                  "id": "fecha_desde_tag",
                  "type": "text"
              }
          }
      },
  }, display="table")
  ```

notes:
  - `display` válidos: scalar, table, line, bar, pie, area, row, funnel, combo, pivot, map, scatter, waterfall, gauge, progress, smartscalar, sankey.
  - Para queries que se repiten como base de otras cards, considerar crear un "model" (type="model") y referenciar desde otras cards con `source-table: "card__<id>"`.

documentation:
  ## Formato dataset_query — IMPORTANTE

  Hay DOS formatos y hay que saber cuál usar:

  **Para CREAR cards (POST /api/card) — formato legacy:**
  ```python
  {"database": id, "type": "native", "native": {"query": "SELECT ..."}}
  ```

  **Lo que DEVUELVE Metabase al LEER cards (GET /api/card/:id) — formato MBQL5:**
  ```python
  {"lib/type": "mbql/query", "database": id,
   "stages": [{"lib/type": "mbql.stage/native", "native": "SELECT ..."}]}
  ```

  Son representaciones distintas de lo mismo. Al leer una card existente y querer extraer el SQL:
  ```python
  card = metabase_get_card(client, 42)
  ds = card["dataset_query"]

  # Formato MBQL5 (Metabase reciente)
  stages = ds.get("stages", [])
  if stages:
      sql = stages[0].get("native", "")

  # Formato legacy
  else:
      sql = ds.get("native", {}).get("query", "")
  ```

  Al crear/actualizar cards, usar SIEMPRE el formato legacy — Metabase lo acepta y lo convierte internamente.

code:
  """CRUD de cards/preguntas de Metabase y ejecucion de queries."""

  from .client import MetabaseClient


  def metabase_list_cards(
      client: MetabaseClient,
      filter: str = "",
      model_id: int = 0,
  ) -> list[dict]:
      """Lista preguntas/cards de Metabase con filtro opcional.

      Endpoint: GET /api/card. No tiene paginacion offset/limit.

      Args:
          client: Cliente autenticado.
          filter: "all", "mine", "fav", "archived", "recent", "popular",
                  "database", "table". Vacio = todas.
          model_id: ID de database/tabla. Solo aplica con filter "database" o "table".

      Returns:
          Lista de dicts, cada uno con: id, name, description, display,
          collection_id, database_id, creator_id, archived, dataset_query.

      Example:
          >>> cards = metabase_list_cards(client, filter="mine")
          >>> for c in cards:
          ...     print(c["id"], c["name"], c["display"])
      """
      params = {}
      if filter:
          params["f"] = filter
      if model_id > 0:
          params["model_id"] = model_id
      return client.request("GET", "/api/card", params=params)


  def metabase_get_card(client: MetabaseClient, card_id: int) -> dict:
      """Obtiene los detalles completos de una card/pregunta.

      Endpoint: GET /api/card/:id.

      Args:
          client: Cliente autenticado.
          card_id: ID de la card.

      Returns:
          Dict con: id, name, description, display, dataset_query,
          visualization_settings, collection_id, database_id, archived,
          creator, created_at, updated_at.

      Example:
          >>> card = metabase_get_card(client, 42)
          >>> print(card["name"], card["display"])
          >>> print(card["dataset_query"]["native"]["query"])  # SQL
      """
      return client.request("GET", f"/api/card/{card_id}")


  def metabase_create_card(
      client: MetabaseClient,
      name: str,
      dataset_query: dict,
      display: str = "table",
      collection_id: int = 0,
      description: str = "",
  ) -> dict:
      """Crea una nueva card/pregunta en Metabase.

      Endpoint: POST /api/card.

      Args:
          client: Cliente autenticado.
          name: Nombre de la pregunta.
          dataset_query: Query de la card. Estructura:
              SQL nativo: {"database": 1, "type": "native", "native": {"query": "SELECT ..."}}
              MBQL: {"database": 1, "type": "query", "query": {"source-table": 4, ...}}
          display: Tipo de visualizacion: "table", "bar", "line", "pie", "scalar",
                   "area", "row", "combo", "funnel", "scatter", "waterfall", etc.
          collection_id: ID de coleccion destino. 0 = root.
          description: Descripcion opcional.

      Returns:
          Dict con la card creada.

      Example:
          >>> card = metabase_create_card(client, "Revenue by Month", {
          ...     "database": 1,
          ...     "type": "native",
          ...     "native": {"query": "SELECT date_trunc('month', created_at), SUM(total) FROM orders GROUP BY 1"},
          ... }, display="line", description="Monthly revenue trend")
      """
      body: dict = {
          "name": name,
          "dataset_query": dataset_query,
          "display": display,
          "visualization_settings": {},
      }
      if collection_id > 0:
          body["collection_id"] = collection_id
      if description:
          body["description"] = description
      return client.request("POST", "/api/card", json=body)


  def metabase_update_card(client: MetabaseClient, card_id: int, **fields) -> dict:
      """Actualiza campos de una card/pregunta en Metabase.

      Endpoint: PUT /api/card/:id. Solo se modifican los campos pasados.

      Args:
          client: Cliente autenticado.
          card_id: ID de la card.
          **fields: Campos a actualizar. Validos:
              name (str), description (str), display (str),
              dataset_query (dict), visualization_settings (dict),
              collection_id (int), archived (bool),
              enable_embedding (bool), embedding_params (dict).

      Returns:
          Dict con la card actualizada.

      Example:
          >>> metabase_update_card(client, 42, name="Updated Name", archived=True)
          >>> metabase_update_card(client, 42, dataset_query={
          ...     "database": 1, "type": "native",
          ...     "native": {"query": "SELECT * FROM users LIMIT 100"},
          ... })
      """
      return client.request("PUT", f"/api/card/{card_id}", json=fields)


  def metabase_delete_card(client: MetabaseClient, card_id: int) -> None:
      """Elimina permanentemente una card/pregunta.

      Endpoint: DELETE /api/card/:id. IRREVERSIBLE.
      Para soft-delete preferir: metabase_update_card(client, card_id, archived=True)

      Args:
          client: Cliente autenticado.
          card_id: ID de la card a eliminar.

      Example:
          >>> metabase_delete_card(client, 42)
          >>> # Preferir soft-delete: metabase_update_card(client, 42, archived=True)
      """
      client.request("DELETE", f"/api/card/{card_id}")


  def metabase_execute_card(
      client: MetabaseClient,
      card_id: int,
      parameters: list[dict] | None = None,
  ) -> dict:
      """Ejecuta la query de una card/pregunta guardada.

      Endpoint: POST /api/card/:id/query.

      Args:
          client: Cliente autenticado.
          card_id: ID de la card a ejecutar.
          parameters: Parametros para queries parametrizadas. Cada parametro:
              {"type": "category", "target": ["variable", ["template-tag", "tag"]], "value": "val"}

      Returns:
          Dict con resultados:
          - status: "completed" o "failed"
          - row_count: numero de filas
          - running_time: tiempo en ms
          - data.columns: nombres de columnas
          - data.rows: filas de datos (lista de listas)
          - data.cols: metadata de columnas
          - data.native_form.query: SQL ejecutado

      Example:
          >>> result = metabase_execute_card(client, 42)
          >>> for row in result["data"]["rows"]:
          ...     print(row)
          >>> # Con parametros:
          >>> result = metabase_execute_card(client, 42, parameters=[
          ...     {"type": "category", "target": ["variable", ["template-tag", "status"]], "value": "active"},
          ... ])
      """
      body = {}
      if parameters:
          body["parameters"] = parameters
      return client.request("POST", f"/api/card/{card_id}/query", json=body or None)


  def metabase_execute_query(
      client: MetabaseClient,
      database_id: int,
      sql: str,
      max_results: int = 0,
  ) -> dict:
      """Ejecuta una query SQL ad-hoc sin guardarla como card.

      Endpoint: POST /api/dataset. Util para exploracion rapida y consultas
      que no necesitan persistirse.

      Args:
          client: Cliente autenticado.
          database_id: ID de la database en Metabase.
          sql: Query SQL a ejecutar.
          max_results: Limite de filas. 0 = default 2000.

      Returns:
          Dict con misma estructura que metabase_execute_card:
          data.columns, data.rows, row_count, running_time, status.

      Example:
          >>> result = metabase_execute_query(client, 1, "SELECT * FROM users LIMIT 10")
          >>> print(f"{result['row_count']} filas en {result['running_time']}ms")
          >>> for row in result["data"]["rows"]:
          ...     print(row)
      """
      body: dict = {
          "database": database_id,
          "type": "native",
          "native": {"query": sql},
      }
      if max_results > 0:
          body["constraints"] = {
              "max-results": max_results,
              "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 <nombre>".
          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)


  def metabase_export_card(
      client: MetabaseClient,
      card_id: int,
      format: str = "csv",
  ) -> bytes:
      """Exporta los resultados de una card en CSV, XLSX o JSON.

      Endpoint: POST /api/card/:id/query/:format.

      Args:
          client: Cliente autenticado.
          card_id: ID de la card.
          format: Formato de exportación: "csv", "xlsx" o "json".

      Returns:
          bytes con el contenido del archivo exportado.

      Example:
          >>> data = metabase_export_card(client, 42, format="csv")
          >>> with open("export.csv", "wb") as f:
          ...     f.write(data)
      """
      resp = client._http.request("POST", f"/api/card/{card_id}/query/{format}")
      resp.raise_for_status()
      return resp.content


  def metabase_create_model(
      client: MetabaseClient,
      name: str,
      sql: str,
      database_id: int,
      collection_id: int = 0,
      description: str = "",
  ) -> dict:
      """Crea un modelo (card tipo model) que otras cards pueden referenciar.

      Un modelo es una card con type="model". Otras cards MBQL pueden usarlo
      como fuente con source-table: "card__<model_id>".

      Endpoint: POST /api/card con type="model".

      Args:
          client: Cliente autenticado.
          name: Nombre del modelo.
          sql: Query SQL del modelo.
          database_id: ID de la database en Metabase.
          collection_id: Coleccion destino. 0 = root.
          description: Descripcion opcional.

      Returns:
          Dict con el modelo creado (id, name, type="model").

      Example:
          >>> model = metabase_create_model(client, "supply_orders_base",
          ...     "SELECT * FROM supply_orders WHERE ...", database_id=6)
          >>> # Usar en otra card MBQL:
          >>> card = metabase_create_card(client, "Revenue", {
          ...     "database": 6, "type": "query",
          ...     "query": {"source-table": f"card__{model['id']}"}
          ... })
      """
      body: dict = {
          "name": name,
          "type": "model",
          "dataset_query": {
              "database": database_id,
              "type": "native",
              "native": {"query": sql},
          },
          "display": "table",
          "visualization_settings": {},
      }
      if collection_id > 0:
          body["collection_id"] = collection_id
      if description:
          body["description"] = description
      return client.request("POST", "/api/card", json=body)

params_schema:
  {"params":[{"name":"client","desc":"instancia autenticada de MetabaseClient"},{"name":"name","desc":"nombre descriptivo de la pregunta/card"},{"name":"dataset_query","desc":"dict con configuración de query (SQL nativa o MBQL)"},{"name":"display","desc":"tipo de visualización (table, scalar, bar, etc.)"},{"name":"collection_id","desc":"ID de colección donde guardar la card (0=root)"},{"name":"description","desc":"descripción opcional de la card"}],"output":"dict: objeto card creado con id y toda su configuración"}


id: metabase_create_card_alert_py_infra
name: metabase_create_card_alert
kind: function
lang: py
domain: infra
version: 1.0.0
purity: impure
tags: ["metabase","notification","alert","card","create","api","python"]
uses_functions: []
uses_types: []
returns: []
error_type: error_go_core
imports: []
tests: []
file_path: python/functions/metabase/notifications.py
created_at: 2026-04-14T15:24:30Z
updated_at: 2026-04-14T15:24:30Z
props: []
emits: []
variant: []
content_hash: f0e6178027ecee1e3b94794b22d7f053dda3d4f9eed46e7324bb6a6e965be7d8
signature:
  def metabase_create_card_alert(client: MetabaseClient, card_id: int, cron_schedule: str, recipients: list[dict], send_condition: str = 'has_result', send_once: bool = False) -> dict

description:
  Crea una alerta sobre los resultados de una card en Metabase. Envia email segun cron cuando la card cumple la condicion (has_result, goal_above, goal_below). Endpoint: POST /api/notification.

example:
  ```python
  # Alerta: enviar email los lunes 9am si la card tiene resultados
  alert = metabase_create_card_alert(
      client,
      card_id=7711,
      cron_schedule="0 9 * * 1",
      recipients=[
          {"type": "notification-recipient/user", "user_id": 1},
          {"type": "notification-recipient/raw-value", "details": {"email": "team@aurgi.com"}},
      ],
      send_condition="has_result",
  )
  print(alert["id"], alert["active"])
  ```

notes:
  Requiere Metabase 0.57+. Reemplaza el antiguo /api/pulse.
  El campo `subscriptions` acepta solo el tipo `notification-subscription/cron`.
  Para alertas de goal configurar previamente el goal en la visualizacion de la card.
  `send_once=True` es util para alertas puntuales que no deben repetirse.

code:
  """CRUD de notificaciones de Metabase (alertas y suscripciones).

  Dos tipos:
    - Card alerts (notification/card): alertas cuando una card tiene resultados,
      supera un goal, etc. Cron configurable.
    - Dashboard subscriptions (notification/dashboard): envio periodico de un
      dashboard completo por email/slack.

  API: /api/notification (Metabase 0.57+, reemplaza el antiguo /api/pulse).
  """

  from .client import MetabaseClient


  def metabase_list_notifications(
      client: MetabaseClient,
      card_id: int = 0,
      dashboard_id: int = 0,
  ) -> list[dict]:
      """Lista notificaciones activas de Metabase.

      Endpoint: GET /api/notification.
      Permite filtrar por card o por dashboard. Si no se pasan filtros,
      retorna todas las notificaciones accesibles por el usuario autenticado.

      Args:
          client: Cliente autenticado.
          card_id: Si > 0, filtra notificaciones de tipo notification/card
              asociadas a esta card.
          dashboard_id: Si > 0, filtra notificaciones de tipo
              notification/dashboard asociadas a este dashboard.

      Returns:
          Lista de dicts con: id, active, payload_type, payload,
          subscriptions, handlers, created_at, updated_at.

      Example:
          >>> # Todas las notificaciones
          >>> notifs = metabase_list_notifications(client)
          >>> # Solo alertas de una card especifica
          >>> alerts = metabase_list_notifications(client, card_id=7711)
          >>> for a in alerts:
          ...     print(a["id"], a["payload_type"], a["active"])
      """
      params: dict = {}
      if card_id > 0:
          params["card_id"] = card_id
      if dashboard_id > 0:
          params["dashboard_id"] = dashboard_id
      return client.request("GET", "/api/notification", params=params)


  def metabase_create_card_alert(
      client: MetabaseClient,
      card_id: int,
      cron_schedule: str,
      recipients: list[dict],
      send_condition: str = "has_result",
      send_once: bool = False,
  ) -> dict:
      """Crea una alerta sobre los resultados de una card.

      Endpoint: POST /api/notification.
      Envia un email cuando la card cumple la condicion especificada
      (tiene resultados, supera un goal, etc.) segun el cron configurado.

      Args:
          client: Cliente autenticado.
          card_id: ID de la card que dispara la alerta.
          cron_schedule: Expresion cron de 5 campos (ej: "0 9 * * 1" = lunes 9am).
          recipients: Lista de destinatarios. Cada dict puede ser:
              - Usuario Metabase: {"type": "notification-recipient/user", "user_id": 1}
              - Email externo: {"type": "notification-recipient/raw-value",
                                "details": {"email": "x@y.com"}}
          send_condition: Condicion que dispara el envio:
              "has_result" (tiene filas), "goal_above" (supera goal),
              "goal_below" (cae bajo goal). Default: "has_result".
          send_once: Si True, se envia una sola vez y se desactiva.
              Default: False.

      Returns:
          Dict con la notificacion creada: id, active, payload_type,
          payload, subscriptions, handlers, created_at.

      Example:
          >>> alert = metabase_create_card_alert(
          ...     client,
          ...     card_id=7711,
          ...     cron_schedule="0 9 * * 1",
          ...     recipients=[
          ...         {"type": "notification-recipient/user", "user_id": 1},
          ...         {"type": "notification-recipient/raw-value",
          ...          "details": {"email": "team@aurgi.com"}},
          ...     ],
          ...     send_condition="has_result",
          ... )
          >>> print(alert["id"], alert["active"])
      """
      body = {
          "payload_type": "notification/card",
          "payload": {
              "card_id": card_id,
              "send_condition": send_condition,
              "send_once": send_once,
          },
          "subscriptions": [
              {
                  "type": "notification-subscription/cron",
                  "cron_schedule": cron_schedule,
              }
          ],
          "handlers": [
              {
                  "channel_type": "channel/email",
                  "recipients": recipients,
              }
          ],
          "active": True,
      }
      return client.request("POST", "/api/notification", json=body)


  def metabase_create_dashboard_subscription(
      client: MetabaseClient,
      dashboard_id: int,
      cron_schedule: str,
      recipients: list[dict],
  ) -> dict:
      """Crea una suscripcion periodica a un dashboard.

      Endpoint: POST /api/notification.
      Envia el dashboard completo por email segun el cron configurado.

      Args:
          client: Cliente autenticado.
          dashboard_id: ID del dashboard a enviar.
          cron_schedule: Expresion cron de 5 campos (ej: "0 8 * * 1-5" = lun-vie 8am).
          recipients: Lista de destinatarios. Cada dict puede ser:
              - Usuario Metabase: {"type": "notification-recipient/user", "user_id": 1}
              - Email externo: {"type": "notification-recipient/raw-value",
                                "details": {"email": "x@y.com"}}

      Returns:
          Dict con la suscripcion creada: id, active, payload_type,
          payload, subscriptions, handlers, created_at.

      Example:
          >>> sub = metabase_create_dashboard_subscription(
          ...     client,
          ...     dashboard_id=42,
          ...     cron_schedule="0 8 * * 1",
          ...     recipients=[
          ...         {"type": "notification-recipient/user", "user_id": 5},
          ...     ],
          ... )
          >>> print(sub["id"], sub["payload"]["dashboard_id"])
      """
      body = {
          "payload_type": "notification/dashboard",
          "payload": {
              "dashboard_id": dashboard_id,
          },
          "subscriptions": [
              {
                  "type": "notification-subscription/cron",
                  "cron_schedule": cron_schedule,
              }
          ],
          "handlers": [
              {
                  "channel_type": "channel/email",
                  "recipients": recipients,
              }
          ],
          "active": True,
      }
      return client.request("POST", "/api/notification", json=body)


  def metabase_update_notification(
      client: MetabaseClient,
      notification_id: int,
      **fields,
  ) -> dict:
      """Actualiza una notificacion existente (alerta o suscripcion).

      Endpoint: PUT /api/notification/:id.
      Permite modificar campos como active, handlers, subscriptions,
      payload, etc. Solo se envian los campos proporcionados.

      Args:
          client: Cliente autenticado.
          notification_id: ID de la notificacion a modificar.
          **fields: Campos a actualizar. Ejemplos:
              active=False para desactivar.
              handlers=[...] para cambiar destinatarios.
              subscriptions=[...] para cambiar el cron.

      Returns:
          Dict con la notificacion actualizada: id, active, payload_type,
          payload, subscriptions, handlers, updated_at.

      Example:
          >>> # Desactivar una alerta
          >>> updated = metabase_update_notification(client, 99, active=False)
          >>> # Cambiar destinatarios
          >>> updated = metabase_update_notification(
          ...     client,
          ...     99,
          ...     handlers=[{
          ...         "channel_type": "channel/email",
          ...         "recipients": [
          ...             {"type": "notification-recipient/user", "user_id": 2},
          ...         ],
          ...     }],
          ... )
          >>> print(updated["active"])
      """
      return client.request("PUT", f"/api/notification/{notification_id}", json=fields)


  def metabase_delete_notification(
      client: MetabaseClient,
      notification_id: int,
  ) -> None:
      """Elimina una notificacion (alerta o suscripcion) de Metabase.

      Endpoint: DELETE /api/notification/:id.
      La operacion es irreversible. Afecta tanto a alertas de cards
      como a suscripciones de dashboards.

      Args:
          client: Cliente autenticado.
          notification_id: ID de la notificacion a eliminar.

      Returns:
          None.

      Example:
          >>> metabase_delete_notification(client, 99)
          >>> # La notificacion ya no existe
      """
      client.request("DELETE", f"/api/notification/{notification_id}")

params_schema:
  {"params":[{"name":"client","desc":"instancia autenticada de MetabaseClient"},{"name":"card_id","desc":"ID de la card que dispara la alerta"},{"name":"cron_schedule","desc":"expresion cron de 5 campos (ej: '0 9 * * 1' = lunes 9am, '0 8 * * 1-5' = lun-vie 8am)"},{"name":"recipients","desc":"lista de destinatarios: usuario Metabase {'type': 'notification-recipient/user', 'user_id': N} o email externo {'type': 'notification-recipient/raw-value', 'details': {'email': 'x@y.com'}}"},{"name":"send_condition","desc":"condicion que dispara el envio: 'has_result' (tiene filas), 'goal_above' (supera goal), 'goal_below' (cae bajo goal)"},{"name":"send_once","desc":"si True, se envia una sola vez y se desactiva automaticamente"}],"output":"dict: notificacion creada con id, active, payload_type, payload, subscriptions, handlers, created_at"}


id: metabase_create_card_raw_py_infra
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
tags: ["metabase","card","question","create","api","python","raw","as-code"]
uses_functions: ["metabase_auth_py_infra"]
uses_types: []
returns: []
error_type: error_go_core
imports: ["httpx"]
tests: []
file_path: python/functions/metabase/cards.py
created_at: 2026-04-13T10:26:52Z
updated_at: 2026-04-14T15:24:30Z
props: []
emits: []
variant: []
content_hash: 4b91fb8b0f7aad1af0e862c4a25b959a04906526118ca37393e1f4ec28bfef68
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.

example:
  ```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
  ```

notes:
  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.

code:
  """CRUD de cards/preguntas de Metabase y ejecucion de queries."""

  from .client import MetabaseClient


  def metabase_list_cards(
      client: MetabaseClient,
      filter: str = "",
      model_id: int = 0,
  ) -> list[dict]:
      """Lista preguntas/cards de Metabase con filtro opcional.

      Endpoint: GET /api/card. No tiene paginacion offset/limit.

      Args:
          client: Cliente autenticado.
          filter: "all", "mine", "fav", "archived", "recent", "popular",
                  "database", "table". Vacio = todas.
          model_id: ID de database/tabla. Solo aplica con filter "database" o "table".

      Returns:
          Lista de dicts, cada uno con: id, name, description, display,
          collection_id, database_id, creator_id, archived, dataset_query.

      Example:
          >>> cards = metabase_list_cards(client, filter="mine")
          >>> for c in cards:
          ...     print(c["id"], c["name"], c["display"])
      """
      params = {}
      if filter:
          params["f"] = filter
      if model_id > 0:
          params["model_id"] = model_id
      return client.request("GET", "/api/card", params=params)


  def metabase_get_card(client: MetabaseClient, card_id: int) -> dict:
      """Obtiene los detalles completos de una card/pregunta.

      Endpoint: GET /api/card/:id.

      Args:
          client: Cliente autenticado.
          card_id: ID de la card.

      Returns:
          Dict con: id, name, description, display, dataset_query,
          visualization_settings, collection_id, database_id, archived,
          creator, created_at, updated_at.

      Example:
          >>> card = metabase_get_card(client, 42)
          >>> print(card["name"], card["display"])
          >>> print(card["dataset_query"]["native"]["query"])  # SQL
      """
      return client.request("GET", f"/api/card/{card_id}")


  def metabase_create_card(
      client: MetabaseClient,
      name: str,
      dataset_query: dict,
      display: str = "table",
      collection_id: int = 0,
      description: str = "",
  ) -> dict:
      """Crea una nueva card/pregunta en Metabase.

      Endpoint: POST /api/card.

      Args:
          client: Cliente autenticado.
          name: Nombre de la pregunta.
          dataset_query: Query de la card. Estructura:
              SQL nativo: {"database": 1, "type": "native", "native": {"query": "SELECT ..."}}
              MBQL: {"database": 1, "type": "query", "query": {"source-table": 4, ...}}
          display: Tipo de visualizacion: "table", "bar", "line", "pie", "scalar",
                   "area", "row", "combo", "funnel", "scatter", "waterfall", etc.
          collection_id: ID de coleccion destino. 0 = root.
          description: Descripcion opcional.

      Returns:
          Dict con la card creada.

      Example:
          >>> card = metabase_create_card(client, "Revenue by Month", {
          ...     "database": 1,
          ...     "type": "native",
          ...     "native": {"query": "SELECT date_trunc('month', created_at), SUM(total) FROM orders GROUP BY 1"},
          ... }, display="line", description="Monthly revenue trend")
      """
      body: dict = {
          "name": name,
          "dataset_query": dataset_query,
          "display": display,
          "visualization_settings": {},
      }
      if collection_id > 0:
          body["collection_id"] = collection_id
      if description:
          body["description"] = description
      return client.request("POST", "/api/card", json=body)


  def metabase_update_card(client: MetabaseClient, card_id: int, **fields) -> dict:
      """Actualiza campos de una card/pregunta en Metabase.

      Endpoint: PUT /api/card/:id. Solo se modifican los campos pasados.

      Args:
          client: Cliente autenticado.
          card_id: ID de la card.
          **fields: Campos a actualizar. Validos:
              name (str), description (str), display (str),
              dataset_query (dict), visualization_settings (dict),
              collection_id (int), archived (bool),
              enable_embedding (bool), embedding_params (dict).

      Returns:
          Dict con la card actualizada.

      Example:
          >>> metabase_update_card(client, 42, name="Updated Name", archived=True)
          >>> metabase_update_card(client, 42, dataset_query={
          ...     "database": 1, "type": "native",
          ...     "native": {"query": "SELECT * FROM users LIMIT 100"},
          ... })
      """
      return client.request("PUT", f"/api/card/{card_id}", json=fields)


  def metabase_delete_card(client: MetabaseClient, card_id: int) -> None:
      """Elimina permanentemente una card/pregunta.

      Endpoint: DELETE /api/card/:id. IRREVERSIBLE.
      Para soft-delete preferir: metabase_update_card(client, card_id, archived=True)

      Args:
          client: Cliente autenticado.
          card_id: ID de la card a eliminar.

      Example:
          >>> metabase_delete_card(client, 42)
          >>> # Preferir soft-delete: metabase_update_card(client, 42, archived=True)
      """
      client.request("DELETE", f"/api/card/{card_id}")


  def metabase_execute_card(
      client: MetabaseClient,
      card_id: int,
      parameters: list[dict] | None = None,
  ) -> dict:
      """Ejecuta la query de una card/pregunta guardada.

      Endpoint: POST /api/card/:id/query.

      Args:
          client: Cliente autenticado.
          card_id: ID de la card a ejecutar.
          parameters: Parametros para queries parametrizadas. Cada parametro:
              {"type": "category", "target": ["variable", ["template-tag", "tag"]], "value": "val"}

      Returns:
          Dict con resultados:
          - status: "completed" o "failed"
          - row_count: numero de filas
          - running_time: tiempo en ms
          - data.columns: nombres de columnas
          - data.rows: filas de datos (lista de listas)
          - data.cols: metadata de columnas
          - data.native_form.query: SQL ejecutado

      Example:
          >>> result = metabase_execute_card(client, 42)
          >>> for row in result["data"]["rows"]:
          ...     print(row)
          >>> # Con parametros:
          >>> result = metabase_execute_card(client, 42, parameters=[
          ...     {"type": "category", "target": ["variable", ["template-tag", "status"]], "value": "active"},
          ... ])
      """
      body = {}
      if parameters:
          body["parameters"] = parameters
      return client.request("POST", f"/api/card/{card_id}/query", json=body or None)


  def metabase_execute_query(
      client: MetabaseClient,
      database_id: int,
      sql: str,
      max_results: int = 0,
  ) -> dict:
      """Ejecuta una query SQL ad-hoc sin guardarla como card.

      Endpoint: POST /api/dataset. Util para exploracion rapida y consultas
      que no necesitan persistirse.

      Args:
          client: Cliente autenticado.
          database_id: ID de la database en Metabase.
          sql: Query SQL a ejecutar.
          max_results: Limite de filas. 0 = default 2000.

      Returns:
          Dict con misma estructura que metabase_execute_card:
          data.columns, data.rows, row_count, running_time, status.

      Example:
          >>> result = metabase_execute_query(client, 1, "SELECT * FROM users LIMIT 10")
          >>> print(f"{result['row_count']} filas en {result['running_time']}ms")
          >>> for row in result["data"]["rows"]:
          ...     print(row)
      """
      body: dict = {
          "database": database_id,
          "type": "native",
          "native": {"query": sql},
      }
      if max_results > 0:
          body["constraints"] = {
              "max-results": max_results,
              "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 <nombre>".
          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)


  def metabase_export_card(
      client: MetabaseClient,
      card_id: int,
      format: str = "csv",
  ) -> bytes:
      """Exporta los resultados de una card en CSV, XLSX o JSON.

      Endpoint: POST /api/card/:id/query/:format.

      Args:
          client: Cliente autenticado.
          card_id: ID de la card.
          format: Formato de exportación: "csv", "xlsx" o "json".

      Returns:
          bytes con el contenido del archivo exportado.

      Example:
          >>> data = metabase_export_card(client, 42, format="csv")
          >>> with open("export.csv", "wb") as f:
          ...     f.write(data)
      """
      resp = client._http.request("POST", f"/api/card/{card_id}/query/{format}")
      resp.raise_for_status()
      return resp.content


  def metabase_create_model(
      client: MetabaseClient,
      name: str,
      sql: str,
      database_id: int,
      collection_id: int = 0,
      description: str = "",
  ) -> dict:
      """Crea un modelo (card tipo model) que otras cards pueden referenciar.

      Un modelo es una card con type="model". Otras cards MBQL pueden usarlo
      como fuente con source-table: "card__<model_id>".

      Endpoint: POST /api/card con type="model".

      Args:
          client: Cliente autenticado.
          name: Nombre del modelo.
          sql: Query SQL del modelo.
          database_id: ID de la database en Metabase.
          collection_id: Coleccion destino. 0 = root.
          description: Descripcion opcional.

      Returns:
          Dict con el modelo creado (id, name, type="model").

      Example:
          >>> model = metabase_create_model(client, "supply_orders_base",
          ...     "SELECT * FROM supply_orders WHERE ...", database_id=6)
          >>> # Usar en otra card MBQL:
          >>> card = metabase_create_card(client, "Revenue", {
          ...     "database": 6, "type": "query",
          ...     "query": {"source-table": f"card__{model['id']}"}
          ... })
      """
      body: dict = {
          "name": name,
          "type": "model",
          "dataset_query": {
              "database": database_id,
              "type": "native",
              "native": {"query": sql},
          },
          "display": "table",
          "visualization_settings": {},
      }
      if collection_id > 0:
          body["collection_id"] = collection_id
      if description:
          body["description"] = description
      return client.request("POST", "/api/card", json=body)

params_schema:
  {"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.)"}


id: metabase_create_dashboard_py_infra
name: metabase_create_dashboard
kind: function
lang: py
domain: infra
version: 1.0.0
purity: impure
tags: ["metabase","dashboard","create","api","python"]
uses_functions: []
uses_types: []
returns: []
error_type: error_go_core
imports: ["httpx"]
tests: []
file_path: python/functions/metabase/dashboards.py
created_at: 2026-04-05T11:22:32Z
updated_at: 2026-04-13T13:34:41Z
props: []
emits: []
variant: []
notes: Se crea vacio. Agregar cards con metabase_update_dashboard(dashcards=[...]).
content_hash: 57edac69f90433bbd6630d3751e22244d922fafe5db210af1f6407d926ea90eb
signature:
  def metabase_create_dashboard(client: MetabaseClient, name: str, description: str = '', collection_id: int = 0) -> dict

description:
  Crea dashboard vacio en Metabase. Para agregar cards usar metabase_update_dashboard con dashcards. Endpoint: POST /api/dashboard.

example:
  ```python
  dash = metabase_create_dashboard(client, "Sales Overview", "KPIs")
  # Agregar cards con metabase_update_dashboard
  ```

code:
  """CRUD de dashboards de Metabase."""

  from .client import MetabaseClient


  def metabase_list_dashboards(
      client: MetabaseClient,
      filter: str = "",
  ) -> list[dict]:
      """Lista dashboards de Metabase con filtro opcional.

      Endpoint: GET /api/dashboard. Retorna dashboards resumidos (sin dashcards).

      Args:
          client: Cliente autenticado.
          filter: "all", "mine" o "archived". Vacio = todas.

      Returns:
          Lista de dicts con: id, name, description, collection_id,
          creator_id, archived, created_at.

      Example:
          >>> dashboards = metabase_list_dashboards(client, filter="mine")
          >>> for d in dashboards:
          ...     print(d["id"], d["name"])
      """
      params = {}
      if filter:
          params["f"] = filter
      return client.request("GET", "/api/dashboard", params=params)


  def metabase_get_dashboard(client: MetabaseClient, dashboard_id: int) -> dict:
      """Obtiene un dashboard completo incluyendo sus cards.

      Endpoint: GET /api/dashboard/:id.

      Args:
          client: Cliente autenticado.
          dashboard_id: ID del dashboard.

      Returns:
          Dict con: id, name, description, dashcards (lista de cards posicionadas),
          parameters (filtros), tabs, collection_id, archived.

          Cada dashcard tiene: id, card_id, card (objeto completo), size_x, size_y,
          col, row, dashboard_tab_id, parameter_mappings, visualization_settings.

      Example:
          >>> dash = metabase_get_dashboard(client, 1)
          >>> for dc in dash["dashcards"]:
          ...     print(f"Card {dc['card_id']} at ({dc['col']}, {dc['row']})")
      """
      return client.request("GET", f"/api/dashboard/{dashboard_id}")


  def metabase_create_dashboard(
      client: MetabaseClient,
      name: str,
      description: str = "",
      collection_id: int = 0,
  ) -> dict:
      """Crea un nuevo dashboard vacio en Metabase.

      Endpoint: POST /api/dashboard.
      Para agregar cards usar metabase_update_dashboard con dashcards.

      Args:
          client: Cliente autenticado.
          name: Nombre del dashboard.
          description: Descripcion opcional.
          collection_id: Coleccion destino. 0 = root.

      Returns:
          Dict con el dashboard creado.

      Example:
          >>> dash = metabase_create_dashboard(client, "Sales Overview", "KPIs de ventas")
          >>> # Agregar cards:
          >>> metabase_update_dashboard(client, dash["id"], dashcards=[
          ...     {"id": -1, "card_id": 42, "size_x": 6, "size_y": 4, "col": 0, "row": 0},
          ... ])
      """
      body: dict = {"name": name}
      if description:
          body["description"] = description
      if collection_id > 0:
          body["collection_id"] = collection_id
      return client.request("POST", "/api/dashboard", json=body)


  def metabase_update_dashboard(client: MetabaseClient, dashboard_id: int, **fields) -> dict:
      """Actualiza un dashboard incluyendo metadata, cards y tabs.

      Endpoint: PUT /api/dashboard/:id.

      El campo dashcards representa el ESTADO COMPLETO DESEADO del dashboard:
      - Agregar card: incluirla con ID negativo (-1, -2, etc.)
      - Actualizar card existente: incluirla con su ID positivo
      - Eliminar card: omitirla del array

      Args:
          client: Cliente autenticado.
          dashboard_id: ID del dashboard.
          **fields: Campos a actualizar. Validos:
              name (str), description (str), archived (bool),
              dashcards (list[dict]), tabs (list[dict]),
              parameters (list[dict]), collection_id (int).

      Returns:
          Dict con el dashboard actualizado.

      Example:
          >>> # Cambiar nombre
          >>> metabase_update_dashboard(client, 1, name="Updated Name")
          >>>
          >>> # Agregar card (primero obtener existentes)
          >>> dash = metabase_get_dashboard(client, 1)
          >>> cards = list(dash["dashcards"])
          >>> cards.append({"id": -1, "card_id": 55, "size_x": 6, "size_y": 4, "col": 0, "row": 0})
          >>> metabase_update_dashboard(client, 1, dashcards=cards)
          >>>
          >>> # Archivar (soft-delete)
          >>> metabase_update_dashboard(client, 1, archived=True)
      """
      return client.request("PUT", f"/api/dashboard/{dashboard_id}", json=fields)


  def metabase_delete_dashboard(client: MetabaseClient, dashboard_id: int) -> None:
      """Elimina permanentemente un dashboard.

      Endpoint: DELETE /api/dashboard/:id. IRREVERSIBLE.
      Para soft-delete preferir: metabase_update_dashboard(client, id, archived=True)

      Args:
          client: Cliente autenticado.
          dashboard_id: ID del dashboard a eliminar.

      Example:
          >>> metabase_delete_dashboard(client, 1)
          >>> # 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 <nombre>".
          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

params_schema:
  {"params":[{"name":"client","desc":"instancia autenticada de MetabaseClient"},{"name":"name","desc":"nombre del dashboard"},{"name":"description","desc":"descripción opcional del dashboard"},{"name":"collection_id","desc":"ID de colección donde guardar el dashboard (0=root)"}],"output":"dict: objeto dashboard vacío con id asignado"}


id: metabase_create_dashboard_raw_py_infra
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
tags: ["metabase","dashboard","create","api","python","raw","as-code","dashcards"]
uses_functions: ["metabase_auth_py_infra"]
uses_types: []
returns: []
error_type: error_go_core
imports: ["httpx"]
tests: []
file_path: python/functions/metabase/dashboards.py
created_at: 2026-04-13T10:26:52Z
updated_at: 2026-04-13T13:34:41Z
props: []
emits: []
variant: []
content_hash: 44703871b7bdce5b48a14923cab3a062b9d25ca0716ec85f8410c7074b9dda92
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).

example:
  ```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
  ```

notes:
  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.

code:
  """CRUD de dashboards de Metabase."""

  from .client import MetabaseClient


  def metabase_list_dashboards(
      client: MetabaseClient,
      filter: str = "",
  ) -> list[dict]:
      """Lista dashboards de Metabase con filtro opcional.

      Endpoint: GET /api/dashboard. Retorna dashboards resumidos (sin dashcards).

      Args:
          client: Cliente autenticado.
          filter: "all", "mine" o "archived". Vacio = todas.

      Returns:
          Lista de dicts con: id, name, description, collection_id,
          creator_id, archived, created_at.

      Example:
          >>> dashboards = metabase_list_dashboards(client, filter="mine")
          >>> for d in dashboards:
          ...     print(d["id"], d["name"])
      """
      params = {}
      if filter:
          params["f"] = filter
      return client.request("GET", "/api/dashboard", params=params)


  def metabase_get_dashboard(client: MetabaseClient, dashboard_id: int) -> dict:
      """Obtiene un dashboard completo incluyendo sus cards.

      Endpoint: GET /api/dashboard/:id.

      Args:
          client: Cliente autenticado.
          dashboard_id: ID del dashboard.

      Returns:
          Dict con: id, name, description, dashcards (lista de cards posicionadas),
          parameters (filtros), tabs, collection_id, archived.

          Cada dashcard tiene: id, card_id, card (objeto completo), size_x, size_y,
          col, row, dashboard_tab_id, parameter_mappings, visualization_settings.

      Example:
          >>> dash = metabase_get_dashboard(client, 1)
          >>> for dc in dash["dashcards"]:
          ...     print(f"Card {dc['card_id']} at ({dc['col']}, {dc['row']})")
      """
      return client.request("GET", f"/api/dashboard/{dashboard_id}")


  def metabase_create_dashboard(
      client: MetabaseClient,
      name: str,
      description: str = "",
      collection_id: int = 0,
  ) -> dict:
      """Crea un nuevo dashboard vacio en Metabase.

      Endpoint: POST /api/dashboard.
      Para agregar cards usar metabase_update_dashboard con dashcards.

      Args:
          client: Cliente autenticado.
          name: Nombre del dashboard.
          description: Descripcion opcional.
          collection_id: Coleccion destino. 0 = root.

      Returns:
          Dict con el dashboard creado.

      Example:
          >>> dash = metabase_create_dashboard(client, "Sales Overview", "KPIs de ventas")
          >>> # Agregar cards:
          >>> metabase_update_dashboard(client, dash["id"], dashcards=[
          ...     {"id": -1, "card_id": 42, "size_x": 6, "size_y": 4, "col": 0, "row": 0},
          ... ])
      """
      body: dict = {"name": name}
      if description:
          body["description"] = description
      if collection_id > 0:
          body["collection_id"] = collection_id
      return client.request("POST", "/api/dashboard", json=body)


  def metabase_update_dashboard(client: MetabaseClient, dashboard_id: int, **fields) -> dict:
      """Actualiza un dashboard incluyendo metadata, cards y tabs.

      Endpoint: PUT /api/dashboard/:id.

      El campo dashcards representa el ESTADO COMPLETO DESEADO del dashboard:
      - Agregar card: incluirla con ID negativo (-1, -2, etc.)
      - Actualizar card existente: incluirla con su ID positivo
      - Eliminar card: omitirla del array

      Args:
          client: Cliente autenticado.
          dashboard_id: ID del dashboard.
          **fields: Campos a actualizar. Validos:
              name (str), description (str), archived (bool),
              dashcards (list[dict]), tabs (list[dict]),
              parameters (list[dict]), collection_id (int).

      Returns:
          Dict con el dashboard actualizado.

      Example:
          >>> # Cambiar nombre
          >>> metabase_update_dashboard(client, 1, name="Updated Name")
          >>>
          >>> # Agregar card (primero obtener existentes)
          >>> dash = metabase_get_dashboard(client, 1)
          >>> cards = list(dash["dashcards"])
          >>> cards.append({"id": -1, "card_id": 55, "size_x": 6, "size_y": 4, "col": 0, "row": 0})
          >>> metabase_update_dashboard(client, 1, dashcards=cards)
          >>>
          >>> # Archivar (soft-delete)
          >>> metabase_update_dashboard(client, 1, archived=True)
      """
      return client.request("PUT", f"/api/dashboard/{dashboard_id}", json=fields)


  def metabase_delete_dashboard(client: MetabaseClient, dashboard_id: int) -> None:
      """Elimina permanentemente un dashboard.

      Endpoint: DELETE /api/dashboard/:id. IRREVERSIBLE.
      Para soft-delete preferir: metabase_update_dashboard(client, id, archived=True)

      Args:
          client: Cliente autenticado.
          dashboard_id: ID del dashboard a eliminar.

      Example:
          >>> metabase_delete_dashboard(client, 1)
          >>> # 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 <nombre>".
          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

params_schema:
  {"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"}


id: metabase_create_dashboard_subscription_py_infra
name: metabase_create_dashboard_subscription
kind: function
lang: py
domain: infra
version: 1.0.0
purity: impure
tags: ["metabase","notification","subscription","dashboard","create","api","python"]
uses_functions: []
uses_types: []
returns: []
error_type: error_go_core
imports: []
tests: []
file_path: python/functions/metabase/notifications.py
created_at: 2026-04-14T15:24:30Z
updated_at: 2026-04-14T15:24:30Z
props: []
emits: []
variant: []
content_hash: 32929cadfb0776f79a00218aced770fc668f44580bd2253af937cd70ecaf0526
signature:
  def metabase_create_dashboard_subscription(client: MetabaseClient, dashboard_id: int, cron_schedule: str, recipients: list[dict]) -> dict

description:
  Crea una suscripcion periodica a un dashboard de Metabase. Envia el dashboard completo por email segun el cron configurado. Endpoint: POST /api/notification.

example:
  ```python
  # Suscripcion: enviar dashboard de ventas cada lunes a las 8am
  sub = metabase_create_dashboard_subscription(
      client,
      dashboard_id=42,
      cron_schedule="0 8 * * 1",
      recipients=[
          {"type": "notification-recipient/user", "user_id": 5},
          {"type": "notification-recipient/raw-value", "details": {"email": "gerencia@aurgi.com"}},
      ],
  )
  print(sub["id"], sub["payload"]["dashboard_id"])
  ```

notes:
  Requiere Metabase 0.57+. Reemplaza el antiguo /api/pulse.
  El dashboard se envia completo con todas sus cards renderizadas.
  Para suscripciones diarias laborales usar cron "0 8 * * 1-5".

code:
  """CRUD de notificaciones de Metabase (alertas y suscripciones).

  Dos tipos:
    - Card alerts (notification/card): alertas cuando una card tiene resultados,
      supera un goal, etc. Cron configurable.
    - Dashboard subscriptions (notification/dashboard): envio periodico de un
      dashboard completo por email/slack.

  API: /api/notification (Metabase 0.57+, reemplaza el antiguo /api/pulse).
  """

  from .client import MetabaseClient


  def metabase_list_notifications(
      client: MetabaseClient,
      card_id: int = 0,
      dashboard_id: int = 0,
  ) -> list[dict]:
      """Lista notificaciones activas de Metabase.

      Endpoint: GET /api/notification.
      Permite filtrar por card o por dashboard. Si no se pasan filtros,
      retorna todas las notificaciones accesibles por el usuario autenticado.

      Args:
          client: Cliente autenticado.
          card_id: Si > 0, filtra notificaciones de tipo notification/card
              asociadas a esta card.
          dashboard_id: Si > 0, filtra notificaciones de tipo
              notification/dashboard asociadas a este dashboard.

      Returns:
          Lista de dicts con: id, active, payload_type, payload,
          subscriptions, handlers, created_at, updated_at.

      Example:
          >>> # Todas las notificaciones
          >>> notifs = metabase_list_notifications(client)
          >>> # Solo alertas de una card especifica
          >>> alerts = metabase_list_notifications(client, card_id=7711)
          >>> for a in alerts:
          ...     print(a["id"], a["payload_type"], a["active"])
      """
      params: dict = {}
      if card_id > 0:
          params["card_id"] = card_id
      if dashboard_id > 0:
          params["dashboard_id"] = dashboard_id
      return client.request("GET", "/api/notification", params=params)


  def metabase_create_card_alert(
      client: MetabaseClient,
      card_id: int,
      cron_schedule: str,
      recipients: list[dict],
      send_condition: str = "has_result",
      send_once: bool = False,
  ) -> dict:
      """Crea una alerta sobre los resultados de una card.

      Endpoint: POST /api/notification.
      Envia un email cuando la card cumple la condicion especificada
      (tiene resultados, supera un goal, etc.) segun el cron configurado.

      Args:
          client: Cliente autenticado.
          card_id: ID de la card que dispara la alerta.
          cron_schedule: Expresion cron de 5 campos (ej: "0 9 * * 1" = lunes 9am).
          recipients: Lista de destinatarios. Cada dict puede ser:
              - Usuario Metabase: {"type": "notification-recipient/user", "user_id": 1}
              - Email externo: {"type": "notification-recipient/raw-value",
                                "details": {"email": "x@y.com"}}
          send_condition: Condicion que dispara el envio:
              "has_result" (tiene filas), "goal_above" (supera goal),
              "goal_below" (cae bajo goal). Default: "has_result".
          send_once: Si True, se envia una sola vez y se desactiva.
              Default: False.

      Returns:
          Dict con la notificacion creada: id, active, payload_type,
          payload, subscriptions, handlers, created_at.

      Example:
          >>> alert = metabase_create_card_alert(
          ...     client,
          ...     card_id=7711,
          ...     cron_schedule="0 9 * * 1",
          ...     recipients=[
          ...         {"type": "notification-recipient/user", "user_id": 1},
          ...         {"type": "notification-recipient/raw-value",
          ...          "details": {"email": "team@aurgi.com"}},
          ...     ],
          ...     send_condition="has_result",
          ... )
          >>> print(alert["id"], alert["active"])
      """
      body = {
          "payload_type": "notification/card",
          "payload": {
              "card_id": card_id,
              "send_condition": send_condition,
              "send_once": send_once,
          },
          "subscriptions": [
              {
                  "type": "notification-subscription/cron",
                  "cron_schedule": cron_schedule,
              }
          ],
          "handlers": [
              {
                  "channel_type": "channel/email",
                  "recipients": recipients,
              }
          ],
          "active": True,
      }
      return client.request("POST", "/api/notification", json=body)


  def metabase_create_dashboard_subscription(
      client: MetabaseClient,
      dashboard_id: int,
      cron_schedule: str,
      recipients: list[dict],
  ) -> dict:
      """Crea una suscripcion periodica a un dashboard.

      Endpoint: POST /api/notification.
      Envia el dashboard completo por email segun el cron configurado.

      Args:
          client: Cliente autenticado.
          dashboard_id: ID del dashboard a enviar.
          cron_schedule: Expresion cron de 5 campos (ej: "0 8 * * 1-5" = lun-vie 8am).
          recipients: Lista de destinatarios. Cada dict puede ser:
              - Usuario Metabase: {"type": "notification-recipient/user", "user_id": 1}
              - Email externo: {"type": "notification-recipient/raw-value",
                                "details": {"email": "x@y.com"}}

      Returns:
          Dict con la suscripcion creada: id, active, payload_type,
          payload, subscriptions, handlers, created_at.

      Example:
          >>> sub = metabase_create_dashboard_subscription(
          ...     client,
          ...     dashboard_id=42,
          ...     cron_schedule="0 8 * * 1",
          ...     recipients=[
          ...         {"type": "notification-recipient/user", "user_id": 5},
          ...     ],
          ... )
          >>> print(sub["id"], sub["payload"]["dashboard_id"])
      """
      body = {
          "payload_type": "notification/dashboard",
          "payload": {
              "dashboard_id": dashboard_id,
          },
          "subscriptions": [
              {
                  "type": "notification-subscription/cron",
                  "cron_schedule": cron_schedule,
              }
          ],
          "handlers": [
              {
                  "channel_type": "channel/email",
                  "recipients": recipients,
              }
          ],
          "active": True,
      }
      return client.request("POST", "/api/notification", json=body)


  def metabase_update_notification(
      client: MetabaseClient,
      notification_id: int,
      **fields,
  ) -> dict:
      """Actualiza una notificacion existente (alerta o suscripcion).

      Endpoint: PUT /api/notification/:id.
      Permite modificar campos como active, handlers, subscriptions,
      payload, etc. Solo se envian los campos proporcionados.

      Args:
          client: Cliente autenticado.
          notification_id: ID de la notificacion a modificar.
          **fields: Campos a actualizar. Ejemplos:
              active=False para desactivar.
              handlers=[...] para cambiar destinatarios.
              subscriptions=[...] para cambiar el cron.

      Returns:
          Dict con la notificacion actualizada: id, active, payload_type,
          payload, subscriptions, handlers, updated_at.

      Example:
          >>> # Desactivar una alerta
          >>> updated = metabase_update_notification(client, 99, active=False)
          >>> # Cambiar destinatarios
          >>> updated = metabase_update_notification(
          ...     client,
          ...     99,
          ...     handlers=[{
          ...         "channel_type": "channel/email",
          ...         "recipients": [
          ...             {"type": "notification-recipient/user", "user_id": 2},
          ...         ],
          ...     }],
          ... )
          >>> print(updated["active"])
      """
      return client.request("PUT", f"/api/notification/{notification_id}", json=fields)


  def metabase_delete_notification(
      client: MetabaseClient,
      notification_id: int,
  ) -> None:
      """Elimina una notificacion (alerta o suscripcion) de Metabase.

      Endpoint: DELETE /api/notification/:id.
      La operacion es irreversible. Afecta tanto a alertas de cards
      como a suscripciones de dashboards.

      Args:
          client: Cliente autenticado.
          notification_id: ID de la notificacion a eliminar.

      Returns:
          None.

      Example:
          >>> metabase_delete_notification(client, 99)
          >>> # La notificacion ya no existe
      """
      client.request("DELETE", f"/api/notification/{notification_id}")

params_schema:
  {"params":[{"name":"client","desc":"instancia autenticada de MetabaseClient"},{"name":"dashboard_id","desc":"ID del dashboard a enviar periodicamente"},{"name":"cron_schedule","desc":"expresion cron de 5 campos (ej: '0 8 * * 1-5' = lun-vie 8am, '0 9 * * 1' = lunes 9am)"},{"name":"recipients","desc":"lista de destinatarios: usuario Metabase {'type': 'notification-recipient/user', 'user_id': N} o email externo {'type': 'notification-recipient/raw-value', 'details': {'email': 'x@y.com'}}"}],"output":"dict: suscripcion creada con id, active, payload_type, payload, subscriptions, handlers, created_at"}


id: metabase_create_document_py_infra
name: metabase_create_document
kind: function
lang: py
domain: infra
version: 1.1.0
purity: impure
tags: ["metabase","document","create","api","prosemirror","python"]
uses_functions: ["metabase_validate_document_payload_py_infra"]
uses_types: []
returns: []
error_type: error_go_core
imports: ["httpx","uuid"]
tests: []
file_path: python/functions/metabase/documents.py
created_at: 2026-04-13T12:54:37Z
updated_at: 2026-04-14T15:24:30Z
props: []
emits: []
variant: []
content_hash: 940b0d01db0cb0f41b091c951106a50b90a143db183b9ab03644b112834439cf
signature:
  def metabase_create_document(client: MetabaseClient, name: str, document: dict, collection_id: int = 0, *, validate: bool = True) -> dict

description:
  Crea un document con contenido ProseMirror. Valida el arbol contra la whitelist de nodos ANTES de enviar (evita documentos que la API acepta pero el frontend renderiza vacíos). Usar prosemirror_card_embed() para embeber cards.

example:
  ```python
  from metabase.documents import metabase_create_document, prosemirror_card_embed

  doc = metabase_create_document(client, "Reporte Q1", {
      "type": "doc",
      "content": [
          {"type": "heading", "attrs": {"level": 1},
           "content": [{"type": "text", "text": "KPIs"}]},
          {"type": "paragraph",
           "content": [{"type": "text", "text": "Revenue por canal:"}]},
          prosemirror_card_embed(42, height=450),
      ]
  })
  ```

notes:
  - La validación (`validate=True`) llama internamente a `metabase_validate_document_payload`. Si detecta nodos no soportados, lanza `ValueError` ANTES de hacer el POST — evita documentos que se ven vacíos.
  - Pasar `validate=False` solo si se está experimentando con nodos nuevos.
  - Para destacar texto, usar `blockquote` (NO `callout`).
  - Cuando embebes un card via `cardEmbed`, Metabase crea una referencia al card — el card debe existir.

documentation:
  ## Nodos ProseMirror — whitelist

  **Renderizan correctamente** (TipTap v0.59):
  `doc, paragraph, text, heading, bulletList, orderedList, listItem, blockquote, codeBlock, horizontalRule, hardBreak, cardEmbed, flexContainer, smartLink, resizeNode, mention`

  **La API acepta pero el frontend IGNORA** (resultado: documento vacío):
  `callout, taskList, taskItem, details, table, tableRow, tableCell, image, iframe`

  **Marks que renderizan:** `bold, italic, strike, code, link`
  **Marks ignorados:** `underline, highlight, subscript, textStyle`

  ## cardEmbed — SIEMPRE envolver en resizeNode

  Un `cardEmbed` desnudo renderiza pero queda con ~50px de alto. Metabase espera que vaya dentro de un `resizeNode`:

  ```python
  # MAL — card diminuta
  {"type": "cardEmbed", "attrs": {"id": 42}}

  # BIEN — usar el helper
  from metabase.documents import prosemirror_card_embed
  prosemirror_card_embed(42, height=450)
  # Genera: {"type": "resizeNode", "attrs": {"height": 450, "minHeight": 280},
  #          "content": [{"type": "cardEmbed", "attrs": {"id": 42, ...}}]}
  ```

code:
  """CRUD de documents de Metabase (feature reciente, Metabase 0.57+).

  Un document es un texto editable en Metabase (tipo Notion) serializado como
  ProseMirror JSON (content_type: application/json+vnd.prose-mirror). Permite
  embeber cards, smart links y flex containers.

  NODOS QUE RENDERIZAN (whitelist TipTap v0.59):
    doc, paragraph, text, heading (level 1-6),
    bulletList, orderedList, listItem,
    blockquote, codeBlock (attrs.language), horizontalRule, hardBreak,
    cardEmbed (attrs.id), flexContainer, smartLink (attrs.entityId),
    resizeNode, mention.

  NODOS QUE LA API ACEPTA PERO EL FRONTEND IGNORA (doc queda vacio):
    callout, taskList, taskItem, details, table, tableRow, tableCell,
    image, iframe.

  MARKS QUE RENDERIZAN: bold, italic, strike, code, link (attrs.href).
  MARKS IGNORADOS: underline, highlight, subscript, textStyle.

  IMPORTANTE — cardEmbed:
    Un cardEmbed desnudo renderiza pero queda muy pequeño (alto ~50px).
    Para que se vea correctamente, envolver en un resizeNode:

      {"type": "resizeNode",
       "attrs": {"height": 400, "minHeight": 280},
       "content": [
           {"type": "cardEmbed", "attrs": {"id": <card_id>}}
       ]}

    Usar el helper `prosemirror_card_embed(card_id)` para generar esto
    automaticamente.
  """

  import uuid

  from .client import MetabaseClient


  def prosemirror_card_embed(card_id: int, height: int = 400) -> dict:
      """Genera un nodo cardEmbed envuelto en resizeNode listo para ProseMirror.

      Un cardEmbed desnudo renderiza pero queda muy pequeño (~50px). Metabase
      espera que vaya dentro de un resizeNode con height/minHeight para que
      se vea con tamaño adecuado.

      Args:
          card_id: ID de la card/pregunta de Metabase a embeber.
          height: Altura en pixeles del embed (default 400). minHeight se
                  fija en 280 (lo que usa la UI de Metabase).

      Returns:
          Dict ProseMirror: resizeNode > cardEmbed, insertable directamente
          en el array content de un document.

      Example:
          >>> node = prosemirror_card_embed(7711, height=500)
          >>> doc = {"type": "doc", "content": [
          ...     {"type": "heading", "attrs": {"level": 1},
          ...      "content": [{"type": "text", "text": "Mi reporte"}]},
          ...     node,
          ... ]}
      """
      return {
          "type": "resizeNode",
          "attrs": {"height": height, "minHeight": 280},
          "content": [
              {
                  "type": "cardEmbed",
                  "attrs": {"id": card_id, "name": None, "_id": str(uuid.uuid4())},
              }
          ],
      }


  def _validate_before_send(name: str, document: dict | str) -> None:
      """Valida el payload del document antes de enviarlo. Raises ValueError."""
      if not document or document == "":
          return
      if not isinstance(document, dict):
          return
      from .validation import metabase_validate_document_payload

      issues = metabase_validate_document_payload({"name": name, "document": document})
      if issues:
          raise ValueError(
              f"Document no renderizará correctamente en Metabase "
              f"({len(issues)} issues):\n" + "\n".join(f"  - {i}" for i in issues)
          )


  def metabase_list_documents(client: MetabaseClient) -> list[dict]:
      """Lista documents de Metabase.

      Endpoint: GET /api/document. Retorna `{"items": [...]}` segun la API.

      Args:
          client: Cliente autenticado.

      Returns:
          Lista de dicts con: id, name, collection_id, archived, created_at,
          updated_at, creator_id, content_type, entity_id.

      Example:
          >>> docs = metabase_list_documents(client)
          >>> for d in docs:
          ...     print(d["id"], d["name"])
      """
      resp = client.request("GET", "/api/document")
      if isinstance(resp, dict) and "items" in resp:
          return resp["items"]
      return resp or []


  def metabase_get_document(client: MetabaseClient, document_id: int) -> dict:
      """Obtiene un document completo con su contenido ProseMirror.

      Endpoint: GET /api/document/:id.

      Args:
          client: Cliente autenticado.
          document_id: ID del document.

      Returns:
          Dict con: id, name, document (ProseMirror tree), collection_id,
          archived, creator, created_at, updated_at, entity_id, content_type.

      Example:
          >>> doc = metabase_get_document(client, 1)
          >>> print(doc["name"])
          >>> tree = doc["document"]  # {"type": "doc", "content": [...]}
      """
      return client.request("GET", f"/api/document/{document_id}")


  def metabase_create_document(
      client: MetabaseClient,
      name: str,
      document: dict,
      collection_id: int = 0,
      *,
      validate: bool = True,
  ) -> dict:
      """Crea un document nuevo.

      Endpoint: POST /api/document.

      Valida el arbol ProseMirror ANTES de enviar (por defecto). Si el
      documento contiene nodos que la API acepta pero el frontend ignora
      (callout, taskList, image, etc.), lanza ValueError con los issues.
      Pasar validate=False para desactivar (uso bajo tu riesgo).

      Para embeber cards, usar prosemirror_card_embed(card_id) que genera
      el nodo resizeNode > cardEmbed con la altura correcta.

      Args:
          client: Cliente autenticado.
          name: Titulo del document (1-254 chars, no blank).
          document: Arbol ProseMirror JSON. Formato minimo:
              {"type": "doc", "content": [{"type": "paragraph", "content": [...]}]}
              O cadena vacia "" si se quiere arrancar en blanco.
          collection_id: ID de coleccion destino. 0 = root.
          validate: Si True (default), valida el ProseMirror antes de enviar.

      Returns:
          Document creado con su id asignado.

      Raises:
          ValueError: Si validate=True y el arbol ProseMirror contiene
              nodos o marks que el frontend de Metabase no renderiza.

      Example:
          >>> from metabase.documents import prosemirror_card_embed
          >>> doc = metabase_create_document(client, "Reporte", {
          ...     "type": "doc",
          ...     "content": [
          ...         {"type": "heading", "attrs": {"level": 1},
          ...          "content": [{"type": "text", "text": "KPIs"}]},
          ...         prosemirror_card_embed(42, height=450),
          ...     ]
          ... })
      """
      if validate:
          _validate_before_send(name, document)
      body: dict = {"name": name, "document": document}
      if collection_id > 0:
          body["collection_id"] = collection_id
      return client.request("POST", "/api/document", json=body)


  def metabase_update_document(
      client: MetabaseClient,
      document_id: int,
      *,
      validate: bool = True,
      **fields,
  ) -> dict:
      """Actualiza un document. Solo se envian los campos pasados.

      Endpoint: PUT /api/document/:id.

      Si se pasa el campo 'document', valida el arbol ProseMirror antes de
      enviar (por defecto). Pasar validate=False para desactivar.

      Campos tipicos: name, document, collection_id, archived.

      Args:
          client: Cliente autenticado.
          document_id: ID del document a actualizar.
          validate: Si True (default), valida el ProseMirror antes de enviar.
          **fields: Campos a modificar.

      Returns:
          Document actualizado.

      Raises:
          ValueError: Si validate=True y el campo 'document' contiene
              nodos o marks no soportados por el frontend de Metabase.

      Example:
          >>> metabase_update_document(client, 1, name="Nuevo titulo")
          >>> metabase_update_document(client, 1, document={"type":"doc","content":[...]})
      """
      if validate and "document" in fields:
          name = fields.get("name", f"document_{document_id}")
          _validate_before_send(name, fields["document"])
      return client.request("PUT", f"/api/document/{document_id}", json=fields)


  def metabase_archive_document(client: MetabaseClient, document_id: int) -> dict:
      """Archiva un document (equivalente a PUT con archived=True).

      Metabase exige archive previo para poder eliminar.

      Args:
          client: Cliente autenticado.
          document_id: ID del document.

      Returns:
          Document con archived=True.
      """
      return client.request("PUT", f"/api/document/{document_id}", json={"archived": True})


  def metabase_list_document_comments(
      client: MetabaseClient,
      document_id: int,
      *,
      include_resolved: bool = True,
      include_deleted: bool = False,
  ) -> list[dict]:
      """Lista los comentarios de un document.

      Endpoint: GET /api/comment?target_type=document&target_id=:id.

      Un comentario puede estar anclado a un bloque especifico via
      `child_target_id` (UUID que matchea `attrs._id` de un parrafo) o al
      document entero si `child_target_id` es null. Las respuestas tipo thread
      cuelgan via `parent_comment_id`.

      Args:
          client: Cliente autenticado.
          document_id: ID del document.
          include_resolved: si False, filtra comentarios con is_resolved=True.
          include_deleted: si False, filtra comentarios con deleted_at no null.

      Returns:
          Lista de dicts con: id, content (arbol ProseMirror), creator, creator_id,
          target_type, target_id, child_target_id, parent_comment_id, is_resolved,
          deleted_at, reactions, created_at, updated_at.

      Example:
          >>> comments = metabase_list_document_comments(client, 29)
          >>> for c in comments:
          ...     text = _comment_plaintext(c["content"])
          ...     print(f'{c["creator"]["common_name"]}: {text}')
      """
      resp = client.request(
          "GET",
          "/api/comment",
          params={"target_type": "document", "target_id": document_id},
      )
      items = resp.get("comments", []) if isinstance(resp, dict) else []
      if not include_resolved:
          items = [c for c in items if not c.get("is_resolved")]
      if not include_deleted:
          items = [c for c in items if c.get("deleted_at") is None]
      return items


  def metabase_create_document_comment(
      client: MetabaseClient,
      document_id: int,
      content: dict,
      *,
      child_target_id: str | None = None,
      parent_comment_id: int | None = None,
  ) -> dict:
      """Crea un comentario en un document.

      Endpoint: POST /api/comment.

      Args:
          client: Cliente autenticado.
          document_id: ID del document donde comentar.
          content: Arbol ProseMirror del comentario. Ejemplo minimo:
              {"type":"doc","content":[{"type":"paragraph","content":[
                  {"type":"text","text":"mi comentario"}]}]}
          child_target_id: UUID del bloque del document al que se ancla el
              comentario (matchea `attrs._id` de un parrafo). None = a nivel doc.
          parent_comment_id: id del comentario al que se responde. None = top-level.

      Returns:
          Comentario creado.
      """
      body: dict = {
          "target_type": "document",
          "target_id": document_id,
          "content": content,
      }
      if child_target_id is not None:
          body["child_target_id"] = child_target_id
      if parent_comment_id is not None:
          body["parent_comment_id"] = parent_comment_id
      return client.request("POST", "/api/comment", json=body)


  def metabase_resolve_document_comment(client: MetabaseClient, comment_id: int) -> dict:
      """Marca un comentario como resuelto (is_resolved=True).

      Endpoint: PUT /api/comment/:id.
      """
      return client.request("PUT", f"/api/comment/{comment_id}", json={"is_resolved": True})


  def metabase_delete_document(client: MetabaseClient, document_id: int) -> None:
      """Elimina un document. REQUIERE archivarlo primero.

      Endpoint: DELETE /api/document/:id. Si el document no esta archivado,
      Metabase responde: "Document must be archived before it can be deleted."

      Args:
          client: Cliente autenticado.
          document_id: ID del document a eliminar.
      """
      client.request("DELETE", f"/api/document/{document_id}")


  def metabase_move_document(
      client: MetabaseClient,
      document_id: int,
      collection_id: int | None,
  ) -> dict:
      """Mueve un document a otra coleccion.

      Thin wrapper sobre metabase_update_document: envia solo collection_id.
      Endpoint: PUT /api/document/:id con {collection_id: ...}.

      Args:
          client: Cliente autenticado.
          document_id: ID del document a mover.
          collection_id: ID de coleccion destino. None mueve a la raiz ("Our analytics").

      Returns:
          Document actualizado con el nuevo collection_id.

      Example:
          >>> doc = metabase_move_document(client, 42, collection_id=7)
          >>> print(doc["collection_id"])  # 7
          >>> # Mover a raiz:
          >>> doc = metabase_move_document(client, 42, collection_id=None)
      """
      return client.request(
          "PUT",
          f"/api/document/{document_id}",
          json={"collection_id": collection_id},
      )


  def metabase_copy_document(
      client: MetabaseClient,
      document_id: int,
      name: str | None = None,
      collection_id: int | None = None,
  ) -> dict:
      """Copia un document usando el endpoint nativo de Metabase.

      Endpoint: POST /api/document/:id/copy.

      Args:
          client: Cliente autenticado.
          document_id: ID del document a copiar.
          name: Nombre del nuevo document. None = Metabase asigna nombre automatico.
          collection_id: Coleccion destino. None = misma coleccion del original.

      Returns:
          Document nuevo recien creado con su id asignado.

      Example:
          >>> copy = metabase_copy_document(client, 42, name="Backup Q1", collection_id=5)
          >>> print(copy["id"])
      """
      body: dict = {}
      if name is not None:
          body["name"] = name
      if collection_id is not None:
          body["collection_id"] = collection_id
      return client.request("POST", f"/api/document/{document_id}/copy", json=body)

params_schema:
  {"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)"},{"name":"validate","desc":"si True (default), valida el ProseMirror antes de enviar. Lanza ValueError si hay nodos no soportados"}],"output":"dict: document recien creado con id, entity_id y metadata"}


id: metabase_create_document_comment_py_infra
name: metabase_create_document_comment
kind: function
lang: py
domain: infra
version: 1.0.0
purity: impure
tags: ["metabase","document","comments","create","api","python"]
uses_functions: []
uses_types: []
returns: []
error_type: error_go_core
imports: ["httpx"]
tests: []
file_path: python/functions/metabase/documents.py
created_at: 2026-04-13T13:19:13Z
updated_at: 2026-04-14T15:24:30Z
props: []
emits: []
variant: []
content_hash: b17c508fc875170d3f979182b6e55c47062cf926782924ea9a52a7aa337245e8
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.

example:
  ```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")
  ```

notes:
  `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`).

code:
[cell repeats an earlier one in this file: 13730 characters]

params_schema:
  {"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"}


id: metabase_create_group_py_infra
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
tags: ["metabase","permissions","group","create","api","python"]
uses_functions: []
uses_types: []
returns: []
error_type: error_go_core
imports: []
tests: []
file_path: python/functions/metabase/permissions.py
created_at: 2026-04-13T13:34:41Z
updated_at: 2026-04-13T13:34:41Z
props: []
emits: []
variant: []
content_hash: c5b562a3c3b49961e527378ecb51d4e1c1d24b8e91d6f2d4dfb93216b234f2c4
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.

example:
  ```python
  group = metabase_create_group(client, "Analytics Team")
  print(group["id"], group["name"])
  ```

notes:
  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).

code:
  """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)

params_schema:
  {"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"}


id: metabase_create_model_py_infra
name: metabase_create_model
kind: function
lang: py
domain: infra
version: 1.0.0
purity: impure
tags: ["metabase","model","card","create","api","python"]
uses_functions: []
uses_types: []
returns: []
error_type: error_go_core
imports: ["httpx"]
tests: []
file_path: python/functions/metabase/cards.py
created_at: 2026-04-14T15:24:30Z
updated_at: 2026-04-14T15:24:30Z
props: []
emits: []
variant: []
content_hash: a6142c51d1e4b94bab3df3998ceb5cedec114220de83e7a236444d28f8eede08
signature:
  def metabase_create_model(client: MetabaseClient, name: str, sql: str, database_id: int, collection_id: int = 0, description: str = '') -> dict

description:
  Crea un modelo de Metabase (card con type='model') que otras cards MBQL pueden usar como fuente via source-table: 'card__<id>'.

example:
  ```python
  # Crear modelo base
  model = metabase_create_model(
      client,
      name="supply_orders_base",
      sql="SELECT * FROM supply_orders WHERE status != 'cancelled'",
      database_id=6,
      collection_id=42,
      description="Ordenes de supply excluyendo canceladas",
  )
  print(model["id"])  # ej: 7820

  # Usar el modelo como fuente en una card MBQL
  card = metabase_create_card(client, "Revenue por proveedor", {
      "database": 6,
      "type": "query",
      "query": {
          "source-table": f"card__{model['id']}",
          "aggregation": [["sum", ["field", "total", None]]],
          "breakout": [["field", "supplier_id", None]],
      },
  }, display="bar")
  ```

notes:
  Un modelo es una card con `type="model"`. Metabase lo trata como una capa de abstraccion — las cards MBQL que lo referencian via `source-table: "card__<id>"` se benefician del schema inferido del modelo (tipos de columna, foreign keys, etc.).

  A diferencia de `metabase_create_card`, esta funcion fuerza `type="model"` y siempre usa query SQL nativa. Para modelos MBQL o con configuracion avanzada (result_metadata, column types), usar `metabase_create_card_raw` con `type="model"` en el payload.

code:
[cell repeats an earlier one in this file: 15149 characters]

params_schema:
  {"params":[{"name":"client","desc":"instancia autenticada de MetabaseClient"},{"name":"name","desc":"nombre del modelo"},{"name":"sql","desc":"query SQL que define el modelo"},{"name":"database_id","desc":"ID de la database en Metabase donde vive la query"},{"name":"collection_id","desc":"ID de coleccion destino; 0 = root"},{"name":"description","desc":"descripcion opcional del modelo"}],"output":"dict con el modelo creado: id, name, type='model', dataset_query y metadata completa"}


id: metabase_create_snippet_py_infra
name: metabase_create_snippet
kind: function
lang: py
domain: infra
version: 1.0.0
purity: impure
tags: ["metabase","snippet","create","api","python"]
uses_functions: []
uses_types: ["MetabaseClient_go_infra"]
returns: []
error_type: error_go_core
imports: ["httpx"]
tests: []
file_path: python/functions/metabase/snippets.py
created_at: 2026-04-14T15:24:30Z
updated_at: 2026-04-14T15:24:30Z
props: []
emits: []
variant: []
content_hash: 408d9493fe6ccc134b71b321161ce21f09fcd0136d0ca683a6805f5fb8f63365
signature:
  def metabase_create_snippet(client: MetabaseClient, name: str, content: str, description: str = "", collection_id: int = 0) -> dict

description:
  Crea un nuevo SQL snippet reutilizable en Metabase. El snippet se referencia en queries nativas con {{snippet: nombre}}.

example:
  ```python
  # Crear snippet con CTE reutilizable
  snippet = metabase_create_snippet(
      client,
      "supply_orders_cte",
      """
  WITH supply_full AS (
      SELECT so.id, so.service_request_id,
             sr.channel_id, ch.name AS channel_name
      FROM supply_orders so
      LEFT JOIN service_requests sr ON so.service_request_id = sr.id
      LEFT JOIN channels ch ON sr.channel_id = ch.id
  )
  """,
      description="CTE base de supply_orders con 6 JOINs para cruce con NAV",
  )
  print(snippet["id"], snippet["name"])

  # Usar el snippet en una card
  from metabase.cards import metabase_create_card
  card = metabase_create_card(client, "Revenue", {
      "database": 6,
      "type": "native",
      "native": {
          "query": "{{snippet: supply_orders_cte}} SELECT channel_name, COUNT(*) FROM supply_full GROUP BY 1"
      }
  })
  ```

notes:
  El campo `name` debe ser unico en Metabase — el servidor retorna 400 si ya existe un snippet con ese nombre.
  Si `collection_id` es 0 o no se provee, el snippet se guarda en la raiz.
  Solo se envian al body los campos no vacios/cero para evitar conflictos con defaults del servidor.

code:
  """CRUD de SQL snippets de Metabase.

  Un snippet es un fragmento SQL reutilizable que se referencia en queries
  nativas con la sintaxis {{snippet: nombre}}. Ideal para CTEs comunes
  que se repiten en multiples cards.
  """

  from .client import MetabaseClient


  def metabase_list_snippets(client: MetabaseClient, archived: bool = False) -> list[dict]:
      """Lista SQL snippets de Metabase.

      Endpoint: GET /api/native-query-snippet.

      Args:
          client: Cliente autenticado.
          archived: Si True, incluye snippets archivados.

      Returns:
          Lista de dicts con: id, name, content, description, collection_id,
          creator_id, archived, created_at, updated_at.

      Example:
          >>> snippets = metabase_list_snippets(client)
          >>> for s in snippets:
          ...     print(s["name"], len(s["content"]))
      """
      params = {}
      if archived:
          params["archived"] = "true"
      return client.request("GET", "/api/native-query-snippet", params=params)


  def metabase_get_snippet(client: MetabaseClient, snippet_id: int) -> dict:
      """Obtiene un SQL snippet de Metabase por su ID.

      Endpoint: GET /api/native-query-snippet/:id.

      Args:
          client: Cliente autenticado.
          snippet_id: ID numerico del snippet.

      Returns:
          Dict con campos completos del snippet: id, name, content, description,
          collection_id, creator_id, archived, created_at, updated_at.

      Raises:
          httpx.HTTPStatusError: 404 si el snippet no existe.

      Example:
          >>> snippet = metabase_get_snippet(client, 42)
          >>> print(snippet["name"], snippet["content"])
      """
      return client.request("GET", f"/api/native-query-snippet/{snippet_id}")


  def metabase_create_snippet(
      client: MetabaseClient,
      name: str,
      content: str,
      description: str = "",
      collection_id: int = 0,
  ) -> dict:
      """Crea un nuevo SQL snippet en Metabase.

      Endpoint: POST /api/native-query-snippet.

      Args:
          client: Cliente autenticado.
          name: Nombre del snippet. Se usa en queries con {{snippet: nombre}}.
          content: SQL del snippet. Puede ser una CTE completa, una subquery,
              o cualquier fragmento SQL reutilizable.
          description: Descripcion opcional del snippet.
          collection_id: ID de la coleccion donde guardar el snippet.
              Si es 0, se guarda en la raiz (Our analytics).

      Returns:
          Dict con el snippet creado, incluyendo el campo "id" asignado
          por Metabase.

      Raises:
          httpx.HTTPStatusError: 400 si el nombre ya existe o el SQL es invalido.

      Example:
          >>> snippet = metabase_create_snippet(
          ...     client,
          ...     "supply_orders_cte",
          ...     '''WITH supply_full AS (
          ...         SELECT so.id, so.service_request_id
          ...         FROM supply_orders so
          ...         LEFT JOIN service_requests sr ON so.service_request_id = sr.id
          ...     )''',
          ...     description="CTE base de supply_orders con JOINs para cruce con NAV",
          ... )
          >>> print(snippet["id"], snippet["name"])
          >>> # Usar en una card:
          >>> # "{{snippet: supply_orders_cte}} SELECT ... FROM supply_full"
      """
      body: dict = {"name": name, "content": content}
      if description:
          body["description"] = description
      if collection_id:
          body["collection_id"] = collection_id
      return client.request("POST", "/api/native-query-snippet", json=body)


  def metabase_update_snippet(
      client: MetabaseClient,
      snippet_id: int,
      **fields,
  ) -> dict:
      """Actualiza campos de un SQL snippet en Metabase.

      Endpoint: PUT /api/native-query-snippet/:id.

      Args:
          client: Cliente autenticado.
          snippet_id: ID numerico del snippet a actualizar.
          **fields: Campos a modificar. Campos validos:
              - name (str): Nuevo nombre del snippet.
              - content (str): Nuevo SQL del snippet.
              - description (str): Nueva descripcion.
              - collection_id (int): Nueva coleccion.
              - archived (bool): True para archivar, False para desarchivar.

      Returns:
          Dict con el snippet actualizado.

      Raises:
          httpx.HTTPStatusError: 404 si el snippet no existe.
          httpx.HTTPStatusError: 400 si los campos son invalidos.

      Example:
          >>> updated = metabase_update_snippet(
          ...     client, 42,
          ...     content="WITH supply_full AS (SELECT * FROM supply_orders)",
          ...     description="Version simplificada",
          ... )
          >>> print(updated["updated_at"])
      """
      valid_fields = {"name", "content", "description", "collection_id", "archived"}
      body = {k: v for k, v in fields.items() if k in valid_fields}
      return client.request("PUT", f"/api/native-query-snippet/{snippet_id}", json=body)


  def metabase_archive_snippet(client: MetabaseClient, snippet_id: int) -> dict:
      """Archiva un SQL snippet en Metabase.

      Wrapper sobre metabase_update_snippet con archived=True.
      Los snippets archivados no aparecen en el autocomplete de queries
      pero sus referencias existentes siguen funcionando.

      Endpoint: PUT /api/native-query-snippet/:id.

      Args:
          client: Cliente autenticado.
          snippet_id: ID numerico del snippet a archivar.

      Returns:
          Dict con el snippet archivado.

      Raises:
          httpx.HTTPStatusError: 404 si el snippet no existe.

      Example:
          >>> result = metabase_archive_snippet(client, 42)
          >>> print(result["archived"])  # True
      """
      return metabase_update_snippet(client, snippet_id, archived=True)

params_schema:
  {"params":[{"name":"client","desc":"instancia autenticada de MetabaseClient"},{"name":"name","desc":"nombre del snippet; se usa en queries con la sintaxis {{snippet: nombre}}"},{"name":"content","desc":"SQL del snippet: puede ser una CTE completa, subquery o cualquier fragmento SQL reutilizable"},{"name":"description","desc":"descripcion opcional del proposito del snippet"},{"name":"collection_id","desc":"ID de la coleccion donde guardar el snippet; 0 para la raiz (Our analytics)"}],"output":"dict: snippet creado con id asignado por Metabase, name, content, description, collection_id, created_at"}


id: metabase_create_user_py_infra
name: metabase_create_user
kind: function
lang: py
domain: infra
version: 1.0.0
purity: impure
tags: ["metabase","user","create","api","python"]
uses_functions: []
uses_types: []
returns: []
error_type: error_go_core
imports: ["httpx"]
tests: []
file_path: python/functions/metabase/users.py
created_at: 2026-04-05T11:22:32Z
updated_at: 2026-04-05T16:44:15Z
props: []
emits: []
variant: []
notes: Email debe ser unico. Error 400 si ya existe.
content_hash: 2e89f34d147d8240040dfde6024d8277b5bde5a8e96c136d95c64c4ae419cfd9
signature:
  def metabase_create_user(client: MetabaseClient, first_name: str, last_name: str, email: str, password: str = '', group_ids: list[int] | None = None) -> dict

description:
  Crea un nuevo usuario en Metabase. Sin password envia invitacion por email. Requiere superusuario. Endpoint: POST /api/user.

example:
  ```python
  user = metabase_create_user(client, "John", "Doe", "john@example.com", "pass123")
  user = metabase_create_user(client, "Jane", "Smith", "jane@example.com", group_ids=[1, 3])
  ```

code:
  """CRUD de usuarios de Metabase."""

  from .client import MetabaseClient


  def metabase_list_users(
      client: MetabaseClient,
      status: str = "",
      query: str = "",
      limit: int = 0,
      offset: int = 0,
  ) -> dict:
      """Lista usuarios de Metabase con filtros opcionales.

      Endpoint: GET /api/user. Requiere permisos de superusuario.

      Args:
          client: Cliente autenticado.
          status: "active" (default), "deactivated" o "all".
          query: Filtro por nombre o email.
          limit: Tamanio de pagina (0 = default Metabase).
          offset: Offset para paginacion.

      Returns:
          Dict con estructura paginada:
          - data: lista de usuarios (id, email, first_name, last_name, is_superuser, etc.)
          - total: numero total de usuarios que coinciden
          - limit: tamanio de pagina usado
          - offset: offset usado

      Example:
          >>> users = metabase_list_users(client, status="active", query="john@")
          >>> for u in users["data"]:
          ...     print(u["email"], u["first_name"])
      """
      params = {}
      if status:
          params["status"] = status
      if query:
          params["query"] = query
      if limit > 0:
          params["limit"] = limit
      if offset > 0:
          params["offset"] = offset
      return client.request("GET", "/api/user", params=params)


  def metabase_get_user(client: MetabaseClient, user_id: int) -> dict:
      """Obtiene un usuario de Metabase por su ID.

      Endpoint: GET /api/user/:id.

      Args:
          client: Cliente autenticado.
          user_id: ID numerico del usuario.

      Returns:
          Dict con datos del usuario: id, email, first_name, last_name,
          is_superuser, is_active, common_name, date_joined, last_login,
          group_ids, locale.

      Raises:
          httpx.HTTPStatusError: 404 si el usuario no existe.

      Example:
          >>> user = metabase_get_user(client, 1)
          >>> print(user["email"], user["is_superuser"])
      """
      return client.request("GET", f"/api/user/{user_id}")


  def metabase_create_user(
      client: MetabaseClient,
      first_name: str,
      last_name: str,
      email: str,
      password: str = "",
      group_ids: list[int] | None = None,
  ) -> dict:
      """Crea un nuevo usuario en Metabase.

      Endpoint: POST /api/user. Requiere permisos de superusuario.

      Args:
          client: Cliente autenticado con permisos admin.
          first_name: Nombre del usuario.
          last_name: Apellido del usuario.
          email: Email unico del usuario.
          password: Password. Vacio = Metabase envia invitacion por email.
          group_ids: IDs de grupos a asignar. None = solo grupo default.

      Returns:
          Dict con el usuario creado (mismos campos que metabase_get_user).

      Raises:
          httpx.HTTPStatusError: 400 si el email ya existe.

      Example:
          >>> user = metabase_create_user(client, "John", "Doe", "john@example.com", "pass123")
          >>> print(user["id"])
      """
      body: dict = {
          "first_name": first_name,
          "last_name": last_name,
          "email": email,
      }
      if password:
          body["password"] = password
      if group_ids:
          body["group_ids"] = group_ids
      return client.request("POST", "/api/user", json=body)


  def metabase_update_user(client: MetabaseClient, user_id: int, **fields) -> dict:
      """Actualiza campos de un usuario en Metabase.

      Endpoint: PUT /api/user/:id. Requiere permisos de superusuario.
      Solo se modifican los campos pasados como keyword arguments.

      Args:
          client: Cliente autenticado con permisos admin.
          user_id: ID del usuario a actualizar.
          **fields: Campos a actualizar. Validos:
              first_name (str), last_name (str), email (str),
              is_superuser (bool), group_ids (list[int]),
              locale (str), login_attributes (dict).

      Returns:
          Dict con el usuario actualizado.

      Example:
          >>> user = metabase_update_user(client, 5, first_name="Jane", is_superuser=True)
          >>> user = metabase_update_user(client, 5, group_ids=[1, 3, 5])
      """
      return client.request("PUT", f"/api/user/{user_id}", json=fields)


  def metabase_deactivate_user(client: MetabaseClient, user_id: int) -> None:
      """Desactiva (soft-delete) un usuario en Metabase.

      Endpoint: DELETE /api/user/:id. Requiere permisos de superusuario.
      El usuario no se elimina permanentemente, solo se marca como inactivo.
      Para reactivar: PUT /api/user/:id/reactivate.

      Args:
          client: Cliente autenticado con permisos admin.
          user_id: ID del usuario a desactivar.

      Example:
          >>> metabase_deactivate_user(client, 5)
          >>> # Para ver desactivados: metabase_list_users(client, status="deactivated")
      """
      client.request("DELETE", f"/api/user/{user_id}")

params_schema:
  {"params":[{"name":"client","desc":"instancia autenticada de MetabaseClient"},{"name":"first_name","desc":"nombre del usuario"},{"name":"last_name","desc":"apellido del usuario"},{"name":"email","desc":"correo único del usuario"},{"name":"password","desc":"contraseña (vacío envía invitación por email)"},{"name":"group_ids","desc":"lista de IDs de grupos a los que agregar el usuario"}],"output":"dict: objeto usuario creado con id y metadata"}


id: metabase_deactivate_user_py_infra
name: metabase_deactivate_user
kind: function
lang: py
domain: infra
version: 1.0.0
purity: impure
signature: def metabase_deactivate_user(client: MetabaseClient, user_id: int) -> None
tags: ["metabase","user","delete","deactivate","api","python"]
uses_functions: []
uses_types: []
returns: []
error_type: error_go_core
imports: ["httpx"]
example:
  ```python
  metabase_deactivate_user(client, 5)
  ```

tests: []
file_path: python/functions/metabase/users.py
created_at: 2026-04-05T11:22:32Z
updated_at: 2026-04-05T16:44:15Z
props: []
emits: []
variant: []
notes: Soft-delete. El usuario se puede reactivar.
content_hash: 99ef9d8c029ad38753b4d39dd89de9909ca9c4c531d9c91d675b0c7e24a6427b
description:
  Desactiva (soft-delete) un usuario en Metabase. Reactivar con PUT /api/user/:id/reactivate. Endpoint: DELETE /api/user/:id.

code:
  """CRUD de usuarios de Metabase."""

  from .client import MetabaseClient


  def metabase_list_users(
      client: MetabaseClient,
      status: str = "",
      query: str = "",
      limit: int = 0,
      offset: int = 0,
  ) -> dict:
      """Lista usuarios de Metabase con filtros opcionales.

      Endpoint: GET /api/user. Requiere permisos de superusuario.

      Args:
          client: Cliente autenticado.
          status: "active" (default), "deactivated" o "all".
          query: Filtro por nombre o email.
          limit: Tamanio de pagina (0 = default Metabase).
          offset: Offset para paginacion.

      Returns:
          Dict con estructura paginada:
          - data: lista de usuarios (id, email, first_name, last_name, is_superuser, etc.)
          - total: numero total de usuarios que coinciden
          - limit: tamanio de pagina usado
          - offset: offset usado

      Example:
          >>> users = metabase_list_users(client, status="active", query="john@")
          >>> for u in users["data"]:
          ...     print(u["email"], u["first_name"])
      """
      params = {}
      if status:
          params["status"] = status
      if query:
          params["query"] = query
      if limit > 0:
          params["limit"] = limit
      if offset > 0:
          params["offset"] = offset
      return client.request("GET", "/api/user", params=params)


  def metabase_get_user(client: MetabaseClient, user_id: int) -> dict:
      """Obtiene un usuario de Metabase por su ID.

      Endpoint: GET /api/user/:id.

      Args:
          client: Cliente autenticado.
          user_id: ID numerico del usuario.

      Returns:
          Dict con datos del usuario: id, email, first_name, last_name,
          is_superuser, is_active, common_name, date_joined, last_login,
          group_ids, locale.

      Raises:
          httpx.HTTPStatusError: 404 si el usuario no existe.

      Example:
          >>> user = metabase_get_user(client, 1)
          >>> print(user["email"], user["is_superuser"])
      """
      return client.request("GET", f"/api/user/{user_id}")


  def metabase_create_user(
      client: MetabaseClient,
      first_name: str,
      last_name: str,
      email: str,
      password: str = "",
      group_ids: list[int] | None = None,
  ) -> dict:
      """Crea un nuevo usuario en Metabase.

      Endpoint: POST /api/user. Requiere permisos de superusuario.

      Args:
          client: Cliente autenticado con permisos admin.
          first_name: Nombre del usuario.
          last_name: Apellido del usuario.
          email: Email unico del usuario.
          password: Password. Vacio = Metabase envia invitacion por email.
          group_ids: IDs de grupos a asignar. None = solo grupo default.

      Returns:
          Dict con el usuario creado (mismos campos que metabase_get_user).

      Raises:
          httpx.HTTPStatusError: 400 si el email ya existe.

      Example:
          >>> user = metabase_create_user(client, "John", "Doe", "john@example.com", "pass123")
          >>> print(user["id"])
      """
      body: dict = {
          "first_name": first_name,
          "last_name": last_name,
          "email": email,
      }
      if password:
          body["password"] = password
      if group_ids:
          body["group_ids"] = group_ids
      return client.request("POST", "/api/user", json=body)


  def metabase_update_user(client: MetabaseClient, user_id: int, **fields) -> dict:
      """Actualiza campos de un usuario en Metabase.

      Endpoint: PUT /api/user/:id. Requiere permisos de superusuario.
      Solo se modifican los campos pasados como keyword arguments.

      Args:
          client: Cliente autenticado con permisos admin.
          user_id: ID del usuario a actualizar.
          **fields: Campos a actualizar. Validos:
              first_name (str), last_name (str), email (str),
              is_superuser (bool), group_ids (list[int]),
              locale (str), login_attributes (dict).

      Returns:
          Dict con el usuario actualizado.

      Example:
          >>> user = metabase_update_user(client, 5, first_name="Jane", is_superuser=True)
          >>> user = metabase_update_user(client, 5, group_ids=[1, 3, 5])
      """
      return client.request("PUT", f"/api/user/{user_id}", json=fields)


  def metabase_deactivate_user(client: MetabaseClient, user_id: int) -> None:
      """Desactiva (soft-delete) un usuario en Metabase.

      Endpoint: DELETE /api/user/:id. Requiere permisos de superusuario.
      El usuario no se elimina permanentemente, solo se marca como inactivo.
      Para reactivar: PUT /api/user/:id/reactivate.

      Args:
          client: Cliente autenticado con permisos admin.
          user_id: ID del usuario a desactivar.

      Example:
          >>> metabase_deactivate_user(client, 5)
          >>> # Para ver desactivados: metabase_list_users(client, status="deactivated")
      """
      client.request("DELETE", f"/api/user/{user_id}")

params_schema:
  {"params":[{"name":"client","desc":"instancia autenticada de MetabaseClient"},{"name":"user_id","desc":"ID del usuario a desactivar"}],"output":"None"}


id: metabase_delete_card_py_infra
name: metabase_delete_card
kind: function
lang: py
domain: infra
version: 1.0.0
purity: impure
signature: def metabase_delete_card(client: MetabaseClient, card_id: int) -> None
tags: ["metabase","card","question","delete","api","python"]
uses_functions: []
uses_types: []
returns: []
error_type: error_go_core
imports: ["httpx"]
tests: []
file_path: python/functions/metabase/cards.py
created_at: 2026-04-05T11:22:32Z
updated_at: 2026-04-14T15:24:30Z
props: []
emits: []
variant: []
notes: IRREVERSIBLE. Preferir soft-delete con metabase_update_card(archived=True).
content_hash: de1ecf54dfc57035f5905a90d1de2fdeba6fab971c0d2a73f5f39d2ca633939b
description:
  Elimina permanentemente una card/pregunta. IRREVERSIBLE. Preferir archived=True. Endpoint: DELETE /api/card/:id.

example:
  ```python
  metabase_delete_card(client, 42)
  # Preferir: metabase_update_card(client, 42, archived=True)
  ```

code:
[cell repeats an earlier one in this file: 15149 characters]

params_schema:
  {"params":[{"name":"client","desc":"instancia autenticada de MetabaseClient"},{"name":"card_id","desc":"ID de la card a eliminar permanentemente"}],"output":"None"}


id: metabase_delete_dashboard_py_infra
name: metabase_delete_dashboard
kind: function
lang: py
domain: infra
version: 1.0.0
purity: impure
signature: def metabase_delete_dashboard(client: MetabaseClient, dashboard_id: int) -> None
tags: ["metabase","dashboard","delete","api","python"]
uses_functions: []
uses_types: []
returns: []
error_type: error_go_core
imports: ["httpx"]
tests: []
file_path: python/functions/metabase/dashboards.py
created_at: 2026-04-05T11:22:32Z
updated_at: 2026-04-13T13:34:41Z
props: []
emits: []
variant: []
notes: IRREVERSIBLE. Preferir soft-delete con metabase_update_dashboard(archived=True).
content_hash: 65f23ed46991980d64708567e612c6dab744adf0b473e7b1be3a152c9bd9747e
description:
  Elimina permanentemente un dashboard. IRREVERSIBLE. Preferir archived=True. Endpoint: DELETE /api/dashboard/:id.

example:
  ```python
  metabase_delete_dashboard(client, 1)
  # Preferir: metabase_update_dashboard(client, 1, archived=True)
  ```

code:
[cell repeats an earlier one in this file: 11317 characters]

params_schema:
  {"params":[{"name":"client","desc":"instancia autenticada de MetabaseClient"},{"name":"dashboard_id","desc":"ID del dashboard a eliminar permanentemente"}],"output":"None"}


id: metabase_delete_document_py_infra
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: []
error_type: error_go_core
imports: ["httpx"]
example:
  ```python
  metabase_archive_document(client, 1)
  metabase_delete_document(client, 1)
  ```

tests: []
file_path: python/functions/metabase/documents.py
created_at: 2026-04-13T12:54:37Z
updated_at: 2026-04-14T15:24:30Z
props: []
emits: []
variant: []
content_hash: 995d1bf5b663a00021e53751d58f69750542e24051f71495714ab4962c06fe80
notes:
  Si se llama sin archivar antes, Metabase responde: `"Document must be archived before it can be deleted."`

code:
[cell repeats an earlier one in this file: 13730 characters]

params_schema:
  {"params":[{"name":"client","desc":"instancia autenticada de MetabaseClient"},{"name":"document_id","desc":"ID del document a eliminar"}],"output":"None"}


id: metabase_delete_group_py_infra
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
tags: ["metabase","permissions","group","delete","api","python"]
uses_functions: []
uses_types: []
returns: []
error_type: error_go_core
imports: []
tests: []
file_path: python/functions/metabase/permissions.py
created_at: 2026-04-13T13:34:41Z
updated_at: 2026-04-13T13:34:41Z
props: []
emits: []
variant: []
content_hash: 036d5ea4ee4ab25323b6ced4b9fa6b47786b9ec38be3ec15f7cd99364b0cc28f
description:
  Elimina permanentemente un Permission Group de Metabase. IRREVERSIBLE. No borra los usuarios, solo el grupo. Endpoint: DELETE /api/permissions/group/:id.

example:
  ```python
  metabase_delete_group(client, 5)
  # CUIDADO: no pasar group_id=1 (All Users) ni group_id=2 (Administrators)
  ```

notes:
  **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.

code:
  """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)

params_schema:
  {"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)"}


id: metabase_delete_membership_py_infra
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
tags: ["metabase","permissions","membership","groups","users","delete","api","python"]
uses_functions: []
uses_types: []
returns: []
error_type: error_go_core
imports: []
tests: []
file_path: python/functions/metabase/permissions.py
created_at: 2026-04-13T13:35:56Z
updated_at: 2026-04-13T13:35:56Z
props: []
emits: []
variant: []
content_hash: f51d556fb04b7d98d9076de8c8f236859c16b2c1fa76d482b79b1825910625b0
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.

example:
  ```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)
  ```

notes:
  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.

code:
[cell repeats an earlier one in this file: 13465 characters]

params_schema:
  {"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"}


id: metabase_delete_notification_py_infra
name: metabase_delete_notification
kind: function
lang: py
domain: infra
version: 1.0.0
purity: impure
signature: def metabase_delete_notification(client: MetabaseClient, notification_id: int) -> None
tags: ["metabase","notification","delete","api","python"]
uses_functions: []
uses_types: []
returns: []
error_type: error_go_core
imports: []
tests: []
file_path: python/functions/metabase/notifications.py
created_at: 2026-04-14T15:24:30Z
updated_at: 2026-04-14T15:24:30Z
props: []
emits: []
variant: []
content_hash: 85d1dcdc1c1f649fb559f3d990417eb1e56f888c389919616790cdcc252a5b05
description:
  Elimina una notificacion de Metabase (alerta de card o suscripcion de dashboard). Operacion irreversible. Endpoint: DELETE /api/notification/:id.

example:
  ```python
  # Eliminar una alerta por su ID
  metabase_delete_notification(client, 99)

  # Patron: listar y eliminar todas las alertas de una card
  alerts = metabase_list_notifications(client, card_id=7711)
  for a in alerts:
      metabase_delete_notification(client, a["id"])
  ```

notes:
  La operacion es irreversible. Para desactivar temporalmente usar metabase_update_notification con active=False.
  Elimina tanto alertas de cards (notification/card) como suscripciones de dashboards (notification/dashboard).

code:
  """CRUD de notificaciones de Metabase (alertas y suscripciones).

  Dos tipos:
    - Card alerts (notification/card): alertas cuando una card tiene resultados,
      supera un goal, etc. Cron configurable.
    - Dashboard subscriptions (notification/dashboard): envio periodico de un
      dashboard completo por email/slack.

  API: /api/notification (Metabase 0.57+, reemplaza el antiguo /api/pulse).
  """

  from .client import MetabaseClient


  def metabase_list_notifications(
      client: MetabaseClient,
      card_id: int = 0,
      dashboard_id: int = 0,
  ) -> list[dict]:
      """Lista notificaciones activas de Metabase.

      Endpoint: GET /api/notification.
      Permite filtrar por card o por dashboard. Si no se pasan filtros,
      retorna todas las notificaciones accesibles por el usuario autenticado.

      Args:
          client: Cliente autenticado.
          card_id: Si > 0, filtra notificaciones de tipo notification/card
              asociadas a esta card.
          dashboard_id: Si > 0, filtra notificaciones de tipo
              notification/dashboard asociadas a este dashboard.

      Returns:
          Lista de dicts con: id, active, payload_type, payload,
          subscriptions, handlers, created_at, updated_at.

      Example:
          >>> # Todas las notificaciones
          >>> notifs = metabase_list_notifications(client)
          >>> # Solo alertas de una card especifica
          >>> alerts = metabase_list_notifications(client, card_id=7711)
          >>> for a in alerts:
          ...     print(a["id"], a["payload_type"], a["active"])
      """
      params: dict = {}
      if card_id > 0:
          params["card_id"] = card_id
      if dashboard_id > 0:
          params["dashboard_id"] = dashboard_id
      return client.request("GET", "/api/notification", params=params)


  def metabase_create_card_alert(
      client: MetabaseClient,
      card_id: int,
      cron_schedule: str,
      recipients: list[dict],
      send_condition: str = "has_result",
      send_once: bool = False,
  ) -> dict:
      """Crea una alerta sobre los resultados de una card.

      Endpoint: POST /api/notification.
      Envia un email cuando la card cumple la condicion especificada
      (tiene resultados, supera un goal, etc.) segun el cron configurado.

      Args:
          client: Cliente autenticado.
          card_id: ID de la card que dispara la alerta.
          cron_schedule: Expresion cron de 5 campos (ej: "0 9 * * 1" = lunes 9am).
          recipients: Lista de destinatarios. Cada dict puede ser:
              - Usuario Metabase: {"type": "notification-recipient/user", "user_id": 1}
              - Email externo: {"type": "notification-recipient/raw-value",
                                "details": {"email": "x@y.com"}}
          send_condition: Condicion que dispara el envio:
              "has_result" (tiene filas), "goal_above" (supera goal),
              "goal_below" (cae bajo goal). Default: "has_result".
          send_once: Si True, se envia una sola vez y se desactiva.
              Default: False.

      Returns:
          Dict con la notificacion creada: id, active, payload_type,
          payload, subscriptions, handlers, created_at.

      Example:
          >>> alert = metabase_create_card_alert(
          ...     client,
          ...     card_id=7711,
          ...     cron_schedule="0 9 * * 1",
          ...     recipients=[
          ...         {"type": "notification-recipient/user", "user_id": 1},
          ...         {"type": "notification-recipient/raw-value",
          ...          "details": {"email": "team@aurgi.com"}},
          ...     ],
          ...     send_condition="has_result",
          ... )
          >>> print(alert["id"], alert["active"])
      """
      body = {
          "payload_type": "notification/card",
          "payload": {
              "card_id": card_id,
              "send_condition": send_condition,
              "send_once": send_once,
          },
          "subscriptions": [
              {
                  "type": "notification-subscription/cron",
                  "cron_schedule": cron_schedule,
              }
          ],
          "handlers": [
              {
                  "channel_type": "channel/email",
                  "recipients": recipients,
              }
          ],
          "active": True,
      }
      return client.request("POST", "/api/notification", json=body)


  def metabase_create_dashboard_subscription(
      client: MetabaseClient,
      dashboard_id: int,
      cron_schedule: str,
      recipients: list[dict],
  ) -> dict:
      """Crea una suscripcion periodica a un dashboard.

      Endpoint: POST /api/notification.
      Envia el dashboard completo por email segun el cron configurado.

      Args:
          client: Cliente autenticado.
          dashboard_id: ID del dashboard a enviar.
          cron_schedule: Expresion cron de 5 campos (ej: "0 8 * * 1-5" = lun-vie 8am).
          recipients: Lista de destinatarios. Cada dict puede ser:
              - Usuario Metabase: {"type": "notification-recipient/user", "user_id": 1}
              - Email externo: {"type": "notification-recipient/raw-value",
                                "details": {"email": "x@y.com"}}

      Returns:
          Dict con la suscripcion creada: id, active, payload_type,
          payload, subscriptions, handlers, created_at.

      Example:
          >>> sub = metabase_create_dashboard_subscription(
          ...     client,
          ...     dashboard_id=42,
          ...     cron_schedule="0 8 * * 1",
          ...     recipients=[
          ...         {"type": "notification-recipient/user", "user_id": 5},
          ...     ],
          ... )
          >>> print(sub["id"], sub["payload"]["dashboard_id"])
      """
      body = {
          "payload_type": "notification/dashboard",
          "payload": {
              "dashboard_id": dashboard_id,
          },
          "subscriptions": [
              {
                  "type": "notification-subscription/cron",
                  "cron_schedule": cron_schedule,
              }
          ],
          "handlers": [
              {
                  "channel_type": "channel/email",
                  "recipients": recipients,
              }
          ],
          "active": True,
      }
      return client.request("POST", "/api/notification", json=body)


  def metabase_update_notification(
      client: MetabaseClient,
      notification_id: int,
      **fields,
  ) -> dict:
      """Actualiza una notificacion existente (alerta o suscripcion).

      Endpoint: PUT /api/notification/:id.
      Permite modificar campos como active, handlers, subscriptions,
      payload, etc. Solo se envian los campos proporcionados.

      Args:
          client: Cliente autenticado.
          notification_id: ID de la notificacion a modificar.
          **fields: Campos a actualizar. Ejemplos:
              active=False para desactivar.
              handlers=[...] para cambiar destinatarios.
              subscriptions=[...] para cambiar el cron.

      Returns:
          Dict con la notificacion actualizada: id, active, payload_type,
          payload, subscriptions, handlers, updated_at.

      Example:
          >>> # Desactivar una alerta
          >>> updated = metabase_update_notification(client, 99, active=False)
          >>> # Cambiar destinatarios
          >>> updated = metabase_update_notification(
          ...     client,
          ...     99,
          ...     handlers=[{
          ...         "channel_type": "channel/email",
          ...         "recipients": [
          ...             {"type": "notification-recipient/user", "user_id": 2},
          ...         ],
          ...     }],
          ... )
          >>> print(updated["active"])
      """
      return client.request("PUT", f"/api/notification/{notification_id}", json=fields)


  def metabase_delete_notification(
      client: MetabaseClient,
      notification_id: int,
  ) -> None:
      """Elimina una notificacion (alerta o suscripcion) de Metabase.

      Endpoint: DELETE /api/notification/:id.
      La operacion es irreversible. Afecta tanto a alertas de cards
      como a suscripciones de dashboards.

      Args:
          client: Cliente autenticado.
          notification_id: ID de la notificacion a eliminar.

      Returns:
          None.

      Example:
          >>> metabase_delete_notification(client, 99)
          >>> # La notificacion ya no existe
      """
      client.request("DELETE", f"/api/notification/{notification_id}")

params_schema:
  {"params":[{"name":"client","desc":"instancia autenticada de MetabaseClient"},{"name":"notification_id","desc":"ID de la notificacion a eliminar"}],"output":"None"}


id: metabase_execute_card_py_infra
name: metabase_execute_card
kind: function
lang: py
domain: infra
version: 1.0.0
purity: impure
tags: ["metabase","card","question","execute","query","api","python"]
uses_functions: []
uses_types: []
returns: []
error_type: error_go_core
imports: ["httpx"]
tests: []
file_path: python/functions/metabase/cards.py
created_at: 2026-04-05T11:22:32Z
updated_at: 2026-04-14T15:24:30Z
props: []
emits: []
variant: []
content_hash: de83cf3cb7fd3b3c757f45bc82738f236260ed263290f7a1a398f35743ef38d9
signature:
  def metabase_execute_card(client: MetabaseClient, card_id: int, parameters: list[dict] | None = None) -> dict

description:
  Ejecuta la query de una card guardada y retorna resultados con columnas y filas. Soporta parametros. Endpoint: POST /api/card/:id/query.

example:
  ```python
  result = metabase_execute_card(client, 42)
  for row in result["data"]["rows"]:
      print(row)
  ```

notes:
  Respuesta: status, row_count, running_time, data.columns, data.rows, data.cols.
  Limite default: 2000 filas. Para ad-hoc sin card usar metabase_execute_query.

code:
[cell repeats an earlier one in this file: 15149 characters]

params_schema:
  {"params":[{"name":"client","desc":"instancia autenticada de MetabaseClient"},{"name":"card_id","desc":"ID de la card guardada a ejecutar"},{"name":"parameters","desc":"lista de parámetros para la query (filtros dinámicos)"}],"output":"dict: resultados con status, row_count, running_time, data.columns, data.rows"}


id: metabase_execute_query_py_infra
name: metabase_execute_query
kind: function
lang: py
domain: infra
version: 1.0.0
purity: impure
tags: ["metabase","query","execute","sql","dataset","api","python"]
uses_functions: []
uses_types: []
returns: []
error_type: error_go_core
imports: ["httpx"]
tests: []
file_path: python/functions/metabase/cards.py
created_at: 2026-04-05T11:22:32Z
updated_at: 2026-04-14T15:24:30Z
props: []
emits: []
variant: []
notes: Misma respuesta que metabase_execute_card. Default 2000 filas, override con max_results.
content_hash: b6e0f205ca8ca5d6dfe5e59369dfed5f3e25ec774575d4194e5c962b4d09594b
signature:
  def metabase_execute_query(client: MetabaseClient, database_id: int, sql: str, max_results: int = 0) -> dict

description:
  Ejecuta query SQL ad-hoc contra Metabase sin guardarla como card. Util para exploracion rapida. Endpoint: POST /api/dataset.

example:
  ```python
  result = metabase_execute_query(client, 1, "SELECT * FROM users LIMIT 10")
  print(f"{result['row_count']} filas en {result['running_time']}ms")
  ```

code:
[cell repeats an earlier one in this file: 15149 characters]

params_schema:
  {"params":[{"name":"client","desc":"instancia autenticada de MetabaseClient"},{"name":"database_id","desc":"ID de la base de datos contra la que ejecutar la query"},{"name":"sql","desc":"sentencia SQL a ejecutar (sin guardar como card)"},{"name":"max_results","desc":"límite de filas a retornar (0=default 2000)"}],"output":"dict: resultados con status, row_count, running_time, data.columns, data.rows"}


id: metabase_export_card_py_infra
name: metabase_export_card
kind: function
lang: py
domain: infra
version: 1.0.0
purity: impure
signature: def metabase_export_card(client: MetabaseClient, card_id: int, format: str = 'csv') -> bytes
tags: ["metabase","card","export","csv","xlsx","api","python"]
uses_functions: []
uses_types: []
returns: []
error_type: error_go_core
imports: ["httpx"]
tests: []
file_path: python/functions/metabase/cards.py
created_at: 2026-04-14T15:24:30Z
updated_at: 2026-04-14T15:24:30Z
props: []
emits: []
variant: []
content_hash: 5e22b2fea572c0dc5f35c904e8e7fe3afe4ad72cd9b20a62337e0c173d79f2a3
description:
  Exporta los resultados de una card de Metabase en CSV, XLSX o JSON. Endpoint: POST /api/card/:id/query/:format.

example:
  ```python
  # Exportar a CSV
  data = metabase_export_card(client, 42, format="csv")
  with open("export.csv", "wb") as f:
      f.write(data)

  # Exportar a Excel
  data = metabase_export_card(client, 42, format="xlsx")
  with open("export.xlsx", "wb") as f:
      f.write(data)

  # Exportar a JSON
  import json
  data = metabase_export_card(client, 42, format="json")
  rows = json.loads(data)
  ```

notes:
  Usa `client._http` directamente para acceder al objeto httpx.Client y obtener `.content` en bytes sin que el wrapper de `client.request` intente parsear la respuesta como JSON.

  Para cards con queries parametrizadas, este endpoint no acepta parametros — ejecuta la query con los valores por defecto. Para pasar parametros, usar `metabase_execute_card` que devuelve JSON estructurado.

code:
[cell repeats an earlier one in this file: 15149 characters]

params_schema:
  {"params":[{"name":"client","desc":"instancia autenticada de MetabaseClient"},{"name":"card_id","desc":"ID de la card cuyos resultados se exportan"},{"name":"format","desc":"formato de exportacion: 'csv', 'xlsx' o 'json'. Default: 'csv'"}],"output":"bytes con el contenido del archivo exportado listo para escribir a disco"}


id: metabase_fix_null_ratio_py_infra
name: metabase_fix_null_ratio
kind: function
lang: py
domain: infra
version: 1.0.0
purity: impure
tags: ["metabase","maintenance","batch","mbql","fix","ratio","aggregation","python"]
uses_functions: ["metabase_list_cards_py_infra"]
uses_types: []
returns: []
error_type: error_go_core
imports: ["copy","time","uuid","httpx"]
tests: []
file_path: python/functions/metabase/maintenance.py
created_at: 2026-04-13T17:05:57Z
updated_at: 2026-04-13T17:10:12Z
props: []
emits: []
variant: []
content_hash: 9fb855fe0146585c2bef57638dec86504c33fc748a337f3e5bbdb359ce258c8d
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.

example:
  ```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': []}
  ```

notes:
  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.

code:
  """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,
      }

params_schema:
  {"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)"}


id: metabase_get_card_py_infra
name: metabase_get_card
kind: function
lang: py
domain: infra
version: 1.0.0
purity: impure
signature: def metabase_get_card(client: MetabaseClient, card_id: int) -> dict
tags: ["metabase","card","question","get","api","python"]
uses_functions: []
uses_types: []
returns: []
error_type: error_go_core
imports: ["httpx"]
example:
  ```python
  card = metabase_get_card(client, 42)
  print(card["name"], card["display"])
  ```

tests: []
file_path: python/functions/metabase/cards.py
created_at: 2026-04-05T11:22:32Z
updated_at: 2026-04-14T15:24:30Z
props: []
emits: []
variant: []
notes: Error 404 si no existe.
content_hash: ea0fc32ef4bf5425909c0c8faa6e567d711758a2167147dea9f79de452d3f481
description:
  Obtiene detalles completos de una card/pregunta de Metabase incluyendo query, visualizacion y metadata. Endpoint: GET /api/card/:id.

code:
[cell repeats an earlier one in this file: 15149 characters]

params_schema:
  {"params":[{"name":"client","desc":"instancia autenticada de MetabaseClient"},{"name":"card_id","desc":"ID de la card a obtener"}],"output":"dict: objeto card completo con query, visualización y metadata"}


id: metabase_get_collection_graph_py_infra
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
tags: ["metabase","permissions","collections","graph","access-control","api","python"]
uses_functions: []
uses_types: []
returns: []
error_type: error_go_core
imports: []
tests: []
file_path: python/functions/metabase/permissions.py
created_at: 2026-04-13T13:35:56Z
updated_at: 2026-04-13T13:35:56Z
props: []
emits: []
variant: []
content_hash: 2de62642c5863feaea3067f456b60b0a2a6cff77a845de5d0c56bb2bd68fb2ed
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.

example:
  ```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")
  ```

notes:
  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"`.

code:
[cell repeats an earlier one in this file: 13465 characters]

params_schema:
  {"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 -\u003e collection_id -\u003e 'read' | 'write' | 'none'). El campo revision es obligatorio para el PUT."}


id: metabase_get_dashboard_py_infra
name: metabase_get_dashboard
kind: function
lang: py
domain: infra
version: 1.0.0
purity: impure
signature: def metabase_get_dashboard(client: MetabaseClient, dashboard_id: int) -> dict
tags: ["metabase","dashboard","get","api","python"]
uses_functions: []
uses_types: []
returns: []
error_type: error_go_core
imports: ["httpx"]
tests: []
file_path: python/functions/metabase/dashboards.py
created_at: 2026-04-05T11:22:32Z
updated_at: 2026-04-13T13:34:41Z
props: []
emits: []
variant: []
content_hash: d98b5c6517901521b9e1bb05b1d6ffd9ea452806fd2c549ceb311f2061660419
description:
  Obtiene dashboard completo con dashcards (cards posicionadas), tabs y parametros. Endpoint: GET /api/dashboard/:id.

example:
  ```python
  dash = metabase_get_dashboard(client, 1)
  for dc in dash["dashcards"]:
      print(f"Card {dc['card_id']} at ({dc['col']}, {dc['row']})")
  ```

notes:
  Cada dashcard tiene: id, card_id, card, size_x, size_y, col, row, dashboard_tab_id, parameter_mappings.

code:
[cell repeats an earlier one in this file: 11317 characters]

params_schema:
  {"params":[{"name":"client","desc":"instancia autenticada de MetabaseClient"},{"name":"dashboard_id","desc":"ID del dashboard a obtener"}],"output":"dict: objeto dashboard completo con dashcards, tabs y parámetros"}


id: metabase_get_database_py_infra
name: metabase_get_database
kind: function
lang: py
domain: infra
version: 1.0.0
purity: impure
signature: def metabase_get_database(client: MetabaseClient, database_id: int) -> dict
description: Obtiene los detalles de una database de Metabase por su ID. Endpoint: GET /api/database/:id.
tags: ["metabase","database","get","api","python"]
uses_functions: []
uses_types: ["MetabaseClient_go_infra"]
returns: []
error_type: error_go_core
imports: ["httpx"]
example:
  ```python
  db = metabase_get_database(client, 2)
  print(db["name"], db["engine"])
  ```

tests: []
file_path: python/functions/metabase/databases.py
created_at: 2026-04-05T11:22:32Z
updated_at: 2026-04-05T16:44:15Z
props: []
emits: []
variant: []
content_hash: 068bf9159113f6214becb9598cea524d75af60eb2bd51443cc955fd6c853c509
notes:
  Error 404 si la database no existe. Retorna campos completos incluyendo
  id, name, engine, details, is_full_sync, auto_run_queries, created_at, features.

code:
  """CRUD de databases de Metabase."""

  from .client import MetabaseClient


  def metabase_list_databases(
      client: MetabaseClient,
      include_tables: bool = False,
  ) -> list:
      """Lista las databases configuradas en Metabase.

      Endpoint: GET /api/database.

      Args:
          client: Cliente autenticado.
          include_tables: Si True, incluye las tablas de cada database en la respuesta.

      Returns:
          Lista de dicts, cada uno con campos de la database:
          - id: ID numerico de la database
          - name: Nombre dado en Metabase
          - engine: Motor de BD (sqlite, postgres, mysql, etc.)
          - details: Dict con parametros de conexion
          - is_full_sync: Si sincroniza el schema automaticamente
          - tables: Lista de tablas (solo si include_tables=True)

      Example:
          >>> dbs = metabase_list_databases(client)
          >>> for db in dbs:
          ...     print(db["id"], db["name"], db["engine"])

          >>> dbs = metabase_list_databases(client, include_tables=True)
          >>> for db in dbs:
          ...     print(db["name"], [t["name"] for t in db.get("tables", [])])
      """
      params = {}
      if include_tables:
          params["include"] = "tables"
      result = client.request("GET", "/api/database", params=params)
      if isinstance(result, dict) and "data" in result:
          return result["data"]
      return result


  def metabase_add_database(
      client: MetabaseClient,
      name: str,
      engine: str,
      details: dict,
  ) -> dict:
      """Agrega una nueva database a Metabase.

      Endpoint: POST /api/database. Requiere permisos de superusuario.

      Args:
          client: Cliente autenticado con permisos admin.
          name: Nombre descriptivo para la database en Metabase.
          engine: Motor de base de datos. Ejemplos: "sqlite", "postgres",
              "mysql", "h2", "mongo", "bigquery".
          details: Dict con parametros de conexion especificos del engine.
              Para SQLite: {"db": "/ruta/al/archivo.db"}
              Para Postgres: {"host": "...", "port": 5432, "dbname": "...",
                             "user": "...", "password": "..."}

      Returns:
          Dict con la database creada, incluyendo el campo "id" asignado
          por Metabase y todos los campos de configuracion.

      Raises:
          httpx.HTTPStatusError: 400 si los datos de conexion son invalidos.

      Example:
          >>> db = metabase_add_database(client, "Mi SQLite", "sqlite", {"db": "/data/ops.db"})
          >>> print(db["id"], db["name"])
      """
      body = {
          "name": name,
          "engine": engine,
          "details": details,
      }
      return client.request("POST", "/api/database", json=body)


  def metabase_get_database(client: MetabaseClient, database_id: int) -> dict:
      """Obtiene los detalles de una database de Metabase por su ID.

      Endpoint: GET /api/database/:id.

      Args:
          client: Cliente autenticado.
          database_id: ID numerico de la database.

      Returns:
          Dict con campos de la database: id, name, engine, details,
          is_full_sync, is_on_demand, auto_run_queries, created_at,
          updated_at, features, etc.

      Raises:
          httpx.HTTPStatusError: 404 si la database no existe.

      Example:
          >>> db = metabase_get_database(client, 2)
          >>> print(db["name"], db["engine"])
      """
      return client.request("GET", f"/api/database/{database_id}")

params_schema:
  {"params":[{"name":"client","desc":"instancia autenticada de MetabaseClient"},{"name":"database_id","desc":"ID de la base de datos a obtener"}],"output":"dict: objeto database con id, name, engine, details, features y metadata"}


id: metabase_get_document_py_infra
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
tags: ["metabase","document","get","api","python"]
uses_functions: []
uses_types: []
returns: []
error_type: error_go_core
imports: ["httpx"]
tests: []
file_path: python/functions/metabase/documents.py
created_at: 2026-04-13T12:54:37Z
updated_at: 2026-04-14T15:24:30Z
props: []
emits: []
variant: []
notes: `content_type` siempre es `application/json+vnd.prose-mirror`.
content_hash: f1307b54a08e28e8b7941ef2e9b650360445a247d4dfc24903f70dc516942e84
description:
  Obtiene un document completo con su arbol ProseMirror (campo document). Endpoint: GET /api/document/:id.

example:
  ```python
  doc = metabase_get_document(client, 1)
  tree = doc["document"]  # {"type": "doc", "content": [...]}
  ```

code:
[cell repeats an earlier one in this file: 13730 characters]

params_schema:
  {"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"}


id: metabase_get_group_py_infra
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
tags: ["metabase","permissions","group","get","members","api","python"]
uses_functions: []
uses_types: []
returns: []
error_type: error_go_core
imports: []
tests: []
file_path: python/functions/metabase/permissions.py
created_at: 2026-04-13T13:34:41Z
updated_at: 2026-04-13T13:34:41Z
props: []
emits: []
variant: []
content_hash: ab94c842624dfc46520f197d504edced0b1434c107d2523e823cdaf7ef835055
description:
  Obtiene un Permission Group de Metabase por ID, incluyendo la lista completa de miembros con sus datos. Endpoint: GET /api/permissions/group/:id.

example:
  ```python
  group = metabase_get_group(client, 3)
  print(group["name"])
  for m in group["members"]:
      print(m["email"], m["user_id"])
  ```

notes:
  A diferencia de `metabase_list_groups`, este endpoint retorna la lista completa de miembros del grupo.
  Error 404 si el grupo no existe.

code:
[cell repeats an earlier one in this file: 13465 characters]

params_schema:
  {"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)"}


id: metabase_get_permission_graph_py_infra
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
tags: ["metabase","permissions","graph","databases","schemas","access-control","api","python"]
uses_functions: []
uses_types: []
returns: []
error_type: error_go_core
imports: []
tests: []
file_path: python/functions/metabase/permissions.py
created_at: 2026-04-13T13:35:56Z
updated_at: 2026-04-13T13:35:56Z
props: []
emits: []
variant: []
content_hash: 480bcbf6499f21aedb6f4c9f8b0125e98501fe706019e6cf4d7e96fca7affeae
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.

example:
  ```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}")
  ```

notes:
  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.

code:
[cell repeats an earlier one in this file: 13465 characters]

params_schema:
  {"params":[{"name":"client","desc":"instancia autenticada de MetabaseClient con permisos de superusuario"}],"output":"dict: grafo completo con revision (int) y groups (group_id -\u003e db_id -\u003e {schemas, native}). El campo revision es obligatorio para el PUT."}


id: metabase_get_snippet_py_infra
name: metabase_get_snippet
kind: function
lang: py
domain: infra
version: 1.0.0
purity: impure
signature: def metabase_get_snippet(client: MetabaseClient, snippet_id: int) -> dict
description: Obtiene un SQL snippet de Metabase por su ID. Endpoint: GET /api/native-query-snippet/:id.
tags: ["metabase","snippet","get","api","python"]
uses_functions: []
uses_types: ["MetabaseClient_go_infra"]
returns: []
error_type: error_go_core
imports: ["httpx"]
tests: []
file_path: python/functions/metabase/snippets.py
created_at: 2026-04-14T15:24:30Z
updated_at: 2026-04-14T15:24:30Z
props: []
emits: []
variant: []
content_hash: fc465ff3b34051b9536bdbf046007b0f9079cbdf7e3c61825632e4b1a75e0205
example:
  ```python
  snippet = metabase_get_snippet(client, 42)
  print(snippet["name"])
  print(snippet["content"])
  print(snippet["description"])
  ```

notes:
  Retorna 404 si el snippet no existe.
  Util para verificar el contenido actual antes de hacer un update.

code:
  """CRUD de SQL snippets de Metabase.

  Un snippet es un fragmento SQL reutilizable que se referencia en queries
  nativas con la sintaxis {{snippet: nombre}}. Ideal para CTEs comunes
  que se repiten en multiples cards.
  """

  from .client import MetabaseClient


  def metabase_list_snippets(client: MetabaseClient, archived: bool = False) -> list[dict]:
      """Lista SQL snippets de Metabase.

      Endpoint: GET /api/native-query-snippet.

      Args:
          client: Cliente autenticado.
          archived: Si True, incluye snippets archivados.

      Returns:
          Lista de dicts con: id, name, content, description, collection_id,
          creator_id, archived, created_at, updated_at.

      Example:
          >>> snippets = metabase_list_snippets(client)
          >>> for s in snippets:
          ...     print(s["name"], len(s["content"]))
      """
      params = {}
      if archived:
          params["archived"] = "true"
      return client.request("GET", "/api/native-query-snippet", params=params)


  def metabase_get_snippet(client: MetabaseClient, snippet_id: int) -> dict:
      """Obtiene un SQL snippet de Metabase por su ID.

      Endpoint: GET /api/native-query-snippet/:id.

      Args:
          client: Cliente autenticado.
          snippet_id: ID numerico del snippet.

      Returns:
          Dict con campos completos del snippet: id, name, content, description,
          collection_id, creator_id, archived, created_at, updated_at.

      Raises:
          httpx.HTTPStatusError: 404 si el snippet no existe.

      Example:
          >>> snippet = metabase_get_snippet(client, 42)
          >>> print(snippet["name"], snippet["content"])
      """
      return client.request("GET", f"/api/native-query-snippet/{snippet_id}")


  def metabase_create_snippet(
      client: MetabaseClient,
      name: str,
      content: str,
      description: str = "",
      collection_id: int = 0,
  ) -> dict:
      """Crea un nuevo SQL snippet en Metabase.

      Endpoint: POST /api/native-query-snippet.

      Args:
          client: Cliente autenticado.
          name: Nombre del snippet. Se usa en queries con {{snippet: nombre}}.
          content: SQL del snippet. Puede ser una CTE completa, una subquery,
              o cualquier fragmento SQL reutilizable.
          description: Descripcion opcional del snippet.
          collection_id: ID de la coleccion donde guardar el snippet.
              Si es 0, se guarda en la raiz (Our analytics).

      Returns:
          Dict con el snippet creado, incluyendo el campo "id" asignado
          por Metabase.

      Raises:
          httpx.HTTPStatusError: 400 si el nombre ya existe o el SQL es invalido.

      Example:
          >>> snippet = metabase_create_snippet(
          ...     client,
          ...     "supply_orders_cte",
          ...     '''WITH supply_full AS (
          ...         SELECT so.id, so.service_request_id
          ...         FROM supply_orders so
          ...         LEFT JOIN service_requests sr ON so.service_request_id = sr.id
          ...     )''',
          ...     description="CTE base de supply_orders con JOINs para cruce con NAV",
          ... )
          >>> print(snippet["id"], snippet["name"])
          >>> # Usar en una card:
          >>> # "{{snippet: supply_orders_cte}} SELECT ... FROM supply_full"
      """
      body: dict = {"name": name, "content": content}
      if description:
          body["description"] = description
      if collection_id:
          body["collection_id"] = collection_id
      return client.request("POST", "/api/native-query-snippet", json=body)


  def metabase_update_snippet(
      client: MetabaseClient,
      snippet_id: int,
      **fields,
  ) -> dict:
      """Actualiza campos de un SQL snippet en Metabase.

      Endpoint: PUT /api/native-query-snippet/:id.

      Args:
          client: Cliente autenticado.
          snippet_id: ID numerico del snippet a actualizar.
          **fields: Campos a modificar. Campos validos:
              - name (str): Nuevo nombre del snippet.
              - content (str): Nuevo SQL del snippet.
              - description (str): Nueva descripcion.
              - collection_id (int): Nueva coleccion.
              - archived (bool): True para archivar, False para desarchivar.

      Returns:
          Dict con el snippet actualizado.

      Raises:
          httpx.HTTPStatusError: 404 si el snippet no existe.
          httpx.HTTPStatusError: 400 si los campos son invalidos.

      Example:
          >>> updated = metabase_update_snippet(
          ...     client, 42,
          ...     content="WITH supply_full AS (SELECT * FROM supply_orders)",
          ...     description="Version simplificada",
          ... )
          >>> print(updated["updated_at"])
      """
      valid_fields = {"name", "content", "description", "collection_id", "archived"}
      body = {k: v for k, v in fields.items() if k in valid_fields}
      return client.request("PUT", f"/api/native-query-snippet/{snippet_id}", json=body)


  def metabase_archive_snippet(client: MetabaseClient, snippet_id: int) -> dict:
      """Archiva un SQL snippet en Metabase.

      Wrapper sobre metabase_update_snippet con archived=True.
      Los snippets archivados no aparecen en el autocomplete de queries
      pero sus referencias existentes siguen funcionando.

      Endpoint: PUT /api/native-query-snippet/:id.

      Args:
          client: Cliente autenticado.
          snippet_id: ID numerico del snippet a archivar.

      Returns:
          Dict con el snippet archivado.

      Raises:
          httpx.HTTPStatusError: 404 si el snippet no existe.

      Example:
          >>> result = metabase_archive_snippet(client, 42)
          >>> print(result["archived"])  # True
      """
      return metabase_update_snippet(client, snippet_id, archived=True)

params_schema:
  {"params":[{"name":"client","desc":"instancia autenticada de MetabaseClient"},{"name":"snippet_id","desc":"ID numerico del snippet a obtener"}],"output":"dict: snippet completo con id, name, content, description, collection_id, creator_id, archived, created_at, updated_at"}


id: metabase_get_user_py_infra
name: metabase_get_user
kind: function
lang: py
domain: infra
version: 1.0.0
purity: impure
signature: def metabase_get_user(client: MetabaseClient, user_id: int) -> dict
description: Obtiene los detalles de un usuario de Metabase por su ID. Endpoint: GET /api/user/:id.
tags: ["metabase","user","get","api","python"]
uses_functions: []
uses_types: []
returns: []
error_type: error_go_core
imports: ["httpx"]
example:
  ```python
  user = metabase_get_user(client, 1)
  print(user["email"], user["is_superuser"])
  ```

tests: []
file_path: python/functions/metabase/users.py
created_at: 2026-04-05T11:22:32Z
updated_at: 2026-04-05T16:44:15Z
props: []
emits: []
variant: []
notes: Error 404 si el usuario no existe.
content_hash: 47dcd68926dc4c2564cec0bc579353bf741211eecd614aa4e03444c908b21b64
code:
  """CRUD de usuarios de Metabase."""

  from .client import MetabaseClient


  def metabase_list_users(
      client: MetabaseClient,
      status: str = "",
      query: str = "",
      limit: int = 0,
      offset: int = 0,
  ) -> dict:
      """Lista usuarios de Metabase con filtros opcionales.

      Endpoint: GET /api/user. Requiere permisos de superusuario.

      Args:
          client: Cliente autenticado.
          status: "active" (default), "deactivated" o "all".
          query: Filtro por nombre o email.
          limit: Tamanio de pagina (0 = default Metabase).
          offset: Offset para paginacion.

      Returns:
          Dict con estructura paginada:
          - data: lista de usuarios (id, email, first_name, last_name, is_superuser, etc.)
          - total: numero total de usuarios que coinciden
          - limit: tamanio de pagina usado
          - offset: offset usado

      Example:
          >>> users = metabase_list_users(client, status="active", query="john@")
          >>> for u in users["data"]:
          ...     print(u["email"], u["first_name"])
      """
      params = {}
      if status:
          params["status"] = status
      if query:
          params["query"] = query
      if limit > 0:
          params["limit"] = limit
      if offset > 0:
          params["offset"] = offset
      return client.request("GET", "/api/user", params=params)


  def metabase_get_user(client: MetabaseClient, user_id: int) -> dict:
      """Obtiene un usuario de Metabase por su ID.

      Endpoint: GET /api/user/:id.

      Args:
          client: Cliente autenticado.
          user_id: ID numerico del usuario.

      Returns:
          Dict con datos del usuario: id, email, first_name, last_name,
          is_superuser, is_active, common_name, date_joined, last_login,
          group_ids, locale.

      Raises:
          httpx.HTTPStatusError: 404 si el usuario no existe.

      Example:
          >>> user = metabase_get_user(client, 1)
          >>> print(user["email"], user["is_superuser"])
      """
      return client.request("GET", f"/api/user/{user_id}")


  def metabase_create_user(
      client: MetabaseClient,
      first_name: str,
      last_name: str,
      email: str,
      password: str = "",
      group_ids: list[int] | None = None,
  ) -> dict:
      """Crea un nuevo usuario en Metabase.

      Endpoint: POST /api/user. Requiere permisos de superusuario.

      Args:
          client: Cliente autenticado con permisos admin.
          first_name: Nombre del usuario.
          last_name: Apellido del usuario.
          email: Email unico del usuario.
          password: Password. Vacio = Metabase envia invitacion por email.
          group_ids: IDs de grupos a asignar. None = solo grupo default.

      Returns:
          Dict con el usuario creado (mismos campos que metabase_get_user).

      Raises:
          httpx.HTTPStatusError: 400 si el email ya existe.

      Example:
          >>> user = metabase_create_user(client, "John", "Doe", "john@example.com", "pass123")
          >>> print(user["id"])
      """
      body: dict = {
          "first_name": first_name,
          "last_name": last_name,
          "email": email,
      }
      if password:
          body["password"] = password
      if group_ids:
          body["group_ids"] = group_ids
      return client.request("POST", "/api/user", json=body)


  def metabase_update_user(client: MetabaseClient, user_id: int, **fields) -> dict:
      """Actualiza campos de un usuario en Metabase.

      Endpoint: PUT /api/user/:id. Requiere permisos de superusuario.
      Solo se modifican los campos pasados como keyword arguments.

      Args:
          client: Cliente autenticado con permisos admin.
          user_id: ID del usuario a actualizar.
          **fields: Campos a actualizar. Validos:
              first_name (str), last_name (str), email (str),
              is_superuser (bool), group_ids (list[int]),
              locale (str), login_attributes (dict).

      Returns:
          Dict con el usuario actualizado.

      Example:
          >>> user = metabase_update_user(client, 5, first_name="Jane", is_superuser=True)
          >>> user = metabase_update_user(client, 5, group_ids=[1, 3, 5])
      """
      return client.request("PUT", f"/api/user/{user_id}", json=fields)


  def metabase_deactivate_user(client: MetabaseClient, user_id: int) -> None:
      """Desactiva (soft-delete) un usuario en Metabase.

      Endpoint: DELETE /api/user/:id. Requiere permisos de superusuario.
      El usuario no se elimina permanentemente, solo se marca como inactivo.
      Para reactivar: PUT /api/user/:id/reactivate.

      Args:
          client: Cliente autenticado con permisos admin.
          user_id: ID del usuario a desactivar.

      Example:
          >>> metabase_deactivate_user(client, 5)
          >>> # Para ver desactivados: metabase_list_users(client, status="deactivated")
      """
      client.request("DELETE", f"/api/user/{user_id}")

params_schema:
  {"params":[{"name":"client","desc":"instancia autenticada de MetabaseClient"},{"name":"user_id","desc":"ID del usuario a obtener"}],"output":"dict: objeto usuario con email, nombre, permisos y metadata"}


id: metabase_list_cards_py_infra
name: metabase_list_cards
kind: function
lang: py
domain: infra
version: 1.0.0
purity: impure
signature: def metabase_list_cards(client: MetabaseClient, filter: str = '', model_id: int = 0) -> list[dict]
tags: ["metabase","card","question","list","api","python"]
uses_functions: []
uses_types: []
returns: []
error_type: error_go_core
imports: ["httpx"]
tests: []
file_path: python/functions/metabase/cards.py
created_at: 2026-04-05T11:22:32Z
updated_at: 2026-04-14T15:24:30Z
props: []
emits: []
variant: []
notes: No tiene paginacion offset/limit. Retorna todas las cards que coinciden.
content_hash: 8cd0cb8860097ba8e83169a397d09b48b51d06ad97e6b21beebf4b965d0bf7cd
description:
  Lista preguntas/cards de Metabase. Filtros: all, mine, fav, archived, recent, popular, database, table. Endpoint: GET /api/card.

example:
  ```python
  cards = metabase_list_cards(client, filter="mine")
  cards = metabase_list_cards(client, filter="database", model_id=1)
  ```

code:
[cell repeats an earlier one in this file: 15149 characters]

params_schema:
  {"params":[{"name":"client","desc":"instancia autenticada de MetabaseClient"},{"name":"filter","desc":"filtro: all, mine, fav, archived, recent, popular, database, table"},{"name":"model_id","desc":"ID de modelo para filtros database/table"}],"output":"list[dict]: lista de cards que coinciden con el filtro"}


id: metabase_list_dashboards_py_infra
name: metabase_list_dashboards
kind: function
lang: py
domain: infra
version: 1.0.0
purity: impure
signature: def metabase_list_dashboards(client: MetabaseClient, filter: str = '') -> list[dict]
tags: ["metabase","dashboard","list","api","python"]
uses_functions: []
uses_types: []
returns: []
error_type: error_go_core
imports: ["httpx"]
tests: []
file_path: python/functions/metabase/dashboards.py
created_at: 2026-04-05T11:22:32Z
updated_at: 2026-04-13T13:34:41Z
props: []
emits: []
variant: []
notes: Para ver cards de un dashboard usar metabase_get_dashboard.
content_hash: 3592564f1b342fa395f3d528235b9010da6e5f605851c95a1a8a44d0ffc19886
description:
  Lista dashboards de Metabase. Filtros: all, mine, archived. Retorna resumen sin dashcards. Endpoint: GET /api/dashboard.

example:
  ```python
  dashboards = metabase_list_dashboards(client, filter="mine")
  for d in dashboards:
      print(d["id"], d["name"])
  ```

code:
[cell repeats an earlier one in this file: 11317 characters]

params_schema:
  {"params":[{"name":"client","desc":"instancia autenticada de MetabaseClient"},{"name":"filter","desc":"filtro: all, mine, archived"}],"output":"list[dict]: lista de dashboards en resumen (sin dashcards)"}


id: metabase_list_databases_py_infra
name: metabase_list_databases
kind: function
lang: py
domain: infra
version: 1.0.0
purity: impure
signature: def metabase_list_databases(client: MetabaseClient, include_tables: bool = False) -> list
tags: ["metabase","database","list","api","python"]
uses_functions: []
uses_types: ["MetabaseClient_go_infra"]
returns: []
error_type: error_go_core
imports: ["httpx"]
tests: []
file_path: python/functions/metabase/databases.py
created_at: 2026-04-05T11:22:32Z
updated_at: 2026-04-05T16:44:15Z
props: []
emits: []
variant: []
content_hash: 7ad9aac47ea5dd1107690405bf7503f6c76c2a743c6356b89b336fd959e371e0
description:
  Lista las databases configuradas en Metabase. Endpoint: GET /api/database. Soporta incluir tablas con include_tables=True.

example:
  ```python
  dbs = metabase_list_databases(client)
  for db in dbs:
      print(db["id"], db["name"], db["engine"])

  # Con tablas incluidas
  dbs = metabase_list_databases(client, include_tables=True)
  for db in dbs:
      print(db["name"], [t["name"] for t in db.get("tables", [])])
  ```

notes:
  Si include_tables=True agrega el query param `?include=tables` al request.
  Retorna lista directa (no paginada) de todas las databases accesibles.

code:
  """CRUD de databases de Metabase."""

  from .client import MetabaseClient


  def metabase_list_databases(
      client: MetabaseClient,
      include_tables: bool = False,
  ) -> list:
      """Lista las databases configuradas en Metabase.

      Endpoint: GET /api/database.

      Args:
          client: Cliente autenticado.
          include_tables: Si True, incluye las tablas de cada database en la respuesta.

      Returns:
          Lista de dicts, cada uno con campos de la database:
          - id: ID numerico de la database
          - name: Nombre dado en Metabase
          - engine: Motor de BD (sqlite, postgres, mysql, etc.)
          - details: Dict con parametros de conexion
          - is_full_sync: Si sincroniza el schema automaticamente
          - tables: Lista de tablas (solo si include_tables=True)

      Example:
          >>> dbs = metabase_list_databases(client)
          >>> for db in dbs:
          ...     print(db["id"], db["name"], db["engine"])

          >>> dbs = metabase_list_databases(client, include_tables=True)
          >>> for db in dbs:
          ...     print(db["name"], [t["name"] for t in db.get("tables", [])])
      """
      params = {}
      if include_tables:
          params["include"] = "tables"
      result = client.request("GET", "/api/database", params=params)
      if isinstance(result, dict) and "data" in result:
          return result["data"]
      return result


  def metabase_add_database(
      client: MetabaseClient,
      name: str,
      engine: str,
      details: dict,
  ) -> dict:
      """Agrega una nueva database a Metabase.

      Endpoint: POST /api/database. Requiere permisos de superusuario.

      Args:
          client: Cliente autenticado con permisos admin.
          name: Nombre descriptivo para la database en Metabase.
          engine: Motor de base de datos. Ejemplos: "sqlite", "postgres",
              "mysql", "h2", "mongo", "bigquery".
          details: Dict con parametros de conexion especificos del engine.
              Para SQLite: {"db": "/ruta/al/archivo.db"}
              Para Postgres: {"host": "...", "port": 5432, "dbname": "...",
                             "user": "...", "password": "..."}

      Returns:
          Dict con la database creada, incluyendo el campo "id" asignado
          por Metabase y todos los campos de configuracion.

      Raises:
          httpx.HTTPStatusError: 400 si los datos de conexion son invalidos.

      Example:
          >>> db = metabase_add_database(client, "Mi SQLite", "sqlite", {"db": "/data/ops.db"})
          >>> print(db["id"], db["name"])
      """
      body = {
          "name": name,
          "engine": engine,
          "details": details,
      }
      return client.request("POST", "/api/database", json=body)


  def metabase_get_database(client: MetabaseClient, database_id: int) -> dict:
      """Obtiene los detalles de una database de Metabase por su ID.

      Endpoint: GET /api/database/:id.

      Args:
          client: Cliente autenticado.
          database_id: ID numerico de la database.

      Returns:
          Dict con campos de la database: id, name, engine, details,
          is_full_sync, is_on_demand, auto_run_queries, created_at,
          updated_at, features, etc.

      Raises:
          httpx.HTTPStatusError: 404 si la database no existe.

      Example:
          >>> db = metabase_get_database(client, 2)
          >>> print(db["name"], db["engine"])
      """
      return client.request("GET", f"/api/database/{database_id}")

params_schema:
  {"params":[{"name":"client","desc":"instancia autenticada de MetabaseClient"},{"name":"include_tables","desc":"incluir lista de tablas en cada database"}],"output":"list[dict]: lista de databases configuradas con id, name, engine y opcionalmente tables"}


id: metabase_list_document_comments_py_infra
name: metabase_list_document_comments
kind: function
lang: py
domain: infra
version: 1.0.0
purity: impure
tags: ["metabase","document","comments","api","python"]
uses_functions: []
uses_types: []
returns: []
error_type: error_go_core
imports: ["httpx"]
tests: []
file_path: python/functions/metabase/documents.py
created_at: 2026-04-13T13:19:13Z
updated_at: 2026-04-14T15:24:30Z
props: []
emits: []
variant: []
content_hash: 35517031381f9faf3853d79608486a14883420729be836cd701c39dfa303a320
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.

example:
  ```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}')
  ```

notes:
  **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.

code:
[cell repeats an earlier one in this file: 13730 characters]

params_schema:
  {"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"}


id: metabase_list_documents_py_infra
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]
tags: ["metabase","document","list","api","python"]
uses_functions: []
uses_types: []
returns: []
error_type: error_go_core
imports: ["httpx"]
example:
  ```python
  docs = metabase_list_documents(client)
  for d in docs:
  print(d["id"], d["name"])
  ```

tests: []
file_path: python/functions/metabase/documents.py
created_at: 2026-04-13T12:54:37Z
updated_at: 2026-04-14T15:24:30Z
props: []
emits: []
variant: []
notes: Metabase devuelve `{"items": [...]}`. Esta funcion desenrolla para retornar la lista directamente.
content_hash: 2dd0b5534c0364ddf686c107d660c04d352928d6bad95f659baaaa46e6aeab47
description:
  Lista documents (texto ProseMirror tipo Notion, feature 0.57+). Endpoint: GET /api/document. Desenrolla {items: [...]}.

code:
[cell repeats an earlier one in this file: 13730 characters]

params_schema:
  {"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"}


id: metabase_list_groups_py_infra
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]
tags: ["metabase","permissions","group","list","api","python"]
uses_functions: []
uses_types: []
returns: []
error_type: error_go_core
imports: []
tests: []
file_path: python/functions/metabase/permissions.py
created_at: 2026-04-13T13:34:41Z
updated_at: 2026-04-13T13:34:41Z
props: []
emits: []
variant: []
content_hash: 00a4eac604b0d9e9c1978462c97643b304f4bdccf3e816eed4857ea9194c3eb2
description:
  Lista todos los Permission Groups de Metabase con su member_count. Requiere superusuario. Endpoint: GET /api/permissions/group.

example:
  ```python
  groups = metabase_list_groups(client)
  for g in groups:
      print(g["id"], g["name"], g["member_count"])
  ```

notes:
  Incluye los grupos especiales del sistema: "All Users" (id=1) y "Administrators" (id=2).
  Requiere que el cliente este autenticado como superusuario.

code:
[cell repeats an earlier one in this file: 13465 characters]

params_schema:
  {"params":[{"name":"client","desc":"instancia autenticada de MetabaseClient con permisos de superusuario"}],"output":"list[dict]: lista de grupos con id, name y member_count"}


id: metabase_list_memberships_py_infra
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]]
tags: ["metabase","permissions","membership","groups","users","api","python"]
uses_functions: []
uses_types: []
returns: []
error_type: error_go_core
imports: []
tests: []
file_path: python/functions/metabase/permissions.py
created_at: 2026-04-13T13:34:52Z
updated_at: 2026-04-13T13:34:52Z
props: []
emits: []
variant: []
content_hash: a3ae055611ab73816c7e911fbff969a0c0b685eead4825eac8a7cc7f198964ce
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.

example:
  ```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"])
  ```

notes:
  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), [])`.

code:
[cell repeats an earlier one in this file: 13465 characters]

params_schema:
  {"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"}


id: metabase_list_notifications_py_infra
name: metabase_list_notifications
kind: function
lang: py
domain: infra
version: 1.0.0
purity: impure
tags: ["metabase","notification","list","api","python"]
uses_functions: []
uses_types: []
returns: []
error_type: error_go_core
imports: []
tests: []
file_path: python/functions/metabase/notifications.py
created_at: 2026-04-14T15:24:30Z
updated_at: 2026-04-14T15:24:30Z
props: []
emits: []
variant: []
content_hash: f31555c15361ec9cc2a6852fde749e3d11ad885e034d504ea7404c20468e5c48
signature:
  def metabase_list_notifications(client: MetabaseClient, card_id: int = 0, dashboard_id: int = 0) -> list[dict]

description:
  Lista notificaciones de Metabase (alertas y suscripciones). Filtra opcionalmente por card_id o dashboard_id. Endpoint: GET /api/notification.

example:
  ```python
  # Todas las notificaciones
  notifs = metabase_list_notifications(client)

  # Solo alertas de una card especifica
  alerts = metabase_list_notifications(client, card_id=7711)
  for a in alerts:
      print(a["id"], a["payload_type"], a["active"])

  # Solo suscripciones de un dashboard
  subs = metabase_list_notifications(client, dashboard_id=42)
  ```

notes:
  Requiere Metabase 0.57+. En versiones anteriores usar /api/pulse (deprecado).
  Sin filtros retorna todas las notificaciones accesibles por el usuario autenticado.

code:
[cell repeats an earlier one in this file: 8185 characters]

params_schema:
  {"params":[{"name":"client","desc":"instancia autenticada de MetabaseClient"},{"name":"card_id","desc":"si \u003e 0, filtra alertas de tipo notification/card asociadas a esta card"},{"name":"dashboard_id","desc":"si \u003e 0, filtra suscripciones de tipo notification/dashboard asociadas a este dashboard"}],"output":"list[dict]: lista de notificaciones con id, active, payload_type, payload, subscriptions, handlers, created_at, updated_at"}


id: metabase_list_snippets_py_infra
name: metabase_list_snippets
kind: function
lang: py
domain: infra
version: 1.0.0
purity: impure
signature: def metabase_list_snippets(client: MetabaseClient, archived: bool = False) -> list[dict]
tags: ["metabase","snippet","list","api","python"]
uses_functions: []
uses_types: ["MetabaseClient_go_infra"]
returns: []
error_type: error_go_core
imports: ["httpx"]
tests: []
file_path: python/functions/metabase/snippets.py
created_at: 2026-04-14T15:24:30Z
updated_at: 2026-04-14T15:24:30Z
props: []
emits: []
variant: []
content_hash: 5658d55d5c1afe8c95323c2a5ed566094ba954dc6a92cc2301a5dbc4cccabcdb
description:
  Lista SQL snippets reutilizables de Metabase. Un snippet se referencia en queries con {{snippet: nombre}}.

example:
  ```python
  snippets = metabase_list_snippets(client)
  for s in snippets:
      print(s["name"], len(s["content"]))

  # Incluir archivados
  all_snippets = metabase_list_snippets(client, archived=True)
  active = [s for s in all_snippets if not s["archived"]]
  ```

notes:
  Si `archived=True` agrega el query param `?archived=true`.
  Retorna lista directa de todos los snippets accesibles.
  Los snippets archivados no aparecen en el autocomplete de queries de Metabase.

code:
[cell repeats an earlier one in this file: 5622 characters]

params_schema:
  {"params":[{"name":"client","desc":"instancia autenticada de MetabaseClient"},{"name":"archived","desc":"si True, incluye snippets archivados (default False)"}],"output":"list[dict]: snippets con id, name, content, description, collection_id, creator_id, archived, created_at, updated_at"}


id: metabase_list_users_py_infra
name: metabase_list_users
kind: function
lang: py
domain: infra
version: 1.0.0
purity: impure
tags: ["metabase","user","list","api","python"]
uses_functions: []
uses_types: []
returns: []
error_type: error_go_core
imports: ["httpx"]
tests: []
file_path: python/functions/metabase/users.py
created_at: 2026-04-05T11:22:32Z
updated_at: 2026-04-05T16:44:15Z
props: []
emits: []
variant: []
notes: Retorna dict paginado con data, total, limit, offset.
content_hash: b2ef0d4b509682bac96b0733575776ff056f2fc9ffbc3ecf44aa55898143955b
signature:
  def metabase_list_users(client: MetabaseClient, status: str = '', query: str = '', limit: int = 0, offset: int = 0) -> dict

description:
  Lista usuarios de Metabase con filtros opcionales por estado, nombre/email y paginacion. Endpoint: GET /api/user.

example:
  ```python
  users = metabase_list_users(client, status="active", query="john@")
  for u in users["data"]:
      print(u["email"], u["first_name"])
  ```

code:
[cell repeats an earlier one in this file: 4778 characters]

params_schema:
  {"params":[{"name":"client","desc":"instancia autenticada de MetabaseClient"},{"name":"status","desc":"filtro por estado de usuario (active, deactivated, etc.)"},{"name":"query","desc":"búsqueda por nombre o email"},{"name":"limit","desc":"límite de resultados por página"},{"name":"offset","desc":"desplazamiento para paginación"}],"output":"dict: objeto paginado con data (usuarios), total, limit, offset"}


id: metabase_mbql_validate_py_core
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]
tags: ["metabase","mbql","validation","pure","query","dataset_query"]
uses_functions: []
uses_types: []
returns: []
imports: []
test_file_path: python/functions/metabase/test_metabase_mbql_validate.py
file_path: python/functions/metabase/metabase_mbql_validate.py
created_at: 2026-04-13T17:05:57Z
updated_at: 2026-04-13T17:05:57Z
props: []
emits: []
variant: []
content_hash: 5a38aed387bd8361990c1f7480947f8fccd3a17bfa4e7e50f8a64d19f7a8350b
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.

example:
  ```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')
  ```

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"]

notes:
  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.

documentation:
  ## 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.

code:
  """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)

params_schema:
  {"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."}


id: metabase_move_card_py_infra
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
tags: ["metabase","card","question","move","collection","api","python"]
uses_functions: []
uses_types: []
returns: []
error_type: error_go_core
imports: ["httpx"]
tests: []
file_path: python/functions/metabase/cards.py
created_at: 2026-04-13T13:34:41Z
updated_at: 2026-04-14T15:24:30Z
props: []
emits: []
variant: []
content_hash: b10ae6af6c47231ddaff3f1162011c24c2cf7c520849fbfd219ffcf151aa19c2
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).

example:
  ```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)
  ```

notes:
  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.

code:
[cell repeats an earlier one in this file: 15149 characters]

params_schema:
  {"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"}


id: metabase_move_collection_py_infra
name: metabase_move_collection
kind: function
lang: py
domain: infra
version: 1.0.0
purity: impure
tags: ["metabase","collection","move","tree","api","python"]
uses_functions: []
uses_types: []
returns: []
error_type: error_go_core
imports: ["httpx"]
tests: []
file_path: python/functions/metabase/collections.py
created_at: 2026-04-13T13:34:41Z
updated_at: 2026-04-13T13:34:41Z
props: []
emits: []
variant: []
content_hash: 2edef7564b726b8d782a1666b562f6dc608dd7c8a68c4d5eeb846091266d780d
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: ...}.

example:
  ```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"])  # "/"
  ```

notes:
  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`.

code:
  """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},
      )

params_schema:
  {"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"}


id: metabase_move_dashboard_py_infra
name: metabase_move_dashboard
kind: function
lang: py
domain: infra
version: 1.0.0
purity: impure
tags: ["metabase","dashboard","move","collection","api","python"]
uses_functions: []
uses_types: []
returns: []
error_type: error_go_core
imports: ["httpx"]
tests: []
file_path: python/functions/metabase/dashboards.py
created_at: 2026-04-13T13:34:41Z
updated_at: 2026-04-13T13:34:41Z
props: []
emits: []
variant: []
content_hash: ffcbc2bb0ba7dcc51d96a25ceff76367908060bcf105a981b8e3e14f2e977caa
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).

example:
  ```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)
  ```

notes:
  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.

code:
[cell repeats an earlier one in this file: 11317 characters]

params_schema:
  {"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"}


id: metabase_move_document_py_infra
name: metabase_move_document
kind: function
lang: py
domain: infra
version: 1.0.0
purity: impure
tags: ["metabase","document","move","collection","api","python"]
uses_functions: []
uses_types: []
returns: []
error_type: error_go_core
imports: ["httpx"]
tests: []
file_path: python/functions/metabase/documents.py
created_at: 2026-04-13T13:34:41Z
updated_at: 2026-04-14T15:24:30Z
props: []
emits: []
variant: []
content_hash: c41aacd2b2ce1e8bf0e64f52286817a9fd47f050afcc36b26c79579149a97ebf
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.

example:
  ```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)
  ```

notes:
  Equivale a `metabase_update_document(client, document_id, collection_id=collection_id)` pero con
  firma explicita para mayor legibilidad en codigo de migracion/reorganizacion.

code:
[cell repeats an earlier one in this file: 13730 characters]

params_schema:
  {"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"}


id: metabase_pair_n_n1_columns_py_infra
name: metabase_pair_n_n1_columns
kind: function
lang: py
domain: infra
version: 1.0.0
purity: impure
tags: ["metabase","maintenance","batch","visualization","columns","table","pivot","python"]
uses_functions: ["metabase_list_cards_py_infra"]
uses_types: []
returns: []
error_type: error_go_core
imports: ["copy","time","httpx"]
tests: []
file_path: python/functions/metabase/maintenance.py
created_at: 2026-04-13T17:05:57Z
updated_at: 2026-04-13T17:10:12Z
props: []
emits: []
variant: []
content_hash: f5cb8c9742e3fffcf0c7b28833a1c7569605a3436c75f422c9b743b256446a6a
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.

example:
  ```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])
  ```

notes:
  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.

code:
  """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,
      }

params_schema:
  {"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)"}


id: metabase_resolve_document_comment_py_infra
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
tags: ["metabase","document","comments","resolve","api","python"]
uses_functions: []
uses_types: []
returns: []
error_type: error_go_core
imports: ["httpx"]
tests: []
file_path: python/functions/metabase/documents.py
created_at: 2026-04-13T13:19:13Z
updated_at: 2026-04-14T15:24:30Z
props: []
emits: []
variant: []
content_hash: e7494e484dc9c0b67f4f70abb641c29b65c68ca8024bc74733795a58430bc6d0
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.

example:
  ```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"])
  ```

notes:
  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.

code:
[cell repeats an earlier one in this file: 13730 characters]

params_schema:
  {"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"}


id: metabase_setup_py_infra
name: metabase_setup
kind: function
lang: py
domain: infra
version: 1.0.0
purity: impure
tags: ["metabase","setup","api","infra"]
uses_functions: []
uses_types: []
returns: []
error_type: error_go_core
imports: ["httpx"]
tests: []
file_path: python/functions/metabase/setup.py
created_at: 2026-04-05T11:22:32Z
updated_at: 2026-04-05T16:44:15Z
props: []
emits: []
variant: []
content_hash: 9207215e1c4ae7ba2fbc19fb013f659cce35a3ea51b6fe87f679bb3e60d1455b
signature:
  metabase_setup(base_url: str, admin_email: str, admin_password: str, admin_first_name: str, admin_last_name: str, site_name: str, site_locale: str) -> dict

description:
  Ejecuta el setup inicial de una instancia Metabase nueva via POST /api/setup. Obtiene el setup-token automaticamente y crea el usuario admin con preferencias del sitio.

example:
  ```python
  from metabase.setup import metabase_setup

  result = metabase_setup(
      base_url="http://localhost:3000",
      admin_email="admin@fnregistry.local",
      admin_password="FnRegistry2024!",
      admin_first_name="Lucas",
      admin_last_name="Admin",
      site_name="fn-registry",
      site_locale="es",
  )
  print(result["id"])  # session token
  ```

notes:
  Solo funciona en instancias sin configurar (setup-token disponible). Si Metabase ya tiene un usuario, lanza RuntimeError.

  El setup-token se obtiene automaticamente de GET /api/session/properties. Una vez usado, Metabase invalida el token.

code:
  """Setup inicial de Metabase via API."""

  import httpx


  def metabase_setup(
      base_url: str,
      admin_email: str,
      admin_password: str,
      admin_first_name: str = "Admin",
      admin_last_name: str = "User",
      site_name: str = "Metabase",
      site_locale: str = "en",
  ) -> dict:
      """Ejecuta el setup inicial de una instancia Metabase nueva.

      Usa el setup-token de una instancia sin configurar para crear el
      usuario admin y configurar preferencias del sitio. Solo funciona
      una vez — si Metabase ya tiene un usuario, retorna error.

      Endpoint: POST /api/setup (requiere setup-token valido).

      Args:
          base_url: URL base de la instancia (ej: "http://localhost:3000").
          admin_email: Email del usuario admin a crear.
          admin_password: Password del admin (min 8 chars con complejidad).
          admin_first_name: Nombre del admin.
          admin_last_name: Apellido del admin.
          site_name: Nombre del sitio Metabase.
          site_locale: Locale del sitio (ej: "es", "en").

      Returns:
          Dict con el session id del admin recien creado.

      Raises:
          httpx.HTTPStatusError: 400 si el setup-token es invalido o
              Metabase ya fue configurado.
          RuntimeError: Si no se puede obtener el setup-token.

      Example:
          >>> result = metabase_setup(
          ...     "http://localhost:3000",
          ...     "admin@example.com",
          ...     "SecurePass123!",
          ...     site_name="fn-registry",
          ...     site_locale="es",
          ... )
          >>> print(result["id"])  # session token
      """
      url = base_url.rstrip("/")

      # Obtener setup-token
      resp = httpx.get(f"{url}/api/session/properties")
      resp.raise_for_status()
      props = resp.json()
      setup_token = props.get("setup-token")
      if not setup_token:
          raise RuntimeError(
              "No setup-token disponible. Metabase ya fue configurado "
              "o has-user-setup es True."
          )

      # Ejecutar setup
      resp = httpx.post(
          f"{url}/api/setup",
          json={
              "token": setup_token,
              "user": {
                  "first_name": admin_first_name,
                  "last_name": admin_last_name,
                  "email": admin_email,
                  "password": admin_password,
              },
              "prefs": {
                  "site_name": site_name,
                  "site_locale": site_locale,
                  "allow_tracking": False,
              },
          },
          timeout=30.0,
      )
      resp.raise_for_status()
      return resp.json()

params_schema:
  {"params":[{"name":"base_url","desc":"URL base de Metabase a configurar"},{"name":"admin_email","desc":"correo del usuario administrador a crear"},{"name":"admin_password","desc":"contraseña del administrador"},{"name":"admin_first_name","desc":"nombre del administrador"},{"name":"admin_last_name","desc":"apellido del administrador"},{"name":"site_name","desc":"nombre del sitio Metabase"},{"name":"site_locale","desc":"código de idioma (ej: es, en, fr)"}],"output":"dict: resultado del setup con session token de administrador"}


id: metabase_update_card_py_infra
name: metabase_update_card
kind: function
lang: py
domain: infra
version: 1.0.0
purity: impure
signature: def metabase_update_card(client: MetabaseClient, card_id: int, **fields) -> dict
tags: ["metabase","card","question","update","api","python"]
uses_functions: []
uses_types: []
returns: []
error_type: error_go_core
imports: ["httpx"]
example:
  ```python
  metabase_update_card(client, 42, name="New Name", archived=True)
  ```

tests: []
file_path: python/functions/metabase/cards.py
created_at: 2026-04-05T11:22:32Z
updated_at: 2026-04-14T15:24:30Z
props: []
emits: []
variant: []
notes: Soft-delete con `archived=True`. Para delete permanente usar metabase_delete_card.
content_hash: d74899ff0b358ab5e3686f9e828c71127a190c83a4cdc4e1bafbaab0dd510d0a
description:
  Actualiza campos de una card/pregunta via kwargs. Campos: name, description, display, dataset_query, collection_id, archived. Endpoint: PUT /api/card/:id.

code:
[cell repeats an earlier one in this file: 15149 characters]

params_schema:
  {"params":[{"name":"client","desc":"instancia autenticada de MetabaseClient"},{"name":"card_id","desc":"ID de la card a actualizar"},{"name":"fields","desc":"kwargs con campos a actualizar (name, description, display, dataset_query, collection_id, archived)"}],"output":"dict: objeto card actualizado"}


id: metabase_update_collection_graph_py_infra
name: metabase_update_collection_graph
kind: function
lang: py
domain: infra
version: 1.0.0
purity: impure
tags: ["metabase","permissions","collections","graph","access-control","update","api","python"]
uses_functions: []
uses_types: []
returns: []
error_type: error_go_core
imports: []
tests: []
file_path: python/functions/metabase/permissions.py
created_at: 2026-04-13T13:35:56Z
updated_at: 2026-04-13T13:35:56Z
props: []
emits: []
variant: []
content_hash: cae118f04bf384871ad59b952ab2f26d947a369e5b108b4738b1fcab6bed3008
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.

example:
  ```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")
  ```

documentation:
  ## 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".

code:
[cell repeats an earlier one in this file: 13465 characters]

params_schema:
  {"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"}


id: metabase_update_dashboard_py_infra
name: metabase_update_dashboard
kind: function
lang: py
domain: infra
version: 1.0.0
purity: impure
signature: def metabase_update_dashboard(client: MetabaseClient, dashboard_id: int, **fields) -> dict
tags: ["metabase","dashboard","update","cards","api","python"]
uses_functions: []
uses_types: []
returns: []
error_type: error_go_core
imports: ["httpx"]
tests: []
file_path: python/functions/metabase/dashboards.py
created_at: 2026-04-05T11:22:32Z
updated_at: 2026-04-13T13:34:41Z
props: []
emits: []
variant: []
content_hash: a3b5d7fd723d4bd30dd494167c73f9b3f052a4a8d94108db3b4bdc21e195f6a9
description:
  Actualiza dashboard incluyendo metadata, cards y tabs via kwargs. dashcards es el estado completo deseado: nuevas con ID negativo, existentes con positivo, omitidas se eliminan. Endpoint: PUT /api/dashboard/:id.

example:
  ```python
  # Agregar card
  dash = metabase_get_dashboard(client, 1)
  cards = list(dash["dashcards"])
  cards.append({"id": -1, "card_id": 55, "size_x": 6, "size_y": 4, "col": 0, "row": 0})
  metabase_update_dashboard(client, 1, dashcards=cards)

  # Archivar
  metabase_update_dashboard(client, 1, archived=True)
  ```

notes:
  dashcards = estado completo. ID negativo = nueva, positivo = existente, omitida = eliminada.
  Campos: name, description, archived, dashcards, tabs, parameters, collection_id.

code:
[cell repeats an earlier one in this file: 11317 characters]

params_schema:
  {"params":[{"name":"client","desc":"instancia autenticada de MetabaseClient"},{"name":"dashboard_id","desc":"ID del dashboard a actualizar"},{"name":"fields","desc":"kwargs con campos a actualizar (name, description, archived, dashcards, tabs, parameters, collection_id)"}],"output":"dict: objeto dashboard actualizado"}


id: metabase_update_dashboard_safe_py_infra
name: metabase_update_dashboard_safe
kind: function
lang: py
domain: infra
version: 1.0.0
purity: impure
tags: ["metabase","dashboard","dashcard","api","wrapper","safe","put"]
uses_functions: ["metabase_get_dashboard_py_infra"]
uses_types: []
returns: []
error_type: error_go_core
imports: ["httpx"]
tests: []
file_path: python/functions/metabase/metabase_update_dashboard_safe.py
created_at: 2026-04-13T17:06:25Z
updated_at: 2026-04-13T17:06:25Z
props: []
emits: []
variant: []
content_hash: 1ad5de49a4d1b0432d9bcd903b6702f5d25ae7d8285d24d3ca93bff1ff1d7a59
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.

example:
  ```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"},
  )
  ```

notes:
  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.

documentation:
  ## 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`.

code:
  """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,
      }

params_schema:
  {"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}"}


id: metabase_update_document_py_infra
name: metabase_update_document
kind: function
lang: py
domain: infra
version: 1.1.0
purity: impure
tags: ["metabase","document","update","api","python"]
uses_functions: ["metabase_validate_document_payload_py_infra"]
uses_types: []
returns: []
error_type: error_go_core
imports: ["httpx"]
tests: []
file_path: python/functions/metabase/documents.py
created_at: 2026-04-13T12:54:37Z
updated_at: 2026-04-14T15:24:30Z
props: []
emits: []
variant: []
content_hash: c7c1870ce8cb3bbd9c0e350ada2364c74cdd271dded11f6467e60ab39717cc3c
signature:
  def metabase_update_document(client: MetabaseClient, document_id: int, *, validate: bool = True, **fields) -> dict

description:
  Actualiza un document. Solo envia los campos pasados. Si se pasa 'document', valida el ProseMirror antes de enviar (evita documentos vacíos por nodos no soportados).

example:
  ```python
  from metabase.documents import prosemirror_card_embed

  # Renombrar
  metabase_update_document(client, 1, name="Nuevo titulo")

  # Reemplazar contenido con card embebida
  metabase_update_document(client, 1, document={
      "type": "doc",
      "content": [
          {"type": "heading", "attrs": {"level": 1},
           "content": [{"type": "text", "text": "Resumen"}]},
          prosemirror_card_embed(42, height=450),
      ]
  })

  # Mover a coleccion
  metabase_update_document(client, 1, collection_id=5)
  ```

notes:
  - La validación es automática cuando se pasa `document=...`. Si contiene nodos que el frontend no renderiza (callout, taskList, etc.), lanza `ValueError` antes de enviar.
  - Usar `blockquote` en vez de `callout` para destacar texto.
  - Usar `prosemirror_card_embed(card_id)` en vez de `cardEmbed` desnudo.

code:
[cell repeats an earlier one in this file: 13730 characters]

params_schema:
  {"params":[{"name":"client","desc":"instancia autenticada de MetabaseClient"},{"name":"document_id","desc":"ID del document a actualizar"},{"name":"validate","desc":"si True (default), valida el ProseMirror antes de enviar cuando se pasa 'document'"},{"name":"fields","desc":"kwargs con campos a modificar: name, document (arbol ProseMirror), collection_id, archived"}],"output":"dict: document actualizado"}


id: metabase_update_group_py_infra
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
tags: ["metabase","permissions","group","update","rename","api","python"]
uses_functions: []
uses_types: []
returns: []
error_type: error_go_core
imports: []
tests: []
file_path: python/functions/metabase/permissions.py
created_at: 2026-04-13T13:34:41Z
updated_at: 2026-04-13T13:34:41Z
props: []
emits: []
variant: []
content_hash: 6ab6560070ad92fdf337c998d9837df894f7350d8d50a1a671ed13ca02f8751c
description:
  Renombra un Permission Group en Metabase. La API solo permite modificar el nombre del grupo. Endpoint: PUT /api/permissions/group/:id.

example:
  ```python
  group = metabase_update_group(client, 3, "Data Team")
  print(group["name"])  # "Data Team"
  ```

notes:
  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.

code:
[cell repeats an earlier one in this file: 13465 characters]

params_schema:
  {"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"}


id: metabase_update_notification_py_infra
name: metabase_update_notification
kind: function
lang: py
domain: infra
version: 1.0.0
purity: impure
signature: def metabase_update_notification(client: MetabaseClient, notification_id: int, **fields) -> dict
tags: ["metabase","notification","update","api","python"]
uses_functions: []
uses_types: []
returns: []
error_type: error_go_core
imports: []
tests: []
file_path: python/functions/metabase/notifications.py
created_at: 2026-04-14T15:24:30Z
updated_at: 2026-04-14T15:24:30Z
props: []
emits: []
variant: []
content_hash: 214b75215bde9e6b9a96d8f7fbf9cfcf2d990f969ad57d7ce563cd902b3eb2d3
description:
  Actualiza una notificacion existente de Metabase (alerta o suscripcion). Permite modificar active, handlers, subscriptions o payload. Endpoint: PUT /api/notification/:id.

example:
  ```python
  # Desactivar una alerta
  updated = metabase_update_notification(client, 99, active=False)
  print(updated["active"])  # False

  # Cambiar destinatarios
  updated = metabase_update_notification(
      client,
      99,
      handlers=[{
          "channel_type": "channel/email",
          "recipients": [
              {"type": "notification-recipient/user", "user_id": 2},
          ],
      }],
  )

  # Cambiar cron a diario a las 7am
  updated = metabase_update_notification(
      client,
      99,
      subscriptions=[{
          "type": "notification-subscription/cron",
          "cron_schedule": "0 7 * * *",
      }],
  )
  ```

notes:
  Solo se envian los campos que se pasan como kwargs.
  Para reactivar una alerta previamente desactivada usar active=True.

code:
[cell repeats an earlier one in this file: 8185 characters]

params_schema:
  {"params":[{"name":"client","desc":"instancia autenticada de MetabaseClient"},{"name":"notification_id","desc":"ID de la notificacion a modificar"},{"name":"**fields","desc":"campos a actualizar: active (bool), handlers (list[dict]), subscriptions (list[dict]), payload (dict)"}],"output":"dict: notificacion actualizada con id, active, payload_type, payload, subscriptions, handlers, updated_at"}


id: metabase_update_permission_graph_py_infra
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
tags: ["metabase","permissions","graph","databases","schemas","access-control","update","api","python"]
uses_functions: []
uses_types: []
returns: []
error_type: error_go_core
imports: []
tests: []
file_path: python/functions/metabase/permissions.py
created_at: 2026-04-13T13:35:56Z
updated_at: 2026-04-13T13:35:56Z
props: []
emits: []
variant: []
content_hash: 958fcd9c604eae8ddc63a870eb2c01dd7f184f19a2f647bc7c4ac66250fe694e
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.

example:
  ```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"])
  ```

documentation:
  ## 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",
  }
  ```

code:
[cell repeats an earlier one in this file: 13465 characters]

params_schema:
  {"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"}


id: metabase_update_snippet_py_infra
name: metabase_update_snippet
kind: function
lang: py
domain: infra
version: 1.0.0
purity: impure
signature: def metabase_update_snippet(client: MetabaseClient, snippet_id: int, **fields) -> dict
tags: ["metabase","snippet","update","api","python"]
uses_functions: []
uses_types: ["MetabaseClient_go_infra"]
returns: []
error_type: error_go_core
imports: ["httpx"]
tests: []
file_path: python/functions/metabase/snippets.py
created_at: 2026-04-14T15:24:30Z
updated_at: 2026-04-14T15:24:30Z
props: []
emits: []
variant: []
content_hash: 05a929225c551f2b598d97244fecece8c040cc7d9376eb5af64c94a61208c6af
description:
  Actualiza campos de un SQL snippet en Metabase. Acepta name, content, description, collection_id, archived via **fields.

example:
  ```python
  # Actualizar solo el contenido SQL
  updated = metabase_update_snippet(
      client, 42,
      content="WITH supply_full AS (SELECT * FROM supply_orders)",
      description="Version simplificada sin JOINs",
  )
  print(updated["updated_at"])

  # Renombrar
  metabase_update_snippet(client, 42, name="supply_orders_simple_cte")

  # Mover a otra coleccion
  metabase_update_snippet(client, 42, collection_id=7)

  # Archivar (o usar metabase_archive_snippet)
  metabase_update_snippet(client, 42, archived=True)
  ```

notes:
  Solo los campos reconocidos (name, content, description, collection_id, archived) se incluyen en el body.
  Campos desconocidos en **fields se ignoran silenciosamente para evitar errores 400.
  Para archivar en una sola llamada, preferir metabase_archive_snippet.

code:
[cell repeats an earlier one in this file: 5622 characters]

params_schema:
  {"params":[{"name":"client","desc":"instancia autenticada de MetabaseClient"},{"name":"snippet_id","desc":"ID numerico del snippet a actualizar"},{"name":"**fields","desc":"campos a modificar: name (str), content (str), description (str), collection_id (int), archived (bool)"}],"output":"dict: snippet actualizado con todos los campos incluyendo updated_at"}


id: metabase_update_user_py_infra
name: metabase_update_user
kind: function
lang: py
domain: infra
version: 1.0.0
purity: impure
signature: def metabase_update_user(client: MetabaseClient, user_id: int, **fields) -> dict
tags: ["metabase","user","update","api","python"]
uses_functions: []
uses_types: []
returns: []
error_type: error_go_core
imports: ["httpx"]
tests: []
file_path: python/functions/metabase/users.py
created_at: 2026-04-05T11:22:32Z
updated_at: 2026-04-05T16:44:26Z
props: []
emits: []
variant: []
notes: Solo se modifican los campos pasados como kwargs.
content_hash: 7c30deba0f07546cc3eb2c6068768243fabc537cf9c9827fa2cc4a3bf8caa12f
description:
  Actualiza campos de un usuario en Metabase via keyword arguments. Campos: first_name, last_name, email, is_superuser, group_ids, locale. Endpoint: PUT /api/user/:id.

example:
  ```python
  metabase_update_user(client, 5, first_name="Jane", is_superuser=True)
  metabase_update_user(client, 5, group_ids=[1, 3, 5])
  ```

code:
[cell repeats an earlier one in this file: 4778 characters]

params_schema:
  {"params":[{"name":"client","desc":"instancia autenticada de MetabaseClient"},{"name":"user_id","desc":"ID del usuario a actualizar"},{"name":"fields","desc":"kwargs con campos a actualizar (first_name, last_name, email, is_superuser, group_ids, locale)"}],"output":"dict: objeto usuario actualizado"}


id: metabase_validate_card_payload_py_infra
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]
tags: ["metabase","validation","card","pure","pre-flight","structural"]
uses_functions: []
uses_types: []
returns: []
imports: []
test_file_path: python/functions/metabase/validation_test.py
file_path: python/functions/metabase/validation.py
created_at: 2026-04-13T10:12:25Z
updated_at: 2026-04-13T13:02:40Z
props: []
emits: []
variant: []
content_hash: e3acb586f6cbc9fde44b1b6675c0be718ea228609d3b1f5971aa35da87f91881
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.

example:
  ```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)
  ```

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"]

notes:
  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.

code:
  """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)

params_schema:
  {"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"}


id: metabase_validate_dashboard_payload_py_infra
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]
tags: ["metabase","validation","dashboard","dashcard","overlap","pure","pre-flight","structural"]
uses_functions: []
uses_types: []
returns: []
imports: []
test_file_path: python/functions/metabase/validation_test.py
file_path: python/functions/metabase/validation.py
created_at: 2026-04-13T10:12:25Z
updated_at: 2026-04-13T13:02:40Z
props: []
emits: []
variant: []
content_hash: 030953837a54453625b6609f01fbdcb8fb8244115b1e38ef09aa59bf1300596d
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.

example:
  ```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)
  ```

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"]

notes:
  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.

code:
  """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)

params_schema:
  {"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"}


id: metabase_validate_document_payload_py_infra
name: metabase_validate_document_payload
kind: function
lang: py
domain: infra
version: 1.0.0
purity: pure
tags: ["metabase","document","prosemirror","validate","pure","python"]
uses_functions: []
uses_types: []
returns: []
imports: []
test_file_path: python/functions/metabase/validation_test.py
file_path: python/functions/metabase/validation.py
created_at: 2026-04-13T13:02:40Z
updated_at: 2026-04-13T13:02:40Z
props: []
emits: []
variant: []
content_hash: ee608f34772eec75c1c2c8c6b2b5a805bed8ad8a6cffad7119892773f0dc056f
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.

example:
  ```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..."]
  ```

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"]

notes:
  **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)

code:
  """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)

params_schema:
  {"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"}


