Kairo-Jinkendo/backend/services/work_cycle.py
Lars f6aba94864
Some checks failed
Deploy Development / deploy (push) Successful in 48s
Test Suite / pytest-backend (push) Successful in 2m56s
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) Has been cancelled
AP2.2d: Sprint-Planung, Abschluss und Product-Fallback.
Backlog committet in gewaehlten Sprint, Sprint complete-Endpoint, continuous_product-Ansicht ohne aktiven Sprint.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-12 19:21:49 +02:00

242 lines
7.4 KiB
Python

"""Work cycle (Sprint/Zeitbox) — AP2.0f on RoadmapItem(type=work_cycle)."""
from __future__ import annotations
from datetime import date
from typing import Any, Optional
from psycopg2.extras import RealDictCursor
from db import get_connection
from services.audit import log_audit
from services.initiatives import get_initiative
from services.roadmap import create_roadmap_item, list_roadmap_items_for_initiative
WORK_CYCLE_TYPE = "work_cycle"
OPEN_CYCLE_STATUSES = frozenset({"planned", "active", "at_risk"})
def _serialize_cycle(item: dict[str, Any]) -> dict[str, Any]:
return {
"id": item["id"],
"initiative_id": item.get("initiative_id"),
"title": item["title"],
"goal_description": item.get("goal_description") or "",
"status": item["status"],
"target_date": item.get("target_date"),
"sort_order": item.get("sort_order", 0),
"item_type": item.get("item_type", WORK_CYCLE_TYPE),
}
def list_work_cycles_for_initiative(
*, tenant_id: str, initiative_id: str
) -> list[dict[str, Any]]:
items = list_roadmap_items_for_initiative(
tenant_id=tenant_id, initiative_id=initiative_id
)
return [
_serialize_cycle(item)
for item in items
if item.get("item_type") == WORK_CYCLE_TYPE
]
def get_active_work_cycle(
*, tenant_id: str, initiative_id: str
) -> Optional[dict[str, Any]]:
cycles = list_work_cycles_for_initiative(
tenant_id=tenant_id, initiative_id=initiative_id
)
active = [c for c in cycles if c["status"] == "active"]
if not active:
return None
active.sort(key=lambda c: (c.get("sort_order", 0), c["id"]))
return active[0]
def validate_work_cycle_in_initiative(
*,
tenant_id: str,
initiative_id: str,
work_cycle_id: Optional[str],
cur=None,
) -> None:
if not work_cycle_id:
return
sql = """
SELECT ri.item_type
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
"""
params = (work_cycle_id, tenant_id, initiative_id)
if cur is not None:
cur.execute(sql, params)
row = cur.fetchone()
else:
conn = get_connection()
try:
with conn.cursor(cursor_factory=RealDictCursor) as cursor:
cursor.execute(sql, params)
row = cursor.fetchone()
finally:
conn.close()
if not row:
raise ValueError("Zeitbox gehört nicht zu diesem Vorhaben")
item_type = row["item_type"] if isinstance(row, dict) else row[0]
if item_type != WORK_CYCLE_TYPE:
raise ValueError("work_cycle_id muss auf RoadmapItem(type=work_cycle) verweisen")
def create_work_cycle(
*,
tenant_id: str,
initiative_id: str,
title: str,
goal_description: str = "",
status: str = "planned",
target_date: Optional[date] = None,
sort_order: int = 0,
user_id: Optional[str] = None,
) -> dict[str, Any]:
if status == "reached":
raise ValueError("Status 'reached' für work_cycle über complete-Endpoint")
item = create_roadmap_item(
tenant_id=tenant_id,
initiative_id=initiative_id,
title=title,
item_type="work_cycle",
goal_description=goal_description,
status=status, # type: ignore[arg-type]
target_date=target_date,
sort_order=sort_order,
initial_criterion_title=None,
user_id=user_id,
)
return _serialize_cycle(item)
def activate_work_cycle(
*,
tenant_id: str,
initiative_id: str,
work_cycle_id: str,
user_id: Optional[str] = None,
) -> dict[str, Any]:
validate_work_cycle_in_initiative(
tenant_id=tenant_id,
initiative_id=initiative_id,
work_cycle_id=work_cycle_id,
)
if not get_initiative(tenant_id=tenant_id, initiative_id=initiative_id):
raise ValueError("Initiative nicht gefunden")
conn = get_connection()
try:
with conn.cursor(cursor_factory=RealDictCursor) as cur:
cur.execute(
"""
UPDATE roadmap_items ri
SET status = 'planned', updated_at = NOW()
FROM roadmaps r
WHERE ri.roadmap_id = r.id
AND r.initiative_id = %s AND ri.tenant_id = %s
AND ri.item_type = %s AND ri.status = 'active'
AND ri.id <> %s
""",
(initiative_id, tenant_id, WORK_CYCLE_TYPE, work_cycle_id),
)
cur.execute(
"""
UPDATE roadmap_items
SET status = 'active', updated_at = NOW()
WHERE id = %s AND tenant_id = %s
RETURNING id, title, goal_description, status, target_date, sort_order, item_type
""",
(work_cycle_id, tenant_id),
)
row = cur.fetchone()
if not row:
raise ValueError("Zeitbox nicht gefunden")
conn.commit()
finally:
conn.close()
result = _serialize_cycle({**dict(row), "initiative_id": initiative_id})
log_audit(
"work_cycle.activated",
user_id=user_id,
tenant_id=tenant_id,
details={"work_cycle_id": work_cycle_id, "initiative_id": initiative_id},
)
return result
def complete_work_cycle(
*,
tenant_id: str,
initiative_id: str,
work_cycle_id: str,
user_id: Optional[str] = None,
) -> dict[str, Any]:
validate_work_cycle_in_initiative(
tenant_id=tenant_id,
initiative_id=initiative_id,
work_cycle_id=work_cycle_id,
)
if not get_initiative(tenant_id=tenant_id, initiative_id=initiative_id):
raise ValueError("Initiative nicht gefunden")
conn = get_connection()
try:
with conn.cursor(cursor_factory=RealDictCursor) as cur:
cur.execute(
"""
UPDATE roadmap_items
SET status = 'reached', updated_at = NOW()
WHERE id = %s AND tenant_id = %s
AND item_type = %s
AND status IN ('planned', 'active', 'at_risk')
RETURNING id, title, goal_description, status, target_date, sort_order, item_type
""",
(work_cycle_id, tenant_id, WORK_CYCLE_TYPE),
)
row = cur.fetchone()
if not row:
raise ValueError("Sprint nicht gefunden oder bereits abgeschlossen")
conn.commit()
finally:
conn.close()
result = _serialize_cycle({**dict(row), "initiative_id": initiative_id})
log_audit(
"work_cycle.completed",
user_id=user_id,
tenant_id=tenant_id,
details={"work_cycle_id": work_cycle_id, "initiative_id": initiative_id},
)
return result
def count_actions_in_work_cycle(
*, tenant_id: str, initiative_id: str, work_cycle_id: str
) -> int:
conn = get_connection()
try:
with conn.cursor() as cur:
cur.execute(
"""
SELECT COUNT(*) FROM actions
WHERE tenant_id = %s AND initiative_id = %s AND work_cycle_id = %s
AND status NOT IN ('done', 'discarded')
""",
(tenant_id, initiative_id, work_cycle_id),
)
return int(cur.fetchone()[0])
finally:
conn.close()