Kairo-Jinkendo/backend/routers/roadmap.py
Lars 4f8725e234
All checks were successful
Deploy Development / deploy (push) Successful in 45s
Test Suite / pytest-backend (push) Successful in 2m17s
Test Suite / lint-backend (push) Successful in 2s
Test Suite / compose-smoke (push) Has been skipped
Test Suite / k6 /api/health Baseline (push) Successful in 18s
Test Suite / playwright-smoke (push) Successful in 13s
AP1.4d: Graph Engine mit blocked/ready Read Models.
Migration 020 fuer Kantenarten und Parallelgruppe; steering/graph berechnet Fortschritt; UI zeigt Bereit/Blockiert in Liste und Graph.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-11 07:59:18 +02:00

445 lines
15 KiB
Python

"""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", "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 = ""
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.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,
group_key=body.group_key,
user_id=ctx.user_id,
)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc