Kairo-Jinkendo/backend/routers/roadmap.py
Lars 87ecee5301
All checks were successful
Deploy Development / deploy (push) Successful in 50s
Test Suite / pytest-backend (push) Successful in 4m54s
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 19s
Test Suite / playwright-smoke (push) Successful in 13s
feat(A1): Activity Set am Gate-Detail — Übungen an Zielzustand binden
Gate-Detail: Übungen anlegen mit Rhythmus, Gate aktivieren, Link zu Today. API: /roadmap-items/{id}/practices und roadmap_item_id auf Recurring-Create. Damit ist A1 ohne Starter-Kit manuell durchspielbar.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-03 19:36:50 +02:00

582 lines
20 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 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)
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 datetime import datetime, timezone
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.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,
next_due_at=datetime.now(timezone.utc),
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