"""Operational Actor API — AP1.7 (Vibe-Coder / Agenten).""" from __future__ import annotations from typing import Any, Literal, Optional from data_layer.attention import get_next_action_candidates_for_initiative from data_layer.initiative_snapshot import get_initiative_steering_snapshot from fastapi import APIRouter, Depends, HTTPException, Query from operational_auth import require_operational_capability 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 recurring as recurring_service from services.audit import log_audit from tenant_context import TenantContext router = APIRouter(prefix="/api/operational", tags=["operational"]) def _ok(ctx: TenantContext, data: Any) -> dict[str, Any]: return { "ok": True, "data": data, "actor_id": ctx.actor_id, } class ActionStatusPatch(BaseModel): status: Literal[ "open", "ready", "in_progress", "blocked", "review_required", "done", "discarded" ] note: Optional[str] = None class ActionAssignmentsBody(BaseModel): actor_ids: list[str] = Field(default_factory=list) class BlockerCreateBody(BaseModel): initiative_id: str title: str = Field(min_length=1, max_length=255) description: str = "" action_id: Optional[str] = None status: Literal["open", "in_progress"] = "open" class BlockerPatchBody(BaseModel): status: Literal["open", "in_progress", "resolved", "accepted_risk", "dismissed"] class EvidenceCreateBody(BaseModel): initiative_id: str title: str = Field(min_length=1, max_length=255) body: str = "" action_id: Optional[str] = None roadmap_item_id: Optional[str] = None status: Literal["submitted", "accepted", "rejected"] = "submitted" class DecisionProposalBody(BaseModel): initiative_id: str title: str = Field(min_length=1, max_length=255) rationale: str = "" proposed_status: Literal["proposed"] = "proposed" class BacklogProposalBody(BaseModel): initiative_id: str title: str = Field(min_length=1, max_length=255) description: str = "" roadmap_item_id: Optional[str] = None class BacklogConvertBody(BaseModel): work_cycle_id: Optional[str] = None assign_active_sprint: bool = True assigned_actor_ids: list[str] = Field(default_factory=list) class RecurringStatusPatch(BaseModel): status: Literal["active", "paused", "ended"] class ActionWorkCyclePatch(BaseModel): work_cycle_id: Optional[str] = None clear_work_cycle: bool = False @router.get("/me") def operational_me(ctx: TenantContext = Depends(require_operational_capability("kairo.action.read"))): return _ok( ctx, { "auth_source": ctx.auth_source, "tenant_id": ctx.tenant_id, "tenant_slug": ctx.tenant_slug, "actor_id": ctx.actor_id, "actor_type": ctx.actor_type, "display_name": ctx.display_name, "capabilities": sorted(ctx.capabilities), }, ) @router.get("/initiatives/{initiative_id}/context") def operational_context( initiative_id: str, ctx: TenantContext = Depends(require_operational_capability("kairo.initiative.read")), ): snapshot = get_initiative_steering_snapshot(ctx, initiative_id=initiative_id) if not snapshot: raise HTTPException(status_code=404, detail="Vorhaben nicht gefunden") subset = { "initiative_id": snapshot.get("initiative_id"), "title": snapshot.get("initiative_title"), "status": snapshot.get("initiative_status"), "method_key": snapshot.get("method_key"), "method_label": snapshot.get("method_label"), "archetype_key": snapshot.get("archetype_key"), "archetype_label": snapshot.get("archetype_label"), "steering_guidance": snapshot.get("steering_guidance"), "lifecycle_state": snapshot.get("lifecycle_state"), "lifecycle_label": snapshot.get("lifecycle_label"), "signals": snapshot.get("signals"), "attention_items": snapshot.get("attention_items"), "next_actions": snapshot.get("next_actions"), "active_work_cycle": snapshot.get("active_work_cycle"), } return _ok(ctx, subset) @router.get("/next-action") def operational_next_action( initiative_id: str = Query(...), limit: int = Query(default=5, ge=1, le=20), ctx: TenantContext = Depends(require_operational_capability("kairo.initiative.read")), ): items = get_next_action_candidates_for_initiative( ctx, initiative_id=initiative_id, limit=limit ) return _ok(ctx, items) @router.get("/actions/{action_id}") def operational_get_action( action_id: str, ctx: TenantContext = Depends(require_operational_capability("kairo.action.read")), ): item = action_service.get_action(tenant_id=ctx.tenant_id, action_id=action_id) if not item: raise HTTPException(status_code=404, detail="Arbeitspaket nicht gefunden") return _ok(ctx, item) @router.patch("/actions/{action_id}/status") def operational_patch_action_status( action_id: str, body: ActionStatusPatch, ctx: TenantContext = Depends(require_operational_capability("kairo.action.manage")), ): description = None if body.note: description = body.note.strip() try: item = action_service.update_action( tenant_id=ctx.tenant_id, action_id=action_id, user_id=ctx.user_id or None, status=body.status, description=description, ) except ValueError as exc: raise HTTPException(status_code=400, detail=str(exc)) from exc if not item: raise HTTPException(status_code=404, detail="Arbeitspaket nicht gefunden") log_audit( "operational.action.status", user_id=ctx.user_id or None, tenant_id=ctx.tenant_id, details={ "action_id": action_id, "status": body.status, "actor_id": ctx.actor_id, "auth_source": ctx.auth_source, }, ) return _ok(ctx, item) @router.post("/actions/{action_id}/assignments") def operational_set_assignments( action_id: str, body: ActionAssignmentsBody, ctx: TenantContext = Depends(require_operational_capability("kairo.action.manage")), ): try: item = action_service.set_action_assignments( tenant_id=ctx.tenant_id, action_id=action_id, actor_ids=body.actor_ids, user_id=ctx.user_id or None, ) except ValueError as exc: raise HTTPException(status_code=400, detail=str(exc)) from exc if not item: raise HTTPException(status_code=404, detail="Arbeitspaket nicht gefunden") return _ok(ctx, item) @router.post("/blockers", status_code=201) def operational_create_blocker( body: BlockerCreateBody, ctx: TenantContext = Depends(require_operational_capability("kairo.blocker.manage")), ): try: item = blocker_service.create_blocker( tenant_id=ctx.tenant_id, initiative_id=body.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 or None, ) 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 return _ok(ctx, item) @router.patch("/blockers/{blocker_id}") def operational_patch_blocker( blocker_id: str, body: BlockerPatchBody, ctx: TenantContext = Depends(require_operational_capability("kairo.blocker.manage")), ): try: item = blocker_service.update_blocker( tenant_id=ctx.tenant_id, blocker_id=blocker_id, user_id=ctx.user_id or None, status=body.status, ) except ValueError as exc: raise HTTPException(status_code=400, detail=str(exc)) from exc if not item: raise HTTPException(status_code=404, detail="Blocker nicht gefunden") return _ok(ctx, item) @router.post("/evidence", status_code=201) def operational_create_evidence( body: EvidenceCreateBody, ctx: TenantContext = Depends(require_operational_capability("kairo.evidence.manage")), ): try: item = evidence_service.create_evidence( tenant_id=ctx.tenant_id, initiative_id=body.initiative_id, title=body.title, description=body.body, status=body.status, action_id=body.action_id, roadmap_item_id=body.roadmap_item_id, submitted_by_actor_id=ctx.actor_id, user_id=ctx.user_id or None, ) 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 return _ok(ctx, item) @router.post("/decisions/proposals", status_code=201) def operational_decision_proposal( body: DecisionProposalBody, ctx: TenantContext = Depends(require_operational_capability("kairo.decision.manage")), ): try: item = decision_service.create_decision( tenant_id=ctx.tenant_id, initiative_id=body.initiative_id, title=body.title, description=body.rationale, status=body.proposed_status, decided_by_actor_id=ctx.actor_id, user_id=ctx.user_id or None, ) 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 return _ok(ctx, item) @router.post("/backlog/proposals", status_code=201) def operational_backlog_proposal( body: BacklogProposalBody, ctx: TenantContext = Depends(require_operational_capability("kairo.backlog.manage")), ): try: item = backlog_service.create_backlog_item( tenant_id=ctx.tenant_id, initiative_id=body.initiative_id, title=body.title, description=body.description, status="new", roadmap_item_id=body.roadmap_item_id, user_id=ctx.user_id or None, ) 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 return _ok(ctx, item) @router.post("/backlog/{backlog_item_id}/convert-to-action", status_code=201) def operational_convert_backlog( backlog_item_id: str, body: BacklogConvertBody, ctx: TenantContext = Depends(require_operational_capability("kairo.backlog.manage")), ): assigned = body.assigned_actor_ids if not assigned and ctx.actor_id: assigned = [ctx.actor_id] try: result = backlog_service.convert_backlog_to_action( tenant_id=ctx.tenant_id, backlog_item_id=backlog_item_id, user_id=ctx.user_id or None, assigned_actor_ids=assigned, work_cycle_id=body.work_cycle_id, assign_active_sprint=body.assign_active_sprint, ) except ValueError as exc: detail = str(exc) if detail == "Backlog-Item nicht gefunden": raise HTTPException(status_code=404, detail=detail) from exc raise HTTPException(status_code=400, detail=detail) from exc log_audit( "operational.backlog.convert", user_id=ctx.user_id or None, tenant_id=ctx.tenant_id, details={ "backlog_item_id": backlog_item_id, "action_id": result.get("action", {}).get("id"), "work_cycle_id": body.work_cycle_id, "actor_id": ctx.actor_id, }, ) return _ok(ctx, result) @router.patch("/recurring/{recurring_id}") def operational_patch_recurring( recurring_id: str, body: RecurringStatusPatch, ctx: TenantContext = Depends(require_operational_capability("kairo.recurring.manage")), ): try: item = recurring_service.update_recurring_element( tenant_id=ctx.tenant_id, recurring_id=recurring_id, user_id=ctx.user_id or None, status=body.status, ) except ValueError as exc: raise HTTPException(status_code=400, detail=str(exc)) from exc if not item: raise HTTPException(status_code=404, detail="Recurring-Element nicht gefunden") log_audit( "operational.recurring.status", user_id=ctx.user_id or None, tenant_id=ctx.tenant_id, details={ "recurring_id": recurring_id, "status": body.status, "actor_id": ctx.actor_id, }, ) return _ok(ctx, item) @router.patch("/actions/{action_id}/work-cycle") def operational_patch_action_work_cycle( action_id: str, body: ActionWorkCyclePatch, ctx: TenantContext = Depends(require_operational_capability("kairo.action.manage")), ): try: item = action_service.update_action( tenant_id=ctx.tenant_id, action_id=action_id, user_id=ctx.user_id or None, work_cycle_id=body.work_cycle_id, clear_work_cycle=body.clear_work_cycle, ) except ValueError as exc: raise HTTPException(status_code=400, detail=str(exc)) from exc if not item: raise HTTPException(status_code=404, detail="Arbeitspaket nicht gefunden") log_audit( "operational.action.work_cycle", user_id=ctx.user_id or None, tenant_id=ctx.tenant_id, details={ "action_id": action_id, "work_cycle_id": body.work_cycle_id, "clear_work_cycle": body.clear_work_cycle, "actor_id": ctx.actor_id, }, ) return _ok(ctx, item)