fix(fn-run): propagar stdout/stderr de bash functions library-style #1

Open
dataforge wants to merge 537 commits from auto/0077-fn-run-bash-mudo into master
3 changed files with 443 additions and 0 deletions
Showing only changes of commit 88119ee1b2 - Show all commits
@@ -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()