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