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