"""SteeringContext write service — AP1.0.""" 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 steering.lifecycle.states import validate_lifecycle_state DEFAULT_METHOD_KEY = "generic_operating" DEFAULT_METHOD_VERSION = "0.1.0" def _serialize_row(row: dict[str, Any]) -> dict[str, Any]: result = dict(row) for key in ("id", "tenant_id", "initiative_id"): if result.get(key): result[key] = str(result[key]) if result.get("created_at"): result["created_at"] = result["created_at"].isoformat() if result.get("updated_at"): result["updated_at"] = result["updated_at"].isoformat() if isinstance(result.get("lifecycle_metadata"), dict): pass elif result.get("lifecycle_metadata") is not None: result["lifecycle_metadata"] = dict(result["lifecycle_metadata"]) return result def _infer_initial_lifecycle_state(*, tenant_id: str, initiative_id: str) -> str: initiative = get_initiative(tenant_id=tenant_id, initiative_id=initiative_id) if not initiative: return "intake" if initiative["status"] in ("completed", "archived"): return "closure" conn = get_connection() try: with conn.cursor(cursor_factory=RealDictCursor) as cur: cur.execute( """ SELECT 1 FROM actions WHERE tenant_id = %s AND initiative_id = %s AND status IN ( 'open', 'ready', 'in_progress', 'blocked', 'review_required' ) LIMIT 1 """, (tenant_id, initiative_id), ) if cur.fetchone(): return "action_selection" cur.execute( """ SELECT 1 FROM backlog_items WHERE tenant_id = %s AND initiative_id = %s AND status IN ('new', 'triaged', 'accepted') LIMIT 1 """, (tenant_id, initiative_id), ) if cur.fetchone(): return "structure_setup" finally: conn.close() return "planning" def get_steering_context( *, tenant_id: str, initiative_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, method_key, method_version, lifecycle_state, lifecycle_metadata, created_at, updated_at FROM steering_contexts WHERE tenant_id = %s AND initiative_id = %s """, (tenant_id, initiative_id), ) row = cur.fetchone() return _serialize_row(dict(row)) if row else None finally: conn.close() def ensure_steering_context( *, tenant_id: str, initiative_id: str, initial_state: Optional[str] = None, user_id: Optional[str] = None, ) -> dict[str, Any]: existing = get_steering_context(tenant_id=tenant_id, initiative_id=initiative_id) if existing: return existing if not get_initiative(tenant_id=tenant_id, initiative_id=initiative_id): raise ValueError("Vorhaben nicht gefunden") state = initial_state or _infer_initial_lifecycle_state( tenant_id=tenant_id, initiative_id=initiative_id ) validate_lifecycle_state(state) conn = get_connection() try: with conn.cursor(cursor_factory=RealDictCursor) as cur: cur.execute( """ INSERT INTO steering_contexts ( tenant_id, initiative_id, method_key, method_version, lifecycle_state, lifecycle_metadata ) VALUES (%s, %s, %s, %s, %s, %s) RETURNING id, tenant_id, initiative_id, method_key, method_version, lifecycle_state, lifecycle_metadata, created_at, updated_at """, ( tenant_id, initiative_id, DEFAULT_METHOD_KEY, DEFAULT_METHOD_VERSION, state, Json({}), ), ) row = _serialize_row(dict(cur.fetchone())) conn.commit() finally: conn.close() log_audit( "steering_context.created", user_id=user_id, tenant_id=tenant_id, details={"initiative_id": initiative_id, "lifecycle_state": state}, ) return row def update_lifecycle_state( *, tenant_id: str, initiative_id: str, lifecycle_state: str, user_id: Optional[str] = None, reason: str = "", from_state: Optional[str] = None, ) -> dict[str, Any]: validate_lifecycle_state(lifecycle_state) conn = get_connection() try: with conn.cursor(cursor_factory=RealDictCursor) as cur: cur.execute( """ UPDATE steering_contexts SET lifecycle_state = %s, updated_at = NOW() WHERE tenant_id = %s AND initiative_id = %s RETURNING id, tenant_id, initiative_id, method_key, method_version, lifecycle_state, lifecycle_metadata, created_at, updated_at """, (lifecycle_state, tenant_id, initiative_id), ) row = cur.fetchone() if not row: raise ValueError("SteeringContext nicht gefunden") result = _serialize_row(dict(row)) conn.commit() finally: conn.close() log_audit( "steering_context.lifecycle_transition", user_id=user_id, tenant_id=tenant_id, details={ "initiative_id": initiative_id, "from_state": from_state, "to_state": lifecycle_state, "reason": reason, }, ) return result def update_method_key( *, tenant_id: str, initiative_id: str, method_key: str, user_id: Optional[str] = None, ) -> dict[str, Any]: from steering.methods.registry import get_method method = get_method(method_key) if not method: raise ValueError(f"Unbekannte Methode: {method_key}") existing = get_steering_context(tenant_id=tenant_id, initiative_id=initiative_id) if not existing: raise ValueError("SteeringContext nicht gefunden") conn = get_connection() try: with conn.cursor(cursor_factory=RealDictCursor) as cur: cur.execute( """ UPDATE steering_contexts SET method_key = %s, method_version = %s, updated_at = NOW() WHERE tenant_id = %s AND initiative_id = %s RETURNING id, tenant_id, initiative_id, method_key, method_version, lifecycle_state, lifecycle_metadata, created_at, updated_at """, (method.key, method.version, tenant_id, initiative_id), ) row = cur.fetchone() if not row: raise ValueError("SteeringContext nicht gefunden") result = _serialize_row(dict(row)) conn.commit() finally: conn.close() log_audit( "steering_context.method_changed", user_id=user_id, tenant_id=tenant_id, details={ "initiative_id": initiative_id, "from_method": existing["method_key"], "to_method": method_key, }, ) return result def create_context_for_new_initiative( *, tenant_id: str, initiative_id: str, user_id: Optional[str] = None, ) -> dict[str, Any]: return ensure_steering_context( tenant_id=tenant_id, initiative_id=initiative_id, initial_state="intake", user_id=user_id, )