---
name: metabase_bulk_add_users_to_group
kind: pipeline
lang: py
domain: pipelines
version: "1.1.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:
  - clean_email_py_core
  - 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.
- Desde v1.1.0 cada `target["email"]` pasa por `clean_email` antes de cualquier llamada: strippea espacios, angle brackets `<...>`, comillas y normaliza a minusculas. Si la limpieza falla (ValueError) la fila sale como `status="create_failed"` sin tocar Metabase. Esto absorbe TSVs/CSVs con formato Outlook (`" <foo@bar.com>"`) que antes provocaban 400 Bad Request al POST /api/user.

## Capability growth log

- v1.1.0 (2026-05-28) — integra `clean_email_py_core` para normalizar emails de cada target. Absorbe `<...>`, comillas y whitespace antes del POST a Metabase. Fix tras incidente real con TSV de 90 jefes de centro donde 1 fila tenia `" <eaznarez@mutuamadmotor.com>"` y rompia create_user con 400.
