feat: add GET /api/v2/refractor/cards list endpoint (#172)
Closes #172 - New GET /api/v2/refractor/cards endpoint in refractor router with team_id (required), card_type, tier, season, progress, limit, offset filters - season filter uses EXISTS subquery against batting/pitching_season_stats - progress=close filter uses CASE expression to compare current_value against next tier threshold (>= 80%) - LEFT JOIN on Player so deleted players return player_name: null - Sorting: current_tier DESC, current_value DESC - count reflects total matching rows before pagination - Extended _build_card_state_response() with progress_pct (computed) and optional player_name; single-card endpoint gains progress_pct automatically - Added non-unique team_id index on refractor_card_state in db_engine.py - Migration: 2026-03-25_add_refractor_card_state_team_index.sql - Removed pre-existing unused RefractorTrack import in evaluate_game (ruff) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
de9b511ae9
commit
0b5d0b474b
@ -1245,6 +1245,13 @@ refractor_card_state_index = ModelIndex(
|
||||
)
|
||||
RefractorCardState.add_index(refractor_card_state_index)
|
||||
|
||||
refractor_card_state_team_index = ModelIndex(
|
||||
RefractorCardState,
|
||||
(RefractorCardState.team,),
|
||||
unique=False,
|
||||
)
|
||||
RefractorCardState.add_index(refractor_card_state_team_index)
|
||||
|
||||
|
||||
class RefractorTierBoost(BaseModel):
|
||||
track = ForeignKeyField(RefractorTrack)
|
||||
|
||||
@ -21,14 +21,15 @@ _NEXT_THRESHOLD_ATTR = {
|
||||
}
|
||||
|
||||
|
||||
def _build_card_state_response(state) -> dict:
|
||||
def _build_card_state_response(state, player_name=None) -> dict:
|
||||
"""Serialise a RefractorCardState into the standard API response shape.
|
||||
|
||||
Produces a flat dict with player_id and team_id as plain integers,
|
||||
a nested 'track' dict with all threshold fields, and a computed
|
||||
'next_threshold' field:
|
||||
- For tiers 0-3: the threshold value for the tier immediately above.
|
||||
- For tier 4 (fully evolved): None.
|
||||
a nested 'track' dict with all threshold fields, and computed fields:
|
||||
- 'next_threshold': threshold for the tier immediately above (None when fully evolved).
|
||||
- 'progress_pct': current_value / next_threshold * 100, rounded to 1 decimal
|
||||
(None when fully evolved or next_threshold is zero).
|
||||
- 'player_name': included when passed (e.g. from a list join); omitted otherwise.
|
||||
|
||||
Uses model_to_dict(recurse=False) internally so FK fields are returned
|
||||
as IDs rather than nested objects, then promotes the needed IDs up to
|
||||
@ -40,7 +41,11 @@ def _build_card_state_response(state) -> dict:
|
||||
next_attr = _NEXT_THRESHOLD_ATTR.get(state.current_tier)
|
||||
next_threshold = getattr(track, next_attr) if next_attr else None
|
||||
|
||||
return {
|
||||
progress_pct = None
|
||||
if next_threshold is not None and next_threshold > 0:
|
||||
progress_pct = round((state.current_value / next_threshold) * 100, 1)
|
||||
|
||||
result = {
|
||||
"player_id": state.player_id,
|
||||
"team_id": state.team_id,
|
||||
"current_tier": state.current_tier,
|
||||
@ -51,8 +56,14 @@ def _build_card_state_response(state) -> dict:
|
||||
),
|
||||
"track": track_dict,
|
||||
"next_threshold": next_threshold,
|
||||
"progress_pct": progress_pct,
|
||||
}
|
||||
|
||||
if player_name is not None:
|
||||
result["player_name"] = player_name
|
||||
|
||||
return result
|
||||
|
||||
|
||||
@router.get("/tracks")
|
||||
async def list_tracks(
|
||||
@ -89,6 +100,118 @@ async def get_track(track_id: int, token: str = Depends(oauth2_scheme)):
|
||||
return model_to_dict(track, recurse=False)
|
||||
|
||||
|
||||
@router.get("/cards")
|
||||
async def list_card_states(
|
||||
team_id: int = Query(...),
|
||||
card_type: Optional[str] = Query(default=None),
|
||||
tier: Optional[int] = Query(default=None, ge=0, le=4),
|
||||
season: Optional[int] = Query(default=None),
|
||||
progress: Optional[str] = Query(default=None),
|
||||
limit: int = Query(default=10, ge=1, le=100),
|
||||
offset: int = Query(default=0, ge=0),
|
||||
token: str = Depends(oauth2_scheme),
|
||||
):
|
||||
"""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
|
||||
|
||||
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
|
||||
|
||||
Pagination:
|
||||
limit -- page size (1-100, default 10)
|
||||
offset -- items to skip (default 0)
|
||||
|
||||
Response: {"count": N, "items": [...]}
|
||||
count is the total matching rows before limit/offset.
|
||||
Each item includes player_name and progress_pct in addition to the
|
||||
standard single-card response fields.
|
||||
|
||||
Sort order: current_tier DESC, current_value DESC.
|
||||
"""
|
||||
if not valid_token(token):
|
||||
logging.warning("Bad Token: [REDACTED]")
|
||||
raise HTTPException(status_code=401, detail="Unauthorized")
|
||||
|
||||
from ..db_engine import (
|
||||
RefractorCardState,
|
||||
RefractorTrack,
|
||||
Player,
|
||||
BattingSeasonStats,
|
||||
PitchingSeasonStats,
|
||||
fn,
|
||||
Case,
|
||||
JOIN,
|
||||
)
|
||||
|
||||
query = (
|
||||
RefractorCardState.select(RefractorCardState, RefractorTrack, Player)
|
||||
.join(RefractorTrack)
|
||||
.switch(RefractorCardState)
|
||||
.join(
|
||||
Player, JOIN.LEFT_OUTER, on=(RefractorCardState.player == Player.player_id)
|
||||
)
|
||||
.where(RefractorCardState.team == team_id)
|
||||
.order_by(
|
||||
RefractorCardState.current_tier.desc(),
|
||||
RefractorCardState.current_value.desc(),
|
||||
)
|
||||
)
|
||||
|
||||
if card_type is not None:
|
||||
query = query.where(RefractorTrack.card_type == card_type)
|
||||
|
||||
if tier is not None:
|
||||
query = query.where(RefractorCardState.current_tier == tier)
|
||||
|
||||
if season is not None:
|
||||
batter_exists = BattingSeasonStats.select().where(
|
||||
(BattingSeasonStats.player == RefractorCardState.player)
|
||||
& (BattingSeasonStats.team == RefractorCardState.team)
|
||||
& (BattingSeasonStats.season == season)
|
||||
)
|
||||
pitcher_exists = PitchingSeasonStats.select().where(
|
||||
(PitchingSeasonStats.player == RefractorCardState.player)
|
||||
& (PitchingSeasonStats.team == RefractorCardState.team)
|
||||
& (PitchingSeasonStats.season == season)
|
||||
)
|
||||
query = query.where(fn.EXISTS(batter_exists) | fn.EXISTS(pitcher_exists))
|
||||
|
||||
if progress == "close":
|
||||
next_threshold_expr = Case(
|
||||
RefractorCardState.current_tier,
|
||||
(
|
||||
(0, RefractorTrack.t1_threshold),
|
||||
(1, RefractorTrack.t2_threshold),
|
||||
(2, RefractorTrack.t3_threshold),
|
||||
(3, RefractorTrack.t4_threshold),
|
||||
),
|
||||
None,
|
||||
)
|
||||
query = query.where(
|
||||
(RefractorCardState.fully_evolved == False) # noqa: E712
|
||||
& (RefractorCardState.current_value >= next_threshold_expr * 0.8)
|
||||
)
|
||||
|
||||
total = query.count()
|
||||
items = []
|
||||
for state in query.offset(offset).limit(limit):
|
||||
player_name = None
|
||||
try:
|
||||
player_name = state.player.p_name
|
||||
except Exception:
|
||||
pass
|
||||
items.append(_build_card_state_response(state, player_name=player_name))
|
||||
|
||||
return {"count": total, "items": items}
|
||||
|
||||
|
||||
@router.get("/cards/{card_id}")
|
||||
async def get_card_state(card_id: int, token: str = Depends(oauth2_scheme)):
|
||||
"""Return the RefractorCardState for a card identified by its Card.id.
|
||||
@ -175,7 +298,7 @@ async def evaluate_game(game_id: int, token: str = Depends(oauth2_scheme)):
|
||||
logging.warning("Bad Token: [REDACTED]")
|
||||
raise HTTPException(status_code=401, detail="Unauthorized")
|
||||
|
||||
from ..db_engine import RefractorCardState, RefractorTrack, Player, StratPlay
|
||||
from ..db_engine import RefractorCardState, Player, StratPlay
|
||||
from ..services.refractor_evaluator import evaluate_card
|
||||
|
||||
plays = list(StratPlay.select().where(StratPlay.game == game_id))
|
||||
|
||||
@ -0,0 +1,19 @@
|
||||
-- Migration: Add team_id index to refractor_card_state
|
||||
-- Date: 2026-03-25
|
||||
--
|
||||
-- Adds a non-unique index on refractor_card_state.team_id to support the new
|
||||
-- GET /api/v2/refractor/cards list endpoint, which filters by team as its
|
||||
-- primary discriminator and is called on every /refractor status bot command.
|
||||
--
|
||||
-- The existing unique index is on (player_id, team_id) with player leading,
|
||||
-- so team-only queries cannot use it efficiently.
|
||||
|
||||
BEGIN;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_refractor_card_state_team
|
||||
ON refractor_card_state (team_id);
|
||||
|
||||
COMMIT;
|
||||
|
||||
-- Rollback:
|
||||
-- DROP INDEX IF EXISTS idx_refractor_card_state_team;
|
||||
Loading…
Reference in New Issue
Block a user