Kairo-Jinkendo/backend/services/roadmap_criteria.py
Lars bd64340ca1
All checks were successful
Deploy Development / deploy (push) Successful in 47s
Test Suite / pytest-backend (push) Successful in 2m13s
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
AP1.4c: Gate-Checkliste statt Freitext-DoD.
Kriterien-Fortschritt in der Gate-Liste, Modal-UX fuer Checklisten und optionales Erst-Kriterium beim Anlegen.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-11 07:53:38 +02:00

693 lines
22 KiB
Python

"""RoadmapItem criteria / gate checklist service (AP1.4b)."""
from __future__ import annotations
from datetime import datetime, timezone
from typing import Any, Literal, Optional
from psycopg2.extras import RealDictCursor
from db import get_connection
from services.audit import log_audit
from services.decisions import create_decision
CriterionKind = Literal["manual", "evidence_required", "review_required", "metric"]
CriterionStatus = Literal["open", "satisfied", "waived", "deferred", "failed"]
CRITERION_KINDS = frozenset({"manual", "evidence_required", "review_required", "metric"})
CRITERION_STATUSES = frozenset({"open", "satisfied", "waived", "deferred", "failed"})
CLOSED_CRITERION_STATUSES = frozenset({"satisfied", "waived", "deferred"})
_TERMINAL_ITEM_STATUSES = frozenset({"reached", "moved", "discarded"})
_CRITERION_COLUMNS = """
id, tenant_id, roadmap_item_id, sort_order, title, description,
criterion_kind, verification_hint, status,
waived_or_deferred_decision_id, satisfied_by_evidence_id, satisfied_at,
created_at, updated_at
"""
def _serialize_row(row: dict[str, Any]) -> dict[str, Any]:
result = dict(row)
for key in (
"id",
"tenant_id",
"roadmap_item_id",
"waived_or_deferred_decision_id",
"satisfied_by_evidence_id",
):
if result.get(key):
result[key] = str(result[key])
for ts in ("satisfied_at", "created_at", "updated_at"):
if result.get(ts):
result[ts] = result[ts].isoformat()
return result
def _validate_kind(kind: str) -> None:
if kind not in CRITERION_KINDS:
raise ValueError(f"Ungültiger criterion_kind: {kind}")
def _validate_status(status: str) -> None:
if status not in CRITERION_STATUSES:
raise ValueError(f"Ungültiger Kriterien-Status: {status}")
def _get_item_or_raise(*, tenant_id: str, item_id: str) -> dict[str, Any]:
from services.roadmap import get_roadmap_item
item = get_roadmap_item(tenant_id=tenant_id, item_id=item_id)
if not item:
raise ValueError("RoadmapItem nicht gefunden")
return item
def _reopen_item_if_reached(
cur,
*,
tenant_id: str,
item: dict[str, Any],
user_id: Optional[str],
audit_reason: str,
) -> None:
if item["status"] != "reached":
return
cur.execute(
"""
UPDATE roadmap_items
SET status = 'active', updated_at = NOW()
WHERE id = %s AND tenant_id = %s
""",
(item["id"], tenant_id),
)
log_audit(
"roadmap_item.reopened",
user_id=user_id,
tenant_id=tenant_id,
details={
"roadmap_item_id": item["id"],
"reason": audit_reason,
"from_status": "reached",
"to_status": "active",
},
)
def create_default_criterion(
cur,
*,
tenant_id: str,
roadmap_item_id: str,
title: str = "Gate allgemein",
description: str = "",
sort_order: int = 0,
) -> dict[str, Any]:
cur.execute(
f"""
INSERT INTO roadmap_item_criteria (
tenant_id, roadmap_item_id, sort_order, title, description, criterion_kind
)
VALUES (%s, %s, %s, %s, %s, 'evidence_required')
RETURNING {_CRITERION_COLUMNS}
""",
(tenant_id, roadmap_item_id, sort_order, title, description or title),
)
return _serialize_row(dict(cur.fetchone()))
def list_criteria_for_item(*, tenant_id: str, item_id: str) -> list[dict[str, Any]]:
_get_item_or_raise(tenant_id=tenant_id, item_id=item_id)
conn = get_connection()
try:
with conn.cursor(cursor_factory=RealDictCursor) as cur:
cur.execute(
f"""
SELECT {_CRITERION_COLUMNS}
FROM roadmap_item_criteria
WHERE tenant_id = %s AND roadmap_item_id = %s
ORDER BY sort_order ASC, created_at ASC
""",
(tenant_id, item_id),
)
return [_serialize_row(dict(r)) for r in cur.fetchall()]
finally:
conn.close()
def get_criterion(*, tenant_id: str, criterion_id: str) -> Optional[dict[str, Any]]:
conn = get_connection()
try:
with conn.cursor(cursor_factory=RealDictCursor) as cur:
cur.execute(
f"""
SELECT {_CRITERION_COLUMNS}
FROM roadmap_item_criteria
WHERE id = %s AND tenant_id = %s
""",
(criterion_id, tenant_id),
)
row = cur.fetchone()
return _serialize_row(dict(row)) if row else None
finally:
conn.close()
def create_criterion(
*,
tenant_id: str,
item_id: str,
title: str,
description: str = "",
criterion_kind: CriterionKind = "manual",
verification_hint: str = "",
sort_order: int = 0,
user_id: Optional[str] = None,
) -> dict[str, Any]:
title = title.strip()
if not title:
raise ValueError("Titel ist erforderlich")
_validate_kind(criterion_kind)
item = _get_item_or_raise(tenant_id=tenant_id, item_id=item_id)
conn = get_connection()
try:
with conn.cursor(cursor_factory=RealDictCursor) as cur:
cur.execute(
f"""
INSERT INTO roadmap_item_criteria (
tenant_id, roadmap_item_id, sort_order, title, description,
criterion_kind, verification_hint
)
VALUES (%s, %s, %s, %s, %s, %s, %s)
RETURNING {_CRITERION_COLUMNS}
""",
(
tenant_id,
item_id,
sort_order,
title,
description,
criterion_kind,
verification_hint,
),
)
row = _serialize_row(dict(cur.fetchone()))
if item["status"] == "reached":
_reopen_item_if_reached(
cur,
tenant_id=tenant_id,
item=item,
user_id=user_id,
audit_reason="criterion_created_on_reached_gate",
)
conn.commit()
finally:
conn.close()
log_audit(
"roadmap_item_criterion.created",
user_id=user_id,
tenant_id=tenant_id,
details={"criterion_id": row["id"], "roadmap_item_id": item_id, "title": title},
)
return row
def update_criterion(
*,
tenant_id: str,
criterion_id: str,
user_id: Optional[str] = None,
title: Optional[str] = None,
description: Optional[str] = None,
criterion_kind: Optional[CriterionKind] = None,
verification_hint: Optional[str] = None,
sort_order: Optional[int] = None,
change_reason: str = "",
) -> Optional[dict[str, Any]]:
existing = get_criterion(tenant_id=tenant_id, criterion_id=criterion_id)
if not existing:
return None
item = _get_item_or_raise(tenant_id=tenant_id, item_id=existing["roadmap_item_id"])
updates: list[str] = []
params: list[Any] = []
if title is not None:
title = title.strip()
if not title:
raise ValueError("Titel ist erforderlich")
updates.append("title = %s")
params.append(title)
if description is not None:
updates.append("description = %s")
params.append(description)
if criterion_kind is not None:
_validate_kind(criterion_kind)
updates.append("criterion_kind = %s")
params.append(criterion_kind)
if verification_hint is not None:
updates.append("verification_hint = %s")
params.append(verification_hint)
if sort_order is not None:
updates.append("sort_order = %s")
params.append(sort_order)
if not updates:
return existing
if item["status"] == "reached" and not change_reason.strip():
raise ValueError(
"Änderung an Kriterien bei erreichtem Gate erfordert Begründung (change_reason)"
)
updates.append("updated_at = NOW()")
params.extend([criterion_id, tenant_id])
conn = get_connection()
try:
with conn.cursor(cursor_factory=RealDictCursor) as cur:
cur.execute(
f"""
UPDATE roadmap_item_criteria
SET {", ".join(updates)}
WHERE id = %s AND tenant_id = %s
RETURNING {_CRITERION_COLUMNS}
""",
params,
)
row = cur.fetchone()
if not row:
return None
result = _serialize_row(dict(row))
if item["status"] == "reached":
_reopen_item_if_reached(
cur,
tenant_id=tenant_id,
item=item,
user_id=user_id,
audit_reason=change_reason.strip() or "criterion_updated_on_reached_gate",
)
conn.commit()
finally:
conn.close()
log_audit(
"roadmap_item_criterion.updated",
user_id=user_id,
tenant_id=tenant_id,
details={
"criterion_id": criterion_id,
"roadmap_item_id": existing["roadmap_item_id"],
"change_reason": change_reason.strip() or None,
},
)
return result
def delete_criterion(
*, tenant_id: str, criterion_id: str, user_id: Optional[str] = None
) -> bool:
existing = get_criterion(tenant_id=tenant_id, criterion_id=criterion_id)
if not existing:
return False
conn = get_connection()
try:
with conn.cursor() as cur:
cur.execute(
"""
SELECT COUNT(*) FROM roadmap_item_criteria
WHERE tenant_id = %s AND roadmap_item_id = %s
""",
(tenant_id, existing["roadmap_item_id"]),
)
if cur.fetchone()[0] <= 1:
raise ValueError("Letztes Kriterium eines Gates kann nicht gelöscht werden")
cur.execute(
"""
DELETE FROM roadmap_item_criteria
WHERE id = %s AND tenant_id = %s
RETURNING id
""",
(criterion_id, tenant_id),
)
deleted = cur.fetchone() is not None
conn.commit()
finally:
conn.close()
if deleted:
log_audit(
"roadmap_item_criterion.deleted",
user_id=user_id,
tenant_id=tenant_id,
details={
"criterion_id": criterion_id,
"roadmap_item_id": existing["roadmap_item_id"],
},
)
return deleted
def _accepted_evidence_for_item(cur, *, tenant_id: str, initiative_id: str, item_id: str):
cur.execute(
"""
SELECT id FROM evidence
WHERE tenant_id = %s AND initiative_id = %s AND status = 'accepted'
AND (roadmap_item_id = %s OR milestone_id = %s)
ORDER BY updated_at DESC
LIMIT 1
""",
(tenant_id, initiative_id, item_id, item_id),
)
row = cur.fetchone()
return str(row["id"]) if row else None
def _completed_review_for_item(cur, *, tenant_id: str, initiative_id: str, item_id: str) -> bool:
cur.execute(
"""
SELECT 1 FROM reviews
WHERE tenant_id = %s AND initiative_id = %s AND status = 'completed'
AND (roadmap_item_id = %s OR milestone_id = %s)
LIMIT 1
""",
(tenant_id, initiative_id, item_id, item_id),
)
return cur.fetchone() is not None
def satisfy_criterion(
*,
tenant_id: str,
criterion_id: str,
user_id: Optional[str] = None,
evidence_id: Optional[str] = None,
) -> dict[str, Any]:
existing = get_criterion(tenant_id=tenant_id, criterion_id=criterion_id)
if not existing:
raise ValueError("Kriterium nicht gefunden")
item = _get_item_or_raise(tenant_id=tenant_id, item_id=existing["roadmap_item_id"])
if item["status"] in _TERMINAL_ITEM_STATUSES and item["status"] != "reached":
raise ValueError(f"Gate ist terminal: {item['status']}")
conn = get_connection()
try:
with conn.cursor(cursor_factory=RealDictCursor) as cur:
linked_evidence = evidence_id
kind = existing["criterion_kind"]
if kind == "evidence_required":
if linked_evidence:
cur.execute(
"""
SELECT id FROM evidence
WHERE id = %s AND tenant_id = %s AND status = 'accepted'
AND initiative_id = %s
""",
(linked_evidence, tenant_id, item["initiative_id"]),
)
if not cur.fetchone():
raise ValueError("Evidence nicht gefunden oder nicht akzeptiert")
else:
linked_evidence = _accepted_evidence_for_item(
cur,
tenant_id=tenant_id,
initiative_id=item["initiative_id"],
item_id=item["id"],
)
if not linked_evidence:
raise ValueError(
"Evidence-Kriterium: akzeptierter Nachweis am Plan-Element erforderlich"
)
elif kind == "review_required":
if not _completed_review_for_item(
cur,
tenant_id=tenant_id,
initiative_id=item["initiative_id"],
item_id=item["id"],
):
raise ValueError(
"Review-Kriterium: abgeschlossenes Review am Plan-Element erforderlich"
)
cur.execute(
f"""
UPDATE roadmap_item_criteria
SET status = 'satisfied',
satisfied_by_evidence_id = %s,
satisfied_at = NOW(),
waived_or_deferred_decision_id = NULL,
updated_at = NOW()
WHERE id = %s AND tenant_id = %s
RETURNING {_CRITERION_COLUMNS}
""",
(linked_evidence, criterion_id, tenant_id),
)
result = _serialize_row(dict(cur.fetchone()))
conn.commit()
finally:
conn.close()
log_audit(
"roadmap_item_criterion.satisfied",
user_id=user_id,
tenant_id=tenant_id,
details={
"criterion_id": criterion_id,
"roadmap_item_id": existing["roadmap_item_id"],
"evidence_id": linked_evidence,
},
)
return result
def _waive_or_defer(
*,
tenant_id: str,
criterion_id: str,
action: Literal["waived", "deferred"],
user_id: Optional[str],
decision_title: str,
decision_description: str,
) -> dict[str, Any]:
existing = get_criterion(tenant_id=tenant_id, criterion_id=criterion_id)
if not existing:
raise ValueError("Kriterium nicht gefunden")
item = _get_item_or_raise(tenant_id=tenant_id, item_id=existing["roadmap_item_id"])
if item["status"] in _TERMINAL_ITEM_STATUSES and item["status"] != "reached":
raise ValueError(f"Gate ist terminal: {item['status']}")
decision_title = decision_title.strip()
if not decision_title:
raise ValueError("Decision-Titel (Begründung) ist erforderlich")
prefix = f"criterion_{action}:"
decision = create_decision(
tenant_id=tenant_id,
initiative_id=item["initiative_id"],
title=f"{prefix}{criterion_id}{decision_title}",
description=decision_description,
status="decided",
user_id=user_id,
)
conn = get_connection()
try:
with conn.cursor(cursor_factory=RealDictCursor) as cur:
cur.execute(
f"""
UPDATE roadmap_item_criteria
SET status = %s,
waived_or_deferred_decision_id = %s,
satisfied_by_evidence_id = NULL,
satisfied_at = NULL,
updated_at = NOW()
WHERE id = %s AND tenant_id = %s
RETURNING {_CRITERION_COLUMNS}
""",
(action, decision["id"], criterion_id, tenant_id),
)
result = _serialize_row(dict(cur.fetchone()))
conn.commit()
finally:
conn.close()
log_audit(
f"roadmap_item_criterion.{action}",
user_id=user_id,
tenant_id=tenant_id,
details={
"criterion_id": criterion_id,
"roadmap_item_id": existing["roadmap_item_id"],
"decision_id": decision["id"],
},
)
return result
def waive_criterion(
*,
tenant_id: str,
criterion_id: str,
user_id: Optional[str] = None,
decision_title: str,
decision_description: str = "",
) -> dict[str, Any]:
return _waive_or_defer(
tenant_id=tenant_id,
criterion_id=criterion_id,
action="waived",
user_id=user_id,
decision_title=decision_title,
decision_description=decision_description,
)
def defer_criterion(
*,
tenant_id: str,
criterion_id: str,
user_id: Optional[str] = None,
decision_title: str,
decision_description: str = "",
) -> dict[str, Any]:
return _waive_or_defer(
tenant_id=tenant_id,
criterion_id=criterion_id,
action="deferred",
user_id=user_id,
decision_title=decision_title,
decision_description=decision_description,
)
def criteria_progress_for_initiative(
*, tenant_id: str, initiative_id: str
) -> dict[str, dict[str, int]]:
from services.initiatives import get_initiative
if not get_initiative(tenant_id=tenant_id, initiative_id=initiative_id):
raise ValueError("Initiative nicht gefunden")
conn = get_connection()
try:
with conn.cursor() as cur:
cur.execute(
"""
SELECT
ri.id::text,
COUNT(c.id) AS total,
COUNT(c.id) FILTER (
WHERE c.status IN ('satisfied', 'waived', 'deferred')
) AS closed
FROM roadmap_items ri
JOIN roadmaps r ON r.id = ri.roadmap_id AND r.tenant_id = ri.tenant_id
LEFT JOIN roadmap_item_criteria c
ON c.roadmap_item_id = ri.id AND c.tenant_id = ri.tenant_id
WHERE ri.tenant_id = %s AND r.initiative_id = %s
GROUP BY ri.id
""",
(tenant_id, initiative_id),
)
return {
row[0]: {
"total": row[1],
"closed": row[2],
"open": row[1] - row[2],
}
for row in cur.fetchall()
}
finally:
conn.close()
def criteria_progress(*, tenant_id: str, item_id: str) -> dict[str, int]:
conn = get_connection()
try:
with conn.cursor() as cur:
cur.execute(
"""
SELECT
COUNT(*) AS total,
COUNT(*) FILTER (
WHERE status IN ('satisfied', 'waived', 'deferred')
) AS closed
FROM roadmap_item_criteria
WHERE tenant_id = %s AND roadmap_item_id = %s
""",
(tenant_id, item_id),
)
total, closed = cur.fetchone()
return {"total": total, "closed": closed, "open": total - closed}
finally:
conn.close()
def prepare_criteria_for_verify(
cur,
*,
tenant_id: str,
initiative_id: str,
item_id: str,
) -> tuple[bool, str]:
"""Auto-satisfy open evidence/review criteria wenn Nachweis da; prüft Gesamtstatus."""
cur.execute(
f"""
SELECT {_CRITERION_COLUMNS}
FROM roadmap_item_criteria
WHERE tenant_id = %s AND roadmap_item_id = %s
ORDER BY sort_order ASC, created_at ASC
""",
(tenant_id, item_id),
)
criteria = [dict(r) for r in cur.fetchall()]
if not criteria:
return True, "no_criteria"
evidence_id = _accepted_evidence_for_item(
cur, tenant_id=tenant_id, initiative_id=initiative_id, item_id=item_id
)
has_review = _completed_review_for_item(
cur, tenant_id=tenant_id, initiative_id=initiative_id, item_id=item_id
)
for crit in criteria:
status = crit["status"]
if status == "failed":
return False, f"Kriterium fehlgeschlagen: {crit['title']}"
if status in CLOSED_CRITERION_STATUSES:
if status in ("waived", "deferred") and not crit["waived_or_deferred_decision_id"]:
return False, f"Kriterium ohne Decision: {crit['title']}"
continue
if status == "open":
kind = crit["criterion_kind"]
if kind == "evidence_required" and evidence_id:
cur.execute(
"""
UPDATE roadmap_item_criteria
SET status = 'satisfied',
satisfied_by_evidence_id = %s,
satisfied_at = NOW(),
updated_at = NOW()
WHERE id = %s AND tenant_id = %s
""",
(evidence_id, crit["id"], tenant_id),
)
continue
if kind == "review_required" and has_review:
cur.execute(
"""
UPDATE roadmap_item_criteria
SET status = 'satisfied', satisfied_at = NOW(), updated_at = NOW()
WHERE id = %s AND tenant_id = %s
""",
(crit["id"], tenant_id),
)
continue
if kind == "manual":
return False, f"Manuelles Kriterium noch offen: {crit['title']}"
return False, f"Kriterium noch offen: {crit['title']}"
return True, "criteria_ready"