merge: quick/metabase-expansion-and-issues — expansion Metabase + issues tracker
This commit is contained in:
@@ -1,183 +0,0 @@
|
||||
# 0008 — SQLite API Web
|
||||
|
||||
## Metadata
|
||||
|
||||
| Campo | Valor |
|
||||
|-------|-------|
|
||||
| **ID** | 0008 |
|
||||
| **Estado** | 🟡 pendiente |
|
||||
| **Prioridad** | alta |
|
||||
| **Tipo** | feature |
|
||||
|
||||
## Dependencias
|
||||
|
||||
Ninguna.
|
||||
|
||||
---
|
||||
|
||||
## Objetivo
|
||||
|
||||
App que expone `registry.db` y los `operations.db` de cada app como API REST HTTP, permitiendo que herramientas externas (dashboards, scripts, agentes, frontends) consulten las bases de datos del registry sin necesidad de acceso directo al filesystem ni SQLite CLI.
|
||||
|
||||
## Contexto
|
||||
|
||||
- Actualmente para consultar `registry.db` hay que estar en la misma máquina y usar `sqlite3` directamente o funciones Go que abren el archivo.
|
||||
- Las apps existentes (metabase_registry, registry_dashboard) acceden a SQLite localmente. Cualquier herramienta nueva que necesite datos del registry tiene que reimplementar la conexión.
|
||||
- Con una API web, cualquier cliente HTTP (curl, fetch, Python requests, frontends React) puede consultar el registry de forma uniforme.
|
||||
- Metabase ya resuelve visualización, pero no da acceso programático limpio a los datos para agentes y scripts remotos.
|
||||
|
||||
## Arquitectura
|
||||
|
||||
```
|
||||
apps/sqlite_api/
|
||||
├── main.go — NEW: Entry point, configura rutas y arranca servidor
|
||||
├── handlers.go — NEW: Handlers HTTP (query, tables, schema)
|
||||
├── config.go — NEW: Configuración (puerto, DBs permitidas, read-only)
|
||||
├── app.md — NEW: Metadata de la app (tag: service)
|
||||
└── operations.db — Runtime: operaciones propias
|
||||
```
|
||||
|
||||
### Patrón pure core / impure shell
|
||||
|
||||
- **Funciones del registry usadas:** `http_get_json_go_infra`, `http_post_json_go_infra` (para tests/clientes), `cache_to_sqlite_go_infra` (opcional para cache de queries)
|
||||
- **Core puro:** validación de queries (solo SELECT/PRAGMA permitidos), parsing de parámetros, formateo de resultados JSON
|
||||
- **Shell impuro:** servidor HTTP, apertura de SQLite, ejecución de queries
|
||||
|
||||
## Diseño de API
|
||||
|
||||
### Endpoints
|
||||
|
||||
```
|
||||
GET /api/databases — Lista de DBs disponibles
|
||||
GET /api/databases/:db/tables — Lista tablas de una DB
|
||||
GET /api/databases/:db/schema — Schema completo (.schema)
|
||||
POST /api/databases/:db/query — Ejecuta query SQL (solo SELECT)
|
||||
GET /api/databases/:db/fts?q=texto&table=functions — Búsqueda FTS5 directa
|
||||
GET /health — Health check
|
||||
```
|
||||
|
||||
### Bases de datos expuestas
|
||||
|
||||
| Alias | Path real | Descripción |
|
||||
|-------|-----------|-------------|
|
||||
| `registry` | `registry.db` (raíz) | Funciones, tipos, proposals |
|
||||
| `ops:{app}` | `apps/{app}/operations.db` | Entities, relations, executions de cada app |
|
||||
|
||||
### Seguridad
|
||||
|
||||
- **Read-only obligatorio:** Solo queries SELECT y PRAGMA. Cualquier INSERT/UPDATE/DELETE/DROP se rechaza antes de ejecutar.
|
||||
- **Bind por defecto a localhost** (`127.0.0.1:8484`). Flag `--bind` para cambiar.
|
||||
- **Sin autenticación** en v1 (solo acceso local). Documentar cómo poner detrás de reverse proxy si se necesita auth.
|
||||
- **Query timeout:** máximo 5 segundos por query para evitar bloqueos.
|
||||
- **Apertura con `?mode=ro`** en el connection string de SQLite para doble protección.
|
||||
|
||||
### Formato de respuesta
|
||||
|
||||
```json
|
||||
// POST /api/databases/registry/query
|
||||
// Body: {"sql": "SELECT id, name, purity FROM functions WHERE domain = 'core' LIMIT 5"}
|
||||
{
|
||||
"columns": ["id", "name", "purity"],
|
||||
"rows": [
|
||||
["filter_slice_go_core", "filter_slice", "pure"],
|
||||
["map_slice_go_core", "map_slice", "pure"]
|
||||
],
|
||||
"count": 2,
|
||||
"duration_ms": 3
|
||||
}
|
||||
```
|
||||
|
||||
## Tareas
|
||||
|
||||
### Fase 1: Servidor base
|
||||
|
||||
- [ ] **1.1** Crear `apps/sqlite_api/` con `main.go`, `go.mod` (o usar módulo raíz)
|
||||
- [ ] **1.2** Handler `/health` y `/api/databases` (lista estática de DBs detectadas)
|
||||
- [ ] **1.3** Handler `POST /api/databases/:db/query` con validación read-only
|
||||
- [ ] **1.4** Abrir DBs con `?mode=ro` y `-tags fts5`
|
||||
- [ ] **1.5** `app.md` con tag `service`, documentar puerto y health check
|
||||
|
||||
### Fase 2: Endpoints de exploración
|
||||
|
||||
- [ ] **2.1** Handler `/api/databases/:db/tables` (lista tablas vía `sqlite_master`)
|
||||
- [ ] **2.2** Handler `/api/databases/:db/schema` (output de `.schema`)
|
||||
- [ ] **2.3** Handler `/api/databases/:db/fts` para búsqueda FTS5 sin escribir SQL
|
||||
|
||||
### Fase 3: Operations discovery
|
||||
|
||||
- [ ] **3.1** Auto-detectar `apps/*/operations.db` al arrancar
|
||||
- [ ] **3.2** Exponer cada operations.db como `ops:{app_name}`
|
||||
- [ ] **3.3** Endpoint `GET /api/databases` incluye las operations detectadas
|
||||
|
||||
### Fase 4: Cleanup y docs
|
||||
|
||||
- [ ] Crear `app.md` completo
|
||||
- [ ] Ejecutar `go vet` y `go test`
|
||||
- [ ] Actualizar issue en `dev/issues/README.md`
|
||||
|
||||
---
|
||||
|
||||
## Ejemplo de uso
|
||||
|
||||
```bash
|
||||
# Arrancar el servicio
|
||||
cd apps/sqlite_api && go run . --port 8484
|
||||
|
||||
# Health check
|
||||
curl http://localhost:8484/health
|
||||
|
||||
# Listar databases disponibles
|
||||
curl http://localhost:8484/api/databases
|
||||
|
||||
# Query al registry
|
||||
curl -X POST http://localhost:8484/api/databases/registry/query \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"sql": "SELECT id, purity, description FROM functions WHERE domain = '\''core'\'' LIMIT 5"}'
|
||||
|
||||
# Búsqueda FTS5
|
||||
curl "http://localhost:8484/api/databases/registry/fts?q=slice&table=functions"
|
||||
|
||||
# Schema
|
||||
curl http://localhost:8484/api/databases/registry/schema
|
||||
|
||||
# Query a operations de una app
|
||||
curl -X POST http://localhost:8484/api/databases/ops:pipeline_launcher/query \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"sql": "SELECT * FROM executions ORDER BY started_at DESC LIMIT 10"}'
|
||||
```
|
||||
|
||||
```python
|
||||
# Desde Python
|
||||
import requests
|
||||
|
||||
r = requests.post("http://localhost:8484/api/databases/registry/query", json={
|
||||
"sql": "SELECT id, name FROM functions WHERE purity = 'pure' AND domain = 'core'"
|
||||
})
|
||||
data = r.json()
|
||||
for row in data["rows"]:
|
||||
print(row[0], row[1])
|
||||
```
|
||||
|
||||
## Decisiones de diseño
|
||||
|
||||
- **Go con net/http estándar**: sin framework externo, coherente con el resto del registry. Router simple con `http.ServeMux`.
|
||||
- **Puerto 8484**: no colisiona con Metabase (3000), Jupyter (8888), ni otros servicios comunes.
|
||||
- **Read-only estricto**: la API nunca modifica datos. Para escribir se usan los mecanismos existentes (`fn ops`, `fn index`).
|
||||
- **Sin ORM**: queries se pasan tal cual a SQLite. El valor es el acceso HTTP, no una capa de abstracción SQL.
|
||||
- **Auto-discovery de operations.db**: escanea `apps/*/operations.db` al inicio para no tener que configurar cada app manualmente.
|
||||
|
||||
## Riesgos
|
||||
|
||||
- **SQL injection vía queries arbitrarias**: Mitigado con apertura read-only (`?mode=ro`) + validación de que el statement empieza con SELECT o PRAGMA.
|
||||
- **Queries pesadas bloquean el servidor**: Mitigado con timeout de 5s por query y context cancelable.
|
||||
- **Archivos SQLite bloqueados por escritores concurrentes**: Mitigado con `journal_mode=wal` y apertura read-only que no bloquea escritores.
|
||||
|
||||
## Criterios de aceptación
|
||||
|
||||
- [ ] `curl localhost:8484/health` retorna 200
|
||||
- [ ] Queries SELECT funcionan contra registry.db
|
||||
- [ ] Queries INSERT/UPDATE/DELETE son rechazadas con 400
|
||||
- [ ] Operations.db de apps existentes son accesibles como `ops:{nombre}`
|
||||
- [ ] FTS5 funciona a través de la API
|
||||
- [ ] Tag `service` en app.md
|
||||
- [ ] El servidor arranca con `go run .` sin configuración adicional
|
||||
@@ -0,0 +1,435 @@
|
||||
# 0010 — Auth System
|
||||
|
||||
## Metadata
|
||||
|
||||
| Campo | Valor |
|
||||
|-------|-------|
|
||||
| **ID** | 0010 |
|
||||
| **Estado** | pendiente |
|
||||
| **Prioridad** | alta |
|
||||
| **Tipo** | feature |
|
||||
|
||||
## Dependencias
|
||||
|
||||
Depende de 0009 (HTTP Server Foundation) para los middleware de auth (`Middleware` type, `http_middleware_chain`, `http_error_response`).
|
||||
|
||||
---
|
||||
|
||||
## Objetivo
|
||||
|
||||
Crear funciones reutilizables de autenticacion y autorizacion en Go (dominio infra) cubriendo JWT, hashing de passwords, sesiones SQLite, cliente OAuth2 y checks RBAC, de forma que cualquier app nueva del registry pueda tener auth completo componiendo primitivas en vez de implementarlo desde cero.
|
||||
|
||||
## Contexto
|
||||
|
||||
- Actualmente el registry tiene **CERO funciones de auth genericas**. Solo existe `auth_form_ts_ui` (componente UI de login) y funciones de auth especificas para Metabase (`metabase_auth_go_infra`, `metabase_auth_py_infra`).
|
||||
- Existen funciones de password store (`pass_get`, `pass_set`, `pass_generate` en Bash) pero son wrappers de `pass`, no primitivas de hashing/verificacion.
|
||||
- `generate_password_bash_cybersecurity` genera passwords pero no los hashea.
|
||||
- Cada vez que una app necesita auth (como `sqlite_api` o `deploy_server`), hay que implementar JWT, sesiones y validacion ad-hoc.
|
||||
- Con estas funciones, una app nueva solo hace: `password_hash` al registrar, `jwt_generate` al login, `jwt_middleware` en las rutas protegidas, y opcionalmente `rbac_middleware` para permisos granulares.
|
||||
- Las funciones HTTP server de 0009 (`Middleware` type, `http_middleware_chain`) son el punto de integracion natural para los middlewares de auth.
|
||||
|
||||
## Arquitectura
|
||||
|
||||
```
|
||||
functions/infra/
|
||||
├── jwt_generate.go — NEW: genera JWT firmado desde claims + secret
|
||||
├── jwt_generate.md — NEW
|
||||
├── jwt_validate.go — NEW: valida JWT, retorna claims o error
|
||||
├── jwt_validate.md — NEW
|
||||
├── jwt_middleware.go — NEW: middleware HTTP que valida JWT del header Authorization
|
||||
├── jwt_middleware.md — NEW
|
||||
├── password_hash.go — NEW: hashea password con bcrypt
|
||||
├── password_hash.md — NEW
|
||||
├── password_verify.go — NEW: verifica password contra hash bcrypt
|
||||
├── password_verify.md — NEW
|
||||
├── session_create.go — NEW: crea sesion en SQLite, retorna token
|
||||
├── session_create.md — NEW
|
||||
├── session_validate.go — NEW: valida token de sesion, retorna datos de usuario
|
||||
├── session_validate.md — NEW
|
||||
├── session_cleanup.go — NEW: elimina sesiones expiradas
|
||||
├── session_cleanup.md — NEW
|
||||
├── oauth2_auth_url.go — NEW: construye URL de autorizacion OAuth2
|
||||
├── oauth2_auth_url.md — NEW
|
||||
├── oauth2_exchange.go — NEW: intercambia auth code por tokens
|
||||
├── oauth2_exchange.md — NEW
|
||||
├── oauth2_refresh.go — NEW: renueva access token con refresh token
|
||||
├── oauth2_refresh.md — NEW
|
||||
├── rbac_check.go — NEW: verifica si un rol tiene un permiso (datos puros)
|
||||
├── rbac_check.md — NEW
|
||||
├── rbac_middleware.go — NEW: middleware HTTP que verifica permisos tras auth
|
||||
├── rbac_middleware.md — NEW
|
||||
|
||||
types/infra/
|
||||
├── jwt_claims.md — NEW: metadata del tipo JWTClaims
|
||||
├── session.md — NEW: metadata del tipo Session
|
||||
├── oauth_config.md — NEW: metadata del tipo OAuthConfig
|
||||
├── oauth_tokens.md — NEW: metadata del tipo OAuthTokens
|
||||
├── permission.md — NEW: metadata del tipo Permission
|
||||
├── role.md — NEW: metadata del tipo Role
|
||||
```
|
||||
|
||||
### Patron pure core / impure shell
|
||||
|
||||
- **Pure:** `oauth2_auth_url` (construye string URL sin I/O), `rbac_check` (evaluacion de datos en memoria)
|
||||
- **Impure:** todo lo demas — JWT usa `time.Now()` para expiracion/validacion, bcrypt hace trabajo criptografico con entropia del OS, sesiones interactuan con SQLite, OAuth2 hace HTTP requests, middlewares interactuan con `http.Request`/`http.ResponseWriter`
|
||||
|
||||
## Diseno
|
||||
|
||||
### Tipos
|
||||
|
||||
```go
|
||||
// JWTClaims contiene claims estandar y custom para un JWT
|
||||
type JWTClaims struct {
|
||||
Subject string `json:"sub"`
|
||||
Issuer string `json:"iss,omitempty"`
|
||||
Audience string `json:"aud,omitempty"`
|
||||
ExpiresAt int64 `json:"exp"`
|
||||
IssuedAt int64 `json:"iat"`
|
||||
Custom map[string]any `json:"custom,omitempty"`
|
||||
}
|
||||
|
||||
// Session representa una sesion de usuario almacenada en SQLite
|
||||
type Session struct {
|
||||
Token string `json:"token"`
|
||||
UserID string `json:"user_id"`
|
||||
ExpiresAt int64 `json:"expires_at"`
|
||||
CreatedAt int64 `json:"created_at"`
|
||||
Metadata map[string]any `json:"metadata,omitempty"`
|
||||
}
|
||||
|
||||
// OAuthConfig contiene la configuracion de un proveedor OAuth2
|
||||
type OAuthConfig struct {
|
||||
ClientID string `json:"client_id"`
|
||||
ClientSecret string `json:"client_secret"`
|
||||
AuthURL string `json:"auth_url"`
|
||||
TokenURL string `json:"token_url"`
|
||||
RedirectURL string `json:"redirect_url"`
|
||||
Scopes []string `json:"scopes"`
|
||||
}
|
||||
|
||||
// OAuthTokens contiene los tokens obtenidos de un flujo OAuth2
|
||||
type OAuthTokens struct {
|
||||
AccessToken string `json:"access_token"`
|
||||
RefreshToken string `json:"refresh_token"`
|
||||
TokenType string `json:"token_type"`
|
||||
ExpiresAt int64 `json:"expires_at"`
|
||||
}
|
||||
|
||||
// Permission representa una accion sobre un recurso
|
||||
type Permission struct {
|
||||
Resource string `json:"resource"`
|
||||
Action string `json:"action"`
|
||||
}
|
||||
|
||||
// Role agrupa permisos bajo un nombre
|
||||
type Role struct {
|
||||
Name string `json:"name"`
|
||||
Permissions []Permission `json:"permissions"`
|
||||
}
|
||||
```
|
||||
|
||||
### Funciones
|
||||
|
||||
| Funcion | Purity | Firma (simplificada) |
|
||||
|---------|--------|---------------------|
|
||||
| `jwt_generate` | impure | `(claims JWTClaims, secret string) (string, error)` |
|
||||
| `jwt_validate` | impure | `(token string, secret string) (JWTClaims, error)` |
|
||||
| `jwt_middleware` | impure | `(secret string) Middleware` |
|
||||
| `password_hash` | impure | `(password string, cost int) (string, error)` |
|
||||
| `password_verify` | impure | `(password string, hash string) error` |
|
||||
| `session_create` | impure | `(db *sql.DB, userID string, ttl time.Duration, metadata map[string]any) (Session, error)` |
|
||||
| `session_validate` | impure | `(db *sql.DB, token string) (Session, error)` |
|
||||
| `session_cleanup` | impure | `(db *sql.DB) (int64, error)` |
|
||||
| `oauth2_auth_url` | pure | `(config OAuthConfig, state string) string` |
|
||||
| `oauth2_exchange` | impure | `(config OAuthConfig, code string) (OAuthTokens, error)` |
|
||||
| `oauth2_refresh` | impure | `(config OAuthConfig, refreshToken string) (OAuthTokens, error)` |
|
||||
| `rbac_check` | pure | `(roles []Role, roleName string, perm Permission) bool` |
|
||||
| `rbac_middleware` | impure | `(roles []Role, requiredPerm Permission) Middleware` |
|
||||
|
||||
## Tareas
|
||||
|
||||
### Fase 1: Tipos
|
||||
|
||||
- [ ] **1.1** Crear tipo `JWTClaims` en `functions/infra/jwt_claims.go` con `.md` en `types/infra/jwt_claims.md`
|
||||
- [ ] **1.2** Crear tipo `Session` en `functions/infra/session.go` con `.md` en `types/infra/session.md`
|
||||
- [ ] **1.3** Crear tipo `OAuthConfig` en `functions/infra/oauth_config.go` con `.md` en `types/infra/oauth_config.md`
|
||||
- [ ] **1.4** Crear tipo `OAuthTokens` en `functions/infra/oauth_tokens.go` con `.md` en `types/infra/oauth_tokens.md`
|
||||
- [ ] **1.5** Crear tipo `Permission` en `functions/infra/permission.go` con `.md` en `types/infra/permission.md`
|
||||
- [ ] **1.6** Crear tipo `Role` en `functions/infra/role.go` con `.md` en `types/infra/role.md`
|
||||
|
||||
### Fase 2: JWT + Password (auth core)
|
||||
|
||||
- [ ] **2.1** `jwt_generate` — codifica header+payload en base64url, firma con HMAC-SHA256, retorna token string. Setea `iat` automaticamente si no viene en claims.
|
||||
- [ ] **2.2** `jwt_validate` — split por `.`, verifica firma HMAC-SHA256, decodifica claims, valida `exp` contra `time.Now()`. Error descriptivo si firma invalida, expirado, o malformado.
|
||||
- [ ] **2.3** `password_hash` — wrapper de `golang.org/x/crypto/bcrypt.GenerateFromPassword`. Cost por defecto: 12.
|
||||
- [ ] **2.4** `password_verify` — wrapper de `bcrypt.CompareHashAndPassword`. Retorna nil si match, error si no.
|
||||
|
||||
### Fase 3: Sesiones SQLite
|
||||
|
||||
- [ ] **3.1** `session_create` — genera token con `crypto/rand` (32 bytes hex), inserta en tabla `sessions` (la funcion crea la tabla si no existe via `CREATE TABLE IF NOT EXISTS`), retorna `Session`.
|
||||
- [ ] **3.2** `session_validate` — busca token en tabla `sessions`, verifica que no este expirado, retorna `Session` o error.
|
||||
- [ ] **3.3** `session_cleanup` — `DELETE FROM sessions WHERE expires_at < ?` con timestamp actual. Retorna cantidad de filas eliminadas.
|
||||
|
||||
### Fase 4: OAuth2
|
||||
|
||||
- [ ] **4.1** `oauth2_auth_url` — construye URL con query params: `client_id`, `redirect_uri`, `response_type=code`, `scope`, `state`. Funcion pura, solo concatena strings.
|
||||
- [ ] **4.2** `oauth2_exchange` — POST a `token_url` con `grant_type=authorization_code`, `code`, `client_id`, `client_secret`, `redirect_uri`. Parsea JSON response a `OAuthTokens`.
|
||||
- [ ] **4.3** `oauth2_refresh` — POST a `token_url` con `grant_type=refresh_token`, `refresh_token`, `client_id`, `client_secret`. Parsea JSON response a `OAuthTokens`.
|
||||
|
||||
### Fase 5: RBAC + Middlewares
|
||||
|
||||
- [ ] **5.1** `rbac_check` — busca el rol por nombre en `[]Role`, itera sus permisos, retorna `true` si encuentra match de `resource` + `action`. Funcion pura.
|
||||
- [ ] **5.2** `jwt_middleware` — extrae token del header `Authorization: Bearer <token>`, valida con `jwt_validate`, inyecta claims en `r.Context()` con `context.WithValue`, llama `next.ServeHTTP`. Retorna 401 si falta token o es invalido. Usa tipo `Middleware` de 0009.
|
||||
- [ ] **5.3** `rbac_middleware` — extrae claims del context (puestas por `jwt_middleware`), lee el rol del campo `custom["role"]`, evalua con `rbac_check`. Retorna 403 si no tiene permiso. Requiere que `jwt_middleware` se ejecute antes en la chain.
|
||||
|
||||
### Fase 6: Tests y cleanup
|
||||
|
||||
- [ ] **6.1** Tests para JWT: generar token, validar token valido, rechazar token expirado, rechazar firma invalida
|
||||
- [ ] **6.2** Tests para password: hash y verify OK, verify con password incorrecto falla
|
||||
- [ ] **6.3** Tests para sesiones: crear, validar, validar expirada, cleanup
|
||||
- [ ] **6.4** Tests para OAuth2: `oauth2_auth_url` genera URL correcta (test puro), exchange/refresh con `httptest.NewServer` mock
|
||||
- [ ] **6.5** Tests para RBAC: check con permiso, sin permiso, rol inexistente
|
||||
- [ ] **6.6** Tests para middlewares: jwt_middleware con token valido/invalido/ausente, rbac_middleware con/sin permiso
|
||||
- [ ] **6.7** `fn index` y verificar que todas las funciones y tipos aparecen en registry.db
|
||||
- [ ] **6.8** Verificar `go vet -tags fts5` y `go test -tags fts5 ./functions/infra/`
|
||||
|
||||
---
|
||||
|
||||
## Ejemplo de uso
|
||||
|
||||
### Registro de usuario y login con JWT
|
||||
|
||||
```go
|
||||
// Handler de registro
|
||||
func registerHandler(w http.ResponseWriter, r *http.Request) {
|
||||
var input struct {
|
||||
Email string `json:"email"`
|
||||
Password string `json:"password"`
|
||||
}
|
||||
if err := infra.HttpParseBody(r, &input, 1<<20); err != nil {
|
||||
infra.HttpErrorResponse(w, infra.HTTPError{Status: 400, Code: "bad_request", Message: err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
hash, err := infra.PasswordHash(input.Password, 12)
|
||||
if err != nil {
|
||||
infra.HttpErrorResponse(w, infra.HTTPError{Status: 500, Code: "hash_error", Message: err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
// Guardar email + hash en la BD de la app...
|
||||
userID := saveUser(input.Email, hash)
|
||||
|
||||
infra.HttpJsonResponse(w, 201, map[string]string{"id": userID})
|
||||
}
|
||||
|
||||
// Handler de login
|
||||
func loginHandler(w http.ResponseWriter, r *http.Request) {
|
||||
var input struct {
|
||||
Email string `json:"email"`
|
||||
Password string `json:"password"`
|
||||
}
|
||||
if err := infra.HttpParseBody(r, &input, 1<<20); err != nil {
|
||||
infra.HttpErrorResponse(w, infra.HTTPError{Status: 400, Code: "bad_request", Message: err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
user, err := findUserByEmail(input.Email)
|
||||
if err != nil {
|
||||
infra.HttpErrorResponse(w, infra.HTTPError{Status: 401, Code: "invalid_credentials", Message: "email o password incorrectos"})
|
||||
return
|
||||
}
|
||||
|
||||
if err := infra.PasswordVerify(input.Password, user.PasswordHash); err != nil {
|
||||
infra.HttpErrorResponse(w, infra.HTTPError{Status: 401, Code: "invalid_credentials", Message: "email o password incorrectos"})
|
||||
return
|
||||
}
|
||||
|
||||
claims := infra.JWTClaims{
|
||||
Subject: user.ID,
|
||||
ExpiresAt: time.Now().Add(24 * time.Hour).Unix(),
|
||||
Custom: map[string]any{"role": "admin", "email": user.Email},
|
||||
}
|
||||
token, err := infra.JwtGenerate(claims, os.Getenv("JWT_SECRET"))
|
||||
if err != nil {
|
||||
infra.HttpErrorResponse(w, infra.HTTPError{Status: 500, Code: "token_error", Message: err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
infra.HttpJsonResponse(w, 200, map[string]string{"token": token})
|
||||
}
|
||||
```
|
||||
|
||||
### Rutas protegidas con JWT + RBAC
|
||||
|
||||
```go
|
||||
// Definir roles y permisos como datos
|
||||
roles := []infra.Role{
|
||||
{
|
||||
Name: "admin",
|
||||
Permissions: []infra.Permission{
|
||||
{Resource: "users", Action: "read"},
|
||||
{Resource: "users", Action: "write"},
|
||||
{Resource: "users", Action: "delete"},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "viewer",
|
||||
Permissions: []infra.Permission{
|
||||
{Resource: "users", Action: "read"},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
// Rutas publicas
|
||||
publicRoutes := []infra.Route{
|
||||
{Method: "POST", Path: "/register", Handler: registerHandler},
|
||||
{Method: "POST", Path: "/login", Handler: loginHandler},
|
||||
}
|
||||
|
||||
// Rutas protegidas con JWT
|
||||
protectedRoutes := []infra.Route{
|
||||
{Method: "GET", Path: "/api/me", Handler: meHandler},
|
||||
}
|
||||
|
||||
// Rutas protegidas con JWT + RBAC
|
||||
adminRoutes := []infra.Route{
|
||||
{Method: "DELETE", Path: "/api/users/{id}", Handler: deleteUserHandler},
|
||||
}
|
||||
|
||||
secret := os.Getenv("JWT_SECRET")
|
||||
|
||||
mux := http.NewServeMux()
|
||||
|
||||
// Publicas: sin middleware de auth
|
||||
for _, r := range publicRoutes {
|
||||
mux.HandleFunc(r.Method+" "+r.Path, r.Handler)
|
||||
}
|
||||
|
||||
// Protegidas: JWT middleware
|
||||
jwtProtected := infra.HttpMiddlewareChain(
|
||||
infra.JwtMiddleware(secret),
|
||||
)
|
||||
for _, r := range protectedRoutes {
|
||||
mux.Handle(r.Method+" "+r.Path, jwtProtected(http.HandlerFunc(r.Handler)))
|
||||
}
|
||||
|
||||
// Admin: JWT + RBAC
|
||||
adminProtected := infra.HttpMiddlewareChain(
|
||||
infra.JwtMiddleware(secret),
|
||||
infra.RbacMiddleware(roles, infra.Permission{Resource: "users", Action: "delete"}),
|
||||
)
|
||||
for _, r := range adminRoutes {
|
||||
mux.Handle(r.Method+" "+r.Path, adminProtected(http.HandlerFunc(r.Handler)))
|
||||
}
|
||||
|
||||
ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt)
|
||||
defer cancel()
|
||||
infra.HttpServe(":8080", mux, ctx)
|
||||
```
|
||||
|
||||
### Flujo OAuth2 (ej. Google)
|
||||
|
||||
```go
|
||||
googleConfig := infra.OAuthConfig{
|
||||
ClientID: os.Getenv("GOOGLE_CLIENT_ID"),
|
||||
ClientSecret: os.Getenv("GOOGLE_CLIENT_SECRET"),
|
||||
AuthURL: "https://accounts.google.com/o/oauth2/v2/auth",
|
||||
TokenURL: "https://oauth2.googleapis.com/token",
|
||||
RedirectURL: "http://localhost:8080/callback",
|
||||
Scopes: []string{"openid", "email", "profile"},
|
||||
}
|
||||
|
||||
// 1. Redirigir al usuario al proveedor
|
||||
func oauthLoginHandler(w http.ResponseWriter, r *http.Request) {
|
||||
state := generateRandomState() // guardar en cookie/session para validar despues
|
||||
url := infra.Oauth2AuthUrl(googleConfig, state)
|
||||
http.Redirect(w, r, url, http.StatusTemporaryRedirect)
|
||||
}
|
||||
|
||||
// 2. Callback: intercambiar code por tokens
|
||||
func oauthCallbackHandler(w http.ResponseWriter, r *http.Request) {
|
||||
code := r.URL.Query().Get("code")
|
||||
state := r.URL.Query().Get("state")
|
||||
|
||||
// Validar state contra el guardado en cookie/session...
|
||||
|
||||
tokens, err := infra.Oauth2Exchange(googleConfig, code)
|
||||
if err != nil {
|
||||
infra.HttpErrorResponse(w, infra.HTTPError{Status: 500, Code: "oauth_error", Message: err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
// Usar tokens.AccessToken para obtener datos del usuario...
|
||||
// Crear sesion local o JWT propio
|
||||
infra.HttpJsonResponse(w, 200, map[string]string{"access_token": tokens.AccessToken})
|
||||
}
|
||||
|
||||
// 3. Renovar token cuando expire
|
||||
func refreshHandler(w http.ResponseWriter, r *http.Request) {
|
||||
refreshToken := extractRefreshToken(r)
|
||||
newTokens, err := infra.Oauth2Refresh(googleConfig, refreshToken)
|
||||
if err != nil {
|
||||
infra.HttpErrorResponse(w, infra.HTTPError{Status: 401, Code: "refresh_failed", Message: err.Error()})
|
||||
return
|
||||
}
|
||||
infra.HttpJsonResponse(w, 200, newTokens)
|
||||
}
|
||||
```
|
||||
|
||||
### Sesiones SQLite (alternativa a JWT para apps con estado)
|
||||
|
||||
```go
|
||||
db, _ := sql.Open("sqlite3", "app.db?_journal_mode=wal")
|
||||
|
||||
// Crear sesion al login
|
||||
func sessionLoginHandler(w http.ResponseWriter, r *http.Request) {
|
||||
// ... validar credenciales ...
|
||||
session, err := infra.SessionCreate(db, user.ID, 24*time.Hour, map[string]any{
|
||||
"email": user.Email,
|
||||
"role": "admin",
|
||||
})
|
||||
if err != nil {
|
||||
infra.HttpErrorResponse(w, infra.HTTPError{Status: 500, Code: "session_error", Message: err.Error()})
|
||||
return
|
||||
}
|
||||
infra.HttpJsonResponse(w, 200, map[string]string{"session_token": session.Token})
|
||||
}
|
||||
|
||||
// Validar sesion en cada request
|
||||
func protectedHandler(w http.ResponseWriter, r *http.Request) {
|
||||
token := r.Header.Get("X-Session-Token")
|
||||
session, err := infra.SessionValidate(db, token)
|
||||
if err != nil {
|
||||
infra.HttpErrorResponse(w, infra.HTTPError{Status: 401, Code: "invalid_session", Message: "sesion invalida o expirada"})
|
||||
return
|
||||
}
|
||||
infra.HttpJsonResponse(w, 200, map[string]any{"user_id": session.UserID, "metadata": session.Metadata})
|
||||
}
|
||||
|
||||
// Limpiar sesiones expiradas periodicamente
|
||||
deleted, _ := infra.SessionCleanup(db)
|
||||
fmt.Printf("sesiones eliminadas: %d\n", deleted)
|
||||
```
|
||||
|
||||
## Decisiones de diseno
|
||||
|
||||
- **bcrypt sobre argon2:** bcrypt esta en `golang.org/x/crypto` (dependencia semi-oficial de Go), es robusto y ampliamente adoptado. Argon2 es mas moderno pero agrega complejidad sin beneficio practico para la mayoria de apps del registry. Cost por defecto 12 (buen balance velocidad/seguridad).
|
||||
- **JWT con HMAC-SHA256 sin libreria externa:** implementacion manual con `crypto/hmac` + `crypto/sha256` + `encoding/base64`. Solo soporta HS256 — suficiente para apps single-server. Si se necesita RS256 (multiples servicios verificando), se crea una funcion separada en el futuro.
|
||||
- **Sesiones en SQLite:** coherente con la filosofia del registry (SQLite en todas partes). La tabla `sessions` se crea en la BD de la app, no en registry.db. `CREATE TABLE IF NOT EXISTS` evita setup manual.
|
||||
- **OAuth2 sin `golang.org/x/oauth2`:** las tres operaciones (auth_url, exchange, refresh) son suficientemente simples para implementar con `net/http` y `encoding/json`. Evita una dependencia mas y mantiene el control total del flujo.
|
||||
- **RBAC como datos puros:** roles y permisos son slices pasados como argumento, no leidos de BD. Cada app decide donde los almacena (hardcoded, JSON, SQLite). `rbac_check` es pura — la unica funcion pura del modulo junto con `oauth2_auth_url`.
|
||||
- **Claims en context con key privada:** `jwt_middleware` inyecta claims en `r.Context()` usando un tipo no exportado como key (`type contextKey struct{}`) para evitar colisiones. `rbac_middleware` las extrae del mismo context.
|
||||
|
||||
## Prerequisitos
|
||||
|
||||
- **0009 (HTTP Server Foundation):** tipo `Middleware`, funciones `http_middleware_chain`, `http_error_response`, `http_json_response`, `http_parse_body`. Los middlewares de auth (`jwt_middleware`, `rbac_middleware`) retornan `Middleware` y usan los helpers de response para errores 401/403.
|
||||
|
||||
## Riesgos
|
||||
|
||||
- **Seguridad de la implementacion JWT manual:** Mitigado manteniendo el scope en HS256, usando `hmac.Equal` para comparacion constant-time, y validando siempre `exp`. Documentar en cada `.md` que NO es apto para escenarios multi-servicio donde se necesita RSA/ECDSA.
|
||||
- **Almacenamiento de secrets:** Las funciones reciben el secret como parametro (`string`), no lo leen de env ni de archivos. Es responsabilidad de la app obtener el secret de forma segura (env var, `pass_get`, etc.). Documentar este patron.
|
||||
- **Token en header vs cookie:** `jwt_middleware` solo lee `Authorization: Bearer`. Para apps que necesiten cookies (frontend SSR), se crearia un middleware separado en el futuro. No mezclar ambos patrones en la misma funcion.
|
||||
- **SQL injection en sesiones:** Mitigado usando prepared statements con `?` placeholders en todas las queries de sesion. Nunca concatenar strings.
|
||||
- **Session fixation:** `session_create` genera tokens con `crypto/rand` (32 bytes = 256 bits de entropia), haciendo inviable la prediccion o fijacion de tokens.
|
||||
- **bcrypt timing attacks:** `bcrypt.CompareHashAndPassword` ya es constant-time internamente. No se necesita proteccion adicional.
|
||||
@@ -0,0 +1,327 @@
|
||||
# 0011 — WebSocket & SSE Server
|
||||
|
||||
## Metadata
|
||||
|
||||
| Campo | Valor |
|
||||
|-------|-------|
|
||||
| **ID** | 0011 |
|
||||
| **Estado** | pendiente |
|
||||
| **Prioridad** | alta |
|
||||
| **Tipo** | feature |
|
||||
|
||||
## Dependencias
|
||||
|
||||
| ID | Titulo | Estado | Requerido |
|
||||
|----|--------|--------|-----------|
|
||||
| 0009 | HTTP Server Foundation | pendiente | si |
|
||||
|
||||
**Bloqueada por:** `#0009` — las funciones WebSocket y SSE son handlers HTTP que se montan sobre las primitivas de servidor (router, middleware chain, graceful shutdown).
|
||||
|
||||
**Desbloquea:** apps de dashboard en tiempo real, notificaciones push, pipelines con feedback live.
|
||||
|
||||
---
|
||||
|
||||
## Objetivo
|
||||
|
||||
Crear funciones reutilizables de WebSocket y Server-Sent Events en Go (dominio infra) que permitan anadir comunicacion bidireccional (WS) y unidireccional server-to-client (SSE) a cualquier app del registry, componiendo con las primitivas HTTP de `#0009`.
|
||||
|
||||
## Contexto
|
||||
|
||||
- Actualmente hay **CERO** funciones de WebSocket/SSE server en el registry. Las unicas conexiones WebSocket existentes son de **cliente**: `cdp_connect_go_browser` (conecta a Chrome DevTools), `stream_ticks_go_finance` (consume un stream de ticks), y las funciones de `jupyter_*_py_notebook` (hablan con kernels Jupyter).
|
||||
- `stream_ticks_go_finance` abre un WebSocket como cliente para recibir datos financieros — es especifico de un dominio, no una primitiva de server.
|
||||
- Patrones comunes que necesitan WS/SSE server: dashboards con datos en vivo, logs en streaming, notificaciones de estado de pipelines, chat entre agentes.
|
||||
- WebSocket es bidireccional (cliente y servidor envian mensajes). SSE es unidireccional server-to-client (mas simple, funciona sobre HTTP normal, reconexion automatica en el browser).
|
||||
- Go stdlib soporta SSE nativamente con `http.Flusher`. Para WebSocket se necesita una dependencia externa: `nhooyr.io/websocket` (moderna, context-aware) o `gorilla/websocket` (clasica, ampliamente usada).
|
||||
- Con estas funciones, una app nueva que necesite real-time solo hace: montar el `ws_handler` o `sse_handler` como una ruta mas del router de `#0009`.
|
||||
|
||||
## Arquitectura
|
||||
|
||||
```
|
||||
functions/infra/
|
||||
├── ws_upgrader.go — NEW: upgrade HTTP connection a WebSocket
|
||||
├── ws_upgrader.md — NEW
|
||||
├── ws_hub.go — NEW: hub de conexiones (register/unregister/broadcast)
|
||||
├── ws_hub.md — NEW
|
||||
├── ws_broadcast.go — NEW: enviar mensaje a todos los clientes conectados
|
||||
├── ws_broadcast.md — NEW
|
||||
├── ws_send.go — NEW: enviar mensaje a un cliente especifico
|
||||
├── ws_send.md — NEW
|
||||
├── ws_handler.go — NEW: HTTP handler que upgradea y gestiona una conexion WS
|
||||
├── ws_handler.md — NEW
|
||||
├── sse_handler.go — NEW: HTTP handler para stream SSE con flush
|
||||
├── sse_handler.md — NEW
|
||||
├── sse_send.go — NEW: enviar un evento SSE (event, data, id)
|
||||
├── sse_send.md — NEW
|
||||
├── sse_keepalive.go — NEW: enviar comentarios keepalive periodicos
|
||||
├── sse_keepalive.md — NEW
|
||||
|
||||
types/infra/
|
||||
├── ws_hub.md — NEW: metadata del tipo WSHub
|
||||
├── ws_client.md — NEW: metadata del tipo WSClient
|
||||
├── ws_message.md — NEW: metadata del tipo WSMessage
|
||||
├── sse_event.md — NEW: metadata del tipo SSEEvent
|
||||
```
|
||||
|
||||
### Patron pure core / impure shell
|
||||
|
||||
Todas las funciones de este issue son **impuras** — manejan conexiones de red, goroutines y estado mutable (el hub mantiene un mapa de clientes). No hay funciones puras en este issue porque la naturaleza del real-time es inherentemente I/O-bound.
|
||||
|
||||
El core puro vive en los tipos (structs sin metodos con side effects) y en la logica de serializado/parseado de mensajes que se delega a funciones existentes del registry (`json_marshal`, etc.).
|
||||
|
||||
## Diseno
|
||||
|
||||
### Tipos
|
||||
|
||||
```go
|
||||
// WSHub gestiona el ciclo de vida de conexiones WebSocket.
|
||||
// Mantiene un mapa de clientes activos y canales para registro,
|
||||
// desregistro y broadcast. Se ejecuta como goroutine via Run().
|
||||
type WSHub struct {
|
||||
Clients map[*WSClient]bool
|
||||
Broadcast chan []byte
|
||||
Register chan *WSClient
|
||||
Unregister chan *WSClient
|
||||
}
|
||||
|
||||
// WSClient representa una conexion WebSocket individual.
|
||||
// Cada cliente tiene su propio canal de envio buffereado
|
||||
// y una referencia al hub al que pertenece.
|
||||
type WSClient struct {
|
||||
Hub *WSHub
|
||||
Conn *websocket.Conn
|
||||
Send chan []byte
|
||||
ID string
|
||||
}
|
||||
|
||||
// WSMessage es un mensaje tipado que viaja por WebSocket.
|
||||
// El campo Type permite al receptor decidir como procesar el payload.
|
||||
type WSMessage struct {
|
||||
Type string `json:"type"`
|
||||
Payload []byte `json:"payload"`
|
||||
SenderID string `json:"sender_id"`
|
||||
Ts int64 `json:"ts"`
|
||||
}
|
||||
|
||||
// SSEEvent es un evento Server-Sent Events segun la spec W3C.
|
||||
// Campos opcionales: si Event esta vacio se envia solo data,
|
||||
// si ID esta vacio no se incluye campo id, Retry en ms (0 = omitir).
|
||||
type SSEEvent struct {
|
||||
Event string `json:"event"`
|
||||
Data string `json:"data"`
|
||||
ID string `json:"id"`
|
||||
Retry int `json:"retry"`
|
||||
}
|
||||
```
|
||||
|
||||
### Funciones
|
||||
|
||||
| Funcion | Purity | Firma (simplificada) | Descripcion |
|
||||
|---------|--------|---------------------|-------------|
|
||||
| `ws_upgrader` | impure | `(w http.ResponseWriter, r *http.Request, origins []string) (*websocket.Conn, error)` | Upgrade HTTP a WebSocket con validacion de origen |
|
||||
| `ws_hub` | impure | `() *WSHub` + `(hub *WSHub) Run()` | Crea hub y lo ejecuta como goroutine (loop select sobre canales) |
|
||||
| `ws_broadcast` | impure | `(hub *WSHub, msg []byte)` | Envia mensaje al canal Broadcast del hub |
|
||||
| `ws_send` | impure | `(client *WSClient, msg []byte) error` | Envia mensaje al canal Send de un cliente especifico |
|
||||
| `ws_handler` | impure | `(hub *WSHub, origins []string) http.HandlerFunc` | Retorna handler que upgradea la conexion, registra el cliente en el hub, y lanza read/write pumps |
|
||||
| `sse_handler` | impure | `(events <-chan SSEEvent) http.HandlerFunc` | Retorna handler que setea headers SSE, consume del canal y flushea cada evento |
|
||||
| `sse_send` | impure | `(w http.ResponseWriter, event SSEEvent) error` | Escribe un evento SSE formateado al writer y hace flush |
|
||||
| `sse_keepalive` | impure | `(w http.ResponseWriter, interval time.Duration, done <-chan struct{})` | Goroutine que envia `: keepalive\n\n` periodicamente hasta que done se cierre |
|
||||
|
||||
### Protocolo WebSocket
|
||||
|
||||
El hub sigue el patron clasico de Go concurrency:
|
||||
|
||||
```
|
||||
┌──────────┐
|
||||
HTTP request ──→ │ws_handler│ ──→ ws_upgrader ──→ *websocket.Conn
|
||||
└────┬─────┘
|
||||
│
|
||||
┌─────▼─────┐
|
||||
│ WSClient │
|
||||
│ .Send ch │
|
||||
└─────┬─────┘
|
||||
│ Register
|
||||
┌─────▼─────┐
|
||||
│ WSHub │ ←── ws_broadcast
|
||||
│ .Run() │
|
||||
│ loop { │
|
||||
│ select │
|
||||
│ } │
|
||||
└───────────┘
|
||||
│ Broadcast
|
||||
┌─────▼─────┐
|
||||
│ client.Send│ ──→ writePump ──→ conn.WriteMessage
|
||||
└───────────┘
|
||||
```
|
||||
|
||||
Cada cliente tiene dos goroutines internas:
|
||||
- **readPump**: lee mensajes del conn y los envia al hub Broadcast (o los procesa con un callback)
|
||||
- **writePump**: consume del canal Send y escribe al conn
|
||||
|
||||
### Protocolo SSE
|
||||
|
||||
```
|
||||
HTTP/1.1 200 OK
|
||||
Content-Type: text/event-stream
|
||||
Cache-Control: no-cache
|
||||
Connection: keep-alive
|
||||
|
||||
: keepalive
|
||||
|
||||
event: price_update
|
||||
id: 42
|
||||
data: {"symbol":"BTC","price":67000}
|
||||
|
||||
data: simple message without event type
|
||||
|
||||
: keepalive
|
||||
```
|
||||
|
||||
`sse_handler` detecta si el `ResponseWriter` implementa `http.Flusher` y hace flush despues de cada evento. Si el cliente se desconecta (context cancelado), el handler retorna limpiamente.
|
||||
|
||||
## Tareas
|
||||
|
||||
### Fase 1: Tipos
|
||||
|
||||
- [ ] **1.1** Crear tipo `WSHub` en `functions/infra/ws_hub.go` con `.md` en `types/infra/ws_hub.md`
|
||||
- [ ] **1.2** Crear tipo `WSClient` en `functions/infra/ws_client.go` con `.md` en `types/infra/ws_client.md`
|
||||
- [ ] **1.3** Crear tipo `WSMessage` en `functions/infra/ws_message.go` con `.md` en `types/infra/ws_message.md`
|
||||
- [ ] **1.4** Crear tipo `SSEEvent` en `functions/infra/sse_event.go` con `.md` en `types/infra/sse_event.md`
|
||||
|
||||
### Fase 2: SSE (mas simple, sin dependencia externa)
|
||||
|
||||
- [ ] **2.1** `sse_send` — formatea y escribe un SSEEvent al writer, hace flush
|
||||
- [ ] **2.2** `sse_keepalive` — goroutine que envia comentarios keepalive periodicamente
|
||||
- [ ] **2.3** `sse_handler` — HTTP handler completo: setea headers, consume canal de eventos, flush, detecta desconexion
|
||||
|
||||
### Fase 3: WebSocket
|
||||
|
||||
- [ ] **3.1** Elegir dependencia: `nhooyr.io/websocket` (preferida por soporte nativo de context) o `gorilla/websocket` (mas madura). Anadir a `go.mod`.
|
||||
- [ ] **3.2** `ws_upgrader` — upgrade HTTP a WebSocket con validacion de origenes permitidos
|
||||
- [ ] **3.3** `ws_hub` — constructor + metodo Run() con loop select sobre Register/Unregister/Broadcast
|
||||
- [ ] **3.4** `ws_send` — envia bytes al canal Send de un cliente
|
||||
- [ ] **3.5** `ws_broadcast` — envia bytes al canal Broadcast del hub
|
||||
- [ ] **3.6** `ws_handler` — handler HTTP que upgradea, crea WSClient, registra en hub, lanza readPump/writePump
|
||||
|
||||
### Fase 4: Tests
|
||||
|
||||
- [ ] **4.1** Tests de SSE con `httptest.NewRecorder` y pipe para simular flush
|
||||
- [ ] **4.2** Tests de WebSocket con `httptest.NewServer` y cliente WS de test
|
||||
- [ ] **4.3** Test de integracion: hub con multiples clientes, broadcast, desconexion
|
||||
- [ ] **4.4** `fn index` y verificar que todas las funciones y tipos aparecen en registry.db
|
||||
- [ ] **4.5** `go vet -tags fts5` limpio
|
||||
|
||||
---
|
||||
|
||||
## Ejemplo de uso
|
||||
|
||||
### Chat-like: broadcast de mensajes entre clientes
|
||||
|
||||
```go
|
||||
// Montar WebSocket en una app con las primitivas de #0009
|
||||
hub := infra.NewWSHub()
|
||||
go hub.Run()
|
||||
|
||||
routes := []infra.Route{
|
||||
{Method: "GET", Path: "/health", Handler: healthHandler},
|
||||
{Method: "GET", Path: "/api/data", Handler: dataHandler},
|
||||
{Method: "GET", Path: "/ws", Handler: infra.WsHandler(hub, []string{"*"})},
|
||||
}
|
||||
|
||||
mux := infra.HttpRouter(routes)
|
||||
middleware := infra.HttpMiddlewareChain(
|
||||
infra.HttpCorsMiddleware([]string{"*"}, []string{"GET", "POST"}),
|
||||
infra.HttpLoggerMiddleware(os.Stdout),
|
||||
)
|
||||
|
||||
ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt)
|
||||
defer cancel()
|
||||
infra.HttpServe(":8080", middleware(mux), ctx)
|
||||
```
|
||||
|
||||
```javascript
|
||||
// Cliente browser
|
||||
const ws = new WebSocket("ws://localhost:8080/ws");
|
||||
ws.onmessage = (e) => {
|
||||
const msg = JSON.parse(e.data);
|
||||
console.log(`[${msg.sender_id}] ${msg.type}: ${new TextDecoder().decode(msg.payload)}`);
|
||||
};
|
||||
ws.send(JSON.stringify({type: "chat", payload: btoa("hola"), sender_id: "user1"}));
|
||||
```
|
||||
|
||||
### Dashboard live updates via SSE
|
||||
|
||||
```go
|
||||
// Canal de eventos que se alimenta desde cualquier goroutine
|
||||
events := make(chan infra.SSEEvent, 100)
|
||||
|
||||
// Goroutine que genera eventos (ej: watch a operations.db)
|
||||
go func() {
|
||||
ticker := time.NewTicker(2 * time.Second)
|
||||
defer ticker.Stop()
|
||||
for i := 0; ; i++ {
|
||||
<-ticker.C
|
||||
events <- infra.SSEEvent{
|
||||
Event: "metrics_update",
|
||||
ID: fmt.Sprintf("%d", i),
|
||||
Data: fmt.Sprintf(`{"cpu": %.1f, "mem": %.1f}`, rand.Float64()*100, rand.Float64()*100),
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
routes := []infra.Route{
|
||||
{Method: "GET", Path: "/health", Handler: healthHandler},
|
||||
{Method: "GET", Path: "/events", Handler: infra.SseHandler(events)},
|
||||
{Method: "GET", Path: "/api/snapshot", Handler: snapshotHandler},
|
||||
}
|
||||
|
||||
mux := infra.HttpRouter(routes)
|
||||
infra.HttpServe(":8080", mux, ctx)
|
||||
```
|
||||
|
||||
```javascript
|
||||
// Cliente browser — reconexion automatica gratis con EventSource
|
||||
const es = new EventSource("http://localhost:8080/events");
|
||||
es.addEventListener("metrics_update", (e) => {
|
||||
const data = JSON.parse(e.data);
|
||||
updateDashboard(data.cpu, data.mem);
|
||||
});
|
||||
es.onerror = () => console.log("reconectando...");
|
||||
```
|
||||
|
||||
### Notificaciones de estado de pipelines
|
||||
|
||||
```go
|
||||
// En una app que ejecuta pipelines del registry:
|
||||
hub := infra.NewWSHub()
|
||||
go hub.Run()
|
||||
|
||||
// Cada vez que un step termina, broadcast a todos los clientes
|
||||
func onStepComplete(step string, status string, durationMs int) {
|
||||
msg, _ := json.Marshal(infra.WSMessage{
|
||||
Type: "step_complete",
|
||||
Payload: []byte(fmt.Sprintf(`{"step":%q,"status":%q,"ms":%d}`, step, status, durationMs)),
|
||||
SenderID: "pipeline_runner",
|
||||
Ts: time.Now().UnixMilli(),
|
||||
})
|
||||
infra.WsBroadcast(hub, msg)
|
||||
}
|
||||
```
|
||||
|
||||
## Decisiones de diseno
|
||||
|
||||
- **`nhooyr.io/websocket` como primera opcion:** mas moderna que gorilla/websocket, soporta `context.Context` nativamente (encaja con graceful shutdown de `#0009`), API mas simple. Si da problemas de compatibilidad, fallback a gorilla.
|
||||
- **Hub como goroutine con canales:** patron estandar de Go para gestionar estado compartido sin mutex. Un solo punto de escritura al mapa de clientes evita races.
|
||||
- **SSE sin dependencias externas:** solo usa `net/http` stdlib + `http.Flusher` interface. Mas simple que WebSocket y suficiente para dashboards y notificaciones unidireccionales.
|
||||
- **Separar send de broadcast:** `ws_send` (un cliente) y `ws_broadcast` (todos) son funciones distintas porque tienen patrones de uso y error handling diferentes.
|
||||
- **Canal buffereado en WSClient.Send:** evita que un cliente lento bloquee el broadcast a los demas. Si el canal se llena, el hub desconecta al cliente.
|
||||
- **SSEEvent.Retry opcional:** el campo retry en SSE le dice al browser cuanto esperar antes de reconectar. Dejarlo en 0 usa el default del browser (~3 segundos).
|
||||
- **Validacion de origenes en ws_upgrader:** proteccion basica contra cross-origin WebSocket hijacking. Para produccion se complementa con auth middleware de `#0009`.
|
||||
- **Todas impuras, sin excepciones:** a diferencia de `#0009` donde hay funciones puras (middleware chain, CORS config), aqui todo toca red o estado mutable. No forzar pureza artificial.
|
||||
|
||||
## Riesgos
|
||||
|
||||
- **Leak de goroutines:** Cada cliente WS genera 2 goroutines (readPump + writePump). Si no se limpian bien al desconectar, se acumulan. Mitigado con el patron hub.Unregister + defer cleanup en cada pump.
|
||||
- **Clientes lentos saturan el hub:** Un cliente que no consume su canal Send puede bloquear el broadcast. Mitigado con canal buffereado y desconexion forzada si el buffer se llena (write deadline).
|
||||
- **Dependencia externa para WebSocket:** `nhooyr.io/websocket` anade un import fuera de stdlib. Mitigado porque es una dependencia mantenida, sin subdependencias transitivas, y solo afecta a las funciones `ws_*` (no contamina el resto del paquete infra).
|
||||
- **Compatibilidad con proxies/load balancers:** WebSocket requiere que proxies soporten upgrade HTTP. SSE funciona sobre HTTP normal sin problema. Documentar en los `.md` que WS detras de nginx/caddy necesita config especifica (`proxy_pass` con upgrade headers).
|
||||
- **Scope creep hacia un framework de real-time:** Mitigado manteniendo funciones atomicas. El hub es un mapa de conexiones, no un pub/sub con rooms, channels, ni autenticacion. Esas abstracciones se componen encima si se necesitan.
|
||||
@@ -0,0 +1,270 @@
|
||||
# 0014 — File Upload & Storage
|
||||
|
||||
## Metadata
|
||||
|
||||
| Campo | Valor |
|
||||
|-------|-------|
|
||||
| **ID** | 0014 |
|
||||
| **Estado** | pendiente |
|
||||
| **Prioridad** | media |
|
||||
| **Tipo** | feature |
|
||||
|
||||
## Dependencias
|
||||
|
||||
- **0009 — HTTP Server Foundation**: las funciones `upload_handler` y `file_serve` son handlers HTTP que dependen de los tipos y patrones definidos en 0009 (Route, Middleware, HTTPError, http_json_response, http_error_response, http_parse_body).
|
||||
|
||||
---
|
||||
|
||||
## Objetivo
|
||||
|
||||
Crear funciones reutilizables en Go (dominio infra) para manejar subida de archivos, almacenamiento en disco, servido de archivos estaticos, y opcionalmente almacenamiento en S3. Cualquier app que necesite gestionar imagenes, documentos o media puede componer estas primitivas en vez de reimplementar el manejo de archivos desde cero.
|
||||
|
||||
## Contexto
|
||||
|
||||
- No existen funciones de upload/storage en el registry. Cada app que necesita manejar archivos tiene que construir el handler de upload, la validacion, el almacenamiento y el servido desde cero.
|
||||
- El patron es comun: apps con imagenes de perfil, documentos adjuntos, exports de datos, thumbnails, etc.
|
||||
- Go stdlib tiene todo lo necesario para multipart parsing (`mime/multipart`), manipulacion de imagenes (`image`, `image/jpeg`, `image/png`), y filesystem. Para S3 se necesita el SDK de AWS, pero como dependencia opcional.
|
||||
- Con estas funciones, una app nueva que necesite uploads solo hace: montar `upload_handler` como ruta, configurar `StorageConfig`, y usar `file_serve` para servir los archivos.
|
||||
|
||||
## Arquitectura
|
||||
|
||||
```
|
||||
functions/infra/
|
||||
upload_handler.go -- NEW: HTTP handler multipart upload
|
||||
upload_handler.md -- NEW
|
||||
upload_parse.go -- NEW: parse multipart form, extraer archivos
|
||||
upload_parse.md -- NEW
|
||||
file_save_disk.go -- NEW: guardar archivo en disco con nombre unico
|
||||
file_save_disk.md -- NEW
|
||||
file_serve.go -- NEW: HTTP handler para servir archivos estaticos
|
||||
file_serve.md -- NEW
|
||||
file_delete.go -- NEW: eliminar archivo del disco
|
||||
file_delete.md -- NEW
|
||||
thumbnail_generate.go -- NEW: generar thumbnail de imagen
|
||||
thumbnail_generate.md -- NEW
|
||||
file_validate_type.go -- NEW: validar MIME type por magic bytes
|
||||
file_validate_type.md -- NEW
|
||||
file_unique_name.go -- NEW: generar nombre unico UUID + extension
|
||||
file_unique_name.md -- NEW
|
||||
s3_upload.go -- NEW: subir archivo a S3-compatible
|
||||
s3_upload.md -- NEW
|
||||
s3_download.go -- NEW: descargar archivo desde S3-compatible
|
||||
s3_download.md -- NEW
|
||||
s3_presign_url.go -- NEW: generar URL presignada S3
|
||||
s3_presign_url.md -- NEW
|
||||
|
||||
types/infra/
|
||||
uploaded_file.md -- NEW: metadata del tipo UploadedFile
|
||||
storage_config.md -- NEW: metadata del tipo StorageConfig
|
||||
s3_config.md -- NEW: metadata del tipo S3Config
|
||||
```
|
||||
|
||||
### Patron pure core / impure shell
|
||||
|
||||
- **Pure:** `file_validate_type` (lee bytes en memoria, sin I/O), `file_unique_name` (genera string, determinista dado un UUID)
|
||||
- **Impure:** todo lo demas — interactuan con disco, red, HTTP requests/responses
|
||||
|
||||
## Diseno
|
||||
|
||||
### Tipos
|
||||
|
||||
```go
|
||||
// UploadedFile contiene la metadata de un archivo subido y almacenado.
|
||||
type UploadedFile struct {
|
||||
Filename string `json:"filename"` // nombre original del archivo
|
||||
StoredName string `json:"stored_name"` // nombre en disco (UUID-based)
|
||||
Size int64 `json:"size"` // tamano en bytes
|
||||
ContentType string `json:"content_type"` // MIME type detectado
|
||||
Path string `json:"path"` // ruta completa en disco
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
// StorageConfig configura el almacenamiento local de archivos.
|
||||
type StorageConfig struct {
|
||||
BaseDir string `json:"base_dir"` // directorio base para almacenar archivos
|
||||
MaxFileSize int64 `json:"max_file_size"` // tamano maximo en bytes (ej: 10<<20 = 10MB)
|
||||
AllowedTypes []string `json:"allowed_types"` // MIME types permitidos (ej: ["image/png", "image/jpeg", "application/pdf"])
|
||||
}
|
||||
|
||||
// S3Config configura la conexion a almacenamiento S3-compatible.
|
||||
type S3Config struct {
|
||||
Endpoint string `json:"endpoint"` // URL del servidor (ej: "s3.amazonaws.com", "minio.local:9000")
|
||||
Bucket string `json:"bucket"`
|
||||
AccessKey string `json:"access_key"`
|
||||
SecretKey string `json:"secret_key"`
|
||||
Region string `json:"region"`
|
||||
UseSSL bool `json:"use_ssl"`
|
||||
}
|
||||
```
|
||||
|
||||
### Funciones
|
||||
|
||||
| Funcion | Purity | Firma (simplificada) |
|
||||
|---------|--------|---------------------|
|
||||
| `upload_handler` | impure | `(cfg StorageConfig) http.HandlerFunc` |
|
||||
| `upload_parse` | impure | `(r *http.Request, maxSize int64) ([]ParsedFile, error)` |
|
||||
| `file_save_disk` | impure | `(baseDir string, filename string, data io.Reader) (UploadedFile, error)` |
|
||||
| `file_serve` | impure | `(dir string, pathPrefix string, maxAge int) http.Handler` |
|
||||
| `file_delete` | impure | `(path string) error` |
|
||||
| `thumbnail_generate` | impure | `(srcPath string, dstPath string, maxWidth int, maxHeight int) error` |
|
||||
| `file_validate_type` | pure | `(header []byte, allowedTypes []string) (string, bool)` |
|
||||
| `file_unique_name` | pure | `(originalName string) string` |
|
||||
| `s3_upload` | impure | `(cfg S3Config, key string, data io.Reader, contentType string) error` |
|
||||
| `s3_download` | impure | `(cfg S3Config, key string, dst io.Writer) error` |
|
||||
| `s3_presign_url` | impure | `(cfg S3Config, key string, expiry time.Duration) (string, error)` |
|
||||
|
||||
### Detalle de cada funcion
|
||||
|
||||
**`upload_handler`** — Handler HTTP completo para multipart upload. Recibe un `StorageConfig`, aplica limites de tamano, valida tipos MIME, guarda en disco con nombre unico, y responde con JSON del `UploadedFile`. Compone internamente `upload_parse`, `file_validate_type`, `file_unique_name`, y `file_save_disk`.
|
||||
|
||||
**`upload_parse`** — Parsea el request multipart, extrae uno o mas archivos con su metadata (nombre original, tamano, content type, contenido). Aplica `http.MaxBytesReader` para limitar tamano. Retorna un slice de structs intermedios con el contenido listo para guardar.
|
||||
|
||||
**`file_save_disk`** — Recibe un `io.Reader` con el contenido y lo escribe a disco en `baseDir` con un nombre unico generado por `file_unique_name`. Crea subdirectorios si no existen. Retorna el `UploadedFile` con la ruta final.
|
||||
|
||||
**`file_serve`** — Retorna un `http.Handler` que sirve archivos estaticos desde un directorio. Setea headers de cache (`Cache-Control`, `ETag`). Usa `http.FileServer` internamente pero stripea el prefijo de path y anade seguridad contra path traversal.
|
||||
|
||||
**`file_delete`** — Elimina un archivo del disco. Valida que el path es relativo al directorio de storage (no permite `../` traversal). Retorna error si el archivo no existe.
|
||||
|
||||
**`thumbnail_generate`** — Lee una imagen del disco, la redimensiona manteniendo aspect ratio al tamano maximo indicado, y la guarda en `dstPath`. Usa `image`, `image/jpeg`, `image/png` de stdlib. Soporta JPEG y PNG.
|
||||
|
||||
**`file_validate_type`** — Lee los primeros N bytes (magic bytes / file signature) de un `[]byte` y determina el MIME type real del archivo. Compara contra la lista de tipos permitidos. No confia en el Content-Type del request — siempre verifica los bytes. Retorna el MIME type detectado y si esta en la lista permitida.
|
||||
|
||||
**`file_unique_name`** — Genera un nombre de archivo unico combinando un UUID v4 con la extension del archivo original. Ejemplo: `a1b2c3d4-e5f6-7890-abcd-ef1234567890.png`. Sanitiza la extension (solo alfanumericos).
|
||||
|
||||
**`s3_upload`** — Sube un archivo a un bucket S3-compatible. Acepta `S3Config` para conectar a AWS S3, MinIO, o cualquier implementacion compatible. Usa el AWS SDK v2.
|
||||
|
||||
**`s3_download`** — Descarga un archivo desde S3 a un `io.Writer`. Permite streaming directo a disco o a un HTTP response.
|
||||
|
||||
**`s3_presign_url`** — Genera una URL presignada para upload o download directo sin pasar por el servidor. Util para uploads grandes donde el cliente sube directamente a S3.
|
||||
|
||||
## Tareas
|
||||
|
||||
### Fase 1: Tipos
|
||||
|
||||
- [ ] **1.1** Crear tipo `UploadedFile` en `functions/infra/uploaded_file.go` con `.md` en `types/infra/`
|
||||
- [ ] **1.2** Crear tipo `StorageConfig` en `functions/infra/storage_config.go` con `.md` en `types/infra/`
|
||||
- [ ] **1.3** Crear tipo `S3Config` en `functions/infra/s3_config.go` con `.md` en `types/infra/`
|
||||
|
||||
### Fase 2: Funciones puras (validacion y naming)
|
||||
|
||||
- [ ] **2.1** `file_validate_type` — detectar MIME type por magic bytes, comparar contra lista permitida. Tabla interna de signatures: JPEG (`FF D8 FF`), PNG (`89 50 4E 47`), GIF (`47 49 46 38`), PDF (`25 50 44 46`), WebP (`52 49 46 46...57 45 42 50`), ZIP (`50 4B 03 04`)
|
||||
- [ ] **2.2** `file_unique_name` — UUID v4 + extension sanitizada. Sin I/O, sin estado
|
||||
- [ ] **2.3** Tests unitarios para ambas funciones puras
|
||||
|
||||
### Fase 3: Almacenamiento en disco
|
||||
|
||||
- [ ] **3.1** `file_save_disk` — escribir a disco con nombre unico, crear subdirectorios, retornar UploadedFile
|
||||
- [ ] **3.2** `file_delete` — eliminar archivo, validar path traversal
|
||||
- [ ] **3.3** `file_serve` — http.Handler con FileServer, cache headers, path traversal protection
|
||||
- [ ] **3.4** `upload_parse` — parsear multipart form, extraer archivos con metadata
|
||||
- [ ] **3.5** `upload_handler` — handler HTTP completo que compone parse + validate + save
|
||||
- [ ] **3.6** `thumbnail_generate` — resize con image stdlib, mantener aspect ratio
|
||||
- [ ] **3.7** Tests para funciones de disco con `os.MkdirTemp`
|
||||
|
||||
### Fase 4: S3-compatible storage
|
||||
|
||||
- [ ] **4.1** `s3_upload` — subir archivo a bucket con AWS SDK v2
|
||||
- [ ] **4.2** `s3_download` — descargar archivo desde bucket
|
||||
- [ ] **4.3** `s3_presign_url` — generar URL presignada con expiracion configurable
|
||||
- [ ] **4.4** Tests con stub/mock de S3 (o `go build` + `go vet` si no hay MinIO local)
|
||||
- [ ] **4.5** `fn index` y verificar que todas las funciones aparecen en registry.db
|
||||
- [ ] **4.6** Verificar `go vet -tags fts5`
|
||||
|
||||
---
|
||||
|
||||
## Ejemplo de uso
|
||||
|
||||
```go
|
||||
// Configurar storage
|
||||
cfg := infra.StorageConfig{
|
||||
BaseDir: "./uploads",
|
||||
MaxFileSize: 10 << 20, // 10 MB
|
||||
AllowedTypes: []string{"image/jpeg", "image/png", "application/pdf"},
|
||||
}
|
||||
|
||||
// Montar rutas (usando funciones de 0009 HTTP Server Foundation)
|
||||
routes := []infra.Route{
|
||||
{Method: "POST", Path: "/api/upload", Handler: infra.UploadHandler(cfg)},
|
||||
}
|
||||
|
||||
mux := infra.HttpRouter(routes)
|
||||
|
||||
// Servir archivos subidos
|
||||
mux.Handle("/files/", infra.FileServe("./uploads", "/files/", 3600))
|
||||
|
||||
// Graceful shutdown
|
||||
ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt)
|
||||
defer cancel()
|
||||
infra.HttpServe(":8080", mux, ctx)
|
||||
```
|
||||
|
||||
```go
|
||||
// Dentro de un handler custom (sin usar upload_handler):
|
||||
func customUpload(w http.ResponseWriter, r *http.Request) {
|
||||
files, err := infra.UploadParse(r, 10<<20)
|
||||
if err != nil {
|
||||
infra.HttpErrorResponse(w, infra.HTTPError{Status: 400, Code: "parse_error", Message: err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
for _, f := range files {
|
||||
// Validar tipo real (no confiar en Content-Type del request)
|
||||
mimeType, ok := infra.FileValidateType(f.Header, cfg.AllowedTypes)
|
||||
if !ok {
|
||||
infra.HttpErrorResponse(w, infra.HTTPError{Status: 415, Code: "invalid_type", Message: "tipo no permitido"})
|
||||
return
|
||||
}
|
||||
|
||||
// Guardar en disco
|
||||
uploaded, err := infra.FileSaveDisk(cfg.BaseDir, f.Filename, f.Content)
|
||||
if err != nil {
|
||||
infra.HttpErrorResponse(w, infra.HTTPError{Status: 500, Code: "save_error", Message: err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
// Generar thumbnail si es imagen
|
||||
if mimeType == "image/jpeg" || mimeType == "image/png" {
|
||||
thumbPath := filepath.Join(cfg.BaseDir, "thumbs", uploaded.StoredName)
|
||||
infra.ThumbnailGenerate(uploaded.Path, thumbPath, 200, 200)
|
||||
}
|
||||
}
|
||||
|
||||
infra.HttpJsonResponse(w, 200, map[string]string{"status": "ok"})
|
||||
}
|
||||
```
|
||||
|
||||
```go
|
||||
// S3 upload (opcional, para apps que necesiten storage remoto)
|
||||
s3cfg := infra.S3Config{
|
||||
Endpoint: "minio.local:9000",
|
||||
Bucket: "uploads",
|
||||
AccessKey: os.Getenv("S3_ACCESS_KEY"),
|
||||
SecretKey: os.Getenv("S3_SECRET_KEY"),
|
||||
Region: "us-east-1",
|
||||
UseSSL: false,
|
||||
}
|
||||
|
||||
f, _ := os.Open("./uploads/a1b2c3d4.png")
|
||||
defer f.Close()
|
||||
err := infra.S3Upload(s3cfg, "images/a1b2c3d4.png", f, "image/png")
|
||||
|
||||
// Generar URL presignada para download directo (1 hora)
|
||||
url, err := infra.S3PresignUrl(s3cfg, "images/a1b2c3d4.png", time.Hour)
|
||||
```
|
||||
|
||||
## Decisiones de diseno
|
||||
|
||||
- **Disco local primero, S3 opcional:** la mayoria de apps del registry corren en un solo servidor. El almacenamiento en disco es suficiente y no requiere infraestructura adicional. S3 es para apps que escalan o necesitan storage distribuido.
|
||||
- **Validacion por magic bytes, no por Content-Type:** el header Content-Type del request puede ser falso. Los primeros bytes del archivo son la fuente de verdad para determinar el tipo real.
|
||||
- **UUID para nombres en disco:** evita colisiones de nombres y elimina problemas con caracteres especiales en nombres de archivo originales. Se preserva la extension para que el MIME type sea inferible por el filesystem.
|
||||
- **Solo stdlib para imagenes:** `image/jpeg` y `image/png` de Go stdlib son suficientes para thumbnails basicos. Si se necesita soporte de mas formatos (WebP, AVIF), se puede extender despues sin romper la interfaz.
|
||||
- **Sin base de datos de metadata:** las funciones manejan archivos en disco/S3 pero no mantienen un indice de archivos subidos. Cada app decide como trackear sus uploads (puede usar operations.db, una tabla SQL, o simplemente el filesystem).
|
||||
- **S3Config como struct separado:** permite que apps que no usan S3 no tengan que importar el AWS SDK. Las funciones S3 son independientes del resto.
|
||||
|
||||
## Riesgos
|
||||
|
||||
- **Path traversal:** Un atacante podria intentar subir archivos con nombres como `../../etc/passwd` o acceder a archivos fuera del directorio de storage. Mitigado: `file_save_disk` ignora el nombre original y usa UUID, `file_serve` valida que el path resuelto esta dentro del directorio base, `file_delete` rechaza paths con `..`.
|
||||
- **File size DoS:** Un cliente podria enviar archivos enormes para agotar disco o memoria. Mitigado: `upload_parse` usa `http.MaxBytesReader` para cortar la lectura al limite configurado, `upload_handler` rechaza antes de leer si `Content-Length` excede el maximo.
|
||||
- **MIME type bypass:** Un archivo puede tener magic bytes validos pero contenido malicioso despues. `file_validate_type` solo verifica los primeros bytes — no es un antivirus. Documentar que para apps con requisitos de seguridad altos se necesita escaneo adicional.
|
||||
- **Dependencia AWS SDK para S3:** Anade un arbol de dependencias significativo. Mitigado: las funciones S3 son opcionales y estan en archivos separados. Si una app no importa S3, no paga el costo.
|
||||
- **Thumbnails con stdlib limitados:** `image/jpeg` y `image/png` no soportan formatos modernos (WebP, AVIF, HEIC). Para apps que necesiten mas formatos, habra que evaluar dependencias externas o delegar a herramientas de sistema (`imagemagick`).
|
||||
@@ -0,0 +1,174 @@
|
||||
# 0016 — Rate Limiting
|
||||
|
||||
## Metadata
|
||||
|
||||
| Campo | Valor |
|
||||
|-------|-------|
|
||||
| **ID** | 0016 |
|
||||
| **Estado** | pendiente |
|
||||
| **Prioridad** | media |
|
||||
| **Tipo** | feature |
|
||||
|
||||
## Dependencias
|
||||
|
||||
- **0009** (HTTP Server Foundation) — el middleware de rate limiting se integra via `http_middleware_chain`.
|
||||
|
||||
---
|
||||
|
||||
## Objetivo
|
||||
|
||||
Proteger cualquier API del registry contra abuso con rate limiting in-memory basado en token bucket, sin dependencias externas (no Redis). Funciones componibles que se enchufan al stack de middlewares de 0009.
|
||||
|
||||
## Contexto
|
||||
|
||||
- `sqlite_api` y futuras apps HTTP no tienen ninguna proteccion contra abuso. Un cliente puede hacer miles de requests por segundo sin limite.
|
||||
- Con las funciones de HTTP server de 0009, integrar rate limiting es cuestion de componer un middleware mas en la chain.
|
||||
- Token bucket es el algoritmo estandar para rate limiting HTTP: permite rafagas controladas (`burst`) mientras mantiene una tasa sostenida (`rate`).
|
||||
- Go stdlib incluye `golang.org/x/time/rate` pero crear funciones propias permite control total sobre cleanup, headers y key extraction.
|
||||
|
||||
## Arquitectura
|
||||
|
||||
```
|
||||
functions/infra/
|
||||
rate_limiter_new.go — NEW: crea rate limiter in-memory
|
||||
rate_limiter_new.md — NEW
|
||||
rate_limiter_check.go — NEW: consulta si un request esta permitido
|
||||
rate_limiter_check.md — NEW
|
||||
rate_limit_middleware.go — NEW: middleware HTTP por IP
|
||||
rate_limit_middleware.md — NEW
|
||||
rate_limiter_by_key.go — NEW: rate limit por clave custom
|
||||
rate_limiter_by_key.md — NEW
|
||||
rate_limiter_cleanup.go — NEW: GC de entries stale
|
||||
rate_limiter_cleanup.md — NEW
|
||||
rate_limit_headers.go — NEW: construye headers estandar
|
||||
rate_limit_headers.md — NEW
|
||||
|
||||
types/infra/
|
||||
rate_limiter.md — NEW
|
||||
rate_limit_config.md — NEW
|
||||
rate_limit_result.md — NEW
|
||||
```
|
||||
|
||||
## Diseno
|
||||
|
||||
### Tipos
|
||||
|
||||
```go
|
||||
// RateLimiter mantiene estado de todos los clientes
|
||||
type RateLimiter struct {
|
||||
rate float64 // tokens por segundo
|
||||
burst int // capacidad maxima del bucket
|
||||
mu sync.Mutex
|
||||
clients map[string]*clientEntry // key -> bucket state
|
||||
}
|
||||
|
||||
type clientEntry struct {
|
||||
tokens float64
|
||||
lastSeen time.Time
|
||||
}
|
||||
|
||||
// RateLimitConfig configura el middleware
|
||||
type RateLimitConfig struct {
|
||||
RequestsPerSecond float64 // tasa sostenida
|
||||
BurstSize int // rafaga maxima
|
||||
KeyFunc func(r *http.Request) string // extractor de clave (nil = IP)
|
||||
CleanupInterval time.Duration // frecuencia de GC
|
||||
}
|
||||
|
||||
// RateLimitResult es el resultado de un check
|
||||
type RateLimitResult struct {
|
||||
Allowed bool // request permitido
|
||||
Remaining int // tokens restantes
|
||||
ResetAt time.Time // cuando se rellena el bucket
|
||||
RetryAfter float64 // segundos hasta que se pueda reintentar (0 si allowed)
|
||||
}
|
||||
```
|
||||
|
||||
### Funciones
|
||||
|
||||
| Funcion | Purity | Firma (simplificada) |
|
||||
|---------|--------|---------------------|
|
||||
| `rate_limiter_new` | impure | `(rate float64, burst int) *RateLimiter` |
|
||||
| `rate_limiter_check` | impure | `(rl *RateLimiter, key string) RateLimitResult` |
|
||||
| `rate_limit_middleware` | impure | `(rl *RateLimiter) Middleware` |
|
||||
| `rate_limiter_by_key` | impure | `(rl *RateLimiter, keyFunc func(*http.Request) string) Middleware` |
|
||||
| `rate_limiter_cleanup` | impure | `(rl *RateLimiter, maxAge time.Duration, interval time.Duration) func()` |
|
||||
| `rate_limit_headers` | pure | `(result RateLimitResult, limit int) http.Header` |
|
||||
|
||||
### Token bucket
|
||||
|
||||
Cada key tiene un bucket con `burst` tokens. Se recargan a `rate` tokens/segundo. Un request consume 1 token. Si no quedan tokens, se rechaza con 429.
|
||||
|
||||
---
|
||||
|
||||
## Tareas
|
||||
|
||||
### Fase 1: Core + tipos
|
||||
|
||||
- [ ] **1.1** Crear tipos `RateLimiter`, `RateLimitConfig`, `RateLimitResult` en `functions/infra/` con `.md` en `types/infra/`
|
||||
- [ ] **1.2** `rate_limiter_new` — inicializa `RateLimiter` con rate y burst
|
||||
- [ ] **1.3** `rate_limiter_check` — evalua token bucket para una key, retorna `RateLimitResult`
|
||||
- [ ] **1.4** `rate_limit_headers` — construye `X-RateLimit-Limit`, `X-RateLimit-Remaining`, `X-RateLimit-Reset`, `Retry-After` a partir de `RateLimitResult`
|
||||
- [ ] **1.5** `rate_limiter_cleanup` — goroutine que borra entries sin actividad reciente, retorna `func()` para cancelar
|
||||
|
||||
### Fase 2: Middlewares + tests
|
||||
|
||||
- [ ] **2.1** `rate_limit_middleware` — middleware que limita por IP del cliente (extrae de `RemoteAddr` / `X-Forwarded-For`)
|
||||
- [ ] **2.2** `rate_limiter_by_key` — middleware configurable con `keyFunc` para limitar por API key, user ID, etc.
|
||||
- [ ] **2.3** Tests de cada funcion con `httptest.NewRecorder`
|
||||
- [ ] **2.4** `fn index` y verificar con `fn show`
|
||||
|
||||
---
|
||||
|
||||
## Ejemplo de uso
|
||||
|
||||
```go
|
||||
// Crear limiter: 10 req/s con burst de 20
|
||||
rl := infra.RateLimiterNew(10, 20)
|
||||
|
||||
// Arrancar cleanup cada 5 minutos, borra entries sin actividad en 10 min
|
||||
stopCleanup := infra.RateLimiterCleanup(rl, 10*time.Minute, 5*time.Minute)
|
||||
defer stopCleanup()
|
||||
|
||||
// Componer con otros middlewares de 0009
|
||||
middleware := infra.HttpMiddlewareChain(
|
||||
infra.HttpCorsMiddleware([]string{"*"}, []string{"GET", "POST"}),
|
||||
infra.RateLimitMiddleware(rl), // por IP
|
||||
infra.HttpLoggerMiddleware(os.Stdout),
|
||||
)
|
||||
|
||||
mux := infra.HttpRouter(routes)
|
||||
infra.HttpServe(":8484", middleware(mux), ctx)
|
||||
```
|
||||
|
||||
```go
|
||||
// Rate limit por API key en vez de IP
|
||||
keyMiddleware := infra.RateLimiterByKey(rl, func(r *http.Request) string {
|
||||
return r.Header.Get("X-API-Key")
|
||||
})
|
||||
```
|
||||
|
||||
```go
|
||||
// Respuesta 429 automatica del middleware:
|
||||
// HTTP/1.1 429 Too Many Requests
|
||||
// X-RateLimit-Limit: 10
|
||||
// X-RateLimit-Remaining: 0
|
||||
// X-RateLimit-Reset: 1713045600
|
||||
// Retry-After: 1
|
||||
// Content-Type: application/json
|
||||
//
|
||||
// {"status":429,"code":"rate_limited","message":"too many requests"}
|
||||
```
|
||||
|
||||
## Decisiones de diseno
|
||||
|
||||
- **In-memory, no Redis:** para el scope del registry (single-process, pocas apps) un `sync.Mutex` + `map` es suficiente y evita una dependencia de infraestructura.
|
||||
- **Token bucket sobre sliding window:** permite rafagas legitimas (burst) sin penalizar al cliente por picos puntuales, y es trivial de implementar.
|
||||
- **Headers IETF draft:** sigue `draft-ietf-httpapi-ratelimit-headers` (`X-RateLimit-Limit`, `X-RateLimit-Remaining`, `X-RateLimit-Reset`). Los clientes pueden adaptar su ritmo sin adivinar.
|
||||
- **`rate_limit_headers` como funcion pura:** construir headers no requiere I/O, solo formateo. El middleware la usa internamente pero queda disponible para otros usos.
|
||||
- **Cleanup explicito:** el GC goroutine se arranca con parametros configurables y se para con la funcion retornada, sin goroutine leaks.
|
||||
|
||||
## Riesgos
|
||||
|
||||
- **Memoria con muchas IPs unicas:** Mitigado con `rate_limiter_cleanup` que purga entries inactivas periodicamente. Para APIs con millones de IPs distintas habria que migrar a Redis, pero ese no es el caso del registry.
|
||||
- **IP detras de proxy:** `X-Forwarded-For` puede ser spoofed. Para uso interno es aceptable; para exposicion publica real habria que validar el header contra trusted proxies.
|
||||
@@ -0,0 +1,191 @@
|
||||
# 0019 — Structured Logging Go
|
||||
|
||||
## Metadata
|
||||
|
||||
| Campo | Valor |
|
||||
|-------|-------|
|
||||
| **ID** | 0019 |
|
||||
| **Estado** | pendiente |
|
||||
| **Prioridad** | media |
|
||||
| **Tipo** | feature |
|
||||
|
||||
## Dependencias
|
||||
|
||||
- `logger_middleware` depende de issue 0009 (HTTP Server Foundation) para el tipo `Middleware`.
|
||||
- El resto de funciones no tiene dependencias externas.
|
||||
|
||||
---
|
||||
|
||||
## Objetivo
|
||||
|
||||
Funciones de structured logging en Go (dominio infra) basadas en `log/slog` de stdlib. Logs en JSON con niveles, campos contextuales y middleware HTTP, reemplazando el uso ad-hoc de `fmt.Println` y `log.Printf` en las apps.
|
||||
|
||||
## Contexto
|
||||
|
||||
- Python ya tiene `get_logger_py_infra` y `setup_logger_py_infra` con rotacion, dual output y niveles.
|
||||
- Bash tiene `bash_log_bash_shell` con niveles y colores.
|
||||
- Go tiene **cero** funciones de logging estructurado. Las apps (`deploy_server`, `sqlite_api`, `pipeline_launcher`) loguean con `fmt.Println` o `log.Printf` sin estructura, sin niveles, sin contexto.
|
||||
- Go 1.21+ incluye `log/slog` en stdlib: JSON handler, niveles, campos key-value, groups. No se necesita zerolog ni zap.
|
||||
|
||||
## Arquitectura
|
||||
|
||||
```
|
||||
functions/infra/
|
||||
├── logger_new.go — NEW: crea logger con nivel, output y formato
|
||||
├── logger_new.md — NEW
|
||||
├── logger_with.go — NEW: retorna copia del logger con campos adicionales
|
||||
├── logger_with.md — NEW
|
||||
├── logger_middleware.go — NEW: middleware HTTP que loguea requests
|
||||
├── logger_middleware.md — NEW
|
||||
├── log_debug.go — NEW: log a nivel debug
|
||||
├── log_debug.md — NEW
|
||||
├── log_info.go — NEW: log a nivel info
|
||||
├── log_info.md — NEW
|
||||
├── log_warn.go — NEW: log a nivel warn
|
||||
├── log_warn.md — NEW
|
||||
├── log_error.go — NEW: log a nivel error
|
||||
├── log_error.md — NEW
|
||||
|
||||
types/infra/
|
||||
├── logger.md — NEW: metadata del tipo Logger
|
||||
├── log_level.md — NEW: metadata del tipo LogLevel
|
||||
├── log_entry.md — NEW: metadata del tipo LogEntry
|
||||
```
|
||||
|
||||
### Patron pure core / impure shell
|
||||
|
||||
- **Pure:** `logger_with` (copia inmutable del logger con campos adicionales, sin I/O)
|
||||
- **Impure:** `logger_new`, `log_debug`, `log_info`, `log_warn`, `log_error`, `logger_middleware` (escriben a un `io.Writer`)
|
||||
|
||||
## Diseno
|
||||
|
||||
### Tipos
|
||||
|
||||
```go
|
||||
// LogLevel representa los niveles de log soportados.
|
||||
type LogLevel int
|
||||
|
||||
const (
|
||||
LogLevelDebug LogLevel = iota
|
||||
LogLevelInfo
|
||||
LogLevelWarn
|
||||
LogLevelError
|
||||
)
|
||||
|
||||
// Logger wrappea slog.Logger con config del registry.
|
||||
type Logger struct {
|
||||
Level LogLevel
|
||||
Output io.Writer
|
||||
Format string // "json" | "text"
|
||||
Fields map[string]any
|
||||
inner *slog.Logger
|
||||
}
|
||||
|
||||
// LogEntry representa una entrada de log estructurada.
|
||||
type LogEntry struct {
|
||||
Timestamp time.Time `json:"timestamp"`
|
||||
Level string `json:"level"`
|
||||
Message string `json:"message"`
|
||||
Fields map[string]any `json:"fields,omitempty"`
|
||||
}
|
||||
```
|
||||
|
||||
### Funciones
|
||||
|
||||
| Funcion | Purity | Firma (simplificada) |
|
||||
|---------|--------|---------------------|
|
||||
| `logger_new` | impure | `(level LogLevel, output io.Writer, format string) (*Logger, error)` |
|
||||
| `logger_with` | pure | `(logger *Logger, fields map[string]any) *Logger` |
|
||||
| `log_debug` | impure | `(logger *Logger, msg string, fields ...any)` |
|
||||
| `log_info` | impure | `(logger *Logger, msg string, fields ...any)` |
|
||||
| `log_warn` | impure | `(logger *Logger, msg string, fields ...any)` |
|
||||
| `log_error` | impure | `(logger *Logger, msg string, fields ...any)` |
|
||||
| `logger_middleware` | impure | `(logger *Logger) Middleware` |
|
||||
|
||||
## Tareas
|
||||
|
||||
### Fase 1: Tipos y funciones core
|
||||
|
||||
- [ ] **1.1** Crear tipos `Logger`, `LogLevel`, `LogEntry` en `functions/infra/` con `.md` en `types/infra/`
|
||||
- [ ] **1.2** `logger_new` — crea `*Logger` con `slog.NewJSONHandler` o `slog.NewTextHandler` segun `format`
|
||||
- [ ] **1.3** `logger_with` — clona el logger y anade campos al `slog.Logger` interno via `slog.With()`
|
||||
- [ ] **1.4** `log_debug`, `log_info`, `log_warn`, `log_error` — delegan al `slog.Logger` interno con el nivel correspondiente
|
||||
- [ ] **1.5** Tests unitarios: verificar output JSON, niveles filtrados, campos inyectados
|
||||
|
||||
### Fase 2: Middleware HTTP (requiere 0009)
|
||||
|
||||
- [ ] **2.1** `logger_middleware` — wrappea `http.Handler`, loguea method, path, status, duration_ms al completar cada request
|
||||
- [ ] **2.2** Tests con `httptest.NewRecorder`
|
||||
- [ ] **2.3** `fn index` y verificar todas las funciones en registry.db
|
||||
|
||||
---
|
||||
|
||||
## Ejemplo de uso
|
||||
|
||||
```go
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/signal"
|
||||
|
||||
"github.com/fn_registry/functions/infra"
|
||||
)
|
||||
|
||||
func main() {
|
||||
// Crear logger JSON a stdout, nivel info
|
||||
logger, _ := infra.LoggerNew(infra.LogLevelInfo, os.Stdout, "json")
|
||||
|
||||
// Logger con contexto de app
|
||||
appLog := infra.LoggerWith(logger, map[string]any{
|
||||
"app": "sqlite_api",
|
||||
"version": "1.0.0",
|
||||
})
|
||||
|
||||
infra.LogInfo(appLog, "server starting", "port", 8484)
|
||||
// {"time":"2026-04-13T...","level":"INFO","msg":"server starting","app":"sqlite_api","version":"1.0.0","port":8484}
|
||||
|
||||
// Logger por request con campos adicionales
|
||||
reqLog := infra.LoggerWith(appLog, map[string]any{"request_id": "abc-123"})
|
||||
infra.LogDebug(reqLog, "parsing body") // filtrado: nivel < info
|
||||
infra.LogError(reqLog, "db query failed", "err", "connection refused", "table", "functions")
|
||||
|
||||
// Middleware HTTP (compone con las funciones de 0009)
|
||||
routes := []infra.Route{
|
||||
{Method: "GET", Path: "/health", Handler: healthHandler},
|
||||
}
|
||||
mux := infra.HttpRouter(routes)
|
||||
|
||||
middleware := infra.HttpMiddlewareChain(
|
||||
infra.LoggerMiddleware(appLog),
|
||||
infra.HttpCorsMiddleware([]string{"*"}, []string{"GET"}),
|
||||
)
|
||||
|
||||
ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt)
|
||||
defer cancel()
|
||||
|
||||
infra.HttpServe(":8484", middleware(mux), ctx)
|
||||
// Cada request produce:
|
||||
// {"time":"...","level":"INFO","msg":"http request","app":"sqlite_api","method":"GET","path":"/health","status":200,"duration_ms":1}
|
||||
}
|
||||
|
||||
func healthHandler(w http.ResponseWriter, r *http.Request) {
|
||||
infra.HttpJsonResponse(w, 200, map[string]string{"status": "ok"})
|
||||
}
|
||||
```
|
||||
|
||||
## Decisiones de diseno
|
||||
|
||||
- **`log/slog` de stdlib (Go 1.21+):** Zero dependencies. `slog` ya resuelve JSON structured logging, niveles, campos key-value y handlers extensibles. No se justifica zerolog ni zap para el scope de este registry.
|
||||
- **Logger como struct, no global:** Cada app/componente crea su logger con su config. Sin `slog.SetDefault()` ni variables de paquete. Inyeccion explicita.
|
||||
- **`logger_with` puro:** `slog.Logger.With()` retorna un nuevo logger sin mutar el original. Esto permite crear loggers contextuales (por request, por componente) sin side effects.
|
||||
- **Funciones de nivel separadas (`log_info`, `log_error`...):** En vez de un unico `Log(level, msg)`, funciones dedicadas por nivel. Mas legibles en el call site y mas buscables en el registry.
|
||||
- **Formato configurable (JSON/text):** JSON para produccion y pipelines de logs, text para desarrollo local. Un solo parametro en `logger_new`.
|
||||
|
||||
## Riesgos
|
||||
|
||||
- **Adopcion gradual:** Las apps existentes usan `fmt.Println`/`log.Printf`. Mitigado porque las funciones nuevas no rompen nada — las apps migran a su ritmo.
|
||||
- **Middleware depende de 0009:** `logger_middleware` usa el tipo `Middleware` de 0009. Si 0009 no esta implementado, la fase 2 se pospone. La fase 1 es independiente.
|
||||
- **Proliferacion de funciones de log:** 4 funciones de nivel + `logger_new` + `logger_with` = 6 funciones. Aceptable: cada una es trivial y atomica, preferible a una sola funcion con parametro de nivel.
|
||||
@@ -0,0 +1,309 @@
|
||||
# 0021 — CRUD Generator
|
||||
|
||||
## Metadata
|
||||
|
||||
| Campo | Valor |
|
||||
|-------|-------|
|
||||
| **ID** | 0021 |
|
||||
| **Estado** | pendiente |
|
||||
| **Prioridad** | media |
|
||||
| **Tipo** | feature |
|
||||
|
||||
## Dependencias
|
||||
|
||||
- **0009** (HTTP Server Foundation) — los handlers generados usan `http_json_response`, `http_error_response`, `http_parse_body` y se registran via `http_router`.
|
||||
- **0015** (DB Migrations) — la tabla generada por `crud_generate_table_sql` se ejecuta como migracion.
|
||||
|
||||
---
|
||||
|
||||
## Objetivo
|
||||
|
||||
Generar handlers REST completos (list, get, create, update, delete) a partir de una definicion declarativa de recurso, sin escribir codigo repetitivo. El 80% de los endpoints de cualquier API son CRUD identico — solo cambian el nombre de la tabla y los campos.
|
||||
|
||||
## Contexto
|
||||
|
||||
- Ya existen `wails_bind_crud_go_infra` (genera bindings CRUD para Wails como string de codigo Go) y `crud_page_ts_ui` (layout CRUD en frontend). Pero no hay nada que genere **endpoints HTTP** CRUD funcionales.
|
||||
- Las apps del registry (`sqlite_api`, `deploy_server`, futuras) construyen sus handlers CRUD a mano cada vez: misma estructura de list con paginacion, mismo get por ID, mismo create con validacion, mismo update parcial, mismo delete.
|
||||
- Este issue NO genera archivos ni templates — genera **handler factories en runtime**. Le pasas una definicion de recurso y te devuelve `http.HandlerFunc` listas para usar.
|
||||
- Especifico para SQLite (usa `database/sql` con mattn/go-sqlite3). No abstrae multiples motores de BD.
|
||||
|
||||
## Arquitectura
|
||||
|
||||
```
|
||||
functions/infra/
|
||||
crud_define_resource.go — NEW: construye CRUDResource a partir de nombre y campos
|
||||
crud_define_resource.md — NEW
|
||||
crud_generate_table_sql.go — NEW: genera CREATE TABLE a partir de CRUDResource
|
||||
crud_generate_table_sql.md — NEW
|
||||
crud_generate_handlers.go — NEW: genera los 5 handlers a partir de CRUDResource + *sql.DB
|
||||
crud_generate_handlers.md — NEW
|
||||
crud_register_routes.go — NEW: registra rutas CRUD en router
|
||||
crud_register_routes.md — NEW
|
||||
crud_list_handler.go — NEW: handler generico list con paginacion/filtro/sort
|
||||
crud_list_handler.md — NEW
|
||||
crud_get_handler.go — NEW: handler get por ID
|
||||
crud_get_handler.md — NEW
|
||||
crud_create_handler.go — NEW: handler create con validacion
|
||||
crud_create_handler.md — NEW
|
||||
crud_update_handler.go — NEW: handler update parcial por ID
|
||||
crud_update_handler.md — NEW
|
||||
crud_delete_handler.go — NEW: handler delete por ID
|
||||
crud_delete_handler.md — NEW
|
||||
|
||||
types/infra/
|
||||
crud_resource.md — NEW: metadata del tipo CRUDResource
|
||||
crud_field.md — NEW: metadata del tipo CRUDField
|
||||
crud_list_params.md — NEW: metadata del tipo CRUDListParams
|
||||
crud_list_result.md — NEW: metadata del tipo CRUDListResult
|
||||
```
|
||||
|
||||
### Patron pure core / impure shell
|
||||
|
||||
- **Pure:** `crud_define_resource` (construye struct), `crud_generate_table_sql` (string SQL), `crud_generate_handlers` (retorna handlers sin ejecutar I/O, pero los handlers en si hacen I/O al invocarse — la funcion factory es pure, el handler resultante es impure)
|
||||
- **Impure:** `crud_register_routes` (muta router), `crud_list_handler`, `crud_get_handler`, `crud_create_handler`, `crud_update_handler`, `crud_delete_handler` (todos hacen I/O contra SQLite y HTTP)
|
||||
|
||||
**Nota sobre `crud_generate_handlers`:** La funcion que genera los handlers es pura (recibe definicion + db, retorna funciones). Pero los handlers retornados son closures impuros que leen/escriben BD y HTTP. Se clasifica como **pure** porque la funcion en si no hace I/O — solo construye closures.
|
||||
|
||||
## Diseno
|
||||
|
||||
### Tipos
|
||||
|
||||
```go
|
||||
// CRUDResource define un recurso CRUD completo
|
||||
type CRUDResource struct {
|
||||
Name string // nombre del recurso (singular, snake_case: "project")
|
||||
Table string // nombre de la tabla SQLite ("projects")
|
||||
Fields []CRUDField // campos del recurso (sin ID ni timestamps)
|
||||
SoftDelete bool // si true, usa deleted_at en vez de DELETE real
|
||||
}
|
||||
|
||||
// CRUDField define un campo del recurso
|
||||
type CRUDField struct {
|
||||
Name string // nombre del campo (snake_case: "display_name")
|
||||
Type string // tipo SQLite: TEXT, INTEGER, REAL, BLOB
|
||||
Required bool // NOT NULL + validacion en create
|
||||
Unique bool // UNIQUE constraint
|
||||
Default string // valor por defecto en CREATE TABLE (vacio = sin default)
|
||||
Validations map[string]string // reglas: "min_length":"3", "max_length":"255", "pattern":"^[a-z]+"
|
||||
}
|
||||
|
||||
// CRUDListParams parametros de paginacion, orden y filtro
|
||||
type CRUDListParams struct {
|
||||
Page int // pagina actual (1-based, default 1)
|
||||
PerPage int // items por pagina (default 20, max 100)
|
||||
SortBy string // campo por el que ordenar (default "created_at")
|
||||
SortDir string // "asc" o "desc" (default "desc")
|
||||
Filters map[string]string // campo -> valor para WHERE exacto
|
||||
}
|
||||
|
||||
// CRUDListResult resultado paginado
|
||||
type CRUDListResult struct {
|
||||
Items []map[string]any `json:"items"`
|
||||
Total int `json:"total"`
|
||||
Page int `json:"page"`
|
||||
PerPage int `json:"per_page"`
|
||||
TotalPages int `json:"total_pages"`
|
||||
}
|
||||
```
|
||||
|
||||
### Funciones
|
||||
|
||||
| Funcion | Purity | Firma (simplificada) |
|
||||
|---------|--------|---------------------|
|
||||
| `crud_define_resource` | pure | `(name string, table string, fields []CRUDField, softDelete bool) CRUDResource` |
|
||||
| `crud_generate_table_sql` | pure | `(res CRUDResource) string` |
|
||||
| `crud_generate_handlers` | pure | `(res CRUDResource, db *sql.DB) map[string]http.HandlerFunc` |
|
||||
| `crud_register_routes` | impure | `(mux *http.ServeMux, basePath string, res CRUDResource, db *sql.DB)` |
|
||||
| `crud_list_handler` | impure | `(res CRUDResource, db *sql.DB) http.HandlerFunc` |
|
||||
| `crud_get_handler` | impure | `(res CRUDResource, db *sql.DB) http.HandlerFunc` |
|
||||
| `crud_create_handler` | impure | `(res CRUDResource, db *sql.DB) http.HandlerFunc` |
|
||||
| `crud_update_handler` | impure | `(res CRUDResource, db *sql.DB) http.HandlerFunc` |
|
||||
| `crud_delete_handler` | impure | `(res CRUDResource, db *sql.DB) http.HandlerFunc` |
|
||||
|
||||
### Comportamiento de cada handler
|
||||
|
||||
**List** (`GET /basePath`):
|
||||
- Query params: `page`, `per_page`, `sort_by`, `sort_dir`, `filter_{campo}={valor}`
|
||||
- Genera `SELECT * FROM table WHERE ... ORDER BY ... LIMIT ... OFFSET ...`
|
||||
- Cuenta total con `SELECT COUNT(*) FROM table WHERE ...`
|
||||
- Retorna `CRUDListResult` como JSON
|
||||
- Si `soft_delete`, agrega `WHERE deleted_at IS NULL` automaticamente
|
||||
|
||||
**Get** (`GET /basePath/{id}`):
|
||||
- Genera `SELECT * FROM table WHERE id = ?`
|
||||
- 404 si no existe (o si `soft_delete` y tiene `deleted_at`)
|
||||
- Retorna el registro como JSON
|
||||
|
||||
**Create** (`POST /basePath`):
|
||||
- Parsea body JSON con `http_parse_body`
|
||||
- Valida campos required y validaciones de cada campo
|
||||
- Genera UUID para `id`, timestamp para `created_at` y `updated_at`
|
||||
- `INSERT INTO table (id, field1, ..., created_at, updated_at) VALUES (?, ?, ..., ?, ?)`
|
||||
- Retorna 201 con el registro creado
|
||||
|
||||
**Update** (`PUT /basePath/{id}`):
|
||||
- Parsea body JSON — solo campos presentes se actualizan (partial update)
|
||||
- Valida campos enviados contra sus reglas
|
||||
- `UPDATE table SET field1=?, updated_at=? WHERE id=?`
|
||||
- 404 si no existe
|
||||
- Retorna el registro actualizado
|
||||
|
||||
**Delete** (`DELETE /basePath/{id}`):
|
||||
- Si `soft_delete`: `UPDATE table SET deleted_at=? WHERE id=?`
|
||||
- Si no: `DELETE FROM table WHERE id=?`
|
||||
- 404 si no existe
|
||||
- Retorna 204 sin body
|
||||
|
||||
### SQL generado por `crud_generate_table_sql`
|
||||
|
||||
```sql
|
||||
-- Para un recurso "projects" con campos name (TEXT, required, unique) y description (TEXT)
|
||||
CREATE TABLE IF NOT EXISTS projects (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL UNIQUE,
|
||||
description TEXT,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
-- Si soft_delete:
|
||||
CREATE TABLE IF NOT EXISTS projects (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL UNIQUE,
|
||||
description TEXT,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL,
|
||||
deleted_at TEXT
|
||||
);
|
||||
```
|
||||
|
||||
### Validaciones soportadas
|
||||
|
||||
| Clave | Aplica a | Ejemplo |
|
||||
|-------|----------|---------|
|
||||
| `min_length` | TEXT | `"3"` — minimo 3 caracteres |
|
||||
| `max_length` | TEXT | `"255"` — maximo 255 caracteres |
|
||||
| `pattern` | TEXT | `"^[a-z_]+$"` — regex match |
|
||||
| `min` | INTEGER, REAL | `"0"` — valor minimo |
|
||||
| `max` | INTEGER, REAL | `"1000"` — valor maximo |
|
||||
| `enum` | TEXT | `"active,inactive,archived"` — valores permitidos |
|
||||
|
||||
---
|
||||
|
||||
## Tareas
|
||||
|
||||
### Fase 1: Tipos y definicion
|
||||
|
||||
- [ ] **1.1** Crear tipos `CRUDResource`, `CRUDField`, `CRUDListParams`, `CRUDListResult` en `functions/infra/` con `.md` en `types/infra/`
|
||||
- [ ] **1.2** `crud_define_resource` — construye `CRUDResource` validando que haya al menos un campo y que los tipos sean validos (TEXT, INTEGER, REAL, BLOB)
|
||||
- [ ] **1.3** `crud_generate_table_sql` — genera DDL `CREATE TABLE IF NOT EXISTS` con las constraints derivadas de los campos
|
||||
|
||||
### Fase 2: Handlers individuales
|
||||
|
||||
- [ ] **2.1** `crud_list_handler` — parsea query params a `CRUDListParams`, construye SQL dinamico con paginacion y filtros, retorna `CRUDListResult`
|
||||
- [ ] **2.2** `crud_get_handler` — busca por ID, escanea columnas dinamicamente a `map[string]any`, responde 404 si no existe
|
||||
- [ ] **2.3** `crud_create_handler` — valida input contra definicion, genera UUID, inserta, retorna 201
|
||||
- [ ] **2.4** `crud_update_handler` — partial update con solo los campos enviados, valida los presentes, 404 si no existe
|
||||
- [ ] **2.5** `crud_delete_handler` — hard/soft delete segun config, 404 si no existe, retorna 204
|
||||
|
||||
### Fase 3: Composicion y tests
|
||||
|
||||
- [ ] **3.1** `crud_generate_handlers` — llama a los 5 handlers individuales, retorna `map[string]http.HandlerFunc` con keys "list", "get", "create", "update", "delete"
|
||||
- [ ] **3.2** `crud_register_routes` — registra en `http.ServeMux` las rutas `GET /base`, `GET /base/{id}`, `POST /base`, `PUT /base/{id}`, `DELETE /base/{id}`
|
||||
- [ ] **3.3** Tests con `httptest.NewServer` + SQLite in-memory (`:memory:`) para cada handler
|
||||
- [ ] **3.4** Test de integracion: define recurso, genera tabla, registra rutas, CRUD completo via HTTP
|
||||
- [ ] **3.5** `fn index` y verificar con `fn show` que todas las funciones y tipos aparecen
|
||||
|
||||
---
|
||||
|
||||
## Ejemplo de uso
|
||||
|
||||
```go
|
||||
// 1. Definir el recurso
|
||||
resource := infra.CRUDDefineResource("project", "projects", []infra.CRUDField{
|
||||
{Name: "name", Type: "TEXT", Required: true, Unique: true,
|
||||
Validations: map[string]string{"min_length": "1", "max_length": "100"}},
|
||||
{Name: "description", Type: "TEXT"},
|
||||
{Name: "status", Type: "TEXT", Required: true, Default: "'active'",
|
||||
Validations: map[string]string{"enum": "active,archived,deleted"}},
|
||||
{Name: "priority", Type: "INTEGER", Default: "0",
|
||||
Validations: map[string]string{"min": "0", "max": "10"}},
|
||||
}, false) // soft_delete = false
|
||||
|
||||
// 2. Crear la tabla
|
||||
ddl := infra.CRUDGenerateTableSQL(resource)
|
||||
db.Exec(ddl)
|
||||
|
||||
// 3. Registrar rutas (una linea)
|
||||
mux := http.NewServeMux()
|
||||
infra.CRUDRegisterRoutes(mux, "/api/projects", resource, db)
|
||||
|
||||
// Listo. Endpoints disponibles:
|
||||
// GET /api/projects — list con paginacion
|
||||
// GET /api/projects/{id} — get por ID
|
||||
// POST /api/projects — create
|
||||
// PUT /api/projects/{id} — update parcial
|
||||
// DELETE /api/projects/{id} — delete
|
||||
|
||||
// 4. Multiples recursos en la misma API
|
||||
infra.CRUDRegisterRoutes(mux, "/api/users", userResource, db)
|
||||
infra.CRUDRegisterRoutes(mux, "/api/tasks", taskResource, db)
|
||||
|
||||
// 5. Componer con middlewares de 0009
|
||||
middleware := infra.HttpMiddlewareChain(
|
||||
infra.HttpCorsMiddleware([]string{"*"}, []string{"GET", "POST", "PUT", "DELETE"}),
|
||||
infra.HttpLoggerMiddleware(os.Stdout),
|
||||
)
|
||||
|
||||
ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt)
|
||||
defer cancel()
|
||||
|
||||
infra.HttpServe(":8080", middleware(mux), ctx)
|
||||
```
|
||||
|
||||
```bash
|
||||
# Uso desde curl:
|
||||
|
||||
# List con paginacion y filtros
|
||||
curl "localhost:8080/api/projects?page=1&per_page=10&sort_by=name&sort_dir=asc&filter_status=active"
|
||||
|
||||
# Get
|
||||
curl "localhost:8080/api/projects/abc-123"
|
||||
|
||||
# Create
|
||||
curl -X POST localhost:8080/api/projects \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{"name":"mi-proyecto","description":"Desc","status":"active","priority":5}'
|
||||
|
||||
# Update parcial (solo cambia description)
|
||||
curl -X PUT localhost:8080/api/projects/abc-123 \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{"description":"Nueva descripcion"}'
|
||||
|
||||
# Delete
|
||||
curl -X DELETE localhost:8080/api/projects/abc-123
|
||||
```
|
||||
|
||||
## Decisiones de diseno
|
||||
|
||||
- **Runtime handler factories, no code generation:** `wails_bind_crud_go_infra` genera codigo como string para Wails. Este issue toma el camino opuesto: las funciones **son** los handlers, no generan texto. Esto evita templates, archivos generados y el ciclo generate-compile. El tradeoff es que los handlers son genericos (`map[string]any`) en vez de tipados, pero para APIs REST sobre SQLite es aceptable.
|
||||
- **`map[string]any` en vez de structs tipados:** Como los campos se definen en runtime, no es posible usar structs Go tipados. Toda la serializacion pasa por `map[string]any`. Esto es idiomatico para APIs JSON + SQLite donde el schema es dinamico.
|
||||
- **SQLite especifico, no multi-motor:** Simplifica enormemente el SQL generado (TEXT para timestamps, sin SERIAL, sin esquemas). Si en el futuro se necesita Postgres, se crea una variante separada.
|
||||
- **Validacion en Go, no en SQLite:** Las constraints de SQLite (CHECK, NOT NULL) son la ultima linea de defensa. La validacion principal ocurre en el handler antes del INSERT/UPDATE, con mensajes de error descriptivos para el cliente.
|
||||
- **UUID como ID:** Todos los recursos usan `id TEXT PRIMARY KEY` con UUID generado server-side. No IDs autoincrement — evita problemas de concurrencia y es mas portable.
|
||||
- **Timestamps como TEXT ISO 8601:** Consistente con como registry.db y operations.db ya almacenan timestamps.
|
||||
- **Partial update en PUT:** Normalmente PATCH es para partial update y PUT para replace completo. Aqui se usa PUT con partial update por simplicidad — solo los campos presentes en el JSON se actualizan. Es pragmatico para APIs internas.
|
||||
|
||||
## Relacion con funciones existentes
|
||||
|
||||
| Funcion existente | Relacion |
|
||||
|---|---|
|
||||
| `wails_bind_crud_go_infra` | Genera **codigo Go** como string para desktop (Wails). Este issue genera **handlers HTTP** en runtime para REST APIs. Complementarios, no solapados. |
|
||||
| `crud_page_ts_ui` | Frontend CRUD layout en React. Los handlers generados aqui serian el **backend** que esa pagina consume. Stack completo: `crud_define_resource` (definicion) + handlers (backend) + `crud_page` (frontend). |
|
||||
|
||||
## Riesgos
|
||||
|
||||
- **SQL injection en filtros:** Los filtros se pasan como query params y se inyectan en WHERE. Mitigado validando que el campo exista en la definicion del recurso y usando siempre `?` placeholders — nunca interpolacion de strings en SQL.
|
||||
- **Performance en tablas grandes:** El `SELECT COUNT(*)` para paginacion se ejecuta en cada list request. Para tablas con millones de filas esto es lento. Mitigado: las apps del registry manejan miles de registros, no millones. Si se necesita, se anade cache de count como mejora futura.
|
||||
- **Scope creep hacia un ORM:** Hay tentacion de agregar relaciones, joins, nested resources, hooks before/after. Mitigado limitando el scope a CRUD plano de una tabla. Relaciones y logica de negocio van en handlers custom, no en el generador.
|
||||
- **Colision con 0009 si cambia la API:** Los handlers usan `http_json_response` y `http_error_response` de 0009. Si esas firmas cambian, hay que actualizar. Mitigado: las funciones de 0009 son primitivas estables con firmas simples.
|
||||
@@ -0,0 +1,554 @@
|
||||
# 0022 — Init Pipelines
|
||||
|
||||
## Metadata
|
||||
|
||||
| Campo | Valor |
|
||||
|-------|-------|
|
||||
| **ID** | 0022 |
|
||||
| **Estado** | pendiente |
|
||||
| **Prioridad** | alta |
|
||||
| **Tipo** | feature |
|
||||
|
||||
## Dependencias
|
||||
|
||||
| ID | Titulo | Estado | Requerido |
|
||||
|----|--------|--------|-----------|
|
||||
| 0009 | HTTP Server Foundation | pendiente | si |
|
||||
| 0010 | Auth System | pendiente | si |
|
||||
| 0015 | Database Migrations | pendiente | si |
|
||||
|
||||
**Bloqueada por:** `#0009` (http_serve, http_router, http_json_response, http_middleware_chain), `#0010` (jwt_middleware, password_hash, session_create), `#0015` (migration_create, migration_up, migration_status). Los pipelines de init generan boilerplate que importa y compone estas funciones — sin ellas, el codigo scaffoldeado no compilaria.
|
||||
|
||||
**Desbloquea:** cualquier app nueva del registry se puede crear con un solo comando en vez de copiar y adaptar una app existente.
|
||||
|
||||
---
|
||||
|
||||
## Objetivo
|
||||
|
||||
Cuatro bash pipelines que scaffold apps completas en `apps/` con un solo comando. Cada uno genera la estructura de directorios, archivos boilerplate, `app.md` con frontmatter correcto, y verifica que el resultado compila. Misma filosofia que `init_jupyter_analysis_bash_pipelines`: componer funciones atomicas del registry para producir un entorno listo para trabajar.
|
||||
|
||||
## Contexto
|
||||
|
||||
- Existen tres init pipelines: `init_go_project_bash_pipelines` (repo Go generico), `init_go_module_bash_pipelines` (modulo Go simple), `init_jupyter_analysis_bash_pipelines` (analysis Jupyter). Todos scaffoldean estructuras basicas.
|
||||
- **No existe ningun pipeline para scaffoldear apps del registry** con HTTP server, auth, DB, frontend, Wails o TUI. Cada app nueva se construye copiando otra y adaptando manualmente.
|
||||
- Las apps existentes (`deploy_server`, `sqlite_api`, `rapid_dashboards`, `pipeline_launcher`) comparten patrones repetitivos: main.go con graceful shutdown, config desde env vars, health check, migrations, app.md.
|
||||
- Con las funciones de 0009 (HTTP), 0010 (auth) y 0015 (migrations) disponibles, el boilerplate de una app API es predecible y automatizable.
|
||||
- El registry ya tiene funciones Wails completas (`scaffold_wails_app_go_infra`, `install_wails_bash_infra`, `wails_build_go_infra`, hooks `use_wails_*_ts_ui`, `wails_provider_ts_ui`) y TUI (`new_base_model_go_tui`, `run_fullscreen_go_tui`, temas, spinners, listas).
|
||||
|
||||
## Arquitectura
|
||||
|
||||
```
|
||||
bash/functions/pipelines/
|
||||
├── init_api_app.sh — NEW: scaffold Go HTTP API app
|
||||
├── init_api_app.md — NEW
|
||||
├── init_web_app.sh — NEW: scaffold full-stack app (Go API + React)
|
||||
├── init_web_app.md — NEW
|
||||
├── init_desktop_app.sh — NEW: scaffold Wails desktop app
|
||||
├── init_desktop_app.md — NEW
|
||||
├── init_cli_app.sh — NEW: scaffold Go CLI/TUI app
|
||||
├── init_cli_app.md — NEW
|
||||
```
|
||||
|
||||
Todas son `kind: pipeline`, `purity: impure`, `lang: bash`, `domain: pipelines`.
|
||||
|
||||
### Patron de composicion
|
||||
|
||||
Cada pipeline sigue el mismo patron que `init_jupyter_analysis`:
|
||||
|
||||
1. Source funciones atomicas del registry via `source "$REGISTRY_ROOT/bash/functions/..."`
|
||||
2. Parsear argumentos (nombre obligatorio, flags opcionales)
|
||||
3. Crear estructura de directorios con `mkdir -p`
|
||||
4. Escribir archivos boilerplate con heredocs
|
||||
5. Generar `app.md` con frontmatter correcto
|
||||
6. Ejecutar `fn index` para registrar la app
|
||||
7. Verificar con `go vet` / `pnpm build` / `wails build` segun corresponda
|
||||
|
||||
---
|
||||
|
||||
## Diseno
|
||||
|
||||
### Pipeline 1: `init_api_app`
|
||||
|
||||
Scaffold de Go HTTP API app en `apps/`.
|
||||
|
||||
**Uso:**
|
||||
|
||||
```bash
|
||||
fn run init_api_app my_service
|
||||
fn run init_api_app my_service --port 8080 --with-auth --with-db
|
||||
```
|
||||
|
||||
**Archivos generados:**
|
||||
|
||||
```
|
||||
apps/{nombre}/
|
||||
├── main.go — Entry point: config → router → middleware → http_serve con graceful shutdown
|
||||
├── handlers.go — Handler GET /health + handler de ejemplo GET /api/v1/status
|
||||
├── config.go — Struct Config con tags + carga desde .env / env vars
|
||||
├── migrations/
|
||||
│ └── 001_initial.sql — CREATE TABLE ejemplo con id, created_at, updated_at
|
||||
├── app.md — Frontmatter con tag service, uses_functions, dir_path
|
||||
├── Makefile — Targets: build, run, test, vet, clean
|
||||
├── .env.example — Variables de entorno documentadas (PORT, DB_PATH, etc.)
|
||||
└── .gitignore — Binario, .env, *.db-shm, *.db-wal
|
||||
```
|
||||
|
||||
**Funciones del registry compuestas:**
|
||||
|
||||
| Funcion | Para que |
|
||||
|---------|---------|
|
||||
| `assert_command_exists_bash_shell` | Verificar que `go` esta instalado |
|
||||
| `http_serve_go_infra` (0009) | Codigo de graceful shutdown en main.go |
|
||||
| `http_router_go_infra` (0009) | Registro de rutas en main.go |
|
||||
| `http_json_response_go_infra` (0009) | Helper en handlers.go |
|
||||
| `http_error_response_go_infra` (0009) | Helper en handlers.go |
|
||||
| `http_middleware_chain_go_infra` (0009) | Composicion de middlewares en main.go |
|
||||
| `http_logger_middleware_go_infra` (0009) | Logging en main.go |
|
||||
| `http_cors_middleware_go_infra` (0009) | CORS en main.go |
|
||||
| `migration_up_go_infra` (0015) | Aplicar migrations en main.go al arrancar |
|
||||
| `config_load_go_infra` (0018) | Carga de config en config.go (si existe) |
|
||||
|
||||
**Flags opcionales:**
|
||||
|
||||
| Flag | Efecto |
|
||||
|------|--------|
|
||||
| `--port N` | Puerto por defecto en config y .env.example (default: 8080) |
|
||||
| `--with-auth` | Anade jwt_middleware, handlers de login/register, tabla users en migration |
|
||||
| `--with-db` | Anade operations.db setup, store.go con helpers CRUD basicos |
|
||||
| `--with-ops` | Anade `fn ops init` para crear operations.db con schema completo |
|
||||
|
||||
**main.go generado (esquema):**
|
||||
|
||||
```go
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log"
|
||||
"os"
|
||||
"os/signal"
|
||||
|
||||
"fn_registry/functions/infra"
|
||||
)
|
||||
|
||||
func main() {
|
||||
cfg := LoadConfig()
|
||||
|
||||
// Migrations
|
||||
if err := infra.MigrationUp(cfg.DBPath, "migrations"); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
// Routes
|
||||
routes := []infra.Route{
|
||||
{Method: "GET", Path: "/health", Handler: healthHandler},
|
||||
{Method: "GET", Path: "/api/v1/status", Handler: statusHandler},
|
||||
}
|
||||
mux := infra.HttpRouter(routes)
|
||||
|
||||
// Middleware
|
||||
middleware := infra.HttpMiddlewareChain(
|
||||
infra.HttpCorsMiddleware(cfg.CORSOrigins, []string{"GET", "POST", "PUT", "DELETE"}),
|
||||
infra.HttpLoggerMiddleware(os.Stdout),
|
||||
)
|
||||
|
||||
// Serve
|
||||
ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt)
|
||||
defer cancel()
|
||||
|
||||
log.Printf("starting %s on :%s", cfg.AppName, cfg.Port)
|
||||
if err := infra.HttpServe(":"+cfg.Port, middleware(mux), ctx); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Pipeline 2: `init_web_app`
|
||||
|
||||
Scaffold de full-stack app: Go API backend + React frontend con Mantine.
|
||||
|
||||
**Uso:**
|
||||
|
||||
```bash
|
||||
fn run init_web_app my_dashboard
|
||||
fn run init_web_app my_dashboard --port 8080 --with-auth
|
||||
```
|
||||
|
||||
**Archivos generados:**
|
||||
|
||||
```
|
||||
apps/{nombre}/
|
||||
├── main.go — Igual que init_api_app + serve static files del frontend build
|
||||
├── handlers.go — Health + API handlers de ejemplo
|
||||
├── config.go — Config con FRONTEND_DIR
|
||||
├── migrations/
|
||||
│ └── 001_initial.sql
|
||||
├── app.md — tag service, uses frontend
|
||||
├── Makefile — Targets: build, build-frontend, run, dev, test, clean
|
||||
├── .env.example
|
||||
├── .gitignore
|
||||
├── docker-compose.yml — Dev: Go API hot-reload + frontend dev server
|
||||
└── frontend/
|
||||
├── package.json — pnpm, vite, react, @mantine/core, @mantine/charts, @fn_library
|
||||
├── vite.config.ts — API proxy a localhost:${port}
|
||||
├── tsconfig.json
|
||||
├── index.html
|
||||
├── postcss.config.cjs
|
||||
└── src/
|
||||
├── main.tsx — FnMantineProvider + App mount
|
||||
├── App.tsx — Router basico con pagina de ejemplo
|
||||
├── theme.ts — createTheme() con colores del proyecto
|
||||
└── pages/
|
||||
└── Home.tsx — Pagina de ejemplo usando crud_page_ts_ui o dashboard_layout_ts_ui
|
||||
```
|
||||
|
||||
**Funciones adicionales compuestas (sobre init_api_app):**
|
||||
|
||||
| Funcion | Para que |
|
||||
|---------|---------|
|
||||
| `mantine_provider_ts_ui` | Provider raiz en main.tsx |
|
||||
| `crud_page_ts_ui` | Pagina de ejemplo funcional |
|
||||
| `app_shell_ts_ui` | Layout con navbar y header |
|
||||
| `data_table_ts_ui` | Tabla de datos en la pagina de ejemplo |
|
||||
|
||||
**vite.config.ts generado:**
|
||||
|
||||
```ts
|
||||
import { defineConfig } from "vite";
|
||||
import react from "@vitejs/plugin-react";
|
||||
import path from "path";
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
resolve: {
|
||||
alias: {
|
||||
"@fn_library": path.resolve(__dirname, "../../frontend/functions/ui"),
|
||||
},
|
||||
},
|
||||
server: {
|
||||
proxy: {
|
||||
"/api": {
|
||||
target: "http://localhost:${PORT}",
|
||||
changeOrigin: true,
|
||||
},
|
||||
"/health": {
|
||||
target: "http://localhost:${PORT}",
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
**docker-compose.yml generado:**
|
||||
|
||||
```yaml
|
||||
services:
|
||||
api:
|
||||
build: .
|
||||
ports:
|
||||
- "${PORT}:${PORT}"
|
||||
env_file: .env
|
||||
volumes:
|
||||
- ./migrations:/app/migrations
|
||||
frontend:
|
||||
image: node:20-alpine
|
||||
working_dir: /app
|
||||
command: sh -c "corepack enable && pnpm install && pnpm dev"
|
||||
ports:
|
||||
- "5173:5173"
|
||||
volumes:
|
||||
- ./frontend:/app
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Pipeline 3: `init_desktop_app`
|
||||
|
||||
Scaffold de Wails desktop app con Go backend y React frontend con @fn_library.
|
||||
|
||||
**Uso:**
|
||||
|
||||
```bash
|
||||
fn run init_desktop_app my_tool
|
||||
fn run init_desktop_app my_tool --with-db
|
||||
```
|
||||
|
||||
**Archivos generados:**
|
||||
|
||||
```
|
||||
apps/{nombre}/
|
||||
├── main.go — Wails entry point con embed del frontend
|
||||
├── app.go — Struct App con bindings base (Greet, GetVersion)
|
||||
├── wails.json — Config Wails apuntando a frontend/
|
||||
├── go.mod
|
||||
├── app.md — framework: wails, uses wails hooks
|
||||
├── .gitignore
|
||||
└── frontend/
|
||||
├── package.json — pnpm, vite, react, @mantine/core, @fn_library
|
||||
├── vite.config.ts
|
||||
├── tsconfig.json
|
||||
├── index.html
|
||||
└── src/
|
||||
├── main.tsx — WailsProvider + FnMantineProvider + App
|
||||
├── App.tsx — Ejemplo usando useWailsQuery + data_table
|
||||
└── theme.ts — createTheme()
|
||||
```
|
||||
|
||||
**Funciones del registry compuestas:**
|
||||
|
||||
| Funcion | Para que |
|
||||
|---------|---------|
|
||||
| `scaffold_wails_app_go_infra` | Genera estructura base Wails (main.go, app.go, wails.json, go.mod) |
|
||||
| `install_wails_bash_infra` | Verifica/instala Wails CLI y deps de sistema |
|
||||
| `wails_provider_ts_ui` | Provider React para IPC cache |
|
||||
| `use_wails_query_ts_ui` | Hook de ejemplo en App.tsx |
|
||||
| `mantine_provider_ts_ui` | Provider Mantine |
|
||||
| `wails_bind_crud_go_infra` | Genera bindings CRUD si `--with-db` |
|
||||
|
||||
**Flags opcionales:**
|
||||
|
||||
| Flag | Efecto |
|
||||
|------|--------|
|
||||
| `--with-db` | Anade SQLite con migrations, bindings CRUD generados por `wails_bind_crud_go_infra` |
|
||||
|
||||
---
|
||||
|
||||
### Pipeline 4: `init_cli_app`
|
||||
|
||||
Scaffold de Go CLI app con subcomandos y componentes TUI de Bubbletea.
|
||||
|
||||
**Uso:**
|
||||
|
||||
```bash
|
||||
fn run init_cli_app my_cli
|
||||
fn run init_cli_app my_cli --with-tui
|
||||
```
|
||||
|
||||
**Archivos generados:**
|
||||
|
||||
```
|
||||
apps/{nombre}/
|
||||
├── main.go — Entry point con subcommand routing (os.Args)
|
||||
├── cmd_version.go — Subcomando: version
|
||||
├── cmd_status.go — Subcomando de ejemplo: status (imprime info)
|
||||
├── app.md — framework vacio (CLI puro) o bubbletea (con --with-tui)
|
||||
├── Makefile — Targets: build, run, install, test, clean
|
||||
├── .gitignore
|
||||
└── go.mod
|
||||
```
|
||||
|
||||
**Con `--with-tui`:**
|
||||
|
||||
```
|
||||
apps/{nombre}/
|
||||
├── main.go — Entry point con run_fullscreen o run_model
|
||||
├── model.go — BaseModel + Update + View con tema oscuro
|
||||
├── cmd_version.go — Subcomando no-TUI
|
||||
├── app.md — framework: bubbletea
|
||||
├── Makefile
|
||||
├── .gitignore
|
||||
└── go.mod
|
||||
```
|
||||
|
||||
**Funciones del registry compuestas:**
|
||||
|
||||
| Funcion | Para que |
|
||||
|---------|---------|
|
||||
| `assert_command_exists_bash_shell` | Verificar Go |
|
||||
| `new_base_model_go_tui` | Modelo base en model.go |
|
||||
| `dark_styles_go_tui` | Tema oscuro por defecto |
|
||||
| `run_fullscreen_go_tui` | Arranque fullscreen en main.go |
|
||||
| `new_spinner_go_tui` | Componente de ejemplo |
|
||||
| `new_filtered_list_go_tui` | Componente de ejemplo |
|
||||
|
||||
**main.go generado (sin TUI):**
|
||||
|
||||
```go
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
)
|
||||
|
||||
var version = "dev"
|
||||
|
||||
func main() {
|
||||
if len(os.Args) < 2 {
|
||||
printUsage()
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
switch os.Args[1] {
|
||||
case "version":
|
||||
cmdVersion()
|
||||
case "status":
|
||||
cmdStatus()
|
||||
default:
|
||||
fmt.Fprintf(os.Stderr, "unknown command: %s\n", os.Args[1])
|
||||
printUsage()
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
func printUsage() {
|
||||
fmt.Println("Usage: {nombre} <command>")
|
||||
fmt.Println()
|
||||
fmt.Println("Commands:")
|
||||
fmt.Println(" version Print version")
|
||||
fmt.Println(" status Show status")
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Tareas
|
||||
|
||||
### Fase 1: `init_api_app` (pipeline base)
|
||||
|
||||
- [ ] **1.1** Crear `bash/functions/pipelines/init_api_app.sh` con source de funciones atomicas, parseo de argumentos, y generacion de estructura
|
||||
- [ ] **1.2** Escribir heredocs para `main.go`, `handlers.go`, `config.go` que importen funciones de 0009 y 0015
|
||||
- [ ] **1.3** Generar `app.md` con frontmatter correcto (tag `service`, `uses_functions` con IDs reales, `dir_path`)
|
||||
- [ ] **1.4** Generar `Makefile`, `.env.example`, `.gitignore`, `migrations/001_initial.sql`
|
||||
- [ ] **1.5** Flag `--with-auth`: anadir imports de 0010, handlers de login/register, tabla users en migration
|
||||
- [ ] **1.6** Flag `--with-db`: anadir `store.go` con helpers CRUD, setup de SQLite al arrancar
|
||||
- [ ] **1.7** Ejecutar `go vet -tags fts5` al final como verificacion
|
||||
- [ ] **1.8** Crear `init_api_app.md` con frontmatter de pipeline
|
||||
|
||||
### Fase 2: `init_web_app` (extiende init_api_app)
|
||||
|
||||
- [ ] **2.1** Crear `bash/functions/pipelines/init_web_app.sh` que primero invoca la logica de `init_api_app` y luego anade el frontend
|
||||
- [ ] **2.2** Generar `frontend/` con `package.json` (pnpm, vite, react, mantine, @fn_library alias)
|
||||
- [ ] **2.3** Generar `vite.config.ts` con proxy al backend y alias `@fn_library`
|
||||
- [ ] **2.4** Generar `src/main.tsx` con `FnMantineProvider`, `src/App.tsx` con `AppShell`, `src/pages/Home.tsx` con ejemplo
|
||||
- [ ] **2.5** Generar `docker-compose.yml` para desarrollo
|
||||
- [ ] **2.6** Actualizar `main.go` para servir static files del frontend build
|
||||
- [ ] **2.7** Ejecutar `pnpm install && pnpm build` como verificacion del frontend
|
||||
- [ ] **2.8** Crear `init_web_app.md` con frontmatter de pipeline
|
||||
|
||||
### Fase 3: `init_desktop_app`
|
||||
|
||||
- [ ] **3.1** Crear `bash/functions/pipelines/init_desktop_app.sh` que invoca `scaffold_wails_app_go_infra` y anade frontend React
|
||||
- [ ] **3.2** Verificar/instalar Wails con `install_wails_bash_infra`
|
||||
- [ ] **3.3** Generar frontend con `WailsProvider` + `FnMantineProvider` y ejemplo con `useWailsQuery`
|
||||
- [ ] **3.4** Flag `--with-db`: invocar `wails_bind_crud_go_infra` para generar bindings
|
||||
- [ ] **3.5** Ejecutar `wails build` como verificacion
|
||||
- [ ] **3.6** Crear `init_desktop_app.md` con frontmatter de pipeline
|
||||
|
||||
### Fase 4: `init_cli_app`
|
||||
|
||||
- [ ] **4.1** Crear `bash/functions/pipelines/init_cli_app.sh` con generacion de estructura CLI basica
|
||||
- [ ] **4.2** Generar `main.go` con routing de subcomandos, `cmd_version.go`, `cmd_status.go`
|
||||
- [ ] **4.3** Flag `--with-tui`: generar `model.go` con `new_base_model`, `dark_styles`, `run_fullscreen`
|
||||
- [ ] **4.4** Ejecutar `go vet` como verificacion
|
||||
- [ ] **4.5** Crear `init_cli_app.md` con frontmatter de pipeline
|
||||
|
||||
### Fase 5: Integracion
|
||||
|
||||
- [ ] **5.1** `fn index` y verificar que los 4 pipelines aparecen en registry.db con kind=pipeline, purity=impure
|
||||
- [ ] **5.2** Verificar `fn run init_api_app test_app` end-to-end: genera, compila, limpia
|
||||
- [ ] **5.3** Verificar `fn run init_web_app test_web` end-to-end
|
||||
- [ ] **5.4** Verificar `fn run init_desktop_app test_desktop` end-to-end
|
||||
- [ ] **5.5** Verificar `fn run init_cli_app test_cli` end-to-end
|
||||
|
||||
### Fase 6: Documentacion de uso rapido
|
||||
|
||||
Cada pipeline debe ser usable sin leer el issue completo. La documentacion va en dos niveles: el `.md` de cada funcion (fuente de verdad para `fn show`) y una guia consolidada.
|
||||
|
||||
- [ ] **6.1** En cada `.md` de pipeline (`init_api_app.md`, etc.) documentar en la seccion `documentation` del frontmatter:
|
||||
- Sinopsis: `fn run init_api_app <nombre> [--port N] [--with-auth] [--with-db]`
|
||||
- Descripcion de cada flag y su efecto concreto (que archivos anade, que imports genera)
|
||||
- Listado de archivos generados con una linea de descripcion cada uno
|
||||
- Post-setup: que comandos ejecutar despues (`make run`, `make dev`, `wails dev`, etc.)
|
||||
- Ejemplo rapido: un bloque copy-paste de 3-4 lineas que crea la app y la arranca
|
||||
- [ ] **6.2** En el campo `params` del frontmatter de cada pipeline, documentar cada argumento y flag con `name` y `desc` semantico para que `fn check params` pase y la info sea buscable via FTS5
|
||||
- [ ] **6.3** En el campo `example` del frontmatter, poner el caso de uso mas comun (una linea):
|
||||
- `init_api_app`: `fn run init_api_app my_service --with-db`
|
||||
- `init_web_app`: `fn run init_web_app my_dashboard --with-auth`
|
||||
- `init_desktop_app`: `fn run init_desktop_app my_tool`
|
||||
- `init_cli_app`: `fn run init_cli_app my_cli --with-tui`
|
||||
- [ ] **6.4** Crear `docs/init-pipelines.md` como guia consolidada de referencia rapida con:
|
||||
- Tabla resumen de los 4 pipelines (nombre, que genera, flags disponibles)
|
||||
- Arbol de decision: "quiero una API" → init_api_app, "quiero frontend" → init_web_app, "quiero desktop" → init_desktop_app, "quiero CLI" → init_cli_app
|
||||
- Seccion de combinaciones comunes (API + auth + DB, web dashboard, desktop con SQLite, CLI con TUI)
|
||||
- FAQ: como anadir auth despues, como cambiar el puerto, como anadir operations.db, como agregar mas paginas al frontend
|
||||
- [ ] **6.5** Verificar que `fn show init_api_app_bash_pipelines` (y los otros 3) muestra la documentacion completa con params, ejemplo y notas de uso
|
||||
|
||||
---
|
||||
|
||||
## Ejemplo de uso
|
||||
|
||||
```bash
|
||||
# API service con auth y database
|
||||
fn run init_api_app billing_api --port 8090 --with-auth --with-db
|
||||
cd apps/billing_api
|
||||
make run
|
||||
# → starting billing_api on :8090
|
||||
# → curl localhost:8090/health → {"status":"ok"}
|
||||
|
||||
# Full-stack dashboard
|
||||
fn run init_web_app inventory_dashboard --with-auth
|
||||
cd apps/inventory_dashboard
|
||||
make dev
|
||||
# → API en :8080, frontend en :5173 con proxy
|
||||
|
||||
# Desktop app con base de datos
|
||||
fn run init_desktop_app data_explorer --with-db
|
||||
cd apps/data_explorer
|
||||
wails dev
|
||||
# → App de escritorio con React + SQLite
|
||||
|
||||
# CLI con TUI
|
||||
fn run init_cli_app deploy_helper --with-tui
|
||||
cd apps/deploy_helper
|
||||
make run -- status
|
||||
# → TUI fullscreen con lista filtrable
|
||||
```
|
||||
|
||||
**Cada pipeline genera su `app.md` listo para `fn index`:**
|
||||
|
||||
```yaml
|
||||
---
|
||||
name: billing_api
|
||||
lang: go
|
||||
domain: tools
|
||||
description: "API de facturacion."
|
||||
tags: [service]
|
||||
uses_functions:
|
||||
- http_serve_go_infra
|
||||
- http_router_go_infra
|
||||
- http_middleware_chain_go_infra
|
||||
- http_cors_middleware_go_infra
|
||||
- http_logger_middleware_go_infra
|
||||
- http_json_response_go_infra
|
||||
- http_error_response_go_infra
|
||||
- migration_up_go_infra
|
||||
uses_types: []
|
||||
framework: "net/http"
|
||||
entry_point: "main.go"
|
||||
dir_path: "apps/billing_api"
|
||||
---
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Decisiones de diseno
|
||||
|
||||
- **Bash, no Go:** los init pipelines generan archivos con heredocs — bash es el lenguaje natural para esto. Go seria overengineering para scaffolding de texto. Coherente con `init_jupyter_analysis` y los demas init existentes.
|
||||
- **Composicion sobre monolito:** cada pipeline sourcea funciones atomicas del registry (`assert_command_exists`, `scaffold_wails_app`, etc.) en vez de reimplementar. Si una funcion atomica mejora, todos los pipelines se benefician.
|
||||
- **init_web_app extiende init_api_app:** el pipeline web reutiliza la logica del API (misma estructura backend) y anade la capa frontend encima. No duplica codigo.
|
||||
- **Verificacion al final:** cada pipeline termina con `go vet`, `pnpm build`, o `wails build` para garantizar que el scaffold compila. Si falla, el pipeline reporta el error antes de declarar exito.
|
||||
- **Flags opcionales con defaults sensatos:** el caso base (sin flags) genera una app funcional minima. `--with-auth`, `--with-db`, `--with-tui` anaden capas incrementales. El usuario no necesita decidir todo upfront.
|
||||
- **@fn_library como alias, no copia:** el frontend generado referencia `@fn_library` via alias en `vite.config.ts` apuntando a `frontend/functions/ui/` del registry. Los componentes se comparten, no se duplican.
|
||||
- **app.md generado automaticamente:** el frontmatter incluye `uses_functions` con los IDs reales de las funciones que el boilerplate importa. `fn index` los valida al registrar la app.
|
||||
- **Sin framework CLI externo para init_cli_app:** routing de subcomandos con `os.Args` y switch — consistente con las apps existentes del registry que no usan cobra/urfave. Para TUI se usa Bubbletea que ya esta en el registry.
|
||||
|
||||
## Riesgos
|
||||
|
||||
- **Dependencias no implementadas:** los tres issues de dependencia (0009, 0010, 0015) estan pendientes. Si alguna funcion cambia de firma durante su implementacion, los heredocs de los pipelines necesitaran ajuste. **Mitigacion:** implementar los pipelines despues de que las dependencias esten merged, o mantener los heredocs parametricos para absorber cambios menores.
|
||||
- **Heredocs fragiles:** generar Go/TS/YAML con heredocs bash es propenso a errores de indentacion, escape de variables y quoting. **Mitigacion:** cada pipeline incluye verificacion final (`go vet` / `pnpm build`) que detecta errores de sintaxis inmediatamente. Tests end-to-end en fase 5.
|
||||
- **Frontend desactualizado respecto a @fn_library:** si los componentes de `frontend/functions/ui/` evolucionan, el boilerplate generado puede quedar desactualizado. **Mitigacion:** el boilerplate es minimo (un Provider, un AppShell, una pagina de ejemplo) — el usuario lo extiende con los componentes actuales del registry.
|
||||
- **Wails como dependencia de sistema:** `init_desktop_app` requiere GTK3 + WebKit2GTK instalados en Linux. `install_wails_bash_infra` lo maneja, pero puede fallar en distros no soportadas. **Mitigacion:** el pipeline verifica la instalacion al inicio y falla rapido con mensaje descriptivo.
|
||||
- **Colision de nombres:** si el usuario elige un nombre que ya existe en `apps/`, el pipeline sobreescribiria archivos. **Mitigacion:** verificar si `apps/{nombre}/` existe al inicio y abortar con error si ya existe.
|
||||
@@ -0,0 +1,225 @@
|
||||
# 0024 — Split dashboard YAMLs por tab
|
||||
|
||||
## APP Metadata
|
||||
|
||||
| Campo | Valor |
|
||||
|-------|-------|
|
||||
| **ID** | 0024 |
|
||||
| **Estado** | pendiente |
|
||||
| **Prioridad** | alta |
|
||||
| **Tipo** | mejora — devex de auto_metabase |
|
||||
|
||||
## Dependencias
|
||||
|
||||
Ninguna. Modifica `apps/auto_metabase/sync_pull.py`, `sync_push.py`, `sync_validate.py` y `app.md`.
|
||||
|
||||
**Desbloquea:** edicion fluida de dashboards grandes (aurgi id=734: 11.282 lineas, 90 dashcards, 11 tabs). Actualmente cada cambio obliga a scrollear el monolitico; tras esta issue cada tab es un archivo independiente.
|
||||
|
||||
---
|
||||
|
||||
## Objetivo
|
||||
|
||||
Dividir el YAML monolitico de cada dashboard en un directorio `{slug}/` con un archivo `_dashboard.yaml` (metadata, parametros, tabs) y un archivo por tab (`tab_{slug_tab}.yaml`) con sus dashcards. El push compone todo de vuelta a un unico body JSON antes de enviar a Metabase — cambio 100% cosmetico del sync local, el API no se entera.
|
||||
|
||||
## Contexto
|
||||
|
||||
**Situacion actual:**
|
||||
- `pull_dashboard()` escribe `projects/{name}/dashboards/{slug}.yaml` con todo: metadata + parametros + tabs + 90+ dashcards en un solo archivo.
|
||||
- Aurgi (proyecto real) tiene un dashboard `bi_ventas_portfolio_producto_en_construccion` que genera **11.282 lineas YAML**. Para editar una dashcard en el tab "Foto Categorias" hay que encontrarla entre 90 entries via `dashboard_tab_id` (que recientemente volvimos a preservar — ver commit de sync_pull que re-inyecta ids de tabs tras el strip).
|
||||
- Los diffs de git son ilegibles cuando se reordenan dashcards: cambiar 1 dashcard puede mover 40 bloques de YAML y romper el review.
|
||||
- `validate` corre sobre el archivo completo aunque solo hayas editado una tab.
|
||||
|
||||
**Alternativas consideradas:**
|
||||
- **One file per dashcard** (muy granular) — descartado: demasiados archivos para un dashboard mediano, el tab se pierde como unidad semantica.
|
||||
- **One file per tab** — elegido: coincide con la unidad semantica que un humano edita ("trabajar en la tab 5 Min View"), diffs acotados a una tab, tamaño manejable (~1000 lineas por tab en el peor caso).
|
||||
- **Mantener monolitico + jq/yq helpers** — descartado: no resuelve el problema del diff ni de la navegacion cognitiva.
|
||||
|
||||
## Arquitectura
|
||||
|
||||
```
|
||||
apps/auto_metabase/
|
||||
├── sync_pull.py # MOD: pull_dashboard -> split en directorio
|
||||
├── sync_push.py # MOD: load_dashboard -> compose desde directorio
|
||||
├── sync_validate.py # MOD: resolver path a dashboard (file o directory)
|
||||
├── payload.py # MOD si hace falta (builders actuales)
|
||||
├── app.md # MOD: documentar nuevo layout
|
||||
└── projects/{name}/
|
||||
└── dashboards/
|
||||
├── {slug}.yaml # LEGACY: sigue soportado (backward-compat)
|
||||
└── {slug}/ # NEW: nueva estructura directory-based
|
||||
├── _dashboard.yaml # NEW: _meta + _refs + name + description + parameters + tabs (solo names/ids) + dashcards_globales (sin tab_id)
|
||||
├── tab_5_min_view.yaml # NEW: dashcards con dashboard_tab_id=191
|
||||
├── tab_foto_centros_compara_semana.yaml # NEW
|
||||
└── ...
|
||||
```
|
||||
|
||||
### Pure core / impure shell
|
||||
|
||||
- **Pure (testable sin Metabase):**
|
||||
- `split_dashboard_payload(payload: dict) -> dict[str, dict]` — toma el dict de un dashboard tal y como lo escribe el pull actual y devuelve `{"_dashboard": {...}, "tab_xxx": {...}, ...}`. Input → output puro. En `sync_pull.py` o en un nuevo modulo `dashboard_split.py`.
|
||||
- `merge_dashboard_payload(parts: dict[str, dict]) -> dict` — inverso: toma el dict de partes y reconstruye el payload unico para push. Tambien puro.
|
||||
- `tab_slug(tab_name: str) -> str` — slugify, reusar la que ya existe en `sync_pull._slugify()`.
|
||||
- **Impure (I/O):**
|
||||
- `pull_dashboard()` — escribe directorio en disco tras el split.
|
||||
- `load_dashboard_from_disk(dir_path)` — lee todos los `tab_*.yaml` + `_dashboard.yaml` y pasa a `merge_dashboard_payload()`.
|
||||
|
||||
### Formato de `_dashboard.yaml` (directory-based)
|
||||
|
||||
```yaml
|
||||
_meta:
|
||||
kind: dashboard
|
||||
id: 734
|
||||
slug: bi_ventas_portfolio_producto_en_construccion
|
||||
synced_at: '2026-04-13T16:04:34Z'
|
||||
remote_updated_at: '2026-04-09T15:11:20Z'
|
||||
dashcards_count: 90
|
||||
tabs_count: 11
|
||||
parameters_count: 26
|
||||
split_version: 1 # NEW: marcador de formato splitteado
|
||||
_refs:
|
||||
collection: repositorio_central
|
||||
payload:
|
||||
archived: false
|
||||
enable_embedding: false
|
||||
name: "🚧BI - VENTAS - PORTFOLIO PRODUCTO 🚧 En construccion"
|
||||
description: ...
|
||||
width: full
|
||||
parameters: [...] # 26 parametros
|
||||
tabs: # lista con nombre + id (para mapear a files)
|
||||
- id: 191
|
||||
name: 5 Min View
|
||||
file: tab_5_min_view.yaml
|
||||
- id: 192
|
||||
name: Foto Centros - Compara Semana
|
||||
file: tab_foto_centros_compara_semana.yaml
|
||||
- ...
|
||||
dashcards_globales: [] # dashcards SIN dashboard_tab_id (headings root-level)
|
||||
```
|
||||
|
||||
### Formato de `tab_{slug}.yaml`
|
||||
|
||||
```yaml
|
||||
_meta:
|
||||
kind: dashboard_tab
|
||||
parent_slug: bi_ventas_portfolio_producto_en_construccion
|
||||
tab_id: 191
|
||||
tab_name: 5 Min View
|
||||
payload:
|
||||
dashcards:
|
||||
- size_x: 6
|
||||
size_y: 2
|
||||
col: 0
|
||||
row: 0
|
||||
card: ventas_totales_2 # slug resuelto (como ya lo hace R15)
|
||||
parameter_mappings: [...]
|
||||
visualization_settings: {}
|
||||
series: []
|
||||
- ...
|
||||
```
|
||||
|
||||
## Tareas
|
||||
|
||||
### Fase 1: Helpers puros
|
||||
|
||||
- [ ] **1.1** Crear `dashboard_split.py` (modulo nuevo) con `split_dashboard_payload()` y `merge_dashboard_payload()`. Input/output puros, sin I/O.
|
||||
- [ ] **1.2** Implementar `tab_file_slug(tab_name: str) -> str` reusando `_slugify()` de `sync_pull.py`. Prefijo `tab_` obligatorio para reconocer archivos.
|
||||
- [ ] **1.3** Tests unitarios en `dashboard_split_test.py`: round-trip `split → merge == original`, caso con tabs, sin tabs, con dashcards globales, con parameter_mappings, con series.
|
||||
|
||||
### Fase 2: Pull (escribe directorio)
|
||||
|
||||
- [ ] **2.1** En `sync_pull.py::pull_dashboard()`, tras construir `body`, llamar `split_dashboard_payload(body)` y escribir:
|
||||
- `dashboards/{slug}/_dashboard.yaml`
|
||||
- `dashboards/{slug}/tab_{tab_slug}.yaml` por cada tab
|
||||
- [ ] **2.2** Si existe el legacy `dashboards/{slug}.yaml` en disco, dejarlo sin tocar pero preferir siempre el directory-based al leer (warning de migracion).
|
||||
- [ ] **2.3** Log de pull imprime los archivos escritos (cantidad de tabs + globales).
|
||||
|
||||
### Fase 3: Push (lee directorio y compone)
|
||||
|
||||
- [ ] **3.1** En `sync_push.py`, resolver path del item dashboard: si existe `dashboards/{slug}/_dashboard.yaml` usar directory-based, sino fallback a `dashboards/{slug}.yaml`.
|
||||
- [ ] **3.2** Cargar todos los `tab_*.yaml` del directorio. Para cada uno:
|
||||
- Verificar `_meta.parent_slug == slug`
|
||||
- Leer `payload.dashcards`, inyectar `dashboard_tab_id` desde el mapeo en `_dashboard.yaml::payload.tabs[*].id`
|
||||
- [ ] **3.3** Llamar `merge_dashboard_payload()` con `_dashboard.yaml` + todos los `tab_*.yaml` → payload unificado igual al del pull monolitico.
|
||||
- [ ] **3.4** Todo lo demas (freshness R17, count R18, R6 backup, `--patch` diff) sigue funcionando sin cambios.
|
||||
|
||||
### Fase 4: Validate + diff
|
||||
|
||||
- [ ] **4.1** `sync_validate.py` y `cmd_diff` resuelven el mismo path unificado via un helper `resolve_dashboard_path(project, slug) -> tuple[str, Path]` (`"legacy"|"split"`, path).
|
||||
- [ ] **4.2** Si es `split`, validar:
|
||||
- `_dashboard.yaml` existe y tiene `_meta.kind == 'dashboard'`
|
||||
- Cada `tab_*.yaml` referenciado en `tabs[*].file` existe en el directorio
|
||||
- Cada archivo de tab tiene `_meta.parent_slug == slug` y `_meta.tab_id` coincide con `_dashboard.yaml`
|
||||
- No hay archivos `tab_*.yaml` huerfanos (sin entrada en `tabs`)
|
||||
|
||||
### Fase 5: Backward-compat + migracion
|
||||
|
||||
- [ ] **5.1** Flag opcional en `pull`: `--split` (default true en configs nuevos). Variable de config `split_dashboards: true` en `config.yaml` del proyecto. Si `false`, sigue escribiendo monolitico (solo para casos legacy).
|
||||
- [ ] **5.2** Script `scripts/migrate_dashboards_to_split.py`: itera `dashboards/*.yaml` de un proyecto y los convierte a directory-based (usando `split_dashboard_payload()` sobre el YAML cargado — no toca Metabase).
|
||||
- [ ] **5.3** Probar migracion sobre aurgi: `dashboards/bi_ventas_portfolio_producto_en_construccion.yaml` → directorio.
|
||||
|
||||
### Fase 6: Tests end-to-end + docs
|
||||
|
||||
- [ ] **6.1** Test e2e con dashboard 734 (aurgi): pull (nueva estructura) → commit → editar una celda en `tab_5_min_view.yaml` (p.ej. renombrar description de una dashcard) → `push --patch --apply` → re-pull → diff debe mostrar SOLO la edicion deliberada.
|
||||
- [ ] **6.2** Actualizar `app.md` seccion "Crear cards y dashboards desde cero" con el nuevo layout de directorio.
|
||||
- [ ] **6.3** Añadir troubleshooting row a la tabla del readme: "archivo tab_xxx.yaml huerfano" / "tab en `_dashboard.yaml::tabs` sin archivo correspondiente".
|
||||
|
||||
## Ejemplo de uso
|
||||
|
||||
```bash
|
||||
# Pull bajo nuevo formato
|
||||
./main.py -p aurgi pull dashboard bi_ventas_portfolio_producto_en_construccion
|
||||
# [aurgi] pull dashboard bi_ventas_portfolio_producto_en_construccion (id=734) ->
|
||||
# projects/aurgi/dashboards/bi_ventas_portfolio_producto_en_construccion/
|
||||
# _dashboard.yaml (26 parametros, 11 tabs)
|
||||
# tab_5_min_view.yaml (7 dashcards)
|
||||
# tab_foto_centros_compara_semana.yaml (7 dashcards)
|
||||
# tab_foto_categorias_compara_semana.yaml (9 dashcards)
|
||||
# ... (8 mas)
|
||||
|
||||
# Edicion quirurgica: abrir solo la tab
|
||||
$EDITOR projects/aurgi/dashboards/bi_ventas_portfolio_producto_en_construccion/tab_5_min_view.yaml
|
||||
|
||||
# Diff acotado
|
||||
git diff --stat
|
||||
# projects/aurgi/dashboards/bi_ventas_portfolio_producto_en_construccion/tab_5_min_view.yaml | 12 ++++++------
|
||||
|
||||
# Push atomic, solo lo que cambio
|
||||
./main.py -p aurgi push dashboard bi_ventas_portfolio_producto_en_construccion --patch --apply
|
||||
# (patch) enviando 1 keys: ['dashcards']
|
||||
# aplicado.
|
||||
```
|
||||
|
||||
## Decisiones de diseno
|
||||
|
||||
- **Prefijo `tab_` obligatorio** en archivos de tab: permite que `ls dashboards/{slug}/tab_*.yaml` liste exactamente los tabs, separandolos de `_dashboard.yaml`. Sin prefijo seria ambiguo.
|
||||
- **`_dashboard.yaml` con guion bajo**: se ordena primero alfabeticamente al hacer `ls`, lo cual ayuda a la navegacion.
|
||||
- **`_meta.split_version: 1`** en `_dashboard.yaml`: abre la puerta a evoluciones del formato sin romper dashboards antiguos. Pull puede detectar versiones antiguas y migrar al vuelo.
|
||||
- **Mapping `tabs[*].file`** en `_dashboard.yaml` en vez de deducirlo del nombre: protege contra renames manuales y permite nombres de tab con caracteres raros.
|
||||
- **Dashcards globales en `_dashboard.yaml::payload.dashcards_globales`**: algunos dashboards (sobre todo legacy) tienen dashcards sin `dashboard_tab_id`. Separarlos del array de tabs evita perderlos.
|
||||
- **NO cambiar el schema del API de Metabase**: merge reconstruye el mismo JSON que el pull monolitico actual. Byte-a-byte equivalente.
|
||||
- **Legacy YAML monolitico sigue soportado**: un proyecto viejo con `dashboards/x.yaml` sigue pushable. La migracion es opcional / script-assisted.
|
||||
|
||||
## Prerequisitos
|
||||
|
||||
- Commit actual de `sync_pull.py` que preserva `id` en tabs (ya aplicado en esta sesion).
|
||||
- `state/index.json` con mapeos de slug→id intactos.
|
||||
|
||||
## Riesgos
|
||||
|
||||
| Riesgo | Impacto | Mitigacion |
|
||||
|---|---|---|
|
||||
| Dashboard con 2 tabs del mismo nombre | Colision de archivo | Slugify + sufijo `_2`, `_3` como en `_slug_for()`; registrar el file mapping en `_dashboard.yaml::tabs[*].file`, no deducirlo |
|
||||
| Usuario edita `_dashboard.yaml::tabs` y borra una entrada pero no el archivo `tab_*.yaml` | Archivo huerfano queda en disco | Validate avisa "archivo tab_xxx.yaml huerfano" en fase 4.2 |
|
||||
| Orden de dashcards en YAML no determinista entre pulls | Diffs falsos en git | Ordenar dashcards por `(row, col)` antes de escribir — idempotente |
|
||||
| Migracion masiva rompe dashboards de otros proyectos | Push falla en `push-all` | Migracion opcional via script, no automatica. Legacy sigue funcionando |
|
||||
| Dashboard con 0 tabs (solo dashcards root) | Solo `_dashboard.yaml` sin tabs | Soportado via `dashcards_globales`; validate acepta `tabs: []` |
|
||||
| Rename de tab en Metabase entre pulls | `tab_slug` cambia, archivo anterior queda huerfano | Detectar por `tab_id` en remoto: si coincide pero nombre cambio, renombrar archivo local (no borrar) |
|
||||
|
||||
## Verificacion final
|
||||
|
||||
- [ ] Pull de dashboard 734 (aurgi) genera el directorio con 11 tabs + `_dashboard.yaml`
|
||||
- [ ] `git diff` de una edicion en 1 tab solo muestra cambios en ese archivo
|
||||
- [ ] Push roundtrip: pull → push (sin cambios) → pull → diff vacio
|
||||
- [ ] Legacy monolitico (`dashboards/x.yaml` antiguo) sigue validable y pushable
|
||||
- [ ] `app.md` actualizado con el nuevo layout + ejemplo
|
||||
- [ ] Tests de `dashboard_split` pasan (round-trip + casos edge)
|
||||
@@ -29,3 +29,4 @@
|
||||
| [0021](0021-crud-generator.md) | CRUD Generator | pendiente | media | feature | — |
|
||||
| [0022](0022-init-pipelines.md) | Init Pipelines (scaffolding) | pendiente | alta | feature | — |
|
||||
| [0023](completed/0023-testing-utils.md) | Testing Utilities Go | completado | media | feature | — |
|
||||
| [0024](0024-dashboard-yaml-split-por-tab.md) | auto_metabase: split dashboard YAMLs por tab | pendiente | alta | mejora | — |
|
||||
|
||||
@@ -1,15 +1,35 @@
|
||||
from .client import MetabaseClient
|
||||
from .users import metabase_list_users, metabase_get_user, metabase_create_user, metabase_update_user, metabase_deactivate_user
|
||||
from .cards import metabase_list_cards, metabase_get_card, metabase_create_card, metabase_update_card, metabase_delete_card, metabase_execute_card, metabase_execute_query
|
||||
from .dashboards import metabase_list_dashboards, metabase_get_dashboard, metabase_create_dashboard, metabase_update_dashboard, metabase_delete_dashboard
|
||||
from .cards import metabase_list_cards, metabase_get_card, metabase_create_card, metabase_update_card, metabase_delete_card, metabase_execute_card, metabase_execute_query, metabase_copy_card, metabase_move_card
|
||||
from .dashboards import metabase_list_dashboards, metabase_get_dashboard, metabase_create_dashboard, metabase_update_dashboard, metabase_delete_dashboard, metabase_copy_dashboard, metabase_move_dashboard
|
||||
from .databases import metabase_list_databases, metabase_add_database, metabase_get_database
|
||||
from .documents import metabase_list_documents, metabase_get_document, metabase_create_document, metabase_update_document, metabase_archive_document, metabase_delete_document, metabase_list_document_comments, metabase_create_document_comment, metabase_resolve_document_comment, metabase_move_document, metabase_copy_document
|
||||
from .collections import metabase_move_collection
|
||||
from .permissions import metabase_list_groups, metabase_get_group, metabase_create_group, metabase_update_group, metabase_delete_group, metabase_list_memberships, metabase_add_membership, metabase_delete_membership, metabase_get_permission_graph, metabase_update_permission_graph, metabase_get_collection_graph, metabase_update_collection_graph
|
||||
from .setup import metabase_setup
|
||||
from .maintenance import metabase_fix_null_ratio, metabase_pair_n_n1_columns
|
||||
from .metabase_mbql_validate import metabase_mbql_validate
|
||||
from .metabase_update_dashboard_safe import metabase_update_dashboard_safe
|
||||
|
||||
__all__ = [
|
||||
"MetabaseClient",
|
||||
"metabase_list_users", "metabase_get_user", "metabase_create_user", "metabase_update_user", "metabase_deactivate_user",
|
||||
"metabase_list_cards", "metabase_get_card", "metabase_create_card", "metabase_update_card", "metabase_delete_card", "metabase_execute_card", "metabase_execute_query",
|
||||
"metabase_copy_card", "metabase_move_card",
|
||||
"metabase_list_dashboards", "metabase_get_dashboard", "metabase_create_dashboard", "metabase_update_dashboard", "metabase_delete_dashboard",
|
||||
"metabase_copy_dashboard", "metabase_move_dashboard",
|
||||
"metabase_list_databases", "metabase_add_database", "metabase_get_database",
|
||||
"metabase_list_documents", "metabase_get_document", "metabase_create_document", "metabase_update_document", "metabase_archive_document", "metabase_delete_document",
|
||||
"metabase_list_document_comments", "metabase_create_document_comment", "metabase_resolve_document_comment",
|
||||
"metabase_move_document", "metabase_copy_document",
|
||||
"metabase_move_collection",
|
||||
"metabase_list_groups", "metabase_get_group", "metabase_create_group", "metabase_update_group", "metabase_delete_group",
|
||||
"metabase_list_memberships", "metabase_add_membership", "metabase_delete_membership",
|
||||
"metabase_get_permission_graph", "metabase_update_permission_graph",
|
||||
"metabase_get_collection_graph", "metabase_update_collection_graph",
|
||||
"metabase_setup",
|
||||
"metabase_fix_null_ratio",
|
||||
"metabase_pair_n_n1_columns",
|
||||
"metabase_mbql_validate",
|
||||
"metabase_update_dashboard_safe",
|
||||
]
|
||||
|
||||
@@ -225,3 +225,132 @@ def metabase_execute_query(
|
||||
"max-results-bare-rows": max_results,
|
||||
}
|
||||
return client.request("POST", "/api/dataset", json=body)
|
||||
|
||||
|
||||
def metabase_copy_card(
|
||||
client: MetabaseClient,
|
||||
card_id: int,
|
||||
name: str | None = None,
|
||||
collection_id: int | None = None,
|
||||
description: str | None = None,
|
||||
) -> dict:
|
||||
"""Crea una copia de una card/pregunta existente en Metabase.
|
||||
|
||||
Endpoint: POST /api/card/:id/copy. Usa el endpoint nativo de Metabase para
|
||||
duplicar la card, copiando dataset_query, display y visualization_settings.
|
||||
Los campos name, collection_id y description se pueden sobrescribir via body.
|
||||
|
||||
Args:
|
||||
client: Cliente autenticado.
|
||||
card_id: ID de la card a copiar.
|
||||
name: Nombre para la copia. None = Metabase asigna "Copy of <nombre>".
|
||||
collection_id: Coleccion destino. None = misma coleccion que el original.
|
||||
description: Descripcion de la copia. None = misma que el original.
|
||||
|
||||
Returns:
|
||||
Dict con la card nueva creada por Metabase. Incluye el campo `id`
|
||||
asignado a la copia y todos los campos heredados del original.
|
||||
|
||||
Example:
|
||||
>>> copy = metabase_copy_card(client, 42)
|
||||
>>> print(copy["id"], copy["name"]) # "Copy of ..."
|
||||
>>> # Copiar a otra coleccion con nombre propio:
|
||||
>>> copy = metabase_copy_card(client, 42, name="Revenue Q2", collection_id=7)
|
||||
"""
|
||||
body: dict = {}
|
||||
if name is not None:
|
||||
body["name"] = name
|
||||
if collection_id is not None:
|
||||
body["collection_id"] = collection_id
|
||||
if description is not None:
|
||||
body["description"] = description
|
||||
return client.request("POST", f"/api/card/{card_id}/copy", json=body or None)
|
||||
|
||||
|
||||
def metabase_move_card(
|
||||
client: MetabaseClient,
|
||||
card_id: int,
|
||||
collection_id: int | None,
|
||||
) -> dict:
|
||||
"""Mueve una card/pregunta a otra coleccion.
|
||||
|
||||
Wrapper thin sobre PUT /api/card/:id que solo actualiza collection_id.
|
||||
Equivalente a metabase_update_card(client, card_id, collection_id=...) pero
|
||||
con intencion explicita y soporte para mover a root con None.
|
||||
|
||||
Endpoint: PUT /api/card/:id.
|
||||
|
||||
Args:
|
||||
client: Cliente autenticado.
|
||||
card_id: ID de la card a mover.
|
||||
collection_id: ID de la coleccion destino. None mueve a "Our analytics" (root).
|
||||
|
||||
Returns:
|
||||
Dict con la card actualizada, incluyendo el nuevo collection_id.
|
||||
|
||||
Example:
|
||||
>>> card = metabase_move_card(client, 42, collection_id=7)
|
||||
>>> print(card["collection_id"]) # 7
|
||||
>>> # Mover a root:
|
||||
>>> card = metabase_move_card(client, 42, collection_id=None)
|
||||
"""
|
||||
return client.request("PUT", f"/api/card/{card_id}", json={"collection_id": collection_id})
|
||||
|
||||
|
||||
def metabase_create_card_raw(client: MetabaseClient, payload: dict) -> dict:
|
||||
"""Crea una card en Metabase con payload completo ya construido por el caller.
|
||||
|
||||
Version raw de metabase_create_card. El caller es responsable de construir
|
||||
el payload completo antes de llamar a esta funcion — no se realiza ninguna
|
||||
validacion ni transformacion local. Util para flujos "Metabase as code"
|
||||
donde el YAML define todos los campos de la card tal como los espera la API.
|
||||
|
||||
Endpoint: POST /api/card.
|
||||
|
||||
El payload minimo necesita:
|
||||
- name (str): nombre de la card.
|
||||
- dataset_query (dict): query SQL nativa o MBQL.
|
||||
- display (str): tipo de visualizacion (table, bar, scalar, etc.).
|
||||
|
||||
Campos opcionales que esta funcion preserva (a diferencia de metabase_create_card):
|
||||
- visualization_settings (dict): configuracion detallada del grafico.
|
||||
- parameters (list[dict]): parametros de la query con template tags.
|
||||
- parameter_mappings (list[dict]): mapeo de parametros a dashboard filters.
|
||||
- type (str): "question" (default), "model", "metric".
|
||||
- collection_id (int): ID de coleccion destino.
|
||||
- description (str): descripcion de la card.
|
||||
- archived (bool): estado de archivo inicial.
|
||||
- enable_embedding (bool): habilitar embedding publico.
|
||||
- embedding_params (dict): configuracion de embedding.
|
||||
|
||||
Si Metabase devuelve 4xx/5xx, httpx lanza HTTPStatusError sin capturar.
|
||||
|
||||
Args:
|
||||
client: Cliente autenticado con sesion activa.
|
||||
payload: Dict con el payload completo de la card tal como lo espera
|
||||
la API de Metabase. Se envia sin modificaciones.
|
||||
|
||||
Returns:
|
||||
Dict con la card recien creada. Incluye el campo `id` asignado por
|
||||
Metabase y todos los campos normalizados (display, dataset_query,
|
||||
visualization_settings, created_at, etc.).
|
||||
|
||||
Example:
|
||||
>>> card = metabase_create_card_raw(client, {
|
||||
... "name": "Revenue by Month",
|
||||
... "dataset_query": {
|
||||
... "database": 1,
|
||||
... "type": "native",
|
||||
... "native": {"query": "SELECT date_trunc('month', created_at), SUM(total) FROM orders GROUP BY 1"},
|
||||
... },
|
||||
... "display": "line",
|
||||
... "visualization_settings": {
|
||||
... "graph.x_axis.title_text": "Month",
|
||||
... "graph.y_axis.title_text": "Revenue",
|
||||
... },
|
||||
... "description": "Monthly revenue trend",
|
||||
... "collection_id": 5,
|
||||
... })
|
||||
>>> print(card["id"]) # ID asignado por Metabase
|
||||
"""
|
||||
return client.request("POST", "/api/card", json=payload)
|
||||
|
||||
@@ -12,16 +12,19 @@ class MetabaseClient:
|
||||
_http: Cliente httpx reutilizable con headers de auth.
|
||||
"""
|
||||
|
||||
def __init__(self, base_url: str, token: str) -> None:
|
||||
def __init__(self, base_url: str, token: str, timeout: float = 120.0) -> None:
|
||||
self.base_url = base_url.rstrip("/")
|
||||
self.token = token
|
||||
# API keys de Metabase empiezan por "mb_" y usan X-API-KEY.
|
||||
# Session tokens usan X-Metabase-Session.
|
||||
auth_header = "X-API-KEY" if token.startswith("mb_") else "X-Metabase-Session"
|
||||
self._http = httpx.Client(
|
||||
base_url=self.base_url,
|
||||
headers={
|
||||
"Content-Type": "application/json",
|
||||
"X-Metabase-Session": token,
|
||||
auth_header: token,
|
||||
},
|
||||
timeout=30.0,
|
||||
timeout=timeout,
|
||||
)
|
||||
|
||||
def request(self, method: str, path: str, **kwargs) -> dict | list | None:
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
"""CRUD y operaciones sobre collections de Metabase."""
|
||||
|
||||
from .client import MetabaseClient
|
||||
|
||||
|
||||
def metabase_move_collection(
|
||||
client: MetabaseClient,
|
||||
collection_id: int,
|
||||
parent_id: int | None,
|
||||
) -> dict:
|
||||
"""Mueve una collection (sub-arbol completo) a otro padre.
|
||||
|
||||
Endpoint: PUT /api/collection/:id con {parent_id: ...}.
|
||||
parent_id=None mueve la collection a la raiz ("Our analytics").
|
||||
|
||||
Metabase reubica la collection y todo su sub-arbol (colecciones hijas,
|
||||
cards, dashboards, documents) atomicamente.
|
||||
|
||||
Args:
|
||||
client: Cliente autenticado.
|
||||
collection_id: ID de la collection a mover.
|
||||
parent_id: ID de la collection padre destino. None = raiz.
|
||||
|
||||
Returns:
|
||||
Collection actualizada con el nuevo parent_id y location.
|
||||
|
||||
Example:
|
||||
>>> col = metabase_move_collection(client, 12, parent_id=3)
|
||||
>>> print(col["location"]) # "/3/"
|
||||
|
||||
>>> # Mover a raiz:
|
||||
>>> col = metabase_move_collection(client, 12, parent_id=None)
|
||||
>>> print(col["location"]) # "/"
|
||||
"""
|
||||
return client.request(
|
||||
"PUT",
|
||||
f"/api/collection/{collection_id}",
|
||||
json={"parent_id": parent_id},
|
||||
)
|
||||
@@ -141,3 +141,162 @@ def metabase_delete_dashboard(client: MetabaseClient, dashboard_id: int) -> None
|
||||
>>> # Preferir: metabase_update_dashboard(client, 1, archived=True)
|
||||
"""
|
||||
client.request("DELETE", f"/api/dashboard/{dashboard_id}")
|
||||
|
||||
|
||||
def metabase_copy_dashboard(
|
||||
client: MetabaseClient,
|
||||
dashboard_id: int,
|
||||
name: str | None = None,
|
||||
collection_id: int | None = None,
|
||||
description: str | None = None,
|
||||
is_deep_copy: bool = False,
|
||||
) -> dict:
|
||||
"""Crea una copia de un dashboard existente en Metabase.
|
||||
|
||||
Endpoint: POST /api/dashboard/:id/copy. Usa el endpoint nativo de Metabase para
|
||||
duplicar el dashboard junto con su layout de dashcards y parametros.
|
||||
Con is_deep_copy=True tambien clona las cards referenciadas.
|
||||
|
||||
Args:
|
||||
client: Cliente autenticado.
|
||||
dashboard_id: ID del dashboard a copiar.
|
||||
name: Nombre para la copia. None = Metabase asigna "Copy of <nombre>".
|
||||
collection_id: Coleccion destino. None = misma coleccion que el original.
|
||||
description: Descripcion de la copia. None = misma que el original.
|
||||
is_deep_copy: Si True, clona tambien todas las cards referenciadas por el
|
||||
dashboard (deep copy). Si False, la copia referencia las cards originales.
|
||||
|
||||
Returns:
|
||||
Dict con el dashboard nuevo creado por Metabase. Incluye el campo `id`
|
||||
asignado a la copia y el layout de dashcards copiado.
|
||||
|
||||
Example:
|
||||
>>> copy = metabase_copy_dashboard(client, 1)
|
||||
>>> print(copy["id"], copy["name"]) # "Copy of ..."
|
||||
>>> # Deep copy a otra coleccion:
|
||||
>>> copy = metabase_copy_dashboard(client, 1, name="Sales Q2", collection_id=7, is_deep_copy=True)
|
||||
"""
|
||||
body: dict = {"is_deep_copy": is_deep_copy}
|
||||
if name is not None:
|
||||
body["name"] = name
|
||||
if collection_id is not None:
|
||||
body["collection_id"] = collection_id
|
||||
if description is not None:
|
||||
body["description"] = description
|
||||
return client.request("POST", f"/api/dashboard/{dashboard_id}/copy", json=body)
|
||||
|
||||
|
||||
def metabase_move_dashboard(
|
||||
client: MetabaseClient,
|
||||
dashboard_id: int,
|
||||
collection_id: int | None,
|
||||
) -> dict:
|
||||
"""Mueve un dashboard a otra coleccion.
|
||||
|
||||
Wrapper thin sobre PUT /api/dashboard/:id que solo actualiza collection_id.
|
||||
Equivalente a metabase_update_dashboard(client, id, collection_id=...) pero
|
||||
con intencion explicita y soporte para mover a root con None.
|
||||
|
||||
Endpoint: PUT /api/dashboard/:id.
|
||||
|
||||
Args:
|
||||
client: Cliente autenticado.
|
||||
dashboard_id: ID del dashboard a mover.
|
||||
collection_id: ID de la coleccion destino. None mueve a "Our analytics" (root).
|
||||
|
||||
Returns:
|
||||
Dict con el dashboard actualizado, incluyendo el nuevo collection_id.
|
||||
|
||||
Example:
|
||||
>>> dash = metabase_move_dashboard(client, 1, collection_id=7)
|
||||
>>> print(dash["collection_id"]) # 7
|
||||
>>> # Mover a root:
|
||||
>>> dash = metabase_move_dashboard(client, 1, collection_id=None)
|
||||
"""
|
||||
return client.request("PUT", f"/api/dashboard/{dashboard_id}", json={"collection_id": collection_id})
|
||||
|
||||
|
||||
def metabase_create_dashboard_raw(client: MetabaseClient, payload: dict) -> dict:
|
||||
"""Crea un dashboard en Metabase con payload completo ya construido por el caller.
|
||||
|
||||
Version raw de metabase_create_dashboard. El caller es responsable de
|
||||
construir el payload completo — no se realiza validacion ni transformacion
|
||||
local. Util para flujos "Metabase as code" donde el YAML define todos los
|
||||
campos del dashboard tal como los espera la API.
|
||||
|
||||
COMPORTAMIENTO EN DOS PASOS (limitacion de la API de Metabase):
|
||||
El endpoint POST /api/dashboard NO acepta `dashcards` en el body inicial;
|
||||
solo crea el dashboard vacio. Para añadir cards es necesario un PUT posterior.
|
||||
Esta funcion maneja esa limitacion automaticamente:
|
||||
|
||||
1. Si el payload contiene `dashcards`, se extraen antes del POST.
|
||||
2. POST /api/dashboard crea el dashboard vacio (sin dashcards).
|
||||
3. Si habia dashcards, PUT /api/dashboard/:id con {"dashcards": [...]}
|
||||
las añade al dashboard recien creado.
|
||||
4. Retorna la respuesta del PUT (con dashcards pobladas), o la del POST
|
||||
si el payload original no contenia dashcards.
|
||||
|
||||
Endpoint inicial: POST /api/dashboard.
|
||||
Endpoint secundario (condicional): PUT /api/dashboard/:id.
|
||||
|
||||
El payload puede incluir:
|
||||
- name (str, requerido): nombre del dashboard.
|
||||
- description (str): descripcion.
|
||||
- collection_id (int): ID de coleccion destino.
|
||||
- parameters (list[dict]): filtros/parametros del dashboard.
|
||||
- dashcards (list[dict]): cards posicionadas. Cada dashcard necesita:
|
||||
id (int negativo para nuevas, ej: -1, -2),
|
||||
card_id (int), size_x (int), size_y (int), col (int), row (int).
|
||||
Campos opcionales: visualization_settings, parameter_mappings.
|
||||
- tabs (list[dict]): pestañas del dashboard.
|
||||
- enable_embedding (bool): habilitar embedding publico.
|
||||
- embedding_params (dict): configuracion de embedding.
|
||||
|
||||
Si Metabase devuelve 4xx/5xx en cualquier paso, httpx lanza HTTPStatusError.
|
||||
|
||||
Args:
|
||||
client: Cliente autenticado con sesion activa.
|
||||
payload: Dict con el payload completo del dashboard tal como lo espera
|
||||
la API de Metabase. Puede incluir dashcards.
|
||||
|
||||
Returns:
|
||||
Dict con el dashboard creado. Si habia dashcards, la respuesta es la
|
||||
del PUT final e incluye el campo `dashcards` con las cards posicionadas.
|
||||
Si no habia dashcards, es la respuesta del POST inicial.
|
||||
|
||||
Example:
|
||||
>>> dash = metabase_create_dashboard_raw(client, {
|
||||
... "name": "Sales Overview",
|
||||
... "description": "KPIs de ventas mensuales",
|
||||
... "collection_id": 5,
|
||||
... "parameters": [],
|
||||
... "dashcards": [
|
||||
... {
|
||||
... "id": -1,
|
||||
... "card_id": 42,
|
||||
... "size_x": 6,
|
||||
... "size_y": 4,
|
||||
... "col": 0,
|
||||
... "row": 0,
|
||||
... "visualization_settings": {},
|
||||
... "parameter_mappings": [],
|
||||
... },
|
||||
... ],
|
||||
... })
|
||||
>>> print(dash["id"]) # ID asignado por Metabase
|
||||
>>> print(len(dash["dashcards"])) # 1
|
||||
"""
|
||||
body = {k: v for k, v in payload.items() if k != "dashcards"}
|
||||
dashcards = payload.get("dashcards")
|
||||
|
||||
created = client.request("POST", "/api/dashboard", json=body)
|
||||
|
||||
if dashcards:
|
||||
dashboard_id = created["id"]
|
||||
return client.request(
|
||||
"PUT",
|
||||
f"/api/dashboard/{dashboard_id}",
|
||||
json={"dashcards": dashcards},
|
||||
)
|
||||
|
||||
return created
|
||||
|
||||
@@ -0,0 +1,319 @@
|
||||
"""CRUD de documents de Metabase (feature reciente, Metabase 0.57+).
|
||||
|
||||
Un document es un texto editable en Metabase (tipo Notion) serializado como
|
||||
ProseMirror JSON (content_type: application/json+vnd.prose-mirror). Permite
|
||||
embeber cards, smart links y flex containers.
|
||||
|
||||
Nodos ProseMirror soportados (observados en Metabase v0.59):
|
||||
- doc, paragraph, heading (attrs.level 1-6), text
|
||||
- bulletList, orderedList, listItem
|
||||
- blockquote, codeBlock (attrs.language), horizontalRule, hardBreak
|
||||
- cardEmbed (attrs.id — card_id), smartLink (attrs.entityId),
|
||||
flexContainer (attrs.columnWidths), resizeNode, mention
|
||||
- image, iframe, table/tableRow/tableCell, callout, taskList/taskItem, details
|
||||
|
||||
Marks:
|
||||
- bold, italic, strike, code, link (attrs.href), underline,
|
||||
highlight, subscript, textStyle
|
||||
"""
|
||||
|
||||
from .client import MetabaseClient
|
||||
|
||||
|
||||
def metabase_list_documents(client: MetabaseClient) -> list[dict]:
|
||||
"""Lista documents de Metabase.
|
||||
|
||||
Endpoint: GET /api/document. Retorna `{"items": [...]}` segun la API.
|
||||
|
||||
Args:
|
||||
client: Cliente autenticado.
|
||||
|
||||
Returns:
|
||||
Lista de dicts con: id, name, collection_id, archived, created_at,
|
||||
updated_at, creator_id, content_type, entity_id.
|
||||
|
||||
Example:
|
||||
>>> docs = metabase_list_documents(client)
|
||||
>>> for d in docs:
|
||||
... print(d["id"], d["name"])
|
||||
"""
|
||||
resp = client.request("GET", "/api/document")
|
||||
if isinstance(resp, dict) and "items" in resp:
|
||||
return resp["items"]
|
||||
return resp or []
|
||||
|
||||
|
||||
def metabase_get_document(client: MetabaseClient, document_id: int) -> dict:
|
||||
"""Obtiene un document completo con su contenido ProseMirror.
|
||||
|
||||
Endpoint: GET /api/document/:id.
|
||||
|
||||
Args:
|
||||
client: Cliente autenticado.
|
||||
document_id: ID del document.
|
||||
|
||||
Returns:
|
||||
Dict con: id, name, document (ProseMirror tree), collection_id,
|
||||
archived, creator, created_at, updated_at, entity_id, content_type.
|
||||
|
||||
Example:
|
||||
>>> doc = metabase_get_document(client, 1)
|
||||
>>> print(doc["name"])
|
||||
>>> tree = doc["document"] # {"type": "doc", "content": [...]}
|
||||
"""
|
||||
return client.request("GET", f"/api/document/{document_id}")
|
||||
|
||||
|
||||
def metabase_create_document(
|
||||
client: MetabaseClient,
|
||||
name: str,
|
||||
document: dict,
|
||||
collection_id: int = 0,
|
||||
) -> dict:
|
||||
"""Crea un document nuevo.
|
||||
|
||||
Endpoint: POST /api/document.
|
||||
|
||||
Args:
|
||||
client: Cliente autenticado.
|
||||
name: Titulo del document (1-254 chars, no blank).
|
||||
document: Arbol ProseMirror JSON. Formato minimo:
|
||||
{"type": "doc", "content": [{"type": "paragraph", "content": [...]}]}
|
||||
O cadena vacia "" si se quiere arrancar en blanco.
|
||||
collection_id: ID de coleccion destino. 0 = root.
|
||||
|
||||
Returns:
|
||||
Document creado con su id asignado.
|
||||
|
||||
Example:
|
||||
>>> doc = metabase_create_document(client, "Notas", {
|
||||
... "type": "doc",
|
||||
... "content": [{
|
||||
... "type": "paragraph",
|
||||
... "content": [{"type": "text", "text": "Hola mundo"}]
|
||||
... }]
|
||||
... })
|
||||
"""
|
||||
body: dict = {"name": name, "document": document}
|
||||
if collection_id > 0:
|
||||
body["collection_id"] = collection_id
|
||||
return client.request("POST", "/api/document", json=body)
|
||||
|
||||
|
||||
def metabase_update_document(
|
||||
client: MetabaseClient,
|
||||
document_id: int,
|
||||
**fields,
|
||||
) -> dict:
|
||||
"""Actualiza un document. Solo se envian los campos pasados.
|
||||
|
||||
Endpoint: PUT /api/document/:id.
|
||||
|
||||
Campos tipicos: name, document, collection_id, archived.
|
||||
|
||||
Args:
|
||||
client: Cliente autenticado.
|
||||
document_id: ID del document a actualizar.
|
||||
**fields: Campos a modificar.
|
||||
|
||||
Returns:
|
||||
Document actualizado.
|
||||
|
||||
Example:
|
||||
>>> metabase_update_document(client, 1, name="Nuevo titulo")
|
||||
>>> metabase_update_document(client, 1, document={"type":"doc","content":[...]})
|
||||
"""
|
||||
return client.request("PUT", f"/api/document/{document_id}", json=fields)
|
||||
|
||||
|
||||
def metabase_archive_document(client: MetabaseClient, document_id: int) -> dict:
|
||||
"""Archiva un document (equivalente a PUT con archived=True).
|
||||
|
||||
Metabase exige archive previo para poder eliminar.
|
||||
|
||||
Args:
|
||||
client: Cliente autenticado.
|
||||
document_id: ID del document.
|
||||
|
||||
Returns:
|
||||
Document con archived=True.
|
||||
"""
|
||||
return client.request("PUT", f"/api/document/{document_id}", json={"archived": True})
|
||||
|
||||
|
||||
def metabase_list_document_comments(
|
||||
client: MetabaseClient,
|
||||
document_id: int,
|
||||
*,
|
||||
include_resolved: bool = True,
|
||||
include_deleted: bool = False,
|
||||
) -> list[dict]:
|
||||
"""Lista los comentarios de un document.
|
||||
|
||||
Endpoint: GET /api/comment?target_type=document&target_id=:id.
|
||||
|
||||
Un comentario puede estar anclado a un bloque especifico via
|
||||
`child_target_id` (UUID que matchea `attrs._id` de un parrafo) o al
|
||||
document entero si `child_target_id` es null. Las respuestas tipo thread
|
||||
cuelgan via `parent_comment_id`.
|
||||
|
||||
Args:
|
||||
client: Cliente autenticado.
|
||||
document_id: ID del document.
|
||||
include_resolved: si False, filtra comentarios con is_resolved=True.
|
||||
include_deleted: si False, filtra comentarios con deleted_at no null.
|
||||
|
||||
Returns:
|
||||
Lista de dicts con: id, content (arbol ProseMirror), creator, creator_id,
|
||||
target_type, target_id, child_target_id, parent_comment_id, is_resolved,
|
||||
deleted_at, reactions, created_at, updated_at.
|
||||
|
||||
Example:
|
||||
>>> comments = metabase_list_document_comments(client, 29)
|
||||
>>> for c in comments:
|
||||
... text = _comment_plaintext(c["content"])
|
||||
... print(f'{c["creator"]["common_name"]}: {text}')
|
||||
"""
|
||||
resp = client.request(
|
||||
"GET",
|
||||
"/api/comment",
|
||||
params={"target_type": "document", "target_id": document_id},
|
||||
)
|
||||
items = resp.get("comments", []) if isinstance(resp, dict) else []
|
||||
if not include_resolved:
|
||||
items = [c for c in items if not c.get("is_resolved")]
|
||||
if not include_deleted:
|
||||
items = [c for c in items if c.get("deleted_at") is None]
|
||||
return items
|
||||
|
||||
|
||||
def metabase_create_document_comment(
|
||||
client: MetabaseClient,
|
||||
document_id: int,
|
||||
content: dict,
|
||||
*,
|
||||
child_target_id: str | None = None,
|
||||
parent_comment_id: int | None = None,
|
||||
) -> dict:
|
||||
"""Crea un comentario en un document.
|
||||
|
||||
Endpoint: POST /api/comment.
|
||||
|
||||
Args:
|
||||
client: Cliente autenticado.
|
||||
document_id: ID del document donde comentar.
|
||||
content: Arbol ProseMirror del comentario. Ejemplo minimo:
|
||||
{"type":"doc","content":[{"type":"paragraph","content":[
|
||||
{"type":"text","text":"mi comentario"}]}]}
|
||||
child_target_id: UUID del bloque del document al que se ancla el
|
||||
comentario (matchea `attrs._id` de un parrafo). None = a nivel doc.
|
||||
parent_comment_id: id del comentario al que se responde. None = top-level.
|
||||
|
||||
Returns:
|
||||
Comentario creado.
|
||||
"""
|
||||
body: dict = {
|
||||
"target_type": "document",
|
||||
"target_id": document_id,
|
||||
"content": content,
|
||||
}
|
||||
if child_target_id is not None:
|
||||
body["child_target_id"] = child_target_id
|
||||
if parent_comment_id is not None:
|
||||
body["parent_comment_id"] = parent_comment_id
|
||||
return client.request("POST", "/api/comment", json=body)
|
||||
|
||||
|
||||
def metabase_resolve_document_comment(client: MetabaseClient, comment_id: int) -> dict:
|
||||
"""Marca un comentario como resuelto (is_resolved=True).
|
||||
|
||||
Endpoint: PUT /api/comment/:id.
|
||||
"""
|
||||
return client.request("PUT", f"/api/comment/{comment_id}", json={"is_resolved": True})
|
||||
|
||||
|
||||
def metabase_delete_document(client: MetabaseClient, document_id: int) -> None:
|
||||
"""Elimina un document. REQUIERE archivarlo primero.
|
||||
|
||||
Endpoint: DELETE /api/document/:id. Si el document no esta archivado,
|
||||
Metabase responde: "Document must be archived before it can be deleted."
|
||||
|
||||
Args:
|
||||
client: Cliente autenticado.
|
||||
document_id: ID del document a eliminar.
|
||||
"""
|
||||
client.request("DELETE", f"/api/document/{document_id}")
|
||||
|
||||
|
||||
def metabase_move_document(
|
||||
client: MetabaseClient,
|
||||
document_id: int,
|
||||
collection_id: int | None,
|
||||
) -> dict:
|
||||
"""Mueve un document a otra coleccion.
|
||||
|
||||
Thin wrapper sobre metabase_update_document: envia solo collection_id.
|
||||
Endpoint: PUT /api/document/:id con {collection_id: ...}.
|
||||
|
||||
Args:
|
||||
client: Cliente autenticado.
|
||||
document_id: ID del document a mover.
|
||||
collection_id: ID de coleccion destino. None mueve a la raiz ("Our analytics").
|
||||
|
||||
Returns:
|
||||
Document actualizado con el nuevo collection_id.
|
||||
|
||||
Example:
|
||||
>>> doc = metabase_move_document(client, 42, collection_id=7)
|
||||
>>> print(doc["collection_id"]) # 7
|
||||
>>> # Mover a raiz:
|
||||
>>> doc = metabase_move_document(client, 42, collection_id=None)
|
||||
"""
|
||||
return client.request(
|
||||
"PUT",
|
||||
f"/api/document/{document_id}",
|
||||
json={"collection_id": collection_id},
|
||||
)
|
||||
|
||||
|
||||
def metabase_copy_document(
|
||||
client: MetabaseClient,
|
||||
document_id: int,
|
||||
name: str | None = None,
|
||||
collection_id: int | None = None,
|
||||
) -> dict:
|
||||
"""Copia un document (Metabase no tiene endpoint nativo).
|
||||
|
||||
Obtiene el document original con metabase_get_document, luego crea uno
|
||||
nuevo con metabase_create_document clonando el contenido ProseMirror.
|
||||
|
||||
Si name=None usa "{original_name} (copia)".
|
||||
Si collection_id=None copia a la misma coleccion del original.
|
||||
|
||||
Realiza 2 requests HTTP: GET /api/document/:id + POST /api/document.
|
||||
|
||||
Args:
|
||||
client: Cliente autenticado.
|
||||
document_id: ID del document a copiar.
|
||||
name: Nombre del nuevo document. None = "{original} (copia)".
|
||||
collection_id: Coleccion destino. None = misma coleccion del original.
|
||||
|
||||
Returns:
|
||||
Document nuevo recien creado con su id asignado.
|
||||
|
||||
Example:
|
||||
>>> copy = metabase_copy_document(client, 42)
|
||||
>>> print(copy["name"]) # "Mi documento (copia)"
|
||||
>>> print(copy["id"]) # nuevo ID
|
||||
|
||||
>>> # Clonar a otra coleccion con nombre personalizado:
|
||||
>>> copy = metabase_copy_document(client, 42, name="Backup Q1", collection_id=5)
|
||||
"""
|
||||
original = metabase_get_document(client, document_id)
|
||||
new_name = name if name is not None else f"{original['name']} (copia)"
|
||||
dest_collection = collection_id if collection_id is not None else original.get("collection_id", 0)
|
||||
doc_content = original.get("document", "")
|
||||
body: dict = {"name": new_name, "document": doc_content}
|
||||
if dest_collection:
|
||||
body["collection_id"] = dest_collection
|
||||
return client.request("POST", "/api/document", json=body)
|
||||
@@ -0,0 +1,419 @@
|
||||
"""Mantenimiento y reparacion de cards MBQL de Metabase."""
|
||||
|
||||
import copy
|
||||
import time
|
||||
import uuid
|
||||
|
||||
from .client import MetabaseClient
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers internos compartidos
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _new_uuid() -> str:
|
||||
return str(uuid.uuid4())
|
||||
|
||||
|
||||
def _field_name_of(node) -> tuple[str | None, str | None]:
|
||||
"""Extrae (name, kind) de un node ['field'|'expression', meta, 'name']."""
|
||||
if isinstance(node, list) and len(node) >= 3 and node[0] in ("field", "expression"):
|
||||
nm = node[-1]
|
||||
if isinstance(nm, str):
|
||||
return nm, node[0]
|
||||
return None, None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# metabase_fix_null_ratio
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _analyze_stage_null_ratio(stage: dict) -> tuple[dict, dict]:
|
||||
"""Detecta slots vulnerables al patron SUM(a-b)/SUM(b) en una stage MBQL.
|
||||
|
||||
Devuelve:
|
||||
vulnerable: {slot_diff: (slot_a, slot_b, expr_name)}
|
||||
subtractions: {expr_name: (op_a_name, op_b_name)}
|
||||
"""
|
||||
subtractions = {}
|
||||
for e in stage.get("expressions", []) or []:
|
||||
if not (isinstance(e, list) and len(e) == 4 and e[0] == "-"):
|
||||
continue
|
||||
meta = e[1] if isinstance(e[1], dict) else {}
|
||||
name = meta.get("lib/expression-name")
|
||||
if not name:
|
||||
continue
|
||||
a_name, a_kind = _field_name_of(e[2])
|
||||
b_name, b_kind = _field_name_of(e[3])
|
||||
if a_name and b_name and a_kind == "field" and b_kind == "field":
|
||||
subtractions[name] = (a_name, b_name)
|
||||
|
||||
aggs = stage.get("aggregation", []) or []
|
||||
func_counts: dict[str, int] = {}
|
||||
sum_field_to_slot: dict[str, str] = {}
|
||||
sum_expr_to_slot: dict[str, str] = {}
|
||||
for agg in aggs:
|
||||
if not isinstance(agg, list) or not agg:
|
||||
continue
|
||||
func = agg[0]
|
||||
func_counts[func] = func_counts.get(func, 0) + 1
|
||||
slot = func if func_counts[func] == 1 else f"{func}_{func_counts[func]}"
|
||||
if func == "sum" and len(agg) >= 3:
|
||||
operand = agg[2]
|
||||
nm, kind = _field_name_of(operand)
|
||||
if kind == "field" and nm:
|
||||
sum_field_to_slot[nm] = slot
|
||||
elif kind == "expression" and nm:
|
||||
sum_expr_to_slot[nm] = slot
|
||||
|
||||
vulnerable = {}
|
||||
for expr_name, slot_diff in sum_expr_to_slot.items():
|
||||
if expr_name not in subtractions:
|
||||
continue
|
||||
op_a, op_b = subtractions[expr_name]
|
||||
slot_a = sum_field_to_slot.get(op_a)
|
||||
slot_b = sum_field_to_slot.get(op_b)
|
||||
if slot_a and slot_b:
|
||||
vulnerable[slot_diff] = (slot_a, slot_b, expr_name)
|
||||
return vulnerable, subtractions
|
||||
|
||||
|
||||
def _rewrite_field_refs(node, slot_map: dict):
|
||||
"""Reemplaza recursivamente [field, meta, slot] donde slot in slot_map
|
||||
por [-, new_meta, [field, ..., slot_a], [field, ..., slot_b]]."""
|
||||
if isinstance(node, list):
|
||||
if (
|
||||
len(node) >= 3
|
||||
and node[0] == "field"
|
||||
and isinstance(node[-1], str)
|
||||
and node[-1] in slot_map
|
||||
):
|
||||
slot_a, slot_b, _ = slot_map[node[-1]]
|
||||
base_type = (
|
||||
node[1].get("base-type", "type/Decimal")
|
||||
if isinstance(node[1], dict)
|
||||
else "type/Decimal"
|
||||
)
|
||||
return [
|
||||
"-",
|
||||
{"lib/uuid": _new_uuid()},
|
||||
["field", {"base-type": base_type, "lib/uuid": _new_uuid()}, slot_a],
|
||||
["field", {"base-type": base_type, "lib/uuid": _new_uuid()}, slot_b],
|
||||
]
|
||||
return [_rewrite_field_refs(x, slot_map) for x in node]
|
||||
return node
|
||||
|
||||
|
||||
def _fix_card_query(dq: dict) -> list[str]:
|
||||
"""Aplica el fix in-place al dataset_query. Devuelve lista de cambios."""
|
||||
stages = dq.get("stages", [])
|
||||
changes = []
|
||||
for si, stage in enumerate(stages):
|
||||
vulnerable, subtractions = _analyze_stage_null_ratio(stage)
|
||||
if not vulnerable:
|
||||
continue
|
||||
|
||||
preagg_names = set(subtractions.keys())
|
||||
new_exprs = []
|
||||
for e in stage.get("expressions", []) or []:
|
||||
if (
|
||||
isinstance(e, list)
|
||||
and len(e) == 4
|
||||
and e[0] == "-"
|
||||
and isinstance(e[1], dict)
|
||||
and e[1].get("lib/expression-name") in preagg_names
|
||||
):
|
||||
new_exprs.append(e)
|
||||
else:
|
||||
new_exprs.append(_rewrite_field_refs(e, vulnerable))
|
||||
stage["expressions"] = new_exprs
|
||||
|
||||
for sj in range(si + 1, len(stages)):
|
||||
for key in ("expressions", "aggregation", "breakout", "filters", "order-by", "fields"):
|
||||
if key in stages[sj]:
|
||||
stages[sj][key] = [
|
||||
_rewrite_field_refs(x, vulnerable) for x in stages[sj][key]
|
||||
]
|
||||
|
||||
for slot, (sa, sb, ename) in vulnerable.items():
|
||||
changes.append(f"stage[{si}] {slot}=sum({ename!r}) -> ({sa} - {sb})")
|
||||
return changes
|
||||
|
||||
|
||||
def metabase_fix_null_ratio(
|
||||
client: MetabaseClient,
|
||||
*,
|
||||
dry_run: bool = True,
|
||||
card_ids: list[int] | None = None,
|
||||
) -> dict:
|
||||
"""Detecta y repara el patron SUM(a-b)/SUM(b) en cards MBQL de Metabase.
|
||||
|
||||
El patron vulnerable ocurre cuando una agregacion computa SUM(expr_resta)
|
||||
donde expr_resta es una resta pre-agg de dos campos. Si alguna fila tiene
|
||||
NULL, SUM(A-B) != SUM(A) - SUM(B). El fix reescribe las referencias
|
||||
post-agg al slot diferencia para usar (SUM(A) - SUM(B)) en su lugar.
|
||||
|
||||
Solo procesa cards MBQL activas (type='query', no archivadas). Las cards
|
||||
SQL nativas o modelos se omiten silenciosamente.
|
||||
|
||||
Args:
|
||||
client: Cliente Metabase autenticado.
|
||||
dry_run: Si True (default), escanea y reporta sin modificar nada.
|
||||
Si False, aplica el fix via PUT /api/card/:id.
|
||||
card_ids: Lista de IDs a procesar. None = todas las cards MBQL activas.
|
||||
|
||||
Returns:
|
||||
dict con campos:
|
||||
scanned (int): cards MBQL evaluadas.
|
||||
affected (int): cards donde se detecto el patron vulnerable.
|
||||
fixed (int): cards efectivamente actualizadas (0 si dry_run=True).
|
||||
errors (list[dict]): lista de {card_id, error} para fallos en PUT.
|
||||
|
||||
Example:
|
||||
>>> from metabase import MetabaseClient, metabase_fix_null_ratio
|
||||
>>> c = MetabaseClient("https://metabase.example.com", "mb_apikey")
|
||||
>>> report = metabase_fix_null_ratio(c, dry_run=True)
|
||||
>>> print(report)
|
||||
{'scanned': 312, 'affected': 4, 'fixed': 0, 'errors': []}
|
||||
>>> # Para aplicar:
|
||||
>>> report = metabase_fix_null_ratio(c, dry_run=False)
|
||||
"""
|
||||
all_cards = client.request("GET", "/api/card")
|
||||
|
||||
if card_ids is not None:
|
||||
card_id_set = set(card_ids)
|
||||
all_cards = [c for c in all_cards if c.get("id") in card_id_set]
|
||||
|
||||
mbql_cards = [
|
||||
c for c in all_cards
|
||||
if not c.get("archived", False)
|
||||
and isinstance(c.get("dataset_query"), dict)
|
||||
and c["dataset_query"].get("type") == "query"
|
||||
and isinstance(c["dataset_query"].get("query", {}).get("stages") if "query" in c["dataset_query"] else c["dataset_query"].get("stages"), list)
|
||||
]
|
||||
|
||||
# Metabase MBQL puede tener stages en dataset_query.query.stages (legacy)
|
||||
# o en dataset_query.stages (v2). Normalizar:
|
||||
def _get_stages(dq: dict) -> list | None:
|
||||
if isinstance(dq.get("stages"), list):
|
||||
return dq["stages"]
|
||||
q = dq.get("query", {})
|
||||
if isinstance(q.get("stages"), list):
|
||||
return q["stages"]
|
||||
return None
|
||||
|
||||
affected_cards = []
|
||||
scanned = 0
|
||||
for card in all_cards:
|
||||
if card_ids is not None and card.get("id") not in set(card_ids):
|
||||
continue
|
||||
if card.get("archived", False):
|
||||
continue
|
||||
dq = card.get("dataset_query")
|
||||
if not isinstance(dq, dict):
|
||||
continue
|
||||
stages = _get_stages(dq)
|
||||
if stages is None:
|
||||
continue
|
||||
scanned += 1
|
||||
dq_copy = copy.deepcopy(dq)
|
||||
# Operar sobre el stages del objeto correcto
|
||||
target = dq_copy if isinstance(dq_copy.get("stages"), list) else dq_copy.get("query", {})
|
||||
changes = _fix_card_query(target)
|
||||
if changes:
|
||||
affected_cards.append((card, dq_copy, changes))
|
||||
|
||||
fixed = 0
|
||||
errors: list[dict] = []
|
||||
|
||||
if not dry_run:
|
||||
for card, dq_fixed, _changes in affected_cards:
|
||||
try:
|
||||
client.request("PUT", f"/api/card/{card['id']}", json={"dataset_query": dq_fixed})
|
||||
fixed += 1
|
||||
except Exception as exc:
|
||||
errors.append({"card_id": card["id"], "error": str(exc)[:200]})
|
||||
time.sleep(0.05)
|
||||
|
||||
return {
|
||||
"scanned": scanned,
|
||||
"affected": len(affected_cards),
|
||||
"fixed": fixed,
|
||||
"errors": errors,
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# metabase_pair_n_n1_columns
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _slot_for_sum_field(stage: dict, target_field_name: str) -> str | None:
|
||||
"""Devuelve el slot MBQL (ej: 'sum', 'sum_4') de sum(target_field) en la stage."""
|
||||
aggs = stage.get("aggregation", []) or []
|
||||
func_counts: dict[str, int] = {}
|
||||
for agg in aggs:
|
||||
if not isinstance(agg, list) or not agg:
|
||||
continue
|
||||
func = agg[0]
|
||||
func_counts[func] = func_counts.get(func, 0) + 1
|
||||
slot = func if func_counts[func] == 1 else f"{func}_{func_counts[func]}"
|
||||
if func == "sum" and len(agg) >= 3:
|
||||
nm, kind = _field_name_of(agg[2])
|
||||
if kind == "field" and nm == target_field_name:
|
||||
return slot
|
||||
return None
|
||||
|
||||
|
||||
def _find_paired_slots(dq: dict, base_name: str) -> tuple[str | None, str | None]:
|
||||
"""Busca (slot_base, slot_n1) para sum(base_name) y sum(base_name_1) en el MBQL."""
|
||||
for stage in (dq.get("stages") or []):
|
||||
if not stage.get("aggregation"):
|
||||
continue
|
||||
slot_n = _slot_for_sum_field(stage, base_name)
|
||||
slot_n1 = _slot_for_sum_field(stage, f"{base_name}_1")
|
||||
if slot_n and slot_n1:
|
||||
return slot_n, slot_n1
|
||||
return None, None
|
||||
|
||||
|
||||
def _reorder_table_columns(
|
||||
cols: list[dict],
|
||||
slot_n: str,
|
||||
slot_n1: str,
|
||||
) -> tuple[list[dict], bool, str]:
|
||||
"""Habilita slot_n1 y lo reubica inmediatamente despues de slot_n.
|
||||
|
||||
Returns:
|
||||
(new_cols, changed, reason)
|
||||
"""
|
||||
cols = [dict(c) for c in cols]
|
||||
idx_n = next((i for i, c in enumerate(cols) if c.get("name") == slot_n), -1)
|
||||
if idx_n < 0:
|
||||
return cols, False, "slot_n no presente en table.columns"
|
||||
|
||||
idx_n1 = next((i for i, c in enumerate(cols) if c.get("name") == slot_n1), -1)
|
||||
|
||||
# Ya en posicion correcta y habilitada: no hay cambio
|
||||
if idx_n1 == idx_n + 1 and cols[idx_n1].get("enabled") is True:
|
||||
return cols, False, "ya en la posicion correcta y habilitado"
|
||||
|
||||
if idx_n1 < 0:
|
||||
entry: dict = {"name": slot_n1, "enabled": True}
|
||||
else:
|
||||
entry = cols.pop(idx_n1)
|
||||
entry["enabled"] = True
|
||||
if idx_n1 < idx_n:
|
||||
idx_n -= 1
|
||||
|
||||
insert_at = idx_n + 1
|
||||
cols.insert(insert_at, entry)
|
||||
return cols, True, "reubicado y habilitado"
|
||||
|
||||
|
||||
def metabase_pair_n_n1_columns(
|
||||
client: MetabaseClient,
|
||||
*,
|
||||
dry_run: bool = True,
|
||||
card_ids: list[int] | None = None,
|
||||
base_field: str = "Valor_vendido",
|
||||
) -> dict:
|
||||
"""Habilita y posiciona la columna _1 junto a su par en cards tabla/pivot de Metabase.
|
||||
|
||||
Para cards con display 'table' o 'pivot' que contienen agregaciones
|
||||
SUM(base_field) y SUM(base_field_1), busca la columna base_field_1 en
|
||||
visualization_settings.table.columns, la habilita (enabled=True) y la
|
||||
reubica inmediatamente despues de base_field para comparacion visual.
|
||||
|
||||
Solo procesa cards con display 'table' o 'pivot' que tengan ambos slots
|
||||
y tengan table.columns definido en visualization_settings.
|
||||
|
||||
Args:
|
||||
client: Cliente Metabase autenticado.
|
||||
dry_run: Si True (default), escanea y reporta sin modificar nada.
|
||||
Si False, aplica el cambio via PUT /api/card/:id.
|
||||
card_ids: Lista de IDs a procesar. None = todas las cards activas.
|
||||
base_field: Nombre del campo base MBQL (sin sufijo _1). Por defecto
|
||||
'Valor_vendido'. La funcion buscara sum(base_field) y
|
||||
sum(base_field_1) en las agregaciones.
|
||||
|
||||
Returns:
|
||||
dict con campos:
|
||||
scanned (int): cards con display table/pivot evaluadas.
|
||||
affected (int): cards donde se encontro el par y habia que mover.
|
||||
fixed (int): cards efectivamente actualizadas (0 si dry_run=True).
|
||||
skipped (int): cards ya correctas o sin table.columns.
|
||||
errors (list[dict]): lista de {card_id, error} para fallos en PUT.
|
||||
|
||||
Example:
|
||||
>>> from metabase import MetabaseClient, metabase_pair_n_n1_columns
|
||||
>>> c = MetabaseClient("https://metabase.example.com", "mb_apikey")
|
||||
>>> report = metabase_pair_n_n1_columns(c, dry_run=True)
|
||||
>>> print(report)
|
||||
{'scanned': 45, 'affected': 3, 'fixed': 0, 'skipped': 42, 'errors': []}
|
||||
>>> # Con campo personalizado:
|
||||
>>> report = metabase_pair_n_n1_columns(c, dry_run=False, base_field="Importe")
|
||||
"""
|
||||
all_cards = client.request("GET", "/api/card")
|
||||
|
||||
tabular_displays = {"table", "pivot"}
|
||||
scanned = 0
|
||||
skipped = 0
|
||||
to_update: list[tuple[dict, list[dict], str, str]] = []
|
||||
|
||||
for card in all_cards:
|
||||
if card_ids is not None and card.get("id") not in set(card_ids):
|
||||
continue
|
||||
if card.get("archived", False):
|
||||
continue
|
||||
if card.get("display") not in tabular_displays:
|
||||
continue
|
||||
dq = card.get("dataset_query")
|
||||
if not isinstance(dq, dict):
|
||||
continue
|
||||
|
||||
slot_n, slot_n1 = _find_paired_slots(dq, base_field)
|
||||
if not (slot_n and slot_n1):
|
||||
continue
|
||||
|
||||
scanned += 1
|
||||
vs = card.get("visualization_settings") or {}
|
||||
cols = vs.get("table.columns")
|
||||
if not isinstance(cols, list):
|
||||
skipped += 1
|
||||
continue
|
||||
|
||||
new_cols, changed, _reason = _reorder_table_columns(cols, slot_n, slot_n1)
|
||||
if not changed:
|
||||
skipped += 1
|
||||
continue
|
||||
|
||||
to_update.append((card, new_cols, slot_n, slot_n1))
|
||||
|
||||
fixed = 0
|
||||
errors: list[dict] = []
|
||||
|
||||
if not dry_run:
|
||||
for card, new_cols, _slot_n, _slot_n1 in to_update:
|
||||
new_vs = copy.deepcopy(card.get("visualization_settings") or {})
|
||||
new_vs["table.columns"] = new_cols
|
||||
try:
|
||||
client.request(
|
||||
"PUT",
|
||||
f"/api/card/{card['id']}",
|
||||
json={"visualization_settings": new_vs},
|
||||
)
|
||||
fixed += 1
|
||||
except Exception as exc:
|
||||
errors.append({"card_id": card["id"], "error": str(exc)[:200]})
|
||||
time.sleep(0.05)
|
||||
|
||||
return {
|
||||
"scanned": scanned,
|
||||
"affected": len(to_update),
|
||||
"fixed": fixed,
|
||||
"skipped": skipped,
|
||||
"errors": errors,
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
---
|
||||
name: metabase_add_membership
|
||||
kind: function
|
||||
lang: py
|
||||
domain: infra
|
||||
version: "1.0.0"
|
||||
purity: impure
|
||||
signature: "def metabase_add_membership(client: MetabaseClient, user_id: int, group_id: int, is_group_manager: bool = False) -> list[dict]"
|
||||
description: "Añade un usuario a un Permission Group de Metabase. Endpoint: POST /api/permissions/membership. Retorna la lista completa de membresías del grupo tras la operación."
|
||||
tags: [metabase, permissions, membership, groups, users, api, python]
|
||||
uses_functions: []
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: "error_go_core"
|
||||
imports: []
|
||||
params:
|
||||
- name: client
|
||||
desc: "instancia autenticada de MetabaseClient con permisos de superusuario"
|
||||
- name: user_id
|
||||
desc: "ID numérico del usuario a añadir al grupo"
|
||||
- name: group_id
|
||||
desc: "ID numérico del grupo destino"
|
||||
- name: is_group_manager
|
||||
desc: "si True, el usuario obtiene rol de manager del grupo (puede gestionar sus miembros)"
|
||||
output: "list[dict]: lista de todas las membresías actuales del grupo tras la operación. Cada elemento contiene membership_id, user_id, group_id, is_group_manager"
|
||||
tested: false
|
||||
tests: []
|
||||
test_file_path: ""
|
||||
file_path: "python/functions/metabase/permissions.py"
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```python
|
||||
# Añadir usuario como miembro regular
|
||||
members = metabase_add_membership(client, user_id=5, group_id=3)
|
||||
print(len(members), "miembros en el grupo")
|
||||
|
||||
# Añadir como manager del grupo
|
||||
members = metabase_add_membership(client, user_id=5, group_id=3, is_group_manager=True)
|
||||
```
|
||||
|
||||
## Notas
|
||||
|
||||
Metabase responde con la lista completa de membresías del grupo (no solo la nueva). Lanza HTTPStatusError 400 si el usuario ya es miembro del grupo. El `membership_id` de la entrada creada está en el elemento nuevo de la lista retornada.
|
||||
@@ -0,0 +1,34 @@
|
||||
---
|
||||
name: metabase_archive_document
|
||||
kind: function
|
||||
lang: py
|
||||
domain: infra
|
||||
version: "1.0.0"
|
||||
purity: impure
|
||||
signature: "def metabase_archive_document(client: MetabaseClient, document_id: int) -> dict"
|
||||
description: "Archiva un document (PUT archived=True). Prerequisito para poder eliminarlo."
|
||||
tags: [metabase, document, archive, api, python]
|
||||
uses_functions: []
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: "error_go_core"
|
||||
imports: [httpx]
|
||||
params:
|
||||
- name: client
|
||||
desc: "instancia autenticada de MetabaseClient"
|
||||
- name: document_id
|
||||
desc: "ID del document a archivar"
|
||||
output: "dict: document con archived=True"
|
||||
tested: false
|
||||
tests: []
|
||||
test_file_path: ""
|
||||
file_path: "python/functions/metabase/documents.py"
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```python
|
||||
metabase_archive_document(client, 1)
|
||||
metabase_delete_document(client, 1) # ahora si puede eliminarse
|
||||
```
|
||||
@@ -0,0 +1,51 @@
|
||||
---
|
||||
name: metabase_copy_card
|
||||
kind: function
|
||||
lang: py
|
||||
domain: infra
|
||||
version: "1.0.0"
|
||||
purity: impure
|
||||
signature: "def metabase_copy_card(client: MetabaseClient, card_id: int, name: str | None = None, collection_id: int | None = None, description: str | None = None) -> dict"
|
||||
description: "Crea una copia de una card/pregunta en Metabase via el endpoint nativo POST /api/card/:id/copy. Permite sobrescribir nombre, coleccion y descripcion en la copia."
|
||||
tags: [metabase, card, question, copy, duplicate, collection, api, python]
|
||||
uses_functions: []
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: "error_go_core"
|
||||
imports: [httpx]
|
||||
params:
|
||||
- name: client
|
||||
desc: "instancia autenticada de MetabaseClient"
|
||||
- name: card_id
|
||||
desc: "ID de la card a copiar"
|
||||
- name: name
|
||||
desc: "nombre para la copia; None = Metabase asigna 'Copy of <nombre>'"
|
||||
- name: collection_id
|
||||
desc: "ID de la coleccion destino; None = misma coleccion que el original"
|
||||
- name: description
|
||||
desc: "descripcion de la copia; None = hereda la del original"
|
||||
output: "dict: objeto card nueva creada por Metabase, con el id asignado y todos los campos heredados del original"
|
||||
tested: false
|
||||
tests: []
|
||||
test_file_path: ""
|
||||
file_path: "python/functions/metabase/cards.py"
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```python
|
||||
# Copia simple (nombre automatico)
|
||||
copy = metabase_copy_card(client, 42)
|
||||
print(copy["id"], copy["name"]) # "Copy of ..."
|
||||
|
||||
# Copia a otra coleccion con nombre propio
|
||||
copy = metabase_copy_card(client, 42, name="Revenue Q2", collection_id=7)
|
||||
print(copy["collection_id"]) # 7
|
||||
```
|
||||
|
||||
## Notas
|
||||
|
||||
Usa el endpoint nativo de Metabase que copia dataset_query, display y
|
||||
visualization_settings. Los campos opcionales del body se omiten si son None
|
||||
para que Metabase aplique sus defaults (herencia del original).
|
||||
@@ -0,0 +1,54 @@
|
||||
---
|
||||
name: metabase_copy_dashboard
|
||||
kind: function
|
||||
lang: py
|
||||
domain: infra
|
||||
version: "1.0.0"
|
||||
purity: impure
|
||||
signature: "def metabase_copy_dashboard(client: MetabaseClient, dashboard_id: int, name: str | None = None, collection_id: int | None = None, description: str | None = None, is_deep_copy: bool = False) -> dict"
|
||||
description: "Crea una copia de un dashboard en Metabase via POST /api/dashboard/:id/copy. Con is_deep_copy=True tambien clona las cards referenciadas."
|
||||
tags: [metabase, dashboard, copy, duplicate, collection, deep_copy, api, python]
|
||||
uses_functions: []
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: "error_go_core"
|
||||
imports: [httpx]
|
||||
params:
|
||||
- name: client
|
||||
desc: "instancia autenticada de MetabaseClient"
|
||||
- name: dashboard_id
|
||||
desc: "ID del dashboard a copiar"
|
||||
- name: name
|
||||
desc: "nombre para la copia; None = Metabase asigna 'Copy of <nombre>'"
|
||||
- name: collection_id
|
||||
desc: "ID de la coleccion destino; None = misma coleccion que el original"
|
||||
- name: description
|
||||
desc: "descripcion de la copia; None = hereda la del original"
|
||||
- name: is_deep_copy
|
||||
desc: "si True, clona tambien todas las cards referenciadas (deep copy); si False, la copia referencia las cards originales"
|
||||
output: "dict: objeto dashboard nuevo creado por Metabase, con id asignado y layout de dashcards copiado"
|
||||
tested: false
|
||||
tests: []
|
||||
test_file_path: ""
|
||||
file_path: "python/functions/metabase/dashboards.py"
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```python
|
||||
# Copia simple (referencia cards originales)
|
||||
copy = metabase_copy_dashboard(client, 1)
|
||||
print(copy["id"], copy["name"]) # "Copy of ..."
|
||||
|
||||
# Deep copy a otra coleccion con nombre propio
|
||||
copy = metabase_copy_dashboard(client, 1, name="Sales Q2", collection_id=7, is_deep_copy=True)
|
||||
print(copy["collection_id"]) # 7
|
||||
```
|
||||
|
||||
## Notas
|
||||
|
||||
`is_deep_copy=True` hace que Metabase clone tambien las cards internas del
|
||||
dashboard, util para crear instancias completamente independientes. Con
|
||||
`is_deep_copy=False` (default), las dashcards del clon apuntan a las mismas
|
||||
cards que el original — cambios en esas cards afectan ambos dashboards.
|
||||
@@ -0,0 +1,52 @@
|
||||
---
|
||||
name: metabase_copy_document
|
||||
kind: function
|
||||
lang: py
|
||||
domain: infra
|
||||
version: "1.0.0"
|
||||
purity: impure
|
||||
signature: "def metabase_copy_document(client: MetabaseClient, document_id: int, name: str | None = None, collection_id: int | None = None) -> dict"
|
||||
description: "Copia un document clonando su contenido ProseMirror. Metabase no tiene endpoint nativo; realiza GET + POST internamente."
|
||||
tags: [metabase, document, copy, clone, prosemirror, api, python]
|
||||
uses_functions: [metabase_get_document_py_infra, metabase_create_document_py_infra]
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: "error_go_core"
|
||||
imports: [httpx]
|
||||
params:
|
||||
- name: client
|
||||
desc: "instancia autenticada de MetabaseClient"
|
||||
- name: document_id
|
||||
desc: "ID del document original a copiar"
|
||||
- name: name
|
||||
desc: "nombre del nuevo document; None usa '{original} (copia)'"
|
||||
- name: collection_id
|
||||
desc: "coleccion destino; None copia a la misma coleccion del original"
|
||||
output: "dict: document nuevo recien creado con id asignado y metadata completa"
|
||||
tested: false
|
||||
tests: []
|
||||
test_file_path: ""
|
||||
file_path: "python/functions/metabase/documents.py"
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```python
|
||||
# Copia simple con nombre automatico a la misma coleccion
|
||||
copy = metabase_copy_document(client, 42)
|
||||
print(copy["name"]) # "Mi documento (copia)"
|
||||
print(copy["id"]) # nuevo ID
|
||||
|
||||
# Clonar a otra coleccion con nombre personalizado
|
||||
copy = metabase_copy_document(client, 42, name="Backup Q1", collection_id=5)
|
||||
```
|
||||
|
||||
## Notas
|
||||
|
||||
Realiza 2 requests HTTP: `GET /api/document/:id` para obtener el original y
|
||||
`POST /api/document` para crear la copia con el mismo arbol ProseMirror.
|
||||
|
||||
Metabase no tiene endpoint `POST /api/document/:id/copy` — esta funcion implementa
|
||||
la copia en cliente. Los `cardEmbed` del documento original apuntaran a los mismos
|
||||
cards embebidos; no se duplican los cards embebidos.
|
||||
@@ -0,0 +1,62 @@
|
||||
---
|
||||
name: metabase_create_card_raw
|
||||
kind: function
|
||||
lang: py
|
||||
domain: infra
|
||||
version: "1.0.0"
|
||||
purity: impure
|
||||
signature: "def metabase_create_card_raw(client: MetabaseClient, payload: dict) -> dict"
|
||||
description: "Crea una card en Metabase enviando el payload completo sin modificaciones. Version raw para flujos Metabase-as-code donde el caller construye el body entero. Endpoint: POST /api/card."
|
||||
tags: [metabase, card, question, create, api, python, raw, as-code]
|
||||
uses_functions: [metabase_auth_py_infra]
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: "error_go_core"
|
||||
imports: [httpx]
|
||||
params:
|
||||
- name: client
|
||||
desc: "instancia autenticada de MetabaseClient con sesion activa"
|
||||
- name: payload
|
||||
desc: "dict con el payload completo de la card tal como lo espera la API de Metabase; campos minimos: name, dataset_query, display; campos opcionales preservados: visualization_settings, parameters, parameter_mappings, type, collection_id, description, archived, enable_embedding, embedding_params"
|
||||
output: "dict: objeto card recien creado con id asignado por Metabase y todos los campos normalizados (display, dataset_query, visualization_settings, created_at, etc.)"
|
||||
tested: false
|
||||
tests: []
|
||||
test_file_path: ""
|
||||
file_path: "python/functions/metabase/cards.py"
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```python
|
||||
card = metabase_create_card_raw(client, {
|
||||
"name": "Revenue by Month",
|
||||
"dataset_query": {
|
||||
"database": 1,
|
||||
"type": "native",
|
||||
"native": {"query": "SELECT date_trunc('month', created_at), SUM(total) FROM orders GROUP BY 1"},
|
||||
},
|
||||
"display": "line",
|
||||
"visualization_settings": {
|
||||
"graph.x_axis.title_text": "Month",
|
||||
"graph.y_axis.title_text": "Revenue",
|
||||
},
|
||||
"description": "Monthly revenue trend",
|
||||
"collection_id": 5,
|
||||
})
|
||||
print(card["id"]) # ID asignado por Metabase
|
||||
```
|
||||
|
||||
## Notas
|
||||
|
||||
No se escriben tests automaticos porque requiere una instancia real de Metabase.
|
||||
Los intentos de mockear `client.request` quedan fuera del alcance del registry
|
||||
por ahora — la validacion se hace en integracion contra un entorno Metabase real.
|
||||
|
||||
A diferencia de `metabase_create_card`, esta funcion no descarta ni transforma
|
||||
ningun campo del payload: lo envia tal cual al endpoint. Esto permite pasar
|
||||
`visualization_settings`, `parameters`, `embedding_params`, `type`, etc. sin
|
||||
que la funcion los filtre.
|
||||
|
||||
Si Metabase devuelve 4xx/5xx, httpx lanza `HTTPStatusError` sin capturar.
|
||||
El caller debe manejar errores si necesita recuperacion.
|
||||
@@ -0,0 +1,77 @@
|
||||
---
|
||||
name: metabase_create_dashboard_raw
|
||||
kind: function
|
||||
lang: py
|
||||
domain: infra
|
||||
version: "1.0.0"
|
||||
purity: impure
|
||||
signature: "def metabase_create_dashboard_raw(client: MetabaseClient, payload: dict) -> dict"
|
||||
description: "Crea un dashboard en Metabase enviando el payload completo sin modificaciones. Maneja automaticamente la limitacion de la API que no acepta dashcards en el POST inicial: si el payload contiene dashcards, hace POST para crear el dashboard y luego PUT para añadir las cards. Endpoint: POST /api/dashboard (+ PUT condicional)."
|
||||
tags: [metabase, dashboard, create, api, python, raw, as-code, dashcards]
|
||||
uses_functions: [metabase_auth_py_infra]
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: "error_go_core"
|
||||
imports: [httpx]
|
||||
params:
|
||||
- name: client
|
||||
desc: "instancia autenticada de MetabaseClient con sesion activa"
|
||||
- name: payload
|
||||
desc: "dict con el payload completo del dashboard tal como lo espera la API de Metabase; campos soportados: name (requerido), description, collection_id, parameters, tabs, enable_embedding, embedding_params; dashcards (list[dict]): si presente, se extrae del body POST y se añade en un PUT posterior con id negativo para cards nuevas"
|
||||
output: "dict: objeto dashboard creado; si habia dashcards en el payload, retorna la respuesta del PUT final con el campo dashcards poblado; si no habia dashcards, retorna la respuesta del POST inicial"
|
||||
tested: false
|
||||
tests: []
|
||||
test_file_path: ""
|
||||
file_path: "python/functions/metabase/dashboards.py"
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```python
|
||||
# Sin dashcards (solo POST)
|
||||
dash = metabase_create_dashboard_raw(client, {
|
||||
"name": "Sales Overview",
|
||||
"description": "KPIs de ventas mensuales",
|
||||
"collection_id": 5,
|
||||
"parameters": [],
|
||||
})
|
||||
print(dash["id"])
|
||||
|
||||
# Con dashcards (POST + PUT automatico)
|
||||
dash = metabase_create_dashboard_raw(client, {
|
||||
"name": "Sales Overview",
|
||||
"description": "KPIs de ventas mensuales",
|
||||
"collection_id": 5,
|
||||
"parameters": [],
|
||||
"dashcards": [
|
||||
{
|
||||
"id": -1,
|
||||
"card_id": 42,
|
||||
"size_x": 6,
|
||||
"size_y": 4,
|
||||
"col": 0,
|
||||
"row": 0,
|
||||
"visualization_settings": {},
|
||||
"parameter_mappings": [],
|
||||
},
|
||||
],
|
||||
})
|
||||
print(dash["id"])
|
||||
print(len(dash["dashcards"])) # 1
|
||||
```
|
||||
|
||||
## Notas
|
||||
|
||||
No se escriben tests automaticos porque requiere una instancia real de Metabase.
|
||||
Los intentos de mockear `client.request` quedan fuera del alcance del registry
|
||||
por ahora — la validacion se hace en integracion contra un entorno Metabase real.
|
||||
|
||||
La API de Metabase (al menos hasta v0.49) ignora el campo `dashcards` en el
|
||||
POST inicial de creacion de dashboard. Esta funcion absorbe esa limitacion
|
||||
internamente: extrae las dashcards del payload antes del POST y las envia en
|
||||
un PUT separado usando el id que Metabase asigno al nuevo dashboard.
|
||||
|
||||
Si Metabase devuelve 4xx/5xx en cualquier paso, httpx lanza `HTTPStatusError`
|
||||
sin capturar. Si el POST tiene exito pero el PUT falla, el dashboard queda
|
||||
creado pero vacio — el caller debe manejar este caso si necesita atomicidad.
|
||||
@@ -0,0 +1,49 @@
|
||||
---
|
||||
name: metabase_create_document
|
||||
kind: function
|
||||
lang: py
|
||||
domain: infra
|
||||
version: "1.0.0"
|
||||
purity: impure
|
||||
signature: "def metabase_create_document(client: MetabaseClient, name: str, document: dict, collection_id: int = 0) -> dict"
|
||||
description: "Crea un document nuevo con contenido ProseMirror. Endpoint: POST /api/document. Soporta cardEmbed, smartLink, flexContainer, callout, taskList y demas nodos custom de Metabase."
|
||||
tags: [metabase, document, create, api, prosemirror, python]
|
||||
uses_functions: []
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: "error_go_core"
|
||||
imports: [httpx]
|
||||
params:
|
||||
- name: client
|
||||
desc: "instancia autenticada de MetabaseClient"
|
||||
- name: name
|
||||
desc: "titulo del document (1-254 caracteres, no blank)"
|
||||
- name: document
|
||||
desc: "arbol ProseMirror JSON: {type: 'doc', content: [...]}, o '' para arrancar vacio"
|
||||
- name: collection_id
|
||||
desc: "ID de coleccion destino (0 = root)"
|
||||
output: "dict: document recien creado con id, entity_id y metadata"
|
||||
tested: false
|
||||
tests: []
|
||||
test_file_path: ""
|
||||
file_path: "python/functions/metabase/documents.py"
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```python
|
||||
doc = metabase_create_document(client, "Notas", {
|
||||
"type": "doc",
|
||||
"content": [
|
||||
{"type": "paragraph", "content": [{"type": "text", "text": "Hola"}]}
|
||||
]
|
||||
})
|
||||
print(doc["id"])
|
||||
```
|
||||
|
||||
## Notas
|
||||
|
||||
Nodos custom de Metabase observados (v0.59): `cardEmbed` (attrs.id=card_id), `smartLink` (attrs.entityId), `flexContainer` (attrs.columnWidths), `resizeNode`, `mention`. Marks estandar + `underline`, `highlight`, `subscript`, `textStyle`.
|
||||
|
||||
Cuando embebes un card via `cardEmbed`, Metabase crea una copia interna del card con `document_id` apuntando al document — no referencia el card original.
|
||||
@@ -0,0 +1,59 @@
|
||||
---
|
||||
name: metabase_create_document_comment
|
||||
kind: function
|
||||
lang: py
|
||||
domain: infra
|
||||
version: "1.0.0"
|
||||
purity: impure
|
||||
signature: "def metabase_create_document_comment(client: MetabaseClient, document_id: int, content: dict, *, child_target_id: str | None = None, parent_comment_id: int | None = None) -> dict"
|
||||
description: "Crea un comentario en un document. Soporta anclaje a bloque concreto (via UUID de _id) y respuestas en thread (via parent_comment_id). Endpoint: POST /api/comment."
|
||||
tags: [metabase, document, comments, create, api, python]
|
||||
uses_functions: []
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: "error_go_core"
|
||||
imports: [httpx]
|
||||
params:
|
||||
- name: client
|
||||
desc: "instancia autenticada de MetabaseClient"
|
||||
- name: document_id
|
||||
desc: "ID del document donde crear el comentario"
|
||||
- name: content
|
||||
desc: "arbol ProseMirror del comentario: {type: doc, content: [...]}"
|
||||
- name: child_target_id
|
||||
desc: "UUID de bloque al que se ancla el comentario (matchea attrs._id de un parrafo). None = comentario a nivel doc"
|
||||
- name: parent_comment_id
|
||||
desc: "ID del comentario al que se responde. None = comentario top-level"
|
||||
output: "dict: comentario creado con id, created_at, creator, reactions=[], is_resolved=False"
|
||||
tested: false
|
||||
tests: []
|
||||
test_file_path: ""
|
||||
file_path: "python/functions/metabase/documents.py"
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```python
|
||||
# Comentario top-level
|
||||
metabase_create_document_comment(client, 29, {
|
||||
"type": "doc",
|
||||
"content": [{"type": "paragraph", "content": [
|
||||
{"type": "text", "text": "Deberiamos anadir un paso para configurar Slack"}
|
||||
]}]
|
||||
})
|
||||
|
||||
# Respuesta en thread
|
||||
metabase_create_document_comment(client, 29, content=reply_tree,
|
||||
parent_comment_id=1)
|
||||
|
||||
# Anclado a un bloque concreto del documento
|
||||
metabase_create_document_comment(client, 29, content=tree,
|
||||
child_target_id="48f9a7a4-79a0-a282-03a1-ffe2f76b9106")
|
||||
```
|
||||
|
||||
## Notas
|
||||
|
||||
`target_type` se fija internamente a `"document"` (unico valor aceptado por la API en v0.59).
|
||||
|
||||
El `content` sigue el mismo schema ProseMirror que los documents (ver whitelist en `metabase_validate_document_payload`).
|
||||
@@ -0,0 +1,39 @@
|
||||
---
|
||||
name: metabase_create_group
|
||||
kind: function
|
||||
lang: py
|
||||
domain: infra
|
||||
version: "1.0.0"
|
||||
purity: impure
|
||||
signature: "def metabase_create_group(client: MetabaseClient, name: str) -> dict"
|
||||
description: "Crea un nuevo Permission Group en Metabase. El nombre debe ser unico. Retorna el grupo creado con su id asignado. Endpoint: POST /api/permissions/group."
|
||||
tags: [metabase, permissions, group, create, api, python]
|
||||
uses_functions: []
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: "error_go_core"
|
||||
imports: []
|
||||
params:
|
||||
- name: client
|
||||
desc: "instancia autenticada de MetabaseClient con permisos de superusuario"
|
||||
- name: name
|
||||
desc: "nombre del grupo, debe ser unico en la instancia Metabase"
|
||||
output: "dict: grupo creado con id y name"
|
||||
tested: false
|
||||
tests: []
|
||||
test_file_path: ""
|
||||
file_path: "python/functions/metabase/permissions.py"
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```python
|
||||
group = metabase_create_group(client, "Analytics Team")
|
||||
print(group["id"], group["name"])
|
||||
```
|
||||
|
||||
## Notas
|
||||
|
||||
Error 400 si ya existe un grupo con ese nombre.
|
||||
Para asignar usuarios al grupo recien creado usar la API de memberships (POST /api/permissions/membership).
|
||||
@@ -0,0 +1,38 @@
|
||||
---
|
||||
name: metabase_delete_document
|
||||
kind: function
|
||||
lang: py
|
||||
domain: infra
|
||||
version: "1.0.0"
|
||||
purity: impure
|
||||
signature: "def metabase_delete_document(client: MetabaseClient, document_id: int) -> None"
|
||||
description: "Elimina un document. Requiere archivado previo (Metabase rechaza DELETE si archived=False)."
|
||||
tags: [metabase, document, delete, api, python]
|
||||
uses_functions: []
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: "error_go_core"
|
||||
imports: [httpx]
|
||||
params:
|
||||
- name: client
|
||||
desc: "instancia autenticada de MetabaseClient"
|
||||
- name: document_id
|
||||
desc: "ID del document a eliminar"
|
||||
output: "None"
|
||||
tested: false
|
||||
tests: []
|
||||
test_file_path: ""
|
||||
file_path: "python/functions/metabase/documents.py"
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```python
|
||||
metabase_archive_document(client, 1)
|
||||
metabase_delete_document(client, 1)
|
||||
```
|
||||
|
||||
## Notas
|
||||
|
||||
Si se llama sin archivar antes, Metabase responde: `"Document must be archived before it can be deleted."`
|
||||
@@ -0,0 +1,47 @@
|
||||
---
|
||||
name: metabase_delete_group
|
||||
kind: function
|
||||
lang: py
|
||||
domain: infra
|
||||
version: "1.0.0"
|
||||
purity: impure
|
||||
signature: "def metabase_delete_group(client: MetabaseClient, group_id: int) -> None"
|
||||
description: "Elimina permanentemente un Permission Group de Metabase. IRREVERSIBLE. No borra los usuarios, solo el grupo. Endpoint: DELETE /api/permissions/group/:id."
|
||||
tags: [metabase, permissions, group, delete, api, python]
|
||||
uses_functions: []
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: "error_go_core"
|
||||
imports: []
|
||||
params:
|
||||
- name: client
|
||||
desc: "instancia autenticada de MetabaseClient con permisos de superusuario"
|
||||
- name: group_id
|
||||
desc: "ID numerico del grupo a eliminar. ADVERTENCIA: no pasar id=1 (All Users) ni id=2 (Administrators)"
|
||||
output: "None: retorna None en caso de exito (204 No Content)"
|
||||
tested: false
|
||||
tests: []
|
||||
test_file_path: ""
|
||||
file_path: "python/functions/metabase/permissions.py"
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```python
|
||||
metabase_delete_group(client, 5)
|
||||
# CUIDADO: no pasar group_id=1 (All Users) ni group_id=2 (Administrators)
|
||||
```
|
||||
|
||||
## Notas
|
||||
|
||||
**OPERACION IRREVERSIBLE.** El grupo se elimina permanentemente sin posibilidad de recuperacion.
|
||||
|
||||
Grupos especiales del sistema que NO deben borrarse:
|
||||
- `id=1`: "All Users" — todos los usuarios de Metabase pertenecen automaticamente a este grupo.
|
||||
- `id=2`: "Administrators" — grupo de administradores del sistema.
|
||||
|
||||
Esta funcion NO valida ni bloquea el borrado de esos IDs. Es responsabilidad del caller verificar que no se pasen IDs protegidos antes de invocar esta funcion.
|
||||
|
||||
Al borrar un grupo, los usuarios que pertenecian a el no se eliminan, solo pierden la membresia.
|
||||
Error 404 si el grupo no existe.
|
||||
@@ -0,0 +1,41 @@
|
||||
---
|
||||
name: metabase_delete_membership
|
||||
kind: function
|
||||
lang: py
|
||||
domain: infra
|
||||
version: "1.0.0"
|
||||
purity: impure
|
||||
signature: "def metabase_delete_membership(client: MetabaseClient, membership_id: int) -> None"
|
||||
description: "Elimina una membresía de grupo en Metabase por su membership_id. Endpoint: DELETE /api/permissions/membership/:id. NO acepta user_id+group_id — requiere el membership_id exacto."
|
||||
tags: [metabase, permissions, membership, groups, users, delete, api, python]
|
||||
uses_functions: []
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: "error_go_core"
|
||||
imports: []
|
||||
params:
|
||||
- name: client
|
||||
desc: "instancia autenticada de MetabaseClient con permisos de superusuario"
|
||||
- name: membership_id
|
||||
desc: "ID numérico de la membresía a eliminar. Obtenerse vía metabase_list_memberships — no es el user_id ni el group_id"
|
||||
output: "None: sin valor de retorno. Lanza HTTPStatusError 404 si la membresía no existe"
|
||||
tested: false
|
||||
tests: []
|
||||
test_file_path: ""
|
||||
file_path: "python/functions/metabase/permissions.py"
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```python
|
||||
# Primero obtener el membership_id via list_memberships
|
||||
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)
|
||||
```
|
||||
|
||||
## Notas
|
||||
|
||||
La API de Metabase no expone DELETE por user_id+group_id. El `membership_id` es distinto de ambos y debe obtenerse vía `metabase_list_memberships`. Esta función no bloquea el borrado de membresías en grupos del sistema (All Users, Administrators) — es responsabilidad del caller verificarlo.
|
||||
@@ -0,0 +1,61 @@
|
||||
---
|
||||
name: metabase_fix_null_ratio
|
||||
kind: function
|
||||
lang: py
|
||||
domain: infra
|
||||
version: "1.0.0"
|
||||
purity: impure
|
||||
signature: "def metabase_fix_null_ratio(client: MetabaseClient, *, dry_run: bool = True, card_ids: list[int] | None = None) -> dict"
|
||||
description: "Detecta y repara el patron vulnerable SUM(a-b)/SUM(b) en cards MBQL de Metabase. Cuando una resta pre-agg tiene operandos con NULL, SUM(A-B)!=SUM(A)-SUM(B). El fix reescribe las referencias post-agg al slot diferencia para usar (SUM(A)-SUM(B)) en su lugar. Soporta dry_run para escanear sin modificar."
|
||||
tags: [metabase, maintenance, batch, mbql, fix, null, ratio, aggregation, python]
|
||||
uses_functions: [metabase_list_cards_py_infra]
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: "error_go_core"
|
||||
imports: [copy, time, uuid, httpx]
|
||||
params:
|
||||
- name: client
|
||||
desc: "instancia autenticada de MetabaseClient con acceso a GET /api/card y PUT /api/card/:id"
|
||||
- name: dry_run
|
||||
desc: "si True (default) solo escanea y reporta sin modificar ninguna card; si False aplica el fix via PUT"
|
||||
- name: card_ids
|
||||
desc: "lista de IDs especificos a procesar; None = todas las cards MBQL activas no archivadas"
|
||||
output: "dict con scanned (cards MBQL evaluadas), affected (cards con el patron detectado), fixed (cards modificadas, 0 en dry_run), errors (lista de {card_id, error} para fallos en PUT)"
|
||||
tested: false
|
||||
tests: []
|
||||
test_file_path: ""
|
||||
file_path: "python/functions/metabase/maintenance.py"
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```python
|
||||
from metabase import MetabaseClient, metabase_fix_null_ratio
|
||||
|
||||
c = MetabaseClient("https://metabase.example.com", "mb_apikey")
|
||||
|
||||
# Escanear sin modificar
|
||||
report = metabase_fix_null_ratio(c, dry_run=True)
|
||||
print(report)
|
||||
# {'scanned': 312, 'affected': 4, 'fixed': 0, 'errors': []}
|
||||
|
||||
# Aplicar solo a cards especificas
|
||||
report = metabase_fix_null_ratio(c, dry_run=False, card_ids=[101, 205, 307])
|
||||
print(report)
|
||||
# {'scanned': 3, 'affected': 2, 'fixed': 2, 'errors': []}
|
||||
```
|
||||
|
||||
## Notas
|
||||
|
||||
El patron vulnerable ocurre en cards MBQL con esta estructura:
|
||||
- expressions: `["-", meta, ["field", m, "campo_a"], ["field", m, "campo_b"]]` con nombre `Diferencia_X`
|
||||
- aggregation: `["sum", meta, ["expression", m, "Diferencia_X"]]` → slot `sum_N`
|
||||
- aggregation: `["sum", meta, ["field", m, "campo_a"]]` → slot `sum_A`
|
||||
- aggregation: `["sum", meta, ["field", m, "campo_b"]]` → slot `sum_B`
|
||||
|
||||
El fix reemplaza referencias a `sum_N` en stages posteriores por `(sum_A - sum_B)`.
|
||||
Las restas pre-agg originales (definiciones en expressions[]) no se tocan.
|
||||
|
||||
Solo procesa cards con `dataset_query.type == "query"` y stages MBQL.
|
||||
Las cards SQL nativas se omiten silenciosamente. Rate-limit: 50ms entre PUTs.
|
||||
@@ -0,0 +1,45 @@
|
||||
---
|
||||
name: metabase_get_collection_graph
|
||||
kind: function
|
||||
lang: py
|
||||
domain: infra
|
||||
version: "1.0.0"
|
||||
purity: impure
|
||||
signature: "def metabase_get_collection_graph(client: MetabaseClient, namespace: str | None = None) -> dict"
|
||||
description: "Obtiene el grafo de permisos de colecciones de Metabase. Endpoint: GET /api/collection/graph. Soporta namespace opcional para snippet collections. El campo revision es crítico para concurrency control en el PUT posterior."
|
||||
tags: [metabase, permissions, collections, graph, access-control, api, python]
|
||||
uses_functions: []
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: "error_go_core"
|
||||
imports: []
|
||||
params:
|
||||
- name: client
|
||||
desc: "instancia autenticada de MetabaseClient con permisos de superusuario"
|
||||
- name: namespace
|
||||
desc: "namespace opcional: 'snippets' para snippet collections, None para colecciones regulares"
|
||||
output: "dict: grafo con revision (int) y groups (group_id -> collection_id -> 'read' | 'write' | 'none'). El campo revision es obligatorio para el PUT."
|
||||
tested: false
|
||||
tests: []
|
||||
test_file_path: ""
|
||||
file_path: "python/functions/metabase/permissions.py"
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```python
|
||||
# Colecciones regulares
|
||||
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")
|
||||
```
|
||||
|
||||
## Notas
|
||||
|
||||
Siempre hacer GET fresco justo antes del PUT. El `revision` es el mecanismo de concurrency control nativo de Metabase — ver `metabase_update_collection_graph` para el patrón completo. Los niveles de acceso son `"read"`, `"write"` y `"none"`.
|
||||
@@ -0,0 +1,38 @@
|
||||
---
|
||||
name: metabase_get_document
|
||||
kind: function
|
||||
lang: py
|
||||
domain: infra
|
||||
version: "1.0.0"
|
||||
purity: impure
|
||||
signature: "def metabase_get_document(client: MetabaseClient, document_id: int) -> dict"
|
||||
description: "Obtiene un document completo con su arbol ProseMirror (campo document). Endpoint: GET /api/document/:id."
|
||||
tags: [metabase, document, get, api, python]
|
||||
uses_functions: []
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: "error_go_core"
|
||||
imports: [httpx]
|
||||
params:
|
||||
- name: client
|
||||
desc: "instancia autenticada de MetabaseClient"
|
||||
- name: document_id
|
||||
desc: "ID del document a obtener"
|
||||
output: "dict: objeto document con name, document (ProseMirror tree), collection_id, archived, creator, created_at, updated_at, entity_id, content_type"
|
||||
tested: false
|
||||
tests: []
|
||||
test_file_path: ""
|
||||
file_path: "python/functions/metabase/documents.py"
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```python
|
||||
doc = metabase_get_document(client, 1)
|
||||
tree = doc["document"] # {"type": "doc", "content": [...]}
|
||||
```
|
||||
|
||||
## Notas
|
||||
|
||||
`content_type` siempre es `application/json+vnd.prose-mirror`.
|
||||
@@ -0,0 +1,41 @@
|
||||
---
|
||||
name: metabase_get_group
|
||||
kind: function
|
||||
lang: py
|
||||
domain: infra
|
||||
version: "1.0.0"
|
||||
purity: impure
|
||||
signature: "def metabase_get_group(client: MetabaseClient, group_id: int) -> dict"
|
||||
description: "Obtiene un Permission Group de Metabase por ID, incluyendo la lista completa de miembros con sus datos. Endpoint: GET /api/permissions/group/:id."
|
||||
tags: [metabase, permissions, group, get, members, api, python]
|
||||
uses_functions: []
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: "error_go_core"
|
||||
imports: []
|
||||
params:
|
||||
- name: client
|
||||
desc: "instancia autenticada de MetabaseClient con permisos de superusuario"
|
||||
- name: group_id
|
||||
desc: "ID numerico del grupo a consultar"
|
||||
output: "dict: grupo con id, name y members (lista con user_id, email, first_name, last_name, membership_id)"
|
||||
tested: false
|
||||
tests: []
|
||||
test_file_path: ""
|
||||
file_path: "python/functions/metabase/permissions.py"
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```python
|
||||
group = metabase_get_group(client, 3)
|
||||
print(group["name"])
|
||||
for m in group["members"]:
|
||||
print(m["email"], m["user_id"])
|
||||
```
|
||||
|
||||
## Notas
|
||||
|
||||
A diferencia de `metabase_list_groups`, este endpoint retorna la lista completa de miembros del grupo.
|
||||
Error 404 si el grupo no existe.
|
||||
@@ -0,0 +1,39 @@
|
||||
---
|
||||
name: metabase_get_permission_graph
|
||||
kind: function
|
||||
lang: py
|
||||
domain: infra
|
||||
version: "1.0.0"
|
||||
purity: impure
|
||||
signature: "def metabase_get_permission_graph(client: MetabaseClient) -> dict"
|
||||
description: "Obtiene el grafo de permisos de datos (databases/schemas/tables) de Metabase. Endpoint: GET /api/permissions/graph. El campo revision es crítico para concurrency control en el PUT posterior."
|
||||
tags: [metabase, permissions, graph, databases, schemas, access-control, api, python]
|
||||
uses_functions: []
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: "error_go_core"
|
||||
imports: []
|
||||
params:
|
||||
- name: client
|
||||
desc: "instancia autenticada de MetabaseClient con permisos de superusuario"
|
||||
output: "dict: grafo completo con revision (int) y groups (group_id -> db_id -> {schemas, native}). El campo revision es obligatorio para el PUT."
|
||||
tested: false
|
||||
tests: []
|
||||
test_file_path: ""
|
||||
file_path: "python/functions/metabase/permissions.py"
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```python
|
||||
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}")
|
||||
```
|
||||
|
||||
## Notas
|
||||
|
||||
Siempre hacer GET fresco justo antes del PUT. El `revision` es el mecanismo de concurrency control nativo de Metabase — ver `metabase_update_permission_graph` para el patrón completo.
|
||||
@@ -0,0 +1,51 @@
|
||||
---
|
||||
name: metabase_list_document_comments
|
||||
kind: function
|
||||
lang: py
|
||||
domain: infra
|
||||
version: "1.0.0"
|
||||
purity: impure
|
||||
signature: "def metabase_list_document_comments(client: MetabaseClient, document_id: int, *, include_resolved: bool = True, include_deleted: bool = False) -> list[dict]"
|
||||
description: "Lista los comentarios de un document (threads, anclajes por bloque UUID, reacciones). Endpoint no documentado: GET /api/comment?target_type=document&target_id=:id."
|
||||
tags: [metabase, document, comments, api, python]
|
||||
uses_functions: []
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: "error_go_core"
|
||||
imports: [httpx]
|
||||
params:
|
||||
- name: client
|
||||
desc: "instancia autenticada de MetabaseClient"
|
||||
- name: document_id
|
||||
desc: "ID del document del que listar comentarios"
|
||||
- name: include_resolved
|
||||
desc: "si False, filtra comentarios con is_resolved=True"
|
||||
- name: include_deleted
|
||||
desc: "si False, filtra comentarios soft-deleted (deleted_at != null)"
|
||||
output: "list[dict]: cada dict con id, content (arbol ProseMirror), creator, creator_id, target_type, target_id, child_target_id (UUID de bloque anclado o null), parent_comment_id (para threads), is_resolved, deleted_at, reactions, created_at, updated_at"
|
||||
tested: false
|
||||
tests: []
|
||||
test_file_path: ""
|
||||
file_path: "python/functions/metabase/documents.py"
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```python
|
||||
comments = metabase_list_document_comments(client, 29, include_resolved=False)
|
||||
for c in comments:
|
||||
author = c["creator"]["common_name"]
|
||||
# content es ProseMirror — aplanar si quieres texto plano
|
||||
text = "".join(n["text"] for n in _walk(c["content"]) if n.get("type") == "text")
|
||||
anchor = c["child_target_id"] or "doc"
|
||||
print(f'{author} @ {anchor}: {text}')
|
||||
```
|
||||
|
||||
## Notas
|
||||
|
||||
**Endpoint no documentado** en la API publica de Metabase — descubierto inspeccionando trafico. target_type solo acepta el enum `"document"` en v0.59.
|
||||
|
||||
**Anclaje por bloque**: cuando un usuario comenta sobre un parrafo concreto en la UI, Metabase inyecta un `attrs._id` UUID en ese parrafo y guarda ese UUID en `child_target_id`. Si editas el parrafo vía YAML sin preservar el `_id`, el comentario queda huerfano.
|
||||
|
||||
**Threads**: los comentarios que responden a otros tienen `parent_comment_id` apuntando al padre.
|
||||
@@ -0,0 +1,37 @@
|
||||
---
|
||||
name: metabase_list_documents
|
||||
kind: function
|
||||
lang: py
|
||||
domain: infra
|
||||
version: "1.0.0"
|
||||
purity: impure
|
||||
signature: "def metabase_list_documents(client: MetabaseClient) -> list[dict]"
|
||||
description: "Lista documents (texto ProseMirror tipo Notion, feature 0.57+). Endpoint: GET /api/document. Desenrolla {items: [...]}."
|
||||
tags: [metabase, document, list, api, python]
|
||||
uses_functions: []
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: "error_go_core"
|
||||
imports: [httpx]
|
||||
params:
|
||||
- name: client
|
||||
desc: "instancia autenticada de MetabaseClient"
|
||||
output: "list[dict]: cada dict con id, name, collection_id, archived, created_at, updated_at, creator_id, content_type, entity_id"
|
||||
tested: false
|
||||
tests: []
|
||||
test_file_path: ""
|
||||
file_path: "python/functions/metabase/documents.py"
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```python
|
||||
docs = metabase_list_documents(client)
|
||||
for d in docs:
|
||||
print(d["id"], d["name"])
|
||||
```
|
||||
|
||||
## Notas
|
||||
|
||||
Metabase devuelve `{"items": [...]}`. Esta funcion desenrolla para retornar la lista directamente.
|
||||
@@ -0,0 +1,38 @@
|
||||
---
|
||||
name: metabase_list_groups
|
||||
kind: function
|
||||
lang: py
|
||||
domain: infra
|
||||
version: "1.0.0"
|
||||
purity: impure
|
||||
signature: "def metabase_list_groups(client: MetabaseClient) -> list[dict]"
|
||||
description: "Lista todos los Permission Groups de Metabase con su member_count. Requiere superusuario. Endpoint: GET /api/permissions/group."
|
||||
tags: [metabase, permissions, group, list, api, python]
|
||||
uses_functions: []
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: "error_go_core"
|
||||
imports: []
|
||||
params:
|
||||
- name: client
|
||||
desc: "instancia autenticada de MetabaseClient con permisos de superusuario"
|
||||
output: "list[dict]: lista de grupos con id, name y member_count"
|
||||
tested: false
|
||||
tests: []
|
||||
test_file_path: ""
|
||||
file_path: "python/functions/metabase/permissions.py"
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```python
|
||||
groups = metabase_list_groups(client)
|
||||
for g in groups:
|
||||
print(g["id"], g["name"], g["member_count"])
|
||||
```
|
||||
|
||||
## Notas
|
||||
|
||||
Incluye los grupos especiales del sistema: "All Users" (id=1) y "Administrators" (id=2).
|
||||
Requiere que el cliente este autenticado como superusuario.
|
||||
@@ -0,0 +1,38 @@
|
||||
---
|
||||
name: metabase_list_memberships
|
||||
kind: function
|
||||
lang: py
|
||||
domain: infra
|
||||
version: "1.0.0"
|
||||
purity: impure
|
||||
signature: "def metabase_list_memberships(client: MetabaseClient) -> dict[str, list[dict]]"
|
||||
description: "Lista todas las membresías de grupos de Metabase. Endpoint: GET /api/permissions/membership. Retorna dict con group_id (str) como clave y lista de membresías como valor."
|
||||
tags: [metabase, permissions, membership, groups, users, api, python]
|
||||
uses_functions: []
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: "error_go_core"
|
||||
imports: []
|
||||
params:
|
||||
- name: client
|
||||
desc: "instancia autenticada de MetabaseClient con permisos de superusuario"
|
||||
output: "dict[str, list[dict]]: mapa de group_id (str) a lista de membresías, cada una con membership_id, user_id, group_id, is_group_manager"
|
||||
tested: false
|
||||
tests: []
|
||||
test_file_path: ""
|
||||
file_path: "python/functions/metabase/permissions.py"
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```python
|
||||
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"])
|
||||
```
|
||||
|
||||
## Notas
|
||||
|
||||
La respuesta nativa de Metabase es un dict indexado por group_id como string, no una lista plana. Para buscar la membresía de un usuario en un grupo específico, hay que iterar `memberships.get(str(group_id), [])`.
|
||||
@@ -0,0 +1,76 @@
|
||||
---
|
||||
name: metabase_mbql_validate
|
||||
kind: function
|
||||
lang: py
|
||||
domain: core
|
||||
version: "1.0.0"
|
||||
purity: pure
|
||||
signature: "def metabase_mbql_validate(dataset_query: dict) -> list[str]"
|
||||
description: "Valida la estructura de un dataset_query MBQL sin I/O. Detecta UUIDs duplicados, stage mixing (aggregations + expressions que referencian slots en la misma stage), slot refs rotas (sum_X inexistente), case structures invalidas y name collisions en expressions. Retorna lista de errores, vacia si el query es valido."
|
||||
tags: [metabase, mbql, validation, pure, query, dataset_query]
|
||||
uses_functions: []
|
||||
uses_types: []
|
||||
params:
|
||||
- name: dataset_query
|
||||
desc: "Dict completo del dataset_query MBQL tal como lo devuelve GET /api/card/:id. Debe tener clave 'stages' con lista de stage dicts. Cada stage puede tener 'expressions', 'aggregation', 'filters'."
|
||||
output: "Lista de strings con errores encontrados. Lista vacia si el query supera todos los checks. Cada error incluye la ubicacion (stage[N]) y descripcion del problema."
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: ""
|
||||
imports: []
|
||||
tested: true
|
||||
tests:
|
||||
- "DQ valido retorna lista vacia"
|
||||
- "UUID duplicado genera error"
|
||||
- "Stage mixing con slot refs genera error"
|
||||
- "Slot sum_99 inexistente genera error"
|
||||
- "Case con casos no pares genera error"
|
||||
- "Name collision en expressions genera error"
|
||||
- "stages ausente devuelve error de estructura"
|
||||
test_file_path: "python/functions/metabase/test_metabase_mbql_validate.py"
|
||||
file_path: "python/functions/metabase/metabase_mbql_validate.py"
|
||||
---
|
||||
|
||||
## Checks implementados
|
||||
|
||||
### 1. UUIDs duplicados
|
||||
Metabase requiere que cada `lib/uuid` sea unico globalmente dentro del dataset_query. Un UUID repetido (por ejemplo al copiar-pegar un nodo MBQL) causa errores silenciosos o 400 en la API.
|
||||
|
||||
### 2. Stage mixing
|
||||
Si una stage tiene `aggregation` y `expressions`, las expressions NO deben referenciar los slot names generados por las aggregations (`sum`, `avg`, `sum_1`, etc.). Esas references deben ir en la stage siguiente. Si estan en la misma stage, Metabase retorna 500.
|
||||
|
||||
### 3. Slot refs rotas
|
||||
Una expression `["field", {sin base-type}, "sum_X"]` referencia la X-esima aggregation de tipo sum. Si X >= cantidad de sums en la stage, el slot no existe y la query falla.
|
||||
|
||||
### 4. Case structure
|
||||
Los nodos `["case", meta, cases]` deben tener `cases` como lista de pares `[cond, result]`. Una estructura malformada (e.g., lista de un solo elemento) causa errores de parsing en Metabase.
|
||||
|
||||
### 5. Name collision
|
||||
Dos `expressions` con el mismo `lib/expression-name` en la misma stage generan conflictos de alias en la query SQL generada.
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```python
|
||||
import sys
|
||||
sys.path.insert(0, '/home/lucas/fn_registry/python/functions')
|
||||
from metabase import MetabaseClient
|
||||
from metabase.metabase_mbql_validate import metabase_mbql_validate
|
||||
|
||||
client = MetabaseClient('https://metabase.example.com', 'token...')
|
||||
card = client.request('GET', '/api/card/5705')
|
||||
|
||||
errors = metabase_mbql_validate(card['dataset_query'])
|
||||
if errors:
|
||||
for e in errors:
|
||||
print(f'ERROR: {e}')
|
||||
else:
|
||||
print('Query valida')
|
||||
```
|
||||
|
||||
## Notas
|
||||
|
||||
Funcion 100% pura: sin I/O, sin estado mutable, determinista. Solo stdlib Python.
|
||||
|
||||
Los slots reconocidos como aggregation slots son: `sum`, `avg`, `count`, `min`, `max`, `distinct`, `cum-sum`, `cum-count`, `share`, `stddev` (y sus variantes `_N`).
|
||||
|
||||
Un field con `base-type` en su metadata NO se considera slot ref — es una referencia a columna real. Solo los fields sin `base-type` se tratan como slots de aggregation.
|
||||
@@ -0,0 +1,236 @@
|
||||
"""Validacion estatica de dataset_query MBQL antes de enviarlo a Metabase."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import collections
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
|
||||
def metabase_mbql_validate(dataset_query: dict) -> list[str]:
|
||||
"""Valida la estructura de un dataset_query MBQL sin hacer I/O.
|
||||
|
||||
Detecta los errores mas comunes que causan respuestas 400/500 de la API de
|
||||
Metabase, permitiendo corregirlos antes del round-trip.
|
||||
|
||||
Checks realizados:
|
||||
1. UUIDs duplicados: cualquier ``lib/uuid`` que aparezca mas de una vez en
|
||||
el arbol MBQL. Metabase los requiere unicos globalmente por query.
|
||||
2. Stage mixing: stages que tienen tanto ``aggregation`` como ``expressions``
|
||||
donde las expressions referencian slot names (``sum``, ``sum_N``, etc.)
|
||||
generados por aggregations. Esas expressions deben ir en la stage siguiente.
|
||||
3. Slot refs rotos: expressions que referencian ``sum_X`` deben tener X menor
|
||||
que la cantidad de sums en la stage previa (o misma).
|
||||
4. Case structure: nodos ``["case", meta, cases]`` deben tener ``cases``
|
||||
como lista de pares ``[cond, result]``.
|
||||
5. Name collision: dos expressions con el mismo ``lib/expression-name`` en
|
||||
la misma stage.
|
||||
|
||||
Args:
|
||||
dataset_query: Dict con la estructura completa del dataset_query MBQL
|
||||
tal como lo devuelve GET /api/card/:id o lo construye el caller.
|
||||
Debe tener clave ``stages`` (lista de stage dicts). Si no tiene
|
||||
``stages``, se devuelve error de estructura.
|
||||
|
||||
Returns:
|
||||
Lista de strings describiendo errores encontrados. Lista vacia si el
|
||||
dataset_query es valido segun todos los checks.
|
||||
|
||||
Example:
|
||||
>>> errors = metabase_mbql_validate(card["dataset_query"])
|
||||
>>> if errors:
|
||||
... for e in errors:
|
||||
... print(e)
|
||||
... else:
|
||||
... print("Query valida")
|
||||
"""
|
||||
errors: list[str] = []
|
||||
|
||||
stages = dataset_query.get("stages")
|
||||
if not isinstance(stages, list):
|
||||
errors.append("dataset_query.stages ausente o no es lista")
|
||||
return errors
|
||||
|
||||
# ---- Check 1: UUIDs duplicados ------------------------------------------
|
||||
uuid_locations: list[tuple[str, str]] = []
|
||||
_collect_uuids(dataset_query, root="", out=uuid_locations)
|
||||
uuid_counter: dict[str, list[str]] = collections.defaultdict(list)
|
||||
for uid, path in uuid_locations:
|
||||
uuid_counter[uid].append(path)
|
||||
for uid, paths in uuid_counter.items():
|
||||
if len(paths) > 1:
|
||||
errors.append(
|
||||
f"Duplicate lib/uuid '{uid}' aparece {len(paths)} veces: "
|
||||
+ ", ".join(paths[:3])
|
||||
+ ("..." if len(paths) > 3 else "")
|
||||
)
|
||||
|
||||
# ---- Checks por stage ---------------------------------------------------
|
||||
for si, stage in enumerate(stages):
|
||||
if not isinstance(stage, dict):
|
||||
continue
|
||||
tag = f"stage[{si}]"
|
||||
|
||||
expressions: list[Any] = stage.get("expressions") or []
|
||||
aggregations: list[Any] = stage.get("aggregation") or []
|
||||
|
||||
# Check 5: name collision en expressions
|
||||
expr_names: list[str] = []
|
||||
for expr in expressions:
|
||||
name = _expr_name(expr)
|
||||
if name:
|
||||
if name in expr_names:
|
||||
errors.append(
|
||||
f"{tag} tiene dos expressions con mismo "
|
||||
f"lib/expression-name '{name}'"
|
||||
)
|
||||
else:
|
||||
expr_names.append(name)
|
||||
|
||||
# Check 2: stage mixing
|
||||
if aggregations and expressions:
|
||||
for expr in expressions:
|
||||
slot_refs = _find_slot_refs(expr)
|
||||
if slot_refs:
|
||||
ename = _expr_name(expr) or "?"
|
||||
errors.append(
|
||||
f"{tag} mezcla aggregations con expressions "
|
||||
f"post-agg que referencian slot names "
|
||||
f"({', '.join(repr(s) for s in slot_refs)}) "
|
||||
f"en expression '{ename}'. "
|
||||
f"Mover esas expressions a la stage siguiente."
|
||||
)
|
||||
|
||||
# Check 3: slot refs rotos
|
||||
# Contar sums en aggregations de esta stage
|
||||
sum_count = sum(1 for agg in aggregations if _agg_is_sum(agg))
|
||||
for expr in expressions:
|
||||
for slot in _find_slot_refs(expr):
|
||||
m = re.match(r"sum(?:_(\d+))?$", slot, re.IGNORECASE)
|
||||
if m:
|
||||
idx = int(m.group(1)) if m.group(1) else 0
|
||||
if idx >= sum_count:
|
||||
ename = _expr_name(expr) or "?"
|
||||
errors.append(
|
||||
f"{tag} expression '{ename}' referencia "
|
||||
f"'{slot}' que no existe "
|
||||
f"(solo hay {sum_count} sum(s) en aggregation)"
|
||||
)
|
||||
|
||||
# Check 4: case structure
|
||||
for expr in expressions:
|
||||
_check_case_structure(expr, tag, errors)
|
||||
|
||||
return errors
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers privados
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _collect_uuids(
|
||||
obj: Any,
|
||||
root: str,
|
||||
out: list[tuple[str, str]],
|
||||
) -> None:
|
||||
"""Recorre obj recursivamente y añade (uuid, path) a out."""
|
||||
if isinstance(obj, dict):
|
||||
if "lib/uuid" in obj:
|
||||
out.append((obj["lib/uuid"], root))
|
||||
for k, v in obj.items():
|
||||
_collect_uuids(v, f"{root}.{k}" if root else k, out)
|
||||
elif isinstance(obj, list):
|
||||
for i, item in enumerate(obj):
|
||||
_collect_uuids(item, f"{root}[{i}]", out)
|
||||
|
||||
|
||||
def _expr_name(expr: Any) -> str | None:
|
||||
"""Extrae lib/expression-name del segundo elemento de un nodo MBQL."""
|
||||
if isinstance(expr, list) and len(expr) >= 2 and isinstance(expr[1], dict):
|
||||
return expr[1].get("lib/expression-name")
|
||||
return None
|
||||
|
||||
|
||||
# Patron de slot name: word chars, puede terminar en _N
|
||||
_SLOT_RE = re.compile(r"^[a-zA-Z_][a-zA-Z0-9_]*(?:_\d+)?$")
|
||||
# Slots que corresponden a aggregation functions conocidas
|
||||
_AGG_SLOTS = {
|
||||
"sum", "avg", "count", "min", "max",
|
||||
"distinct", "cum-sum", "cum-count", "share", "stddev",
|
||||
}
|
||||
|
||||
|
||||
def _find_slot_refs(obj: Any) -> list[str]:
|
||||
"""Devuelve lista de slot names encontrados en refs tipo ["field", meta, slot]."""
|
||||
slots: list[str] = []
|
||||
_collect_slot_refs(obj, slots)
|
||||
return slots
|
||||
|
||||
|
||||
def _collect_slot_refs(obj: Any, out: list[str]) -> None:
|
||||
if isinstance(obj, list):
|
||||
if (
|
||||
len(obj) == 3
|
||||
and obj[0] == "field"
|
||||
and isinstance(obj[1], dict)
|
||||
and isinstance(obj[2], str)
|
||||
and not obj[1].get("base-type") # field sin base-type = slot ref
|
||||
and _is_slot_name(obj[2])
|
||||
):
|
||||
out.append(obj[2])
|
||||
else:
|
||||
for item in obj:
|
||||
_collect_slot_refs(item, out)
|
||||
elif isinstance(obj, dict):
|
||||
for v in obj.values():
|
||||
_collect_slot_refs(v, out)
|
||||
|
||||
|
||||
def _is_slot_name(s: str) -> bool:
|
||||
"""Devuelve True si s parece un slot name de aggregation."""
|
||||
# Slot: nombre sin espacio que es una funcion de agg o variant con sufijo _N
|
||||
base = re.sub(r"_\d+$", "", s)
|
||||
return base in _AGG_SLOTS
|
||||
|
||||
|
||||
def _agg_is_sum(agg: Any) -> bool:
|
||||
"""Retorna True si el nodo aggregation es de tipo sum."""
|
||||
if isinstance(agg, list) and len(agg) >= 1:
|
||||
return str(agg[0]).lower() == "sum"
|
||||
return False
|
||||
|
||||
|
||||
def _check_case_structure(expr: Any, tag: str, errors: list[str]) -> None:
|
||||
"""Valida recursivamente nodos case dentro de una expression."""
|
||||
if not isinstance(expr, list):
|
||||
return
|
||||
if expr and expr[0] == "case":
|
||||
ename = _expr_name(expr) or "?"
|
||||
# Esperado: ["case", meta, [[cond, result], ...]]
|
||||
if len(expr) < 3:
|
||||
errors.append(
|
||||
f"{tag} expression '{ename}': case con menos de 3 elementos"
|
||||
)
|
||||
return
|
||||
cases = expr[2]
|
||||
if not isinstance(cases, list):
|
||||
errors.append(
|
||||
f"{tag} expression '{ename}': tercer elemento de case "
|
||||
f"debe ser lista de pares, got {type(cases).__name__}"
|
||||
)
|
||||
return
|
||||
for i, pair in enumerate(cases):
|
||||
if not (isinstance(pair, list) and len(pair) == 2):
|
||||
errors.append(
|
||||
f"{tag} expression '{ename}': case[{i}] no es par "
|
||||
f"[cond, result], got {pair!r}"
|
||||
)
|
||||
# Recursar en ramas
|
||||
for pair in cases:
|
||||
if isinstance(pair, list):
|
||||
for node in pair:
|
||||
_check_case_structure(node, tag, errors)
|
||||
else:
|
||||
for item in expr:
|
||||
_check_case_structure(item, tag, errors)
|
||||
@@ -0,0 +1,46 @@
|
||||
---
|
||||
name: metabase_move_card
|
||||
kind: function
|
||||
lang: py
|
||||
domain: infra
|
||||
version: "1.0.0"
|
||||
purity: impure
|
||||
signature: "def metabase_move_card(client: MetabaseClient, card_id: int, collection_id: int | None) -> dict"
|
||||
description: "Mueve una card/pregunta a otra coleccion via PUT /api/card/:id. Wrapper thin que solo actualiza collection_id. collection_id=None mueve a 'Our analytics' (root)."
|
||||
tags: [metabase, card, question, move, collection, api, python]
|
||||
uses_functions: []
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: "error_go_core"
|
||||
imports: [httpx]
|
||||
params:
|
||||
- name: client
|
||||
desc: "instancia autenticada de MetabaseClient"
|
||||
- name: card_id
|
||||
desc: "ID de la card a mover"
|
||||
- name: collection_id
|
||||
desc: "ID de la coleccion destino; None mueve a 'Our analytics' (root)"
|
||||
output: "dict: objeto card actualizado con el nuevo collection_id"
|
||||
tested: false
|
||||
tests: []
|
||||
test_file_path: ""
|
||||
file_path: "python/functions/metabase/cards.py"
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```python
|
||||
# Mover a una coleccion especifica
|
||||
card = metabase_move_card(client, 42, collection_id=7)
|
||||
print(card["collection_id"]) # 7
|
||||
|
||||
# Mover a root ("Our analytics")
|
||||
card = metabase_move_card(client, 42, collection_id=None)
|
||||
```
|
||||
|
||||
## Notas
|
||||
|
||||
Wrapper con intencion explicita sobre metabase_update_card. Envia solo
|
||||
`{collection_id: ...}` en el body para no sobreescribir otros campos.
|
||||
Pasar `collection_id=None` es el mecanismo de Metabase para mover a root.
|
||||
@@ -0,0 +1,50 @@
|
||||
---
|
||||
name: metabase_move_collection
|
||||
kind: function
|
||||
lang: py
|
||||
domain: infra
|
||||
version: "1.0.0"
|
||||
purity: impure
|
||||
signature: "def metabase_move_collection(client: MetabaseClient, collection_id: int, parent_id: int | None) -> dict"
|
||||
description: "Mueve una collection (sub-arbol completo) a otro padre. Endpoint: PUT /api/collection/:id con {parent_id: ...}."
|
||||
tags: [metabase, collection, move, tree, api, python]
|
||||
uses_functions: []
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: "error_go_core"
|
||||
imports: [httpx]
|
||||
params:
|
||||
- name: client
|
||||
desc: "instancia autenticada de MetabaseClient"
|
||||
- name: collection_id
|
||||
desc: "ID de la collection a mover"
|
||||
- name: parent_id
|
||||
desc: "ID de la collection padre destino; None mueve a la raiz (Our analytics)"
|
||||
output: "dict: collection actualizada con nuevo parent_id y location actualizado"
|
||||
tested: false
|
||||
tests: []
|
||||
test_file_path: ""
|
||||
file_path: "python/functions/metabase/collections.py"
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```python
|
||||
# Mover collection 12 dentro de collection 3
|
||||
col = metabase_move_collection(client, 12, parent_id=3)
|
||||
print(col["location"]) # "/3/"
|
||||
|
||||
# Mover a raiz
|
||||
col = metabase_move_collection(client, 12, parent_id=None)
|
||||
print(col["location"]) # "/"
|
||||
```
|
||||
|
||||
## Notas
|
||||
|
||||
Metabase reubica atomicamente la collection y todo su sub-arbol: colecciones
|
||||
hijas, cards, dashboards y documents contenidos en ellas. El campo `location`
|
||||
de la respuesta refleja la nueva ruta en formato `"/parent_id/"` o `"/"` para
|
||||
la raiz.
|
||||
|
||||
Para mover un document individual usar `metabase_move_document`.
|
||||
@@ -0,0 +1,47 @@
|
||||
---
|
||||
name: metabase_move_dashboard
|
||||
kind: function
|
||||
lang: py
|
||||
domain: infra
|
||||
version: "1.0.0"
|
||||
purity: impure
|
||||
signature: "def metabase_move_dashboard(client: MetabaseClient, dashboard_id: int, collection_id: int | None) -> dict"
|
||||
description: "Mueve un dashboard a otra coleccion via PUT /api/dashboard/:id. Wrapper thin que solo actualiza collection_id. collection_id=None mueve a 'Our analytics' (root)."
|
||||
tags: [metabase, dashboard, move, collection, api, python]
|
||||
uses_functions: []
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: "error_go_core"
|
||||
imports: [httpx]
|
||||
params:
|
||||
- name: client
|
||||
desc: "instancia autenticada de MetabaseClient"
|
||||
- name: dashboard_id
|
||||
desc: "ID del dashboard a mover"
|
||||
- name: collection_id
|
||||
desc: "ID de la coleccion destino; None mueve a 'Our analytics' (root)"
|
||||
output: "dict: objeto dashboard actualizado con el nuevo collection_id"
|
||||
tested: false
|
||||
tests: []
|
||||
test_file_path: ""
|
||||
file_path: "python/functions/metabase/dashboards.py"
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```python
|
||||
# Mover a una coleccion especifica
|
||||
dash = metabase_move_dashboard(client, 1, collection_id=7)
|
||||
print(dash["collection_id"]) # 7
|
||||
|
||||
# Mover a root ("Our analytics")
|
||||
dash = metabase_move_dashboard(client, 1, collection_id=None)
|
||||
```
|
||||
|
||||
## Notas
|
||||
|
||||
Wrapper con intencion explicita sobre metabase_update_dashboard. Envia solo
|
||||
`{collection_id: ...}` en el body para no sobreescribir dashcards ni otros
|
||||
campos del dashboard. Pasar `collection_id=None` es el mecanismo de Metabase
|
||||
para mover a root.
|
||||
@@ -0,0 +1,45 @@
|
||||
---
|
||||
name: metabase_move_document
|
||||
kind: function
|
||||
lang: py
|
||||
domain: infra
|
||||
version: "1.0.0"
|
||||
purity: impure
|
||||
signature: "def metabase_move_document(client: MetabaseClient, document_id: int, collection_id: int | None) -> dict"
|
||||
description: "Mueve un document a otra coleccion. Thin wrapper sobre PUT /api/document/:id enviando solo collection_id."
|
||||
tags: [metabase, document, move, collection, api, python]
|
||||
uses_functions: []
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: "error_go_core"
|
||||
imports: [httpx]
|
||||
params:
|
||||
- name: client
|
||||
desc: "instancia autenticada de MetabaseClient"
|
||||
- name: document_id
|
||||
desc: "ID del document a mover"
|
||||
- name: collection_id
|
||||
desc: "ID de coleccion destino; None mueve a la raiz (Our analytics)"
|
||||
output: "dict: document actualizado con el nuevo collection_id"
|
||||
tested: false
|
||||
tests: []
|
||||
test_file_path: ""
|
||||
file_path: "python/functions/metabase/documents.py"
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```python
|
||||
# Mover a coleccion 7
|
||||
doc = metabase_move_document(client, 42, collection_id=7)
|
||||
print(doc["collection_id"]) # 7
|
||||
|
||||
# Mover a raiz
|
||||
doc = metabase_move_document(client, 42, collection_id=None)
|
||||
```
|
||||
|
||||
## Notas
|
||||
|
||||
Equivale a `metabase_update_document(client, document_id, collection_id=collection_id)` pero con
|
||||
firma explicita para mayor legibilidad en codigo de migracion/reorganizacion.
|
||||
@@ -0,0 +1,66 @@
|
||||
---
|
||||
name: metabase_pair_n_n1_columns
|
||||
kind: function
|
||||
lang: py
|
||||
domain: infra
|
||||
version: "1.0.0"
|
||||
purity: impure
|
||||
signature: "def metabase_pair_n_n1_columns(client: MetabaseClient, *, dry_run: bool = True, card_ids: list[int] | None = None, base_field: str = 'Valor_vendido') -> dict"
|
||||
description: "Para cards Metabase con display table/pivot que agregan SUM(base_field) y SUM(base_field_1), habilita la columna base_field_1 en visualization_settings.table.columns y la posiciona inmediatamente despues de base_field para comparacion visual. Soporta dry_run y campo base configurable."
|
||||
tags: [metabase, maintenance, batch, visualization, columns, table, pivot, python]
|
||||
uses_functions: [metabase_list_cards_py_infra]
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: "error_go_core"
|
||||
imports: [copy, time, httpx]
|
||||
params:
|
||||
- name: client
|
||||
desc: "instancia autenticada de MetabaseClient con acceso a GET /api/card y PUT /api/card/:id"
|
||||
- name: dry_run
|
||||
desc: "si True (default) solo escanea y reporta sin modificar ninguna card; si False aplica el cambio via PUT"
|
||||
- name: card_ids
|
||||
desc: "lista de IDs especificos a procesar; None = todas las cards activas con display table o pivot"
|
||||
- name: base_field
|
||||
desc: "nombre del campo MBQL base (sin sufijo _1); la funcion busca sum(base_field) y sum(base_field_1) en las agregaciones; por defecto 'Valor_vendido'"
|
||||
output: "dict con scanned (cards table/pivot con par detectado), affected (cards con columna a mover), fixed (cards modificadas, 0 en dry_run), skipped (cards ya correctas o sin table.columns), errors (lista de {card_id, error} para fallos en PUT)"
|
||||
tested: false
|
||||
tests: []
|
||||
test_file_path: ""
|
||||
file_path: "python/functions/metabase/maintenance.py"
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```python
|
||||
from metabase import MetabaseClient, metabase_pair_n_n1_columns
|
||||
|
||||
c = MetabaseClient("https://metabase.example.com", "mb_apikey")
|
||||
|
||||
# Escanear sin modificar (campo por defecto: Valor_vendido)
|
||||
report = metabase_pair_n_n1_columns(c, dry_run=True)
|
||||
print(report)
|
||||
# {'scanned': 45, 'affected': 3, 'fixed': 0, 'skipped': 42, 'errors': []}
|
||||
|
||||
# Aplicar con campo personalizado
|
||||
report = metabase_pair_n_n1_columns(c, dry_run=False, base_field="Importe")
|
||||
print(report)
|
||||
# {'scanned': 12, 'affected': 2, 'fixed': 2, 'skipped': 10, 'errors': []}
|
||||
|
||||
# Solo cards especificas
|
||||
report = metabase_pair_n_n1_columns(c, dry_run=False, card_ids=[42, 99])
|
||||
```
|
||||
|
||||
## Notas
|
||||
|
||||
Requisitos para que una card sea candidata:
|
||||
1. display == "table" o "pivot"
|
||||
2. dataset_query tiene aggregation con sum(base_field) y sum(base_field_1)
|
||||
3. visualization_settings.table.columns es una lista
|
||||
|
||||
La funcion detecta los slots MBQL dinamicamente (ej: "sum", "sum_3") contando
|
||||
el orden de aparicion de cada funcion de agregacion en el array. Luego busca
|
||||
esos slots en table.columns por el campo "name" y reordena.
|
||||
|
||||
Si base_field_1 no aparece en table.columns se inserta como nueva entrada
|
||||
`{"name": slot_n1, "enabled": True}`. Rate-limit: 50ms entre PUTs.
|
||||
@@ -0,0 +1,40 @@
|
||||
---
|
||||
name: metabase_resolve_document_comment
|
||||
kind: function
|
||||
lang: py
|
||||
domain: infra
|
||||
version: "1.0.0"
|
||||
purity: impure
|
||||
signature: "def metabase_resolve_document_comment(client: MetabaseClient, comment_id: int) -> dict"
|
||||
description: "Marca un comentario como resuelto (is_resolved=True). Los comentarios resueltos se ocultan en la UI pero siguen consultables via metabase_list_document_comments(include_resolved=True). Endpoint: PUT /api/comment/:id."
|
||||
tags: [metabase, document, comments, resolve, api, python]
|
||||
uses_functions: []
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: "error_go_core"
|
||||
imports: [httpx]
|
||||
params:
|
||||
- name: client
|
||||
desc: "instancia autenticada de MetabaseClient"
|
||||
- name: comment_id
|
||||
desc: "ID del comentario a marcar como resuelto"
|
||||
output: "dict: comentario actualizado con is_resolved=True"
|
||||
tested: false
|
||||
tests: []
|
||||
test_file_path: ""
|
||||
file_path: "python/functions/metabase/documents.py"
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```python
|
||||
open_comments = metabase_list_document_comments(client, 29, include_resolved=False)
|
||||
for c in open_comments:
|
||||
if "obsoleto" in _plaintext(c["content"]):
|
||||
metabase_resolve_document_comment(client, c["id"])
|
||||
```
|
||||
|
||||
## Notas
|
||||
|
||||
El endpoint `PUT /api/comment/:id` acepta cualquier campo actualizable (content, is_resolved, etc.); esta funcion solo envia `is_resolved=True` para mantener contrato estrecho. Para editar contenido usar `PUT /api/comment/:id` directo via client.
|
||||
@@ -0,0 +1,64 @@
|
||||
---
|
||||
name: metabase_update_collection_graph
|
||||
kind: function
|
||||
lang: py
|
||||
domain: infra
|
||||
version: "1.0.0"
|
||||
purity: impure
|
||||
signature: "def metabase_update_collection_graph(client: MetabaseClient, graph: dict, namespace: str | None = None) -> dict"
|
||||
description: "Actualiza el grafo de permisos de colecciones en Metabase. Endpoint: PUT /api/collection/graph. El campo revision en el graph es obligatorio — el servidor rechaza con 409 si no coincide con el actual. Soporta namespace para snippet collections."
|
||||
tags: [metabase, permissions, collections, graph, access-control, update, api, python]
|
||||
uses_functions: []
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: "error_go_core"
|
||||
imports: []
|
||||
params:
|
||||
- name: client
|
||||
desc: "instancia autenticada de MetabaseClient con permisos de superusuario"
|
||||
- name: graph
|
||||
desc: "dict con el grafo completo incluyendo el campo revision actual. Debe obtenerse vía metabase_get_collection_graph justo antes de modificar"
|
||||
- name: namespace
|
||||
desc: "namespace opcional: 'snippets' para snippet collections, None para colecciones regulares. Debe coincidir con el namespace usado en el GET"
|
||||
output: "dict: nuevo grafo tras la actualización con revision incrementado"
|
||||
tested: false
|
||||
tests: []
|
||||
test_file_path: ""
|
||||
file_path: "python/functions/metabase/permissions.py"
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```python
|
||||
graph = metabase_get_collection_graph(client)
|
||||
# Dar acceso write al grupo 3 sobre la colección 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")
|
||||
```
|
||||
|
||||
## Control de concurrencia por revision
|
||||
|
||||
El campo `graph["revision"]` es el mecanismo de optimistic locking nativo de Metabase.
|
||||
|
||||
**Patrón obligatorio:**
|
||||
|
||||
1. `graph = metabase_get_collection_graph(client)` — GET fresco
|
||||
2. Modificar `graph["groups"][group_id][collection_id] = "read" | "write" | "none"`
|
||||
3. `graph = metabase_update_collection_graph(client, graph)` — PUT con revision
|
||||
|
||||
**Nunca cachear el graph.** Si otro proceso modificó el graph entre el GET y el PUT, Metabase devuelve HTTP 409 Conflict y el caller debe reintentar desde el GET.
|
||||
|
||||
### Niveles de acceso para colecciones
|
||||
|
||||
- `"write"` — el grupo puede ver y editar contenido de la colección
|
||||
- `"read"` — el grupo puede ver pero no editar
|
||||
- `"none"` — sin acceso (la colección es invisible para el grupo)
|
||||
|
||||
El campo `"root"` como collection_id hace referencia a la colección raíz "Our analytics".
|
||||
@@ -0,0 +1,95 @@
|
||||
---
|
||||
name: metabase_update_dashboard_safe
|
||||
kind: function
|
||||
lang: py
|
||||
domain: infra
|
||||
version: "1.0.0"
|
||||
purity: impure
|
||||
signature: "def metabase_update_dashboard_safe(client: MetabaseClient, dashboard_id: int, *, dashcards_update: list[dict] | None = None, dashcards_add: list[dict] | None = None, dashcards_remove: list[int] | None = None, extra_fields: dict | None = None) -> dict"
|
||||
description: "Wrapper sobre PUT /api/dashboard/:id que maneja los tres gotchas documentados: strip del campo '.card' denormalizado (evita 413), inclusion obligatoria de 'tabs' (evita 500 FK violation) y asignacion de IDs negativos a dashcards nuevos. Soporta reemplazo completo (dashcards_update), operaciones incrementales (add/remove) y actualizacion de campos extra del dashboard."
|
||||
tags: [metabase, dashboard, dashcard, api, wrapper, safe, put]
|
||||
uses_functions:
|
||||
- metabase_get_dashboard_py_infra
|
||||
uses_types: []
|
||||
params:
|
||||
- name: client
|
||||
desc: "MetabaseClient autenticado con sesion activa."
|
||||
- name: dashboard_id
|
||||
desc: "ID entero del dashboard a actualizar."
|
||||
- name: dashcards_update
|
||||
desc: "Lista completa de dashcards que reemplaza el estado actual (full replace). Si se da, dashcards_add y dashcards_remove se ignoran. Cada dict puede incluir o no el campo 'card' — se strippea automaticamente."
|
||||
- name: dashcards_add
|
||||
desc: "Dashcards a añadir sobre los existentes. No deben incluir 'id' — la funcion asigna IDs negativos (-1, -2, ...). Se ignora si dashcards_update esta presente."
|
||||
- name: dashcards_remove
|
||||
desc: "Lista de IDs de dashcards existentes a eliminar del dashboard. Se aplica sobre existentes antes de añadir dashcards_add."
|
||||
- name: extra_fields
|
||||
desc: "Campos adicionales del dashboard a actualizar junto con las cards: name, description, parameters, archived, collection_id, etc."
|
||||
output: "Dict con resumen: {'added': [negative_ids_assigned], 'updated': count_existentes_conservados, 'removed': count_eliminados, 'response': dict_respuesta_PUT}"
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: "error_go_core"
|
||||
imports:
|
||||
- httpx
|
||||
tested: false
|
||||
tests: []
|
||||
test_file_path: ""
|
||||
file_path: "python/functions/metabase/metabase_update_dashboard_safe.py"
|
||||
---
|
||||
|
||||
## Gotchas que maneja
|
||||
|
||||
### 1. 413 Payload Too Large — campo `.card` denormalizado
|
||||
GET /api/dashboard/:id devuelve cada dashcard con un campo `card` que contiene el objeto completo de la question (dataset_query, visualization_settings, result_metadata, etc.). Ese campo puede pesar varios KB por dashcard. PUT /api/dashboard/:id lo rechaza con 413 si se incluye.
|
||||
|
||||
Esta funcion hace strip automatico, conservando solo los campos aceptados por la API:
|
||||
`id`, `card_id`, `dashboard_tab_id`, `col`, `row`, `size_x`, `size_y`, `parameter_mappings`, `visualization_settings`, `series`, `action_id`, `inline_parameters`.
|
||||
|
||||
### 2. 500 FK violation — tabs ausentes
|
||||
Si el body del PUT no incluye `tabs`, Metabase interpreta que se deben borrar todas las tabs. Los dashcards que referencian esas tabs via `dashboard_tab_id` quedan con FK dangling y se produce 500 Internal Server Error.
|
||||
|
||||
Esta funcion siempre hace GET del estado actual y re-incluye `current_tabs` en el body.
|
||||
|
||||
### 3. IDs negativos para dashcards nuevos
|
||||
Los dashcards nuevos deben tener `id` negativo temporal (-1, -2, ...) en el payload del PUT. Sin el campo `id`, Metabase los ignora. Esta funcion asigna IDs negativos automaticamente a cualquier dashcard sin `id` en `dashcards_add` o en `dashcards_update`.
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```python
|
||||
from metabase import MetabaseClient, metabase_update_dashboard_safe
|
||||
|
||||
client = MetabaseClient('https://metabase.example.com', 'token...')
|
||||
|
||||
# Añadir una card al dashboard
|
||||
result = metabase_update_dashboard_safe(
|
||||
client,
|
||||
dashboard_id=42,
|
||||
dashcards_add=[{
|
||||
"card_id": 100,
|
||||
"col": 0,
|
||||
"row": 0,
|
||||
"size_x": 6,
|
||||
"size_y": 4,
|
||||
"parameter_mappings": [],
|
||||
"visualization_settings": {},
|
||||
}],
|
||||
)
|
||||
print(result["added"]) # [-1]
|
||||
print(result["updated"]) # N dashcards existentes conservados
|
||||
|
||||
# Reemplazar todos los dashcards y cambiar nombre
|
||||
dash = client.request("GET", "/api/dashboard/42")
|
||||
cards = dash["dashcards"]
|
||||
cards.append({"card_id": 200, "col": 6, "row": 0, "size_x": 6, "size_y": 4})
|
||||
result = metabase_update_dashboard_safe(
|
||||
client,
|
||||
dashboard_id=42,
|
||||
dashcards_update=cards,
|
||||
extra_fields={"name": "Dashboard actualizado"},
|
||||
)
|
||||
```
|
||||
|
||||
## Notas
|
||||
|
||||
Realiza siempre 2 requests (GET + PUT). Para operaciones de solo metadata sin tocar cards (ej. cambiar nombre), usar `metabase_update_dashboard` directamente que solo hace PUT.
|
||||
|
||||
En caso de 413 o 500, el mensaje de error incluye diagnostico del gotcha mas probable para facilitar debugging.
|
||||
@@ -0,0 +1,212 @@
|
||||
"""Wrapper seguro sobre PUT /api/dashboard/:id que maneja los gotchas conocidos."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import httpx
|
||||
|
||||
from .client import MetabaseClient
|
||||
|
||||
|
||||
# Campos permitidos en un dashcard al enviar a la API de Metabase.
|
||||
# Cualquier otro campo (especialmente 'card' denormalizado) causa 413.
|
||||
_DASHCARD_ALLOWED_KEYS = {
|
||||
"id",
|
||||
"card_id",
|
||||
"dashboard_tab_id",
|
||||
"col",
|
||||
"row",
|
||||
"size_x",
|
||||
"size_y",
|
||||
"parameter_mappings",
|
||||
"visualization_settings",
|
||||
"series",
|
||||
"action_id",
|
||||
"inline_parameters",
|
||||
}
|
||||
|
||||
|
||||
def _strip_dashcard(dc: dict) -> dict:
|
||||
"""Elimina campos denormalizados de un dashcard, conservando solo los permitidos.
|
||||
|
||||
El campo ``card`` contiene el objeto completo de la question (con dataset_query,
|
||||
visualization_settings, etc.) y puede superar facilmente el limite de payload.
|
||||
Metabase lo añade en las respuestas GET pero lo rechaza (413) en PUT.
|
||||
|
||||
Args:
|
||||
dc: Dict de dashcard tal como lo devuelve GET /api/dashboard/:id.
|
||||
|
||||
Returns:
|
||||
Dict con solo los campos que acepta PUT /api/dashboard/:id.
|
||||
"""
|
||||
return {k: v for k, v in dc.items() if k in _DASHCARD_ALLOWED_KEYS}
|
||||
|
||||
|
||||
def metabase_update_dashboard_safe(
|
||||
client: MetabaseClient,
|
||||
dashboard_id: int,
|
||||
*,
|
||||
dashcards_update: list[dict] | None = None,
|
||||
dashcards_add: list[dict] | None = None,
|
||||
dashcards_remove: list[int] | None = None,
|
||||
extra_fields: dict | None = None,
|
||||
) -> dict:
|
||||
"""Actualiza un dashboard de Metabase manejando los tres gotchas conocidos.
|
||||
|
||||
Gotchas que maneja automaticamente:
|
||||
1. **413 Payload Too Large**: strippea el campo ``.card`` denormalizado de
|
||||
cada dashcard antes de enviar. Metabase incluye ese objeto en GET pero
|
||||
lo rechaza en PUT.
|
||||
2. **500 FK violation (tabs)**: siempre incluye el array ``tabs`` actual en
|
||||
el body del PUT. Si se omite, Metabase borra las tabs y viola FK
|
||||
constraints de dashcards que las referencian.
|
||||
3. **IDs negativos para dashcards nuevos**: los dashcards sin ``id``
|
||||
(nuevos) reciben IDs negativos temporales (-1, -2, ...) que Metabase
|
||||
reemplaza con IDs reales en la respuesta.
|
||||
|
||||
Flujo:
|
||||
1. GET /api/dashboard/:id — obtiene estado actual (dashcards + tabs).
|
||||
2. Construye la lista final de dashcards:
|
||||
- Si ``dashcards_update`` dado: usarla directamente (tras strip).
|
||||
- Si ``dashcards_add`` y/o ``dashcards_remove``: aplicar sobre existentes.
|
||||
- Sin ninguno: mantener existentes sin cambios.
|
||||
3. Strip de campos denormalizados en todos los dashcards.
|
||||
4. Asignar IDs negativos a dashcards nuevos (sin ``id``).
|
||||
5. PUT /api/dashboard/:id con ``{dashcards, tabs, **extra_fields}``.
|
||||
6. Devolver resumen de operacion.
|
||||
|
||||
Args:
|
||||
client: Cliente autenticado con sesion activa.
|
||||
dashboard_id: ID del dashboard a actualizar.
|
||||
dashcards_update: Lista completa de dashcards que REEMPLAZA el estado
|
||||
actual. Si se da, ``dashcards_add`` y ``dashcards_remove`` se ignoran.
|
||||
dashcards_add: Dashcards a añadir sobre los existentes. Cada item
|
||||
no debe incluir ``id`` — la funcion asigna IDs negativos.
|
||||
dashcards_remove: Lista de IDs de dashcards existentes a eliminar.
|
||||
extra_fields: Campos adicionales del dashboard a actualizar
|
||||
(name, description, parameters, archived, collection_id, etc.).
|
||||
|
||||
Returns:
|
||||
Dict con resumen de la operacion::
|
||||
|
||||
{
|
||||
"added": [list of temp negative ids assigned],
|
||||
"updated": int, # dashcards existentes conservados
|
||||
"removed": int, # dashcards eliminados
|
||||
"response": dict, # respuesta raw de PUT /api/dashboard/:id
|
||||
}
|
||||
|
||||
Raises:
|
||||
httpx.HTTPStatusError: Si la API devuelve 4xx/5xx con mensaje de
|
||||
diagnostico indicando que gotcha probablemente se activo.
|
||||
|
||||
Example:
|
||||
>>> # Añadir una card al dashboard
|
||||
>>> result = metabase_update_dashboard_safe(
|
||||
... client,
|
||||
... dashboard_id=42,
|
||||
... dashcards_add=[{
|
||||
... "card_id": 100,
|
||||
... "col": 0,
|
||||
... "row": 0,
|
||||
... "size_x": 6,
|
||||
... "size_y": 4,
|
||||
... "parameter_mappings": [],
|
||||
... "visualization_settings": {},
|
||||
... }],
|
||||
... )
|
||||
>>> print(result["added"]) # [-1]
|
||||
|
||||
>>> # Reemplazar todos los dashcards y cambiar el nombre
|
||||
>>> result = metabase_update_dashboard_safe(
|
||||
... client,
|
||||
... dashboard_id=42,
|
||||
... dashcards_update=existing_dashcards,
|
||||
... extra_fields={"name": "Nuevo nombre"},
|
||||
... )
|
||||
>>> print(result["updated"])
|
||||
"""
|
||||
# ---- Paso 1: GET estado actual ------------------------------------------
|
||||
current = client.request("GET", f"/api/dashboard/{dashboard_id}")
|
||||
current_dashcards: list[dict] = current.get("dashcards") or []
|
||||
current_tabs: list[dict] = current.get("tabs") or []
|
||||
|
||||
# ---- Paso 2: Construir lista final de dashcards -------------------------
|
||||
removed_count = 0
|
||||
added_ids: list[int] = []
|
||||
|
||||
if dashcards_update is not None:
|
||||
# Reemplazo completo: usar tal cual (tras strip)
|
||||
final_dashcards = list(dashcards_update)
|
||||
# Contabilizar: existentes que siguen presentes vs removidos
|
||||
existing_ids = {dc.get("id") for dc in current_dashcards if dc.get("id")}
|
||||
final_pos_ids = {dc.get("id") for dc in final_dashcards if dc.get("id") and dc.get("id", 0) > 0}
|
||||
removed_count = len(existing_ids - final_pos_ids)
|
||||
else:
|
||||
# Operacion incremental: partir de existentes
|
||||
remove_set: set[int] = set(dashcards_remove or [])
|
||||
final_dashcards = [dc for dc in current_dashcards if dc.get("id") not in remove_set]
|
||||
removed_count = len(remove_set)
|
||||
|
||||
if dashcards_add:
|
||||
final_dashcards = list(final_dashcards) + list(dashcards_add)
|
||||
|
||||
# ---- Paso 3 & 4: Strip + asignar IDs negativos --------------------------
|
||||
cleaned: list[dict] = []
|
||||
next_neg_id = -1
|
||||
existing_count = 0
|
||||
|
||||
for dc in final_dashcards:
|
||||
dc_clean = _strip_dashcard(dc)
|
||||
|
||||
if "id" not in dc_clean or dc_clean.get("id") is None:
|
||||
# Dashcard nuevo sin id: asignar negativo
|
||||
dc_clean["id"] = next_neg_id
|
||||
added_ids.append(next_neg_id)
|
||||
next_neg_id -= 1
|
||||
elif isinstance(dc_clean.get("id"), int) and dc_clean["id"] < 0:
|
||||
# Ya tiene id negativo (caller lo asigno): registrar
|
||||
added_ids.append(dc_clean["id"])
|
||||
else:
|
||||
existing_count += 1
|
||||
|
||||
cleaned.append(dc_clean)
|
||||
|
||||
# ---- Paso 5: Construir body y PUT ----------------------------------------
|
||||
body: dict = {
|
||||
"dashcards": cleaned,
|
||||
"tabs": current_tabs,
|
||||
}
|
||||
if extra_fields:
|
||||
body.update(extra_fields)
|
||||
|
||||
try:
|
||||
response = client.request("PUT", f"/api/dashboard/{dashboard_id}", json=body)
|
||||
except httpx.HTTPStatusError as exc:
|
||||
status = exc.response.status_code
|
||||
if status == 413:
|
||||
raise httpx.HTTPStatusError(
|
||||
f"413 Payload Too Large al actualizar dashboard {dashboard_id}. "
|
||||
"Probable causa: dashcard con campo '.card' denormalizado sin strippear. "
|
||||
"Revisar que dashcards_update no contenga el objeto 'card' completo.",
|
||||
request=exc.request,
|
||||
response=exc.response,
|
||||
) from exc
|
||||
if status == 500:
|
||||
raise httpx.HTTPStatusError(
|
||||
f"500 Internal Server Error al actualizar dashboard {dashboard_id}. "
|
||||
"Posibles causas: (a) tabs FK violation — el body no incluyo 'tabs' "
|
||||
"(no deberia ocurrir con esta funcion); "
|
||||
"(b) dashcard referencia tab_id inexistente; "
|
||||
"(c) error interno de Metabase. "
|
||||
f"Body enviado tenia {len(cleaned)} dashcards y {len(current_tabs)} tabs.",
|
||||
request=exc.request,
|
||||
response=exc.response,
|
||||
) from exc
|
||||
raise
|
||||
|
||||
return {
|
||||
"added": added_ids,
|
||||
"updated": existing_count,
|
||||
"removed": removed_count,
|
||||
"response": response,
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
---
|
||||
name: metabase_update_document
|
||||
kind: function
|
||||
lang: py
|
||||
domain: infra
|
||||
version: "1.0.0"
|
||||
purity: impure
|
||||
signature: "def metabase_update_document(client: MetabaseClient, document_id: int, **fields) -> dict"
|
||||
description: "Actualiza un document. Solo envia los campos pasados. Endpoint: PUT /api/document/:id."
|
||||
tags: [metabase, document, update, api, python]
|
||||
uses_functions: []
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: "error_go_core"
|
||||
imports: [httpx]
|
||||
params:
|
||||
- name: client
|
||||
desc: "instancia autenticada de MetabaseClient"
|
||||
- name: document_id
|
||||
desc: "ID del document a actualizar"
|
||||
- name: fields
|
||||
desc: "kwargs con campos a modificar: name, document (arbol ProseMirror), collection_id, archived"
|
||||
output: "dict: document actualizado"
|
||||
tested: false
|
||||
tests: []
|
||||
test_file_path: ""
|
||||
file_path: "python/functions/metabase/documents.py"
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```python
|
||||
# Renombrar
|
||||
metabase_update_document(client, 1, name="Nuevo titulo")
|
||||
|
||||
# Reemplazar contenido completo
|
||||
metabase_update_document(client, 1, document={
|
||||
"type": "doc",
|
||||
"content": [{"type": "paragraph", "content": [{"type": "text", "text": "Nuevo"}]}]
|
||||
})
|
||||
|
||||
# Mover a coleccion
|
||||
metabase_update_document(client, 1, collection_id=5)
|
||||
```
|
||||
@@ -0,0 +1,42 @@
|
||||
---
|
||||
name: metabase_update_group
|
||||
kind: function
|
||||
lang: py
|
||||
domain: infra
|
||||
version: "1.0.0"
|
||||
purity: impure
|
||||
signature: "def metabase_update_group(client: MetabaseClient, group_id: int, name: str) -> dict"
|
||||
description: "Renombra un Permission Group en Metabase. La API solo permite modificar el nombre del grupo. Endpoint: PUT /api/permissions/group/:id."
|
||||
tags: [metabase, permissions, group, update, rename, api, python]
|
||||
uses_functions: []
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: "error_go_core"
|
||||
imports: []
|
||||
params:
|
||||
- name: client
|
||||
desc: "instancia autenticada de MetabaseClient con permisos de superusuario"
|
||||
- name: group_id
|
||||
desc: "ID numerico del grupo a renombrar"
|
||||
- name: name
|
||||
desc: "nuevo nombre del grupo"
|
||||
output: "dict: grupo actualizado con id y name"
|
||||
tested: false
|
||||
tests: []
|
||||
test_file_path: ""
|
||||
file_path: "python/functions/metabase/permissions.py"
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```python
|
||||
group = metabase_update_group(client, 3, "Data Team")
|
||||
print(group["name"]) # "Data Team"
|
||||
```
|
||||
|
||||
## Notas
|
||||
|
||||
La API de Metabase para grupos solo expone el campo `name` como modificable via PUT.
|
||||
Para cambiar miembros usar la API de memberships (/api/permissions/membership).
|
||||
Error 404 si el grupo no existe.
|
||||
@@ -0,0 +1,73 @@
|
||||
---
|
||||
name: metabase_update_permission_graph
|
||||
kind: function
|
||||
lang: py
|
||||
domain: infra
|
||||
version: "1.0.0"
|
||||
purity: impure
|
||||
signature: "def metabase_update_permission_graph(client: MetabaseClient, graph: dict) -> dict"
|
||||
description: "Actualiza el grafo de permisos de datos en Metabase. Endpoint: PUT /api/permissions/graph. El campo revision en el graph es obligatorio — el servidor rechaza con 409 si no coincide con el actual."
|
||||
tags: [metabase, permissions, graph, databases, schemas, access-control, update, api, python]
|
||||
uses_functions: []
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: "error_go_core"
|
||||
imports: []
|
||||
params:
|
||||
- name: client
|
||||
desc: "instancia autenticada de MetabaseClient con permisos de superusuario"
|
||||
- name: graph
|
||||
desc: "dict con el grafo completo incluyendo el campo revision actual. Debe obtenerse vía metabase_get_permission_graph justo antes de modificar"
|
||||
output: "dict: nuevo grafo tras la actualización con revision incrementado"
|
||||
tested: false
|
||||
tests: []
|
||||
test_file_path: ""
|
||||
file_path: "python/functions/metabase/permissions.py"
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```python
|
||||
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"])
|
||||
```
|
||||
|
||||
## Control de concurrencia por revision
|
||||
|
||||
El campo `graph["revision"]` es el mecanismo de optimistic locking nativo de Metabase.
|
||||
|
||||
**Patrón obligatorio:**
|
||||
|
||||
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.** Si otro proceso modificó el graph entre el GET y el PUT, Metabase devuelve HTTP 409 Conflict y el caller debe reintentar desde el GET.
|
||||
|
||||
### Estructura de permisos por database
|
||||
|
||||
```python
|
||||
# Acceso completo con SQL nativo
|
||||
graph["groups"]["3"]["1"] = {"schemas": "all", "native": "write"}
|
||||
|
||||
# Solo lectura, sin SQL nativo
|
||||
graph["groups"]["3"]["1"] = {"schemas": "all", "native": "none"}
|
||||
|
||||
# Sin acceso
|
||||
graph["groups"]["3"]["1"] = {"schemas": "none", "native": "none"}
|
||||
|
||||
# Acceso granular por schema/tabla
|
||||
graph["groups"]["3"]["1"] = {
|
||||
"schemas": {
|
||||
"public": {
|
||||
"orders": {"read": "all"},
|
||||
"users": {"read": "none"},
|
||||
}
|
||||
},
|
||||
"native": "none",
|
||||
}
|
||||
```
|
||||
@@ -0,0 +1,70 @@
|
||||
---
|
||||
name: metabase_validate_card_payload
|
||||
kind: function
|
||||
lang: py
|
||||
domain: infra
|
||||
version: "1.0.0"
|
||||
purity: pure
|
||||
signature: "def metabase_validate_card_payload(payload: dict) -> list[str]"
|
||||
description: "Valida la estructura de un payload de card de Metabase sin necesidad de red. Recorre todos los checks y acumula todos los issues en vez de abortar al primero. Retorna lista vacia si el payload es valido."
|
||||
tags: [metabase, validation, card, pure, pre-flight, structural]
|
||||
uses_functions: []
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: ""
|
||||
imports: []
|
||||
params:
|
||||
- name: payload
|
||||
desc: "dict con los campos de la card a validar: name, display, dataset_query, type (opcional), visualization_settings (opcional), parameters (opcional), archived (opcional)"
|
||||
output: "lista de strings describiendo cada issue estructural encontrado; lista vacia indica payload valido listo para enviarse a POST/PUT /api/card"
|
||||
tested: true
|
||||
tests:
|
||||
- "card valido retorna lista vacia"
|
||||
- "card display invalido"
|
||||
- "card display ausente"
|
||||
- "card name ausente"
|
||||
- "card name vacio"
|
||||
- "card dataset query ausente"
|
||||
- "card dataset query sin database"
|
||||
- "card nativa sin sql"
|
||||
- "card nativa mbql5"
|
||||
- "card type invalido"
|
||||
- "card type valido"
|
||||
- "card visualization settings no dict"
|
||||
- "card parameters no list"
|
||||
- "card archived no bool"
|
||||
- "card acumula multiples errores"
|
||||
test_file_path: "python/functions/metabase/validation_test.py"
|
||||
file_path: "python/functions/metabase/validation.py"
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```python
|
||||
issues = metabase_validate_card_payload({
|
||||
"name": "Revenue by Month",
|
||||
"display": "line",
|
||||
"dataset_query": {
|
||||
"database": 1,
|
||||
"type": "native",
|
||||
"native": {"query": "SELECT date_trunc('month', created_at), SUM(total) FROM orders GROUP BY 1"},
|
||||
},
|
||||
})
|
||||
if issues:
|
||||
print("Payload invalido:", issues)
|
||||
else:
|
||||
metabase_update_card(client, card_id, **payload)
|
||||
```
|
||||
|
||||
## Notas
|
||||
|
||||
Displays validos: scalar, table, line, bar, pie, area, row, funnel, smartscalar, gauge, progress, combo, pivot, map, scatter, waterfall, sankey, object.
|
||||
|
||||
Tipos validos (campo `type`): question, model, metric.
|
||||
|
||||
Soporta dos formatos de SQL nativo:
|
||||
- Legacy: `dataset_query.native.query`
|
||||
- MBQL5: `dataset_query.stages[0].native`
|
||||
|
||||
No aborta al primer error — recolecta todos los issues para que el caller pueda mostrarlos todos de una vez.
|
||||
@@ -0,0 +1,63 @@
|
||||
---
|
||||
name: metabase_validate_dashboard_payload
|
||||
kind: function
|
||||
lang: py
|
||||
domain: infra
|
||||
version: "1.0.0"
|
||||
purity: pure
|
||||
signature: "def metabase_validate_dashboard_payload(payload: dict, known_card_ids: set[int]) -> list[str]"
|
||||
description: "Valida la estructura de un payload de dashboard de Metabase sin red. Verifica campos obligatorios, bounds de dashcards, referencias a cards conocidas y solapamientos entre dashcards. Acumula todos los issues."
|
||||
tags: [metabase, validation, dashboard, dashcard, overlap, pure, pre-flight, structural]
|
||||
uses_functions: []
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: ""
|
||||
imports: []
|
||||
params:
|
||||
- name: payload
|
||||
desc: "dict con los campos del dashboard a validar: name, dashcards (opcional), tabs (opcional), parameters (opcional)"
|
||||
- name: known_card_ids
|
||||
desc: "conjunto de IDs enteros de cards que existen en Metabase; las dashcards con card_id entero deben referenciar un ID de este conjunto. Pasar set vacio si no se quiere verificar referencias."
|
||||
output: "lista de strings describiendo cada issue encontrado; lista vacia indica payload valido listo para enviarse a PUT /api/dashboard/:id"
|
||||
tested: true
|
||||
tests:
|
||||
- "dashboard valido sin dashcards"
|
||||
- "dashboard valido con dashcards"
|
||||
- "dashboard card id desconocido"
|
||||
- "dashboard card virtual null permitido"
|
||||
- "dashboard dashcards solapadas"
|
||||
- "dashboard dashcards adyacentes no solapan"
|
||||
- "dashboard col fuera de bounds"
|
||||
- "dashboard col mas size x excede grid"
|
||||
- "dashboard size y fuera de bounds"
|
||||
- "dashboard name ausente"
|
||||
- "dashboard tabs invalidos"
|
||||
- "dashboard parameters no list"
|
||||
test_file_path: "python/functions/metabase/validation_test.py"
|
||||
file_path: "python/functions/metabase/validation.py"
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```python
|
||||
known_ids = {c["id"] for c in metabase_list_cards(client)}
|
||||
issues = metabase_validate_dashboard_payload(dashboard_payload, known_card_ids=known_ids)
|
||||
if issues:
|
||||
print("Dashboard invalido:")
|
||||
for issue in issues:
|
||||
print(f" - {issue}")
|
||||
else:
|
||||
metabase_update_dashboard(client, dashboard_id, **dashboard_payload)
|
||||
```
|
||||
|
||||
## Notas
|
||||
|
||||
Bounds del grid de Metabase:
|
||||
- `col` in [0, 23], `row` >= 0
|
||||
- `size_x` in [1, 24], `size_y` in [1, 100]
|
||||
- `col + size_x <= 24` (no exceder el ancho del grid)
|
||||
|
||||
Deteccion de solapamientos: O(n^2) sobre pares de dashcards. Optimo para dashboards tipicos (< 50 cards).
|
||||
|
||||
Dashcards con `card_id = null` son virtuales (texto, headings, iframes) y se permiten sin verificar contra known_card_ids.
|
||||
@@ -0,0 +1,57 @@
|
||||
---
|
||||
name: metabase_validate_document_payload
|
||||
kind: function
|
||||
lang: py
|
||||
domain: infra
|
||||
version: "1.0.0"
|
||||
purity: pure
|
||||
signature: "def metabase_validate_document_payload(payload: dict, known_card_slugs: set[str] | None = None) -> list[str]"
|
||||
description: "Valida un arbol ProseMirror contra la whitelist de nodos y marks que el editor TipTap de Metabase renderiza. Detecta nodos desconocidos que la API acepta pero el frontend descarta silenciosamente."
|
||||
tags: [metabase, document, prosemirror, validate, pure, python]
|
||||
uses_functions: []
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: ""
|
||||
imports: []
|
||||
params:
|
||||
- name: payload
|
||||
desc: "payload del document (name, document=arbol ProseMirror, archived)"
|
||||
- name: known_card_slugs
|
||||
desc: "set de slugs de cards del index para validar cardEmbed.attrs.card (None = skip)"
|
||||
output: "list[str]: warnings describiendo nodos/marks no soportados o violaciones del schema. Lista vacia = payload renderizable"
|
||||
tested: true
|
||||
tests: [test_document_valido_minimo, test_document_nodo_desconocido_callout, test_document_mark_desconocido_underline, test_document_heading_level_invalido, test_document_cardEmbed_sin_id_ni_slug, test_document_flexContainer_demasiados_hijos, test_document_kitchen_sink_valido]
|
||||
test_file_path: "python/functions/metabase/validation_test.py"
|
||||
file_path: "python/functions/metabase/validation.py"
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```python
|
||||
issues = metabase_validate_document_payload({
|
||||
"name": "Notas",
|
||||
"document": {
|
||||
"type": "doc",
|
||||
"content": [
|
||||
{"type": "callout", "content": [...]} # ← no soportado por TipTap
|
||||
],
|
||||
},
|
||||
})
|
||||
# → ["document.content[0]: nodo 'callout' no soportado..."]
|
||||
```
|
||||
|
||||
## Notas
|
||||
|
||||
**Whitelist de nodos** (derivada de inspeccionar el bundle de Metabase v0.59):
|
||||
`doc, paragraph, text, heading, bulletList, orderedList, listItem, blockquote, codeBlock, horizontalRule, hardBreak, cardEmbed, flexContainer, smartLink, resizeNode, mention`
|
||||
|
||||
**Whitelist de marks:** `bold, italic, strike, code, link`
|
||||
|
||||
Nodos comunes de ProseMirror que la API acepta pero el editor **no renderiza** (el resultado es un documento vacio o incompleto en la UI): `callout, taskList, taskItem, details, table, image, iframe`. Marks equivalentes: `underline, highlight, subscript, textStyle`.
|
||||
|
||||
Restricciones estructurales adicionales:
|
||||
- `heading.attrs.level` ∈ [1, 6]
|
||||
- `flexContainer` acepta 1-3 hijos, solo `cardEmbed` o `supportingText`
|
||||
- `flexContainer.attrs.columnWidths` debe tener el mismo largo que `content`
|
||||
- `cardEmbed.attrs` requiere `id` (int) o `card` (slug del index)
|
||||
@@ -0,0 +1,58 @@
|
||||
---
|
||||
name: metabase_validate_sql
|
||||
kind: function
|
||||
lang: py
|
||||
domain: infra
|
||||
version: "1.0.0"
|
||||
purity: impure
|
||||
signature: "def metabase_validate_sql(client: MetabaseClient, database_id: int, sql: str, max_rows: int = 0) -> dict"
|
||||
description: "Valida sintaxis y referencias de una query SQL ejecutandola contra Metabase via POST /api/dataset. Captura tanto errores HTTP como errores embebidos en el body (Metabase a veces devuelve 200 con status failed). Retorna ok, error y rows_returned."
|
||||
tags: [metabase, validation, sql, pre-flight, impure, query, syntax-check]
|
||||
uses_functions: [metabase_execute_query_py_infra, metabase_auth_py_infra]
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: "error_go_core"
|
||||
imports: [httpx]
|
||||
params:
|
||||
- name: client
|
||||
desc: "instancia autenticada de MetabaseClient con sesion activa"
|
||||
- name: database_id
|
||||
desc: "ID de la base de datos en Metabase contra la que ejecutar el SQL"
|
||||
- name: sql
|
||||
desc: "sentencia SQL a validar (SELECT, WITH, etc.) — se ejecuta realmente contra la BD"
|
||||
- name: max_rows
|
||||
desc: "limite de filas a retornar durante la validacion para minimizar carga (0 = default de Metabase, tipicamente 2000)"
|
||||
output: "dict con tres claves: ok (bool) indica si la query se ejecuto sin error; error (str|None) mensaje de error si ok=False; rows_returned (int) numero de filas devueltas si ok=True"
|
||||
tested: false
|
||||
tests: []
|
||||
test_file_path: ""
|
||||
file_path: "python/functions/metabase/validation.py"
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```python
|
||||
result = metabase_validate_sql(client, database_id=1, sql="SELECT id FROM orders LIMIT 1")
|
||||
if not result["ok"]:
|
||||
print(f"SQL invalido: {result['error']}")
|
||||
else:
|
||||
print(f"SQL valido, {result['rows_returned']} filas retornadas")
|
||||
```
|
||||
|
||||
## Notas
|
||||
|
||||
No tiene tests automatizados porque requiere una instancia Metabase corriendo con una base de datos real. Para testear manualmente:
|
||||
|
||||
```python
|
||||
client = metabase_auth("http://localhost:3000", "admin@example.com", "pass")
|
||||
result = metabase_validate_sql(client, 1, "SELECT * FROM tabla_inexistente")
|
||||
# result["ok"] == False, result["error"] == "Table 'tabla_inexistente' not found"
|
||||
```
|
||||
|
||||
Comportamiento ante errores:
|
||||
- `httpx.HTTPStatusError` (4xx/5xx): extrae el campo `error` o `message` del JSON del response de Metabase.
|
||||
- Body con `status: "failed"` (Metabase devuelve 200 en algunos errores de query): captura el campo `error` del body.
|
||||
- Cualquier otra excepcion: convierte a string y la incluye en el campo `error`.
|
||||
|
||||
Usa `metabase_execute_query` internamente, que mapea a POST /api/dataset.
|
||||
@@ -0,0 +1,369 @@
|
||||
"""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)
|
||||
@@ -0,0 +1,275 @@
|
||||
"""Tests para metabase_mbql_validate."""
|
||||
|
||||
import sys
|
||||
import os
|
||||
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
|
||||
|
||||
from metabase.metabase_mbql_validate import metabase_mbql_validate
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# DQ valido: estructura simplificada basada en card 5705 post-fix
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
VALID_DQ = {
|
||||
"lib/type": "mbql/query",
|
||||
"database": 6,
|
||||
"stages": [
|
||||
{
|
||||
"lib/type": "mbql.stage/mbql",
|
||||
"source-card": 100,
|
||||
"aggregation": [
|
||||
[
|
||||
"sum",
|
||||
{"lib/uuid": "agg-uuid-1"},
|
||||
[
|
||||
"field",
|
||||
{"base-type": "type/Decimal", "lib/uuid": "field-uuid-1"},
|
||||
"Cantidad",
|
||||
],
|
||||
]
|
||||
],
|
||||
"expressions": [
|
||||
[
|
||||
"-",
|
||||
{
|
||||
"lib/uuid": "expr-uuid-1",
|
||||
"lib/expression-name": "Diferencia_Cantidad",
|
||||
},
|
||||
[
|
||||
"field",
|
||||
{"base-type": "type/Decimal", "lib/uuid": "field-uuid-2"},
|
||||
"Valor_A",
|
||||
],
|
||||
[
|
||||
"field",
|
||||
{"base-type": "type/Decimal", "lib/uuid": "field-uuid-3"},
|
||||
"Valor_B",
|
||||
],
|
||||
]
|
||||
],
|
||||
},
|
||||
{
|
||||
"lib/type": "mbql.stage/mbql",
|
||||
"expressions": [
|
||||
[
|
||||
"case",
|
||||
{
|
||||
"lib/uuid": "case-uuid-1",
|
||||
"lib/expression-name": "Valor medio de venta",
|
||||
},
|
||||
[
|
||||
[
|
||||
[
|
||||
"=",
|
||||
{"lib/uuid": "cond-uuid-1"},
|
||||
[
|
||||
"field",
|
||||
{
|
||||
"base-type": "type/Integer",
|
||||
"lib/uuid": "field-uuid-4",
|
||||
},
|
||||
"Tickets",
|
||||
],
|
||||
0,
|
||||
],
|
||||
0,
|
||||
],
|
||||
[
|
||||
[
|
||||
"!=",
|
||||
{"lib/uuid": "cond-uuid-2"},
|
||||
[
|
||||
"field",
|
||||
{
|
||||
"base-type": "type/Integer",
|
||||
"lib/uuid": "field-uuid-5",
|
||||
},
|
||||
"Tickets",
|
||||
],
|
||||
0,
|
||||
],
|
||||
[
|
||||
"/",
|
||||
{"lib/uuid": "div-uuid-1"},
|
||||
[
|
||||
"field",
|
||||
{
|
||||
"base-type": "type/Decimal",
|
||||
"lib/uuid": "field-uuid-6",
|
||||
},
|
||||
"Valor_vendido",
|
||||
],
|
||||
[
|
||||
"field",
|
||||
{
|
||||
"base-type": "type/Integer",
|
||||
"lib/uuid": "field-uuid-7",
|
||||
},
|
||||
"Tickets",
|
||||
],
|
||||
],
|
||||
],
|
||||
],
|
||||
]
|
||||
],
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def test_valid_dq_returns_no_errors():
|
||||
"""DQ valido retorna lista vacia."""
|
||||
errors = metabase_mbql_validate(VALID_DQ)
|
||||
assert errors == [], f"Esperaba 0 errores, got: {errors}"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Check 1: UUID duplicado
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_duplicate_uuid_detected():
|
||||
"""UUID repetido en el arbol MBQL genera error."""
|
||||
dq = {
|
||||
"stages": [
|
||||
{
|
||||
"lib/type": "mbql.stage/mbql",
|
||||
"expressions": [
|
||||
[
|
||||
"-",
|
||||
{"lib/uuid": "dup-uuid-001", "lib/expression-name": "ExprA"},
|
||||
["field", {"base-type": "type/Decimal", "lib/uuid": "dup-uuid-001"}, "CampoX"],
|
||||
["field", {"base-type": "type/Decimal", "lib/uuid": "unique-uuid-002"}, "CampoY"],
|
||||
]
|
||||
],
|
||||
}
|
||||
]
|
||||
}
|
||||
errors = metabase_mbql_validate(dq)
|
||||
assert any("dup-uuid-001" in e for e in errors), f"Esperaba error de UUID duplicado, got: {errors}"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Check 2: Stage mixing — expressions referencian slots de aggregation
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_stage_mixing_detected():
|
||||
"""Stage con aggregations y expressions que referencian slots generados."""
|
||||
dq = {
|
||||
"stages": [
|
||||
{
|
||||
"lib/type": "mbql.stage/mbql",
|
||||
"aggregation": [
|
||||
["sum", {"lib/uuid": "agg1"}, ["field", {"base-type": "type/Decimal", "lib/uuid": "f1"}, "Precio"]]
|
||||
],
|
||||
"expressions": [
|
||||
[
|
||||
"*",
|
||||
{"lib/uuid": "expr2", "lib/expression-name": "DobleSum"},
|
||||
# field sin base-type y nombre 'sum' = slot ref de aggregation
|
||||
["field", {"lib/uuid": "ref-slot"}, "sum"],
|
||||
2,
|
||||
]
|
||||
],
|
||||
}
|
||||
]
|
||||
}
|
||||
errors = metabase_mbql_validate(dq)
|
||||
assert any("mezcla" in e.lower() or "mixing" in e.lower() or "stage" in e.lower() for e in errors), \
|
||||
f"Esperaba error de stage mixing, got: {errors}"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Check 3: Slot ref rota — sum_99 inexistente
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_broken_slot_ref_detected():
|
||||
"""Expression que referencia sum_99 cuando solo hay 1 sum genera error."""
|
||||
dq = {
|
||||
"stages": [
|
||||
{
|
||||
"lib/type": "mbql.stage/mbql",
|
||||
"aggregation": [
|
||||
["sum", {"lib/uuid": "agg-s1"}, ["field", {"base-type": "type/Decimal", "lib/uuid": "f-s1"}, "Precio"]]
|
||||
],
|
||||
"expressions": [
|
||||
[
|
||||
"+",
|
||||
{"lib/uuid": "expr-broken", "lib/expression-name": "SumRota"},
|
||||
# sum_99 no existe (solo hay 1 sum = sum_0)
|
||||
["field", {"lib/uuid": "ref-broken"}, "sum_99"],
|
||||
1,
|
||||
]
|
||||
],
|
||||
}
|
||||
]
|
||||
}
|
||||
errors = metabase_mbql_validate(dq)
|
||||
assert any("sum_99" in e for e in errors), f"Esperaba error de slot ref roto, got: {errors}"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Check 4: Case con estructura invalida
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_case_malformed_cases_not_pairs():
|
||||
"""Case con casos que no son pares [cond, result] genera error."""
|
||||
dq = {
|
||||
"stages": [
|
||||
{
|
||||
"lib/type": "mbql.stage/mbql",
|
||||
"expressions": [
|
||||
[
|
||||
"case",
|
||||
{"lib/uuid": "case-bad", "lib/expression-name": "CaseMalo"},
|
||||
# lista con un solo elemento (no par)
|
||||
[["solo_elemento"]],
|
||||
]
|
||||
],
|
||||
}
|
||||
]
|
||||
}
|
||||
errors = metabase_mbql_validate(dq)
|
||||
assert any("case" in e.lower() for e in errors), f"Esperaba error de case structure, got: {errors}"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Check 5: Name collision en expressions
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_name_collision_detected():
|
||||
"""Dos expressions con mismo lib/expression-name en la misma stage generan error."""
|
||||
dq = {
|
||||
"stages": [
|
||||
{
|
||||
"lib/type": "mbql.stage/mbql",
|
||||
"expressions": [
|
||||
["-", {"lib/uuid": "e1", "lib/expression-name": "MiCalculo"},
|
||||
["field", {"base-type": "type/Decimal", "lib/uuid": "f1"}, "A"],
|
||||
["field", {"base-type": "type/Decimal", "lib/uuid": "f2"}, "B"]],
|
||||
["+", {"lib/uuid": "e2", "lib/expression-name": "MiCalculo"},
|
||||
["field", {"base-type": "type/Decimal", "lib/uuid": "f3"}, "C"],
|
||||
1],
|
||||
],
|
||||
}
|
||||
]
|
||||
}
|
||||
errors = metabase_mbql_validate(dq)
|
||||
assert any("MiCalculo" in e for e in errors), f"Esperaba error de name collision, got: {errors}"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Check: stages ausente
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_missing_stages_returns_error():
|
||||
"""dataset_query sin 'stages' devuelve error de estructura."""
|
||||
errors = metabase_mbql_validate({"database": 1})
|
||||
assert any("stages" in e.lower() for e in errors), f"Esperaba error de stages ausente, got: {errors}"
|
||||
@@ -0,0 +1,505 @@
|
||||
"""Validacion estructural de cards y dashboards de Metabase antes de pusharlos a la API."""
|
||||
|
||||
import httpx
|
||||
|
||||
from .client import MetabaseClient
|
||||
from .cards import metabase_execute_query
|
||||
|
||||
|
||||
_VALID_DISPLAYS = {
|
||||
"scalar", "table", "line", "bar", "pie", "area", "row", "funnel",
|
||||
"smartscalar", "gauge", "progress", "combo", "pivot", "map", "scatter",
|
||||
"waterfall", "sankey", "object",
|
||||
}
|
||||
|
||||
_VALID_TYPES = {"question", "model", "metric"}
|
||||
|
||||
|
||||
def metabase_validate_card_payload(payload: dict) -> list[str]:
|
||||
"""Valida la estructura de un payload de card antes de enviarlo a Metabase.
|
||||
|
||||
Comprueba invariantes estructurales sin necesidad de red. Recorre todos los
|
||||
checks y acumula todos los issues en lugar de abortar al primero.
|
||||
|
||||
Args:
|
||||
payload: dict con los campos de la card a validar (name, display,
|
||||
dataset_query, type, visualization_settings, parameters, archived).
|
||||
|
||||
Returns:
|
||||
Lista de strings describiendo cada issue encontrado. Lista vacia = payload valido.
|
||||
|
||||
Example:
|
||||
>>> issues = metabase_validate_card_payload({"name": "Revenue", "display": "bar",
|
||||
... "dataset_query": {"database": 1, "type": "native",
|
||||
... "native": {"query": "SELECT 1"}}})
|
||||
>>> assert issues == []
|
||||
"""
|
||||
issues: list[str] = []
|
||||
|
||||
# --- name ---
|
||||
name = payload.get("name")
|
||||
if name is None:
|
||||
issues.append("campo 'name' ausente")
|
||||
elif not isinstance(name, str) or not name.strip():
|
||||
issues.append("campo 'name' debe ser un string no vacio")
|
||||
|
||||
# --- display ---
|
||||
display = payload.get("display")
|
||||
if display is None:
|
||||
issues.append("campo 'display' ausente")
|
||||
elif display not in _VALID_DISPLAYS:
|
||||
validos = ", ".join(sorted(_VALID_DISPLAYS))
|
||||
issues.append(f"display '{display}' invalido (validos: {validos})")
|
||||
|
||||
# --- type (opcional) ---
|
||||
card_type = payload.get("type")
|
||||
if card_type is not None and card_type not in _VALID_TYPES:
|
||||
validos = ", ".join(sorted(_VALID_TYPES))
|
||||
issues.append(f"type '{card_type}' invalido (validos: {validos})")
|
||||
|
||||
# --- dataset_query ---
|
||||
dq = payload.get("dataset_query")
|
||||
if dq is None:
|
||||
issues.append("campo 'dataset_query' ausente")
|
||||
elif not isinstance(dq, dict):
|
||||
issues.append("campo 'dataset_query' debe ser un dict")
|
||||
else:
|
||||
# database presente
|
||||
if "database" not in dq:
|
||||
issues.append("'dataset_query.database' ausente")
|
||||
|
||||
# deteccion de query nativa
|
||||
query_type = payload.get("query_type") or dq.get("type", "")
|
||||
is_native = query_type == "native"
|
||||
|
||||
# Tambien chequear si stages[0] tiene clave "native" (formato MBQL5)
|
||||
stages = dq.get("stages", [])
|
||||
has_mbql5_native = (
|
||||
isinstance(stages, list)
|
||||
and len(stages) > 0
|
||||
and isinstance(stages[0], dict)
|
||||
and "native" in stages[0]
|
||||
)
|
||||
|
||||
if is_native or has_mbql5_native:
|
||||
# Formato legacy: dataset_query.native.query
|
||||
legacy_sql = None
|
||||
native_block = dq.get("native")
|
||||
if isinstance(native_block, dict):
|
||||
legacy_sql = native_block.get("query")
|
||||
|
||||
# Formato MBQL5: dataset_query.stages[0].native
|
||||
mbql5_sql = None
|
||||
if has_mbql5_native:
|
||||
mbql5_sql = stages[0].get("native")
|
||||
|
||||
legacy_ok = isinstance(legacy_sql, str) and legacy_sql.strip()
|
||||
mbql5_ok = isinstance(mbql5_sql, str) and mbql5_sql.strip()
|
||||
|
||||
if not legacy_ok and not mbql5_ok:
|
||||
issues.append(
|
||||
"query nativa sin SQL: falta 'dataset_query.native.query' "
|
||||
"(legacy) o 'dataset_query.stages[0].native' (MBQL5)"
|
||||
)
|
||||
|
||||
# --- visualization_settings (opcional) ---
|
||||
vs = payload.get("visualization_settings")
|
||||
if vs is not None and not isinstance(vs, dict):
|
||||
issues.append("'visualization_settings' debe ser un dict")
|
||||
|
||||
# --- parameters (opcional) ---
|
||||
params = payload.get("parameters")
|
||||
if params is not None and not isinstance(params, list):
|
||||
issues.append("'parameters' debe ser una list")
|
||||
|
||||
# --- archived (opcional) ---
|
||||
archived = payload.get("archived")
|
||||
if archived is not None and not isinstance(archived, bool):
|
||||
issues.append("'archived' debe ser bool")
|
||||
|
||||
return issues
|
||||
|
||||
|
||||
def metabase_validate_dashboard_payload(
|
||||
payload: dict,
|
||||
known_card_ids: set[int],
|
||||
) -> list[str]:
|
||||
"""Valida la estructura de un payload de dashboard antes de enviarlo a Metabase.
|
||||
|
||||
Verifica campos obligatorios, bounds de dashcards, referencias a cards y
|
||||
solapamientos entre dashcards. Acumula todos los issues sin abortar.
|
||||
|
||||
Args:
|
||||
payload: dict con los campos del dashboard (name, dashcards, tabs, parameters).
|
||||
known_card_ids: conjunto de IDs de cards conocidos; las dashcards con
|
||||
card_id entero deben referenciar un ID de este conjunto.
|
||||
|
||||
Returns:
|
||||
Lista de strings describiendo cada issue encontrado. Lista vacia = payload valido.
|
||||
|
||||
Example:
|
||||
>>> issues = metabase_validate_dashboard_payload(
|
||||
... {"name": "KPIs", "dashcards": []},
|
||||
... known_card_ids={1, 2, 3},
|
||||
... )
|
||||
>>> assert issues == []
|
||||
"""
|
||||
issues: list[str] = []
|
||||
|
||||
# --- name ---
|
||||
name = payload.get("name")
|
||||
if name is None:
|
||||
issues.append("campo 'name' ausente")
|
||||
elif not isinstance(name, str) or not name.strip():
|
||||
issues.append("campo 'name' debe ser un string no vacio")
|
||||
|
||||
# --- dashcards (opcional, pero si esta, debe ser list) ---
|
||||
dashcards = payload.get("dashcards")
|
||||
if dashcards is not None:
|
||||
if not isinstance(dashcards, list):
|
||||
issues.append("'dashcards' debe ser una list")
|
||||
else:
|
||||
valid_rects: list[tuple[int, int, int, int, int]] = [] # (idx, row, col, sx, sy)
|
||||
|
||||
for i, dc in enumerate(dashcards):
|
||||
if not isinstance(dc, dict):
|
||||
issues.append(f"dashcard[{i}] debe ser un dict")
|
||||
continue
|
||||
|
||||
# card_id: si es int debe estar en known_card_ids; null es virtual (ok)
|
||||
card_id = dc.get("card_id")
|
||||
if card_id is not None:
|
||||
if not isinstance(card_id, int):
|
||||
issues.append(f"dashcard[{i}].card_id debe ser int o null")
|
||||
elif card_id not in known_card_ids:
|
||||
issues.append(
|
||||
f"dashcard[{i}].card_id={card_id} no existe en las cards conocidas"
|
||||
)
|
||||
|
||||
# Campos de posicion y tamanio
|
||||
missing = [f for f in ("row", "col", "size_x", "size_y") if f not in dc]
|
||||
if missing:
|
||||
issues.append(
|
||||
f"dashcard[{i}] falta campos de layout: {', '.join(missing)}"
|
||||
)
|
||||
continue
|
||||
|
||||
row = dc["row"]
|
||||
col = dc["col"]
|
||||
size_x = dc["size_x"]
|
||||
size_y = dc["size_y"]
|
||||
|
||||
for fname, val in (("row", row), ("col", col), ("size_x", size_x), ("size_y", size_y)):
|
||||
if not isinstance(val, int):
|
||||
issues.append(f"dashcard[{i}].{fname} debe ser int")
|
||||
|
||||
if not isinstance(row, int) or not isinstance(col, int) or \
|
||||
not isinstance(size_x, int) or not isinstance(size_y, int):
|
||||
continue # ya reportado arriba
|
||||
|
||||
# Bounds
|
||||
if row < 0:
|
||||
issues.append(f"dashcard[{i}].row={row} debe ser >= 0")
|
||||
if not 0 <= col <= 23:
|
||||
issues.append(f"dashcard[{i}].col={col} debe estar en [0, 23]")
|
||||
if not 1 <= size_x <= 24:
|
||||
issues.append(f"dashcard[{i}].size_x={size_x} debe estar en [1, 24]")
|
||||
if not 1 <= size_y <= 100:
|
||||
issues.append(f"dashcard[{i}].size_y={size_y} debe estar en [1, 100]")
|
||||
if col + size_x > 24:
|
||||
issues.append(
|
||||
f"dashcard[{i}] excede el ancho del grid: col={col} + size_x={size_x} = {col + size_x} > 24"
|
||||
)
|
||||
|
||||
valid_rects.append((i, row, col, size_x, size_y))
|
||||
|
||||
# Deteccion de solapamientos O(n^2) — dashboards tipicos tienen < 50 cards
|
||||
for a in range(len(valid_rects)):
|
||||
for b in range(a + 1, len(valid_rects)):
|
||||
ia, ra, ca, sxa, sya = valid_rects[a]
|
||||
ib, rb, cb, sxb, syb = valid_rects[b]
|
||||
|
||||
# Rectangulos: [ca, ca+sxa) x [ra, ra+sya) y [cb, cb+sxb) x [rb, rb+syb)
|
||||
overlap_x = ca < cb + sxb and cb < ca + sxa
|
||||
overlap_y = ra < rb + syb and rb < ra + sya
|
||||
|
||||
if overlap_x and overlap_y:
|
||||
issues.append(
|
||||
f"dashcards en posiciones (row={ra},col={ca},{sxa}x{sya}) "
|
||||
f"y (row={rb},col={cb},{sxb}x{syb}) solapan"
|
||||
)
|
||||
|
||||
# --- tabs (opcional) ---
|
||||
tabs = payload.get("tabs")
|
||||
if tabs is not None:
|
||||
if not isinstance(tabs, list):
|
||||
issues.append("'tabs' debe ser una list")
|
||||
else:
|
||||
for i, tab in enumerate(tabs):
|
||||
if not isinstance(tab, dict):
|
||||
issues.append(f"tabs[{i}] debe ser un dict")
|
||||
continue
|
||||
if "id" not in tab:
|
||||
issues.append(f"tabs[{i}] falta campo 'id'")
|
||||
if "name" not in tab:
|
||||
issues.append(f"tabs[{i}] falta campo 'name'")
|
||||
|
||||
# --- parameters (opcional) ---
|
||||
params = payload.get("parameters")
|
||||
if params is not None and not isinstance(params, list):
|
||||
issues.append("'parameters' debe ser una list")
|
||||
|
||||
return issues
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- Documents
|
||||
|
||||
# Nodos ProseMirror que el editor TipTap de Metabase (v0.59) sabe renderizar.
|
||||
# Si un documento contiene nodos fuera de esta whitelist, el backend los acepta
|
||||
# pero el frontend silenciosamente descarta contenido y el doc aparece vacio o
|
||||
# incompleto.
|
||||
_VALID_DOC_NODES = {
|
||||
"doc", "paragraph", "text", "heading",
|
||||
"bulletList", "orderedList", "listItem",
|
||||
"blockquote", "codeBlock", "horizontalRule", "hardBreak",
|
||||
"cardEmbed", "flexContainer", "smartLink", "resizeNode", "mention",
|
||||
}
|
||||
|
||||
_VALID_DOC_MARKS = {"bold", "italic", "strike", "code", "link"}
|
||||
|
||||
# Rangos de attrs.level en headings
|
||||
_HEADING_LEVELS = {1, 2, 3, 4, 5, 6}
|
||||
|
||||
|
||||
def metabase_validate_document_payload(
|
||||
payload: dict,
|
||||
known_card_slugs: set[str] | None = None,
|
||||
) -> list[str]:
|
||||
"""Valida un payload de document antes de pusharlo a Metabase.
|
||||
|
||||
El editor TipTap de Metabase solo renderiza un subconjunto concreto de
|
||||
nodos y marks ProseMirror. La API *acepta* cualquier arbol, pero el
|
||||
frontend silenciosamente descarta lo que no conoce. Este validador
|
||||
rechaza (como warning) cualquier nodo o mark fuera de la whitelist.
|
||||
|
||||
Comprueba tambien restricciones estructurales:
|
||||
- `heading.attrs.level` en [1, 6].
|
||||
- `flexContainer` solo contiene `cardEmbed` o `supportingText`,
|
||||
y como maximo 3 hijos.
|
||||
- `cardEmbed.attrs` debe resolverse a un card real (por `id` o por
|
||||
`card` slug si el caller pasa `known_card_slugs`).
|
||||
|
||||
Args:
|
||||
payload: dict del document listo para POST/PUT (con `name` y `document`).
|
||||
known_card_slugs: set de slugs conocidos en el index (para validar
|
||||
`cardEmbed.attrs.card`). None = skip check.
|
||||
|
||||
Returns:
|
||||
Lista de warnings. Lista vacia = payload renderizable.
|
||||
"""
|
||||
issues: list[str] = []
|
||||
|
||||
# --- name ---
|
||||
name = payload.get("name")
|
||||
if name is None:
|
||||
issues.append("campo 'name' ausente")
|
||||
elif not isinstance(name, str) or not name.strip():
|
||||
issues.append("campo 'name' debe ser un string no vacio")
|
||||
elif len(name) > 254:
|
||||
issues.append(f"'name' excede 254 chars ({len(name)})")
|
||||
|
||||
# --- archived ---
|
||||
archived = payload.get("archived")
|
||||
if archived is not None and not isinstance(archived, bool):
|
||||
issues.append("'archived' debe ser bool")
|
||||
|
||||
# --- document (arbol ProseMirror) ---
|
||||
tree = payload.get("document")
|
||||
if tree is None:
|
||||
issues.append("campo 'document' ausente")
|
||||
return issues
|
||||
if tree == "":
|
||||
# Document vacio es valido (Metabase lo acepta)
|
||||
return issues
|
||||
if not isinstance(tree, dict):
|
||||
issues.append(f"'document' debe ser dict o string vacia, no {type(tree).__name__}")
|
||||
return issues
|
||||
|
||||
if tree.get("type") != "doc":
|
||||
issues.append(f"'document.type' debe ser 'doc', no '{tree.get('type')}'")
|
||||
|
||||
# Walk recursivo acumulando issues con path
|
||||
_walk_doc_node(tree, "document", issues, known_card_slugs or set())
|
||||
|
||||
return issues
|
||||
|
||||
|
||||
def _walk_doc_node(
|
||||
node: dict,
|
||||
path: str,
|
||||
issues: list[str],
|
||||
known_card_slugs: set[str],
|
||||
) -> None:
|
||||
"""Valida un nodo ProseMirror y desciende por sus hijos."""
|
||||
if not isinstance(node, dict):
|
||||
issues.append(f"{path}: nodo no es dict ({type(node).__name__})")
|
||||
return
|
||||
|
||||
ntype = node.get("type")
|
||||
if not isinstance(ntype, str):
|
||||
issues.append(f"{path}: campo 'type' ausente o no string")
|
||||
return
|
||||
|
||||
if ntype not in _VALID_DOC_NODES:
|
||||
issues.append(
|
||||
f"{path}: nodo '{ntype}' no soportado por el editor de Metabase. "
|
||||
f"Validos: {sorted(_VALID_DOC_NODES)}"
|
||||
)
|
||||
# Seguir igualmente — puede haber issues mas adentro
|
||||
|
||||
# --- Validaciones especificas por tipo ---
|
||||
attrs = node.get("attrs") or {}
|
||||
|
||||
if ntype == "heading":
|
||||
level = attrs.get("level")
|
||||
if level not in _HEADING_LEVELS:
|
||||
issues.append(f"{path}: heading.level={level!r} debe estar en {sorted(_HEADING_LEVELS)}")
|
||||
|
||||
if ntype == "cardEmbed":
|
||||
# cardEmbed requiere o bien attrs.id (int) o attrs.card (slug del index)
|
||||
cid = attrs.get("id")
|
||||
cslug = attrs.get("card")
|
||||
if cid is None and cslug is None:
|
||||
issues.append(f"{path}: cardEmbed sin 'attrs.id' ni 'attrs.card'")
|
||||
elif cid is not None and not isinstance(cid, int):
|
||||
issues.append(f"{path}: cardEmbed.attrs.id debe ser int, no {type(cid).__name__}")
|
||||
elif cslug is not None:
|
||||
if not isinstance(cslug, str):
|
||||
issues.append(f"{path}: cardEmbed.attrs.card debe ser string slug")
|
||||
elif known_card_slugs and cslug not in known_card_slugs:
|
||||
issues.append(
|
||||
f"{path}: cardEmbed.attrs.card='{cslug}' no existe en el index "
|
||||
f"(conocidos: {sorted(known_card_slugs)[:10]}...)"
|
||||
)
|
||||
|
||||
if ntype == "flexContainer":
|
||||
children = node.get("content") or []
|
||||
if not isinstance(children, list):
|
||||
issues.append(f"{path}: flexContainer.content debe ser lista")
|
||||
else:
|
||||
if not 1 <= len(children) <= 3:
|
||||
issues.append(
|
||||
f"{path}: flexContainer debe tener 1-3 hijos (tiene {len(children)})"
|
||||
)
|
||||
for i, ch in enumerate(children):
|
||||
ct = ch.get("type") if isinstance(ch, dict) else None
|
||||
if ct not in ("cardEmbed", "supportingText"):
|
||||
issues.append(
|
||||
f"{path}.content[{i}]: flexContainer solo acepta 'cardEmbed' "
|
||||
f"o 'supportingText' como hijos (tiene '{ct}')"
|
||||
)
|
||||
cw = attrs.get("columnWidths")
|
||||
if cw is not None:
|
||||
if not isinstance(cw, list) or not all(isinstance(x, (int, float)) for x in cw):
|
||||
issues.append(f"{path}: flexContainer.attrs.columnWidths debe ser lista de numeros")
|
||||
elif isinstance(children, list) and len(cw) != len(children):
|
||||
issues.append(
|
||||
f"{path}: columnWidths tiene {len(cw)} valores pero hay {len(children)} hijos"
|
||||
)
|
||||
|
||||
if ntype == "smartLink":
|
||||
# smartLink necesita entityId (id numerico del card en Metabase)
|
||||
if attrs.get("entityId") is None:
|
||||
issues.append(f"{path}: smartLink sin 'attrs.entityId'")
|
||||
|
||||
if ntype == "text":
|
||||
if not isinstance(node.get("text"), str):
|
||||
issues.append(f"{path}: text sin campo 'text' string")
|
||||
# Validar marks
|
||||
marks = node.get("marks") or []
|
||||
if not isinstance(marks, list):
|
||||
issues.append(f"{path}: 'marks' debe ser lista")
|
||||
else:
|
||||
for i, m in enumerate(marks):
|
||||
if not isinstance(m, dict):
|
||||
issues.append(f"{path}.marks[{i}]: mark no es dict")
|
||||
continue
|
||||
mt = m.get("type")
|
||||
if mt not in _VALID_DOC_MARKS:
|
||||
issues.append(
|
||||
f"{path}.marks[{i}]: mark '{mt}' no soportado. "
|
||||
f"Validos: {sorted(_VALID_DOC_MARKS)}"
|
||||
)
|
||||
|
||||
# --- Recursion sobre content ---
|
||||
content = node.get("content")
|
||||
if isinstance(content, list):
|
||||
for i, child in enumerate(content):
|
||||
_walk_doc_node(child, f"{path}.content[{i}]", issues, known_card_slugs)
|
||||
|
||||
|
||||
def metabase_validate_sql(
|
||||
client: MetabaseClient,
|
||||
database_id: int,
|
||||
sql: str,
|
||||
max_rows: int = 0,
|
||||
) -> dict:
|
||||
"""Valida sintaxis y referencias de SQL ejecutandolo contra Metabase.
|
||||
|
||||
Ejecuta la query via POST /api/dataset con LIMIT implicito (max_rows=1 si
|
||||
el caller no especifica nada, para minimizar carga). Captura tanto errores
|
||||
HTTP como errores embebidos en el body (Metabase a veces devuelve 200 + status failed).
|
||||
|
||||
Args:
|
||||
client: instancia autenticada de MetabaseClient.
|
||||
database_id: ID de la base de datos donde ejecutar el SQL.
|
||||
sql: sentencia SQL a validar (SELECT, WITH, etc.).
|
||||
max_rows: limite de filas a retornar para la validacion (0 = default de Metabase).
|
||||
|
||||
Returns:
|
||||
dict con:
|
||||
ok (bool): True si la query se ejecuto sin errores.
|
||||
error (str|None): mensaje de error si ok=False, None si ok=True.
|
||||
rows_returned (int): numero de filas devueltas si ok=True.
|
||||
|
||||
Example:
|
||||
>>> result = metabase_validate_sql(client, 1, "SELECT id FROM orders LIMIT 1")
|
||||
>>> if not result["ok"]:
|
||||
... print("SQL invalido:", result["error"])
|
||||
"""
|
||||
try:
|
||||
response = metabase_execute_query(client, database_id, sql, max_rows)
|
||||
except httpx.HTTPStatusError as exc:
|
||||
# Intentar extraer mensaje del body JSON de Metabase
|
||||
error_msg = _extract_metabase_error(exc)
|
||||
return {"ok": False, "error": error_msg, "rows_returned": 0}
|
||||
except Exception as exc:
|
||||
return {"ok": False, "error": str(exc), "rows_returned": 0}
|
||||
|
||||
# Metabase puede devolver 200 con status: "failed" en el body
|
||||
status = response.get("status") if isinstance(response, dict) else None
|
||||
if status == "failed":
|
||||
error_msg = response.get("error") or "query fallida (sin mensaje)"
|
||||
return {"ok": False, "error": error_msg, "rows_returned": 0}
|
||||
|
||||
# Contar filas retornadas
|
||||
rows_returned = 0
|
||||
if isinstance(response, dict):
|
||||
data = response.get("data", {})
|
||||
if isinstance(data, dict):
|
||||
rows = data.get("rows", [])
|
||||
if isinstance(rows, list):
|
||||
rows_returned = len(rows)
|
||||
|
||||
return {"ok": True, "error": None, "rows_returned": rows_returned}
|
||||
|
||||
|
||||
def _extract_metabase_error(exc: httpx.HTTPStatusError) -> str:
|
||||
"""Extrae el mensaje de error legible del response de Metabase."""
|
||||
try:
|
||||
body = exc.response.json()
|
||||
if isinstance(body, dict):
|
||||
return body.get("error") or body.get("message") or str(exc)
|
||||
except Exception:
|
||||
pass
|
||||
return str(exc)
|
||||
@@ -0,0 +1,389 @@
|
||||
"""Tests para metabase_validate_card_payload y metabase_validate_dashboard_payload."""
|
||||
|
||||
import sys
|
||||
|
||||
sys.path.insert(0, "/home/lucas/fn_registry/python/functions")
|
||||
|
||||
from metabase.validation import (
|
||||
metabase_validate_card_payload,
|
||||
metabase_validate_dashboard_payload,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# metabase_validate_card_payload
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _base_card() -> dict:
|
||||
return {
|
||||
"name": "Revenue by Month",
|
||||
"display": "line",
|
||||
"dataset_query": {
|
||||
"database": 1,
|
||||
"type": "native",
|
||||
"native": {"query": "SELECT 1"},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def test_card_valido_retorna_lista_vacia():
|
||||
issues = metabase_validate_card_payload(_base_card())
|
||||
assert issues == [], f"Se esperaba lista vacia, got: {issues}"
|
||||
|
||||
|
||||
def test_card_display_invalido():
|
||||
payload = _base_card()
|
||||
payload["display"] = "foobar"
|
||||
issues = metabase_validate_card_payload(payload)
|
||||
assert any("display" in i and "foobar" in i for i in issues), issues
|
||||
|
||||
|
||||
def test_card_display_ausente():
|
||||
payload = _base_card()
|
||||
del payload["display"]
|
||||
issues = metabase_validate_card_payload(payload)
|
||||
assert any("display" in i and "ausente" in i for i in issues), issues
|
||||
|
||||
|
||||
def test_card_name_ausente():
|
||||
payload = _base_card()
|
||||
del payload["name"]
|
||||
issues = metabase_validate_card_payload(payload)
|
||||
assert any("name" in i and "ausente" in i for i in issues), issues
|
||||
|
||||
|
||||
def test_card_name_vacio():
|
||||
payload = _base_card()
|
||||
payload["name"] = " "
|
||||
issues = metabase_validate_card_payload(payload)
|
||||
assert any("name" in i for i in issues), issues
|
||||
|
||||
|
||||
def test_card_dataset_query_ausente():
|
||||
payload = _base_card()
|
||||
del payload["dataset_query"]
|
||||
issues = metabase_validate_card_payload(payload)
|
||||
assert any("dataset_query" in i and "ausente" in i for i in issues), issues
|
||||
|
||||
|
||||
def test_card_dataset_query_sin_database():
|
||||
payload = _base_card()
|
||||
del payload["dataset_query"]["database"]
|
||||
issues = metabase_validate_card_payload(payload)
|
||||
assert any("database" in i for i in issues), issues
|
||||
|
||||
|
||||
def test_card_nativa_sin_sql():
|
||||
payload = _base_card()
|
||||
payload["dataset_query"]["native"]["query"] = ""
|
||||
issues = metabase_validate_card_payload(payload)
|
||||
assert any("SQL" in i or "native" in i for i in issues), issues
|
||||
|
||||
|
||||
def test_card_nativa_mbql5():
|
||||
"""Acepta SQL en stages[0].native (formato MBQL5)."""
|
||||
payload = {
|
||||
"name": "MBQL5 card",
|
||||
"display": "table",
|
||||
"dataset_query": {
|
||||
"database": 2,
|
||||
"stages": [{"native": "SELECT 1"}],
|
||||
},
|
||||
}
|
||||
issues = metabase_validate_card_payload(payload)
|
||||
assert issues == [], f"Se esperaba lista vacia, got: {issues}"
|
||||
|
||||
|
||||
def test_card_type_invalido():
|
||||
payload = _base_card()
|
||||
payload["type"] = "unknown_type"
|
||||
issues = metabase_validate_card_payload(payload)
|
||||
assert any("type" in i and "unknown_type" in i for i in issues), issues
|
||||
|
||||
|
||||
def test_card_type_valido():
|
||||
for t in ("question", "model", "metric"):
|
||||
payload = _base_card()
|
||||
payload["type"] = t
|
||||
issues = metabase_validate_card_payload(payload)
|
||||
assert issues == [], f"type={t!r} no deberia dar issues: {issues}"
|
||||
|
||||
|
||||
def test_card_visualization_settings_no_dict():
|
||||
payload = _base_card()
|
||||
payload["visualization_settings"] = "no es un dict"
|
||||
issues = metabase_validate_card_payload(payload)
|
||||
assert any("visualization_settings" in i for i in issues), issues
|
||||
|
||||
|
||||
def test_card_parameters_no_list():
|
||||
payload = _base_card()
|
||||
payload["parameters"] = {"not": "a list"}
|
||||
issues = metabase_validate_card_payload(payload)
|
||||
assert any("parameters" in i for i in issues), issues
|
||||
|
||||
|
||||
def test_card_archived_no_bool():
|
||||
payload = _base_card()
|
||||
payload["archived"] = "yes"
|
||||
issues = metabase_validate_card_payload(payload)
|
||||
assert any("archived" in i for i in issues), issues
|
||||
|
||||
|
||||
def test_card_acumula_multiples_errores():
|
||||
"""Un payload con varios errores debe retornar todos los issues, no solo el primero."""
|
||||
payload = {
|
||||
"display": "invalid_display",
|
||||
"dataset_query": "not a dict",
|
||||
"archived": "yes",
|
||||
}
|
||||
issues = metabase_validate_card_payload(payload)
|
||||
assert len(issues) >= 3, f"Se esperaban >= 3 issues, got {len(issues)}: {issues}"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# metabase_validate_dashboard_payload
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _base_dashboard() -> dict:
|
||||
return {"name": "My Dashboard"}
|
||||
|
||||
|
||||
def test_dashboard_valido_sin_dashcards():
|
||||
issues = metabase_validate_dashboard_payload(_base_dashboard(), known_card_ids=set())
|
||||
assert issues == [], issues
|
||||
|
||||
|
||||
def test_dashboard_valido_con_dashcards():
|
||||
payload = {
|
||||
"name": "KPIs",
|
||||
"dashcards": [
|
||||
{"card_id": 1, "row": 0, "col": 0, "size_x": 6, "size_y": 4},
|
||||
{"card_id": 2, "row": 0, "col": 6, "size_x": 6, "size_y": 4},
|
||||
],
|
||||
}
|
||||
issues = metabase_validate_dashboard_payload(payload, known_card_ids={1, 2})
|
||||
assert issues == [], issues
|
||||
|
||||
|
||||
def test_dashboard_card_id_desconocido():
|
||||
payload = {
|
||||
"name": "Dashboard",
|
||||
"dashcards": [{"card_id": 999, "row": 0, "col": 0, "size_x": 6, "size_y": 4}],
|
||||
}
|
||||
issues = metabase_validate_dashboard_payload(payload, known_card_ids={1, 2})
|
||||
assert any("999" in i for i in issues), issues
|
||||
|
||||
|
||||
def test_dashboard_card_virtual_null_permitido():
|
||||
"""card_id null = dashcard virtual (texto/heading), siempre permitido."""
|
||||
payload = {
|
||||
"name": "Dashboard",
|
||||
"dashcards": [{"card_id": None, "row": 0, "col": 0, "size_x": 6, "size_y": 2}],
|
||||
}
|
||||
issues = metabase_validate_dashboard_payload(payload, known_card_ids=set())
|
||||
assert issues == [], issues
|
||||
|
||||
|
||||
def test_dashboard_dashcards_solapadas():
|
||||
payload = {
|
||||
"name": "Dashboard",
|
||||
"dashcards": [
|
||||
{"card_id": 1, "row": 0, "col": 0, "size_x": 6, "size_y": 4},
|
||||
{"card_id": 2, "row": 2, "col": 2, "size_x": 4, "size_y": 4},
|
||||
],
|
||||
}
|
||||
issues = metabase_validate_dashboard_payload(payload, known_card_ids={1, 2})
|
||||
assert any("solapan" in i for i in issues), issues
|
||||
|
||||
|
||||
def test_dashboard_dashcards_adyacentes_no_solapan():
|
||||
"""Dos dashcards que se tocan en el borde NO solapan."""
|
||||
payload = {
|
||||
"name": "Dashboard",
|
||||
"dashcards": [
|
||||
{"card_id": 1, "row": 0, "col": 0, "size_x": 6, "size_y": 4},
|
||||
{"card_id": 2, "row": 0, "col": 6, "size_x": 6, "size_y": 4},
|
||||
],
|
||||
}
|
||||
issues = metabase_validate_dashboard_payload(payload, known_card_ids={1, 2})
|
||||
assert not any("solapan" in i for i in issues), issues
|
||||
|
||||
|
||||
def test_dashboard_col_fuera_de_bounds():
|
||||
payload = {
|
||||
"name": "Dashboard",
|
||||
"dashcards": [{"card_id": 1, "row": 0, "col": 25, "size_x": 1, "size_y": 1}],
|
||||
}
|
||||
issues = metabase_validate_dashboard_payload(payload, known_card_ids={1})
|
||||
assert any("col" in i for i in issues), issues
|
||||
|
||||
|
||||
def test_dashboard_col_mas_size_x_excede_grid():
|
||||
payload = {
|
||||
"name": "Dashboard",
|
||||
"dashcards": [{"card_id": 1, "row": 0, "col": 20, "size_x": 6, "size_y": 4}],
|
||||
}
|
||||
issues = metabase_validate_dashboard_payload(payload, known_card_ids={1})
|
||||
assert any("24" in i for i in issues), issues
|
||||
|
||||
|
||||
def test_dashboard_size_y_fuera_de_bounds():
|
||||
payload = {
|
||||
"name": "Dashboard",
|
||||
"dashcards": [{"card_id": 1, "row": 0, "col": 0, "size_x": 6, "size_y": 0}],
|
||||
}
|
||||
issues = metabase_validate_dashboard_payload(payload, known_card_ids={1})
|
||||
assert any("size_y" in i for i in issues), issues
|
||||
|
||||
|
||||
def test_dashboard_name_ausente():
|
||||
issues = metabase_validate_dashboard_payload({}, known_card_ids=set())
|
||||
assert any("name" in i and "ausente" in i for i in issues), issues
|
||||
|
||||
|
||||
def test_dashboard_tabs_invalidos():
|
||||
payload = {"name": "Dashboard", "tabs": [{"name": "Tab1"}]} # falta id
|
||||
issues = metabase_validate_dashboard_payload(payload, known_card_ids=set())
|
||||
assert any("id" in i for i in issues), issues
|
||||
|
||||
|
||||
def test_dashboard_parameters_no_list():
|
||||
payload = {"name": "Dashboard", "parameters": "not a list"}
|
||||
issues = metabase_validate_dashboard_payload(payload, known_card_ids=set())
|
||||
assert any("parameters" in i for i in issues), issues
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# metabase_validate_document_payload
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
from metabase.validation import metabase_validate_document_payload
|
||||
|
||||
|
||||
def _base_doc(content):
|
||||
return {"name": "Notas", "document": {"type": "doc", "content": content}}
|
||||
|
||||
|
||||
def test_document_valido_minimo():
|
||||
issues = metabase_validate_document_payload(_base_doc([
|
||||
{"type": "paragraph", "content": [{"type": "text", "text": "hola"}]}
|
||||
]))
|
||||
assert issues == [], issues
|
||||
|
||||
|
||||
def test_document_name_ausente():
|
||||
issues = metabase_validate_document_payload({"document": {"type": "doc", "content": []}})
|
||||
assert any("name" in i and "ausente" in i for i in issues), issues
|
||||
|
||||
|
||||
def test_document_nodo_desconocido_callout():
|
||||
issues = metabase_validate_document_payload(_base_doc([
|
||||
{"type": "callout", "content": [{"type": "paragraph", "content": [{"type": "text", "text": "x"}]}]}
|
||||
]))
|
||||
assert any("callout" in i and "no soportado" in i for i in issues), issues
|
||||
|
||||
|
||||
def test_document_nodo_desconocido_taskList():
|
||||
issues = metabase_validate_document_payload(_base_doc([
|
||||
{"type": "taskList", "content": []}
|
||||
]))
|
||||
assert any("taskList" in i for i in issues), issues
|
||||
|
||||
|
||||
def test_document_mark_desconocido_underline():
|
||||
issues = metabase_validate_document_payload(_base_doc([
|
||||
{"type": "paragraph", "content": [
|
||||
{"type": "text", "marks": [{"type": "underline"}], "text": "x"}
|
||||
]}
|
||||
]))
|
||||
assert any("underline" in i and "no soportado" in i for i in issues), issues
|
||||
|
||||
|
||||
def test_document_heading_level_invalido():
|
||||
issues = metabase_validate_document_payload(_base_doc([
|
||||
{"type": "heading", "attrs": {"level": 9}, "content": [{"type": "text", "text": "x"}]}
|
||||
]))
|
||||
assert any("level" in i for i in issues), issues
|
||||
|
||||
|
||||
def test_document_cardEmbed_sin_id_ni_slug():
|
||||
issues = metabase_validate_document_payload(_base_doc([
|
||||
{"type": "cardEmbed", "attrs": {}}
|
||||
]))
|
||||
assert any("cardEmbed" in i and "id" in i for i in issues), issues
|
||||
|
||||
|
||||
def test_document_cardEmbed_con_id_valido():
|
||||
issues = metabase_validate_document_payload(_base_doc([
|
||||
{"type": "cardEmbed", "attrs": {"id": 42}}
|
||||
]))
|
||||
assert issues == [], issues
|
||||
|
||||
|
||||
def test_document_cardEmbed_slug_desconocido():
|
||||
issues = metabase_validate_document_payload(
|
||||
_base_doc([{"type": "cardEmbed", "attrs": {"card": "inventado"}}]),
|
||||
known_card_slugs={"real_one", "real_two"},
|
||||
)
|
||||
assert any("inventado" in i for i in issues), issues
|
||||
|
||||
|
||||
def test_document_flexContainer_demasiados_hijos():
|
||||
children = [{"type": "cardEmbed", "attrs": {"id": 1}} for _ in range(4)]
|
||||
issues = metabase_validate_document_payload(_base_doc([
|
||||
{"type": "flexContainer", "attrs": {"columnWidths": [25, 25, 25, 25]}, "content": children}
|
||||
]))
|
||||
assert any("1-3" in i for i in issues), issues
|
||||
|
||||
|
||||
def test_document_flexContainer_hijo_invalido():
|
||||
issues = metabase_validate_document_payload(_base_doc([
|
||||
{"type": "flexContainer", "attrs": {"columnWidths": [100]}, "content": [
|
||||
{"type": "paragraph", "content": [{"type": "text", "text": "x"}]}
|
||||
]}
|
||||
]))
|
||||
assert any("flexContainer solo acepta" in i for i in issues), issues
|
||||
|
||||
|
||||
def test_document_flexContainer_columnWidths_mismatch():
|
||||
issues = metabase_validate_document_payload(_base_doc([
|
||||
{"type": "flexContainer", "attrs": {"columnWidths": [50, 50]}, "content": [
|
||||
{"type": "cardEmbed", "attrs": {"id": 1}}
|
||||
]}
|
||||
]))
|
||||
assert any("columnWidths" in i for i in issues), issues
|
||||
|
||||
|
||||
def test_document_vacio_string_es_valido():
|
||||
issues = metabase_validate_document_payload({"name": "vacio", "document": ""})
|
||||
assert issues == [], issues
|
||||
|
||||
|
||||
def test_document_kitchen_sink_valido():
|
||||
"""Simula el kitchen_sink real y debe pasar sin issues."""
|
||||
content = [
|
||||
{"type": "heading", "attrs": {"level": 1}, "content": [{"type": "text", "text": "T"}]},
|
||||
{"type": "paragraph", "content": [
|
||||
{"type": "text", "text": "a "},
|
||||
{"type": "text", "marks": [{"type": "bold"}], "text": "b"},
|
||||
{"type": "text", "marks": [{"type": "link", "attrs": {"href": "https://x"}}], "text": "l"},
|
||||
]},
|
||||
{"type": "bulletList", "content": [
|
||||
{"type": "listItem", "content": [
|
||||
{"type": "paragraph", "content": [{"type": "text", "text": "i"}]}
|
||||
]}
|
||||
]},
|
||||
{"type": "blockquote", "content": [
|
||||
{"type": "paragraph", "content": [{"type": "text", "text": "q"}]}
|
||||
]},
|
||||
{"type": "codeBlock", "attrs": {"language": "py"}, "content": [{"type": "text", "text": "x=1"}]},
|
||||
{"type": "horizontalRule"},
|
||||
{"type": "cardEmbed", "attrs": {"id": 1}},
|
||||
{"type": "flexContainer", "attrs": {"columnWidths": [50, 50]}, "content": [
|
||||
{"type": "cardEmbed", "attrs": {"id": 1}},
|
||||
{"type": "cardEmbed", "attrs": {"id": 2}},
|
||||
]},
|
||||
]
|
||||
issues = metabase_validate_document_payload(_base_doc(content))
|
||||
assert issues == [], issues
|
||||
@@ -13,6 +13,7 @@ dependencies = [
|
||||
"openpyxl>=3.1.5",
|
||||
"pypdf>=6.10.0",
|
||||
"python-docx>=1.2.0",
|
||||
"pyyaml>=6.0.3",
|
||||
"xlrd>=2.0.2",
|
||||
]
|
||||
|
||||
|
||||
Generated
+48
@@ -252,6 +252,7 @@ dependencies = [
|
||||
{ name = "openpyxl" },
|
||||
{ name = "pypdf" },
|
||||
{ name = "python-docx" },
|
||||
{ name = "pyyaml" },
|
||||
{ name = "xlrd" },
|
||||
]
|
||||
|
||||
@@ -270,6 +271,7 @@ requires-dist = [
|
||||
{ name = "openpyxl", specifier = ">=3.1.5" },
|
||||
{ name = "pypdf", specifier = ">=6.10.0" },
|
||||
{ name = "python-docx", specifier = ">=1.2.0" },
|
||||
{ name = "pyyaml", specifier = ">=6.0.3" },
|
||||
{ name = "xlrd", specifier = ">=2.0.2" },
|
||||
]
|
||||
|
||||
@@ -865,6 +867,52 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/d0/00/1e03a4989fa5795da308cd774f05b704ace555a70f9bf9d3be057b680bcf/python_docx-1.2.0-py3-none-any.whl", hash = "sha256:3fd478f3250fbbbfd3b94fe1e985955737c145627498896a8a6bf81f4baf66c7", size = 252987, upload-time = "2025-06-16T20:46:22.506Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pyyaml"
|
||||
version = "6.0.3"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/d1/33/422b98d2195232ca1826284a76852ad5a86fe23e31b009c9886b2d0fb8b2/pyyaml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196", size = 182063, upload-time = "2025-09-25T21:32:11.445Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/89/a0/6cf41a19a1f2f3feab0e9c0b74134aa2ce6849093d5517a0c550fe37a648/pyyaml-6.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0", size = 173973, upload-time = "2025-09-25T21:32:12.492Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ed/23/7a778b6bd0b9a8039df8b1b1d80e2e2ad78aa04171592c8a5c43a56a6af4/pyyaml-6.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28", size = 775116, upload-time = "2025-09-25T21:32:13.652Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/65/30/d7353c338e12baef4ecc1b09e877c1970bd3382789c159b4f89d6a70dc09/pyyaml-6.0.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c", size = 844011, upload-time = "2025-09-25T21:32:15.21Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8b/9d/b3589d3877982d4f2329302ef98a8026e7f4443c765c46cfecc8858c6b4b/pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc", size = 807870, upload-time = "2025-09-25T21:32:16.431Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/05/c0/b3be26a015601b822b97d9149ff8cb5ead58c66f981e04fedf4e762f4bd4/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e", size = 761089, upload-time = "2025-09-25T21:32:17.56Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/be/8e/98435a21d1d4b46590d5459a22d88128103f8da4c2d4cb8f14f2a96504e1/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea", size = 790181, upload-time = "2025-09-25T21:32:18.834Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/74/93/7baea19427dcfbe1e5a372d81473250b379f04b1bd3c4c5ff825e2327202/pyyaml-6.0.3-cp312-cp312-win32.whl", hash = "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5", size = 137658, upload-time = "2025-09-25T21:32:20.209Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/86/bf/899e81e4cce32febab4fb42bb97dcdf66bc135272882d1987881a4b519e9/pyyaml-6.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b", size = 154003, upload-time = "2025-09-25T21:32:21.167Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1a/08/67bd04656199bbb51dbed1439b7f27601dfb576fb864099c7ef0c3e55531/pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd", size = 140344, upload-time = "2025-09-25T21:32:22.617Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d1/11/0fd08f8192109f7169db964b5707a2f1e8b745d4e239b784a5a1dd80d1db/pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8", size = 181669, upload-time = "2025-09-25T21:32:23.673Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b1/16/95309993f1d3748cd644e02e38b75d50cbc0d9561d21f390a76242ce073f/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1", size = 173252, upload-time = "2025-09-25T21:32:25.149Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/50/31/b20f376d3f810b9b2371e72ef5adb33879b25edb7a6d072cb7ca0c486398/pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c", size = 767081, upload-time = "2025-09-25T21:32:26.575Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/49/1e/a55ca81e949270d5d4432fbbd19dfea5321eda7c41a849d443dc92fd1ff7/pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5", size = 841159, upload-time = "2025-09-25T21:32:27.727Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/74/27/e5b8f34d02d9995b80abcef563ea1f8b56d20134d8f4e5e81733b1feceb2/pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6", size = 801626, upload-time = "2025-09-25T21:32:28.878Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f9/11/ba845c23988798f40e52ba45f34849aa8a1f2d4af4b798588010792ebad6/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6", size = 753613, upload-time = "2025-09-25T21:32:30.178Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3d/e0/7966e1a7bfc0a45bf0a7fb6b98ea03fc9b8d84fa7f2229e9659680b69ee3/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be", size = 794115, upload-time = "2025-09-25T21:32:31.353Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/de/94/980b50a6531b3019e45ddeada0626d45fa85cbe22300844a7983285bed3b/pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26", size = 137427, upload-time = "2025-09-25T21:32:32.58Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/97/c9/39d5b874e8b28845e4ec2202b5da735d0199dbe5b8fb85f91398814a9a46/pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c", size = 154090, upload-time = "2025-09-25T21:32:33.659Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", size = 140246, upload-time = "2025-09-25T21:32:34.663Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9d/8c/f4bd7f6465179953d3ac9bc44ac1a8a3e6122cf8ada906b4f96c60172d43/pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac", size = 181814, upload-time = "2025-09-25T21:32:35.712Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bd/9c/4d95bb87eb2063d20db7b60faa3840c1b18025517ae857371c4dd55a6b3a/pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310", size = 173809, upload-time = "2025-09-25T21:32:36.789Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/92/b5/47e807c2623074914e29dabd16cbbdd4bf5e9b2db9f8090fa64411fc5382/pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7", size = 766454, upload-time = "2025-09-25T21:32:37.966Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/02/9e/e5e9b168be58564121efb3de6859c452fccde0ab093d8438905899a3a483/pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788", size = 836355, upload-time = "2025-09-25T21:32:39.178Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/88/f9/16491d7ed2a919954993e48aa941b200f38040928474c9e85ea9e64222c3/pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5", size = 794175, upload-time = "2025-09-25T21:32:40.865Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/dd/3f/5989debef34dc6397317802b527dbbafb2b4760878a53d4166579111411e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764", size = 755228, upload-time = "2025-09-25T21:32:42.084Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d7/ce/af88a49043cd2e265be63d083fc75b27b6ed062f5f9fd6cdc223ad62f03e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35", size = 789194, upload-time = "2025-09-25T21:32:43.362Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/23/20/bb6982b26a40bb43951265ba29d4c246ef0ff59c9fdcdf0ed04e0687de4d/pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac", size = 156429, upload-time = "2025-09-25T21:32:57.844Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f4/f4/a4541072bb9422c8a883ab55255f918fa378ecf083f5b85e87fc2b4eda1b/pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3", size = 143912, upload-time = "2025-09-25T21:32:59.247Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7c/f9/07dd09ae774e4616edf6cda684ee78f97777bdd15847253637a6f052a62f/pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3", size = 189108, upload-time = "2025-09-25T21:32:44.377Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4e/78/8d08c9fb7ce09ad8c38ad533c1191cf27f7ae1effe5bb9400a46d9437fcf/pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba", size = 183641, upload-time = "2025-09-25T21:32:45.407Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7b/5b/3babb19104a46945cf816d047db2788bcaf8c94527a805610b0289a01c6b/pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c", size = 831901, upload-time = "2025-09-25T21:32:48.83Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8b/cc/dff0684d8dc44da4d22a13f35f073d558c268780ce3c6ba1b87055bb0b87/pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702", size = 861132, upload-time = "2025-09-25T21:32:50.149Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b1/5e/f77dc6b9036943e285ba76b49e118d9ea929885becb0a29ba8a7c75e29fe/pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c", size = 839261, upload-time = "2025-09-25T21:32:51.808Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ce/88/a9db1376aa2a228197c58b37302f284b5617f56a5d959fd1763fb1675ce6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065", size = 805272, upload-time = "2025-09-25T21:32:52.941Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/da/92/1446574745d74df0c92e6aa4a7b0b3130706a4142b2d1a5869f2eaa423c6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65", size = 829923, upload-time = "2025-09-25T21:32:54.537Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f0/7a/1c7270340330e575b92f397352af856a8c06f230aa3e76f86b39d01b416a/pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9", size = 174062, upload-time = "2025-09-25T21:32:55.767Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "requests"
|
||||
version = "2.33.1"
|
||||
|
||||
Reference in New Issue
Block a user