"""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 services import roadmap as roadmap_service from services import roadmap_criteria as criteria_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"] = "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 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"]] = 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 class DependencyCreateRequest(BaseModel): to_item_id: str dependency_type: Literal["requires", "blocks", "related"] = "requires" 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 = "" 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.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, 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, ) 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")), ): if not roadmap_service.delete_roadmap_item( tenant_id=ctx.tenant_id, item_id=item_id, user_id=ctx.user_id ): raise HTTPException(status_code=404, detail="RoadmapItem nicht gefunden") @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, user_id=ctx.user_id, ) except ValueError as exc: raise HTTPException(status_code=400, detail=str(exc)) from exc