auto(0129): agents_dashboard — secret_store_cpp_infra + CMakeLists register #4
@@ -0,0 +1,81 @@
|
||||
---
|
||||
name: metabase_bulk_add_users_to_group
|
||||
kind: pipeline
|
||||
lang: py
|
||||
domain: pipelines
|
||||
version: "1.0.0"
|
||||
purity: impure
|
||||
signature: "def metabase_bulk_add_users_to_group(client: MetabaseClient, group: int | str, targets: list[dict], send_reset_existing: bool = False) -> list[dict]"
|
||||
description: "Crea (si faltan) y añade al Permission Group dado una lista de usuarios. Idempotente: usuarios existentes se re-añaden solo si no son miembros. Soporta resolver group por id o por nombre (substring case-insensitive). Opcionalmente reenvia mail de reset a los ya existentes."
|
||||
tags: [metabase, users, groups, bulk]
|
||||
uses_functions:
|
||||
- metabase_list_groups_py_infra
|
||||
- metabase_list_users_py_infra
|
||||
- metabase_get_group_py_infra
|
||||
- metabase_create_user_py_infra
|
||||
- metabase_add_membership_py_infra
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: "error_go_core"
|
||||
imports: ["httpx"]
|
||||
file_path: "python/functions/pipelines/metabase_bulk_add_users_to_group.py"
|
||||
params:
|
||||
- name: client
|
||||
desc: "MetabaseClient autenticado (admin/superuser)."
|
||||
- name: group
|
||||
desc: "group_id (int) o nombre del grupo (str, substring case-insensitive). Debe matchear exactamente 1 grupo."
|
||||
- name: targets
|
||||
desc: "Lista de dicts con keys first_name, last_name, email."
|
||||
- name: send_reset_existing
|
||||
desc: "Si True, reenvia POST /api/session/forgot_password a los usuarios ya existentes."
|
||||
output: "Lista de dicts por target con keys email, user_id, status (created|existing|create_failed), membership (added|already_member|membership_failed|skipped), reset_sent (bool|None), error (str|None)."
|
||||
example: "results = metabase_bulk_add_users_to_group(client, 'Jefes de Centro', [{'first_name': 'Salvador', 'last_name': 'Marco Sanchez', 'email': 'smarco@mutuamadmotor.com'}])"
|
||||
tested: false
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```python
|
||||
import os
|
||||
import sys
|
||||
sys.path.insert(0, "python/functions")
|
||||
|
||||
from metabase import MetabaseClient
|
||||
from pipelines.metabase_bulk_add_users_to_group import metabase_bulk_add_users_to_group
|
||||
|
||||
client = MetabaseClient("https://reports.autingo.es", os.environ["MB_KEY"])
|
||||
|
||||
targets = [
|
||||
{"first_name": "Salvador", "last_name": "Marco Sanchez", "email": "smarco@mutuamadmotor.com"},
|
||||
{"first_name": "Fernando", "last_name": "Martinez Gomez", "email": "fmartinez@mutuamadmotor.com"},
|
||||
{"first_name": "Lucia", "last_name": "Perez Ruiz", "email": "lperez@mutuamadmotor.com"},
|
||||
]
|
||||
|
||||
results = metabase_bulk_add_users_to_group(
|
||||
client,
|
||||
"Jefes de Centro",
|
||||
targets,
|
||||
send_reset_existing=True,
|
||||
)
|
||||
|
||||
for r in results:
|
||||
print(r["email"], r["status"], r["membership"])
|
||||
# smarco@mutuamadmotor.com created added
|
||||
# fmartinez@mutuamadmotor.com existing already_member
|
||||
# lperez@mutuamadmotor.com created added
|
||||
```
|
||||
|
||||
## Cuando usarla
|
||||
|
||||
Cuando tengas que dar de alta o re-añadir N usuarios al mismo Permission Group de Metabase. Reemplaza la composicion manual de list_groups + list_users + get_group + create_user + add_membership (22 calls para 9 usuarios -> 1 call one-shot).
|
||||
|
||||
## Gotchas
|
||||
|
||||
- Requiere `client` con permisos superuser. Sin ellos la primera llamada a `metabase_list_groups` ya devuelve 403.
|
||||
- `metabase_list_users` se llama con `limit=1000`. Si la instancia tiene mas de 1000 usuarios activos/inactivos, este pipeline no los ve todos — reescribir con paginacion.
|
||||
- Email match es case-insensitive contra el campo `email` del usuario. No deduplica targets repetidos en `targets` (dos dicts con el mismo email generan dos filas).
|
||||
- `metabase_create_user` sin password manda email de invitacion. Si SMTP no esta configurado, los nuevos quedan creados pero sin correo; el caller puede llamar a `forgot_password` manualmente luego.
|
||||
- `send_reset_existing=True` solo afecta a los `status="existing"`. Los `status="created"` no reciben reset (ya recibieron invitacion).
|
||||
- `group` por nombre (str): si el substring matchea 0 o >1 grupos -> `ValueError`. Preferir `group_id` (int) si hay nombres ambiguos.
|
||||
- La respuesta de `metabase_list_users` puede ser un dict con key `data` o directamente la lista, dependiendo de la version de Metabase. El pipeline maneja ambos formatos.
|
||||
@@ -0,0 +1,160 @@
|
||||
"""Pipeline: crea (si faltan) y añade al Permission Group una lista de usuarios.
|
||||
|
||||
Compone: metabase_list_groups + metabase_list_users + metabase_get_group
|
||||
+ metabase_create_user + metabase_add_membership.
|
||||
|
||||
Reduce 22 llamadas para 9 usuarios a una sola llamada one-shot idempotente.
|
||||
"""
|
||||
|
||||
import sys
|
||||
import os
|
||||
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
|
||||
|
||||
import httpx
|
||||
from metabase import (
|
||||
MetabaseClient,
|
||||
metabase_list_groups,
|
||||
metabase_list_users,
|
||||
metabase_get_group,
|
||||
metabase_create_user,
|
||||
metabase_add_membership,
|
||||
)
|
||||
|
||||
|
||||
def metabase_bulk_add_users_to_group(
|
||||
client: MetabaseClient,
|
||||
group: int | str,
|
||||
targets: list[dict],
|
||||
send_reset_existing: bool = False,
|
||||
) -> list[dict]:
|
||||
"""Crea (si faltan) y añade al Permission Group dado una lista de usuarios.
|
||||
|
||||
Idempotente: si el usuario ya existe, no lo crea; si ya es miembro, no lo
|
||||
re-añade. Soporta resolver el grupo por id (int) o por nombre (str,
|
||||
substring case-insensitive).
|
||||
|
||||
Args:
|
||||
client: MetabaseClient autenticado (admin/superuser).
|
||||
group: group_id (int) o nombre del grupo (str, substring
|
||||
case-insensitive). Debe matchear exactamente 1 grupo.
|
||||
targets: lista de dicts con keys first_name, last_name, email.
|
||||
send_reset_existing: si True, reenvia POST /api/session/forgot_password
|
||||
a los usuarios ya existentes (no a los recien
|
||||
creados, que ya recibieron invitacion).
|
||||
|
||||
Returns:
|
||||
Lista de dicts, uno por target, en el mismo orden que targets:
|
||||
{
|
||||
"email": str,
|
||||
"user_id": int | None,
|
||||
"status": "created" | "existing" | "create_failed",
|
||||
"membership": "added" | "already_member" | "membership_failed" | "skipped",
|
||||
"reset_sent": bool | None,
|
||||
"error": str | None,
|
||||
}
|
||||
|
||||
Raises:
|
||||
ValueError: si group es str y no matchea exactamente 1 grupo.
|
||||
KeyError: si algun dict de targets no tiene first_name, last_name o email.
|
||||
"""
|
||||
# 1. Resolver group_id
|
||||
if isinstance(group, int):
|
||||
group_id = group
|
||||
else:
|
||||
groups = metabase_list_groups(client)
|
||||
needle = group.lower()
|
||||
matches = [g for g in groups if needle in g["name"].lower()]
|
||||
if len(matches) == 0:
|
||||
raise ValueError(
|
||||
f"No group found matching '{group}'. "
|
||||
f"Available groups: {[g['name'] for g in groups]}"
|
||||
)
|
||||
if len(matches) > 1:
|
||||
raise ValueError(
|
||||
f"Ambiguous group name '{group}': matched {len(matches)} groups: "
|
||||
+ ", ".join(f"{g['id']}:{g['name']}" for g in matches)
|
||||
)
|
||||
group_id = matches[0]["id"]
|
||||
|
||||
# 2. Snapshot usuarios existentes (email -> user dict)
|
||||
users_response = metabase_list_users(client, status="all", limit=1000)
|
||||
# metabase_list_users devuelve dict con key 'data'
|
||||
users_data = users_response.get("data", users_response) if isinstance(users_response, dict) else users_response
|
||||
by_email: dict[str, dict] = {u["email"].lower(): u for u in users_data}
|
||||
|
||||
# 3. Snapshot miembros actuales del grupo
|
||||
group_info = metabase_get_group(client, group_id)
|
||||
member_ids: set[int] = {m["user_id"] for m in group_info.get("members", [])}
|
||||
|
||||
# 4. Procesar cada target
|
||||
results: list[dict] = []
|
||||
|
||||
for target in targets:
|
||||
first_name = target["first_name"]
|
||||
last_name = target["last_name"]
|
||||
email = target["email"]
|
||||
email_lower = email.lower()
|
||||
|
||||
row: dict = {
|
||||
"email": email,
|
||||
"user_id": None,
|
||||
"status": None,
|
||||
"membership": None,
|
||||
"reset_sent": None,
|
||||
"error": None,
|
||||
}
|
||||
|
||||
# Determinar si el usuario ya existe
|
||||
if email_lower in by_email:
|
||||
uid = by_email[email_lower]["id"]
|
||||
row["user_id"] = uid
|
||||
row["status"] = "existing"
|
||||
else:
|
||||
try:
|
||||
created = metabase_create_user(client, first_name, last_name, email)
|
||||
uid = created["id"]
|
||||
row["user_id"] = uid
|
||||
row["status"] = "created"
|
||||
# Actualizar snapshot para idempotencia dentro del mismo batch
|
||||
by_email[email_lower] = created
|
||||
except Exception as exc:
|
||||
row["status"] = "create_failed"
|
||||
row["membership"] = "skipped"
|
||||
row["error"] = str(exc)
|
||||
results.append(row)
|
||||
continue
|
||||
|
||||
# Determinar membresia
|
||||
if uid in member_ids:
|
||||
row["membership"] = "already_member"
|
||||
else:
|
||||
try:
|
||||
metabase_add_membership(client, uid, group_id)
|
||||
row["membership"] = "added"
|
||||
member_ids.add(uid)
|
||||
except Exception as exc:
|
||||
row["membership"] = "membership_failed"
|
||||
row["error"] = str(exc)
|
||||
|
||||
# Reset email (solo para usuarios existentes, no recien creados)
|
||||
if send_reset_existing and row["status"] == "existing" and row["error"] is None:
|
||||
try:
|
||||
resp = httpx.post(
|
||||
f"{client.base_url}/api/session/forgot_password",
|
||||
json={"email": email},
|
||||
timeout=10,
|
||||
)
|
||||
if resp.status_code == 204:
|
||||
row["reset_sent"] = True
|
||||
else:
|
||||
row["reset_sent"] = False
|
||||
msg = f"forgot_password returned {resp.status_code}"
|
||||
row["error"] = msg
|
||||
except Exception as exc:
|
||||
row["reset_sent"] = False
|
||||
row["error"] = f"forgot_password error: {exc}"
|
||||
|
||||
results.append(row)
|
||||
|
||||
return results
|
||||
@@ -0,0 +1,202 @@
|
||||
"""Tests para metabase_bulk_add_users_to_group (mocks only — sin instancia real)."""
|
||||
|
||||
import sys
|
||||
import os
|
||||
import unittest
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
|
||||
|
||||
from pipelines.metabase_bulk_add_users_to_group import metabase_bulk_add_users_to_group
|
||||
|
||||
|
||||
def _make_client():
|
||||
"""Crea un MetabaseClient mock con base_url."""
|
||||
client = MagicMock()
|
||||
client.base_url = "https://metabase.example.com"
|
||||
return client
|
||||
|
||||
|
||||
class TestBulkAddUsersToGroup(unittest.TestCase):
|
||||
|
||||
def test_one_new_one_existing_already_member(self):
|
||||
"""1 usuario a crear + 1 existente que ya es miembro -> created+added / existing+already_member."""
|
||||
client = _make_client()
|
||||
|
||||
targets = [
|
||||
{"first_name": "Alice", "last_name": "Smith", "email": "alice@example.com"},
|
||||
{"first_name": "Bob", "last_name": "Jones", "email": "bob@example.com"},
|
||||
]
|
||||
|
||||
# bob ya existe con id=10, alice no existe
|
||||
users_data = [{"id": 10, "email": "bob@example.com"}]
|
||||
# El grupo tiene id=5 y bob (id=10) ya es miembro
|
||||
group_info = {"id": 5, "name": "Admins", "members": [{"user_id": 10}]}
|
||||
# alice se crea con id=20
|
||||
created_alice = {"id": 20, "email": "alice@example.com"}
|
||||
|
||||
with patch(
|
||||
"pipelines.metabase_bulk_add_users_to_group.metabase_list_groups",
|
||||
return_value=[{"id": 5, "name": "Admins"}],
|
||||
), patch(
|
||||
"pipelines.metabase_bulk_add_users_to_group.metabase_list_users",
|
||||
return_value={"data": users_data},
|
||||
), patch(
|
||||
"pipelines.metabase_bulk_add_users_to_group.metabase_get_group",
|
||||
return_value=group_info,
|
||||
), patch(
|
||||
"pipelines.metabase_bulk_add_users_to_group.metabase_create_user",
|
||||
return_value=created_alice,
|
||||
) as mock_create, patch(
|
||||
"pipelines.metabase_bulk_add_users_to_group.metabase_add_membership",
|
||||
return_value=[],
|
||||
) as mock_add:
|
||||
|
||||
results = metabase_bulk_add_users_to_group(client, "Admins", targets)
|
||||
|
||||
self.assertEqual(len(results), 2)
|
||||
|
||||
alice_r = results[0]
|
||||
self.assertEqual(alice_r["email"], "alice@example.com")
|
||||
self.assertEqual(alice_r["status"], "created")
|
||||
self.assertEqual(alice_r["membership"], "added")
|
||||
self.assertIsNone(alice_r["error"])
|
||||
|
||||
bob_r = results[1]
|
||||
self.assertEqual(bob_r["email"], "bob@example.com")
|
||||
self.assertEqual(bob_r["status"], "existing")
|
||||
self.assertEqual(bob_r["membership"], "already_member")
|
||||
self.assertIsNone(bob_r["error"])
|
||||
|
||||
mock_create.assert_called_once_with(client, "Alice", "Smith", "alice@example.com")
|
||||
mock_add.assert_called_once_with(client, 20, 5)
|
||||
|
||||
def test_one_new_one_existing_not_yet_member(self):
|
||||
"""1 existente que NO es miembro todavia -> existing + added."""
|
||||
client = _make_client()
|
||||
|
||||
targets = [
|
||||
{"first_name": "Carol", "last_name": "White", "email": "carol@example.com"},
|
||||
]
|
||||
|
||||
users_data = [{"id": 30, "email": "carol@example.com"}]
|
||||
# carol (id=30) NO esta en el grupo
|
||||
group_info = {"id": 7, "name": "Jefes", "members": []}
|
||||
|
||||
with patch(
|
||||
"pipelines.metabase_bulk_add_users_to_group.metabase_list_groups",
|
||||
return_value=[{"id": 7, "name": "Jefes"}],
|
||||
), patch(
|
||||
"pipelines.metabase_bulk_add_users_to_group.metabase_list_users",
|
||||
return_value={"data": users_data},
|
||||
), patch(
|
||||
"pipelines.metabase_bulk_add_users_to_group.metabase_get_group",
|
||||
return_value=group_info,
|
||||
), patch(
|
||||
"pipelines.metabase_bulk_add_users_to_group.metabase_add_membership",
|
||||
return_value=[],
|
||||
) as mock_add:
|
||||
|
||||
results = metabase_bulk_add_users_to_group(client, 7, targets)
|
||||
|
||||
self.assertEqual(len(results), 1)
|
||||
r = results[0]
|
||||
self.assertEqual(r["status"], "existing")
|
||||
self.assertEqual(r["membership"], "added")
|
||||
mock_add.assert_called_once_with(client, 30, 7)
|
||||
|
||||
def test_group_name_no_match_raises(self):
|
||||
"""group por nombre que no matchea ningun grupo -> ValueError."""
|
||||
client = _make_client()
|
||||
|
||||
with patch(
|
||||
"pipelines.metabase_bulk_add_users_to_group.metabase_list_groups",
|
||||
return_value=[{"id": 1, "name": "Reportes"}, {"id": 2, "name": "Finanzas"}],
|
||||
):
|
||||
with self.assertRaises(ValueError) as ctx:
|
||||
metabase_bulk_add_users_to_group(client, "Inexistente", [])
|
||||
|
||||
self.assertIn("No group found", str(ctx.exception))
|
||||
|
||||
def test_group_name_multiple_matches_raises(self):
|
||||
"""group por nombre que matchea >1 grupo -> ValueError con mensaje claro."""
|
||||
client = _make_client()
|
||||
|
||||
with patch(
|
||||
"pipelines.metabase_bulk_add_users_to_group.metabase_list_groups",
|
||||
return_value=[
|
||||
{"id": 10, "name": "Jefes de Centro"},
|
||||
{"id": 11, "name": "Jefes de Zona"},
|
||||
],
|
||||
):
|
||||
with self.assertRaises(ValueError) as ctx:
|
||||
metabase_bulk_add_users_to_group(client, "Jefes", [])
|
||||
|
||||
msg = str(ctx.exception)
|
||||
self.assertIn("Ambiguous", msg)
|
||||
self.assertIn("10:Jefes de Centro", msg)
|
||||
self.assertIn("11:Jefes de Zona", msg)
|
||||
|
||||
def test_create_failure_marks_skipped(self):
|
||||
"""Si create_user falla, status=create_failed y membership=skipped."""
|
||||
client = _make_client()
|
||||
|
||||
targets = [
|
||||
{"first_name": "Dave", "last_name": "Error", "email": "dave@example.com"},
|
||||
]
|
||||
|
||||
with patch(
|
||||
"pipelines.metabase_bulk_add_users_to_group.metabase_list_groups",
|
||||
return_value=[{"id": 3, "name": "TestGroup"}],
|
||||
), patch(
|
||||
"pipelines.metabase_bulk_add_users_to_group.metabase_list_users",
|
||||
return_value={"data": []},
|
||||
), patch(
|
||||
"pipelines.metabase_bulk_add_users_to_group.metabase_get_group",
|
||||
return_value={"id": 3, "name": "TestGroup", "members": []},
|
||||
), patch(
|
||||
"pipelines.metabase_bulk_add_users_to_group.metabase_create_user",
|
||||
side_effect=Exception("409 Conflict"),
|
||||
), patch(
|
||||
"pipelines.metabase_bulk_add_users_to_group.metabase_add_membership"
|
||||
) as mock_add:
|
||||
|
||||
results = metabase_bulk_add_users_to_group(client, "TestGroup", targets)
|
||||
|
||||
r = results[0]
|
||||
self.assertEqual(r["status"], "create_failed")
|
||||
self.assertEqual(r["membership"], "skipped")
|
||||
self.assertIsNotNone(r["error"])
|
||||
mock_add.assert_not_called()
|
||||
|
||||
def test_group_by_int_skips_list_groups(self):
|
||||
"""group=int usa el id directamente sin llamar a metabase_list_groups."""
|
||||
client = _make_client()
|
||||
|
||||
targets = [{"first_name": "Eve", "last_name": "Test", "email": "eve@example.com"}]
|
||||
|
||||
with patch(
|
||||
"pipelines.metabase_bulk_add_users_to_group.metabase_list_groups"
|
||||
) as mock_list_groups, patch(
|
||||
"pipelines.metabase_bulk_add_users_to_group.metabase_list_users",
|
||||
return_value={"data": []},
|
||||
), patch(
|
||||
"pipelines.metabase_bulk_add_users_to_group.metabase_get_group",
|
||||
return_value={"id": 99, "name": "Direct", "members": []},
|
||||
), patch(
|
||||
"pipelines.metabase_bulk_add_users_to_group.metabase_create_user",
|
||||
return_value={"id": 50, "email": "eve@example.com"},
|
||||
), patch(
|
||||
"pipelines.metabase_bulk_add_users_to_group.metabase_add_membership",
|
||||
return_value=[],
|
||||
):
|
||||
|
||||
results = metabase_bulk_add_users_to_group(client, 99, targets)
|
||||
|
||||
mock_list_groups.assert_not_called()
|
||||
self.assertEqual(results[0]["status"], "created")
|
||||
self.assertEqual(results[0]["membership"], "added")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user