diff --git a/app/db_engine.py b/app/db_engine.py index ce4e999..3e0c786 100644 --- a/app/db_engine.py +++ b/app/db_engine.py @@ -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) diff --git a/app/routers_v2/refractor.py b/app/routers_v2/refractor.py index 45497ee..da795f3 100644 --- a/app/routers_v2/refractor.py +++ b/app/routers_v2/refractor.py @@ -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)) diff --git a/migrations/2026-03-25_add_refractor_card_state_team_index.sql b/migrations/2026-03-25_add_refractor_card_state_team_index.sql new file mode 100644 index 0000000..8dbc78d --- /dev/null +++ b/migrations/2026-03-25_add_refractor_card_state_team_index.sql @@ -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;