Some checks failed
Deploy Development / deploy (push) Failing after 48s
Test Suite / pytest-backend (push) Failing after 1s
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 3s
Test Suite / compose-smoke (push) Has been skipped
RoadmapItem type work_cycle, Sprint-API, agile_iteration Next-Action-Strategie; Steering-Snapshot zeigt active_work_cycle. Co-authored-by: Cursor <cursoragent@cursor.com>
196 lines
5.9 KiB
Python
196 lines
5.9 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 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()
|