state-hub/api/routers/intake.py

174 lines
5.6 KiB
Python
Raw Normal View History

CUST-WP-0061-T01: intake work-record entity (stage 3) Fresh hub entity per the founder-reviewed decision (not a suggestions rename-bridge): kind: intake per canon/standards/work-record-types_v0.1.md, lifecycle open -> vetted -> routed -> closed(promoted|declined|absorbed). - api/models/base.py::new_uuid7 -- dependency-free RFC 9562 UUIDv7 generator (48-bit ms timestamp, version/variant bits, random remainder); existing tables keep new_uuid (UUIDv4) unchanged, this is opt-in for new work-record entities per the identity-layering canon - api/models/intake.py: Intake + IntakeNote ORM models, mirroring Decision's shape (topic/workplan/repo scope, lane, status, outcome, promoted_to back-link); CHECK constraints enforce scope-required, closed-requires-outcome, promoted-requires-promoted_to at the DB level - migrations/a7c3e9f1b4d2: intakes + intake_notes tables, 3 enum types - api/routers/intake.py: list/create/get/patch + /route + /close + /notes actions, mirroring decisions.py's pattern (409 on invalid transitions, progress event on close) - api/schemas/intake.py: Pydantic create/update/route/close/note schemas - mcp_server/server.py: create_intake, list_intakes, route_intake, close_intake tool wrappers - tests/test_intake.py: 12 tests against the real Postgres test DB (create/list/scope-validation, full lifecycle incl. 409s and the promoted-requires-promoted_to constraint, notes, UUIDv7 verification) Verified live against the running dev API + DB (not just pytest): applied the migration, restarted the MCP server, and ran a full create -> route -> close cycle over the real REST endpoints. No regressions: full existing suite (test_routers_core, test_suggestions, test_mcp_smoke, test_mcp_write_tools, test_mcp_registration, test_consistency_check, test_consistency_sweep) all green. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-21 00:27:45 +02:00
import uuid
from datetime import datetime, timezone
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from api.database import get_session
from api.models.intake import Intake, IntakeNote, IntakeOutcome, IntakeStatus
from api.models.progress_event import ProgressEvent
from api.schemas.intake import (
IntakeClose,
IntakeCreate,
IntakeNoteCreate,
IntakeRead,
IntakeRoute,
IntakeUpdate,
)
router = APIRouter(prefix="/intakes", tags=["intakes"])
_ALLOWED_ROUTE_FROM = {IntakeStatus.open, IntakeStatus.vetted}
_ALLOWED_CLOSE_FROM = {IntakeStatus.open, IntakeStatus.vetted, IntakeStatus.routed}
def _reject_status(intake: Intake, allowed: set[IntakeStatus], action: str) -> None:
if intake.status not in allowed:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail=(
f"Cannot {action} intake in status '{intake.status.value}'; "
f"allowed from: {sorted(s.value for s in allowed)}"
),
)
@router.get("/", response_model=list[IntakeRead])
async def list_intakes(
topic_id: uuid.UUID | None = None,
workplan_id: uuid.UUID | None = None,
repo_id: uuid.UUID | None = None,
status_: IntakeStatus | None = None,
session: AsyncSession = Depends(get_session),
) -> list[Intake]:
q = select(Intake)
if topic_id:
q = q.where(Intake.topic_id == topic_id)
if workplan_id:
q = q.where(Intake.workplan_id == workplan_id)
if repo_id:
q = q.where(Intake.repo_id == repo_id)
if status_:
q = q.where(Intake.status == status_)
q = q.order_by(Intake.created_at)
result = await session.execute(q)
return list(result.scalars().all())
@router.post("/", response_model=IntakeRead, status_code=status.HTTP_201_CREATED)
async def create_intake(
body: IntakeCreate,
session: AsyncSession = Depends(get_session),
) -> Intake:
intake = Intake(**body.model_dump())
session.add(intake)
await session.commit()
await session.refresh(intake)
return intake
@router.get("/{intake_id}", response_model=IntakeRead)
async def get_intake(
intake_id: uuid.UUID,
session: AsyncSession = Depends(get_session),
) -> Intake:
intake = await session.get(Intake, intake_id)
if intake is None:
raise HTTPException(status_code=404, detail="Intake not found")
return intake
@router.patch("/{intake_id}", response_model=IntakeRead)
async def update_intake(
intake_id: uuid.UUID,
body: IntakeUpdate,
session: AsyncSession = Depends(get_session),
) -> Intake:
intake = await session.get(Intake, intake_id)
if intake is None:
raise HTTPException(status_code=404, detail="Intake not found")
for field, value in body.model_dump(exclude_unset=True).items():
setattr(intake, field, value)
await session.commit()
await session.refresh(intake)
return intake
@router.post("/{intake_id}/route", response_model=IntakeRead)
async def route_intake(
intake_id: uuid.UUID,
body: IntakeRoute,
session: AsyncSession = Depends(get_session),
) -> Intake:
"""Move an intake into `routed` — eligible for the promotion transition."""
intake = await session.get(Intake, intake_id)
if intake is None:
raise HTTPException(status_code=404, detail="Intake not found")
_reject_status(intake, _ALLOWED_ROUTE_FROM, "route")
intake.status = IntakeStatus.routed
if body.routed_note:
intake.routed_note = body.routed_note
await session.commit()
await session.refresh(intake)
return intake
@router.post("/{intake_id}/close", response_model=IntakeRead)
async def close_intake(
intake_id: uuid.UUID,
body: IntakeClose,
session: AsyncSession = Depends(get_session),
) -> Intake:
"""Close an intake with an outcome. `outcome=promoted` requires
`promoted_to` (the canonical id of the record it became) this is
normally called by the promotion transition (CUST-WP-0061-T03), not by
hand, but a manual close (declined/absorbed, or a promotion recorded
after the fact) is supported directly."""
intake = await session.get(Intake, intake_id)
if intake is None:
raise HTTPException(status_code=404, detail="Intake not found")
_reject_status(intake, _ALLOWED_CLOSE_FROM, "close")
intake.status = IntakeStatus.closed
intake.outcome = body.outcome
intake.closed_at = datetime.now(tz=timezone.utc)
if body.promoted_to:
intake.promoted_to = body.promoted_to
await session.commit()
await session.refresh(intake)
event = ProgressEvent(
topic_id=intake.topic_id,
workplan_id=intake.workplan_id,
event_type="intake_closed",
summary=f"Intake closed ({body.outcome.value}): {intake.title}",
detail={
"intake_id": str(intake.id),
"outcome": body.outcome.value,
"promoted_to": body.promoted_to,
"note": body.note,
},
)
session.add(event)
await session.commit()
return intake
@router.post("/{intake_id}/notes", response_model=IntakeRead, status_code=status.HTTP_201_CREATED)
async def add_intake_note(
intake_id: uuid.UUID,
body: IntakeNoteCreate,
session: AsyncSession = Depends(get_session),
) -> Intake:
intake = await session.get(Intake, intake_id)
if intake is None:
raise HTTPException(status_code=404, detail="Intake not found")
note = IntakeNote(intake_id=intake.id, author=body.author, content=body.content)
session.add(note)
await session.commit()
await session.refresh(intake)
return intake