"""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, }