"""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.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 @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("title"), "status": snapshot.get("status"), "method_key": snapshot.get("method_key"), "archetype_key": snapshot.get("archetype_key"), "archetype_label": snapshot.get("archetype_label"), "steering_guidance": snapshot.get("steering_guidance"), "lifecycle": snapshot.get("lifecycle"), "signals": snapshot.get("signals"), "attention": snapshot.get("attention"), } 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)