Files
roboco/roboco/api/routes/playbooks.py
T
7804e0fafa [f8480831] Batch B: extract route helpers in remaining smaller-offender route files (#760)
* [f8480831] refactor(api): extract route-layer helpers into services/schemas/utils (batch B)

Moves 28 non-@router-decorated helper functions out of 15 route files
(optimal, project, release, dashboard, pitch, x, docs, git, playbooks,
product, provider, research, secretary, system, work_session) into
their paired services module (DB/service-calling helpers), the route's
schemas module as a converter (pure response/request shaping, mirroring
the existing project_to_response/assignment_to_response pattern), or
roboco/utils/converters.py (pure generic helpers). Adds two small
shared role-check helpers to api/deps.py (require_auditor_or_ceo,
require_role_in) for endpoint-specific role gates that had no existing
home. Placement-only: no route paths, schemas, or observable behavior
changed. Fixes the handful of tests that imported the old private
helper names directly.

* [f8480831] docs(map): document Batch B route-helper relocation in api-routes-schemas.md

* [f8480831] docs(map): add Key Symbols rows for require_auditor_or_ceo/require_role_in

---------

Co-authored-by: Backend Developer 2 <be-dev-2@roboco.tech>
Co-authored-by: Backend Documenter <be-doc@roboco.tech>
2026-07-31 20:23:10 +00:00

116 lines
4.4 KiB
Python

"""Playbook curation API — the Auditor (or CEO) reviews drafts.
GET lists drafts (or approved); approve flips a draft to approved (+ indexes it);
reject archives it with a reason. Curation is gated to the Auditor and the CEO —
delivery roles DRAFT via the gateway verb but never curate.
"""
from uuid import UUID
from fastapi import APIRouter, HTTPException, Query, status
from roboco.api.deps import CurrentAgentContext, DbSession, require_auditor_or_ceo
from roboco.api.schemas.playbook import PlaybookRejectBody
from roboco.models.playbook import Playbook
from roboco.services.base import ConflictError, NotFoundError
from roboco.services.playbook import get_playbook_service
router = APIRouter()
_CURATOR_DETAIL = "Only the Auditor or CEO may curate playbooks"
@router.get("", response_model=list[Playbook])
async def list_playbooks(
db: DbSession,
agent: CurrentAgentContext,
status_filter: str = Query(default="draft", alias="status"),
) -> list[Playbook]:
"""List playbooks by status (default: drafts awaiting review)."""
require_auditor_or_ceo(agent.role, _CURATOR_DETAIL)
svc = get_playbook_service(db)
rows = (
await svc.list_approved()
if status_filter == "approved"
else await svc.list_drafts()
)
return [Playbook.model_validate(row) for row in rows]
@router.post("/{playbook_id}/approve", response_model=Playbook)
async def approve_playbook(
playbook_id: UUID, db: DbSession, agent: CurrentAgentContext
) -> Playbook:
"""Approve a draft playbook → approved (and indexed into the KB)."""
require_auditor_or_ceo(agent.role, _CURATOR_DETAIL)
try:
svc = get_playbook_service(db)
playbook = await svc.approve(playbook_id, approver_id=agent.agent_id)
except NotFoundError as exc:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="Playbook not found"
) from exc
except ConflictError as exc:
# The playbook is not a draft (already approved/archived) — the
# curation is already finished, not a missing resource.
raise HTTPException(
status_code=status.HTTP_409_CONFLICT, detail=str(exc)
) from exc
# Commit the status change BEFORE indexing: the RAG index write runs through
# its own auto-committing connection, so indexing before commit would durably
# land an approved playbook in the corpus even if this commit rolled back.
await db.commit()
await svc.index_approved(playbook)
return Playbook.model_validate(playbook)
@router.post("/{playbook_id}/reject", response_model=Playbook)
async def reject_playbook(
playbook_id: UUID,
body: PlaybookRejectBody,
db: DbSession,
agent: CurrentAgentContext,
) -> Playbook:
"""Reject a draft playbook → archived, with the Auditor's reason."""
require_auditor_or_ceo(agent.role, _CURATOR_DETAIL)
try:
svc = get_playbook_service(db)
playbook = await svc.reject(
playbook_id, approver_id=agent.agent_id, reason=body.reason
)
except NotFoundError as exc:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="Playbook not found"
) from exc
except ConflictError as exc:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT, detail=str(exc)
) from exc
# Commit the status change BEFORE de-indexing (see approve_playbook).
await db.commit()
await svc.unindex_playbook(playbook)
return Playbook.model_validate(playbook)
@router.post("/{playbook_id}/archive", response_model=Playbook)
async def archive_playbook(
playbook_id: UUID, db: DbSession, agent: CurrentAgentContext
) -> Playbook:
"""Retire an approved playbook → archived (and de-indexed from the KB)."""
require_auditor_or_ceo(agent.role, _CURATOR_DETAIL)
try:
svc = get_playbook_service(db)
playbook = await svc.archive(playbook_id, approver_id=agent.agent_id)
except NotFoundError as exc:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="Playbook not found"
) from exc
except ConflictError as exc:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT, detail=str(exc)
) from exc
# Commit the status change BEFORE de-indexing (see approve_playbook).
await db.commit()
await svc.unindex_playbook(playbook)
return Playbook.model_validate(playbook)