Some checks failed
Deploy Development / deploy (push) Successful in 46s
Test Suite / pytest-backend (push) Failing after 2m2s
Test Suite / k6 /api/health Baseline (push) Has been skipped
Test Suite / playwright-smoke (push) Has been skipped
Test Suite / lint-backend (push) Successful in 3s
Test Suite / compose-smoke (push) Has been skipped
PO-Dokumente (ADP v0.2, MVP v0.3) und Minimal Complete Slice: Registry-Methoden, Initiative/Project-Spiegel, Default-Methode bei Anlage, Lagebild mit Archetyp und Guidance. Co-authored-by: Cursor <cursoragent@cursor.com>
291 lines
9.1 KiB
Python
291 lines
9.1 KiB
Python
"""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,
|
|
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 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,
|
|
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:
|
|
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,
|
|
)
|