"""Roadmap API — AP1.4.""" from __future__ import annotations from datetime import date from typing import Any, Literal, Optional from capabilities import require_capability from fastapi import APIRouter, Depends, HTTPException from pydantic import BaseModel, Field from schemas.schedule_payload import SchedulePayload from services import roadmap as roadmap_service from services import roadmap_criteria as criteria_service from services import roadmap_plan_snapshot as snapshot_service from tenant_context import TenantContext router = APIRouter(tags=["roadmap"]) class RoadmapItemCreateRequest(BaseModel): title: str = Field(min_length=1, max_length=255) item_type: Literal["milestone", "review_gate", "maturity_stage", "work_cycle", "join_gate"] = "milestone" goal_description: str = "" definition_of_done: list[Any] = Field(default_factory=list) status: Literal[ "planned", "active", "at_risk", "reached", "moved", "discarded" ] = "planned" sequencing_mode: Literal["sequential", "parallel", "optional"] = "sequential" target_date: Optional[date] = None sort_order: int = 0 strand_project_id: Optional[str] = None initial_criterion_title: Optional[str] = Field(default=None, max_length=500) class RoadmapItemUpdateRequest(BaseModel): title: Optional[str] = Field(default=None, min_length=1, max_length=255) item_type: Optional[Literal["milestone", "review_gate", "maturity_stage", "work_cycle", "join_gate"]] = None goal_description: Optional[str] = None definition_of_done: Optional[list[Any]] = None status: Optional[ Literal["planned", "active", "at_risk", "reached", "moved", "discarded"] ] = None sequencing_mode: Optional[Literal["sequential", "parallel", "optional"]] = None target_date: Optional[date] = None clear_target_date: bool = False sort_order: Optional[int] = None strand_project_id: Optional[str] = None clear_strand_project: bool = False class DependencyCreateRequest(BaseModel): to_item_id: str dependency_type: Literal[ "requires", "blocks", "related", "parallel_group", "optional_branch" ] = "requires" group_key: Optional[str] = Field(default=None, max_length=128) class CriterionCreateRequest(BaseModel): title: str = Field(min_length=1, max_length=500) description: str = "" criterion_kind: Literal["manual", "evidence_required", "review_required", "metric"] = "manual" verification_hint: str = "" sort_order: int = 0 class CriterionUpdateRequest(BaseModel): title: Optional[str] = Field(default=None, min_length=1, max_length=500) description: Optional[str] = None criterion_kind: Optional[ Literal["manual", "evidence_required", "review_required", "metric"] ] = None verification_hint: Optional[str] = None sort_order: Optional[int] = None change_reason: str = "" class CriterionWaiveDeferRequest(BaseModel): decision_title: str = Field(min_length=1, max_length=255) decision_description: str = "" class CriterionSatisfyRequest(BaseModel): evidence_id: Optional[str] = None class ReopenRequest(BaseModel): reason: str = "" class GatePracticeCreateRequest(BaseModel): title: str = Field(min_length=1, max_length=255) description: str = "" status: Literal["active", "paused", "ended"] = "active" interval_days: int = Field(default=1, gt=0) schedule: Optional[SchedulePayload] = None class PlanSnapshotCreateRequest(BaseModel): reason: str = Field(default="", max_length=2000) initiative_router = APIRouter(prefix="/api/initiatives", tags=["roadmap"]) items_router = APIRouter(prefix="/api/roadmap-items", tags=["roadmap"]) deps_router = APIRouter(prefix="/api/roadmap-dependencies", tags=["roadmap"]) @initiative_router.get("/{initiative_id}/roadmap") def get_initiative_roadmap( initiative_id: str, ctx: TenantContext = Depends(require_capability("kairo.milestone.read")), ): try: roadmap = roadmap_service.get_roadmap_for_initiative( tenant_id=ctx.tenant_id, initiative_id=initiative_id ) except ValueError as exc: raise HTTPException(status_code=400, detail=str(exc)) from exc if not roadmap: return {"initiative_id": initiative_id, "items": []} items = roadmap_service.list_roadmap_items_for_initiative( tenant_id=ctx.tenant_id, initiative_id=initiative_id ) return {**roadmap, "items": items} @initiative_router.get("/{initiative_id}/roadmap/items") def list_initiative_roadmap_items( initiative_id: str, ctx: TenantContext = Depends(require_capability("kairo.milestone.read")), ): try: return roadmap_service.list_roadmap_items_for_initiative( tenant_id=ctx.tenant_id, initiative_id=initiative_id ) except ValueError as exc: raise HTTPException(status_code=400, detail=str(exc)) from exc @initiative_router.get("/{initiative_id}/roadmap/dependencies") def list_initiative_roadmap_dependencies( initiative_id: str, ctx: TenantContext = Depends(require_capability("kairo.milestone.read")), ): try: return roadmap_service.list_dependencies_for_initiative( tenant_id=ctx.tenant_id, initiative_id=initiative_id ) except ValueError as exc: raise HTTPException(status_code=400, detail=str(exc)) from exc @initiative_router.get("/{initiative_id}/roadmap/criteria-progress") def list_initiative_roadmap_criteria_progress( initiative_id: str, ctx: TenantContext = Depends(require_capability("kairo.milestone.read")), ): try: return criteria_service.criteria_progress_for_initiative( tenant_id=ctx.tenant_id, initiative_id=initiative_id ) except ValueError as exc: raise HTTPException(status_code=400, detail=str(exc)) from exc @initiative_router.get("/{initiative_id}/roadmap/graph-state") def get_initiative_roadmap_graph_state( initiative_id: str, ctx: TenantContext = Depends(require_capability("kairo.milestone.read")), ): try: from steering.graph.roadmap_engine import load_initiative_graph_state return load_initiative_graph_state( tenant_id=ctx.tenant_id, initiative_id=initiative_id ) except ValueError as exc: raise HTTPException(status_code=400, detail=str(exc)) from exc @initiative_router.get("/{initiative_id}/roadmap/progression-state") def get_initiative_progression_state( initiative_id: str, ctx: TenantContext = Depends(require_capability("kairo.milestone.read")), ): try: from services.progression_graph import compute_progression_state return compute_progression_state( tenant_id=ctx.tenant_id, initiative_id=initiative_id ) except ValueError as exc: raise HTTPException(status_code=400, detail=str(exc)) from exc @initiative_router.get("/{initiative_id}/roadmap/plan-ist-view") def get_initiative_plan_ist_view( initiative_id: str, ctx: TenantContext = Depends(require_capability("kairo.milestone.read")), ): try: from steering.graph.plan_ist import load_plan_ist_view return load_plan_ist_view(tenant_id=ctx.tenant_id, initiative_id=initiative_id) except ValueError as exc: raise HTTPException(status_code=400, detail=str(exc)) from exc @initiative_router.get("/{initiative_id}/roadmap/plan-snapshots") def list_initiative_plan_snapshots( initiative_id: str, ctx: TenantContext = Depends(require_capability("kairo.milestone.read")), ): try: return snapshot_service.list_plan_snapshots( tenant_id=ctx.tenant_id, initiative_id=initiative_id ) except ValueError as exc: raise HTTPException(status_code=400, detail=str(exc)) from exc @initiative_router.post("/{initiative_id}/roadmap/plan-snapshots", status_code=201) def create_initiative_plan_snapshot( initiative_id: str, body: PlanSnapshotCreateRequest, ctx: TenantContext = Depends(require_capability("kairo.milestone.manage")), ): try: return snapshot_service.create_plan_snapshot( tenant_id=ctx.tenant_id, initiative_id=initiative_id, reason=body.reason, user_id=ctx.user_id, ) except ValueError as exc: raise HTTPException(status_code=400, detail=str(exc)) from exc @initiative_router.post("/{initiative_id}/roadmap/items", status_code=201) def create_initiative_roadmap_item( initiative_id: str, body: RoadmapItemCreateRequest, ctx: TenantContext = Depends(require_capability("kairo.milestone.manage")), ): try: return roadmap_service.create_roadmap_item( tenant_id=ctx.tenant_id, initiative_id=initiative_id, user_id=ctx.user_id, title=body.title, item_type=body.item_type, goal_description=body.goal_description, definition_of_done=body.definition_of_done, status=body.status, sequencing_mode=body.sequencing_mode, target_date=body.target_date, sort_order=body.sort_order, strand_project_id=body.strand_project_id, initial_criterion_title=body.initial_criterion_title, ) except ValueError as exc: raise HTTPException(status_code=400, detail=str(exc)) from exc @items_router.get("/{item_id}") def get_roadmap_item( item_id: str, ctx: TenantContext = Depends(require_capability("kairo.milestone.read")), ): item = roadmap_service.get_roadmap_item(tenant_id=ctx.tenant_id, item_id=item_id) if not item: raise HTTPException(status_code=404, detail="RoadmapItem nicht gefunden") return item @items_router.patch("/{item_id}") def update_roadmap_item( item_id: str, body: RoadmapItemUpdateRequest, ctx: TenantContext = Depends(require_capability("kairo.milestone.manage")), ): try: item = roadmap_service.update_roadmap_item( tenant_id=ctx.tenant_id, item_id=item_id, user_id=ctx.user_id, title=body.title, item_type=body.item_type, goal_description=body.goal_description, definition_of_done=body.definition_of_done, status=body.status, sequencing_mode=body.sequencing_mode, target_date=body.target_date, clear_target_date=body.clear_target_date, sort_order=body.sort_order, strand_project_id=body.strand_project_id, clear_strand_project=body.clear_strand_project, ) except ValueError as exc: raise HTTPException(status_code=400, detail=str(exc)) from exc if not item: raise HTTPException(status_code=404, detail="RoadmapItem nicht gefunden") return item @items_router.delete("/{item_id}", status_code=204) def delete_roadmap_item( item_id: str, ctx: TenantContext = Depends(require_capability("kairo.milestone.manage")), ): try: deleted = roadmap_service.delete_roadmap_item( tenant_id=ctx.tenant_id, item_id=item_id, user_id=ctx.user_id ) except ValueError as exc: raise HTTPException(status_code=400, detail=str(exc)) from exc if not deleted: raise HTTPException(status_code=404, detail="RoadmapItem nicht gefunden") def _work_gate_or_400(*, tenant_id: str, item_id: str) -> dict[str, Any]: item = roadmap_service.get_roadmap_item(tenant_id=tenant_id, item_id=item_id) if not item: raise HTTPException(status_code=404, detail="RoadmapItem nicht gefunden") if item.get("item_type") == "join_gate": raise HTTPException( status_code=400, detail="Join-Gates haben kein Activity Set (Übungen)", ) return item @items_router.get("/{item_id}/practices") def list_gate_practices( item_id: str, ctx: TenantContext = Depends(require_capability("kairo.recurring.read")), ): from services import recurring as recurring_service item = _work_gate_or_400(tenant_id=ctx.tenant_id, item_id=item_id) try: return recurring_service.list_recurring_for_gate( tenant_id=ctx.tenant_id, initiative_id=str(item["initiative_id"]), gate_id=item_id, ) except ValueError as exc: raise HTTPException(status_code=400, detail=str(exc)) from exc @items_router.post("/{item_id}/practices", status_code=201) def create_gate_practice( item_id: str, body: GatePracticeCreateRequest, ctx: TenantContext = Depends(require_capability("kairo.recurring.manage")), ): from services import recurring as recurring_service item = _work_gate_or_400(tenant_id=ctx.tenant_id, item_id=item_id) sched = body.schedule try: if sched: return recurring_service.create_recurring_element( tenant_id=ctx.tenant_id, initiative_id=str(item["initiative_id"]), title=body.title, description=body.description, status=body.status, schedule_kind=sched.schedule_kind, interval_days=sched.interval_days, weekday_mask=sched.weekday_mask, pause_until=sched.pause_until_date(), roadmap_item_id=item_id, user_id=ctx.user_id, ) return recurring_service.create_recurring_element( tenant_id=ctx.tenant_id, initiative_id=str(item["initiative_id"]), title=body.title, description=body.description, status=body.status, interval_days=body.interval_days, roadmap_item_id=item_id, user_id=ctx.user_id, ) except ValueError as exc: raise HTTPException(status_code=400, detail=str(exc)) from exc @items_router.post("/{item_id}/verify-reached") def verify_roadmap_item_reached( item_id: str, ctx: TenantContext = Depends(require_capability("kairo.milestone.manage")), ): try: return roadmap_service.verify_reached( tenant_id=ctx.tenant_id, item_id=item_id, user_id=ctx.user_id ) except ValueError as exc: raise HTTPException(status_code=400, detail=str(exc)) from exc @items_router.post("/{item_id}/reopen") def reopen_roadmap_item( item_id: str, body: ReopenRequest, ctx: TenantContext = Depends(require_capability("kairo.roadmap.reopen")), ): try: return roadmap_service.reopen_roadmap_item( tenant_id=ctx.tenant_id, item_id=item_id, user_id=ctx.user_id, reason=body.reason, ) except ValueError as exc: raise HTTPException(status_code=400, detail=str(exc)) from exc @items_router.get("/{item_id}/criteria") def list_roadmap_item_criteria( item_id: str, ctx: TenantContext = Depends(require_capability("kairo.milestone.read")), ): try: progress = criteria_service.criteria_progress( tenant_id=ctx.tenant_id, item_id=item_id ) items = criteria_service.list_criteria_for_item( tenant_id=ctx.tenant_id, item_id=item_id ) return {"items": items, "progress": progress} except ValueError as exc: raise HTTPException(status_code=400, detail=str(exc)) from exc @items_router.post("/{item_id}/criteria", status_code=201) def create_roadmap_item_criterion( item_id: str, body: CriterionCreateRequest, ctx: TenantContext = Depends(require_capability("kairo.milestone.manage")), ): try: return criteria_service.create_criterion( tenant_id=ctx.tenant_id, item_id=item_id, user_id=ctx.user_id, title=body.title, description=body.description, criterion_kind=body.criterion_kind, verification_hint=body.verification_hint, sort_order=body.sort_order, ) except ValueError as exc: raise HTTPException(status_code=400, detail=str(exc)) from exc @deps_router.delete("/{dependency_id}", status_code=204) def delete_roadmap_dependency( dependency_id: str, ctx: TenantContext = Depends(require_capability("kairo.milestone.manage")), ): try: roadmap_service.delete_dependency( tenant_id=ctx.tenant_id, dependency_id=dependency_id, user_id=ctx.user_id, ) except ValueError as exc: raise HTTPException(status_code=400, detail=str(exc)) from exc criteria_router = APIRouter(prefix="/api/roadmap-criteria", tags=["roadmap"]) @criteria_router.patch("/{criterion_id}") def update_roadmap_criterion( criterion_id: str, body: CriterionUpdateRequest, ctx: TenantContext = Depends(require_capability("kairo.milestone.manage")), ): try: item = criteria_service.update_criterion( tenant_id=ctx.tenant_id, criterion_id=criterion_id, user_id=ctx.user_id, title=body.title, description=body.description, criterion_kind=body.criterion_kind, verification_hint=body.verification_hint, sort_order=body.sort_order, change_reason=body.change_reason, ) except ValueError as exc: raise HTTPException(status_code=400, detail=str(exc)) from exc if not item: raise HTTPException(status_code=404, detail="Kriterium nicht gefunden") return item @criteria_router.delete("/{criterion_id}", status_code=204) def delete_roadmap_criterion( criterion_id: str, ctx: TenantContext = Depends(require_capability("kairo.milestone.manage")), ): try: if not criteria_service.delete_criterion( tenant_id=ctx.tenant_id, criterion_id=criterion_id, user_id=ctx.user_id ): raise HTTPException(status_code=404, detail="Kriterium nicht gefunden") except ValueError as exc: raise HTTPException(status_code=400, detail=str(exc)) from exc @criteria_router.post("/{criterion_id}/satisfy") def satisfy_roadmap_criterion( criterion_id: str, body: CriterionSatisfyRequest, ctx: TenantContext = Depends(require_capability("kairo.milestone.manage")), ): try: return criteria_service.satisfy_criterion( tenant_id=ctx.tenant_id, criterion_id=criterion_id, user_id=ctx.user_id, evidence_id=body.evidence_id, ) except ValueError as exc: raise HTTPException(status_code=400, detail=str(exc)) from exc @criteria_router.post("/{criterion_id}/waive") def waive_roadmap_criterion( criterion_id: str, body: CriterionWaiveDeferRequest, ctx: TenantContext = Depends(require_capability("kairo.milestone.manage")), ): try: return criteria_service.waive_criterion( tenant_id=ctx.tenant_id, criterion_id=criterion_id, user_id=ctx.user_id, decision_title=body.decision_title, decision_description=body.decision_description, ) except ValueError as exc: raise HTTPException(status_code=400, detail=str(exc)) from exc @criteria_router.post("/{criterion_id}/defer") def defer_roadmap_criterion( criterion_id: str, body: CriterionWaiveDeferRequest, ctx: TenantContext = Depends(require_capability("kairo.milestone.manage")), ): try: return criteria_service.defer_criterion( tenant_id=ctx.tenant_id, criterion_id=criterion_id, user_id=ctx.user_id, decision_title=body.decision_title, decision_description=body.decision_description, ) except ValueError as exc: raise HTTPException(status_code=400, detail=str(exc)) from exc @items_router.get("/{item_id}/dependencies") def list_roadmap_item_dependencies( item_id: str, ctx: TenantContext = Depends(require_capability("kairo.milestone.read")), ): try: return roadmap_service.list_dependencies(tenant_id=ctx.tenant_id, item_id=item_id) except ValueError as exc: raise HTTPException(status_code=400, detail=str(exc)) from exc @items_router.post("/{item_id}/dependencies", status_code=201) def add_roadmap_item_dependency( item_id: str, body: DependencyCreateRequest, ctx: TenantContext = Depends(require_capability("kairo.milestone.manage")), ): try: return roadmap_service.add_dependency( tenant_id=ctx.tenant_id, from_item_id=item_id, to_item_id=body.to_item_id, dependency_type=body.dependency_type, group_key=body.group_key, user_id=ctx.user_id, ) except ValueError as exc: raise HTTPException(status_code=400, detail=str(exc)) from exc