Founder-reviewed decision (WorkOrchestrationArchitectureDraft.md v0.2 section 8 item 6): the fresh intake work-record entity replaces suggestions, not a rename-bridge. All 5 mutation endpoints (create, vet, decline, promote, bump-relevance) now 410 with a pointer to POST /intakes/ and the migration doc; GET/list stay live for the historical record (10 rows migrated to file-backed intake records in the-custodian, see that repo's intake-legacy-suggestions-migration.md and CUST-IN-0001 through CUST-IN-0010). Removed dead code the retirement makes unreachable: Task/TaskPriority/ TaskStatus/normalize_task_status imports (only used by the deleted promote body), the suggestion_relevance.bump_relevance import, and the _ALLOWED_*_FROM stage-guard sets + _reject_stage helper (only used by the deleted vet/decline/promote bodies). WSJF ranking (compute_wsjf, cost_of_delay, suggestion_sort_key) stays -- still exercised by the surviving GET /suggestions/?rank=wsjf read path. MCP tool docstrings (create_suggestion, vet_suggestion, decline_suggestion, promote_suggestion_to_task, bump_suggestion_relevance) updated to point at the replacement (create_intake/route_intake/close_intake) rather than silently 410ing with no guidance. tests/test_suggestions.py rewritten: verifies all 5 mutations 410, GET/list still work for historical rows (seeded directly via the DB session since creation is retired -- there's no other way to get historical data into the table anymore), 404 still behaves normally on unknown ids. Live-verified against the running dev API: POST 410s, GET with include_terminal=true still returns all 10 migrated-and-declined historical rows. No regressions: full repo suite green (563 tests). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
177 lines
No EOL
5.9 KiB
Python
177 lines
No EOL
5.9 KiB
Python
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.schemas.suggestion import (
|
|
SuggestionBumpRelevance,
|
|
SuggestionCreate,
|
|
SuggestionDecline,
|
|
SuggestionNoteRead,
|
|
SuggestionPromote,
|
|
SuggestionRead,
|
|
SuggestionVet,
|
|
)
|
|
from api.services.suggestion_wsjf import compute_wsjf, cost_of_delay, suggestion_sort_key
|
|
|
|
router = APIRouter(prefix="/suggestions", tags=["suggestions"])
|
|
|
|
|
|
def _retired() -> HTTPException:
|
|
"""CUST-WP-0061-T06 (2026-07-21): suggestions are read-only legacy.
|
|
Reads (GET) stay live for the historical record; every mutation is
|
|
retired. Use the intake work-record entity instead
|
|
(canon/standards/work-record-types_v0.1.md)."""
|
|
return HTTPException(
|
|
status_code=410,
|
|
detail=(
|
|
"suggestions are retired (read-only legacy, CUST-WP-0061-T06). "
|
|
"Use POST /intakes/ instead — see canon/standards/"
|
|
"work-record-types_v0.1.md and the-custodian/"
|
|
"intake-legacy-suggestions-migration.md."
|
|
),
|
|
)
|
|
|
|
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
|
|
|
|
|
|
@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:
|
|
"""Retired (CUST-WP-0061-T06, 2026-07-21): suggestions are read-only
|
|
legacy. Use `POST /intakes/` (kind: intake per
|
|
canon/standards/work-record-types_v0.1.md) instead — a fresh entity,
|
|
not a rename of this table. The 10 records that were still open at
|
|
retirement were migrated to file-backed intake records; see
|
|
the-custodian/intake-legacy-suggestions-migration.md."""
|
|
raise _retired()
|
|
|
|
|
|
@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:
|
|
"""Retired (CUST-WP-0061-T06). See _retired()."""
|
|
raise _retired()
|
|
|
|
|
|
@router.post("/{suggestion_id}/decline", response_model=SuggestionRead)
|
|
async def decline_suggestion(
|
|
suggestion_id: uuid.UUID,
|
|
body: SuggestionDecline,
|
|
session: AsyncSession = Depends(get_session),
|
|
) -> SuggestionRead:
|
|
"""Retired (CUST-WP-0061-T06). See _retired()."""
|
|
raise _retired()
|
|
|
|
|
|
@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:
|
|
"""Retired (CUST-WP-0061-T06). See _retired()."""
|
|
raise _retired()
|
|
|
|
|
|
@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:
|
|
"""Retired (CUST-WP-0061-T06). See _retired()."""
|
|
raise _retired()
|
|
|
|
|
|
@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()) |