"""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()