"""Restore desde backup. Estructura de backups: state/backups/{YYYY-MM-DD_HHMMSS}/{cards|dashboards|...}/{slug}.yaml Cada backup es un YAML con: _backup_of: {kind, slug, ts} remote_state: Restore NO aplica automaticamente a Metabase. Solo escribe el remote_state de vuelta al YAML activo del item, en formato local (con _meta + _refs + payload). Despues el usuario debe hacer `push --apply` para aplicar. Esto deja al usuario inspeccionar el resultado antes de aplicar. """ from __future__ import annotations import datetime as dt from pathlib import Path import yaml from payload import item_path def list_backups(project, kind: str, slug: str) -> list[Path]: """Lista todos los backups disponibles para un item, ordenados (mas recientes primero).""" backups_root = project.state_dir / "backups" if not backups_root.exists(): return [] candidates = [] for ts_dir in sorted(backups_root.iterdir(), reverse=True): if not ts_dir.is_dir(): continue bp = ts_dir / (kind + "s") / f"{slug}.yaml" if bp.exists(): candidates.append(bp) return candidates def _utc_now_iso() -> str: return dt.datetime.now(dt.timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ") def _id_to_slug(id_: int | None, mapping: dict[str, int]) -> str | None: if id_ is None: return None for slug, mid in mapping.items(): if mid == id_: return slug return None def _strip_remote_to_local(kind: str, remote: dict, project) -> dict: """Convierte un payload remoto en formato YAML local (_meta + _refs + payload). Reusa la misma logica de transformacion que sync_pull.pull_one. Lo mas simple es importar las funciones de strip y reconstruir el doc. """ from sync_pull import _id_to_slug as _id_to_slug_fn, _strip_volatile index = project.load_index() payload = _strip_volatile(remote) payload.pop("collection", None) body: dict = {"_meta": {}, "_refs": {}, "payload": payload} body["_meta"] = { "kind": kind, "id": remote.get("id"), "slug": None, # se rellena despues "synced_at": _utc_now_iso(), "remote_updated_at": remote.get("updated_at"), "restored_from_backup": True, } if kind == "card": db_slug = _id_to_slug_fn(remote.get("database_id"), index.get("databases", {})) coll_slug = _id_to_slug_fn(remote.get("collection_id"), index.get("collections", {})) body["_refs"] = {"database": db_slug, "collection": coll_slug} payload.pop("database_id", None) payload.pop("collection_id", None) if isinstance(payload.get("dataset_query"), dict) and "database" in payload["dataset_query"]: payload["dataset_query"]["database"] = db_slug elif kind == "dashboard": coll_slug = _id_to_slug_fn(remote.get("collection_id"), index.get("collections", {})) body["_refs"] = {"collection": coll_slug} payload.pop("collection_id", None) # dashcards: card_id -> card slug cards_idx = index.get("cards", {}) clean = [] for dc in payload.get("dashcards", []) or []: dc = dict(dc) cid = dc.pop("card_id", None) dc.pop("card", None) dc.pop("dashboard_id", None) dc["card"] = _id_to_slug_fn(cid, cards_idx) series = dc.get("series") or [] if series: dc["series"] = [_id_to_slug_fn(s.get("id") if isinstance(s, dict) else s, cards_idx) for s in series] clean.append({k: v for k, v in dc.items() if v not in (None, [], {})}) payload["dashcards"] = clean body["_meta"]["dashcards_count"] = len(clean) body["_meta"]["tabs_count"] = len(payload.get("tabs", []) or []) body["_meta"]["parameters_count"] = len(payload.get("parameters", []) or []) return body def restore_one(project, kind: str, slug: str, *, from_ts: str | None = None) -> Path: """Restaura el YAML local desde un backup. NO aplica a Metabase. Solo escribe el archivo de disco para que el usuario inspeccione y haga push --apply manualmente. """ backups = list_backups(project, kind, slug) if not backups: raise SystemExit( f"No hay backups para {kind} {slug} en {project.state_dir / 'backups'}" ) if from_ts is None: chosen = backups[0] # mas reciente else: matches = [b for b in backups if from_ts in str(b)] if not matches: raise SystemExit( f"No hay backup con timestamp '{from_ts}'. Disponibles:\n " + "\n ".join(str(b.relative_to(project.dir.parent.parent)) for b in backups) ) chosen = matches[0] print(f"[{project.name}] restore {kind} {slug}") print(f" desde: {chosen.relative_to(project.dir.parent.parent)}") with chosen.open() as f: backup_doc = yaml.safe_load(f) or {} remote_state = backup_doc.get("remote_state") if not remote_state: raise SystemExit(f"Backup corrupto: falta 'remote_state' en {chosen}") body = _strip_remote_to_local(kind, remote_state, project) body["_meta"]["slug"] = slug body["_meta"]["restored_from"] = str(chosen.relative_to(project.dir.parent.parent)) target = item_path(project.dir, kind, slug) target.parent.mkdir(parents=True, exist_ok=True) with target.open("w") as f: yaml.safe_dump(body, f, sort_keys=False, allow_unicode=True, default_flow_style=False, width=120) print(f" escrito en: {target.relative_to(project.dir.parent.parent)}") print(f"\n El backup quedo restaurado al disco. Para aplicarlo a Metabase:") print(f" python main.py push {kind} {slug} --apply") print(f" Antes, te recomiendo: cat {target.relative_to(project.dir.parent.parent)}") return target