All checks were successful
Deploy Development / deploy (push) Successful in 45s
Test Suite / pytest-backend (push) Successful in 58s
Test Suite / lint-backend (push) Successful in 2s
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 12s
Schema 007, regelbasierte Attention/NextAction im Data Layer, CRUD fuer Blocker/Backlog/Meilensteine, Workspace-Widget und Initiative-Detail-Sektionen. 25 Capabilities. Tests fuer Remote-Pytest auf Pi angepasst (conftest Session-Guard). Co-authored-by: Cursor <cursoragent@cursor.com>
419 lines
14 KiB
Python
419 lines
14 KiB
Python
"""Attention and NextAction read-models — regelbasiert, tenant-scoped (AP0.8a)."""
|
|
|
|
from __future__ import annotations
|
|
|
|
from typing import Any, Literal, Optional
|
|
|
|
from psycopg2.extras import RealDictCursor
|
|
|
|
from db import get_connection
|
|
from tenant_context import TenantContext
|
|
|
|
AttentionKind = Literal[
|
|
"blocked_action",
|
|
"open_blocker",
|
|
"high_priority_action",
|
|
"unassigned_action",
|
|
"initiative_without_next_action",
|
|
"stale_initiative",
|
|
"milestone_at_risk",
|
|
]
|
|
|
|
NextActionKind = Literal[
|
|
"assign_action",
|
|
"resolve_blocker",
|
|
"create_action",
|
|
"convert_backlog",
|
|
"review_milestone",
|
|
]
|
|
|
|
Severity = Literal["info", "warning", "critical"]
|
|
|
|
_SEVERITY_ORDER = {"critical": 0, "warning": 1, "info": 2}
|
|
|
|
OPEN_BLOCKER_STATUSES = ("open", "in_progress")
|
|
OPEN_ACTION_STATUSES = ("open", "in_progress", "blocked")
|
|
ACTIVE_INITIATIVE_STATUSES = ("active", "paused")
|
|
|
|
|
|
def _serialize_attention(row: dict[str, Any]) -> dict[str, Any]:
|
|
item = dict(row)
|
|
for key in ("scope_id", "initiative_id", "action_id", "blocker_id", "milestone_id"):
|
|
if item.get(key):
|
|
item[key] = str(item[key])
|
|
return item
|
|
|
|
|
|
def _blocked_actions(cur, ctx: TenantContext) -> list[dict[str, Any]]:
|
|
cur.execute(
|
|
"""
|
|
SELECT
|
|
'blocked_action' AS kind,
|
|
'critical' AS severity,
|
|
a.title AS title,
|
|
'Maßnahme ist blockiert' AS summary,
|
|
'action' AS scope_type,
|
|
a.id AS scope_id,
|
|
a.initiative_id,
|
|
a.id AS action_id,
|
|
NULL::uuid AS blocker_id,
|
|
NULL::uuid AS milestone_id,
|
|
'action_blocked' AS reason_code,
|
|
'actions' AS data_source
|
|
FROM actions a
|
|
WHERE a.tenant_id = %s AND a.status = 'blocked'
|
|
ORDER BY a.updated_at DESC
|
|
LIMIT 50
|
|
""",
|
|
(ctx.tenant_id,),
|
|
)
|
|
return [_serialize_attention(dict(r)) for r in cur.fetchall()]
|
|
|
|
|
|
def _open_blockers(cur, ctx: TenantContext) -> list[dict[str, Any]]:
|
|
cur.execute(
|
|
"""
|
|
SELECT
|
|
'open_blocker' AS kind,
|
|
'warning' AS severity,
|
|
b.title AS title,
|
|
'Offener Blocker im Vorhaben' AS summary,
|
|
'blocker' AS scope_type,
|
|
b.id AS scope_id,
|
|
b.initiative_id,
|
|
b.action_id,
|
|
b.id AS blocker_id,
|
|
NULL::uuid AS milestone_id,
|
|
'blocker_open' AS reason_code,
|
|
'blockers' AS data_source
|
|
FROM blockers b
|
|
WHERE b.tenant_id = %s AND b.status IN ('open', 'in_progress')
|
|
ORDER BY b.updated_at DESC
|
|
LIMIT 50
|
|
""",
|
|
(ctx.tenant_id,),
|
|
)
|
|
items = []
|
|
for row in cur.fetchall():
|
|
item = _serialize_attention(dict(row))
|
|
if item.get("action_id"):
|
|
item["action_id"] = str(item["action_id"])
|
|
items.append(item)
|
|
return items
|
|
|
|
|
|
def _high_priority_actions(cur, ctx: TenantContext) -> list[dict[str, Any]]:
|
|
actor_filter = ""
|
|
params: list[Any] = [ctx.tenant_id]
|
|
if ctx.actor_id:
|
|
actor_filter = """
|
|
AND EXISTS (
|
|
SELECT 1 FROM action_assignments aa
|
|
WHERE aa.action_id = a.id
|
|
AND aa.tenant_id = a.tenant_id
|
|
AND aa.actor_id = %s
|
|
)
|
|
"""
|
|
params.append(ctx.actor_id)
|
|
else:
|
|
return []
|
|
|
|
cur.execute(
|
|
f"""
|
|
SELECT
|
|
'high_priority_action' AS kind,
|
|
'warning' AS severity,
|
|
a.title AS title,
|
|
'High-Priority Maßnahme offen' AS summary,
|
|
'action' AS scope_type,
|
|
a.id AS scope_id,
|
|
a.initiative_id,
|
|
a.id AS action_id,
|
|
NULL::uuid AS blocker_id,
|
|
NULL::uuid AS milestone_id,
|
|
'action_high_priority_open' AS reason_code,
|
|
'actions' AS data_source
|
|
FROM actions a
|
|
WHERE a.tenant_id = %s
|
|
AND a.priority = 'high'
|
|
AND a.status IN ('open', 'in_progress')
|
|
{actor_filter}
|
|
ORDER BY a.updated_at DESC
|
|
LIMIT 30
|
|
""",
|
|
tuple(params),
|
|
)
|
|
return [_serialize_attention(dict(r)) for r in cur.fetchall()]
|
|
|
|
|
|
def _unassigned_actions(cur, ctx: TenantContext) -> list[dict[str, Any]]:
|
|
cur.execute(
|
|
"""
|
|
SELECT
|
|
'unassigned_action' AS kind,
|
|
'warning' AS severity,
|
|
a.title AS title,
|
|
'Maßnahme ohne Zuweisung' AS summary,
|
|
'action' AS scope_type,
|
|
a.id AS scope_id,
|
|
a.initiative_id,
|
|
a.id AS action_id,
|
|
NULL::uuid AS blocker_id,
|
|
NULL::uuid AS milestone_id,
|
|
'action_unassigned' AS reason_code,
|
|
'actions' AS data_source
|
|
FROM actions a
|
|
WHERE a.tenant_id = %s
|
|
AND a.status IN ('open', 'in_progress')
|
|
AND NOT EXISTS (
|
|
SELECT 1 FROM action_assignments aa
|
|
WHERE aa.action_id = a.id AND aa.tenant_id = a.tenant_id
|
|
)
|
|
ORDER BY a.updated_at DESC
|
|
LIMIT 30
|
|
""",
|
|
(ctx.tenant_id,),
|
|
)
|
|
return [_serialize_attention(dict(r)) for r in cur.fetchall()]
|
|
|
|
|
|
def _initiatives_without_next_action(cur, ctx: TenantContext) -> list[dict[str, Any]]:
|
|
cur.execute(
|
|
"""
|
|
SELECT
|
|
'initiative_without_next_action' AS kind,
|
|
'info' AS severity,
|
|
i.title AS title,
|
|
'Keine offene nächste Maßnahme' AS summary,
|
|
'initiative' AS scope_type,
|
|
i.id AS scope_id,
|
|
i.id AS initiative_id,
|
|
NULL::uuid AS action_id,
|
|
NULL::uuid AS blocker_id,
|
|
NULL::uuid AS milestone_id,
|
|
'initiative_no_open_action' AS reason_code,
|
|
'initiatives' AS data_source
|
|
FROM initiatives i
|
|
WHERE i.tenant_id = %s
|
|
AND i.status IN ('active', 'paused')
|
|
AND NOT EXISTS (
|
|
SELECT 1 FROM actions a
|
|
WHERE a.initiative_id = i.id
|
|
AND a.tenant_id = i.tenant_id
|
|
AND a.status IN ('open', 'in_progress', 'blocked')
|
|
)
|
|
ORDER BY i.updated_at DESC
|
|
LIMIT 30
|
|
""",
|
|
(ctx.tenant_id,),
|
|
)
|
|
return [_serialize_attention(dict(r)) for r in cur.fetchall()]
|
|
|
|
|
|
def _stale_initiatives(cur, ctx: TenantContext) -> list[dict[str, Any]]:
|
|
cur.execute(
|
|
"""
|
|
SELECT
|
|
'stale_initiative' AS kind,
|
|
'info' AS severity,
|
|
i.title AS title,
|
|
'Vorhaben seit über 14 Tagen unverändert' AS summary,
|
|
'initiative' AS scope_type,
|
|
i.id AS scope_id,
|
|
i.id AS initiative_id,
|
|
NULL::uuid AS action_id,
|
|
NULL::uuid AS blocker_id,
|
|
NULL::uuid AS milestone_id,
|
|
'initiative_stale' AS reason_code,
|
|
'initiatives' AS data_source
|
|
FROM initiatives i
|
|
WHERE i.tenant_id = %s
|
|
AND i.status = 'active'
|
|
AND i.updated_at < NOW() - INTERVAL '14 days'
|
|
ORDER BY i.updated_at ASC
|
|
LIMIT 20
|
|
""",
|
|
(ctx.tenant_id,),
|
|
)
|
|
return [_serialize_attention(dict(r)) for r in cur.fetchall()]
|
|
|
|
|
|
def _milestones_at_risk(cur, ctx: TenantContext) -> list[dict[str, Any]]:
|
|
cur.execute(
|
|
"""
|
|
SELECT
|
|
'milestone_at_risk' AS kind,
|
|
'warning' AS severity,
|
|
m.title AS title,
|
|
'Meilenstein als gefährdet markiert' AS summary,
|
|
'milestone' AS scope_type,
|
|
m.id AS scope_id,
|
|
m.initiative_id,
|
|
NULL::uuid AS action_id,
|
|
NULL::uuid AS blocker_id,
|
|
m.id AS milestone_id,
|
|
'milestone_at_risk' AS reason_code,
|
|
'milestones' AS data_source
|
|
FROM milestones m
|
|
WHERE m.tenant_id = %s AND m.status = 'at_risk'
|
|
ORDER BY m.updated_at DESC
|
|
LIMIT 20
|
|
""",
|
|
(ctx.tenant_id,),
|
|
)
|
|
return [_serialize_attention(dict(r)) for r in cur.fetchall()]
|
|
|
|
|
|
def get_attention_items(ctx: TenantContext) -> list[dict[str, Any]]:
|
|
"""Regelbasierte Attention Items — tenant-scoped, erklärbar."""
|
|
conn = get_connection()
|
|
try:
|
|
with conn.cursor(cursor_factory=RealDictCursor) as cur:
|
|
items: list[dict[str, Any]] = []
|
|
items.extend(_blocked_actions(cur, ctx))
|
|
items.extend(_open_blockers(cur, ctx))
|
|
items.extend(_high_priority_actions(cur, ctx))
|
|
items.extend(_unassigned_actions(cur, ctx))
|
|
items.extend(_initiatives_without_next_action(cur, ctx))
|
|
items.extend(_stale_initiatives(cur, ctx))
|
|
items.extend(_milestones_at_risk(cur, ctx))
|
|
|
|
items.sort(key=lambda x: _SEVERITY_ORDER.get(x["severity"], 99))
|
|
return items
|
|
finally:
|
|
conn.close()
|
|
|
|
|
|
def get_next_action_candidates(
|
|
ctx: TenantContext, *, limit: int = 10
|
|
) -> list[dict[str, Any]]:
|
|
"""Regelbasierte NextActionCandidates — limitiert, tenant-scoped."""
|
|
if limit < 1:
|
|
limit = 1
|
|
if limit > 50:
|
|
limit = 50
|
|
|
|
conn = get_connection()
|
|
candidates: list[dict[str, Any]] = []
|
|
try:
|
|
with conn.cursor(cursor_factory=RealDictCursor) as cur:
|
|
cur.execute(
|
|
"""
|
|
SELECT
|
|
'resolve_blocker' AS kind,
|
|
b.title AS title,
|
|
'Blocker klären oder Status aktualisieren' AS summary,
|
|
b.initiative_id,
|
|
b.action_id,
|
|
NULL::uuid AS backlog_item_id,
|
|
'blocker_open' AS reason_code,
|
|
'Blocker bearbeiten' AS recommended_action
|
|
FROM blockers b
|
|
WHERE b.tenant_id = %s AND b.status IN ('open', 'in_progress')
|
|
ORDER BY b.updated_at DESC
|
|
LIMIT %s
|
|
""",
|
|
(ctx.tenant_id, limit),
|
|
)
|
|
for row in cur.fetchall():
|
|
item = dict(row)
|
|
item["initiative_id"] = str(item["initiative_id"])
|
|
if item.get("action_id"):
|
|
item["action_id"] = str(item["action_id"])
|
|
candidates.append(item)
|
|
|
|
remaining = limit - len(candidates)
|
|
if remaining > 0:
|
|
cur.execute(
|
|
"""
|
|
SELECT
|
|
'assign_action' AS kind,
|
|
a.title AS title,
|
|
'Actor zuweisen' AS summary,
|
|
a.initiative_id,
|
|
a.id AS action_id,
|
|
NULL::uuid AS backlog_item_id,
|
|
'action_unassigned' AS reason_code,
|
|
'Maßnahme zuweisen' AS recommended_action
|
|
FROM actions a
|
|
WHERE a.tenant_id = %s
|
|
AND a.status IN ('open', 'in_progress')
|
|
AND NOT EXISTS (
|
|
SELECT 1 FROM action_assignments aa
|
|
WHERE aa.action_id = a.id AND aa.tenant_id = a.tenant_id
|
|
)
|
|
ORDER BY a.updated_at DESC
|
|
LIMIT %s
|
|
""",
|
|
(ctx.tenant_id, remaining),
|
|
)
|
|
for row in cur.fetchall():
|
|
item = dict(row)
|
|
item["initiative_id"] = str(item["initiative_id"])
|
|
item["action_id"] = str(item["action_id"])
|
|
candidates.append(item)
|
|
|
|
remaining = limit - len(candidates)
|
|
if remaining > 0:
|
|
cur.execute(
|
|
"""
|
|
SELECT
|
|
'convert_backlog' AS kind,
|
|
bi.title AS title,
|
|
'Freigegebenes Backlog-Item in Maßnahme umwandeln' AS summary,
|
|
bi.initiative_id,
|
|
NULL::uuid AS action_id,
|
|
bi.id AS backlog_item_id,
|
|
'backlog_accepted_not_converted' AS reason_code,
|
|
'In Maßnahme umwandeln' AS recommended_action
|
|
FROM backlog_items bi
|
|
WHERE bi.tenant_id = %s
|
|
AND bi.status = 'accepted'
|
|
AND bi.converted_action_id IS NULL
|
|
ORDER BY bi.updated_at DESC
|
|
LIMIT %s
|
|
""",
|
|
(ctx.tenant_id, remaining),
|
|
)
|
|
for row in cur.fetchall():
|
|
item = dict(row)
|
|
item["initiative_id"] = str(item["initiative_id"])
|
|
item["backlog_item_id"] = str(item["backlog_item_id"])
|
|
candidates.append(item)
|
|
|
|
remaining = limit - len(candidates)
|
|
if remaining > 0:
|
|
cur.execute(
|
|
"""
|
|
SELECT
|
|
'create_action' AS kind,
|
|
i.title AS title,
|
|
'Nächste Maßnahme für Vorhaben anlegen' AS summary,
|
|
i.id AS initiative_id,
|
|
NULL::uuid AS action_id,
|
|
NULL::uuid AS backlog_item_id,
|
|
'initiative_no_open_action' AS reason_code,
|
|
'Maßnahme anlegen' AS recommended_action
|
|
FROM initiatives i
|
|
WHERE i.tenant_id = %s
|
|
AND i.status IN ('active', 'paused')
|
|
AND NOT EXISTS (
|
|
SELECT 1 FROM actions a
|
|
WHERE a.initiative_id = i.id
|
|
AND a.tenant_id = i.tenant_id
|
|
AND a.status IN ('open', 'in_progress', 'blocked')
|
|
)
|
|
ORDER BY i.updated_at DESC
|
|
LIMIT %s
|
|
""",
|
|
(ctx.tenant_id, remaining),
|
|
)
|
|
for row in cur.fetchall():
|
|
item = dict(row)
|
|
item["initiative_id"] = str(item["initiative_id"])
|
|
candidates.append(item)
|
|
|
|
return candidates[:limit]
|
|
finally:
|
|
conn.close()
|