feat: add evaluated_only filter to GET /api/v2/refractor/cards (#174)

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 <noreply@anthropic.com>
This commit is contained in:
Cal Corum 2026-03-25 17:32:59 -05:00
parent 7e7ff960e2
commit 537eabcc4d
2 changed files with 94 additions and 7 deletions

View File

@ -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):

View File

@ -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