Some checks failed
Deploy Development / deploy (push) Failing after 37s
Test Suite / pytest-backend (push) Successful in 1m41s
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 13s
Backlog, Actions, Projects und Tasks koennen Gates zugeordnet werden; Gate-Beitragsliste und chronologische Journey machen Plan/Ist nachvollziehbar. Co-authored-by: Cursor <cursoragent@cursor.com>
323 lines
12 KiB
Python
323 lines
12 KiB
Python
"""Plan/Ist-Verknüpfung — Gates, Journey, Beiträge (AP1.6)."""
|
|
|
|
from __future__ import annotations
|
|
|
|
from typing import Any, Literal, Optional
|
|
|
|
from psycopg2.extras import RealDictCursor
|
|
|
|
from db import get_connection
|
|
from services.initiatives import get_initiative
|
|
|
|
JourneyEventKind = Literal[
|
|
"decision",
|
|
"review",
|
|
"evidence",
|
|
"action",
|
|
"backlog",
|
|
"gate",
|
|
]
|
|
|
|
|
|
def validate_roadmap_item_in_initiative(
|
|
cur,
|
|
*,
|
|
tenant_id: str,
|
|
initiative_id: str,
|
|
roadmap_item_id: Optional[str],
|
|
) -> None:
|
|
if not roadmap_item_id:
|
|
return
|
|
cur.execute(
|
|
"""
|
|
SELECT 1 FROM roadmap_items ri
|
|
JOIN roadmaps r ON r.id = ri.roadmap_id AND r.tenant_id = ri.tenant_id
|
|
WHERE ri.id = %s AND ri.tenant_id = %s AND r.initiative_id = %s
|
|
""",
|
|
(roadmap_item_id, tenant_id, initiative_id),
|
|
)
|
|
if not cur.fetchone():
|
|
raise ValueError("Gate gehört nicht zu diesem Vorhaben")
|
|
|
|
|
|
def _iso(value: Any) -> Optional[str]:
|
|
if not value:
|
|
return None
|
|
return value.isoformat() if hasattr(value, "isoformat") else str(value)
|
|
|
|
|
|
def list_gate_contributions(
|
|
*, tenant_id: str, roadmap_item_id: str
|
|
) -> dict[str, Any]:
|
|
conn = get_connection()
|
|
try:
|
|
with conn.cursor(cursor_factory=RealDictCursor) as cur:
|
|
cur.execute(
|
|
"""
|
|
SELECT ri.id, ri.title, ri.status, r.initiative_id
|
|
FROM roadmap_items ri
|
|
JOIN roadmaps r ON r.id = ri.roadmap_id AND r.tenant_id = ri.tenant_id
|
|
WHERE ri.id = %s AND ri.tenant_id = %s
|
|
""",
|
|
(roadmap_item_id, tenant_id),
|
|
)
|
|
gate = cur.fetchone()
|
|
if not gate:
|
|
raise ValueError("Gate nicht gefunden")
|
|
initiative_id = str(gate["initiative_id"])
|
|
|
|
cur.execute(
|
|
"""
|
|
SELECT id, title, status, priority, converted_action_id
|
|
FROM backlog_items
|
|
WHERE tenant_id = %s AND initiative_id = %s AND roadmap_item_id = %s
|
|
ORDER BY updated_at DESC
|
|
""",
|
|
(tenant_id, initiative_id, roadmap_item_id),
|
|
)
|
|
backlog = [dict(r) for r in cur.fetchall()]
|
|
|
|
cur.execute(
|
|
"""
|
|
SELECT id, title, status, priority, project_id
|
|
FROM actions
|
|
WHERE tenant_id = %s AND initiative_id = %s AND roadmap_item_id = %s
|
|
ORDER BY updated_at DESC
|
|
""",
|
|
(tenant_id, initiative_id, roadmap_item_id),
|
|
)
|
|
actions = [dict(r) for r in cur.fetchall()]
|
|
|
|
cur.execute(
|
|
"""
|
|
SELECT id, title, status, parent_project_id
|
|
FROM projects
|
|
WHERE tenant_id = %s AND initiative_id = %s AND roadmap_item_id = %s
|
|
ORDER BY updated_at DESC
|
|
""",
|
|
(tenant_id, initiative_id, roadmap_item_id),
|
|
)
|
|
projects = [dict(r) for r in cur.fetchall()]
|
|
|
|
cur.execute(
|
|
"""
|
|
SELECT t.id, t.title, t.status, t.action_id, a.title AS action_title
|
|
FROM tasks t
|
|
JOIN actions a ON a.id = t.action_id AND a.tenant_id = t.tenant_id
|
|
WHERE t.tenant_id = %s AND a.initiative_id = %s AND t.roadmap_item_id = %s
|
|
ORDER BY t.updated_at DESC
|
|
""",
|
|
(tenant_id, initiative_id, roadmap_item_id),
|
|
)
|
|
tasks = [dict(r) for r in cur.fetchall()]
|
|
finally:
|
|
conn.close()
|
|
|
|
def _ids(rows: list[dict]) -> list[dict[str, Any]]:
|
|
out = []
|
|
for row in rows:
|
|
item = dict(row)
|
|
for key in item:
|
|
if item[key] is not None and key.endswith("_id"):
|
|
item[key] = str(item[key])
|
|
out.append(item)
|
|
return out
|
|
|
|
return {
|
|
"roadmap_item_id": roadmap_item_id,
|
|
"initiative_id": initiative_id,
|
|
"gate_title": gate["title"],
|
|
"gate_status": gate["status"],
|
|
"backlog_items": _ids(backlog),
|
|
"actions": _ids(actions),
|
|
"projects": _ids(projects),
|
|
"tasks": _ids(tasks),
|
|
"counts": {
|
|
"backlog": len(backlog),
|
|
"actions": len(actions),
|
|
"projects": len(projects),
|
|
"tasks": len(tasks),
|
|
"total": len(backlog) + len(actions) + len(projects) + len(tasks),
|
|
},
|
|
}
|
|
|
|
|
|
def list_journey_events(*, tenant_id: str, initiative_id: str) -> list[dict[str, Any]]:
|
|
if not get_initiative(tenant_id=tenant_id, initiative_id=initiative_id):
|
|
raise ValueError("Initiative nicht gefunden")
|
|
|
|
events: list[dict[str, Any]] = []
|
|
conn = get_connection()
|
|
try:
|
|
with conn.cursor(cursor_factory=RealDictCursor) as cur:
|
|
cur.execute(
|
|
"""
|
|
SELECT id, title, status, outcome, created_at, updated_at
|
|
FROM decisions
|
|
WHERE tenant_id = %s AND initiative_id = %s
|
|
""",
|
|
(tenant_id, initiative_id),
|
|
)
|
|
for row in cur.fetchall():
|
|
events.append(
|
|
{
|
|
"kind": "decision",
|
|
"entity_id": str(row["id"]),
|
|
"title": row["title"],
|
|
"status": row["status"],
|
|
"summary": row["outcome"] or row["status"],
|
|
"occurred_at": _iso(row["updated_at"] or row["created_at"]),
|
|
"roadmap_item_id": None,
|
|
}
|
|
)
|
|
|
|
cur.execute(
|
|
"""
|
|
SELECT id, title, status, summary, created_at, updated_at, milestone_id, roadmap_item_id
|
|
FROM reviews
|
|
WHERE tenant_id = %s AND initiative_id = %s
|
|
""",
|
|
(tenant_id, initiative_id),
|
|
)
|
|
for row in cur.fetchall():
|
|
rid = row.get("roadmap_item_id") or row.get("milestone_id")
|
|
events.append(
|
|
{
|
|
"kind": "review",
|
|
"entity_id": str(row["id"]),
|
|
"title": row["title"],
|
|
"status": row["status"],
|
|
"summary": row["summary"] or row["status"],
|
|
"occurred_at": _iso(row["updated_at"] or row["created_at"]),
|
|
"roadmap_item_id": str(rid) if rid else None,
|
|
}
|
|
)
|
|
|
|
cur.execute(
|
|
"""
|
|
SELECT id, title, status, description, created_at, updated_at,
|
|
roadmap_item_id, action_id
|
|
FROM evidence
|
|
WHERE tenant_id = %s AND initiative_id = %s
|
|
""",
|
|
(tenant_id, initiative_id),
|
|
)
|
|
for row in cur.fetchall():
|
|
events.append(
|
|
{
|
|
"kind": "evidence",
|
|
"entity_id": str(row["id"]),
|
|
"title": row["title"],
|
|
"status": row["status"],
|
|
"summary": row["description"] or row["status"],
|
|
"occurred_at": _iso(row["updated_at"] or row["created_at"]),
|
|
"roadmap_item_id": (
|
|
str(row["roadmap_item_id"]) if row.get("roadmap_item_id") else None
|
|
),
|
|
"action_id": str(row["action_id"]) if row.get("action_id") else None,
|
|
}
|
|
)
|
|
|
|
cur.execute(
|
|
"""
|
|
SELECT id, title, status, created_at, updated_at, roadmap_item_id, project_id
|
|
FROM actions
|
|
WHERE tenant_id = %s AND initiative_id = %s
|
|
""",
|
|
(tenant_id, initiative_id),
|
|
)
|
|
for row in cur.fetchall():
|
|
events.append(
|
|
{
|
|
"kind": "action",
|
|
"entity_id": str(row["id"]),
|
|
"title": row["title"],
|
|
"status": row["status"],
|
|
"summary": f"Arbeitspaket — {row['status']}",
|
|
"occurred_at": _iso(row["created_at"]),
|
|
"roadmap_item_id": (
|
|
str(row["roadmap_item_id"]) if row.get("roadmap_item_id") else None
|
|
),
|
|
"project_id": str(row["project_id"]) if row.get("project_id") else None,
|
|
}
|
|
)
|
|
if row["status"] in ("done", "discarded") and row["updated_at"] != row["created_at"]:
|
|
events.append(
|
|
{
|
|
"kind": "action",
|
|
"entity_id": str(row["id"]),
|
|
"title": row["title"],
|
|
"status": row["status"],
|
|
"summary": f"Arbeitspaket abgeschlossen — {row['status']}",
|
|
"occurred_at": _iso(row["updated_at"]),
|
|
"roadmap_item_id": (
|
|
str(row["roadmap_item_id"])
|
|
if row.get("roadmap_item_id")
|
|
else None
|
|
),
|
|
"project_id": (
|
|
str(row["project_id"]) if row.get("project_id") else None
|
|
),
|
|
}
|
|
)
|
|
|
|
cur.execute(
|
|
"""
|
|
SELECT id, title, status, created_at, updated_at, roadmap_item_id, converted_action_id
|
|
FROM backlog_items
|
|
WHERE tenant_id = %s AND initiative_id = %s
|
|
""",
|
|
(tenant_id, initiative_id),
|
|
)
|
|
for row in cur.fetchall():
|
|
if row["status"] == "converted":
|
|
events.append(
|
|
{
|
|
"kind": "backlog",
|
|
"entity_id": str(row["id"]),
|
|
"title": row["title"],
|
|
"status": row["status"],
|
|
"summary": "Backlog → Arbeitspaket committet",
|
|
"occurred_at": _iso(row["updated_at"]),
|
|
"roadmap_item_id": (
|
|
str(row["roadmap_item_id"])
|
|
if row.get("roadmap_item_id")
|
|
else None
|
|
),
|
|
"action_id": (
|
|
str(row["converted_action_id"])
|
|
if row.get("converted_action_id")
|
|
else None
|
|
),
|
|
}
|
|
)
|
|
|
|
cur.execute(
|
|
"""
|
|
SELECT ri.id, ri.title, ri.status, ri.updated_at, ri.created_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
|
|
AND ri.status IN ('reached', 'moved', 'discarded', 'at_risk')
|
|
""",
|
|
(tenant_id, initiative_id),
|
|
)
|
|
for row in cur.fetchall():
|
|
events.append(
|
|
{
|
|
"kind": "gate",
|
|
"entity_id": str(row["id"]),
|
|
"title": row["title"],
|
|
"status": row["status"],
|
|
"summary": f"Zielzustand — {row['status']}",
|
|
"occurred_at": _iso(row["updated_at"] or row["created_at"]),
|
|
"roadmap_item_id": str(row["id"]),
|
|
}
|
|
)
|
|
finally:
|
|
conn.close()
|
|
|
|
events = [e for e in events if e.get("occurred_at")]
|
|
events.sort(key=lambda e: e["occurred_at"], reverse=True)
|
|
return events
|