"""Initiative (Vorhaben) API — AP0.5.""" from __future__ import annotations from datetime import datetime from typing import Literal, Optional from capabilities import require_capability from data_layer import initiative_snapshot as dl_initiative_snapshot from fastapi import APIRouter, Depends, HTTPException from pydantic import BaseModel, Field from services import actions as action_service from services import backlog as backlog_service from services import blockers as blocker_service from services import decisions as decision_service from services import evidence as evidence_service from services import initiatives as initiative_service from services import milestones as milestone_service from services import recurring as recurring_service from services import reviews as review_service from tenant_context import TenantContext router = APIRouter(prefix="/api/initiatives", tags=["initiatives"]) class InitiativeCreateRequest(BaseModel): title: str = Field(min_length=1, max_length=255) goal: str = "" status: Literal["active", "paused", "completed", "archived"] = "active" priority: Literal["low", "normal", "high"] = "normal" owner_actor_id: Optional[str] = None class InitiativeUpdateRequest(BaseModel): title: Optional[str] = Field(default=None, min_length=1, max_length=255) goal: Optional[str] = None status: Optional[Literal["active", "paused", "completed", "archived"]] = None priority: Optional[Literal["low", "normal", "high"]] = None owner_actor_id: Optional[str] = None class ActionCreateRequest(BaseModel): title: str = Field(min_length=1, max_length=255) description: str = "" status: Literal[ "open", "ready", "in_progress", "blocked", "review_required", "done", "discarded" ] = "open" priority: Literal["low", "normal", "high"] = "normal" due_at: Optional[str] = None assigned_actor_ids: list[str] = Field(default_factory=list) class BlockerCreateRequest(BaseModel): title: str = Field(min_length=1, max_length=255) description: str = "" status: Literal["open", "in_progress", "resolved", "accepted_risk", "dismissed"] = "open" action_id: Optional[str] = None set_action_blocked: bool = False class BacklogCreateRequest(BaseModel): title: str = Field(min_length=1, max_length=255) description: str = "" status: Literal["new", "triaged", "accepted", "rejected"] = "new" priority: Literal["low", "normal", "high"] = "normal" class MilestoneCreateRequest(BaseModel): title: str = Field(min_length=1, max_length=255) goal_description: str = "" status: Literal["planned", "active", "at_risk", "reached", "moved", "discarded"] = "planned" target_date: Optional[str] = None class EvidenceCreateRequest(BaseModel): title: str = Field(min_length=1, max_length=255) description: str = "" status: Literal["submitted", "accepted", "rejected"] = "submitted" action_id: Optional[str] = None milestone_id: Optional[str] = None class DecisionCreateRequest(BaseModel): title: str = Field(min_length=1, max_length=255) description: str = "" status: Literal["proposed", "decided", "superseded"] = "proposed" outcome: str = "" class ReviewCreateRequest(BaseModel): title: str = Field(min_length=1, max_length=255) summary: str = "" status: Literal["planned", "completed", "skipped"] = "planned" due_at: Optional[str] = None action_id: Optional[str] = None milestone_id: Optional[str] = None class RecurringCreateRequest(BaseModel): title: str = Field(min_length=1, max_length=255) description: str = "" status: Literal["active", "paused", "ended"] = "active" interval_days: Optional[int] = Field(default=None, gt=0) next_due_at: Optional[str] = None def _parse_optional_datetime(value: Optional[str], field_name: str) -> Optional[datetime]: if not value: return None try: return datetime.fromisoformat(value.replace("Z", "+00:00")) except ValueError as exc: raise HTTPException(status_code=400, detail=f"Ungültiges {field_name}") from exc @router.get("") def list_initiatives( ctx: TenantContext = Depends(require_capability("kairo.initiative.read")), ): return initiative_service.list_initiatives(tenant_id=ctx.tenant_id) @router.post("", status_code=201) def create_initiative( body: InitiativeCreateRequest, ctx: TenantContext = Depends(require_capability("kairo.initiative.manage")), ): owner_actor_id = body.owner_actor_id or ctx.actor_id if not owner_actor_id: raise HTTPException(status_code=400, detail="Kein Actor im TenantContext") try: return initiative_service.create_initiative( tenant_id=ctx.tenant_id, title=body.title, goal=body.goal, status=body.status, priority=body.priority, owner_actor_id=owner_actor_id, user_id=ctx.user_id, ) except ValueError as exc: raise HTTPException(status_code=400, detail=str(exc)) from exc @router.get("/{initiative_id}") def get_initiative( initiative_id: str, ctx: TenantContext = Depends(require_capability("kairo.initiative.read")), ): item = initiative_service.get_initiative( tenant_id=ctx.tenant_id, initiative_id=initiative_id ) if not item: raise HTTPException(status_code=404, detail="Vorhaben nicht gefunden") return item @router.get("/{initiative_id}/steering-snapshot") def get_initiative_steering_snapshot( initiative_id: str, ctx: TenantContext = Depends(require_capability("kairo.initiative.read")), ): snapshot = dl_initiative_snapshot.get_initiative_steering_snapshot( ctx, initiative_id=initiative_id ) if not snapshot: raise HTTPException(status_code=404, detail="Vorhaben nicht gefunden") return snapshot @router.patch("/{initiative_id}") def update_initiative( initiative_id: str, body: InitiativeUpdateRequest, ctx: TenantContext = Depends(require_capability("kairo.initiative.manage")), ): try: item = initiative_service.update_initiative( tenant_id=ctx.tenant_id, initiative_id=initiative_id, user_id=ctx.user_id, title=body.title, goal=body.goal, status=body.status, priority=body.priority, owner_actor_id=body.owner_actor_id, ) except ValueError as exc: raise HTTPException(status_code=400, detail=str(exc)) from exc if not item: raise HTTPException(status_code=404, detail="Vorhaben nicht gefunden") return item @router.delete("/{initiative_id}", status_code=204) def delete_initiative( initiative_id: str, ctx: TenantContext = Depends(require_capability("kairo.initiative.manage")), ): if not initiative_service.delete_initiative( tenant_id=ctx.tenant_id, initiative_id=initiative_id, user_id=ctx.user_id, ): raise HTTPException(status_code=404, detail="Vorhaben nicht gefunden") @router.get("/{initiative_id}/actions") def list_initiative_actions( initiative_id: str, ctx: TenantContext = Depends(require_capability("kairo.action.read")), ): if not initiative_service.get_initiative( tenant_id=ctx.tenant_id, initiative_id=initiative_id ): raise HTTPException(status_code=404, detail="Vorhaben nicht gefunden") return action_service.list_actions_for_initiative( tenant_id=ctx.tenant_id, initiative_id=initiative_id ) @router.post("/{initiative_id}/actions", status_code=201) def create_initiative_action( initiative_id: str, body: ActionCreateRequest, ctx: TenantContext = Depends(require_capability("kairo.action.manage")), ): try: due_at = _parse_optional_datetime(body.due_at, "due_at") return action_service.create_action( tenant_id=ctx.tenant_id, initiative_id=initiative_id, title=body.title, description=body.description, status=body.status, priority=body.priority, due_at=due_at, assigned_actor_ids=body.assigned_actor_ids, user_id=ctx.user_id, ) except ValueError as exc: detail = str(exc) if detail == "Initiative nicht gefunden": raise HTTPException(status_code=404, detail=detail) from exc raise HTTPException(status_code=400, detail=detail) from exc @router.get("/{initiative_id}/blockers") def list_initiative_blockers( initiative_id: str, ctx: TenantContext = Depends(require_capability("kairo.blocker.read")), ): try: return blocker_service.list_blockers_for_initiative( tenant_id=ctx.tenant_id, initiative_id=initiative_id ) except ValueError as exc: if str(exc) == "Initiative nicht gefunden": raise HTTPException(status_code=404, detail=str(exc)) from exc raise HTTPException(status_code=400, detail=str(exc)) from exc @router.post("/{initiative_id}/blockers", status_code=201) def create_initiative_blocker( initiative_id: str, body: BlockerCreateRequest, ctx: TenantContext = Depends(require_capability("kairo.blocker.manage")), ): try: return blocker_service.create_blocker( tenant_id=ctx.tenant_id, initiative_id=initiative_id, title=body.title, description=body.description, status=body.status, action_id=body.action_id, reported_by_actor_id=ctx.actor_id, user_id=ctx.user_id, set_action_blocked=body.set_action_blocked, ) except ValueError as exc: detail = str(exc) if detail == "Initiative nicht gefunden": raise HTTPException(status_code=404, detail=detail) from exc raise HTTPException(status_code=400, detail=detail) from exc @router.get("/{initiative_id}/backlog") def list_initiative_backlog( initiative_id: str, ctx: TenantContext = Depends(require_capability("kairo.backlog.read")), ): try: return backlog_service.list_backlog_for_initiative( tenant_id=ctx.tenant_id, initiative_id=initiative_id ) except ValueError as exc: if str(exc) == "Initiative nicht gefunden": raise HTTPException(status_code=404, detail=str(exc)) from exc raise HTTPException(status_code=400, detail=str(exc)) from exc @router.post("/{initiative_id}/backlog", status_code=201) def create_initiative_backlog_item( initiative_id: str, body: BacklogCreateRequest, ctx: TenantContext = Depends(require_capability("kairo.backlog.manage")), ): try: return backlog_service.create_backlog_item( tenant_id=ctx.tenant_id, initiative_id=initiative_id, title=body.title, description=body.description, status=body.status, priority=body.priority, user_id=ctx.user_id, ) except ValueError as exc: detail = str(exc) if detail == "Initiative nicht gefunden": raise HTTPException(status_code=404, detail=detail) from exc raise HTTPException(status_code=400, detail=detail) from exc @router.get("/{initiative_id}/milestones") def list_initiative_milestones( initiative_id: str, ctx: TenantContext = Depends(require_capability("kairo.milestone.read")), ): try: return milestone_service.list_milestones_for_initiative( tenant_id=ctx.tenant_id, initiative_id=initiative_id ) except ValueError as exc: if str(exc) == "Initiative nicht gefunden": raise HTTPException(status_code=404, detail=str(exc)) from exc raise HTTPException(status_code=400, detail=str(exc)) from exc @router.post("/{initiative_id}/milestones", status_code=201) def create_initiative_milestone( initiative_id: str, body: MilestoneCreateRequest, ctx: TenantContext = Depends(require_capability("kairo.milestone.manage")), ): from datetime import date as date_type target_date = None if body.target_date: try: target_date = date_type.fromisoformat(body.target_date) except ValueError as exc: raise HTTPException(status_code=400, detail="Ungültiges target_date") from exc try: return milestone_service.create_milestone( tenant_id=ctx.tenant_id, initiative_id=initiative_id, title=body.title, goal_description=body.goal_description, status=body.status, target_date=target_date, user_id=ctx.user_id, ) except ValueError as exc: detail = str(exc) if detail == "Initiative nicht gefunden": raise HTTPException(status_code=404, detail=detail) from exc raise HTTPException(status_code=400, detail=detail) from exc @router.get("/{initiative_id}/evidence") def list_initiative_evidence( initiative_id: str, ctx: TenantContext = Depends(require_capability("kairo.evidence.read")), ): try: return evidence_service.list_evidence_for_initiative( tenant_id=ctx.tenant_id, initiative_id=initiative_id ) except ValueError as exc: if str(exc) == "Initiative nicht gefunden": raise HTTPException(status_code=404, detail=str(exc)) from exc raise HTTPException(status_code=400, detail=str(exc)) from exc @router.post("/{initiative_id}/evidence", status_code=201) def create_initiative_evidence( initiative_id: str, body: EvidenceCreateRequest, ctx: TenantContext = Depends(require_capability("kairo.evidence.manage")), ): try: return evidence_service.create_evidence( tenant_id=ctx.tenant_id, initiative_id=initiative_id, title=body.title, description=body.description, status=body.status, action_id=body.action_id, milestone_id=body.milestone_id, submitted_by_actor_id=ctx.actor_id, user_id=ctx.user_id, ) except ValueError as exc: detail = str(exc) if detail == "Initiative nicht gefunden": raise HTTPException(status_code=404, detail=detail) from exc raise HTTPException(status_code=400, detail=detail) from exc @router.get("/{initiative_id}/decisions") def list_initiative_decisions( initiative_id: str, ctx: TenantContext = Depends(require_capability("kairo.decision.read")), ): try: return decision_service.list_decisions_for_initiative( tenant_id=ctx.tenant_id, initiative_id=initiative_id ) except ValueError as exc: if str(exc) == "Initiative nicht gefunden": raise HTTPException(status_code=404, detail=str(exc)) from exc raise HTTPException(status_code=400, detail=str(exc)) from exc @router.post("/{initiative_id}/decisions", status_code=201) def create_initiative_decision( initiative_id: str, body: DecisionCreateRequest, ctx: TenantContext = Depends(require_capability("kairo.decision.manage")), ): try: return decision_service.create_decision( tenant_id=ctx.tenant_id, initiative_id=initiative_id, title=body.title, description=body.description, status=body.status, outcome=body.outcome, decided_by_actor_id=ctx.actor_id if body.status == "decided" else None, user_id=ctx.user_id, ) except ValueError as exc: detail = str(exc) if detail == "Initiative nicht gefunden": raise HTTPException(status_code=404, detail=detail) from exc raise HTTPException(status_code=400, detail=detail) from exc @router.get("/{initiative_id}/reviews") def list_initiative_reviews( initiative_id: str, ctx: TenantContext = Depends(require_capability("kairo.review.read")), ): try: return review_service.list_reviews_for_initiative( tenant_id=ctx.tenant_id, initiative_id=initiative_id ) except ValueError as exc: if str(exc) == "Initiative nicht gefunden": raise HTTPException(status_code=404, detail=str(exc)) from exc raise HTTPException(status_code=400, detail=str(exc)) from exc @router.post("/{initiative_id}/reviews", status_code=201) def create_initiative_review( initiative_id: str, body: ReviewCreateRequest, ctx: TenantContext = Depends(require_capability("kairo.review.manage")), ): due_at = _parse_optional_datetime(body.due_at, "due_at") try: return review_service.create_review( tenant_id=ctx.tenant_id, initiative_id=initiative_id, title=body.title, summary=body.summary, status=body.status, due_at=due_at, action_id=body.action_id, milestone_id=body.milestone_id, reviewed_by_actor_id=ctx.actor_id, user_id=ctx.user_id, ) except ValueError as exc: detail = str(exc) if detail == "Initiative nicht gefunden": raise HTTPException(status_code=404, detail=detail) from exc raise HTTPException(status_code=400, detail=detail) from exc @router.get("/{initiative_id}/recurring") def list_initiative_recurring( initiative_id: str, ctx: TenantContext = Depends(require_capability("kairo.recurring.read")), ): try: return recurring_service.list_recurring_for_initiative( tenant_id=ctx.tenant_id, initiative_id=initiative_id ) except ValueError as exc: if str(exc) == "Initiative nicht gefunden": raise HTTPException(status_code=404, detail=str(exc)) from exc raise HTTPException(status_code=400, detail=str(exc)) from exc @router.post("/{initiative_id}/recurring", status_code=201) def create_initiative_recurring( initiative_id: str, body: RecurringCreateRequest, ctx: TenantContext = Depends(require_capability("kairo.recurring.manage")), ): next_due_at = _parse_optional_datetime(body.next_due_at, "next_due_at") try: return recurring_service.create_recurring_element( tenant_id=ctx.tenant_id, initiative_id=initiative_id, title=body.title, description=body.description, status=body.status, interval_days=body.interval_days, next_due_at=next_due_at, user_id=ctx.user_id, ) except ValueError as exc: detail = str(exc) if detail == "Initiative nicht gefunden": raise HTTPException(status_code=404, detail=detail) from exc raise HTTPException(status_code=400, detail=detail) from exc