"""Plan snapshot capture & persistence (AP1.14).""" from __future__ import annotations import json from typing import Any, Optional from psycopg2.extras import RealDictCursor, Json from db import get_connection from services.audit import log_audit from services.initiatives import get_initiative from services import roadmap as roadmap_service from services import roadmap_criteria as criteria_service def _serialize_snapshot(row: dict[str, Any]) -> dict[str, Any]: result = dict(row) for key in ("id", "tenant_id", "initiative_id", "roadmap_id", "created_by_user_id"): if result.get(key): result[key] = str(result[key]) if result.get("created_at"): result["created_at"] = result["created_at"].isoformat() payload = result.get("plan_payload") if isinstance(payload, str): result["plan_payload"] = json.loads(payload) return result def capture_plan_payload( *, tenant_id: str, initiative_id: str ) -> dict[str, Any]: """Soll-Struktur: Topologie + Kriterien-Definitionen ohne Ist-Status.""" items = roadmap_service.list_roadmap_items_for_initiative( tenant_id=tenant_id, initiative_id=initiative_id ) dependencies = roadmap_service.list_dependencies_for_initiative( tenant_id=tenant_id, initiative_id=initiative_id ) plan_items = [] for item in items: plan_items.append( { "id": item["id"], "title": item["title"], "item_type": item["item_type"], "goal_description": item.get("goal_description") or "", "sort_order": item.get("sort_order", 0), "target_date": item.get("target_date"), "sequencing_mode": item.get("sequencing_mode", "sequential"), } ) plan_deps = [] for dep in dependencies: plan_deps.append( { "from_item_id": dep["from_item_id"], "to_item_id": dep["to_item_id"], "dependency_type": dep["dependency_type"], "group_key": dep.get("group_key"), } ) plan_criteria = [] for item in items: for crit in criteria_service.list_criteria_for_item( tenant_id=tenant_id, item_id=item["id"] ): plan_criteria.append( { "id": crit["id"], "roadmap_item_id": crit["roadmap_item_id"], "title": crit["title"], "description": crit.get("description") or "", "criterion_kind": crit["criterion_kind"], "sort_order": crit.get("sort_order", 0), } ) return { "items": plan_items, "dependencies": plan_deps, "criteria": plan_criteria, } def list_plan_snapshots( *, tenant_id: str, initiative_id: str, limit: int = 20 ) -> list[dict[str, Any]]: if not get_initiative(tenant_id=tenant_id, initiative_id=initiative_id): raise ValueError("Initiative nicht gefunden") conn = get_connection() try: with conn.cursor(cursor_factory=RealDictCursor) as cur: cur.execute( """ SELECT id, tenant_id, initiative_id, roadmap_id, revision_number, reason, plan_payload, created_by_user_id, created_at FROM roadmap_plan_snapshots WHERE tenant_id = %s AND initiative_id = %s ORDER BY revision_number DESC LIMIT %s """, (tenant_id, initiative_id, limit), ) return [_serialize_snapshot(dict(r)) for r in cur.fetchall()] finally: conn.close() def get_plan_snapshot( *, tenant_id: str, snapshot_id: str ) -> Optional[dict[str, Any]]: conn = get_connection() try: with conn.cursor(cursor_factory=RealDictCursor) as cur: cur.execute( """ SELECT id, tenant_id, initiative_id, roadmap_id, revision_number, reason, plan_payload, created_by_user_id, created_at FROM roadmap_plan_snapshots WHERE id = %s AND tenant_id = %s """, (snapshot_id, tenant_id), ) row = cur.fetchone() return _serialize_snapshot(dict(row)) if row else None finally: conn.close() def create_plan_snapshot( *, tenant_id: str, initiative_id: str, reason: str = "", user_id: Optional[str] = None, ) -> dict[str, Any]: if not get_initiative(tenant_id=tenant_id, initiative_id=initiative_id): raise ValueError("Initiative nicht gefunden") roadmap = roadmap_service.get_roadmap_for_initiative( tenant_id=tenant_id, initiative_id=initiative_id ) if not roadmap: raise ValueError("Roadmap nicht gefunden") plan_payload = capture_plan_payload(tenant_id=tenant_id, initiative_id=initiative_id) reason = reason.strip() conn = get_connection() try: with conn.cursor(cursor_factory=RealDictCursor) as cur: cur.execute( """ SELECT COALESCE(MAX(revision_number), 0) + 1 AS next_rev FROM roadmap_plan_snapshots WHERE tenant_id = %s AND initiative_id = %s """, (tenant_id, initiative_id), ) revision_number = int(cur.fetchone()["next_rev"]) cur.execute( """ INSERT INTO roadmap_plan_snapshots ( tenant_id, initiative_id, roadmap_id, revision_number, reason, plan_payload, created_by_user_id ) VALUES (%s, %s, %s, %s, %s, %s, %s) RETURNING id, tenant_id, initiative_id, roadmap_id, revision_number, reason, plan_payload, created_by_user_id, created_at """, ( tenant_id, initiative_id, roadmap["id"], revision_number, reason, Json(plan_payload), user_id, ), ) row = _serialize_snapshot(dict(cur.fetchone())) conn.commit() finally: conn.close() log_audit( "roadmap_plan_snapshot.created", user_id=user_id, tenant_id=tenant_id, details={ "snapshot_id": row["id"], "initiative_id": initiative_id, "revision_number": revision_number, "reason": reason or None, }, ) return row