From 537eabcc4d921bb29490ab1f6f19968f23865bc8 Mon Sep 17 00:00:00 2001 From: Cal Corum Date: Wed, 25 Mar 2026 17:32:59 -0500 Subject: [PATCH] feat: add evaluated_only filter to GET /api/v2/refractor/cards (#174) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes #174 Adds `evaluated_only: bool = Query(default=True)` to `list_card_states()`. When True (the default), cards with `last_evaluated_at IS NULL` are excluded — these are placeholder rows created at pack-open time but never run through the evaluator. At team scale this eliminates ~2739 zero-value rows from the default response, making the Discord /refractor status command efficient without any bot-side changes. Set `evaluated_only=false` to include all rows (admin/pipeline use case). Co-Authored-By: Claude Sonnet 4.6 --- app/routers_v2/refractor.py | 21 +++++--- tests/test_refractor_state_api.py | 80 +++++++++++++++++++++++++++++++ 2 files changed, 94 insertions(+), 7 deletions(-) diff --git a/app/routers_v2/refractor.py b/app/routers_v2/refractor.py index da795f3..b9a15a0 100644 --- a/app/routers_v2/refractor.py +++ b/app/routers_v2/refractor.py @@ -107,6 +107,7 @@ async def list_card_states( tier: Optional[int] = Query(default=None, ge=0, le=4), season: Optional[int] = Query(default=None), progress: Optional[str] = Query(default=None), + evaluated_only: bool = Query(default=True), limit: int = Query(default=10, ge=1, le=100), offset: int = Query(default=0, ge=0), token: str = Depends(oauth2_scheme), @@ -114,15 +115,18 @@ async def list_card_states( """List RefractorCardState rows for a team, with optional filters and pagination. Required: - team_id -- filter to this team's cards; returns empty list if team has no states + team_id -- filter to this team's cards; returns empty list if team has no states Optional filters: - card_type -- one of 'batter', 'sp', 'rp'; filters by RefractorTrack.card_type - tier -- filter by current_tier (0-4) - season -- filter to players who have batting or pitching season stats in that - season (EXISTS subquery against batting/pitching_season_stats) - progress -- 'close' = only cards within 80% of their next tier threshold; - fully evolved cards are always excluded from this filter + card_type -- one of 'batter', 'sp', 'rp'; filters by RefractorTrack.card_type + tier -- filter by current_tier (0-4) + season -- filter to players who have batting or pitching season stats in that + season (EXISTS subquery against batting/pitching_season_stats) + progress -- 'close' = only cards within 80% of their next tier threshold; + fully evolved cards are always excluded from this filter + evaluated_only -- default True; when True, excludes cards where last_evaluated_at + is NULL (cards created but never run through the evaluator). + Set to False to include all rows, including zero-value placeholders. Pagination: limit -- page size (1-100, default 10) @@ -199,6 +203,9 @@ async def list_card_states( & (RefractorCardState.current_value >= next_threshold_expr * 0.8) ) + if evaluated_only: + query = query.where(RefractorCardState.last_evaluated_at.is_null(False)) + total = query.count() items = [] for state in query.offset(offset).limit(limit): diff --git a/tests/test_refractor_state_api.py b/tests/test_refractor_state_api.py index 75571b5..9c81154 100644 --- a/tests/test_refractor_state_api.py +++ b/tests/test_refractor_state_api.py @@ -956,3 +956,83 @@ def test_get_card_state_last_evaluated_at_null(setup_state_api_db, state_api_cli f"Expected last_evaluated_at=null for un-evaluated card, " f"got {data['last_evaluated_at']!r}" ) + + +# --------------------------------------------------------------------------- +# T3-8: GET /refractor/cards?team_id=X&evaluated_only=false includes un-evaluated +# --------------------------------------------------------------------------- + + +def test_list_cards_evaluated_only_false_includes_unevaluated( + setup_state_api_db, state_api_client +): + """GET /refractor/cards?team_id=X&evaluated_only=false returns cards with last_evaluated_at=NULL. + + What: Create two RefractorCardState rows for the same team — one with + last_evaluated_at=None (never evaluated) and one with last_evaluated_at set + to a timestamp (has been evaluated). Call the endpoint twice: + 1. evaluated_only=true (default) — only the evaluated card appears. + 2. evaluated_only=false — both cards appear. + + Why: The default evaluated_only=True filter uses + `last_evaluated_at IS NOT NULL` to exclude placeholder rows created at + pack-open time but never run through the evaluator. At team scale (2753 + rows, ~14 evaluated) this filter is critical for bot performance. + This test verifies the opt-out path (evaluated_only=false) exposes all rows, + which is needed for admin/pipeline use cases. + """ + from datetime import datetime, timezone + + team = _sa_make_team("SA_T38", gmid=30380) + track = _sa_make_track("batter") + + # Card 1: never evaluated — last_evaluated_at is NULL + player_unevaluated = _sa_make_player("T38 Unevaluated", pos="1B") + card_unevaluated = _sa_make_card(player_unevaluated, team) # noqa: F841 + RefractorCardState.create( + player=player_unevaluated, + team=team, + track=track, + current_tier=0, + current_value=0.0, + fully_evolved=False, + last_evaluated_at=None, + ) + + # Card 2: has been evaluated — last_evaluated_at is a timestamp + player_evaluated = _sa_make_player("T38 Evaluated", pos="RF") + card_evaluated = _sa_make_card(player_evaluated, team) # noqa: F841 + RefractorCardState.create( + player=player_evaluated, + team=team, + track=track, + current_tier=1, + current_value=5.0, + fully_evolved=False, + last_evaluated_at=datetime(2025, 4, 1, tzinfo=timezone.utc), + ) + + # Default (evaluated_only=true) — only the evaluated card should appear + resp_default = state_api_client.get( + f"/api/v2/refractor/cards?team_id={team.id}", headers=AUTH_HEADER + ) + assert resp_default.status_code == 200 + data_default = resp_default.json() + assert data_default["count"] == 1, ( + f"evaluated_only=true should return 1 card, got {data_default['count']}" + ) + assert data_default["items"][0]["player_name"] == "T38 Evaluated" + + # evaluated_only=false — both cards should appear + resp_all = state_api_client.get( + f"/api/v2/refractor/cards?team_id={team.id}&evaluated_only=false", + headers=AUTH_HEADER, + ) + assert resp_all.status_code == 200 + data_all = resp_all.json() + assert data_all["count"] == 2, ( + f"evaluated_only=false should return 2 cards, got {data_all['count']}" + ) + names = {item["player_name"] for item in data_all["items"]} + assert "T38 Unevaluated" in names + assert "T38 Evaluated" in names