Files
fn_registry/python/functions/metabase/permissions.py
T
egutierrez 4300f1242d feat(metabase): expansion de funciones Python — documents, collections, permissions, validation
Añade un conjunto amplio de funciones al paquete python/functions/metabase:
- Nuevos modulos: collections.py, documents.py, maintenance.py, permissions.py, validation.py (+ test).
- Ampliacion de cards.py, dashboards.py, client.py e __init__.py para exponer las nuevas operaciones.
- Funciones de documentos (create/get/update/delete/archive/copy/move + comentarios), grupos y memberships, permission/collection graphs, copy/move de cards y dashboards, validacion de MBQL/SQL y payloads, actualizacion segura de dashboards y fix_null_ratio.
- .md por funcion con frontmatter para que fn index los registre.
- Actualiza pyproject.toml y uv.lock con las dependencias resultantes.

Impacto: ampliamente mas cobertura de la API de Metabase desde el registry, reutilizable por apps y analisis. No toca Go ni frontend.
2026-04-13 23:31:42 +02:00

370 lines
13 KiB
Python

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