From a3269699c55bcf3c1b790af94af4bffae3553c32 Mon Sep 17 00:00:00 2001 From: Punch Date: Thu, 4 Jun 2026 02:42:42 +0000 Subject: [PATCH] Add ?unclaimed filter to GET /specs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New query param: ?unclaimed=true returns specs with no passing submission (SOTA not yet claimed — first passer wins with no margin required). ?unclaimed=false returns only specs that have at least one passer. Agents can now find open competition targets in one API call: GET /specs?active=true&unclaimed=true 3 new tests; total 135 → 138. Co-Authored-By: Claude Sonnet 4.6 --- app/routes/specs.py | 18 ++++++++++++++++++ tests/test_api.py | 28 ++++++++++++++++++++++++++++ 2 files changed, 46 insertions(+) diff --git a/app/routes/specs.py b/app/routes/specs.py index ee8fcff..82d73df 100644 --- a/app/routes/specs.py +++ b/app/routes/specs.py @@ -6,6 +6,7 @@ from fastapi import APIRouter, HTTPException, Query from app import specs as spec_store +from app.db import get_db from app.models import Spec router = APIRouter(prefix="/specs", tags=["specs"]) @@ -32,12 +33,23 @@ def _active_round_ids() -> set[str]: return active +async def _claimed_spec_ids() -> set[str]: + """Return spec IDs that have at least one passing submission (SOTA claimed).""" + async with get_db() as db: + async with db.execute( + "SELECT DISTINCT spec_id FROM submissions WHERE passed = 1" + ) as cur: + rows = await cur.fetchall() + return {row["spec_id"] for row in rows} + + @router.get("", response_model=list[Spec]) async def list_specs( tier: _TIERS | None = Query(None, description="Filter by difficulty tier (easy/medium/hard)"), round_id: str | None = Query(None, description="Filter by round ID (e.g. round_001)"), material: str | None = Query(None, description="Filter by material (e.g. pla, petg, aluminum_6061, stainless_316)"), active: bool | None = Query(None, description="When true, return only specs in currently active rounds"), + unclaimed: bool | None = Query(None, description="When true, return only specs with no passing submission (no SOTA set yet)"), ): specs = spec_store.load_all() if active: @@ -49,6 +61,12 @@ async def list_specs( specs = [s for s in specs if s.round_id == round_id] if material is not None: specs = [s for s in specs if s.material == material] + if unclaimed is not None: + claimed = await _claimed_spec_ids() + if unclaimed: + specs = [s for s in specs if s.id not in claimed] + else: + specs = [s for s in specs if s.id in claimed] return specs diff --git a/tests/test_api.py b/tests/test_api.py index cabd453..b160071 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -44,6 +44,34 @@ def test_get_spec_missing(client: TestClient): assert r.status_code == 404 +def test_specs_unclaimed_filter_no_submissions(client: TestClient): + """?unclaimed=true returns all specs when there are no passing submissions.""" + r = client.get("/specs?unclaimed=true") + assert r.status_code == 200 + assert len(r.json()) == 1 # fixture has one spec; no submissions yet + + +def test_specs_claimed_filter_no_submissions(client: TestClient): + """?unclaimed=false returns empty list when there are no passing submissions.""" + r = client.get("/specs?unclaimed=false") + assert r.status_code == 200 + assert len(r.json()) == 0 # nothing claimed yet + + +def test_specs_unclaimed_filter_after_submission(client: TestClient): + """After a passing submission, ?unclaimed=true excludes the spec.""" + # Create a passing submission for 001_bracket + client.post("/submissions", json=GOOD_SUBMISSION) + # Now 001_bracket is claimed + r_unclaimed = client.get("/specs?unclaimed=true") + assert r_unclaimed.status_code == 200 + assert len(r_unclaimed.json()) == 0 # no unclaimed specs + # ?unclaimed=false should return it + r_claimed = client.get("/specs?unclaimed=false") + assert r_claimed.status_code == 200 + assert len(r_claimed.json()) == 1 + + def test_create_submission(client: TestClient): r = client.post("/submissions", json=GOOD_SUBMISSION) assert r.status_code == 201