"""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 _resolve_om_capabilities_for_initiative(initiative: dict[str, Any]) -> frozenset[str]: from entity_archetypes.registry import resolve_ui_profile profile = resolve_ui_profile(initiative["archetype_key"]) caps = profile.get("omCapabilities") or profile.get("dataSlices") or [] return frozenset(caps) 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, method_key: Optional[str] = None, lifecycle_metadata: Optional[dict[str, Any]] = 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) resolved_method = method_key or DEFAULT_METHOD_KEY from steering.methods.registry import get_method method = get_method(resolved_method) if not method: resolved_method = DEFAULT_METHOD_KEY method = get_method(resolved_method) method_version = method.version if method else DEFAULT_METHOD_VERSION metadata = lifecycle_metadata or {} 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, resolved_method, method_version, state, Json(metadata), ), ) 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, "method_key": resolved_method, }, ) 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 entity_archetypes.registry import resolve_default_method_key from method_profiles.registry import get_method_profile from steering.methods.registry import get_method, method_compatible_with_archetype from services.initiatives import get_initiative method = get_method(method_key) if not method: raise ValueError(f"Unbekannte Methode: {method_key}") initiative = get_initiative(tenant_id=tenant_id, initiative_id=initiative_id) if not initiative: raise ValueError("Vorhaben nicht gefunden") if not method_compatible_with_archetype( method, initiative["archetype_key"], om_capabilities=_resolve_om_capabilities_for_initiative(initiative), ): raise ValueError( f"Methode {method_key} ist nicht kompatibel mit Archetyp " f"{initiative['archetype_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 update_method_profile_key( *, tenant_id: str, initiative_id: str, method_profile_key: Optional[str], user_id: Optional[str] = None, ) -> dict[str, Any]: """Setzt oder entfernt method_profile_key; passt method_key aus Profile/Archetyp an.""" from entity_archetypes.registry import resolve_default_method_key from method_profiles.registry import get_method_profile from steering.methods.registry import get_method initiative = get_initiative(tenant_id=tenant_id, initiative_id=initiative_id) if not initiative: raise ValueError("Vorhaben nicht gefunden") existing = get_steering_context(tenant_id=tenant_id, initiative_id=initiative_id) if not existing: raise ValueError("SteeringContext nicht gefunden") metadata = dict(existing.get("lifecycle_metadata") or {}) previous_profile = metadata.get("method_profile_key") if method_profile_key: profile = get_method_profile(method_profile_key) if not profile: raise ValueError(f"Unbekannte Ausprägung: {method_profile_key}") if profile["initiative_archetype_key"] != initiative["archetype_key"]: raise ValueError("Ausprägung passt nicht zum Archetyp des Vorhabens") metadata["method_profile_key"] = method_profile_key resolved_method = profile["method_key"] else: metadata.pop("method_profile_key", None) resolved_method = resolve_default_method_key(initiative["archetype_key"]) method = get_method(resolved_method) if not method: raise ValueError(f"Unbekannte Methode: {resolved_method}") conn = get_connection() try: with conn.cursor(cursor_factory=RealDictCursor) as cur: cur.execute( """ UPDATE steering_contexts SET method_key = %s, method_version = %s, lifecycle_metadata = %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, Json(metadata), 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_profile_changed", user_id=user_id, tenant_id=tenant_id, details={ "initiative_id": initiative_id, "from_profile": previous_profile, "to_profile": method_profile_key, "method_key": method.key, }, ) return result def create_context_for_new_initiative( *, tenant_id: str, initiative_id: str, user_id: Optional[str] = None, archetype_key: str = "initiative.generic", method_profile_key: Optional[str] = None, ) -> dict[str, Any]: from entity_archetypes.registry import resolve_default_method_key from method_profiles.registry import resolve_method_for_profile method_key = resolve_default_method_key(archetype_key) lifecycle_metadata: dict[str, Any] = {} if method_profile_key: from method_profiles.registry import get_method_profile profile = get_method_profile(method_profile_key) if not profile: raise ValueError(f"Unbekannte Ausprägung: {method_profile_key}") if profile["initiative_archetype_key"] != archetype_key: raise ValueError("Ausprägung passt nicht zum Archetyp des Vorhabens") profile_method = resolve_method_for_profile(method_profile_key) if profile_method: method_key = profile_method lifecycle_metadata["method_profile_key"] = method_profile_key return ensure_steering_context( tenant_id=tenant_id, initiative_id=initiative_id, initial_state="intake", user_id=user_id, method_key=method_key, lifecycle_metadata=lifecycle_metadata or None, )