fix(v2): reorder /settlements/stuck before /settlements/{id} (route literal vs path-param collision)

FastAPI matches routes in declaration order. The literal /settlements/stuck
was being shadowed by /settlements/{settlement_id} declared earlier, so
GET /settlements/stuck was matching settlement_id="stuck" and 404'ing
with "Settlement not found". Caught while clicking through the v2 UI
post-reinstall: the Worklist tab couldn't load.

Fix: declare the literal sub-route first. Also added a NOTE comment
above the section so a future re-shuffle re-checks the order before
landing.

Verified routes register in correct order (line numbers in views_api.py):
  /settlements         (404)
  /settlements/stuck   (433)  ← literal
  /settlements/{id}    (463)  ← path-param
  /settlements/{id}/partial-dispense (478)
  /settlements/{id}/force-reset      (513)
  /settlements/{id}/retry            (565)
  /settlements/{id}/notes            (600)

76/76 tests pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Padreug 2026-05-14 19:22:10 +02:00
commit 32484e3ce8

View file

@ -422,6 +422,43 @@ async def api_list_settlements_for_machine(
return await get_settlements_for_machine(machine_id)
# NOTE on route ordering: FastAPI matches in declaration order. The literal
# /settlements/stuck must be registered BEFORE /settlements/{settlement_id}
# so the literal wins. Same applies to any future literal sub-route under
# /settlements/* (don't reshuffle this section without re-confirming the
# order).
@satmachineadmin_api_router.get(
"/api/v1/dca/settlements/stuck", response_model=StuckSettlementsResponse
)
async def api_list_stuck_settlements(
threshold_minutes: int = 30,
user: User = Depends(check_user_exists),
) -> StuckSettlementsResponse:
"""Operator worklist of settlements that didn't process cleanly.
Returns three lists:
- errored: distribution failed; retry endpoint handles these
- stuck_pending: landed but never picked up by the processor
- stuck_processing: claim taken but no completion in N minutes
`threshold_minutes` controls the age threshold for 'stuck' (default 30).
Operators can force-recover stuck-processing settlements via
POST /api/v1/dca/settlements/{id}/force-reset."""
if threshold_minutes < 1:
raise HTTPException(
HTTPStatus.BAD_REQUEST, "threshold_minutes must be >= 1"
)
buckets = await get_stuck_settlements_for_operator(user.id, threshold_minutes)
return StuckSettlementsResponse(
threshold_minutes=threshold_minutes,
errored=buckets["errored"],
stuck_pending=buckets["stuck_pending"],
stuck_processing=buckets["stuck_processing"],
)
@satmachineadmin_api_router.get(
"/api/v1/dca/settlements/{settlement_id}", response_model=DcaSettlement
)
@ -472,36 +509,6 @@ async def api_partial_dispense(
raise HTTPException(HTTPStatus.BAD_REQUEST, str(exc)) from exc
@satmachineadmin_api_router.get(
"/api/v1/dca/settlements/stuck", response_model=StuckSettlementsResponse
)
async def api_list_stuck_settlements(
threshold_minutes: int = 30,
user: User = Depends(check_user_exists),
) -> StuckSettlementsResponse:
"""Operator worklist of settlements that didn't process cleanly.
Returns three lists:
- errored: distribution failed; retry endpoint handles these
- stuck_pending: landed but never picked up by the processor
- stuck_processing: claim taken but no completion in N minutes
`threshold_minutes` controls the age threshold for 'stuck' (default 30).
Operators can force-recover stuck-processing settlements via
POST /api/v1/dca/settlements/{id}/force-reset."""
if threshold_minutes < 1:
raise HTTPException(
HTTPStatus.BAD_REQUEST, "threshold_minutes must be >= 1"
)
buckets = await get_stuck_settlements_for_operator(user.id, threshold_minutes)
return StuckSettlementsResponse(
threshold_minutes=threshold_minutes,
errored=buckets["errored"],
stuck_pending=buckets["stuck_pending"],
stuck_processing=buckets["stuck_processing"],
)
@satmachineadmin_api_router.post(
"/api/v1/dca/settlements/{settlement_id}/force-reset",
response_model=DcaSettlement,