# -*- coding: utf-8 -*- """ Gemeinsame Sicherungs-Verwaltung für die schreibenden Plugins. Jeder verändernde Lauf legt einen Ordner unter `data//laeufe//` an und schreibt dort eine `manifest.json`. Darin steht Schritt für Schritt, was geändert wurde und wie der Zustand vorher aussah — genug, um es zurückzudrehen. Was ein Rückspielen leisten kann, unterscheidet sich je nach Eingriff. Das steht ehrlich im Manifest, damit die Oberfläche es anzeigen kann statt zu behaupten, alles ließe sich rückstandsfrei rückgängig machen: "voll" Der vorherige Zustand lässt sich exakt wiederherstellen. Beispiel: geänderte Nährwerte — der alte Wert wird zurückgeschrieben. "neue_id" Der Eintrag kommt zurück, bekommt aber eine neue ID. Bei Einträgen, auf die nichts zeigte (unbenutzt gelöscht), ist das folgenlos. "teilweise" Der Eintrag kommt mit neuer ID zurück und die aufgezeichneten Verweise werden zurückgehängt. Was nicht aufgezeichnet wurde (Einkaufslisten, Automatisierungen, Umrechnungen), bleibt beim Ziel. """ from __future__ import annotations import json from datetime import datetime, timezone from pathlib import Path from typing import Any from pydantic import BaseModel class RestoreRequest(BaseModel): run: str apply: bool = False force: bool = False MANIFEST = "manifest.json" RESTORE_MARK = "zurueckgespielt.json" RESTORE_LEVELS = { "voll": "vollständig rückspielbar", "neue_id": "rückspielbar, Eintrag bekommt neue ID", "teilweise": "nur teilweise rückspielbar", "nein": "nicht rückspielbar", } def stamp() -> str: return datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ") def write_json(path: Path, payload: Any) -> Path: path.parent.mkdir(parents=True, exist_ok=True) temporary = path.with_suffix(path.suffix + ".tmp") temporary.write_text( json.dumps(payload, ensure_ascii=False, indent=2) + "\n", encoding="utf-8" ) temporary.replace(path) return path def write_manifest(run_dir: Path, payload: dict[str, Any]) -> Path: return write_json(run_dir / MANIFEST, payload) def read_manifest(run_dir: Path) -> dict[str, Any] | None: file = run_dir / MANIFEST if not file.is_file(): return None try: return json.loads(file.read_text(encoding="utf-8")) except Exception: return None def mark_restored(run_dir: Path, info: dict[str, Any]) -> None: """Vermerkt, dass dieser Lauf zurückgespielt wurde.""" existing = [] file = run_dir / RESTORE_MARK if file.is_file(): try: existing = json.loads(file.read_text(encoding="utf-8")) except Exception: existing = [] if not isinstance(existing, list): existing = [existing] existing.append({"at": datetime.now(timezone.utc).isoformat(), **info}) write_json(file, existing) def restore_history(run_dir: Path) -> list[dict[str, Any]]: file = run_dir / RESTORE_MARK if not file.is_file(): return [] try: data = json.loads(file.read_text(encoding="utf-8")) return data if isinstance(data, list) else [data] except Exception: return [] def summarize(manifest: dict[str, Any]) -> dict[str, Any]: """Kurzfassung eines Laufs für die Übersicht.""" steps = manifest.get("steps") or [] done = [s for s in steps if s.get("status") == "done"] levels = {s.get("restore_level", "nein") for s in done} worst = ("nein" if "nein" in levels else "teilweise" if "teilweise" in levels else "neue_id" if "neue_id" in levels else "voll" if levels else "nein") return { "steps": len(steps), "done": len(done), "restore_level": worst, "restore_label": RESTORE_LEVELS[worst], } def list_runs(runs_dir: Path) -> list[dict[str, Any]]: """Alle Läufe eines Plugins, neueste zuerst.""" if not runs_dir.is_dir(): return [] runs: list[dict[str, Any]] = [] for path in sorted(runs_dir.iterdir(), reverse=True): if not path.is_dir(): continue manifest = read_manifest(path) if manifest is None: continue history = restore_history(path) runs.append({ "id": path.name, "created_at": manifest.get("created_at"), "tandoor": manifest.get("tandoor"), "label": manifest.get("label") or "", **summarize(manifest), "restored": bool(history), "restored_at": history[-1]["at"] if history else None, }) return runs def resolve_run(runs_dir: Path, name: str) -> Path: """Lauf-Ordner sicher auflösen — kein Ausbrechen aus dem Datenordner.""" candidate = runs_dir / Path(name).name if not candidate.is_dir() or not (candidate / MANIFEST).is_file(): raise FileNotFoundError(f"Lauf „{name}“ gibt es nicht.") return candidate # ------------------------------------------------------- Router fürs Plugin def backup_router(ctx, runs_subdir: str = "laeufe", restore_argv=None): """ Fertiger Satz Endpunkte für die Sicherungen eines Plugins. Damit sieht der Reiter „Sicherungen“ in jedem schreibenden Plugin gleich aus und verhält sich gleich: GET /api/backups Läufe auflisten GET /api/backups/{id} ein Lauf im Detail POST /api/run/restore zurückspielen (Trockenübung oder echt) `restore_argv(run_id, apply, force)` liefert die Kommandozeile für den Job-Runner. Das Zurückspielen läuft damit über dasselbe Skript und denselben Weg wie das Ändern selbst — nicht über einen zweiten, ungetesteten Pfad. """ from fastapi import APIRouter, HTTPException runs_dir = ctx.data_dir / runs_subdir router = APIRouter() @router.get("/api/backups") def backups_list() -> dict[str, Any]: return {"runs": list_runs(runs_dir), "dir": str(runs_dir)} @router.get("/api/backups/{name}") def backup_detail(name: str) -> dict[str, Any]: try: run = resolve_run(runs_dir, name) except FileNotFoundError as exc: raise HTTPException(404, str(exc)) from exc manifest = read_manifest(run) or {} return { "id": run.name, "manifest": manifest, "summary": summarize(manifest), "history": restore_history(run), "files": sorted(p.name for p in run.glob("*.json")), } @router.post("/api/run/restore") async def run_restore(request: RestoreRequest) -> dict[str, Any]: if ctx.jobs.running(ctx.id): raise HTTPException(409, "Es läuft bereits etwas.") if not ctx.settings.status()["tandoor"]: raise HTTPException(400, "Tandoor-URL und Token fehlen.") try: run = resolve_run(runs_dir, request.run) except FileNotFoundError as exc: raise HTTPException(404, str(exc)) from exc env = ctx.settings.tool_env() env["DATA_DIR"] = str(ctx.data_dir) job = await ctx.jobs.start( plugin=ctx.id, label=("Zurückspielen" if request.apply else "Trockenübung Zurückspielen") + f": {run.name}", argv=restore_argv(run.name, request.apply, request.force), cwd=ctx.data_dir, env=env, ) return job.info() return router