All checks were successful
Deploy Development / deploy (push) Successful in 46s
Test Suite / pytest-backend (push) Successful in 2m11s
Test Suite / lint-backend (push) Successful in 3s
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 12s
DELETE fuer Roadmap-Kanten, Duplikat-Schutz beim Anlegen, GateDependenciesSection mit ausgehenden/eingehenden Kanten auf der Gate-Detailseite. Co-authored-by: Cursor <cursoragent@cursor.com>
411 lines
14 KiB
Python
411 lines
14 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
|
|
|
|
|
|
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.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,
|
|
)
|
|
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
|