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>
173 lines
5.6 KiB
Python
173 lines
5.6 KiB
Python
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
|