From 88119ee1b2919b30fbd7428b6d26040142657e84 Mon Sep 17 00:00:00 2001 From: egutierrez Date: Thu, 14 May 2026 18:13:22 +0200 Subject: [PATCH] feat(pipelines): auto-commit con 3 cambios Co-Authored-By: Claude Opus 4.7 (1M context) --- .../metabase_bulk_add_users_to_group.md | 81 +++++++ .../metabase_bulk_add_users_to_group.py | 160 ++++++++++++++ .../test_metabase_bulk_add_users_to_group.py | 202 ++++++++++++++++++ 3 files changed, 443 insertions(+) create mode 100644 python/functions/pipelines/metabase_bulk_add_users_to_group.md create mode 100644 python/functions/pipelines/metabase_bulk_add_users_to_group.py create mode 100644 python/functions/pipelines/test_metabase_bulk_add_users_to_group.py diff --git a/python/functions/pipelines/metabase_bulk_add_users_to_group.md b/python/functions/pipelines/metabase_bulk_add_users_to_group.md new file mode 100644 index 00000000..6ca4dd54 --- /dev/null +++ b/python/functions/pipelines/metabase_bulk_add_users_to_group.md @@ -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. diff --git a/python/functions/pipelines/metabase_bulk_add_users_to_group.py b/python/functions/pipelines/metabase_bulk_add_users_to_group.py new file mode 100644 index 00000000..1bb6804a --- /dev/null +++ b/python/functions/pipelines/metabase_bulk_add_users_to_group.py @@ -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 diff --git a/python/functions/pipelines/test_metabase_bulk_add_users_to_group.py b/python/functions/pipelines/test_metabase_bulk_add_users_to_group.py new file mode 100644 index 00000000..42466482 --- /dev/null +++ b/python/functions/pipelines/test_metabase_bulk_add_users_to_group.py @@ -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()