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>
This commit is contained in:
parent
4541f1d6fc
commit
88ba666c95
9 changed files with 789 additions and 1 deletions
173
api/routers/intake.py
Normal file
173
api/routers/intake.py
Normal file
|
|
@ -0,0 +1,173 @@
|
|||
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
|
||||
Loading…
Add table
Add a link
Reference in a new issue