import uuid from fastapi import APIRouter, Depends, HTTPException, Query, status from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from api.database import get_session from api.models.domain import Domain from api.models.suggestion import ( OPEN_SUGGESTION_STAGES, Suggestion, SuggestionNote, SuggestionStage, ) from api.models.task import Task, TaskPriority, TaskStatus from api.schemas.suggestion import ( SuggestionBumpRelevance, SuggestionCreate, SuggestionDecline, SuggestionNoteRead, SuggestionPromote, SuggestionRead, SuggestionVet, ) from api.services.suggestion_relevance import bump_relevance from api.services.suggestion_wsjf import compute_wsjf, cost_of_delay, suggestion_sort_key from api.task_status import normalize_task_status router = APIRouter(prefix="/suggestions", tags=["suggestions"]) _ALLOWED_VET_FROM = {SuggestionStage.suggestion} _ALLOWED_DECLINE_FROM = {SuggestionStage.suggestion, SuggestionStage.requirement} _ALLOWED_PROMOTE_FROM = {SuggestionStage.requirement} async def _resolve_domain_id(slug: str, session: AsyncSession) -> uuid.UUID: row = await session.execute( select(Domain.id).where(Domain.slug == slug, Domain.status == "active") ) domain_id = row.scalar_one_or_none() if domain_id is None: valid = [r[0] for r in (await session.execute( select(Domain.slug).where(Domain.status == "active") )).all()] raise HTTPException( status_code=422, detail=f"Unknown domain '{slug}'. Valid domains: {sorted(valid)}", ) return domain_id def _enrich_read(suggestion: Suggestion) -> SuggestionRead: data = SuggestionRead.model_validate(suggestion) data.cost_of_delay = cost_of_delay(suggestion) data.wsjf = compute_wsjf(suggestion) return data async def _get_suggestion_or_404( suggestion_id: uuid.UUID, session: AsyncSession, ) -> Suggestion: suggestion = await session.get(Suggestion, suggestion_id) if suggestion is None: raise HTTPException(status_code=404, detail="Suggestion not found") return suggestion def _reject_stage(suggestion: Suggestion, allowed: set[SuggestionStage], action: str) -> None: if suggestion.stage not in allowed: raise HTTPException( status_code=409, detail=f"Cannot {action} suggestion in stage '{suggestion.stage.value}'", ) @router.get("/", response_model=list[SuggestionRead]) async def list_suggestions( domain: str | None = None, stage: SuggestionStage | None = None, include_terminal: bool = Query(False), rank: str | None = Query(None), limit: int = Query(100, ge=1, le=500), session: AsyncSession = Depends(get_session), ) -> list[SuggestionRead]: q = select(Suggestion) if domain: domain_id = await _resolve_domain_id(domain, session) q = q.where(Suggestion.domain_id == domain_id) if stage: q = q.where(Suggestion.stage == stage) elif not include_terminal: q = q.where(Suggestion.stage.in_(OPEN_SUGGESTION_STAGES)) result = await session.execute(q) suggestions = list(result.scalars().all()) if rank == "wsjf": suggestions.sort(key=suggestion_sort_key) else: suggestions.sort(key=lambda s: s.created_at) return [_enrich_read(s) for s in suggestions[:limit]] @router.post("/", response_model=SuggestionRead, status_code=status.HTTP_201_CREATED) async def create_suggestion( body: SuggestionCreate, session: AsyncSession = Depends(get_session), ) -> SuggestionRead: domain_id = await _resolve_domain_id(body.domain, session) suggestion = Suggestion( domain_id=domain_id, topic_id=body.topic_id, workplan_id=body.workplan_id, title=body.title, description=body.description, origin=body.origin, origin_ref=body.origin_ref, base_value=body.base_value, job_size=body.job_size, relevance_weight=body.relevance_weight, ) session.add(suggestion) await session.commit() await session.refresh(suggestion) return _enrich_read(suggestion) @router.get("/{suggestion_id}", response_model=SuggestionRead) async def get_suggestion( suggestion_id: uuid.UUID, session: AsyncSession = Depends(get_session), ) -> SuggestionRead: suggestion = await _get_suggestion_or_404(suggestion_id, session) return _enrich_read(suggestion) @router.post("/{suggestion_id}/vet", response_model=SuggestionRead) async def vet_suggestion( suggestion_id: uuid.UUID, body: SuggestionVet, session: AsyncSession = Depends(get_session), ) -> SuggestionRead: suggestion = await _get_suggestion_or_404(suggestion_id, session) _reject_stage(suggestion, _ALLOWED_VET_FROM, "vet") suggestion.stage = SuggestionStage.requirement if body.base_value is not None: suggestion.base_value = body.base_value if body.job_size is not None: suggestion.job_size = body.job_size if body.relevance_weight is not None: suggestion.relevance_weight = body.relevance_weight if body.workplan_id is not None: suggestion.workplan_id = body.workplan_id session.add( SuggestionNote( suggestion_id=suggestion.id, stage=SuggestionStage.requirement.value, author=body.author, content=body.note, ) ) await session.commit() await session.refresh(suggestion) return _enrich_read(suggestion) @router.post("/{suggestion_id}/decline", response_model=SuggestionRead) async def decline_suggestion( suggestion_id: uuid.UUID, body: SuggestionDecline, session: AsyncSession = Depends(get_session), ) -> SuggestionRead: suggestion = await _get_suggestion_or_404(suggestion_id, session) _reject_stage(suggestion, _ALLOWED_DECLINE_FROM, "decline") suggestion.stage = SuggestionStage.declined session.add( SuggestionNote( suggestion_id=suggestion.id, stage=SuggestionStage.declined.value, author=body.author, content=body.note, ) ) await session.commit() await session.refresh(suggestion) return _enrich_read(suggestion) @router.post("/{suggestion_id}/promote", response_model=SuggestionRead) async def promote_suggestion_to_task( suggestion_id: uuid.UUID, body: SuggestionPromote, session: AsyncSession = Depends(get_session), ) -> SuggestionRead: suggestion = await _get_suggestion_or_404(suggestion_id, session) _reject_stage(suggestion, _ALLOWED_PROMOTE_FROM, "promote") if suggestion.workplan_id is None: raise HTTPException( status_code=409, detail="Suggestion must have workplan_id before promotion", ) task = Task( workplan_id=suggestion.workplan_id, title=body.task_title or suggestion.title, description=body.task_description or suggestion.description, status=TaskStatus(normalize_task_status(body.task_status)), priority=TaskPriority(body.task_priority), ) session.add(task) await session.flush() suggestion.stage = SuggestionStage.promoted suggestion.promoted_task_id = task.id if body.note: session.add( SuggestionNote( suggestion_id=suggestion.id, stage=SuggestionStage.promoted.value, author=body.author, content=body.note, ) ) await session.commit() await session.refresh(suggestion) return _enrich_read(suggestion) @router.post("/{suggestion_id}/bump-relevance", response_model=SuggestionRead) async def bump_suggestion_relevance( suggestion_id: uuid.UUID, body: SuggestionBumpRelevance, session: AsyncSession = Depends(get_session), ) -> SuggestionRead: suggestion = await _get_suggestion_or_404(suggestion_id, session) source_key = body.author or "explicit" await bump_relevance( session, suggestion, source="explicit", source_key=source_key, reason=body.reason, ) await session.commit() await session.refresh(suggestion) return _enrich_read(suggestion) @router.get("/{suggestion_id}/notes", response_model=list[SuggestionNoteRead]) async def list_suggestion_notes( suggestion_id: uuid.UUID, session: AsyncSession = Depends(get_session), ) -> list[SuggestionNote]: await _get_suggestion_or_404(suggestion_id, session) result = await session.execute( select(SuggestionNote) .where(SuggestionNote.suggestion_id == suggestion_id) .order_by(SuggestionNote.created_at) ) return list(result.scalars().all())