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