All checks were successful
Deploy Development / deploy (push) Successful in 47s
Test Suite / pytest-backend (push) Successful in 2m58s
Test Suite / lint-backend (push) Successful in 4s
Test Suite / compose-smoke (push) Has been skipped
Test Suite / k6 /api/health Baseline (push) Successful in 19s
Test Suite / playwright-smoke (push) Successful in 1m1s
Offene APs können beim Abschluss in den Eingang oder einen anderen Sprint überführt werden; abgeschlossene Sprints sind reaktivierbar. Co-authored-by: Cursor <cursoragent@cursor.com>
337 lines
11 KiB
Python
337 lines
11 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})
|
|
open_count = count_actions_in_work_cycle(
|
|
tenant_id=tenant_id,
|
|
initiative_id=initiative_id,
|
|
work_cycle_id=work_cycle_id,
|
|
)
|
|
result["open_action_count_at_completion"] = open_count
|
|
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:
|
|
return len(
|
|
list_open_actions_in_work_cycle(
|
|
tenant_id=tenant_id,
|
|
initiative_id=initiative_id,
|
|
work_cycle_id=work_cycle_id,
|
|
)
|
|
)
|
|
|
|
|
|
def list_open_actions_in_work_cycle(
|
|
*, tenant_id: str, initiative_id: str, work_cycle_id: str
|
|
) -> list[dict[str, Any]]:
|
|
conn = get_connection()
|
|
try:
|
|
with conn.cursor(cursor_factory=RealDictCursor) as cur:
|
|
cur.execute(
|
|
"""
|
|
SELECT id, title, status, priority, action_kind, work_cycle_id
|
|
FROM actions
|
|
WHERE tenant_id = %s AND initiative_id = %s AND work_cycle_id = %s
|
|
AND status NOT IN ('done', 'discarded')
|
|
ORDER BY sort_order ASC, title ASC
|
|
""",
|
|
(tenant_id, initiative_id, work_cycle_id),
|
|
)
|
|
rows = [dict(row) for row in cur.fetchall()]
|
|
finally:
|
|
conn.close()
|
|
for row in rows:
|
|
for key in ("id", "work_cycle_id"):
|
|
if row.get(key):
|
|
row[key] = str(row[key])
|
|
return rows
|
|
|
|
|
|
def reactivate_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(
|
|
"""
|
|
SELECT status FROM roadmap_items
|
|
WHERE id = %s AND tenant_id = %s AND item_type = %s
|
|
""",
|
|
(work_cycle_id, tenant_id, WORK_CYCLE_TYPE),
|
|
)
|
|
row = cur.fetchone()
|
|
if not row:
|
|
raise ValueError("Sprint nicht gefunden")
|
|
if row["status"] != "reached":
|
|
raise ValueError("Nur abgeschlossene Sprints können reaktiviert werden")
|
|
|
|
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
|
|
AND item_type = %s AND status = 'reached'
|
|
RETURNING id, title, goal_description, status, target_date, sort_order, item_type
|
|
""",
|
|
(work_cycle_id, tenant_id, WORK_CYCLE_TYPE),
|
|
)
|
|
updated = cur.fetchone()
|
|
if not updated:
|
|
raise ValueError("Sprint konnte nicht reaktiviert werden")
|
|
conn.commit()
|
|
finally:
|
|
conn.close()
|
|
|
|
result = _serialize_cycle({**dict(updated), "initiative_id": initiative_id})
|
|
log_audit(
|
|
"work_cycle.reactivated",
|
|
user_id=user_id,
|
|
tenant_id=tenant_id,
|
|
details={"work_cycle_id": work_cycle_id, "initiative_id": initiative_id},
|
|
)
|
|
return result
|