Some checks failed
Deploy Development / deploy (push) Successful in 49s
Test Suite / pytest-backend (push) Failing after 4m46s
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 2s
Test Suite / compose-smoke (push) Has been skipped
Activity Set pro Stufe statt Gate-Titel-Recurring; Single-Active-Stufe bei Reopen; Complete heute schliesst Next Action; Heute-Panel fuer alle Uebungen der aktiven Stufe. Co-authored-by: Cursor <cursoragent@cursor.com>
702 lines
22 KiB
Python
702 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
|
|
if item.get("item_type") == "maturity_stage":
|
|
from services.maturity_practice import enforce_single_active_maturity_stage
|
|
|
|
enforce_single_active_maturity_stage(
|
|
cur,
|
|
tenant_id=tenant_id,
|
|
initiative_id=str(item["initiative_id"]),
|
|
keep_active_id=item["id"],
|
|
)
|
|
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"
|