All checks were successful
Deploy Development / deploy (push) Successful in 43s
Test Suite / pytest-backend (push) Successful in 1m29s
Test Suite / lint-backend (push) Successful in 1s
Test Suite / compose-smoke (push) Has been skipped
Test Suite / k6 /api/health Baseline (push) Successful in 18s
Test Suite / playwright-smoke (push) Successful in 15s
Behebt psycopg2 AmbiguousColumn (id/title) bei steering-snapshot nach AP1.4. Co-authored-by: Cursor <cursoragent@cursor.com>
323 lines
11 KiB
Python
323 lines
11 KiB
Python
"""Initiative steering snapshot — verknüpftes Read-Model (AP0.10a).
|
|
|
|
Baut aus flachen OM-Tabellen einen erklärbaren Graph für UI und spätere Steering Core.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from typing import Any, Optional
|
|
|
|
from psycopg2.extras import RealDictCursor
|
|
|
|
from db import get_connection
|
|
from data_layer.attention import get_next_action_candidates_for_initiative
|
|
from steering.context import get_steering_context_dto
|
|
from steering.signals.snapshot_signals import derive_initiative_signals
|
|
from services.initiatives import get_initiative
|
|
from tenant_context import TenantContext
|
|
|
|
OPEN_BLOCKER = ("open", "in_progress")
|
|
OPEN_ACTION = ("open", "ready", "in_progress", "blocked", "review_required")
|
|
ACTIVE_INITIATIVE = ("active", "paused")
|
|
|
|
|
|
def _sid(value: Any) -> Optional[str]:
|
|
return str(value) if value else None
|
|
|
|
|
|
def _iso(value: Any) -> Optional[str]:
|
|
return value.isoformat() if value else None
|
|
|
|
|
|
def get_initiative_steering_snapshot(
|
|
ctx: TenantContext, *, initiative_id: str
|
|
) -> Optional[dict[str, Any]]:
|
|
"""Tenant-scoped Graph-Snapshot eines Vorhabens."""
|
|
initiative = get_initiative(tenant_id=ctx.tenant_id, initiative_id=initiative_id)
|
|
if not initiative:
|
|
return None
|
|
|
|
conn = get_connection()
|
|
try:
|
|
with conn.cursor(cursor_factory=RealDictCursor) as cur:
|
|
cur.execute(
|
|
"""
|
|
SELECT id, title, description, status, priority, due_at,
|
|
created_at, updated_at
|
|
FROM actions
|
|
WHERE tenant_id = %s AND initiative_id = %s
|
|
ORDER BY updated_at DESC
|
|
""",
|
|
(ctx.tenant_id, initiative_id),
|
|
)
|
|
actions_raw = [dict(r) for r in cur.fetchall()]
|
|
|
|
cur.execute(
|
|
"""
|
|
SELECT id, action_id, title, status, created_at, updated_at
|
|
FROM blockers
|
|
WHERE tenant_id = %s AND initiative_id = %s
|
|
ORDER BY updated_at DESC
|
|
""",
|
|
(ctx.tenant_id, initiative_id),
|
|
)
|
|
blockers = [dict(r) for r in cur.fetchall()]
|
|
|
|
cur.execute(
|
|
"""
|
|
SELECT id, action_id, milestone_id, title, status, created_at, updated_at
|
|
FROM evidence
|
|
WHERE tenant_id = %s AND initiative_id = %s
|
|
ORDER BY updated_at DESC
|
|
""",
|
|
(ctx.tenant_id, initiative_id),
|
|
)
|
|
evidence = [dict(r) for r in cur.fetchall()]
|
|
|
|
cur.execute(
|
|
"""
|
|
SELECT id, action_id, milestone_id, title, status, due_at,
|
|
created_at, updated_at
|
|
FROM reviews
|
|
WHERE tenant_id = %s AND initiative_id = %s
|
|
ORDER BY updated_at DESC
|
|
""",
|
|
(ctx.tenant_id, initiative_id),
|
|
)
|
|
reviews = [dict(r) for r in cur.fetchall()]
|
|
|
|
cur.execute(
|
|
"""
|
|
SELECT id, title, status, priority, converted_action_id, created_at, updated_at
|
|
FROM backlog_items
|
|
WHERE tenant_id = %s AND initiative_id = %s
|
|
ORDER BY updated_at DESC
|
|
""",
|
|
(ctx.tenant_id, initiative_id),
|
|
)
|
|
backlog = [dict(r) for r in cur.fetchall()]
|
|
|
|
cur.execute(
|
|
"""
|
|
SELECT ri.id, ri.title, ri.status, ri.target_date, ri.item_type,
|
|
ri.sequencing_mode, ri.sort_order, ri.created_at, ri.updated_at
|
|
FROM roadmap_items ri
|
|
JOIN roadmaps r ON r.id = ri.roadmap_id AND r.tenant_id = ri.tenant_id
|
|
WHERE ri.tenant_id = %s AND r.initiative_id = %s
|
|
ORDER BY ri.sort_order ASC, ri.updated_at DESC
|
|
""",
|
|
(ctx.tenant_id, initiative_id),
|
|
)
|
|
roadmap_items_raw = [dict(r) for r in cur.fetchall()]
|
|
|
|
cur.execute(
|
|
"""
|
|
SELECT id, title, status, outcome, created_at, updated_at
|
|
FROM decisions
|
|
WHERE tenant_id = %s AND initiative_id = %s
|
|
ORDER BY updated_at DESC
|
|
""",
|
|
(ctx.tenant_id, initiative_id),
|
|
)
|
|
decisions = [dict(r) for r in cur.fetchall()]
|
|
|
|
cur.execute(
|
|
"""
|
|
SELECT id, title, status, next_due_at, created_at, updated_at
|
|
FROM recurring_elements
|
|
WHERE tenant_id = %s AND initiative_id = %s
|
|
ORDER BY updated_at DESC
|
|
""",
|
|
(ctx.tenant_id, initiative_id),
|
|
)
|
|
recurring = [dict(r) for r in cur.fetchall()]
|
|
finally:
|
|
conn.close()
|
|
|
|
blockers_by_action: dict[str, list[dict]] = {}
|
|
unlinked_blockers: list[dict] = []
|
|
for b in blockers:
|
|
item = {
|
|
"id": _sid(b["id"]),
|
|
"title": b["title"],
|
|
"status": b["status"],
|
|
"action_id": _sid(b.get("action_id")),
|
|
}
|
|
aid = item["action_id"]
|
|
if aid:
|
|
blockers_by_action.setdefault(aid, []).append(item)
|
|
else:
|
|
unlinked_blockers.append(item)
|
|
|
|
evidence_by_action: dict[str, list[dict]] = {}
|
|
unlinked_evidence: list[dict] = []
|
|
for e in evidence:
|
|
item = {
|
|
"id": _sid(e["id"]),
|
|
"title": e["title"],
|
|
"status": e["status"],
|
|
"action_id": _sid(e.get("action_id")),
|
|
"milestone_id": _sid(e.get("milestone_id")),
|
|
}
|
|
aid = item["action_id"]
|
|
if aid:
|
|
evidence_by_action.setdefault(aid, []).append(item)
|
|
else:
|
|
unlinked_evidence.append(item)
|
|
|
|
reviews_by_action: dict[str, list[dict]] = {}
|
|
reviews_by_milestone: dict[str, list[dict]] = {}
|
|
unlinked_reviews: list[dict] = []
|
|
planned_reviews_due = 0
|
|
for r in reviews:
|
|
item = {
|
|
"id": _sid(r["id"]),
|
|
"title": r["title"],
|
|
"status": r["status"],
|
|
"due_at": _iso(r.get("due_at")),
|
|
"action_id": _sid(r.get("action_id")),
|
|
"milestone_id": _sid(r.get("milestone_id")),
|
|
}
|
|
if r["status"] == "planned" and r.get("due_at"):
|
|
planned_reviews_due += 1
|
|
aid, mid = item["action_id"], item["milestone_id"]
|
|
if aid:
|
|
reviews_by_action.setdefault(aid, []).append(item)
|
|
elif mid:
|
|
reviews_by_milestone.setdefault(mid, []).append(item)
|
|
else:
|
|
unlinked_reviews.append(item)
|
|
|
|
actions: list[dict[str, Any]] = []
|
|
open_actions = blocked_actions = review_required_actions = 0
|
|
for a in actions_raw:
|
|
aid = _sid(a["id"])
|
|
status = a["status"]
|
|
if status in OPEN_ACTION:
|
|
open_actions += 1
|
|
if status == "blocked":
|
|
blocked_actions += 1
|
|
if status == "review_required":
|
|
review_required_actions += 1
|
|
action_blockers = blockers_by_action.get(aid, [])
|
|
open_blocker_count = sum(1 for b in action_blockers if b["status"] in OPEN_BLOCKER)
|
|
actions.append(
|
|
{
|
|
"id": aid,
|
|
"title": a["title"],
|
|
"status": status,
|
|
"priority": a["priority"],
|
|
"due_at": _iso(a.get("due_at")),
|
|
"blockers": action_blockers,
|
|
"evidence": evidence_by_action.get(aid, []),
|
|
"reviews": reviews_by_action.get(aid, []),
|
|
"open_blocker_count": open_blocker_count,
|
|
"has_open_blocker": open_blocker_count > 0,
|
|
}
|
|
)
|
|
|
|
roadmap_items: list[dict[str, Any]] = []
|
|
for m in roadmap_items_raw:
|
|
mid = _sid(m["id"])
|
|
roadmap_items.append(
|
|
{
|
|
"id": mid,
|
|
"title": m["title"],
|
|
"status": m["status"],
|
|
"item_type": m["item_type"],
|
|
"sequencing_mode": m["sequencing_mode"],
|
|
"sort_order": m.get("sort_order", 0),
|
|
"target_date": m["target_date"].isoformat() if m.get("target_date") else None,
|
|
"reviews": reviews_by_milestone.get(mid, []),
|
|
}
|
|
)
|
|
|
|
open_blockers = sum(
|
|
1 for b in blockers if b["status"] in OPEN_BLOCKER
|
|
)
|
|
backlog_new = sum(1 for bi in backlog if bi["status"] in ("new", "triaged", "accepted"))
|
|
|
|
signals = derive_initiative_signals(
|
|
initiative_status=initiative["status"],
|
|
open_actions=open_actions,
|
|
blocked_actions=blocked_actions,
|
|
review_required_actions=review_required_actions,
|
|
open_blockers=open_blockers,
|
|
backlog_new=backlog_new,
|
|
planned_reviews_due=planned_reviews_due,
|
|
)
|
|
|
|
upcoming_milestones = sorted(
|
|
[
|
|
m
|
|
for m in roadmap_items
|
|
if m["status"] in ("planned", "active", "at_risk")
|
|
],
|
|
key=lambda m: (m["target_date"] is None, m["target_date"] or ""),
|
|
)[:5]
|
|
|
|
next_actions = get_next_action_candidates_for_initiative(
|
|
ctx, initiative_id=initiative_id, limit=5
|
|
)
|
|
|
|
steering = get_steering_context_dto(ctx, initiative_id=initiative_id)
|
|
|
|
return {
|
|
"initiative_id": initiative_id,
|
|
"initiative_title": initiative["title"],
|
|
"initiative_status": initiative["status"],
|
|
"lifecycle_state": steering["lifecycle_state"],
|
|
"lifecycle_label": steering["lifecycle_label"],
|
|
"method_key": steering["method_key"],
|
|
"method_label": steering.get("method_label"),
|
|
"method_version": steering["method_version"],
|
|
"signals": signals,
|
|
"upcoming_milestones": upcoming_milestones,
|
|
"upcoming_roadmap_items": upcoming_milestones,
|
|
"next_actions": next_actions,
|
|
"counts": {
|
|
"actions_open": open_actions,
|
|
"actions_blocked": blocked_actions,
|
|
"actions_review_required": review_required_actions,
|
|
"blockers_open": open_blockers,
|
|
"backlog_open": backlog_new,
|
|
"decisions": len(decisions),
|
|
"reviews_planned_due": planned_reviews_due,
|
|
"recurring_active": sum(1 for r in recurring if r["status"] == "active"),
|
|
"unlinked_blockers": len(unlinked_blockers),
|
|
"unlinked_evidence": len(unlinked_evidence),
|
|
"unlinked_reviews": len(unlinked_reviews),
|
|
},
|
|
"actions": actions,
|
|
"roadmap_items": roadmap_items,
|
|
"milestones": roadmap_items,
|
|
"initiative_level": {
|
|
"blockers": unlinked_blockers,
|
|
"evidence": unlinked_evidence,
|
|
"reviews": unlinked_reviews,
|
|
"decisions": [
|
|
{"id": _sid(d["id"]), "title": d["title"], "status": d["status"]}
|
|
for d in decisions
|
|
],
|
|
"backlog": [
|
|
{
|
|
"id": _sid(b["id"]),
|
|
"title": b["title"],
|
|
"status": b["status"],
|
|
"converted_action_id": _sid(b.get("converted_action_id")),
|
|
}
|
|
for b in backlog
|
|
],
|
|
"recurring": [
|
|
{
|
|
"id": _sid(r["id"]),
|
|
"title": r["title"],
|
|
"status": r["status"],
|
|
"next_due_at": _iso(r.get("next_due_at")),
|
|
}
|
|
for r in recurring
|
|
],
|
|
},
|
|
"data_source": "initiative_steering_snapshot",
|
|
}
|