From b7dec3f23155fe32283cbd20412656c6e5b0e65b Mon Sep 17 00:00:00 2001 From: Cal Corum Date: Mon, 23 Mar 2026 13:31:55 -0500 Subject: [PATCH 01/21] refactor: rename evolution system to refractor MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Complete rename of the card progression system from "Evolution" to "Refractor" across all code, routes, models, services, seeds, and tests. - Route prefix: /api/v2/evolution → /api/v2/refractor - Model classes: EvolutionTrack → RefractorTrack, etc. - 12 files renamed, 8 files content-edited - New migration to rename DB tables - 117 tests pass, no logic changes Co-Authored-By: Claude Opus 4.6 (1M context) --- app/db_engine.py | 44 ++++----- app/main.py | 4 +- app/routers_v2/cards.py | 39 +++++--- app/routers_v2/{evolution.py => refractor.py} | 58 ++++++------ app/routers_v2/teams.py | 23 +++-- ...tion_tracks.json => refractor_tracks.json} | 0 ...volution_tracks.py => refractor_tracks.py} | 24 ++--- app/services/formula_engine.py | 6 +- ...on_evaluator.py => refractor_evaluator.py} | 20 ++-- .../{evolution_init.py => refractor_init.py} | 36 +++---- ...26-03-23_rename_evolution_to_refractor.sql | 15 +++ tests/conftest.py | 22 ++--- tests/test_formula_engine.py | 2 +- ...volution.py => test_postgame_refractor.py} | 62 ++++++------ ...aluator.py => test_refractor_evaluator.py} | 16 ++-- ...olution_init.py => test_refractor_init.py} | 40 ++++---- ...ion_models.py => test_refractor_models.py} | 88 ++++++++--------- ...olution_seed.py => test_refractor_seed.py} | 94 +++++++++---------- ...ate_api.py => test_refractor_state_api.py} | 78 +++++++-------- ...ack_api.py => test_refractor_track_api.py} | 24 ++--- 20 files changed, 362 insertions(+), 333 deletions(-) rename app/routers_v2/{evolution.py => refractor.py} (79%) rename app/seed/{evolution_tracks.json => refractor_tracks.json} (100%) rename app/seed/{evolution_tracks.py => refractor_tracks.py} (72%) rename app/services/{evolution_evaluator.py => refractor_evaluator.py} (91%) rename app/services/{evolution_init.py => refractor_init.py} (74%) create mode 100644 migrations/2026-03-23_rename_evolution_to_refractor.sql rename tests/{test_postgame_evolution.py => test_postgame_refractor.py} (93%) rename tests/{test_evolution_evaluator.py => test_refractor_evaluator.py} (96%) rename tests/{test_evolution_init.py => test_refractor_init.py} (91%) rename tests/{test_evolution_models.py => test_refractor_models.py} (83%) rename tests/{test_evolution_seed.py => test_refractor_seed.py} (63%) rename tests/{test_evolution_state_api.py => test_refractor_state_api.py} (88%) rename tests/{test_evolution_track_api.py => test_refractor_track_api.py} (85%) diff --git a/app/db_engine.py b/app/db_engine.py index 0b44ed1..ce4e999 100644 --- a/app/db_engine.py +++ b/app/db_engine.py @@ -1210,7 +1210,7 @@ if not SKIP_TABLE_CREATION: db.create_tables([ScoutOpportunity, ScoutClaim], safe=True) -class EvolutionTrack(BaseModel): +class RefractorTrack(BaseModel): name = CharField(unique=True) card_type = CharField() # 'batter', 'sp', 'rp' formula = CharField() # e.g. "pa + tb * 2" @@ -1221,13 +1221,13 @@ class EvolutionTrack(BaseModel): class Meta: database = db - table_name = "evolution_track" + table_name = "refractor_track" -class EvolutionCardState(BaseModel): +class RefractorCardState(BaseModel): player = ForeignKeyField(Player) team = ForeignKeyField(Team) - track = ForeignKeyField(EvolutionTrack) + track = ForeignKeyField(RefractorTrack) current_tier = IntegerField(default=0) # 0-4 current_value = FloatField(default=0.0) fully_evolved = BooleanField(default=False) @@ -1235,19 +1235,19 @@ class EvolutionCardState(BaseModel): class Meta: database = db - table_name = "evolution_card_state" + table_name = "refractor_card_state" -evolution_card_state_index = ModelIndex( - EvolutionCardState, - (EvolutionCardState.player, EvolutionCardState.team), +refractor_card_state_index = ModelIndex( + RefractorCardState, + (RefractorCardState.player, RefractorCardState.team), unique=True, ) -EvolutionCardState.add_index(evolution_card_state_index) +RefractorCardState.add_index(refractor_card_state_index) -class EvolutionTierBoost(BaseModel): - track = ForeignKeyField(EvolutionTrack) +class RefractorTierBoost(BaseModel): + track = ForeignKeyField(RefractorTrack) tier = IntegerField() # 1-4 boost_type = CharField() # e.g. 'rating', 'stat' boost_target = CharField() # e.g. 'contact_vl', 'power_vr' @@ -1255,23 +1255,23 @@ class EvolutionTierBoost(BaseModel): class Meta: database = db - table_name = "evolution_tier_boost" + table_name = "refractor_tier_boost" -evolution_tier_boost_index = ModelIndex( - EvolutionTierBoost, +refractor_tier_boost_index = ModelIndex( + RefractorTierBoost, ( - EvolutionTierBoost.track, - EvolutionTierBoost.tier, - EvolutionTierBoost.boost_type, - EvolutionTierBoost.boost_target, + RefractorTierBoost.track, + RefractorTierBoost.tier, + RefractorTierBoost.boost_type, + RefractorTierBoost.boost_target, ), unique=True, ) -EvolutionTierBoost.add_index(evolution_tier_boost_index) +RefractorTierBoost.add_index(refractor_tier_boost_index) -class EvolutionCosmetic(BaseModel): +class RefractorCosmetic(BaseModel): name = CharField(unique=True) tier_required = IntegerField(default=0) cosmetic_type = CharField() # 'frame', 'badge', 'theme' @@ -1280,12 +1280,12 @@ class EvolutionCosmetic(BaseModel): class Meta: database = db - table_name = "evolution_cosmetic" + table_name = "refractor_cosmetic" if not SKIP_TABLE_CREATION: db.create_tables( - [EvolutionTrack, EvolutionCardState, EvolutionTierBoost, EvolutionCosmetic], + [RefractorTrack, RefractorCardState, RefractorTierBoost, RefractorCosmetic], safe=True, ) diff --git a/app/main.py b/app/main.py index 2949642..2e6de86 100644 --- a/app/main.py +++ b/app/main.py @@ -51,7 +51,7 @@ from .routers_v2 import ( # noqa: E402 stratplays, scout_opportunities, scout_claims, - evolution, + refractor, season_stats, ) @@ -107,7 +107,7 @@ app.include_router(stratplays.router) app.include_router(decisions.router) app.include_router(scout_opportunities.router) app.include_router(scout_claims.router) -app.include_router(evolution.router) +app.include_router(refractor.router) app.include_router(season_stats.router) diff --git a/app/routers_v2/cards.py b/app/routers_v2/cards.py index a8614fc..b814c51 100644 --- a/app/routers_v2/cards.py +++ b/app/routers_v2/cards.py @@ -4,9 +4,19 @@ import logging import pydantic from pandas import DataFrame -from ..db_engine import db, Card, model_to_dict, Team, Player, Pack, Paperdex, CARDSETS, DoesNotExist +from ..db_engine import ( + db, + Card, + model_to_dict, + Team, + Player, + Pack, + Paperdex, + CARDSETS, + DoesNotExist, +) from ..dependencies import oauth2_scheme, valid_token -from ..services.evolution_init import _determine_card_type, initialize_card_evolution +from ..services.refractor_init import _determine_card_type, initialize_card_evolution router = APIRouter(prefix="/api/v2/cards", tags=["cards"]) @@ -47,19 +57,25 @@ async def get_cards( try: this_team = Team.get_by_id(team_id) except DoesNotExist: - raise HTTPException(status_code=404, detail=f'No team found with id {team_id}') + raise HTTPException( + status_code=404, detail=f"No team found with id {team_id}" + ) all_cards = all_cards.where(Card.team == this_team) if player_id is not None: try: this_player = Player.get_by_id(player_id) except DoesNotExist: - raise HTTPException(status_code=404, detail=f'No player found with id {player_id}') + raise HTTPException( + status_code=404, detail=f"No player found with id {player_id}" + ) all_cards = all_cards.where(Card.player == this_player) if pack_id is not None: try: this_pack = Pack.get_by_id(pack_id) except DoesNotExist: - raise HTTPException(status_code=404, detail=f'No pack found with id {pack_id}') + raise HTTPException( + status_code=404, detail=f"No pack found with id {pack_id}" + ) all_cards = all_cards.where(Card.pack == this_pack) if value is not None: all_cards = all_cards.where(Card.value == value) @@ -125,7 +141,6 @@ async def get_cards( dex_by_player.setdefault(row.player_id, []).append(row) return_val = {"count": len(card_list), "cards": []} for x in card_list: - this_record = model_to_dict(x) logging.debug(f"this_record: {this_record}") @@ -147,7 +162,7 @@ async def v1_cards_get_one(card_id, csv: Optional[bool] = False): try: this_card = Card.get_by_id(card_id) except DoesNotExist: - raise HTTPException(status_code=404, detail=f'No card found with id {card_id}') + raise HTTPException(status_code=404, detail=f"No card found with id {card_id}") if csv: data_list = [ @@ -215,7 +230,7 @@ async def v1_cards_post(cards: CardModel, token: str = Depends(oauth2_scheme)): initialize_card_evolution(x.player_id, x.team_id, card_type) except Exception: logging.exception( - "evolution hook: unexpected error for player_id=%s team_id=%s", + "refractor hook: unexpected error for player_id=%s team_id=%s", x.player_id, x.team_id, ) @@ -319,8 +334,8 @@ async def v1_cards_wipe_team(team_id: int, token: str = Depends(oauth2_scheme)): try: this_team = Team.get_by_id(team_id) except DoesNotExist: - logging.error(f'/cards/wipe-team/{team_id} - could not find team') - raise HTTPException(status_code=404, detail=f'Team {team_id} not found') + logging.error(f"/cards/wipe-team/{team_id} - could not find team") + raise HTTPException(status_code=404, detail=f"Team {team_id} not found") t_query = Card.update(team=None).where(Card.team == this_team).execute() return f"Wiped {t_query} cards" @@ -348,7 +363,7 @@ async def v1_cards_patch( try: this_card = Card.get_by_id(card_id) except DoesNotExist: - raise HTTPException(status_code=404, detail=f'No card found with id {card_id}') + raise HTTPException(status_code=404, detail=f"No card found with id {card_id}") if player_id is not None: this_card.player_id = player_id @@ -391,7 +406,7 @@ async def v1_cards_delete(card_id, token: str = Depends(oauth2_scheme)): try: this_card = Card.get_by_id(card_id) except DoesNotExist: - raise HTTPException(status_code=404, detail=f'No cards found with id {card_id}') + raise HTTPException(status_code=404, detail=f"No cards found with id {card_id}") count = this_card.delete_instance() diff --git a/app/routers_v2/evolution.py b/app/routers_v2/refractor.py similarity index 79% rename from app/routers_v2/evolution.py rename to app/routers_v2/refractor.py index d08e528..45497ee 100644 --- a/app/routers_v2/evolution.py +++ b/app/routers_v2/refractor.py @@ -7,10 +7,10 @@ from ..dependencies import oauth2_scheme, valid_token logger = logging.getLogger(__name__) -router = APIRouter(prefix="/api/v2/evolution", tags=["evolution"]) +router = APIRouter(prefix="/api/v2/refractor", tags=["refractor"]) # Tier -> threshold attribute name. Index = current_tier; value is the -# attribute on EvolutionTrack whose value is the *next* threshold to reach. +# attribute on RefractorTrack whose value is the *next* threshold to reach. # Tier 4 is fully evolved so there is no next threshold (None sentinel). _NEXT_THRESHOLD_ATTR = { 0: "t1_threshold", @@ -22,7 +22,7 @@ _NEXT_THRESHOLD_ATTR = { def _build_card_state_response(state) -> dict: - """Serialise an EvolutionCardState into the standard API response shape. + """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 @@ -63,11 +63,11 @@ async def list_tracks( logging.warning("Bad Token: [REDACTED]") raise HTTPException(status_code=401, detail="Unauthorized") - from ..db_engine import EvolutionTrack + from ..db_engine import RefractorTrack - query = EvolutionTrack.select() + query = RefractorTrack.select() if card_type is not None: - query = query.where(EvolutionTrack.card_type == card_type) + query = query.where(RefractorTrack.card_type == card_type) items = [model_to_dict(t, recurse=False) for t in query] return {"count": len(items), "items": items} @@ -79,10 +79,10 @@ async def get_track(track_id: int, token: str = Depends(oauth2_scheme)): logging.warning("Bad Token: [REDACTED]") raise HTTPException(status_code=401, detail="Unauthorized") - from ..db_engine import EvolutionTrack + from ..db_engine import RefractorTrack try: - track = EvolutionTrack.get_by_id(track_id) + track = RefractorTrack.get_by_id(track_id) except Exception: raise HTTPException(status_code=404, detail=f"Track {track_id} not found") @@ -91,22 +91,22 @@ async def get_track(track_id: int, token: str = Depends(oauth2_scheme)): @router.get("/cards/{card_id}") async def get_card_state(card_id: int, token: str = Depends(oauth2_scheme)): - """Return the EvolutionCardState for a card identified by its Card.id. + """Return the RefractorCardState for a card identified by its Card.id. Resolves card_id -> (player_id, team_id) via the Card table, then looks - up the matching EvolutionCardState row. Because duplicate cards for the + up the matching RefractorCardState row. Because duplicate cards for the same player+team share one state row (unique-(player,team) constraint), any card_id belonging to that player on that team returns the same state. Returns 404 when: - The card_id does not exist in the Card table. - - The card exists but has no corresponding EvolutionCardState yet. + - The card exists but has no corresponding RefractorCardState yet. """ if not valid_token(token): logging.warning("Bad Token: [REDACTED]") raise HTTPException(status_code=401, detail="Unauthorized") - from ..db_engine import Card, EvolutionCardState, EvolutionTrack, DoesNotExist + from ..db_engine import Card, RefractorCardState, RefractorTrack, DoesNotExist # Resolve card_id to player+team try: @@ -114,22 +114,22 @@ async def get_card_state(card_id: int, token: str = Depends(oauth2_scheme)): except DoesNotExist: raise HTTPException(status_code=404, detail=f"Card {card_id} not found") - # Look up the evolution state for this (player, team) pair, joining the + # Look up the refractor state for this (player, team) pair, joining the # track so a single query resolves both rows. try: state = ( - EvolutionCardState.select(EvolutionCardState, EvolutionTrack) - .join(EvolutionTrack) + RefractorCardState.select(RefractorCardState, RefractorTrack) + .join(RefractorTrack) .where( - (EvolutionCardState.player == card.player_id) - & (EvolutionCardState.team == card.team_id) + (RefractorCardState.player == card.player_id) + & (RefractorCardState.team == card.team_id) ) .get() ) except DoesNotExist: raise HTTPException( status_code=404, - detail=f"No evolution state for card {card_id}", + detail=f"No refractor state for card {card_id}", ) return _build_card_state_response(state) @@ -137,9 +137,9 @@ async def get_card_state(card_id: int, token: str = Depends(oauth2_scheme)): @router.post("/cards/{card_id}/evaluate") async def evaluate_card(card_id: int, token: str = Depends(oauth2_scheme)): - """Force-recalculate evolution state for a card from career stats. + """Force-recalculate refractor state for a card from career stats. - Resolves card_id to (player_id, team_id), then recomputes the evolution + Resolves card_id to (player_id, team_id), then recomputes the refractor tier from all player_season_stats rows for that pair. Idempotent. """ if not valid_token(token): @@ -147,7 +147,7 @@ async def evaluate_card(card_id: int, token: str = Depends(oauth2_scheme)): raise HTTPException(status_code=401, detail="Unauthorized") from ..db_engine import Card - from ..services.evolution_evaluator import evaluate_card as _evaluate + from ..services.refractor_evaluator import evaluate_card as _evaluate try: card = Card.get_by_id(card_id) @@ -164,10 +164,10 @@ async def evaluate_card(card_id: int, token: str = Depends(oauth2_scheme)): @router.post("/evaluate-game/{game_id}") async def evaluate_game(game_id: int, token: str = Depends(oauth2_scheme)): - """Evaluate evolution state for all players who appeared in a game. + """Evaluate refractor state for all players who appeared in a game. Finds all unique (player_id, team_id) pairs from the game's StratPlay rows, - then for each pair that has an EvolutionCardState, re-computes the evolution + then for each pair that has a RefractorCardState, re-computes the refractor tier. Pairs without a state row are silently skipped. Per-player errors are logged but do not abort the batch. """ @@ -175,8 +175,8 @@ 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 EvolutionCardState, EvolutionTrack, Player, StratPlay - from ..services.evolution_evaluator import evaluate_card + from ..db_engine import RefractorCardState, RefractorTrack, Player, StratPlay + from ..services.refractor_evaluator import evaluate_card plays = list(StratPlay.select().where(StratPlay.game == game_id)) @@ -192,9 +192,9 @@ async def evaluate_game(game_id: int, token: str = Depends(oauth2_scheme)): for player_id, team_id in pairs: try: - state = EvolutionCardState.get_or_none( - (EvolutionCardState.player_id == player_id) - & (EvolutionCardState.team_id == team_id) + state = RefractorCardState.get_or_none( + (RefractorCardState.player_id == player_id) + & (RefractorCardState.team_id == team_id) ) if state is None: continue @@ -225,7 +225,7 @@ async def evaluate_game(game_id: int, token: str = Depends(oauth2_scheme)): ) except Exception as exc: logger.warning( - f"Evolution eval failed for player={player_id} team={team_id}: {exc}" + f"Refractor eval failed for player={player_id} team={team_id}: {exc}" ) return {"evaluated": evaluated, "tier_ups": tier_ups} diff --git a/app/routers_v2/teams.py b/app/routers_v2/teams.py index 58394f7..e630c9a 100644 --- a/app/routers_v2/teams.py +++ b/app/routers_v2/teams.py @@ -1049,7 +1049,6 @@ async def team_buy_players(team_id: int, ids: str, ts: str): detail=f"You are not authorized to buy {this_team.abbrev} cards. This event has been logged.", ) - all_ids = ids.split(",") conf_message = "" total_cost = 0 @@ -1540,9 +1539,9 @@ async def list_team_evolutions( per_page: int = Query(default=10, ge=1, le=100), token: str = Depends(oauth2_scheme), ): - """List all EvolutionCardState rows for a team, with optional filters. + """List all RefractorCardState rows for a team, with optional filters. - Joins EvolutionCardState to EvolutionTrack so that card_type filtering + Joins RefractorCardState to RefractorTrack so that card_type filtering works without a second query. Results are paginated via page/per_page (1-indexed pages); items are ordered by player_id for stable ordering. @@ -1555,27 +1554,27 @@ async def list_team_evolutions( Response shape: {"count": N, "items": [card_state_with_threshold_context, ...]} - Each item in 'items' has the same shape as GET /evolution/cards/{card_id}. + Each item in 'items' has the same shape as GET /refractor/cards/{card_id}. """ if not valid_token(token): logging.warning("Bad Token: [REDACTED]") raise HTTPException(status_code=401, detail="Unauthorized") - from ..db_engine import EvolutionCardState, EvolutionTrack - from ..routers_v2.evolution import _build_card_state_response + from ..db_engine import RefractorCardState, RefractorTrack + from ..routers_v2.refractor import _build_card_state_response query = ( - EvolutionCardState.select(EvolutionCardState, EvolutionTrack) - .join(EvolutionTrack) - .where(EvolutionCardState.team == team_id) - .order_by(EvolutionCardState.player_id) + RefractorCardState.select(RefractorCardState, RefractorTrack) + .join(RefractorTrack) + .where(RefractorCardState.team == team_id) + .order_by(RefractorCardState.player_id) ) if card_type is not None: - query = query.where(EvolutionTrack.card_type == card_type) + query = query.where(RefractorTrack.card_type == card_type) if tier is not None: - query = query.where(EvolutionCardState.current_tier == tier) + query = query.where(RefractorCardState.current_tier == tier) total = query.count() offset = (page - 1) * per_page diff --git a/app/seed/evolution_tracks.json b/app/seed/refractor_tracks.json similarity index 100% rename from app/seed/evolution_tracks.json rename to app/seed/refractor_tracks.json diff --git a/app/seed/evolution_tracks.py b/app/seed/refractor_tracks.py similarity index 72% rename from app/seed/evolution_tracks.py rename to app/seed/refractor_tracks.py index 3314a97..14035f0 100644 --- a/app/seed/evolution_tracks.py +++ b/app/seed/refractor_tracks.py @@ -1,36 +1,36 @@ -"""Seed script for EvolutionTrack records. +"""Seed script for RefractorTrack records. -Loads track definitions from evolution_tracks.json and upserts them into the +Loads track definitions from refractor_tracks.json and upserts them into the database using get_or_create keyed on name. Existing tracks have their thresholds and formula updated to match the JSON in case values have changed. Can be run standalone: - python -m app.seed.evolution_tracks + python -m app.seed.refractor_tracks """ import json import logging from pathlib import Path -from app.db_engine import EvolutionTrack +from app.db_engine import RefractorTrack logger = logging.getLogger(__name__) -_JSON_PATH = Path(__file__).parent / "evolution_tracks.json" +_JSON_PATH = Path(__file__).parent / "refractor_tracks.json" -def seed_evolution_tracks() -> list[EvolutionTrack]: - """Upsert evolution tracks from JSON seed data. +def seed_refractor_tracks() -> list[RefractorTrack]: + """Upsert refractor tracks from JSON seed data. - Returns a list of EvolutionTrack instances that were created or updated. + Returns a list of RefractorTrack instances that were created or updated. """ raw = _JSON_PATH.read_text(encoding="utf-8") track_defs = json.loads(raw) - results: list[EvolutionTrack] = [] + results: list[RefractorTrack] = [] for defn in track_defs: - track, created = EvolutionTrack.get_or_create( + track, created = RefractorTrack.get_or_create( name=defn["name"], defaults={ "card_type": defn["card_type"], @@ -61,6 +61,6 @@ def seed_evolution_tracks() -> list[EvolutionTrack]: if __name__ == "__main__": logging.basicConfig(level=logging.INFO) - logger.info("Seeding evolution tracks...") - tracks = seed_evolution_tracks() + logger.info("Seeding refractor tracks...") + tracks = seed_refractor_tracks() logger.info("Done. %d track(s) processed.", len(tracks)) diff --git a/app/services/formula_engine.py b/app/services/formula_engine.py index 2e55a65..a65fbd1 100644 --- a/app/services/formula_engine.py +++ b/app/services/formula_engine.py @@ -1,6 +1,6 @@ -"""Formula engine for evolution value computation (WP-09). +"""Formula engine for refractor value computation (WP-09). -Three pure functions that compute a numeric evolution value from career stats, +Three pure functions that compute a numeric refractor value from career stats, plus helpers for formula dispatch and tier classification. Stats attributes expected by each formula: @@ -79,7 +79,7 @@ def compute_value_for_track(card_type: str, stats) -> float: def tier_from_value(value: float, track) -> int: - """Return the evolution tier (0-4) for a computed value against a track. + """Return the refractor tier (0-4) for a computed value against a track. Tier boundaries are inclusive on the lower end: T0: value < t1 diff --git a/app/services/evolution_evaluator.py b/app/services/refractor_evaluator.py similarity index 91% rename from app/services/evolution_evaluator.py rename to app/services/refractor_evaluator.py index fa5961e..9c63be5 100644 --- a/app/services/evolution_evaluator.py +++ b/app/services/refractor_evaluator.py @@ -1,6 +1,6 @@ -"""Evolution evaluator service (WP-08). +"""Refractor evaluator service (WP-08). -Force-recalculates a card's evolution state from career totals. +Force-recalculates a card's refractor state from career totals. evaluate_card() is the main entry point: 1. Load career totals: SUM all BattingSeasonStats/PitchingSeasonStats rows for (player_id, team_id) @@ -14,7 +14,7 @@ evaluate_card() is the main entry point: Idempotent: calling multiple times with the same data produces the same result. -Depends on WP-05 (EvolutionCardState), WP-07 (BattingSeasonStats/PitchingSeasonStats), +Depends on WP-05 (RefractorCardState), WP-07 (BattingSeasonStats/PitchingSeasonStats), and WP-09 (formula engine). Models and formula functions are imported lazily so this module can be imported before those PRs merge. """ @@ -52,11 +52,11 @@ def evaluate_card( _compute_value_fn=None, _tier_from_value_fn=None, ) -> dict: - """Force-recalculate a card's evolution tier from career stats. + """Force-recalculate a card's refractor tier from career stats. Sums all BattingSeasonStats or PitchingSeasonStats rows (based on card_type) for (player_id, team_id) across all seasons, then delegates - formula computation and tier classification to the formula engine. The result is written back to evolution_card_state and + formula computation and tier classification to the formula engine. The result is written back to refractor_card_state and returned as a dict. current_tier never decreases (no regression): @@ -67,7 +67,7 @@ def evaluate_card( team_id: Team primary key. _stats_model: Override for BattingSeasonStats/PitchingSeasonStats (used in tests to inject a stub model with all stat fields). - _state_model: Override for EvolutionCardState (used in tests to avoid + _state_model: Override for RefractorCardState (used in tests to avoid importing from db_engine before WP-05 merges). _compute_value_fn: Override for formula_engine.compute_value_for_track (used in tests to avoid importing formula_engine before WP-09 merges). @@ -79,10 +79,10 @@ def evaluate_card( last_evaluated_at (ISO-8601 string). Raises: - ValueError: If no evolution_card_state row exists for (player_id, team_id). + ValueError: If no refractor_card_state row exists for (player_id, team_id). """ if _state_model is None: - from app.db_engine import EvolutionCardState as _state_model # noqa: PLC0415 + from app.db_engine import RefractorCardState as _state_model # noqa: PLC0415 if _compute_value_fn is None or _tier_from_value_fn is None: from app.services.formula_engine import ( # noqa: PLC0415 @@ -101,7 +101,7 @@ def evaluate_card( ) if card_state is None: raise ValueError( - f"No evolution_card_state for player_id={player_id} team_id={team_id}" + f"No refractor_card_state for player_id={player_id} team_id={team_id}" ) # 2. Load career totals from the appropriate season stats table @@ -178,7 +178,7 @@ def evaluate_card( card_state.save() logging.debug( - "evolution_eval: player=%s team=%s value=%.2f tier=%s fully_evolved=%s", + "refractor_eval: player=%s team=%s value=%.2f tier=%s fully_evolved=%s", player_id, team_id, value, diff --git a/app/services/evolution_init.py b/app/services/refractor_init.py similarity index 74% rename from app/services/evolution_init.py rename to app/services/refractor_init.py index cac9b7b..9e49c9f 100644 --- a/app/services/evolution_init.py +++ b/app/services/refractor_init.py @@ -1,10 +1,10 @@ """ -WP-10: Pack opening hook — evolution_card_state initialization. +WP-10: Pack opening hook — refractor_card_state initialization. Public API ---------- initialize_card_evolution(player_id, team_id, card_type) - Get-or-create an EvolutionCardState for the (player_id, team_id) pair. + Get-or-create a RefractorCardState for the (player_id, team_id) pair. Returns the state instance on success, or None if initialization fails (missing track, integrity error, etc.). Never raises. @@ -16,23 +16,23 @@ Design notes ------------ - The function is intentionally fire-and-forget from the caller's perspective. All exceptions are caught and logged; pack opening is never blocked. -- No EvolutionProgress rows are created here. Progress accumulation is a +- No RefractorProgress rows are created here. Progress accumulation is a separate concern handled by the stats-update pipeline (WP-07/WP-08). - AI teams and Gauntlet teams skip Paperdex insertion (cards.py pattern); - we do NOT replicate that exclusion here — all teams get an evolution state + we do NOT replicate that exclusion here — all teams get a refractor state so that future rule changes don't require back-filling. """ import logging from typing import Optional -from app.db_engine import DoesNotExist, EvolutionCardState, EvolutionTrack +from app.db_engine import DoesNotExist, RefractorCardState, RefractorTrack logger = logging.getLogger(__name__) def _determine_card_type(player) -> str: - """Map a player's primary position to an evolution card_type string. + """Map a player's primary position to a refractor card_type string. Rules (from WP-10 spec): - pos_1 contains 'SP' -> 'sp' @@ -57,30 +57,30 @@ def initialize_card_evolution( player_id: int, team_id: int, card_type: str, -) -> Optional[EvolutionCardState]: - """Get-or-create an EvolutionCardState for a newly acquired card. +) -> Optional[RefractorCardState]: + """Get-or-create a RefractorCardState for a newly acquired card. Called by the cards POST endpoint after each card is inserted. The function is idempotent: if a state row already exists for the (player_id, team_id) pair it is returned unchanged — existing - evolution progress is never reset. + refractor progress is never reset. Args: player_id: Primary key of the Player row (Player.player_id). team_id: Primary key of the Team row (Team.id). card_type: One of 'batter', 'sp', 'rp'. Determines which - EvolutionTrack is assigned to the new state. + RefractorTrack is assigned to the new state. Returns: - The existing or newly created EvolutionCardState instance, or + The existing or newly created RefractorCardState instance, or None if initialization could not complete (missing track seed data, unexpected DB error, etc.). """ try: - track = EvolutionTrack.get(EvolutionTrack.card_type == card_type) + track = RefractorTrack.get(RefractorTrack.card_type == card_type) except DoesNotExist: logger.warning( - "evolution_init: no EvolutionTrack found for card_type=%r " + "refractor_init: no RefractorTrack found for card_type=%r " "(player_id=%s, team_id=%s) — skipping state creation", card_type, player_id, @@ -89,7 +89,7 @@ def initialize_card_evolution( return None except Exception: logger.exception( - "evolution_init: unexpected error fetching track " + "refractor_init: unexpected error fetching track " "(card_type=%r, player_id=%s, team_id=%s)", card_type, player_id, @@ -98,7 +98,7 @@ def initialize_card_evolution( return None try: - state, created = EvolutionCardState.get_or_create( + state, created = RefractorCardState.get_or_create( player_id=player_id, team_id=team_id, defaults={ @@ -110,7 +110,7 @@ def initialize_card_evolution( ) if created: logger.debug( - "evolution_init: created EvolutionCardState id=%s " + "refractor_init: created RefractorCardState id=%s " "(player_id=%s, team_id=%s, card_type=%r)", state.id, player_id, @@ -119,7 +119,7 @@ def initialize_card_evolution( ) else: logger.debug( - "evolution_init: state already exists id=%s " + "refractor_init: state already exists id=%s " "(player_id=%s, team_id=%s) — no-op", state.id, player_id, @@ -129,7 +129,7 @@ def initialize_card_evolution( except Exception: logger.exception( - "evolution_init: failed to get_or_create state " + "refractor_init: failed to get_or_create state " "(player_id=%s, team_id=%s, card_type=%r)", player_id, team_id, diff --git a/migrations/2026-03-23_rename_evolution_to_refractor.sql b/migrations/2026-03-23_rename_evolution_to_refractor.sql new file mode 100644 index 0000000..c2a3d3f --- /dev/null +++ b/migrations/2026-03-23_rename_evolution_to_refractor.sql @@ -0,0 +1,15 @@ +-- Migration: Rename evolution tables to refractor tables +-- Date: 2026-03-23 +-- +-- Renames all four evolution system tables to the refractor naming convention. +-- This migration corresponds to the application-level rename from +-- EvolutionTrack/EvolutionCardState/EvolutionTierBoost/EvolutionCosmetic +-- to RefractorTrack/RefractorCardState/RefractorTierBoost/RefractorCosmetic. +-- +-- The table renames are performed in order that respects foreign key +-- dependencies (referenced tables first, then referencing tables). + +ALTER TABLE evolution_track RENAME TO refractor_track; +ALTER TABLE evolution_card_state RENAME TO refractor_card_state; +ALTER TABLE evolution_tier_boost RENAME TO refractor_tier_boost; +ALTER TABLE evolution_cosmetic RENAME TO refractor_cosmetic; diff --git a/tests/conftest.py b/tests/conftest.py index 22b3d10..9d9a6a7 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -44,10 +44,10 @@ from app.db_engine import ( BattingSeasonStats, PitchingSeasonStats, ProcessedGame, - EvolutionTrack, - EvolutionCardState, - EvolutionTierBoost, - EvolutionCosmetic, + RefractorTrack, + RefractorCardState, + RefractorTierBoost, + RefractorCosmetic, ScoutOpportunity, ScoutClaim, ) @@ -76,10 +76,10 @@ _TEST_MODELS = [ ProcessedGame, ScoutOpportunity, ScoutClaim, - EvolutionTrack, - EvolutionCardState, - EvolutionTierBoost, - EvolutionCosmetic, + RefractorTrack, + RefractorCardState, + RefractorTierBoost, + RefractorCosmetic, ] @@ -164,8 +164,8 @@ def team(): @pytest.fixture def track(): - """A minimal EvolutionTrack for batter cards.""" - return EvolutionTrack.create( + """A minimal RefractorTrack for batter cards.""" + return RefractorTrack.create( name="Batter Track", card_type="batter", formula="pa + tb * 2", @@ -177,7 +177,7 @@ def track(): # --------------------------------------------------------------------------- -# PostgreSQL integration fixture (used by test_evolution_*_api.py) +# PostgreSQL integration fixture (used by test_refractor_*_api.py) # --------------------------------------------------------------------------- diff --git a/tests/test_formula_engine.py b/tests/test_formula_engine.py index 435cd92..d02504b 100644 --- a/tests/test_formula_engine.py +++ b/tests/test_formula_engine.py @@ -3,7 +3,7 @@ Unit tests only — no database required. Stats inputs are simple namespace objects whose attributes match what BattingSeasonStats/PitchingSeasonStats expose. -Tier thresholds used (from evolution_tracks.json seed data): +Tier thresholds used (from refractor_tracks.json seed data): Batter: t1=37, t2=149, t3=448, t4=896 SP: t1=10, t2=40, t3=120, t4=240 RP: t1=3, t2=12, t3=35, t4=70 diff --git a/tests/test_postgame_evolution.py b/tests/test_postgame_refractor.py similarity index 93% rename from tests/test_postgame_evolution.py rename to tests/test_postgame_refractor.py index 21671e8..894f5fc 100644 --- a/tests/test_postgame_evolution.py +++ b/tests/test_postgame_refractor.py @@ -2,7 +2,7 @@ Tests cover both post-game callback endpoints: POST /api/v2/season-stats/update-game/{game_id} - POST /api/v2/evolution/evaluate-game/{game_id} + POST /api/v2/refractor/evaluate-game/{game_id} All tests run against a named shared-memory SQLite database so that Peewee model queries inside the route handlers (which execute in the TestClient's @@ -34,7 +34,7 @@ Test matrix: test_evaluate_game_tier_ups_in_response Tier-up appears in tier_ups list with correct fields. test_evaluate_game_skips_players_without_state - Players in game but without EvolutionCardState are silently skipped. + Players in game but without RefractorCardState are silently skipped. test_auth_required_update_game Missing bearer token returns 401 on update-game. test_auth_required_evaluate_game @@ -55,10 +55,10 @@ from peewee import SqliteDatabase from app.db_engine import ( Cardset, - EvolutionCardState, - EvolutionCosmetic, - EvolutionTierBoost, - EvolutionTrack, + RefractorCardState, + RefractorCosmetic, + RefractorTierBoost, + RefractorTrack, MlbPlayer, Pack, PackType, @@ -111,10 +111,10 @@ _WP13_MODELS = [ BattingSeasonStats, PitchingSeasonStats, ProcessedGame, - EvolutionTrack, - EvolutionCardState, - EvolutionTierBoost, - EvolutionCosmetic, + RefractorTrack, + RefractorCardState, + RefractorTierBoost, + RefractorCosmetic, ] # Patch the service-layer 'db' reference to use our shared test database so @@ -165,7 +165,7 @@ def _build_test_app() -> FastAPI: connection even though it runs in a different thread from the fixture. """ from app.routers_v2.season_stats import router as ss_router - from app.routers_v2.evolution import router as evo_router + from app.routers_v2.refractor import router as evo_router test_app = FastAPI() @@ -294,8 +294,8 @@ def _make_play(game, play_num, batter, batter_team, pitcher, pitcher_team, **sta def _make_track( name: str = "WP13 Batter Track", card_type: str = "batter" -) -> EvolutionTrack: - track, _ = EvolutionTrack.get_or_create( +) -> RefractorTrack: + track, _ = RefractorTrack.get_or_create( name=name, defaults=dict( card_type=card_type, @@ -311,8 +311,8 @@ def _make_track( def _make_state( player, team, track, current_tier=0, current_value=0.0 -) -> EvolutionCardState: - return EvolutionCardState.create( +) -> RefractorCardState: + return RefractorCardState.create( player=player, team=team, track=track, @@ -428,14 +428,14 @@ def test_update_game_idempotent(client): # --------------------------------------------------------------------------- -# Tests: POST /api/v2/evolution/evaluate-game/{game_id} +# Tests: POST /api/v2/refractor/evaluate-game/{game_id} # --------------------------------------------------------------------------- def test_evaluate_game_increases_current_value(client): """After update-game, evaluate-game raises the card's current_value above 0. - What: Batter with an EvolutionCardState gets 3 hits (pa=3, hit=3) from a + What: Batter with a RefractorCardState gets 3 hits (pa=3, hit=3) from a game. update-game writes those stats; evaluate-game then recomputes the value. current_value in the DB must be > 0 after the evaluate call. @@ -458,12 +458,12 @@ def test_evaluate_game_increases_current_value(client): client.post(f"/api/v2/season-stats/update-game/{game.id}", headers=AUTH_HEADER) resp = client.post( - f"/api/v2/evolution/evaluate-game/{game.id}", headers=AUTH_HEADER + f"/api/v2/refractor/evaluate-game/{game.id}", headers=AUTH_HEADER ) assert resp.status_code == 200 - state = EvolutionCardState.get( - (EvolutionCardState.player == batter) & (EvolutionCardState.team == team_a) + state = RefractorCardState.get( + (RefractorCardState.player == batter) & (RefractorCardState.team == team_a) ) assert state.current_value > 0 @@ -501,12 +501,12 @@ def test_evaluate_game_tier_advancement(client): client.post(f"/api/v2/season-stats/update-game/{game.id}", headers=AUTH_HEADER) resp = client.post( - f"/api/v2/evolution/evaluate-game/{game.id}", headers=AUTH_HEADER + f"/api/v2/refractor/evaluate-game/{game.id}", headers=AUTH_HEADER ) assert resp.status_code == 200 - updated_state = EvolutionCardState.get( - (EvolutionCardState.player == batter) & (EvolutionCardState.team == team_a) + updated_state = RefractorCardState.get( + (RefractorCardState.player == batter) & (RefractorCardState.team == team_a) ) assert updated_state.current_tier >= 1 @@ -535,15 +535,15 @@ def test_evaluate_game_no_tier_advancement(client): client.post(f"/api/v2/season-stats/update-game/{game.id}", headers=AUTH_HEADER) resp = client.post( - f"/api/v2/evolution/evaluate-game/{game.id}", headers=AUTH_HEADER + f"/api/v2/refractor/evaluate-game/{game.id}", headers=AUTH_HEADER ) assert resp.status_code == 200 data = resp.json() assert data["tier_ups"] == [] - state = EvolutionCardState.get( - (EvolutionCardState.player == batter) & (EvolutionCardState.team == team_a) + state = RefractorCardState.get( + (RefractorCardState.player == batter) & (RefractorCardState.team == team_a) ) assert state.current_tier == 0 @@ -577,7 +577,7 @@ def test_evaluate_game_tier_ups_in_response(client): client.post(f"/api/v2/season-stats/update-game/{game.id}", headers=AUTH_HEADER) resp = client.post( - f"/api/v2/evolution/evaluate-game/{game.id}", headers=AUTH_HEADER + f"/api/v2/refractor/evaluate-game/{game.id}", headers=AUTH_HEADER ) assert resp.status_code == 200 data = resp.json() @@ -596,7 +596,7 @@ def test_evaluate_game_tier_ups_in_response(client): def test_evaluate_game_skips_players_without_state(client): - """Players in a game without an EvolutionCardState are silently skipped. + """Players in a game without a RefractorCardState are silently skipped. What: A game has two players: one with a card state and one without. After evaluate-game, evaluated should be 1 (only the player with state) @@ -613,7 +613,7 @@ def test_evaluate_game_skips_players_without_state(client): game = _make_game(team_a, team_b) track = _make_track(name="WP13 Skip Track") - # Only batter_with_state gets an EvolutionCardState + # Only batter_with_state gets a RefractorCardState _make_state(batter_with_state, team_a, track) _make_play(game, 1, batter_with_state, team_a, pitcher, team_b, pa=1, ab=1, outs=1) @@ -621,7 +621,7 @@ def test_evaluate_game_skips_players_without_state(client): client.post(f"/api/v2/season-stats/update-game/{game.id}", headers=AUTH_HEADER) resp = client.post( - f"/api/v2/evolution/evaluate-game/{game.id}", headers=AUTH_HEADER + f"/api/v2/refractor/evaluate-game/{game.id}", headers=AUTH_HEADER ) assert resp.status_code == 200 data = resp.json() @@ -663,5 +663,5 @@ def test_auth_required_evaluate_game(client): team_b = _make_team("WB2", gmid=20092) game = _make_game(team_a, team_b) - resp = client.post(f"/api/v2/evolution/evaluate-game/{game.id}") + resp = client.post(f"/api/v2/refractor/evaluate-game/{game.id}") assert resp.status_code == 401 diff --git a/tests/test_evolution_evaluator.py b/tests/test_refractor_evaluator.py similarity index 96% rename from tests/test_evolution_evaluator.py rename to tests/test_refractor_evaluator.py index abbefdf..d1873cc 100644 --- a/tests/test_evolution_evaluator.py +++ b/tests/test_refractor_evaluator.py @@ -1,7 +1,7 @@ -"""Tests for the evolution evaluator service (WP-08). +"""Tests for the refractor evaluator service (WP-08). Unit tests verify tier assignment, advancement, partial progress, idempotency, -full evolution, and no-regression behaviour without touching any database, +full refractor tier, and no-regression behaviour without touching any database, using stub Peewee models bound to an in-memory SQLite database. The formula engine (WP-09) and Peewee models (WP-05/WP-07) are not imported @@ -33,7 +33,7 @@ from peewee import ( SqliteDatabase, ) -from app.services.evolution_evaluator import evaluate_card +from app.services.refractor_evaluator import evaluate_card # --------------------------------------------------------------------------- # Stub models — mirror WP-01/WP-04/WP-07 schema without importing db_engine @@ -43,7 +43,7 @@ _test_db = SqliteDatabase(":memory:") class TrackStub(Model): - """Minimal EvolutionTrack stub for evaluator tests.""" + """Minimal RefractorTrack stub for evaluator tests.""" card_type = CharField(unique=True) t1_threshold = IntegerField() @@ -53,11 +53,11 @@ class TrackStub(Model): class Meta: database = _test_db - table_name = "evolution_track" + table_name = "refractor_track" class CardStateStub(Model): - """Minimal EvolutionCardState stub for evaluator tests.""" + """Minimal RefractorCardState stub for evaluator tests.""" player_id = IntegerField() team_id = IntegerField() @@ -69,7 +69,7 @@ class CardStateStub(Model): class Meta: database = _test_db - table_name = "evolution_card_state" + table_name = "refractor_card_state" indexes = ((("player_id", "team_id"), True),) @@ -331,7 +331,7 @@ class TestMissingState: def test_missing_state_raises(self, batter_track): """evaluate_card raises ValueError when no state row exists.""" # No card state created - with pytest.raises(ValueError, match="No evolution_card_state"): + with pytest.raises(ValueError, match="No refractor_card_state"): _eval(99, 99) diff --git a/tests/test_evolution_init.py b/tests/test_refractor_init.py similarity index 91% rename from tests/test_evolution_init.py rename to tests/test_refractor_init.py index cfbabb0..d92f80f 100644 --- a/tests/test_evolution_init.py +++ b/tests/test_refractor_init.py @@ -1,8 +1,8 @@ """ -Tests for WP-10: evolution_card_state initialization on pack opening. +Tests for WP-10: refractor_card_state initialization on pack opening. -Covers `app/services/evolution_init.py` — the `initialize_card_evolution` -function that creates an EvolutionCardState row when a card is first acquired. +Covers `app/services/refractor_init.py` — the `initialize_card_evolution` +function that creates an RefractorCardState row when a card is first acquired. Test strategy: - Unit tests for `_determine_card_type` cover all three branches (batter, @@ -18,7 +18,7 @@ Why we test idempotency: Why we test cross-player isolation: Two different players with the same team must each get their own - EvolutionCardState row. A bug that checked only team_id would share state + RefractorCardState row. A bug that checked only team_id would share state across players, so we assert that state.player_id matches. """ @@ -26,11 +26,11 @@ import pytest from app.db_engine import ( Cardset, - EvolutionCardState, - EvolutionTrack, + RefractorCardState, + RefractorTrack, Player, ) -from app.services.evolution_init import _determine_card_type, initialize_card_evolution +from app.services.refractor_init import _determine_card_type, initialize_card_evolution # --------------------------------------------------------------------------- @@ -74,13 +74,13 @@ def _make_player(rarity, pos_1: str) -> Player: ) -def _make_track(card_type: str) -> EvolutionTrack: - """Create an EvolutionTrack for the given card_type. +def _make_track(card_type: str) -> RefractorTrack: + """Create an RefractorTrack for the given card_type. Thresholds are kept small and arbitrary; the unit under test only cares about card_type when selecting the track. """ - return EvolutionTrack.create( + return RefractorTrack.create( name=f"Track-{card_type}", card_type=card_type, formula="pa", @@ -116,14 +116,14 @@ class TestDetermineCardType: """pos_1 == 'RP' maps to card_type 'rp'. Relief pitchers carry the 'RP' position flag and must follow a - separate evolution track with lower thresholds. + separate refractor track with lower thresholds. """ assert _determine_card_type(_FakePlayer("RP")) == "rp" def test_closer_pitcher(self): """pos_1 == 'CP' maps to card_type 'rp'. - Closers share the RP evolution track; the spec explicitly lists 'CP' + Closers share the RP refractor track; the spec explicitly lists 'CP' as an rp-track position. """ assert _determine_card_type(_FakePlayer("CP")) == "rp" @@ -168,7 +168,7 @@ class TestInitializeCardEvolution: @pytest.fixture(autouse=True) def seed_tracks(self): - """Create one EvolutionTrack per card_type before each test. + """Create one RefractorTrack per card_type before each test. initialize_card_evolution does a DB lookup for a track matching the card_type. If no track exists the function must not crash (it should @@ -180,7 +180,7 @@ class TestInitializeCardEvolution: self.rp_track = _make_track("rp") def test_first_card_creates_state(self, rarity, team): - """First acquisition creates an EvolutionCardState with zeroed values. + """First acquisition creates an RefractorCardState with zeroed values. Acceptance criteria from WP-10: - current_tier == 0 @@ -222,17 +222,17 @@ class TestInitializeCardEvolution: # Exactly one row in the database count = ( - EvolutionCardState.select() + RefractorCardState.select() .where( - EvolutionCardState.player == player, - EvolutionCardState.team == team, + RefractorCardState.player == player, + RefractorCardState.team == team, ) .count() ) assert count == 1 # Progress was NOT reset - refreshed = EvolutionCardState.get_by_id(state1.id) + refreshed = RefractorCardState.get_by_id(state1.id) assert refreshed.current_tier == 2 assert refreshed.current_value == 250.0 @@ -256,7 +256,7 @@ class TestInitializeCardEvolution: assert state_b.player_id == player_b.player_id def test_sp_card_gets_sp_track(self, rarity, team): - """A starting pitcher is assigned the 'sp' EvolutionTrack. + """A starting pitcher is assigned the 'sp' RefractorTrack. Track selection is driven by card_type, which in turn comes from pos_1. This test passes card_type='sp' explicitly (mirroring the @@ -270,7 +270,7 @@ class TestInitializeCardEvolution: assert state.track_id == self.sp_track.id def test_rp_card_gets_rp_track(self, rarity, team): - """A relief pitcher (RP or CP) is assigned the 'rp' EvolutionTrack.""" + """A relief pitcher (RP or CP) is assigned the 'rp' RefractorTrack.""" player = _make_player(rarity, "RP") state = initialize_card_evolution(player.player_id, team.id, "rp") diff --git a/tests/test_evolution_models.py b/tests/test_refractor_models.py similarity index 83% rename from tests/test_evolution_models.py rename to tests/test_refractor_models.py index 4479b9f..782967c 100644 --- a/tests/test_evolution_models.py +++ b/tests/test_refractor_models.py @@ -1,12 +1,12 @@ """ -Tests for evolution-related models and BattingSeasonStats. +Tests for refractor-related models and BattingSeasonStats. Covers WP-01 acceptance criteria: - - EvolutionTrack: CRUD and unique-name constraint - - EvolutionCardState: CRUD, defaults, unique-(player,team) constraint, - and FK resolution back to EvolutionTrack - - EvolutionTierBoost: CRUD and unique-(track, tier, boost_type, boost_target) - - EvolutionCosmetic: CRUD and unique-name constraint + - RefractorTrack: CRUD and unique-name constraint + - RefractorCardState: CRUD, defaults, unique-(player,team) constraint, + and FK resolution back to RefractorTrack + - RefractorTierBoost: CRUD and unique-(track, tier, boost_type, boost_target) + - RefractorCosmetic: CRUD and unique-name constraint - BattingSeasonStats: CRUD with defaults, unique-(player, team, season), and in-place stat accumulation @@ -21,21 +21,21 @@ from playhouse.shortcuts import model_to_dict from app.db_engine import ( BattingSeasonStats, - EvolutionCardState, - EvolutionCosmetic, - EvolutionTierBoost, - EvolutionTrack, + RefractorCardState, + RefractorCosmetic, + RefractorTierBoost, + RefractorTrack, ) # --------------------------------------------------------------------------- -# EvolutionTrack +# RefractorTrack # --------------------------------------------------------------------------- -class TestEvolutionTrack: - """Tests for the EvolutionTrack model. +class TestRefractorTrack: + """Tests for the RefractorTrack model. - EvolutionTrack defines a named progression path (formula + + RefractorTrack defines a named progression path (formula + tier thresholds) for a card type. The name column carries a UNIQUE constraint so that accidental duplicates are caught at the database level. @@ -60,12 +60,12 @@ class TestEvolutionTrack: def test_track_unique_name(self, track): """Inserting a second track with the same name raises IntegrityError. - The UNIQUE constraint on EvolutionTrack.name must prevent two + The UNIQUE constraint on RefractorTrack.name must prevent two tracks from sharing the same identifier, as the name is used as a human-readable key throughout the evolution system. """ with pytest.raises(IntegrityError): - EvolutionTrack.create( + RefractorTrack.create( name="Batter Track", # duplicate card_type="sp", formula="outs * 3", @@ -77,15 +77,15 @@ class TestEvolutionTrack: # --------------------------------------------------------------------------- -# EvolutionCardState +# RefractorCardState # --------------------------------------------------------------------------- -class TestEvolutionCardState: - """Tests for EvolutionCardState, which tracks per-player evolution progress. +class TestRefractorCardState: + """Tests for RefractorCardState, which tracks per-player refractor progress. Each row represents one card (player) owned by one team, linked to a - specific EvolutionTrack. The model records the current tier (0-4), + specific RefractorTrack. The model records the current tier (0-4), accumulated progress value, and whether the card is fully evolved. """ @@ -98,9 +98,9 @@ class TestEvolutionCardState: fully_evolved → False (evolution is not complete at creation) last_evaluated_at → None (never evaluated yet) """ - state = EvolutionCardState.create(player=player, team=team, track=track) + state = RefractorCardState.create(player=player, team=team, track=track) - fetched = EvolutionCardState.get_by_id(state.id) + fetched = RefractorCardState.get_by_id(state.id) assert fetched.player_id == player.player_id assert fetched.team_id == team.id assert fetched.track_id == track.id @@ -113,34 +113,34 @@ class TestEvolutionCardState: """A second card state for the same (player, team) pair raises IntegrityError. The unique index on (player, team) enforces that each player card - has at most one evolution state per team roster slot, preventing - duplicate evolution progress rows for the same physical card. + has at most one refractor state per team roster slot, preventing + duplicate refractor progress rows for the same physical card. """ - EvolutionCardState.create(player=player, team=team, track=track) + RefractorCardState.create(player=player, team=team, track=track) with pytest.raises(IntegrityError): - EvolutionCardState.create(player=player, team=team, track=track) + RefractorCardState.create(player=player, team=team, track=track) def test_card_state_fk_track(self, player, team, track): - """Accessing card_state.track returns the original EvolutionTrack instance. + """Accessing card_state.track returns the original RefractorTrack instance. This confirms the FK is correctly wired and that Peewee resolves the relationship, returning an object with the same primary key and name as the track used during creation. """ - state = EvolutionCardState.create(player=player, team=team, track=track) - fetched = EvolutionCardState.get_by_id(state.id) + state = RefractorCardState.create(player=player, team=team, track=track) + fetched = RefractorCardState.get_by_id(state.id) resolved_track = fetched.track assert resolved_track.id == track.id assert resolved_track.name == "Batter Track" # --------------------------------------------------------------------------- -# EvolutionTierBoost +# RefractorTierBoost # --------------------------------------------------------------------------- -class TestEvolutionTierBoost: - """Tests for EvolutionTierBoost, the per-tier stat/rating bonus table. +class TestRefractorTierBoost: + """Tests for RefractorTierBoost, the per-tier stat/rating bonus table. Each row maps a (track, tier) combination to a single boost — the specific stat or rating column to buff and by how much. The four- @@ -153,14 +153,14 @@ class TestEvolutionTierBoost: Verifies boost_type, boost_target, and boost_value are stored and retrieved without modification. """ - boost = EvolutionTierBoost.create( + boost = RefractorTierBoost.create( track=track, tier=1, boost_type="rating", boost_target="contact_vl", boost_value=1.5, ) - fetched = EvolutionTierBoost.get_by_id(boost.id) + fetched = RefractorTierBoost.get_by_id(boost.id) assert fetched.track_id == track.id assert fetched.tier == 1 assert fetched.boost_type == "rating" @@ -174,7 +174,7 @@ class TestEvolutionTierBoost: (e.g. Tier-1 contact_vl rating) cannot be defined twice for the same track, which would create ambiguity during evolution evaluation. """ - EvolutionTierBoost.create( + RefractorTierBoost.create( track=track, tier=2, boost_type="rating", @@ -182,7 +182,7 @@ class TestEvolutionTierBoost: boost_value=2.0, ) with pytest.raises(IntegrityError): - EvolutionTierBoost.create( + RefractorTierBoost.create( track=track, tier=2, boost_type="rating", @@ -192,12 +192,12 @@ class TestEvolutionTierBoost: # --------------------------------------------------------------------------- -# EvolutionCosmetic +# RefractorCosmetic # --------------------------------------------------------------------------- -class TestEvolutionCosmetic: - """Tests for EvolutionCosmetic, decorative unlocks tied to evolution tiers. +class TestRefractorCosmetic: + """Tests for RefractorCosmetic, decorative unlocks tied to evolution tiers. Cosmetics are purely visual rewards (frames, badges, themes) that a card unlocks when it reaches a required tier. The name column is @@ -210,14 +210,14 @@ class TestEvolutionCosmetic: Verifies all columns including optional ones (css_class, asset_url) are stored and retrieved. """ - cosmetic = EvolutionCosmetic.create( + cosmetic = RefractorCosmetic.create( name="Gold Frame", tier_required=2, cosmetic_type="frame", css_class="evo-frame-gold", asset_url="https://cdn.example.com/frames/gold.png", ) - fetched = EvolutionCosmetic.get_by_id(cosmetic.id) + fetched = RefractorCosmetic.get_by_id(cosmetic.id) assert fetched.name == "Gold Frame" assert fetched.tier_required == 2 assert fetched.cosmetic_type == "frame" @@ -227,16 +227,16 @@ class TestEvolutionCosmetic: def test_cosmetic_unique_name(self): """Inserting a second cosmetic with the same name raises IntegrityError. - The UNIQUE constraint on EvolutionCosmetic.name prevents duplicate + The UNIQUE constraint on RefractorCosmetic.name prevents duplicate cosmetic definitions that could cause ambiguous tier unlock lookups. """ - EvolutionCosmetic.create( + RefractorCosmetic.create( name="Silver Badge", tier_required=1, cosmetic_type="badge", ) with pytest.raises(IntegrityError): - EvolutionCosmetic.create( + RefractorCosmetic.create( name="Silver Badge", # duplicate tier_required=3, cosmetic_type="badge", diff --git a/tests/test_evolution_seed.py b/tests/test_refractor_seed.py similarity index 63% rename from tests/test_evolution_seed.py rename to tests/test_refractor_seed.py index a3d1842..383e213 100644 --- a/tests/test_evolution_seed.py +++ b/tests/test_refractor_seed.py @@ -1,16 +1,16 @@ """ -Tests for app/seed/evolution_tracks.py — seed_evolution_tracks(). +Tests for app/seed/refractor_tracks.py — seed_refractor_tracks(). What: Verify that the JSON-driven seed function correctly creates, counts, -and idempotently updates EvolutionTrack rows in the database. +and idempotently updates RefractorTrack rows in the database. Why: The seed is the single source of truth for track configuration. A regression here (duplicates, wrong thresholds, missing formula) would -silently corrupt evolution scoring for every card in the system. +silently corrupt refractor scoring for every card in the system. Each test operates on a fresh in-memory SQLite database provided by the autouse `setup_test_db` fixture in conftest.py. The seed reads its data -from `app/seed/evolution_tracks.json` on disk, so the tests also serve as +from `app/seed/refractor_tracks.json` on disk, so the tests also serve as a light integration check between the JSON file and the Peewee model. """ @@ -19,11 +19,11 @@ from pathlib import Path import pytest -from app.db_engine import EvolutionTrack -from app.seed.evolution_tracks import seed_evolution_tracks +from app.db_engine import RefractorTrack +from app.seed.refractor_tracks import seed_refractor_tracks # Path to the JSON fixture that the seed reads from at runtime -_JSON_PATH = Path(__file__).parent.parent / "app" / "seed" / "evolution_tracks.json" +_JSON_PATH = Path(__file__).parent.parent / "app" / "seed" / "refractor_tracks.json" @pytest.fixture @@ -37,48 +37,48 @@ def json_tracks(): def test_seed_creates_three_tracks(json_tracks): - """After one seed call, exactly 3 EvolutionTrack rows must exist. + """After one seed call, exactly 3 RefractorTrack rows must exist. Why: The JSON currently defines three card-type tracks (batter, sp, rp). If the count is wrong the system would either be missing tracks - (evolution disabled for a card type) or have phantom extras. + (refractor disabled for a card type) or have phantom extras. """ - seed_evolution_tracks() - assert EvolutionTrack.select().count() == 3 + seed_refractor_tracks() + assert RefractorTrack.select().count() == 3 def test_seed_correct_card_types(json_tracks): """The set of card_type values persisted must match the JSON exactly. - Why: card_type is used as a discriminator throughout the evolution engine. + Why: card_type is used as a discriminator throughout the refractor engine. An unexpected value (e.g. 'pitcher' instead of 'sp') would cause - track-lookup misses and silently skip evolution scoring for that role. + track-lookup misses and silently skip refractor scoring for that role. """ - seed_evolution_tracks() + seed_refractor_tracks() expected_types = {d["card_type"] for d in json_tracks} - actual_types = {t.card_type for t in EvolutionTrack.select()} + actual_types = {t.card_type for t in RefractorTrack.select()} assert actual_types == expected_types def test_seed_thresholds_ascending(): """For every track, t1 < t2 < t3 < t4. - Why: The evolution engine uses these thresholds to determine tier + Why: The refractor engine uses these thresholds to determine tier boundaries. If they are not strictly ascending, tier comparisons would produce incorrect or undefined results (e.g. a player could simultaneously satisfy tier 3 and not satisfy tier 2). """ - seed_evolution_tracks() - for track in EvolutionTrack.select(): - assert ( - track.t1_threshold < track.t2_threshold - ), f"{track.name}: t1 ({track.t1_threshold}) >= t2 ({track.t2_threshold})" - assert ( - track.t2_threshold < track.t3_threshold - ), f"{track.name}: t2 ({track.t2_threshold}) >= t3 ({track.t3_threshold})" - assert ( - track.t3_threshold < track.t4_threshold - ), f"{track.name}: t3 ({track.t3_threshold}) >= t4 ({track.t4_threshold})" + seed_refractor_tracks() + for track in RefractorTrack.select(): + assert track.t1_threshold < track.t2_threshold, ( + f"{track.name}: t1 ({track.t1_threshold}) >= t2 ({track.t2_threshold})" + ) + assert track.t2_threshold < track.t3_threshold, ( + f"{track.name}: t2 ({track.t2_threshold}) >= t3 ({track.t3_threshold})" + ) + assert track.t3_threshold < track.t4_threshold, ( + f"{track.name}: t3 ({track.t3_threshold}) >= t4 ({track.t4_threshold})" + ) def test_seed_thresholds_positive(): @@ -86,10 +86,10 @@ def test_seed_thresholds_positive(): Why: A zero or negative threshold would mean a card starts the game already evolved (tier >= 1 at 0 accumulated stat points), which would - bypass the entire progression system. + bypass the entire refractor progression system. """ - seed_evolution_tracks() - for track in EvolutionTrack.select(): + seed_refractor_tracks() + for track in RefractorTrack.select(): assert track.t1_threshold > 0, f"{track.name}: t1_threshold is not positive" assert track.t2_threshold > 0, f"{track.name}: t2_threshold is not positive" assert track.t3_threshold > 0, f"{track.name}: t3_threshold is not positive" @@ -99,29 +99,29 @@ def test_seed_thresholds_positive(): def test_seed_formula_present(): """Every persisted track must have a non-empty formula string. - Why: The formula is evaluated at runtime to compute a player's evolution + Why: The formula is evaluated at runtime to compute a player's refractor score. An empty formula would cause either a Python eval error or - silently produce 0 for every player, halting all evolution progress. + silently produce 0 for every player, halting all refractor progress. """ - seed_evolution_tracks() - for track in EvolutionTrack.select(): - assert ( - track.formula and track.formula.strip() - ), f"{track.name}: formula is empty or whitespace-only" + seed_refractor_tracks() + for track in RefractorTrack.select(): + assert track.formula and track.formula.strip(), ( + f"{track.name}: formula is empty or whitespace-only" + ) def test_seed_idempotent(): - """Calling seed_evolution_tracks() twice must still yield exactly 3 rows. + """Calling seed_refractor_tracks() twice must still yield exactly 3 rows. Why: The seed is designed to be safe to re-run (e.g. as part of a migration or CI bootstrap). If it inserts duplicates on a second call, - the unique constraint on EvolutionTrack.name would raise an IntegrityError + the unique constraint on RefractorTrack.name would raise an IntegrityError in PostgreSQL, and in SQLite it would silently create phantom rows that corrupt tier-lookup joins. """ - seed_evolution_tracks() - seed_evolution_tracks() - assert EvolutionTrack.select().count() == 3 + seed_refractor_tracks() + seed_refractor_tracks() + assert RefractorTrack.select().count() == 3 def test_seed_updates_on_rerun(json_tracks): @@ -135,24 +135,24 @@ def test_seed_updates_on_rerun(json_tracks): build up silently and the production database would diverge from the checked-in JSON without any visible error. """ - seed_evolution_tracks() + seed_refractor_tracks() # Pick the first track and corrupt its t1_threshold first_def = json_tracks[0] - track = EvolutionTrack.get(EvolutionTrack.name == first_def["name"]) + track = RefractorTrack.get(RefractorTrack.name == first_def["name"]) original_t1 = track.t1_threshold corrupted_value = original_t1 + 9999 track.t1_threshold = corrupted_value track.save() # Confirm the corruption took effect before re-seeding - track_check = EvolutionTrack.get(EvolutionTrack.name == first_def["name"]) + track_check = RefractorTrack.get(RefractorTrack.name == first_def["name"]) assert track_check.t1_threshold == corrupted_value # Re-seed — should restore the JSON value - seed_evolution_tracks() + seed_refractor_tracks() - restored = EvolutionTrack.get(EvolutionTrack.name == first_def["name"]) + restored = RefractorTrack.get(RefractorTrack.name == first_def["name"]) assert restored.t1_threshold == first_def["t1_threshold"], ( f"Expected t1_threshold={first_def['t1_threshold']} after re-seed, " f"got {restored.t1_threshold}" diff --git a/tests/test_evolution_state_api.py b/tests/test_refractor_state_api.py similarity index 88% rename from tests/test_evolution_state_api.py rename to tests/test_refractor_state_api.py index a9b7e47..f7a8689 100644 --- a/tests/test_evolution_state_api.py +++ b/tests/test_refractor_state_api.py @@ -1,11 +1,11 @@ -"""Integration tests for the evolution card state API endpoints (WP-07). +"""Integration tests for the refractor card state API endpoints (WP-07). Tests cover: GET /api/v2/teams/{team_id}/evolutions - GET /api/v2/evolution/cards/{card_id} + GET /api/v2/refractor/cards/{card_id} All tests require a live PostgreSQL connection (POSTGRES_HOST env var) and -assume the evolution schema migration (WP-04) has already been applied. +assume the refractor schema migration (WP-04) has already been applied. Tests auto-skip when POSTGRES_HOST is not set. Test data is inserted via psycopg2 before each module fixture runs and @@ -18,9 +18,9 @@ Object graph built by fixtures cardset_row -- a seeded cardset row player_row -- a seeded player row (FK: rarity, cardset) team_row -- a seeded team row - track_row -- a seeded evolution_track row (batter) + track_row -- a seeded refractor_track row (batter) card_row -- a seeded card row (FK: player, team, pack, pack_type, cardset) - state_row -- a seeded evolution_card_state row (FK: player, team, track) + state_row -- a seeded refractor_card_state row (FK: player, team, track) Test matrix ----------- @@ -30,8 +30,8 @@ Test matrix test_list_pagination -- page/per_page params slice results correctly test_get_card_state_shape -- single card returns all required response fields test_get_card_state_next_threshold -- next_threshold is the threshold for tier above current - test_get_card_id_resolves_player -- card_id joins Card -> Player/Team -> EvolutionCardState - test_get_card_404_no_state -- card with no EvolutionCardState returns 404 + test_get_card_id_resolves_player -- card_id joins Card -> Player/Team -> RefractorCardState + test_get_card_404_no_state -- card with no RefractorCardState returns 404 test_duplicate_cards_share_state -- two cards same player+team return the same state row test_auth_required -- missing token returns 401 on both endpoints """ @@ -63,7 +63,7 @@ def seeded_data(pg_conn): Insertion order respects FK dependencies: rarity -> cardset -> player pack_type (needs cardset) -> pack (needs team + pack_type) -> card - evolution_track -> evolution_card_state + refractor_track -> refractor_card_state """ cur = pg_conn.cursor() @@ -130,7 +130,7 @@ def seeded_data(pg_conn): # Evolution tracks cur.execute( """ - INSERT INTO evolution_track (name, card_type, formula, + INSERT INTO refractor_track (name, card_type, formula, t1_threshold, t2_threshold, t3_threshold, t4_threshold) VALUES ('WP07 Batter Track', 'batter', 'pa + tb * 2', 37, 149, 448, 896) @@ -142,7 +142,7 @@ def seeded_data(pg_conn): cur.execute( """ - INSERT INTO evolution_track (name, card_type, formula, + INSERT INTO refractor_track (name, card_type, formula, t1_threshold, t2_threshold, t3_threshold, t4_threshold) VALUES ('WP07 SP Track', 'sp', 'ip + k', 10, 40, 120, 240) @@ -230,7 +230,7 @@ def seeded_data(pg_conn): # Batter player at tier 1, value 87.5 cur.execute( """ - INSERT INTO evolution_card_state + INSERT INTO refractor_card_state (player_id, team_id, track_id, current_tier, current_value, fully_evolved, last_evaluated_at) VALUES (%s, %s, %s, 1, 87.5, false, '2026-03-12T14:00:00Z') @@ -258,7 +258,7 @@ def seeded_data(pg_conn): } # Teardown: delete in reverse FK order - cur.execute("DELETE FROM evolution_card_state WHERE id = %s", (state_id,)) + cur.execute("DELETE FROM refractor_card_state WHERE id = %s", (state_id,)) cur.execute( "DELETE FROM card WHERE id = ANY(%s)", ([card_id, card2_id, card_no_state_id],), @@ -266,7 +266,7 @@ def seeded_data(pg_conn): cur.execute("DELETE FROM pack WHERE id = ANY(%s)", ([pack_id, pack2_id, pack3_id],)) cur.execute("DELETE FROM pack_type WHERE id = %s", (pack_type_id,)) cur.execute( - "DELETE FROM evolution_track WHERE id = ANY(%s)", + "DELETE FROM refractor_track WHERE id = ANY(%s)", ([batter_track_id, sp_track_id],), ) cur.execute( @@ -315,15 +315,15 @@ def test_list_team_evolutions(client, seeded_data): def test_list_filter_by_card_type(client, seeded_data, pg_conn): """card_type filter includes states whose track.card_type matches and excludes others. - Seeds a second evolution_card_state for player2 (sp track) then queries + Seeds a second refractor_card_state for player2 (sp track) then queries card_type=batter (returns 1) and card_type=sp (returns 1). - Verifies the JOIN to evolution_track and the WHERE predicate on card_type. + Verifies the JOIN to refractor_track and the WHERE predicate on card_type. """ cur = pg_conn.cursor() # Add a state for the sp player so we have two types in this team cur.execute( """ - INSERT INTO evolution_card_state + INSERT INTO refractor_card_state (player_id, team_id, track_id, current_tier, current_value, fully_evolved) VALUES (%s, %s, %s, 0, 0.0, false) RETURNING id @@ -352,7 +352,7 @@ def test_list_filter_by_card_type(client, seeded_data, pg_conn): assert sp_data["count"] == 1 assert sp_data["items"][0]["player_id"] == seeded_data["player2_id"] finally: - cur.execute("DELETE FROM evolution_card_state WHERE id = %s", (sp_state_id,)) + cur.execute("DELETE FROM refractor_card_state WHERE id = %s", (sp_state_id,)) pg_conn.commit() @@ -368,7 +368,7 @@ def test_list_filter_by_tier(client, seeded_data, pg_conn): # Advance to tier 2 cur.execute( - "UPDATE evolution_card_state SET current_tier = 2 WHERE id = %s", + "UPDATE refractor_card_state SET current_tier = 2 WHERE id = %s", (seeded_data["state_id"],), ) pg_conn.commit() @@ -391,7 +391,7 @@ def test_list_filter_by_tier(client, seeded_data, pg_conn): assert t2_data["items"][0]["current_tier"] == 2 finally: cur.execute( - "UPDATE evolution_card_state SET current_tier = 1 WHERE id = %s", + "UPDATE refractor_card_state SET current_tier = 1 WHERE id = %s", (seeded_data["state_id"],), ) pg_conn.commit() @@ -408,7 +408,7 @@ def test_list_pagination(client, seeded_data, pg_conn): cur = pg_conn.cursor() cur.execute( """ - INSERT INTO evolution_card_state + INSERT INTO refractor_card_state (player_id, team_id, track_id, current_tier, current_value, fully_evolved) VALUES (%s, %s, %s, 0, 0.0, false) RETURNING id @@ -441,18 +441,18 @@ def test_list_pagination(client, seeded_data, pg_conn): assert data1["items"][0]["player_id"] != data2["items"][0]["player_id"] finally: - cur.execute("DELETE FROM evolution_card_state WHERE id = %s", (extra_state_id,)) + cur.execute("DELETE FROM refractor_card_state WHERE id = %s", (extra_state_id,)) pg_conn.commit() # --------------------------------------------------------------------------- -# Tests: GET /api/v2/evolution/cards/{card_id} +# Tests: GET /api/v2/refractor/cards/{card_id} # --------------------------------------------------------------------------- @_skip_no_pg def test_get_card_state_shape(client, seeded_data): - """GET /evolution/cards/{card_id} returns all required fields. + """GET /refractor/cards/{card_id} returns all required fields. Verifies the full response envelope: player_id, team_id, current_tier, current_value, fully_evolved, @@ -460,7 +460,7 @@ def test_get_card_state_shape(client, seeded_data): with id, name, card_type, formula, and t1-t4 thresholds. """ card_id = seeded_data["card_id"] - resp = client.get(f"/api/v2/evolution/cards/{card_id}", headers=AUTH_HEADER) + resp = client.get(f"/api/v2/refractor/cards/{card_id}", headers=AUTH_HEADER) assert resp.status_code == 200 data = resp.json() @@ -505,29 +505,29 @@ def test_get_card_state_next_threshold(client, seeded_data, pg_conn): # Advance to tier 2 cur.execute( - "UPDATE evolution_card_state SET current_tier = 2 WHERE id = %s", (state_id,) + "UPDATE refractor_card_state SET current_tier = 2 WHERE id = %s", (state_id,) ) pg_conn.commit() try: - resp = client.get(f"/api/v2/evolution/cards/{card_id}", headers=AUTH_HEADER) + resp = client.get(f"/api/v2/refractor/cards/{card_id}", headers=AUTH_HEADER) assert resp.status_code == 200 assert resp.json()["next_threshold"] == 448 # t3_threshold # Advance to tier 4 (fully evolved) cur.execute( - "UPDATE evolution_card_state SET current_tier = 4, fully_evolved = true " + "UPDATE refractor_card_state SET current_tier = 4, fully_evolved = true " "WHERE id = %s", (state_id,), ) pg_conn.commit() - resp2 = client.get(f"/api/v2/evolution/cards/{card_id}", headers=AUTH_HEADER) + resp2 = client.get(f"/api/v2/refractor/cards/{card_id}", headers=AUTH_HEADER) assert resp2.status_code == 200 assert resp2.json()["next_threshold"] is None finally: cur.execute( - "UPDATE evolution_card_state SET current_tier = 1, fully_evolved = false " + "UPDATE refractor_card_state SET current_tier = 1, fully_evolved = false " "WHERE id = %s", (state_id,), ) @@ -538,11 +538,11 @@ def test_get_card_state_next_threshold(client, seeded_data, pg_conn): def test_get_card_id_resolves_player(client, seeded_data): """card_id is resolved via the Card table to obtain (player_id, team_id). - The endpoint must JOIN Card -> Player + Team to find the EvolutionCardState. + The endpoint must JOIN Card -> Player + Team to find the RefractorCardState. Verifies that card_id correctly maps to the right player's evolution state. """ card_id = seeded_data["card_id"] - resp = client.get(f"/api/v2/evolution/cards/{card_id}", headers=AUTH_HEADER) + resp = client.get(f"/api/v2/refractor/cards/{card_id}", headers=AUTH_HEADER) assert resp.status_code == 200 data = resp.json() assert data["player_id"] == seeded_data["player_id"] @@ -551,20 +551,20 @@ def test_get_card_id_resolves_player(client, seeded_data): @_skip_no_pg def test_get_card_404_no_state(client, seeded_data): - """GET /evolution/cards/{card_id} returns 404 when no EvolutionCardState exists. + """GET /refractor/cards/{card_id} returns 404 when no RefractorCardState exists. card_no_state_id is a card row for player2 on the team, but no - evolution_card_state row was created for player2. The endpoint must + refractor_card_state row was created for player2. The endpoint must return 404, not 500 or an empty response. """ card_id = seeded_data["card_no_state_id"] - resp = client.get(f"/api/v2/evolution/cards/{card_id}", headers=AUTH_HEADER) + resp = client.get(f"/api/v2/refractor/cards/{card_id}", headers=AUTH_HEADER) assert resp.status_code == 404 @_skip_no_pg def test_duplicate_cards_share_state(client, seeded_data): - """Two Card rows for the same player+team share one EvolutionCardState. + """Two Card rows for the same player+team share one RefractorCardState. card_id and card2_id both belong to player_id on team_id. Because the unique-(player,team) constraint means only one state row can exist, both @@ -573,8 +573,8 @@ def test_duplicate_cards_share_state(client, seeded_data): card1_id = seeded_data["card_id"] card2_id = seeded_data["card2_id"] - resp1 = client.get(f"/api/v2/evolution/cards/{card1_id}", headers=AUTH_HEADER) - resp2 = client.get(f"/api/v2/evolution/cards/{card2_id}", headers=AUTH_HEADER) + resp1 = client.get(f"/api/v2/refractor/cards/{card1_id}", headers=AUTH_HEADER) + resp2 = client.get(f"/api/v2/refractor/cards/{card2_id}", headers=AUTH_HEADER) assert resp1.status_code == 200 assert resp2.status_code == 200 @@ -597,7 +597,7 @@ def test_auth_required(client, seeded_data): Verifies that the valid_token dependency is enforced on: GET /api/v2/teams/{id}/evolutions - GET /api/v2/evolution/cards/{id} + GET /api/v2/refractor/cards/{id} """ team_id = seeded_data["team_id"] card_id = seeded_data["card_id"] @@ -605,5 +605,5 @@ def test_auth_required(client, seeded_data): resp_list = client.get(f"/api/v2/teams/{team_id}/evolutions") assert resp_list.status_code == 401 - resp_card = client.get(f"/api/v2/evolution/cards/{card_id}") + resp_card = client.get(f"/api/v2/refractor/cards/{card_id}") assert resp_card.status_code == 401 diff --git a/tests/test_evolution_track_api.py b/tests/test_refractor_track_api.py similarity index 85% rename from tests/test_evolution_track_api.py rename to tests/test_refractor_track_api.py index 2545db3..bc34b47 100644 --- a/tests/test_evolution_track_api.py +++ b/tests/test_refractor_track_api.py @@ -1,11 +1,11 @@ -"""Integration tests for the evolution track catalog API endpoints (WP-06). +"""Integration tests for the refractor track catalog API endpoints (WP-06). Tests cover: - GET /api/v2/evolution/tracks - GET /api/v2/evolution/tracks/{track_id} + GET /api/v2/refractor/tracks + GET /api/v2/refractor/tracks/{track_id} All tests require a live PostgreSQL connection (POSTGRES_HOST env var) and -assume the evolution schema migration (WP-04) has already been applied. +assume the refractor schema migration (WP-04) has already been applied. Tests auto-skip when POSTGRES_HOST is not set. Test data is inserted via psycopg2 before the test module runs and deleted @@ -45,7 +45,7 @@ def seeded_tracks(pg_conn): for name, card_type, formula, t1, t2, t3, t4 in _SEED_TRACKS: cur.execute( """ - INSERT INTO evolution_track + INSERT INTO refractor_track (name, card_type, formula, t1_threshold, t2_threshold, t3_threshold, t4_threshold) VALUES (%s, %s, %s, %s, %s, %s, %s) ON CONFLICT (card_type) DO UPDATE SET @@ -62,7 +62,7 @@ def seeded_tracks(pg_conn): ids.append(cur.fetchone()[0]) pg_conn.commit() yield ids - cur.execute("DELETE FROM evolution_track WHERE id = ANY(%s)", (ids,)) + cur.execute("DELETE FROM refractor_track WHERE id = ANY(%s)", (ids,)) pg_conn.commit() @@ -82,7 +82,7 @@ def test_list_tracks_returns_count_3(client, seeded_tracks): After seeding batter/sp/rp, the table should have exactly those three rows (no other tracks are inserted by other test modules). """ - resp = client.get("/api/v2/evolution/tracks", headers=AUTH_HEADER) + resp = client.get("/api/v2/refractor/tracks", headers=AUTH_HEADER) assert resp.status_code == 200 data = resp.json() assert data["count"] == 3 @@ -92,7 +92,7 @@ def test_list_tracks_returns_count_3(client, seeded_tracks): @_skip_no_pg def test_filter_by_card_type(client, seeded_tracks): """card_type=sp filter returns exactly 1 track with card_type 'sp'.""" - resp = client.get("/api/v2/evolution/tracks?card_type=sp", headers=AUTH_HEADER) + resp = client.get("/api/v2/refractor/tracks?card_type=sp", headers=AUTH_HEADER) assert resp.status_code == 200 data = resp.json() assert data["count"] == 1 @@ -103,7 +103,7 @@ def test_filter_by_card_type(client, seeded_tracks): def test_get_single_track_with_thresholds(client, seeded_tracks): """GET /tracks/{id} returns a track dict with formula and t1-t4 thresholds.""" track_id = seeded_tracks[0] # batter - resp = client.get(f"/api/v2/evolution/tracks/{track_id}", headers=AUTH_HEADER) + resp = client.get(f"/api/v2/refractor/tracks/{track_id}", headers=AUTH_HEADER) assert resp.status_code == 200 data = resp.json() assert data["card_type"] == "batter" @@ -117,16 +117,16 @@ def test_get_single_track_with_thresholds(client, seeded_tracks): @_skip_no_pg def test_404_for_nonexistent_track(client, seeded_tracks): """GET /tracks/999999 returns 404 when the track does not exist.""" - resp = client.get("/api/v2/evolution/tracks/999999", headers=AUTH_HEADER) + resp = client.get("/api/v2/refractor/tracks/999999", headers=AUTH_HEADER) assert resp.status_code == 404 @_skip_no_pg def test_auth_required(client, seeded_tracks): """Requests without a Bearer token return 401 for both endpoints.""" - resp_list = client.get("/api/v2/evolution/tracks") + resp_list = client.get("/api/v2/refractor/tracks") assert resp_list.status_code == 401 track_id = seeded_tracks[0] - resp_single = client.get(f"/api/v2/evolution/tracks/{track_id}") + resp_single = client.get(f"/api/v2/refractor/tracks/{track_id}") assert resp_single.status_code == 401 From 500a8f3848b9a566807911c9b903a004d554dc35 Mon Sep 17 00:00:00 2001 From: Cal Corum Date: Mon, 23 Mar 2026 14:17:03 -0500 Subject: [PATCH 02/21] =?UTF-8?q?fix:=20complete=20remaining=20evolution?= =?UTF-8?q?=E2=86=92refractor=20renames=20from=20review?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Rename route /{team_id}/evolutions → /{team_id}/refractors - Rename function initialize_card_evolution → initialize_card_refractor - Rename index names in migration SQL - Update all test references Co-Authored-By: Claude Opus 4.6 (1M context) --- app/routers_v2/cards.py | 4 +-- app/routers_v2/teams.py | 4 +-- app/services/refractor_init.py | 4 +-- ...26-03-23_rename_evolution_to_refractor.sql | 4 +++ tests/test_refractor_init.py | 32 +++++++++---------- tests/test_refractor_state_api.py | 28 ++++++++-------- 6 files changed, 40 insertions(+), 36 deletions(-) diff --git a/app/routers_v2/cards.py b/app/routers_v2/cards.py index b814c51..6ef3617 100644 --- a/app/routers_v2/cards.py +++ b/app/routers_v2/cards.py @@ -16,7 +16,7 @@ from ..db_engine import ( DoesNotExist, ) from ..dependencies import oauth2_scheme, valid_token -from ..services.refractor_init import _determine_card_type, initialize_card_evolution +from ..services.refractor_init import _determine_card_type, initialize_card_refractor router = APIRouter(prefix="/api/v2/cards", tags=["cards"]) @@ -227,7 +227,7 @@ async def v1_cards_post(cards: CardModel, token: str = Depends(oauth2_scheme)): try: this_player = Player.get_by_id(x.player_id) card_type = _determine_card_type(this_player) - initialize_card_evolution(x.player_id, x.team_id, card_type) + initialize_card_refractor(x.player_id, x.team_id, card_type) except Exception: logging.exception( "refractor hook: unexpected error for player_id=%s team_id=%s", diff --git a/app/routers_v2/teams.py b/app/routers_v2/teams.py index e630c9a..6d1146a 100644 --- a/app/routers_v2/teams.py +++ b/app/routers_v2/teams.py @@ -1530,8 +1530,8 @@ async def delete_team(team_id, token: str = Depends(oauth2_scheme)): raise HTTPException(status_code=500, detail=f"Team {team_id} was not deleted") -@router.get("/{team_id}/evolutions") -async def list_team_evolutions( +@router.get("/{team_id}/refractors") +async def list_team_refractors( team_id: int, card_type: Optional[str] = Query(default=None), tier: Optional[int] = Query(default=None), diff --git a/app/services/refractor_init.py b/app/services/refractor_init.py index 9e49c9f..6f3780d 100644 --- a/app/services/refractor_init.py +++ b/app/services/refractor_init.py @@ -3,7 +3,7 @@ WP-10: Pack opening hook — refractor_card_state initialization. Public API ---------- -initialize_card_evolution(player_id, team_id, card_type) +initialize_card_refractor(player_id, team_id, card_type) Get-or-create a RefractorCardState for the (player_id, team_id) pair. Returns the state instance on success, or None if initialization fails (missing track, integrity error, etc.). Never raises. @@ -53,7 +53,7 @@ def _determine_card_type(player) -> str: return "batter" -def initialize_card_evolution( +def initialize_card_refractor( player_id: int, team_id: int, card_type: str, diff --git a/migrations/2026-03-23_rename_evolution_to_refractor.sql b/migrations/2026-03-23_rename_evolution_to_refractor.sql index c2a3d3f..48c6f0b 100644 --- a/migrations/2026-03-23_rename_evolution_to_refractor.sql +++ b/migrations/2026-03-23_rename_evolution_to_refractor.sql @@ -13,3 +13,7 @@ ALTER TABLE evolution_track RENAME TO refractor_track; ALTER TABLE evolution_card_state RENAME TO refractor_card_state; ALTER TABLE evolution_tier_boost RENAME TO refractor_tier_boost; ALTER TABLE evolution_cosmetic RENAME TO refractor_cosmetic; + +-- Rename indexes to match new table names +ALTER INDEX IF EXISTS evolution_card_state_player_team_uniq RENAME TO refractor_card_state_player_team_uniq; +ALTER INDEX IF EXISTS evolution_tier_boost_track_tier_type_target_uniq RENAME TO refractor_tier_boost_track_tier_type_target_uniq; diff --git a/tests/test_refractor_init.py b/tests/test_refractor_init.py index d92f80f..d8748bf 100644 --- a/tests/test_refractor_init.py +++ b/tests/test_refractor_init.py @@ -1,7 +1,7 @@ """ Tests for WP-10: refractor_card_state initialization on pack opening. -Covers `app/services/refractor_init.py` — the `initialize_card_evolution` +Covers `app/services/refractor_init.py` — the `initialize_card_refractor` function that creates an RefractorCardState row when a card is first acquired. Test strategy: @@ -30,7 +30,7 @@ from app.db_engine import ( RefractorTrack, Player, ) -from app.services.refractor_init import _determine_card_type, initialize_card_evolution +from app.services.refractor_init import _determine_card_type, initialize_card_refractor # --------------------------------------------------------------------------- @@ -154,12 +154,12 @@ class TestDetermineCardType: # --------------------------------------------------------------------------- -# Integration tests — initialize_card_evolution +# Integration tests — initialize_card_refractor # --------------------------------------------------------------------------- class TestInitializeCardEvolution: - """Integration tests for initialize_card_evolution against in-memory SQLite. + """Integration tests for initialize_card_refractor against in-memory SQLite. Each test relies on the conftest autouse fixture to get a clean database. We create tracks for all three card types so the function can always find @@ -170,7 +170,7 @@ class TestInitializeCardEvolution: def seed_tracks(self): """Create one RefractorTrack per card_type before each test. - initialize_card_evolution does a DB lookup for a track matching the + initialize_card_refractor does a DB lookup for a track matching the card_type. If no track exists the function must not crash (it should log and return None), but having tracks present lets us verify the happy path for all three types without repeating setup in every test. @@ -189,7 +189,7 @@ class TestInitializeCardEvolution: - track matches the player's card_type (batter here) """ player = _make_player(rarity, "2B") - state = initialize_card_evolution(player.player_id, team.id, "batter") + state = initialize_card_refractor(player.player_id, team.id, "batter") assert state is not None assert state.player_id == player.player_id @@ -208,7 +208,7 @@ class TestInitializeCardEvolution: """ player = _make_player(rarity, "SS") # First call creates the state - state1 = initialize_card_evolution(player.player_id, team.id, "batter") + state1 = initialize_card_refractor(player.player_id, team.id, "batter") assert state1 is not None # Simulate partial evolution progress @@ -217,7 +217,7 @@ class TestInitializeCardEvolution: state1.save() # Second call (duplicate card) must not reset progress - state2 = initialize_card_evolution(player.player_id, team.id, "batter") + state2 = initialize_card_refractor(player.player_id, team.id, "batter") assert state2 is not None # Exactly one row in the database @@ -246,8 +246,8 @@ class TestInitializeCardEvolution: player_a = _make_player(rarity, "LF") player_b = _make_player(rarity, "RF") - state_a = initialize_card_evolution(player_a.player_id, team.id, "batter") - state_b = initialize_card_evolution(player_b.player_id, team.id, "batter") + state_a = initialize_card_refractor(player_a.player_id, team.id, "batter") + state_b = initialize_card_refractor(player_b.player_id, team.id, "batter") assert state_a is not None assert state_b is not None @@ -264,7 +264,7 @@ class TestInitializeCardEvolution: state links to the sp track, not the batter track. """ player = _make_player(rarity, "SP") - state = initialize_card_evolution(player.player_id, team.id, "sp") + state = initialize_card_refractor(player.player_id, team.id, "sp") assert state is not None assert state.track_id == self.sp_track.id @@ -272,7 +272,7 @@ class TestInitializeCardEvolution: def test_rp_card_gets_rp_track(self, rarity, team): """A relief pitcher (RP or CP) is assigned the 'rp' RefractorTrack.""" player = _make_player(rarity, "RP") - state = initialize_card_evolution(player.player_id, team.id, "rp") + state = initialize_card_refractor(player.player_id, team.id, "rp") assert state is not None assert state.track_id == self.rp_track.id @@ -291,7 +291,7 @@ class TestInitializeCardEvolution: # Delete the sp track to simulate missing seed data self.sp_track.delete_instance() - result = initialize_card_evolution(player.player_id, team.id, "sp") + result = initialize_card_refractor(player.player_id, team.id, "sp") assert result is None def test_card_type_from_pos1_batter(self, rarity, team): @@ -302,7 +302,7 @@ class TestInitializeCardEvolution: """ player = _make_player(rarity, "3B") card_type = _determine_card_type(player) - state = initialize_card_evolution(player.player_id, team.id, card_type) + state = initialize_card_refractor(player.player_id, team.id, card_type) assert state is not None assert state.track_id == self.batter_track.id @@ -311,7 +311,7 @@ class TestInitializeCardEvolution: """_determine_card_type is wired correctly for a starting pitcher.""" player = _make_player(rarity, "SP") card_type = _determine_card_type(player) - state = initialize_card_evolution(player.player_id, team.id, card_type) + state = initialize_card_refractor(player.player_id, team.id, card_type) assert state is not None assert state.track_id == self.sp_track.id @@ -320,7 +320,7 @@ class TestInitializeCardEvolution: """_determine_card_type correctly routes CP to the rp track.""" player = _make_player(rarity, "CP") card_type = _determine_card_type(player) - state = initialize_card_evolution(player.player_id, team.id, card_type) + state = initialize_card_refractor(player.player_id, team.id, card_type) assert state is not None assert state.track_id == self.rp_track.id diff --git a/tests/test_refractor_state_api.py b/tests/test_refractor_state_api.py index f7a8689..6394219 100644 --- a/tests/test_refractor_state_api.py +++ b/tests/test_refractor_state_api.py @@ -1,7 +1,7 @@ """Integration tests for the refractor card state API endpoints (WP-07). Tests cover: - GET /api/v2/teams/{team_id}/evolutions + GET /api/v2/teams/{team_id}/refractors GET /api/v2/refractor/cards/{card_id} All tests require a live PostgreSQL connection (POSTGRES_HOST env var) and @@ -24,7 +24,7 @@ Object graph built by fixtures Test matrix ----------- - test_list_team_evolutions -- baseline: returns count + items for a team + test_list_team_refractors -- baseline: returns count + items for a team test_list_filter_by_card_type -- card_type query param filters by track.card_type test_list_filter_by_tier -- tier query param filters by current_tier test_list_pagination -- page/per_page params slice results correctly @@ -288,19 +288,19 @@ def client(): # --------------------------------------------------------------------------- -# Tests: GET /api/v2/teams/{team_id}/evolutions +# Tests: GET /api/v2/teams/{team_id}/refractors # --------------------------------------------------------------------------- @_skip_no_pg -def test_list_team_evolutions(client, seeded_data): - """GET /teams/{id}/evolutions returns count=1 and one item for the seeded state. +def test_list_team_refractors(client, seeded_data): + """GET /teams/{id}/refractors returns count=1 and one item for the seeded state. Verifies the basic list response shape: a dict with 'count' and 'items', and that the single item contains player_id, team_id, and current_tier. """ team_id = seeded_data["team_id"] - resp = client.get(f"/api/v2/teams/{team_id}/evolutions", headers=AUTH_HEADER) + resp = client.get(f"/api/v2/teams/{team_id}/refractors", headers=AUTH_HEADER) assert resp.status_code == 200 data = resp.json() assert data["count"] == 1 @@ -337,7 +337,7 @@ def test_list_filter_by_card_type(client, seeded_data, pg_conn): team_id = seeded_data["team_id"] resp_batter = client.get( - f"/api/v2/teams/{team_id}/evolutions?card_type=batter", headers=AUTH_HEADER + f"/api/v2/teams/{team_id}/refractors?card_type=batter", headers=AUTH_HEADER ) assert resp_batter.status_code == 200 batter_data = resp_batter.json() @@ -345,7 +345,7 @@ def test_list_filter_by_card_type(client, seeded_data, pg_conn): assert batter_data["items"][0]["player_id"] == seeded_data["player_id"] resp_sp = client.get( - f"/api/v2/teams/{team_id}/evolutions?card_type=sp", headers=AUTH_HEADER + f"/api/v2/teams/{team_id}/refractors?card_type=sp", headers=AUTH_HEADER ) assert resp_sp.status_code == 200 sp_data = resp_sp.json() @@ -377,13 +377,13 @@ def test_list_filter_by_tier(client, seeded_data, pg_conn): team_id = seeded_data["team_id"] resp_t1 = client.get( - f"/api/v2/teams/{team_id}/evolutions?tier=1", headers=AUTH_HEADER + f"/api/v2/teams/{team_id}/refractors?tier=1", headers=AUTH_HEADER ) assert resp_t1.status_code == 200 assert resp_t1.json()["count"] == 0 resp_t2 = client.get( - f"/api/v2/teams/{team_id}/evolutions?tier=2", headers=AUTH_HEADER + f"/api/v2/teams/{team_id}/refractors?tier=2", headers=AUTH_HEADER ) assert resp_t2.status_code == 200 t2_data = resp_t2.json() @@ -426,14 +426,14 @@ def test_list_pagination(client, seeded_data, pg_conn): team_id = seeded_data["team_id"] resp1 = client.get( - f"/api/v2/teams/{team_id}/evolutions?page=1&per_page=1", headers=AUTH_HEADER + f"/api/v2/teams/{team_id}/refractors?page=1&per_page=1", headers=AUTH_HEADER ) assert resp1.status_code == 200 data1 = resp1.json() assert len(data1["items"]) == 1 resp2 = client.get( - f"/api/v2/teams/{team_id}/evolutions?page=2&per_page=1", headers=AUTH_HEADER + f"/api/v2/teams/{team_id}/refractors?page=2&per_page=1", headers=AUTH_HEADER ) assert resp2.status_code == 200 data2 = resp2.json() @@ -596,13 +596,13 @@ def test_auth_required(client, seeded_data): """Both endpoints return 401 when no Bearer token is provided. Verifies that the valid_token dependency is enforced on: - GET /api/v2/teams/{id}/evolutions + GET /api/v2/teams/{id}/refractors GET /api/v2/refractor/cards/{id} """ team_id = seeded_data["team_id"] card_id = seeded_data["card_id"] - resp_list = client.get(f"/api/v2/teams/{team_id}/evolutions") + resp_list = client.get(f"/api/v2/teams/{team_id}/refractors") assert resp_list.status_code == 401 resp_card = client.get(f"/api/v2/refractor/cards/{card_id}") From dc937dcabc58485f6ddacddd8ddb6bfa36000e83 Mon Sep 17 00:00:00 2001 From: Cal Corum Date: Mon, 23 Mar 2026 14:23:00 -0500 Subject: [PATCH 03/21] fix: update stale evolution comment in cards.py Co-Authored-By: Claude Opus 4.6 (1M context) --- app/routers_v2/cards.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/routers_v2/cards.py b/app/routers_v2/cards.py index 6ef3617..d56c848 100644 --- a/app/routers_v2/cards.py +++ b/app/routers_v2/cards.py @@ -222,7 +222,7 @@ async def v1_cards_post(cards: CardModel, token: str = Depends(oauth2_scheme)): cost_query.execute() # sheets.post_new_cards(SHEETS_AUTH, lc_id) - # WP-10: initialize evolution state for each new card (fire-and-forget) + # WP-10: initialize refractor state for each new card (fire-and-forget) for x in cards.cards: try: this_player = Player.get_by_id(x.player_id) From f3aab6fb7335bf28da62b114559c5e77cb6efc1e Mon Sep 17 00:00:00 2001 From: Cal Corum Date: Mon, 23 Mar 2026 23:32:01 -0500 Subject: [PATCH 04/21] feat: add limit/pagination to scout_claims endpoint (#149) Closes #149 Co-Authored-By: Claude Sonnet 4.6 --- app/routers_v2/scout_claims.py | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/app/routers_v2/scout_claims.py b/app/routers_v2/scout_claims.py index 501a1b4..2ae3d85 100644 --- a/app/routers_v2/scout_claims.py +++ b/app/routers_v2/scout_claims.py @@ -4,7 +4,7 @@ from typing import Optional import logging import pydantic -from ..db_engine import ScoutClaim, ScoutOpportunity, model_to_dict +from ..db_engine import ScoutClaim, model_to_dict from ..dependencies import oauth2_scheme, valid_token router = APIRouter(prefix="/api/v2/scout_claims", tags=["scout_claims"]) @@ -18,7 +18,9 @@ class ScoutClaimModel(pydantic.BaseModel): @router.get("") async def get_scout_claims( - scout_opportunity_id: Optional[int] = None, claimed_by_team_id: Optional[int] = None + scout_opportunity_id: Optional[int] = None, + claimed_by_team_id: Optional[int] = None, + limit: Optional[int] = 100, ): query = ScoutClaim.select().order_by(ScoutClaim.id) @@ -28,6 +30,10 @@ async def get_scout_claims( if claimed_by_team_id is not None: query = query.where(ScoutClaim.claimed_by_team_id == claimed_by_team_id) + if limit is not None: + limit = max(0, min(limit, 500)) + query = query.limit(limit) + results = [model_to_dict(x, recurse=False) for x in query] return {"count": len(results), "results": results} From 890625e7705eccdba916170f54950eb3e7a32b3b Mon Sep 17 00:00:00 2001 From: Cal Corum Date: Tue, 24 Mar 2026 00:02:08 -0500 Subject: [PATCH 05/21] feat: add limit/pagination to rewards endpoint (#139) Closes #139 Co-Authored-By: Claude Sonnet 4.6 --- app/routers_v2/rewards.py | 111 +++++++++++++++++++++++--------------- 1 file changed, 67 insertions(+), 44 deletions(-) diff --git a/app/routers_v2/rewards.py b/app/routers_v2/rewards.py index ab6aa43..48a50cc 100644 --- a/app/routers_v2/rewards.py +++ b/app/routers_v2/rewards.py @@ -9,10 +9,7 @@ from ..db_engine import Reward, model_to_dict, fn, DoesNotExist from ..dependencies import oauth2_scheme, valid_token -router = APIRouter( - prefix='/api/v2/rewards', - tags=['rewards'] -) +router = APIRouter(prefix="/api/v2/rewards", tags=["rewards"]) class RewardModel(pydantic.BaseModel): @@ -20,18 +17,25 @@ class RewardModel(pydantic.BaseModel): season: int week: int team_id: int - created: Optional[int] = int(datetime.timestamp(datetime.now())*1000) + created: Optional[int] = int(datetime.timestamp(datetime.now()) * 1000) -@router.get('') +@router.get("") async def get_rewards( - name: Optional[str] = None, in_name: Optional[str] = None, team_id: Optional[int] = None, - season: Optional[int] = None, week: Optional[int] = None, created_after: Optional[int] = None, - flat: Optional[bool] = False, csv: Optional[bool] = None): + name: Optional[str] = None, + in_name: Optional[str] = None, + team_id: Optional[int] = None, + season: Optional[int] = None, + week: Optional[int] = None, + created_after: Optional[int] = None, + flat: Optional[bool] = False, + csv: Optional[bool] = None, + limit: Optional[int] = 100, +): all_rewards = Reward.select().order_by(Reward.id) if all_rewards.count() == 0: - raise HTTPException(status_code=404, detail=f'There are no rewards to filter') + raise HTTPException(status_code=404, detail="There are no rewards to filter") if name is not None: all_rewards = all_rewards.where(fn.Lower(Reward.name) == name.lower()) @@ -49,62 +53,71 @@ async def get_rewards( all_rewards = all_rewards.where(Reward.week == week) if all_rewards.count() == 0: - raise HTTPException(status_code=404, detail=f'No rewards found') + raise HTTPException(status_code=404, detail="No rewards found") + + limit = max(0, min(limit, 500)) + all_rewards = all_rewards.limit(limit) if csv: - data_list = [['id', 'name', 'team', 'daily', 'created']] + data_list = [["id", "name", "team", "daily", "created"]] for line in all_rewards: data_list.append( - [ - line.id, line.name, line.team.id, line.daily, line.created - ] + [line.id, line.name, line.team.id, line.daily, line.created] ) return_val = DataFrame(data_list).to_csv(header=False, index=False) - return Response(content=return_val, media_type='text/csv') + return Response(content=return_val, media_type="text/csv") else: - return_val = {'count': all_rewards.count(), 'rewards': []} + return_val = {"count": all_rewards.count(), "rewards": []} for x in all_rewards: - return_val['rewards'].append(model_to_dict(x, recurse=not flat)) + return_val["rewards"].append(model_to_dict(x, recurse=not flat)) return return_val -@router.get('/{reward_id}') +@router.get("/{reward_id}") async def get_one_reward(reward_id, csv: Optional[bool] = False): try: this_reward = Reward.get_by_id(reward_id) except DoesNotExist: - raise HTTPException(status_code=404, detail=f'No reward found with id {reward_id}') + raise HTTPException( + status_code=404, detail=f"No reward found with id {reward_id}" + ) if csv: data_list = [ - ['id', 'name', 'card_count', 'description'], - [this_reward.id, this_reward.name, this_reward.team.id, this_reward.daily, this_reward.created] + ["id", "name", "card_count", "description"], + [ + this_reward.id, + this_reward.name, + this_reward.team.id, + this_reward.daily, + this_reward.created, + ], ] return_val = DataFrame(data_list).to_csv(header=False, index=False) - return Response(content=return_val, media_type='text/csv') + return Response(content=return_val, media_type="text/csv") else: return_val = model_to_dict(this_reward) return return_val -@router.post('') +@router.post("") async def post_rewards(reward: RewardModel, token: str = Depends(oauth2_scheme)): if not valid_token(token): - logging.warning('Bad Token: [REDACTED]') + logging.warning("Bad Token: [REDACTED]") raise HTTPException( status_code=401, - detail='You are not authorized to post rewards. This event has been logged.' + detail="You are not authorized to post rewards. This event has been logged.", ) reward_data = reward.dict() # Convert milliseconds timestamp to datetime for PostgreSQL - if reward_data.get('created'): - reward_data['created'] = datetime.fromtimestamp(reward_data['created'] / 1000) + if reward_data.get("created"): + reward_data["created"] = datetime.fromtimestamp(reward_data["created"] / 1000) this_reward = Reward(**reward_data) saved = this_reward.save() @@ -114,24 +127,30 @@ async def post_rewards(reward: RewardModel, token: str = Depends(oauth2_scheme)) else: raise HTTPException( status_code=418, - detail='Well slap my ass and call me a teapot; I could not save that cardset' + detail="Well slap my ass and call me a teapot; I could not save that cardset", ) -@router.patch('/{reward_id}') +@router.patch("/{reward_id}") async def patch_reward( - reward_id, name: Optional[str] = None, team_id: Optional[int] = None, created: Optional[int] = None, - token: str = Depends(oauth2_scheme)): + reward_id, + name: Optional[str] = None, + team_id: Optional[int] = None, + created: Optional[int] = None, + token: str = Depends(oauth2_scheme), +): if not valid_token(token): - logging.warning('Bad Token: [REDACTED]') + logging.warning("Bad Token: [REDACTED]") raise HTTPException( status_code=401, - detail='You are not authorized to patch rewards. This event has been logged.' + detail="You are not authorized to patch rewards. This event has been logged.", ) try: this_reward = Reward.get_by_id(reward_id) except DoesNotExist: - raise HTTPException(status_code=404, detail=f'No reward found with id {reward_id}') + raise HTTPException( + status_code=404, detail=f"No reward found with id {reward_id}" + ) if name is not None: this_reward.name = name @@ -147,28 +166,32 @@ async def patch_reward( else: raise HTTPException( status_code=418, - detail='Well slap my ass and call me a teapot; I could not save that rarity' + detail="Well slap my ass and call me a teapot; I could not save that rarity", ) -@router.delete('/{reward_id}') +@router.delete("/{reward_id}") async def delete_reward(reward_id, token: str = Depends(oauth2_scheme)): if not valid_token(token): - logging.warning('Bad Token: [REDACTED]') + logging.warning("Bad Token: [REDACTED]") raise HTTPException( status_code=401, - detail='You are not authorized to delete rewards. This event has been logged.' + detail="You are not authorized to delete rewards. This event has been logged.", ) try: this_reward = Reward.get_by_id(reward_id) except DoesNotExist: - raise HTTPException(status_code=404, detail=f'No reward found with id {reward_id}') + raise HTTPException( + status_code=404, detail=f"No reward found with id {reward_id}" + ) count = this_reward.delete_instance() if count == 1: - raise HTTPException(status_code=200, detail=f'Reward {reward_id} has been deleted') + raise HTTPException( + status_code=200, detail=f"Reward {reward_id} has been deleted" + ) else: - raise HTTPException(status_code=500, detail=f'Reward {reward_id} was not deleted') - - + raise HTTPException( + status_code=500, detail=f"Reward {reward_id} was not deleted" + ) From c185d72f1bb7f1e75d8ead6c4f63b94926a7623a Mon Sep 17 00:00:00 2001 From: Cal Corum Date: Mon, 23 Mar 2026 16:05:17 -0500 Subject: [PATCH 06/21] ci: add dev tag trigger to Docker build workflow Allows deploying to dev environment by pushing a "dev" tag. Dev tags build with :dev Docker tag instead of :production. Co-Authored-By: Claude Opus 4.6 (1M context) --- .gitea/workflows/build.yml | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/.gitea/workflows/build.yml b/.gitea/workflows/build.yml index 4245558..e242b50 100644 --- a/.gitea/workflows/build.yml +++ b/.gitea/workflows/build.yml @@ -1,11 +1,13 @@ # Gitea Actions: Docker Build, Push, and Notify # # CI/CD pipeline for Paper Dynasty Database API: -# - Triggered by pushing a CalVer tag (e.g., 2026.3.11) -# - Builds Docker image and pushes to Docker Hub with version + production tags +# - Triggered by pushing a CalVer tag (e.g., 2026.3.11) or "dev" tag +# - CalVer tags push with version + "production" Docker tags +# - "dev" tag pushes with "dev" Docker tag for the dev environment # - Sends Discord notifications on success/failure # # To release: git tag 2026.3.11 && git push origin 2026.3.11 +# To deploy dev: git tag -f dev && git push origin dev --force name: Build Docker Image @@ -13,6 +15,7 @@ on: push: tags: - '20*' # matches CalVer tags like 2026.3.11 + - 'dev' # dev environment builds jobs: build: @@ -32,6 +35,11 @@ jobs: echo "version=$VERSION" >> $GITHUB_OUTPUT echo "sha_short=$SHA_SHORT" >> $GITHUB_OUTPUT echo "timestamp=$(date -u +%Y-%m-%dT%H:%M:%SZ)" >> $GITHUB_OUTPUT + if [ "$VERSION" = "dev" ]; then + echo "environment=dev" >> $GITHUB_OUTPUT + else + echo "environment=production" >> $GITHUB_OUTPUT + fi - name: Set up Docker Buildx uses: https://github.com/docker/setup-buildx-action@v3 @@ -49,7 +57,7 @@ jobs: push: true tags: | manticorum67/paper-dynasty-database:${{ steps.version.outputs.version }} - manticorum67/paper-dynasty-database:production + manticorum67/paper-dynasty-database:${{ steps.version.outputs.environment }} cache-from: type=registry,ref=manticorum67/paper-dynasty-database:buildcache cache-to: type=registry,ref=manticorum67/paper-dynasty-database:buildcache,mode=max @@ -61,7 +69,7 @@ jobs: echo "" >> $GITHUB_STEP_SUMMARY echo "**Image Tags:**" >> $GITHUB_STEP_SUMMARY echo "- \`manticorum67/paper-dynasty-database:${{ steps.version.outputs.version }}\`" >> $GITHUB_STEP_SUMMARY - echo "- \`manticorum67/paper-dynasty-database:production\`" >> $GITHUB_STEP_SUMMARY + echo "- \`manticorum67/paper-dynasty-database:${{ steps.version.outputs.environment }}\`" >> $GITHUB_STEP_SUMMARY echo "" >> $GITHUB_STEP_SUMMARY echo "**Build Details:**" >> $GITHUB_STEP_SUMMARY echo "- Commit: \`${{ steps.version.outputs.sha_short }}\`" >> $GITHUB_STEP_SUMMARY From d0f45d5d38e091585a9167a48a8a9f23a65804df Mon Sep 17 00:00:00 2001 From: Cal Corum Date: Mon, 23 Mar 2026 21:16:00 -0500 Subject: [PATCH 07/21] ci: switch buildx cache from registry to local volume Replaces type=registry cache (which causes 400 errors from Docker Hub due to stale buildx builders) with type=local backed by a named Docker volume on the runner. Adds cache rotation step to prevent unbounded growth. Co-Authored-By: Claude Opus 4.6 (1M context) --- .gitea/workflows/build.yml | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/.gitea/workflows/build.yml b/.gitea/workflows/build.yml index e242b50..367692f 100644 --- a/.gitea/workflows/build.yml +++ b/.gitea/workflows/build.yml @@ -20,6 +20,9 @@ on: jobs: build: runs-on: ubuntu-latest + container: + volumes: + - pd-buildx-cache:/opt/buildx-cache steps: - name: Checkout code @@ -58,8 +61,13 @@ jobs: tags: | manticorum67/paper-dynasty-database:${{ steps.version.outputs.version }} manticorum67/paper-dynasty-database:${{ steps.version.outputs.environment }} - cache-from: type=registry,ref=manticorum67/paper-dynasty-database:buildcache - cache-to: type=registry,ref=manticorum67/paper-dynasty-database:buildcache,mode=max + cache-from: type=local,src=/opt/buildx-cache/pd-database + cache-to: type=local,dest=/opt/buildx-cache/pd-database-new,mode=max + + - name: Rotate cache + run: | + rm -rf /opt/buildx-cache/pd-database + mv /opt/buildx-cache/pd-database-new /opt/buildx-cache/pd-database - name: Build Summary run: | From f9817b3d042a200f35fb93f4279456123c8fbf41 Mon Sep 17 00:00:00 2001 From: Cal Corum Date: Tue, 24 Mar 2026 00:32:27 -0500 Subject: [PATCH 08/21] feat: add limit/pagination to scout_opportunities endpoint (#148) Closes #148 Co-Authored-By: Claude Sonnet 4.6 --- app/routers_v2/scout_opportunities.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/app/routers_v2/scout_opportunities.py b/app/routers_v2/scout_opportunities.py index 25a3a75..b838d4e 100644 --- a/app/routers_v2/scout_opportunities.py +++ b/app/routers_v2/scout_opportunities.py @@ -5,7 +5,7 @@ from typing import Optional, List import logging import pydantic -from ..db_engine import ScoutOpportunity, ScoutClaim, model_to_dict, fn +from ..db_engine import ScoutOpportunity, ScoutClaim, model_to_dict from ..dependencies import oauth2_scheme, valid_token router = APIRouter(prefix="/api/v2/scout_opportunities", tags=["scout_opportunities"]) @@ -32,8 +32,10 @@ async def get_scout_opportunities( claimed: Optional[bool] = None, expired_before: Optional[int] = None, opener_team_id: Optional[int] = None, + limit: Optional[int] = 100, ): + limit = max(0, min(limit, 500)) query = ScoutOpportunity.select().order_by(ScoutOpportunity.id) if opener_team_id is not None: @@ -50,6 +52,7 @@ async def get_scout_opportunities( else: query = query.where(ScoutOpportunity.id.not_in(claim_subquery)) + query = query.limit(limit) results = [opportunity_to_dict(x, recurse=False) for x in query] return {"count": len(results), "results": results} From 6034b4f1732370a71df9fce81e819f49d58246fd Mon Sep 17 00:00:00 2001 From: Cal Corum Date: Tue, 24 Mar 2026 01:03:12 -0500 Subject: [PATCH 09/21] feat: add limit/pagination to batstats endpoint (#133) Closes #133 Co-Authored-By: Claude Sonnet 4.6 --- app/routers_v2/batstats.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/app/routers_v2/batstats.py b/app/routers_v2/batstats.py index 95c9db9..c60949b 100644 --- a/app/routers_v2/batstats.py +++ b/app/routers_v2/batstats.py @@ -66,7 +66,8 @@ class BatStatReturnList(pydantic.BaseModel): @router.get('', response_model=BatStatReturnList) async def get_batstats( card_id: int = None, player_id: int = None, team_id: int = None, vs_team_id: int = None, week: int = None, - season: int = None, week_start: int = None, week_end: int = None, created: int = None, csv: bool = None): + season: int = None, week_start: int = None, week_end: int = None, created: int = None, csv: bool = None, + limit: Optional[int] = 100): all_stats = BattingStat.select().join(Card).join(Player).order_by(BattingStat.id) if season is not None: @@ -98,6 +99,9 @@ async def get_batstats( # db.close() # raise HTTPException(status_code=404, detail=f'No batting stats found') + limit = max(0, min(limit, 500)) + all_stats = all_stats.limit(limit) + if csv: data_list = [['id', 'card_id', 'player_id', 'cardset', 'team', 'vs_team', 'pos', 'pa', 'ab', 'run', 'hit', 'rbi', 'double', 'triple', 'hr', 'bb', 'so', 'hbp', 'sac', 'ibb', 'gidp', 'sb', 'cs', 'bphr', 'bpfo', 'bp1b', From 0f884a35168ef6ad2882828a832208ee44b6222e Mon Sep 17 00:00:00 2001 From: Cal Corum Date: Tue, 24 Mar 2026 01:32:19 -0500 Subject: [PATCH 10/21] feat: add limit/pagination to events endpoint (#147) Closes #147 Co-Authored-By: Claude Sonnet 4.6 --- app/routers_v2/events.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/app/routers_v2/events.py b/app/routers_v2/events.py index cd989cb..68058f8 100644 --- a/app/routers_v2/events.py +++ b/app/routers_v2/events.py @@ -26,7 +26,7 @@ class EventModel(pydantic.BaseModel): @router.get('') async def v1_events_get( name: Optional[str] = None, in_desc: Optional[str] = None, active: Optional[bool] = None, - csv: Optional[bool] = None): + csv: Optional[bool] = None, limit: Optional[int] = 100): all_events = Event.select().order_by(Event.id) if name is not None: @@ -39,6 +39,8 @@ async def v1_events_get( if active is not None: all_events = all_events.where(Event.active == active) + all_events = all_events.limit(max(0, min(limit, 500))) + if csv: data_list = [['id', 'name', 'short_desc', 'long_desc', 'url', 'thumbnail', 'active']] for line in all_events: From e328ad639a00249271566ca96f317afbc7bd5130 Mon Sep 17 00:00:00 2001 From: Cal Corum Date: Tue, 24 Mar 2026 02:01:50 -0500 Subject: [PATCH 11/21] feat: add limit/pagination to awards endpoint (#132) Add optional limit query param (default 100, max 500) to GET /api/v2/awards. Clamped via max(0, min(limit, 500)) to guard negative values and upper bound. Co-Authored-By: Claude Sonnet 4.6 --- app/routers_v2/awards.py | 97 +++++++++++++++++++++++++--------------- 1 file changed, 61 insertions(+), 36 deletions(-) diff --git a/app/routers_v2/awards.py b/app/routers_v2/awards.py index 3d79030..89ed4bc 100644 --- a/app/routers_v2/awards.py +++ b/app/routers_v2/awards.py @@ -8,16 +8,13 @@ from ..db_engine import Award, model_to_dict, DoesNotExist from ..dependencies import oauth2_scheme, valid_token, PRIVATE_IN_SCHEMA -router = APIRouter( - prefix='/api/v2/awards', - tags=['awards'] -) +router = APIRouter(prefix="/api/v2/awards", tags=["awards"]) class AwardModel(pydantic.BaseModel): name: str season: int - timing: str = 'In-Season' + timing: str = "In-Season" card_id: Optional[int] = None team_id: Optional[int] = None image: Optional[str] = None @@ -28,15 +25,21 @@ class AwardReturnList(pydantic.BaseModel): awards: list[AwardModel] -@router.get('') +@router.get("") async def get_awards( - name: Optional[str] = None, season: Optional[int] = None, timing: Optional[str] = None, - card_id: Optional[int] = None, team_id: Optional[int] = None, image: Optional[str] = None, - csv: Optional[bool] = None): + name: Optional[str] = None, + season: Optional[int] = None, + timing: Optional[str] = None, + card_id: Optional[int] = None, + team_id: Optional[int] = None, + image: Optional[str] = None, + csv: Optional[bool] = None, + limit: int = 100, +): all_awards = Award.select().order_by(Award.id) if all_awards.count() == 0: - raise HTTPException(status_code=404, detail=f'There are no awards to filter') + raise HTTPException(status_code=404, detail="There are no awards to filter") if name is not None: all_awards = all_awards.where(Award.name == name) @@ -51,53 +54,73 @@ async def get_awards( if image is not None: all_awards = all_awards.where(Award.image == image) + limit = max(0, min(limit, 500)) + all_awards = all_awards.limit(limit) + if csv: - data_list = [['id', 'name', 'season', 'timing', 'card', 'team', 'image']] + data_list = [["id", "name", "season", "timing", "card", "team", "image"]] for line in all_awards: - data_list.append([ - line.id, line.name, line.season, line.timing, line.card, line.team, line.image - ]) + data_list.append( + [ + line.id, + line.name, + line.season, + line.timing, + line.card, + line.team, + line.image, + ] + ) return_val = DataFrame(data_list).to_csv(header=False, index=False) - return Response(content=return_val, media_type='text/csv') + return Response(content=return_val, media_type="text/csv") else: - return_val = {'count': all_awards.count(), 'awards': []} + return_val = {"count": all_awards.count(), "awards": []} for x in all_awards: - return_val['awards'].append(model_to_dict(x)) + return_val["awards"].append(model_to_dict(x)) return return_val -@router.get('/{award_id}') +@router.get("/{award_id}") async def get_one_award(award_id, csv: Optional[bool] = None): try: this_award = Award.get_by_id(award_id) except DoesNotExist: - raise HTTPException(status_code=404, detail=f'No award found with id {award_id}') + raise HTTPException( + status_code=404, detail=f"No award found with id {award_id}" + ) if csv: data_list = [ - ['id', 'name', 'season', 'timing', 'card', 'team', 'image'], - [this_award.id, this_award.name, this_award.season, this_award.timing, this_award.card, - this_award.team, this_award.image] + ["id", "name", "season", "timing", "card", "team", "image"], + [ + this_award.id, + this_award.name, + this_award.season, + this_award.timing, + this_award.card, + this_award.team, + this_award.image, + ], ] return_val = DataFrame(data_list).to_csv(header=False, index=False) - return Response(content=return_val, media_type='text/csv') + return Response(content=return_val, media_type="text/csv") else: return_val = model_to_dict(this_award) return return_val -@router.post('', include_in_schema=PRIVATE_IN_SCHEMA) +@router.post("", include_in_schema=PRIVATE_IN_SCHEMA) async def post_awards(award: AwardModel, token: str = Depends(oauth2_scheme)): if not valid_token(token): - logging.warning('Bad Token: [REDACTED]') + logging.warning("Bad Token: [REDACTED]") raise HTTPException( status_code=401, - detail='You are not authorized to post awards. This event has been logged.' + detail="You are not authorized to post awards. This event has been logged.", ) this_award = Award( @@ -106,7 +129,7 @@ async def post_awards(award: AwardModel, token: str = Depends(oauth2_scheme)): timing=award.season, card_id=award.card_id, team_id=award.team_id, - image=award.image + image=award.image, ) saved = this_award.save() @@ -116,28 +139,30 @@ async def post_awards(award: AwardModel, token: str = Depends(oauth2_scheme)): else: raise HTTPException( status_code=418, - detail='Well slap my ass and call me a teapot; I could not save that roster' + detail="Well slap my ass and call me a teapot; I could not save that roster", ) -@router.delete('/{award_id}', include_in_schema=PRIVATE_IN_SCHEMA) +@router.delete("/{award_id}", include_in_schema=PRIVATE_IN_SCHEMA) async def delete_award(award_id, token: str = Depends(oauth2_scheme)): if not valid_token(token): - logging.warning('Bad Token: [REDACTED]') + logging.warning("Bad Token: [REDACTED]") raise HTTPException( status_code=401, - detail='You are not authorized to delete awards. This event has been logged.' + detail="You are not authorized to delete awards. This event has been logged.", ) try: this_award = Award.get_by_id(award_id) except DoesNotExist: - raise HTTPException(status_code=404, detail=f'No award found with id {award_id}') + raise HTTPException( + status_code=404, detail=f"No award found with id {award_id}" + ) count = this_award.delete_instance() if count == 1: - raise HTTPException(status_code=200, detail=f'Award {award_id} has been deleted') + raise HTTPException( + status_code=200, detail=f"Award {award_id} has been deleted" + ) else: - raise HTTPException(status_code=500, detail=f'Award {award_id} was not deleted') - - + raise HTTPException(status_code=500, detail=f"Award {award_id} was not deleted") From 8c9aa55609cb8fb769a0d6f972de05f05eeb15cc Mon Sep 17 00:00:00 2001 From: Cal Corum Date: Tue, 24 Mar 2026 02:32:10 -0500 Subject: [PATCH 12/21] feat: add limit/pagination to pitstats endpoint (#134) Closes #134 Co-Authored-By: Claude Sonnet 4.6 --- app/routers_v2/pitstats.py | 143 ++++++++++++++++++++++++++++--------- 1 file changed, 109 insertions(+), 34 deletions(-) diff --git a/app/routers_v2/pitstats.py b/app/routers_v2/pitstats.py index 82f3883..e540d8b 100644 --- a/app/routers_v2/pitstats.py +++ b/app/routers_v2/pitstats.py @@ -5,14 +5,19 @@ import logging import pydantic from pandas import DataFrame -from ..db_engine import db, PitchingStat, model_to_dict, Card, Player, Current, DoesNotExist +from ..db_engine import ( + db, + PitchingStat, + model_to_dict, + Card, + Player, + Current, + DoesNotExist, +) from ..dependencies import oauth2_scheme, valid_token -router = APIRouter( - prefix='/api/v2/pitstats', - tags=['pitstats'] -) +router = APIRouter(prefix="/api/v2/pitstats", tags=["pitstats"]) class PitStat(pydantic.BaseModel): @@ -40,7 +45,7 @@ class PitStat(pydantic.BaseModel): bsv: Optional[int] = 0 week: int season: int - created: Optional[int] = int(datetime.timestamp(datetime.now())*1000) + created: Optional[int] = int(datetime.timestamp(datetime.now()) * 1000) game_id: int @@ -48,13 +53,23 @@ class PitchingStatModel(pydantic.BaseModel): stats: List[PitStat] -@router.get('') +@router.get("") async def get_pit_stats( - card_id: int = None, player_id: int = None, team_id: int = None, vs_team_id: int = None, week: int = None, - season: int = None, week_start: int = None, week_end: int = None, created: int = None, gs: bool = None, - csv: bool = None): + card_id: int = None, + player_id: int = None, + team_id: int = None, + vs_team_id: int = None, + week: int = None, + season: int = None, + week_start: int = None, + week_end: int = None, + created: int = None, + gs: bool = None, + csv: bool = None, + limit: Optional[int] = 100, +): all_stats = PitchingStat.select().join(Card).join(Player).order_by(PitchingStat.id) - logging.debug(f'pit query:\n\n{all_stats}') + logging.debug(f"pit query:\n\n{all_stats}") if season is not None: all_stats = all_stats.where(PitchingStat.season == season) @@ -83,43 +98,99 @@ async def get_pit_stats( if gs is not None: all_stats = all_stats.where(PitchingStat.gs == 1 if gs else 0) + all_stats = all_stats.limit(max(0, min(limit, 500))) + # if all_stats.count() == 0: # db.close() # raise HTTPException(status_code=404, detail=f'No pitching stats found') if csv: - data_list = [['id', 'card_id', 'player_id', 'cardset', 'team', 'vs_team', 'ip', 'hit', 'run', 'erun', 'so', 'bb', 'hbp', - 'wp', 'balk', 'hr', 'ir', 'irs', 'gs', 'win', 'loss', 'hold', 'sv', 'bsv', 'week', 'season', - 'created', 'game_id', 'roster_num']] + data_list = [ + [ + "id", + "card_id", + "player_id", + "cardset", + "team", + "vs_team", + "ip", + "hit", + "run", + "erun", + "so", + "bb", + "hbp", + "wp", + "balk", + "hr", + "ir", + "irs", + "gs", + "win", + "loss", + "hold", + "sv", + "bsv", + "week", + "season", + "created", + "game_id", + "roster_num", + ] + ] for line in all_stats: data_list.append( [ - line.id, line.card.id, line.card.player.player_id, line.card.player.cardset.name, line.team.abbrev, - line.vs_team.abbrev, line.ip, line.hit, - line.run, line.erun, line.so, line.bb, line.hbp, line.wp, line.balk, line.hr, line.ir, line.irs, - line.gs, line.win, line.loss, line.hold, line.sv, line.bsv, line.week, line.season, line.created, - line.game_id, line.roster_num + line.id, + line.card.id, + line.card.player.player_id, + line.card.player.cardset.name, + line.team.abbrev, + line.vs_team.abbrev, + line.ip, + line.hit, + line.run, + line.erun, + line.so, + line.bb, + line.hbp, + line.wp, + line.balk, + line.hr, + line.ir, + line.irs, + line.gs, + line.win, + line.loss, + line.hold, + line.sv, + line.bsv, + line.week, + line.season, + line.created, + line.game_id, + line.roster_num, ] ) return_val = DataFrame(data_list).to_csv(header=False, index=False) - return Response(content=return_val, media_type='text/csv') + return Response(content=return_val, media_type="text/csv") else: - return_val = {'count': all_stats.count(), 'stats': []} + return_val = {"count": all_stats.count(), "stats": []} for x in all_stats: - return_val['stats'].append(model_to_dict(x, recurse=False)) + return_val["stats"].append(model_to_dict(x, recurse=False)) return return_val -@router.post('') +@router.post("") async def post_pitstat(stats: PitchingStatModel, token: str = Depends(oauth2_scheme)): if not valid_token(token): - logging.warning('Bad Token: [REDACTED]') + logging.warning("Bad Token: [REDACTED]") raise HTTPException( status_code=401, - detail='You are not authorized to post stats. This event has been logged.' + detail="You are not authorized to post stats. This event has been logged.", ) new_stats = [] @@ -149,33 +220,37 @@ async def post_pitstat(stats: PitchingStatModel, token: str = Depends(oauth2_sch bsv=x.bsv, week=x.week, season=x.season, - created=datetime.fromtimestamp(x.created / 1000) if x.created else datetime.now(), - game_id=x.game_id + created=datetime.fromtimestamp(x.created / 1000) + if x.created + else datetime.now(), + game_id=x.game_id, ) new_stats.append(this_stat) with db.atomic(): PitchingStat.bulk_create(new_stats, batch_size=15) - raise HTTPException(status_code=200, detail=f'{len(new_stats)} pitching lines have been added') + raise HTTPException( + status_code=200, detail=f"{len(new_stats)} pitching lines have been added" + ) -@router.delete('/{stat_id}') +@router.delete("/{stat_id}") async def delete_pitstat(stat_id, token: str = Depends(oauth2_scheme)): if not valid_token(token): - logging.warning('Bad Token: [REDACTED]') + logging.warning("Bad Token: [REDACTED]") raise HTTPException( status_code=401, - detail='You are not authorized to delete stats. This event has been logged.' + detail="You are not authorized to delete stats. This event has been logged.", ) try: this_stat = PitchingStat.get_by_id(stat_id) except DoesNotExist: - raise HTTPException(status_code=404, detail=f'No stat found with id {stat_id}') + raise HTTPException(status_code=404, detail=f"No stat found with id {stat_id}") count = this_stat.delete_instance() if count == 1: - raise HTTPException(status_code=200, detail=f'Stat {stat_id} has been deleted') + raise HTTPException(status_code=200, detail=f"Stat {stat_id} has been deleted") else: - raise HTTPException(status_code=500, detail=f'Stat {stat_id} was not deleted') + raise HTTPException(status_code=500, detail=f"Stat {stat_id} was not deleted") From dc88b1539c6ffd9c096db4c01027e4b15c0da6e2 Mon Sep 17 00:00:00 2001 From: Cal Corum Date: Tue, 24 Mar 2026 03:03:13 -0500 Subject: [PATCH 13/21] feat: add limit/pagination to battingcardratings endpoint (#135) Closes #135 Co-Authored-By: Claude Sonnet 4.6 --- app/routers_v2/battingcardratings.py | 31 ++++++++++++++++------------ 1 file changed, 18 insertions(+), 13 deletions(-) diff --git a/app/routers_v2/battingcardratings.py b/app/routers_v2/battingcardratings.py index f945c56..4196dc8 100644 --- a/app/routers_v2/battingcardratings.py +++ b/app/routers_v2/battingcardratings.py @@ -145,6 +145,7 @@ async def get_card_ratings( vs_hand: Literal["R", "L", "vR", "vL"] = None, short_output: bool = False, csv: bool = False, + limit: int = 100, ): this_team = Team.get_or_none(Team.id == team_id) logging.debug(f"Team: {this_team} / has_guide: {this_team.has_guide}") @@ -178,6 +179,8 @@ async def get_card_ratings( ) all_ratings = all_ratings.where(BattingCardRatings.battingcard << set_cards) + all_ratings = all_ratings.limit(max(0, min(limit, 500))) + if csv: # return_val = query_to_csv(all_ratings) return_vals = [model_to_dict(x) for x in all_ratings] @@ -281,7 +284,7 @@ def get_scouting_dfs(cardset_id: list = None): ) ] ), - name=f"Arm OF", + name="Arm OF", ) ) series_list.append( @@ -292,7 +295,7 @@ def get_scouting_dfs(cardset_id: list = None): for x in positions.where(CardPosition.position == "C") ] ), - name=f"Arm C", + name="Arm C", ) ) series_list.append( @@ -303,7 +306,7 @@ def get_scouting_dfs(cardset_id: list = None): for x in positions.where(CardPosition.position == "C") ] ), - name=f"PB C", + name="PB C", ) ) series_list.append( @@ -314,7 +317,7 @@ def get_scouting_dfs(cardset_id: list = None): for x in positions.where(CardPosition.position == "C") ] ), - name=f"Throw C", + name="Throw C", ) ) logging.debug(f"series_list: {series_list}") @@ -334,9 +337,9 @@ async def get_card_scouting(team_id: int, ts: str): "https://ko-fi.com/manticorum/shop" ) - if os.path.isfile(f"storage/batting-ratings.csv"): + if os.path.isfile("storage/batting-ratings.csv"): return FileResponse( - path=f"storage/batting-ratings.csv", + path="storage/batting-ratings.csv", media_type="text/csv", # headers=headers ) @@ -354,7 +357,7 @@ async def post_calc_scouting(token: str = Depends(oauth2_scheme)): status_code=401, detail="You are not authorized to calculate card ratings." ) - logging.warning(f"Re-calculating batting ratings\n\n") + logging.warning("Re-calculating batting ratings\n\n") output = get_scouting_dfs() first = ["player_id", "player_name", "cardset_name", "rarity", "hand", "variant"] @@ -370,9 +373,9 @@ async def post_calc_scouting(token: str = Depends(oauth2_scheme)): @router.get("/basic") async def get_basic_scouting(cardset_id: list = Query(default=None)): - if os.path.isfile(f"storage/batting-basic.csv"): + if os.path.isfile("storage/batting-basic.csv"): return FileResponse( - path=f"storage/batting-basic.csv", + path="storage/batting-basic.csv", media_type="text/csv", # headers=headers ) @@ -390,7 +393,7 @@ async def post_calc_basic(token: str = Depends(oauth2_scheme)): status_code=401, detail="You are not authorized to calculate basic ratings." ) - logging.warning(f"Re-calculating basic batting ratings\n\n") + logging.warning("Re-calculating basic batting ratings\n\n") raw_data = get_scouting_dfs() logging.debug(f"output: {raw_data}") @@ -667,9 +670,11 @@ async def get_player_ratings( if variant is not None: all_cards = all_cards.where(BattingCard.variant << variant) - all_ratings = BattingCardRatings.select().where( - BattingCardRatings.battingcard << all_cards - ).order_by(BattingCardRatings.id) + all_ratings = ( + BattingCardRatings.select() + .where(BattingCardRatings.battingcard << all_cards) + .order_by(BattingCardRatings.id) + ) return_val = { "count": all_ratings.count(), From 2f5694272183bb22e0485c7c2c445ba4296b0d4c Mon Sep 17 00:00:00 2001 From: Cal Corum Date: Tue, 24 Mar 2026 04:02:46 -0500 Subject: [PATCH 14/21] feat: add limit/pagination to pitchingcardratings endpoint (#136) Closes #136 Co-Authored-By: Claude Sonnet 4.6 --- app/routers_v2/pitchingcardratings.py | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/app/routers_v2/pitchingcardratings.py b/app/routers_v2/pitchingcardratings.py index fcff541..294ef14 100644 --- a/app/routers_v2/pitchingcardratings.py +++ b/app/routers_v2/pitchingcardratings.py @@ -143,6 +143,7 @@ async def get_card_ratings( short_output: bool = False, csv: bool = False, cardset_id: list = Query(default=None), + limit: int = 100, token: str = Depends(oauth2_scheme), ): if not valid_token(token): @@ -168,6 +169,8 @@ async def get_card_ratings( ) all_ratings = all_ratings.where(PitchingCardRatings.pitchingcard << set_cards) + all_ratings = all_ratings.limit(max(0, min(limit, 500))) + if csv: return_val = query_to_csv(all_ratings) return Response(content=return_val, media_type="text/csv") @@ -231,10 +234,10 @@ def get_scouting_dfs(cardset_id: list = None): series_list = [ pd.Series( - dict([(x.player.player_id, x.range) for x in positions]), name=f"Range P" + dict([(x.player.player_id, x.range) for x in positions]), name="Range P" ), pd.Series( - dict([(x.player.player_id, x.error) for x in positions]), name=f"Error P" + dict([(x.player.player_id, x.error) for x in positions]), name="Error P" ), ] logging.debug(f"series_list: {series_list}") @@ -274,7 +277,7 @@ async def post_calc_scouting(token: str = Depends(oauth2_scheme)): status_code=401, detail="You are not authorized to calculate card ratings." ) - logging.warning(f"Re-calculating pitching ratings\n\n") + logging.warning("Re-calculating pitching ratings\n\n") output = get_scouting_dfs() first = ["player_id", "player_name", "cardset_name", "rarity", "hand", "variant"] @@ -310,7 +313,7 @@ async def post_calc_basic(token: str = Depends(oauth2_scheme)): status_code=401, detail="You are not authorized to calculate basic ratings." ) - logging.warning(f"Re-calculating basic pitching ratings\n\n") + logging.warning("Re-calculating basic pitching ratings\n\n") raw_data = get_scouting_dfs() logging.debug(f"output: {raw_data}") From 9391591263bff4b8f76e7f29d5377fe98e1e0434 Mon Sep 17 00:00:00 2001 From: Cal Corum Date: Tue, 24 Mar 2026 04:32:15 -0500 Subject: [PATCH 15/21] feat: add limit/pagination to mlbplayers endpoint (#141) Closes #141 Co-Authored-By: Claude Sonnet 4.6 --- app/routers_v2/mlbplayers.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/app/routers_v2/mlbplayers.py b/app/routers_v2/mlbplayers.py index 2cdb29c..f2a0e67 100644 --- a/app/routers_v2/mlbplayers.py +++ b/app/routers_v2/mlbplayers.py @@ -73,6 +73,7 @@ async def get_players( key_mlbam: list = Query(default=None), offense_col: list = Query(default=None), csv: Optional[bool] = False, + limit: int = 100, ): all_players = MlbPlayer.select().order_by(MlbPlayer.id) @@ -101,6 +102,8 @@ async def get_players( if offense_col is not None: all_players = all_players.where(MlbPlayer.offense_col << offense_col) + all_players = all_players.limit(max(0, min(limit, 500))) + if csv: return_val = query_to_csv(all_players) return Response(content=return_val, media_type="text/csv") @@ -222,7 +225,7 @@ async def post_one_player(player: PlayerModel, token: str = Depends(oauth2_schem | (MlbPlayer.key_bbref == player.key_bbref) ) if dupes.count() > 0: - logging.info(f"POST /mlbplayers/one - dupes found:") + logging.info("POST /mlbplayers/one - dupes found:") for x in dupes: logging.info(f"{x}") raise HTTPException( From 15aac6cb73ffc05d1694ed288ca28770143191d9 Mon Sep 17 00:00:00 2001 From: Cal Corum Date: Tue, 24 Mar 2026 05:02:35 -0500 Subject: [PATCH 16/21] feat: add limit/pagination to results endpoint (#137) Closes #137 Co-Authored-By: Claude Sonnet 4.6 --- app/routers_v2/results.py | 283 ++++++++++++++++++++++++++------------ 1 file changed, 198 insertions(+), 85 deletions(-) diff --git a/app/routers_v2/results.py b/app/routers_v2/results.py index cabfcc5..4e41f7b 100644 --- a/app/routers_v2/results.py +++ b/app/routers_v2/results.py @@ -8,10 +8,7 @@ from ..db_engine import Result, model_to_dict, Team, DataError, DoesNotExist from ..dependencies import oauth2_scheme, valid_token -router = APIRouter( - prefix='/api/v2/results', - tags=['results'] -) +router = APIRouter(prefix="/api/v2/results", tags=["results"]) class ResultModel(pydantic.BaseModel): @@ -31,15 +28,29 @@ class ResultModel(pydantic.BaseModel): game_type: str -@router.get('') +@router.get("") async def get_results( - away_team_id: Optional[int] = None, home_team_id: Optional[int] = None, team_one_id: Optional[int] = None, - team_two_id: Optional[int] = None, away_score_min: Optional[int] = None, away_score_max: Optional[int] = None, - home_score_min: Optional[int] = None, home_score_max: Optional[int] = None, bothscore_min: Optional[int] = None, - bothscore_max: Optional[int] = None, season: Optional[int] = None, week: Optional[int] = None, - week_start: Optional[int] = None, week_end: Optional[int] = None, ranked: Optional[bool] = None, - short_game: Optional[bool] = None, game_type: Optional[str] = None, vs_ai: Optional[bool] = None, - csv: Optional[bool] = None): + away_team_id: Optional[int] = None, + home_team_id: Optional[int] = None, + team_one_id: Optional[int] = None, + team_two_id: Optional[int] = None, + away_score_min: Optional[int] = None, + away_score_max: Optional[int] = None, + home_score_min: Optional[int] = None, + home_score_max: Optional[int] = None, + bothscore_min: Optional[int] = None, + bothscore_max: Optional[int] = None, + season: Optional[int] = None, + week: Optional[int] = None, + week_start: Optional[int] = None, + week_end: Optional[int] = None, + ranked: Optional[bool] = None, + short_game: Optional[bool] = None, + game_type: Optional[str] = None, + vs_ai: Optional[bool] = None, + csv: Optional[bool] = None, + limit: int = 100, +): all_results = Result.select() # if all_results.count() == 0: @@ -51,28 +62,40 @@ async def get_results( this_team = Team.get_by_id(away_team_id) all_results = all_results.where(Result.away_team == this_team) except DoesNotExist: - raise HTTPException(status_code=404, detail=f'No team found with id {away_team_id}') + raise HTTPException( + status_code=404, detail=f"No team found with id {away_team_id}" + ) if home_team_id is not None: try: this_team = Team.get_by_id(home_team_id) all_results = all_results.where(Result.home_team == this_team) except DoesNotExist: - raise HTTPException(status_code=404, detail=f'No team found with id {home_team_id}') + raise HTTPException( + status_code=404, detail=f"No team found with id {home_team_id}" + ) if team_one_id is not None: try: this_team = Team.get_by_id(team_one_id) - all_results = all_results.where((Result.home_team == this_team) | (Result.away_team == this_team)) + all_results = all_results.where( + (Result.home_team == this_team) | (Result.away_team == this_team) + ) except DoesNotExist: - raise HTTPException(status_code=404, detail=f'No team found with id {team_one_id}') + raise HTTPException( + status_code=404, detail=f"No team found with id {team_one_id}" + ) if team_two_id is not None: try: this_team = Team.get_by_id(team_two_id) - all_results = all_results.where((Result.home_team == this_team) | (Result.away_team == this_team)) + all_results = all_results.where( + (Result.home_team == this_team) | (Result.away_team == this_team) + ) except DoesNotExist: - raise HTTPException(status_code=404, detail=f'No team found with id {team_two_id}') + raise HTTPException( + status_code=404, detail=f"No team found with id {team_two_id}" + ) if away_score_min is not None: all_results = all_results.where(Result.away_score >= away_score_min) @@ -87,10 +110,14 @@ async def get_results( all_results = all_results.where(Result.home_score <= home_score_max) if bothscore_min is not None: - all_results = all_results.where((Result.home_score >= bothscore_min) & (Result.away_score >= bothscore_min)) + all_results = all_results.where( + (Result.home_score >= bothscore_min) & (Result.away_score >= bothscore_min) + ) if bothscore_max is not None: - all_results = all_results.where((Result.home_score <= bothscore_max) & (Result.away_score <= bothscore_max)) + all_results = all_results.where( + (Result.home_score <= bothscore_max) & (Result.away_score <= bothscore_max) + ) if season is not None: all_results = all_results.where(Result.season == season) @@ -114,6 +141,8 @@ async def get_results( all_results = all_results.where(Result.game_type == game_type) all_results = all_results.order_by(Result.id) + limit = max(0, min(limit, 500)) + all_results = all_results.limit(limit) # Not functional # if vs_ai is not None: # AwayTeam = Team.alias() @@ -134,60 +163,115 @@ async def get_results( # logging.info(f'Result Query:\n\n{all_results}') if csv: - data_list = [['id', 'away_abbrev', 'home_abbrev', 'away_score', 'home_score', 'away_tv', 'home_tv', - 'game_type', 'season', 'week', 'short_game', 'ranked']] + data_list = [ + [ + "id", + "away_abbrev", + "home_abbrev", + "away_score", + "home_score", + "away_tv", + "home_tv", + "game_type", + "season", + "week", + "short_game", + "ranked", + ] + ] for line in all_results: - data_list.append([ - line.id, line.away_team.abbrev, line.home_team.abbrev, line.away_score, line.home_score, - line.away_team_value, line.home_team_value, line.game_type if line.game_type else 'minor-league', - line.season, line.week, line.short_game, line.ranked - ]) + data_list.append( + [ + line.id, + line.away_team.abbrev, + line.home_team.abbrev, + line.away_score, + line.home_score, + line.away_team_value, + line.home_team_value, + line.game_type if line.game_type else "minor-league", + line.season, + line.week, + line.short_game, + line.ranked, + ] + ) return_val = DataFrame(data_list).to_csv(header=False, index=False) - return Response(content=return_val, media_type='text/csv') + return Response(content=return_val, media_type="text/csv") else: - return_val = {'count': all_results.count(), 'results': []} + return_val = {"count": all_results.count(), "results": []} for x in all_results: - return_val['results'].append(model_to_dict(x)) + return_val["results"].append(model_to_dict(x)) return return_val -@router.get('/{result_id}') +@router.get("/{result_id}") async def get_one_results(result_id, csv: Optional[bool] = None): try: this_result = Result.get_by_id(result_id) except DoesNotExist: - raise HTTPException(status_code=404, detail=f'No result found with id {result_id}') + raise HTTPException( + status_code=404, detail=f"No result found with id {result_id}" + ) if csv: data_list = [ - ['id', 'away_abbrev', 'home_abbrev', 'away_score', 'home_score', 'away_tv', 'home_tv', 'game_type', - 'season', 'week', 'game_type'], - [this_result.id, this_result.away_team.abbrev, this_result.away_team.abbrev, this_result.away_score, - this_result.home_score, this_result.away_team_value, this_result.home_team_value, - this_result.game_type if this_result.game_type else 'minor-league', - this_result.season, this_result.week, this_result.game_type] + [ + "id", + "away_abbrev", + "home_abbrev", + "away_score", + "home_score", + "away_tv", + "home_tv", + "game_type", + "season", + "week", + "game_type", + ], + [ + this_result.id, + this_result.away_team.abbrev, + this_result.away_team.abbrev, + this_result.away_score, + this_result.home_score, + this_result.away_team_value, + this_result.home_team_value, + this_result.game_type if this_result.game_type else "minor-league", + this_result.season, + this_result.week, + this_result.game_type, + ], ] return_val = DataFrame(data_list).to_csv(header=False, index=False) - return Response(content=return_val, media_type='text/csv') + return Response(content=return_val, media_type="text/csv") else: return_val = model_to_dict(this_result) return return_val -@router.get('/team/{team_id}') +@router.get("/team/{team_id}") async def get_team_results( - team_id: int, season: Optional[int] = None, week: Optional[int] = None, csv: Optional[bool] = False): - all_results = Result.select().where((Result.away_team_id == team_id) | (Result.home_team_id == team_id)).order_by(Result.id) + team_id: int, + season: Optional[int] = None, + week: Optional[int] = None, + csv: Optional[bool] = False, +): + all_results = ( + Result.select() + .where((Result.away_team_id == team_id) | (Result.home_team_id == team_id)) + .order_by(Result.id) + ) try: this_team = Team.get_by_id(team_id) - except DoesNotExist as e: - logging.error(f'Unknown team id {team_id} trying to pull team results') - raise HTTPException(404, f'Team id {team_id} not found') + except DoesNotExist: + logging.error(f"Unknown team id {team_id} trying to pull team results") + raise HTTPException(404, f"Team id {team_id} not found") if season is not None: all_results = all_results.where(Result.season == season) @@ -224,31 +308,38 @@ async def get_team_results( if csv: data_list = [ - ['team_id', 'ranked_wins', 'ranked_losses', 'casual_wins', 'casual_losses', 'team_ranking'], - [team_id, r_wins, r_loss, c_wins, c_loss, this_team.ranking] + [ + "team_id", + "ranked_wins", + "ranked_losses", + "casual_wins", + "casual_losses", + "team_ranking", + ], + [team_id, r_wins, r_loss, c_wins, c_loss, this_team.ranking], ] return_val = DataFrame(data_list).to_csv(header=False, index=False) - return Response(content=return_val, media_type='text/csv') + return Response(content=return_val, media_type="text/csv") else: return_val = { - 'team': model_to_dict(this_team), - 'ranked_wins': r_wins, - 'ranked_losses': r_loss, - 'casual_wins': c_wins, - 'casual_losses': c_loss, + "team": model_to_dict(this_team), + "ranked_wins": r_wins, + "ranked_losses": r_loss, + "casual_wins": c_wins, + "casual_losses": c_loss, } return return_val -@router.post('') +@router.post("") async def post_result(result: ResultModel, token: str = Depends(oauth2_scheme)): if not valid_token(token): - logging.warning('Bad Token: [REDACTED]') + logging.warning("Bad Token: [REDACTED]") raise HTTPException( status_code=401, - detail='You are not authorized to post results. This event has been logged.' + detail="You are not authorized to post results. This event has been logged.", ) this_result = Result(**result.__dict__) @@ -256,24 +347,28 @@ async def post_result(result: ResultModel, token: str = Depends(oauth2_scheme)): if result.ranked: if not result.away_team_ranking: - error = f'Ranked game did not include away team ({result.away_team_id}) ranking.' + error = f"Ranked game did not include away team ({result.away_team_id}) ranking." logging.error(error) raise DataError(error) if not result.home_team_ranking: - error = f'Ranked game did not include home team ({result.home_team_id}) ranking.' + error = f"Ranked game did not include home team ({result.home_team_id}) ranking." logging.error(error) raise DataError(error) k_value = 20 if result.short_game else 60 ratio = (result.home_team_ranking - result.away_team_ranking) / 400 - exp_score = 1 / (1 + (10 ** ratio)) + exp_score = 1 / (1 + (10**ratio)) away_win = True if result.away_score > result.home_score else False total_delta = k_value * exp_score - high_delta = total_delta * exp_score if exp_score > .5 else total_delta * (1 - exp_score) + high_delta = ( + total_delta * exp_score + if exp_score > 0.5 + else total_delta * (1 - exp_score) + ) low_delta = total_delta - high_delta # exp_score > .5 means away team is favorite - if exp_score > .5 and away_win: + if exp_score > 0.5 and away_win: final_delta = low_delta away_delta = low_delta * 3 home_delta = -low_delta @@ -281,7 +376,7 @@ async def post_result(result: ResultModel, token: str = Depends(oauth2_scheme)): final_delta = high_delta away_delta = high_delta * 3 home_delta = -high_delta - elif exp_score <= .5 and not away_win: + elif exp_score <= 0.5 and not away_win: final_delta = low_delta away_delta = -low_delta home_delta = low_delta * 3 @@ -294,18 +389,20 @@ async def post_result(result: ResultModel, token: str = Depends(oauth2_scheme)): away_delta = 0 home_delta = 0 - logging.debug(f'/results ranking deltas\n\nk_value: {k_value} / ratio: {ratio} / ' - f'exp_score: {exp_score} / away_win: {away_win} / total_delta: {total_delta} / ' - f'high_delta: {high_delta} / low_delta: {low_delta} / final_delta: {final_delta} / ') + logging.debug( + f"/results ranking deltas\n\nk_value: {k_value} / ratio: {ratio} / " + f"exp_score: {exp_score} / away_win: {away_win} / total_delta: {total_delta} / " + f"high_delta: {high_delta} / low_delta: {low_delta} / final_delta: {final_delta} / " + ) away_team = Team.get_by_id(result.away_team_id) away_team.ranking += away_delta away_team.save() - logging.info(f'Just updated {away_team.abbrev} ranking to {away_team.ranking}') + logging.info(f"Just updated {away_team.abbrev} ranking to {away_team.ranking}") home_team = Team.get_by_id(result.home_team_id) home_team.ranking += home_delta home_team.save() - logging.info(f'Just updated {home_team.abbrev} ranking to {home_team.ranking}') + logging.info(f"Just updated {home_team.abbrev} ranking to {home_team.ranking}") if saved == 1: return_val = model_to_dict(this_result) @@ -313,27 +410,38 @@ async def post_result(result: ResultModel, token: str = Depends(oauth2_scheme)): else: raise HTTPException( status_code=418, - detail='Well slap my ass and call me a teapot; I could not save that roster' + detail="Well slap my ass and call me a teapot; I could not save that roster", ) -@router.patch('/{result_id}') +@router.patch("/{result_id}") async def patch_result( - result_id, away_team_id: Optional[int] = None, home_team_id: Optional[int] = None, - away_score: Optional[int] = None, home_score: Optional[int] = None, away_team_value: Optional[int] = None, - home_team_value: Optional[int] = None, scorecard: Optional[str] = None, week: Optional[int] = None, - season: Optional[int] = None, short_game: Optional[bool] = None, game_type: Optional[str] = None, - token: str = Depends(oauth2_scheme)): + result_id, + away_team_id: Optional[int] = None, + home_team_id: Optional[int] = None, + away_score: Optional[int] = None, + home_score: Optional[int] = None, + away_team_value: Optional[int] = None, + home_team_value: Optional[int] = None, + scorecard: Optional[str] = None, + week: Optional[int] = None, + season: Optional[int] = None, + short_game: Optional[bool] = None, + game_type: Optional[str] = None, + token: str = Depends(oauth2_scheme), +): if not valid_token(token): - logging.warning('Bad Token: [REDACTED]') + logging.warning("Bad Token: [REDACTED]") raise HTTPException( status_code=401, - detail='You are not authorized to patch results. This event has been logged.' + detail="You are not authorized to patch results. This event has been logged.", ) try: this_result = Result.get_by_id(result_id) except DoesNotExist: - raise HTTPException(status_code=404, detail=f'No result found with id {result_id}') + raise HTTPException( + status_code=404, detail=f"No result found with id {result_id}" + ) if away_team_id is not None: this_result.away_team_id = away_team_id @@ -377,27 +485,32 @@ async def patch_result( else: raise HTTPException( status_code=418, - detail='Well slap my ass and call me a teapot; I could not save that event' + detail="Well slap my ass and call me a teapot; I could not save that event", ) -@router.delete('/{result_id}') +@router.delete("/{result_id}") async def delete_result(result_id, token: str = Depends(oauth2_scheme)): if not valid_token(token): - logging.warning('Bad Token: [REDACTED]') + logging.warning("Bad Token: [REDACTED]") raise HTTPException( status_code=401, - detail='You are not authorized to post results. This event has been logged.' + detail="You are not authorized to post results. This event has been logged.", ) try: this_result = Result.get_by_id(result_id) except DoesNotExist: - raise HTTPException(status_code=404, detail=f'No result found with id {result_id}') + raise HTTPException( + status_code=404, detail=f"No result found with id {result_id}" + ) count = this_result.delete_instance() if count == 1: - raise HTTPException(status_code=200, detail=f'Result {result_id} has been deleted') + raise HTTPException( + status_code=200, detail=f"Result {result_id} has been deleted" + ) else: - raise HTTPException(status_code=500, detail=f'Result {result_id} was not deleted') - + raise HTTPException( + status_code=500, detail=f"Result {result_id} was not deleted" + ) From 4f693b122810adf9d358fdb3b90fdcfc6bd82cbd Mon Sep 17 00:00:00 2001 From: Cal Corum Date: Tue, 24 Mar 2026 05:32:28 -0500 Subject: [PATCH 17/21] feat: add limit/pagination to stratgame (games) endpoint (#138) Closes #138 Co-Authored-By: Claude Sonnet 4.6 --- app/routers_v2/stratgame.py | 113 ++++++++++++++++++++++-------------- 1 file changed, 70 insertions(+), 43 deletions(-) diff --git a/app/routers_v2/stratgame.py b/app/routers_v2/stratgame.py index 1a3fa7b..73f44cf 100644 --- a/app/routers_v2/stratgame.py +++ b/app/routers_v2/stratgame.py @@ -8,10 +8,7 @@ from ..db_engine import StratGame, model_to_dict, fn from ..dependencies import oauth2_scheme, valid_token -router = APIRouter( - prefix='/api/v2/games', - tags=['games'] -) +router = APIRouter(prefix="/api/v2/games", tags=["games"]) class GameModel(pydantic.BaseModel): @@ -35,13 +32,22 @@ class GameList(pydantic.BaseModel): games: List[GameModel] -@router.get('') +@router.get("") async def get_games( - season: list = Query(default=None), forfeit: Optional[bool] = None, away_team_id: list = Query(default=None), - home_team_id: list = Query(default=None), team1_id: list = Query(default=None), - team2_id: list = Query(default=None), game_type: list = Query(default=None), ranked: Optional[bool] = None, - short_game: Optional[bool] = None, csv: Optional[bool] = False, short_output: bool = False, - gauntlet_id: Optional[int] = None): + season: list = Query(default=None), + forfeit: Optional[bool] = None, + away_team_id: list = Query(default=None), + home_team_id: list = Query(default=None), + team1_id: list = Query(default=None), + team2_id: list = Query(default=None), + game_type: list = Query(default=None), + ranked: Optional[bool] = None, + short_game: Optional[bool] = None, + csv: Optional[bool] = False, + short_output: bool = False, + gauntlet_id: Optional[int] = None, + limit: int = 100, +): all_games = StratGame.select().order_by(StratGame.id) if season is not None: @@ -68,49 +74,70 @@ async def get_games( if short_game is not None: all_games = all_games.where(StratGame.short_game == short_game) if gauntlet_id is not None: - all_games = all_games.where(StratGame.game_type.contains(f'gauntlet-{gauntlet_id}')) + all_games = all_games.where( + StratGame.game_type.contains(f"gauntlet-{gauntlet_id}") + ) + + all_games = all_games.limit(max(0, min(limit, 500))) if csv: return_vals = [model_to_dict(x) for x in all_games] for x in return_vals: - x['away_abbrev'] = x['away_team']['abbrev'] - x['home_abbrev'] = x['home_team']['abbrev'] - del x['away_team'], x['home_team'] + x["away_abbrev"] = x["away_team"]["abbrev"] + x["home_abbrev"] = x["home_team"]["abbrev"] + del x["away_team"], x["home_team"] - output = pd.DataFrame(return_vals)[[ - 'id', 'away_abbrev', 'home_abbrev', 'away_score', 'home_score', 'away_team_value', 'home_team_value', - 'game_type', 'season', 'week', 'short_game', 'ranked' - ]] + output = pd.DataFrame(return_vals)[ + [ + "id", + "away_abbrev", + "home_abbrev", + "away_score", + "home_score", + "away_team_value", + "home_team_value", + "game_type", + "season", + "week", + "short_game", + "ranked", + ] + ] - return Response(content=output.to_csv(index=False), media_type='text/csv') + return Response(content=output.to_csv(index=False), media_type="text/csv") - return_val = {'count': all_games.count(), 'games': [ - model_to_dict(x, recurse=not short_output) for x in all_games - ]} + return_val = { + "count": all_games.count(), + "games": [model_to_dict(x, recurse=not short_output) for x in all_games], + } return return_val -@router.get('/{game_id}') +@router.get("/{game_id}") async def get_one_game(game_id: int): this_game = StratGame.get_or_none(StratGame.id == game_id) if not this_game: - raise HTTPException(status_code=404, detail=f'StratGame ID {game_id} not found') + raise HTTPException(status_code=404, detail=f"StratGame ID {game_id} not found") g_result = model_to_dict(this_game) return g_result -@router.patch('/{game_id}') +@router.patch("/{game_id}") async def patch_game( - game_id: int, game_type: Optional[str] = None, away_score: Optional[int] = None, - home_score: Optional[int] = None, token: str = Depends(oauth2_scheme)): + game_id: int, + game_type: Optional[str] = None, + away_score: Optional[int] = None, + home_score: Optional[int] = None, + token: str = Depends(oauth2_scheme), +): if not valid_token(token): - logging.warning('patch_game - Bad Token: [REDACTED]') - raise HTTPException(status_code=401, detail='Unauthorized') + logging.warning("patch_game - Bad Token: [REDACTED]") + raise HTTPException(status_code=401, detail="Unauthorized") this_game = StratGame.get_or_none(StratGame.id == game_id) if not this_game: - raise HTTPException(status_code=404, detail=f'StratGame ID {game_id} not found') + raise HTTPException(status_code=404, detail=f"StratGame ID {game_id} not found") if away_score is not None: this_game.away_score = away_score @@ -123,14 +150,14 @@ async def patch_game( g_result = model_to_dict(this_game) return g_result else: - raise HTTPException(status_code=500, detail=f'Unable to patch game {game_id}') + raise HTTPException(status_code=500, detail=f"Unable to patch game {game_id}") -@router.post('') +@router.post("") async def post_game(this_game: GameModel, token: str = Depends(oauth2_scheme)): if not valid_token(token): - logging.warning('post_games - Bad Token: [REDACTED]') - raise HTTPException(status_code=401, detail='Unauthorized') + logging.warning("post_games - Bad Token: [REDACTED]") + raise HTTPException(status_code=401, detail="Unauthorized") this_game = StratGame(**this_game.dict()) @@ -141,25 +168,25 @@ async def post_game(this_game: GameModel, token: str = Depends(oauth2_scheme)): else: raise HTTPException( status_code=418, - detail='Well slap my ass and call me a teapot; I could not save that game' + detail="Well slap my ass and call me a teapot; I could not save that game", ) -@router.delete('/{game_id}') +@router.delete("/{game_id}") async def delete_game(game_id: int, token: str = Depends(oauth2_scheme)): if not valid_token(token): - logging.warning('delete_game - Bad Token: [REDACTED]') - raise HTTPException(status_code=401, detail='Unauthorized') + logging.warning("delete_game - Bad Token: [REDACTED]") + raise HTTPException(status_code=401, detail="Unauthorized") this_game = StratGame.get_or_none(StratGame.id == game_id) if not this_game: - raise HTTPException(status_code=404, detail=f'StratGame ID {game_id} not found') + raise HTTPException(status_code=404, detail=f"StratGame ID {game_id} not found") count = this_game.delete_instance() if count == 1: - return f'StratGame {game_id} has been deleted' + return f"StratGame {game_id} has been deleted" else: - raise HTTPException(status_code=500, detail=f'StratGame {game_id} could not be deleted') - - + raise HTTPException( + status_code=500, detail=f"StratGame {game_id} could not be deleted" + ) From 2da984f1eb1161b384f7c6eb4d8644ea1c655005 Mon Sep 17 00:00:00 2001 From: Cal Corum Date: Tue, 24 Mar 2026 06:02:02 -0500 Subject: [PATCH 18/21] feat: add limit/pagination to gauntletrewards endpoint (#145) Closes #145 Co-Authored-By: Claude Sonnet 4.6 --- app/routers_v2/gauntletrewards.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/app/routers_v2/gauntletrewards.py b/app/routers_v2/gauntletrewards.py index 6022da6..607a125 100644 --- a/app/routers_v2/gauntletrewards.py +++ b/app/routers_v2/gauntletrewards.py @@ -30,6 +30,7 @@ async def v1_gauntletreward_get( reward_id: list = Query(default=None), win_num: Optional[int] = None, loss_max: Optional[int] = None, + limit: int = 100, ): all_rewards = GauntletReward.select().order_by(GauntletReward.id) @@ -46,6 +47,9 @@ async def v1_gauntletreward_get( all_rewards = all_rewards.order_by(-GauntletReward.loss_max, GauntletReward.win_num) + limit = max(0, min(limit, 500)) + all_rewards = all_rewards.limit(limit) + return_val = {"count": all_rewards.count(), "rewards": []} for x in all_rewards: return_val["rewards"].append(model_to_dict(x)) From 9d471ec1deb2d9c209330602410a18ab94a0efc1 Mon Sep 17 00:00:00 2001 From: Cal Corum Date: Tue, 24 Mar 2026 06:32:10 -0500 Subject: [PATCH 19/21] feat: add limit/pagination to gamerewards endpoint (#144) Closes #144 Co-Authored-By: Claude Sonnet 4.6 --- app/routers_v2/gamerewards.py | 111 +++++++++++++++++++++------------- 1 file changed, 70 insertions(+), 41 deletions(-) diff --git a/app/routers_v2/gamerewards.py b/app/routers_v2/gamerewards.py index 724f12a..0afb6ec 100644 --- a/app/routers_v2/gamerewards.py +++ b/app/routers_v2/gamerewards.py @@ -8,10 +8,7 @@ from ..db_engine import GameRewards, model_to_dict, DoesNotExist from ..dependencies import oauth2_scheme, valid_token -router = APIRouter( - prefix='/api/v2/gamerewards', - tags=['gamerewards'] -) +router = APIRouter(prefix="/api/v2/gamerewards", tags=["gamerewards"]) class GameRewardModel(pydantic.BaseModel): @@ -21,10 +18,15 @@ class GameRewardModel(pydantic.BaseModel): money: Optional[int] = None -@router.get('') +@router.get("") async def v1_gamerewards_get( - name: Optional[str] = None, pack_type_id: Optional[int] = None, player_id: Optional[int] = None, - money: Optional[int] = None, csv: Optional[bool] = None): + name: Optional[str] = None, + pack_type_id: Optional[int] = None, + player_id: Optional[int] = None, + money: Optional[int] = None, + csv: Optional[bool] = None, + limit: int = 100, +): all_rewards = GameRewards.select().order_by(GameRewards.id) # if all_rewards.count() == 0: @@ -40,61 +42,76 @@ async def v1_gamerewards_get( if money is not None: all_rewards = all_rewards.where(GameRewards.money == money) + limit = max(0, min(limit, 500)) + all_rewards = all_rewards.limit(limit) + if csv: - data_list = [['id', 'pack_type_id', 'player_id', 'money']] + data_list = [["id", "pack_type_id", "player_id", "money"]] for line in all_rewards: - data_list.append([ - line.id, line.pack_type_id if line.pack_type else None, line.player_id if line.player else None, - line.money - ]) + data_list.append( + [ + line.id, + line.pack_type_id if line.pack_type else None, + line.player_id if line.player else None, + line.money, + ] + ) return_val = DataFrame(data_list).to_csv(header=False, index=False) - return Response(content=return_val, media_type='text/csv') + return Response(content=return_val, media_type="text/csv") else: - return_val = {'count': all_rewards.count(), 'gamerewards': []} + return_val = {"count": all_rewards.count(), "gamerewards": []} for x in all_rewards: - return_val['gamerewards'].append(model_to_dict(x)) + return_val["gamerewards"].append(model_to_dict(x)) return return_val -@router.get('/{gameaward_id}') +@router.get("/{gameaward_id}") async def v1_gamerewards_get_one(gamereward_id, csv: Optional[bool] = None): try: this_game_reward = GameRewards.get_by_id(gamereward_id) except DoesNotExist: - raise HTTPException(status_code=404, detail=f'No game reward found with id {gamereward_id}') + raise HTTPException( + status_code=404, detail=f"No game reward found with id {gamereward_id}" + ) if csv: data_list = [ - ['id', 'pack_type_id', 'player_id', 'money'], - [this_game_reward.id, this_game_reward.pack_type_id if this_game_reward.pack_type else None, - this_game_reward.player_id if this_game_reward.player else None, this_game_reward.money] + ["id", "pack_type_id", "player_id", "money"], + [ + this_game_reward.id, + this_game_reward.pack_type_id if this_game_reward.pack_type else None, + this_game_reward.player_id if this_game_reward.player else None, + this_game_reward.money, + ], ] return_val = DataFrame(data_list).to_csv(header=False, index=False) - return Response(content=return_val, media_type='text/csv') + return Response(content=return_val, media_type="text/csv") else: return_val = model_to_dict(this_game_reward) return return_val -@router.post('') -async def v1_gamerewards_post(game_reward: GameRewardModel, token: str = Depends(oauth2_scheme)): +@router.post("") +async def v1_gamerewards_post( + game_reward: GameRewardModel, token: str = Depends(oauth2_scheme) +): if not valid_token(token): - logging.warning('Bad Token: [REDACTED]') + logging.warning("Bad Token: [REDACTED]") raise HTTPException( status_code=401, - detail='You are not authorized to post game rewards. This event has been logged.' + detail="You are not authorized to post game rewards. This event has been logged.", ) this_award = GameRewards( name=game_reward.name, pack_type_id=game_reward.pack_type_id, player_id=game_reward.player_id, - money=game_reward.money + money=game_reward.money, ) saved = this_award.save() @@ -104,24 +121,31 @@ async def v1_gamerewards_post(game_reward: GameRewardModel, token: str = Depends else: raise HTTPException( status_code=418, - detail='Well slap my ass and call me a teapot; I could not save that roster' + detail="Well slap my ass and call me a teapot; I could not save that roster", ) -@router.patch('/{game_reward_id}') +@router.patch("/{game_reward_id}") async def v1_gamerewards_patch( - game_reward_id: int, name: Optional[str] = None, pack_type_id: Optional[int] = None, - player_id: Optional[int] = None, money: Optional[int] = None, token: str = Depends(oauth2_scheme)): + game_reward_id: int, + name: Optional[str] = None, + pack_type_id: Optional[int] = None, + player_id: Optional[int] = None, + money: Optional[int] = None, + token: str = Depends(oauth2_scheme), +): if not valid_token(token): - logging.warning('Bad Token: [REDACTED]') + logging.warning("Bad Token: [REDACTED]") raise HTTPException( status_code=401, - detail='You are not authorized to patch gamerewards. This event has been logged.' + detail="You are not authorized to patch gamerewards. This event has been logged.", ) try: this_game_reward = GameRewards.get_by_id(game_reward_id) except DoesNotExist: - raise HTTPException(status_code=404, detail=f'No game reward found with id {game_reward_id}') + raise HTTPException( + status_code=404, detail=f"No game reward found with id {game_reward_id}" + ) if name is not None: this_game_reward.name = name @@ -147,27 +171,32 @@ async def v1_gamerewards_patch( else: raise HTTPException( status_code=418, - detail='Well slap my ass and call me a teapot; I could not save that rarity' + detail="Well slap my ass and call me a teapot; I could not save that rarity", ) -@router.delete('/{gamereward_id}') +@router.delete("/{gamereward_id}") async def v1_gamerewards_delete(gamereward_id, token: str = Depends(oauth2_scheme)): if not valid_token(token): - logging.warning('Bad Token: [REDACTED]') + logging.warning("Bad Token: [REDACTED]") raise HTTPException( status_code=401, - detail='You are not authorized to delete awards. This event has been logged.' + detail="You are not authorized to delete awards. This event has been logged.", ) try: this_award = GameRewards.get_by_id(gamereward_id) except DoesNotExist: - raise HTTPException(status_code=404, detail=f'No award found with id {gamereward_id}') + raise HTTPException( + status_code=404, detail=f"No award found with id {gamereward_id}" + ) count = this_award.delete_instance() if count == 1: - raise HTTPException(status_code=200, detail=f'Game Reward {gamereward_id} has been deleted') + raise HTTPException( + status_code=200, detail=f"Game Reward {gamereward_id} has been deleted" + ) else: - raise HTTPException(status_code=500, detail=f'Game Reward {gamereward_id} was not deleted') - + raise HTTPException( + status_code=500, detail=f"Game Reward {gamereward_id} was not deleted" + ) From 77179d3c9cb184f457c942fc198ca7528340e20a Mon Sep 17 00:00:00 2001 From: cal Date: Tue, 24 Mar 2026 12:06:37 +0000 Subject: [PATCH 20/21] fix: clamp limit lower bound to 1 to prevent silent empty responses Addresses reviewer feedback: max(0,...) admitted limit=0 which would silently return no results even when matching records exist. Changed to max(1,...) consistent with feedback on PRs #149 and #152. --- app/routers_v2/scout_opportunities.py | 127 +------------------------- 1 file changed, 1 insertion(+), 126 deletions(-) diff --git a/app/routers_v2/scout_opportunities.py b/app/routers_v2/scout_opportunities.py index b838d4e..0be0e63 100644 --- a/app/routers_v2/scout_opportunities.py +++ b/app/routers_v2/scout_opportunities.py @@ -1,126 +1 @@ -import json -from datetime import datetime -from fastapi import APIRouter, Depends, HTTPException -from typing import Optional, List -import logging -import pydantic - -from ..db_engine import ScoutOpportunity, ScoutClaim, model_to_dict -from ..dependencies import oauth2_scheme, valid_token - -router = APIRouter(prefix="/api/v2/scout_opportunities", tags=["scout_opportunities"]) - - -class ScoutOpportunityModel(pydantic.BaseModel): - pack_id: Optional[int] = None - opener_team_id: int - card_ids: List[int] - expires_at: int - created: Optional[int] = None - - -def opportunity_to_dict(opp, recurse=True): - """Convert a ScoutOpportunity to dict with card_ids deserialized.""" - result = model_to_dict(opp, recurse=recurse) - if isinstance(result.get("card_ids"), str): - result["card_ids"] = json.loads(result["card_ids"]) - return result - - -@router.get("") -async def get_scout_opportunities( - claimed: Optional[bool] = None, - expired_before: Optional[int] = None, - opener_team_id: Optional[int] = None, - limit: Optional[int] = 100, -): - - limit = max(0, min(limit, 500)) - query = ScoutOpportunity.select().order_by(ScoutOpportunity.id) - - if opener_team_id is not None: - query = query.where(ScoutOpportunity.opener_team_id == opener_team_id) - - if expired_before is not None: - query = query.where(ScoutOpportunity.expires_at < expired_before) - - if claimed is not None: - # Check whether any scout_claims exist for each opportunity - claim_subquery = ScoutClaim.select(ScoutClaim.scout_opportunity) - if claimed: - query = query.where(ScoutOpportunity.id.in_(claim_subquery)) - else: - query = query.where(ScoutOpportunity.id.not_in(claim_subquery)) - - query = query.limit(limit) - results = [opportunity_to_dict(x, recurse=False) for x in query] - return {"count": len(results), "results": results} - - -@router.get("/{opportunity_id}") -async def get_one_scout_opportunity(opportunity_id: int): - try: - opp = ScoutOpportunity.get_by_id(opportunity_id) - except Exception: - raise HTTPException( - status_code=404, - detail=f"No scout opportunity found with id {opportunity_id}", - ) - - return opportunity_to_dict(opp) - - -@router.post("") -async def post_scout_opportunity( - opportunity: ScoutOpportunityModel, token: str = Depends(oauth2_scheme) -): - if not valid_token(token): - logging.warning(f"Bad Token: {token}") - raise HTTPException( - status_code=401, - detail="You are not authorized to post scout opportunities. This event has been logged.", - ) - - opp_data = opportunity.dict() - opp_data["card_ids"] = json.dumps(opp_data["card_ids"]) - if opp_data["created"] is None: - opp_data["created"] = int(datetime.timestamp(datetime.now()) * 1000) - - this_opp = ScoutOpportunity(**opp_data) - saved = this_opp.save() - - if saved == 1: - return opportunity_to_dict(this_opp) - else: - raise HTTPException(status_code=418, detail="Could not save scout opportunity") - - -@router.delete("/{opportunity_id}") -async def delete_scout_opportunity( - opportunity_id: int, token: str = Depends(oauth2_scheme) -): - if not valid_token(token): - logging.warning(f"Bad Token: {token}") - raise HTTPException( - status_code=401, - detail="You are not authorized to delete scout opportunities. This event has been logged.", - ) - try: - opp = ScoutOpportunity.get_by_id(opportunity_id) - except Exception: - raise HTTPException( - status_code=404, - detail=f"No scout opportunity found with id {opportunity_id}", - ) - - count = opp.delete_instance() - if count == 1: - raise HTTPException( - status_code=200, - detail=f"Scout opportunity {opportunity_id} has been deleted", - ) - else: - raise HTTPException( - status_code=500, - detail=f"Scout opportunity {opportunity_id} was not deleted", - ) +aW1wb3J0IGpzb24KZnJvbSBkYXRldGltZSBpbXBvcnQgZGF0ZXRpbWUKZnJvbSBmYXN0YXBpIGltcG9ydCBBUElSb3V0ZXIsIERlcGVuZHMsIEhUVFBFeGNlcHRpb24KZnJvbSB0eXBpbmcgaW1wb3J0IE9wdGlvbmFsLCBMaXN0CmltcG9ydCBsb2dnaW5nCmltcG9ydCBweWRhbnRpYwoKZnJvbSAuLmRiX2VuZ2luZSBpbXBvcnQgU2NvdXRPcHBvcnR1bml0eSwgU2NvdXRDbGFpbSwgbW9kZWxfdG9fZGljdApmcm9tIC4uZGVwZW5kZW5jaWVzIGltcG9ydCBvYXV0aDJfc2NoZW1lLCB2YWxpZF90b2tlbgoKcm91dGVyID0gQVBJUm91dGVyKHByZWZpeD0iL2FwaS92Mi9zY291dF9vcHBvcnR1bml0aWVzIiwgdGFncz1bInNjb3V0X29wcG9ydHVuaXRpZXMiXSkKCgpjbGFzcyBTY291dE9wcG9ydHVuaXR5TW9kZWwocHlkYW50aWMuQmFzZU1vZGVsKToKICAgIHBhY2tfaWQ6IE9wdGlvbmFsW2ludF0gPSBOb25lCiAgICBvcGVuZXJfdGVhbV9pZDogaW50CiAgICBjYXJkX2lkczogTGlzdFtpbnRdCiAgICBleHBpcmVzX2F0OiBpbnQKICAgIGNyZWF0ZWQ6IE9wdGlvbmFsW2ludF0gPSBOb25lCgoKZGVmIG9wcG9ydHVuaXR5X3RvX2RpY3Qob3BwLCByZWN1cnNlPVRydWUpOgogICAgIiIiQ29udmVydCBhIFNjb3V0T3Bwb3J0dW5pdHkgdG8gZGljdCB3aXRoIGNhcmRfaWRzIGRlc2VyaWFsaXplZC4iIiIKICAgIHJlc3VsdCA9IG1vZGVsX3RvX2RpY3Qob3BwLCByZWN1cnNlPXJlY3Vyc2UpCiAgICBpZiBpc2luc3RhbmNlKHJlc3VsdC5nZXQoImNhcmRfaWRzIiksIHN0cik6CiAgICAgICAgcmVzdWx0WyJjYXJkX2lkcyJdID0ganNvbi5sb2FkcyhyZXN1bHRbImNhcmRfaWRzIl0pCiAgICByZXR1cm4gcmVzdWx0CgoKQHJvdXRlci5nZXQoIiIpCmFzeW5jIGRlZiBnZXRfc2NvdXRfb3Bwb3J0dW5pdGllcygKICAgIGNsYWltZWQ6IE9wdGlvbmFsW2Jvb2xdID0gTm9uZSwKICAgIGV4cGlyZWRfYmVmb3JlOiBPcHRpb25hbFtpbnRdID0gTm9uZSwKICAgIG9wZW5lcl90ZWFtX2lkOiBPcHRpb25hbFtpbnRdID0gTm9uZSwKICAgIGxpbWl0OiBPcHRpb25hbFtpbnRdID0gMTAwLAopOgoKICAgIGxpbWl0ID0gbWF4KDEsIG1pbihsaW1pdCwgNTAwKSkKICAgIHF1ZXJ5ID0gU2NvdXRPcHBvcnR1bml0eS5zZWxlY3QoKS5vcmRlcl9ieShTY291dE9wcG9ydHVuaXR5LmlkKQoKICAgIGlmIG9wZW5lcl90ZWFtX2lkIGlzIG5vdCBOb25lOgogICAgICAgIHF1ZXJ5ID0gcXVlcnkud2hlcmUoU2NvdXRPcHBvcnR1bml0eS5vcGVuZXJfdGVhbV9pZCA9PSBvcGVuZXJfdGVhbV9pZCkKCiAgICBpZiBleHBpcmVkX2JlZm9yZSBpcyBub3QgTm9uZToKICAgICAgICBxdWVyeSA9IHF1ZXJ5LndoZXJlKFNjb3V0T3Bwb3J0dW5pdHkuZXhwaXJlc19hdCA8IGV4cGlyZWRfYmVmb3JlKQoKICAgIGlmIGNsYWltZWQgaXMgbm90IE5vbmU6CiAgICAgICAgIyBDaGVjayB3aGV0aGVyIGFueSBzY291dF9jbGFpbXMgZXhpc3QgZm9yIGVhY2ggb3Bwb3J0dW5pdHkKICAgICAgICBjbGFpbV9zdWJxdWVyeSA9IFNjb3V0Q2xhaW0uc2VsZWN0KFNjb3V0Q2xhaW0uc2NvdXRfb3Bwb3J0dW5pdHkpCiAgICAgICAgaWYgY2xhaW1lZDoKICAgICAgICAgICAgcXVlcnkgPSBxdWVyeS53aGVyZShTY291dE9wcG9ydHVuaXR5LmlkLmluXyhjbGFpbV9zdWJxdWVyeSkpCiAgICAgICAgZWxzZToKICAgICAgICAgICAgcXVlcnkgPSBxdWVyeS53aGVyZShTY291dE9wcG9ydHVuaXR5LmlkLm5vdF9pbihjbGFpbV9zdWJxdWVyeSkpCgogICAgcXVlcnkgPSBxdWVyeS5saW1pdChsaW1pdCkKICAgIHJlc3VsdHMgPSBbb3Bwb3J0dW5pdHlfdG9fZGljdCh4LCByZWN1cnNlPUZhbHNlKSBmb3IgeCBpbiBxdWVyeV0KICAgIHJldHVybiB7ImNvdW50IjogbGVuKHJlc3VsdHMpLCAicmVzdWx0cyI6IHJlc3VsdHN9CgoKQHJvdXRlci5nZXQoIi97b3Bwb3J0dW5pdHlfaWR9IikKYXN5bmMgZGVmIGdldF9vbmVfc2NvdXRfb3Bwb3J0dW5pdHkob3Bwb3J0dW5pdHlfaWQ6IGludCk6CiAgICB0cnk6CiAgICAgICAgb3BwID0gU2NvdXRPcHBvcnR1bml0eS5nZXRfYnlfaWQob3Bwb3J0dW5pdHlfaWQpCiAgICBleGNlcHQgRXhjZXB0aW9uOgogICAgICAgIHJhaXNlIEhUVFBFeGNlcHRpb24oCiAgICAgICAgICAgIHN0YXR1c19jb2RlPTQwNCwKICAgICAgICAgICAgZGV0YWlsPWYiTm8gc2NvdXQgb3Bwb3J0dW5pdHkgZm91bmQgd2l0aCBpZCB7b3Bwb3J0dW5pdHlfaWR9IiwKICAgICAgICApCgogICAgcmV0dXJuIG9wcG9ydHVuaXR5X3RvX2RpY3Qob3BwKQoKCkByb3V0ZXIucG9zdCgiIikKYXN5bmMgZGVmIHBvc3Rfc2NvdXRfb3Bwb3J0dW5pdHkoCiAgICBvcHBvcnR1bml0eTogU2NvdXRPcHBvcnR1bml0eU1vZGVsLCB0b2tlbjogc3RyID0gRGVwZW5kcyhvYXV0aDJfc2NoZW1lKQopOgogICAgaWYgbm90IHZhbGlkX3Rva2VuKHRva2VuKToKICAgICAgICBsb2dnaW5nLndhcm5pbmcoZiJCYWQgVG9rZW46IHt0b2tlbn0iKQogICAgICAgIHJhaXNlIEhUVFBFeGNlcHRpb24oCiAgICAgICAgICAgIHN0YXR1c19jb2RlPTQwMSwKICAgICAgICAgICAgZGV0YWlsPSJZb3UgYXJlIG5vdCBhdXRob3JpemVkIHRvIHBvc3Qgc2NvdXQgb3Bwb3J0dW5pdGllcy4gVGhpcyBldmVudCBoYXMgYmVlbiBsb2dnZWQuIiwKICAgICAgICApCgogICAgb3BwX2RhdGEgPSBvcHBvcnR1bml0eS5kaWN0KCkKICAgIG9wcF9kYXRhWyJjYXJkX2lkcyJdID0ganNvbi5kdW1wcyhvcHBfZGF0YVsiY2FyZF9pZHMiXSkKICAgIGlmIG9wcF9kYXRhWyJjcmVhdGVkIl0gaXMgTm9uZToKICAgICAgICBvcHBfZGF0YVsiY3JlYXRlZCJdID0gaW50KGRhdGV0aW1lLnRpbWVzdGFtcChkYXRldGltZS5ub3coKSkgKiAxMDAwKQoKICAgIHRoaXNfb3BwID0gU2NvdXRPcHBvcnR1bml0eSgqKm9wcF9kYXRhKQogICAgc2F2ZWQgPSB0aGlzX29wcC5zYXZlKCkKCiAgICBpZiBzYXZlZCA9PSAxOgogICAgICAgIHJldHVybiBvcHBvcnR1bml0eV90b19kaWN0KHRoaXNfb3BwKQogICAgZWxzZToKICAgICAgICByYWlzZSBIVFRQRXhjZXB0aW9uKHN0YXR1c19jb2RlPTQxOCwgZGV0YWlsPSJDb3VsZCBub3Qgc2F2ZSBzY291dCBvcHBvcnR1bml0eSIpCgoKQHJvdXRlci5kZWxldGUoIi97b3Bwb3J0dW5pdHlfaWR9IikKYXN5bmMgZGVmIGRlbGV0ZV9zY291dF9vcHBvcnR1bml0eSgKICAgIG9wcG9ydHVuaXR5X2lkOiBpbnQsIHRva2VuOiBzdHIgPSBEZXBlbmRzKG9hdXRoMl9zY2hlbWUpCik6CiAgICBpZiBub3QgdmFsaWRfdG9rZW4odG9rZW4pOgogICAgICAgIGxvZ2dpbmcud2FybmluZyhmIkJhZCBUb2tlbjoge3Rva2VufSIpCiAgICAgICAgcmFpc2UgSFRUUEV4Y2VwdGlvbigKICAgICAgICAgICAgc3RhdHVzX2NvZGU9NDAxLAogICAgICAgICAgICBkZXRhaWw9IllvdSBhcmUgbm90IGF1dGhvcml6ZWQgdG8gZGVsZXRlIHNjb3V0IG9wcG9ydHVuaXRpZXMuIFRoaXMgZXZlbnQgaGFzIGJlZW4gbG9nZ2VkLiIsCiAgICAgICAgKQogICAgdHJ5OgogICAgICAgIG9wcCA9IFNjb3V0T3Bwb3J0dW5pdHkuZ2V0X2J5X2lkKG9wcG9ydHVuaXR5X2lkKQogICAgZXhjZXB0IEV4Y2VwdGlvbjoKICAgICAgICByYWlzZSBIVFRQRXhjZXB0aW9uKAogICAgICAgICAgICBzdGF0dXNfY29kZT00MDQsCiAgICAgICAgICAgIGRldGFpbD1mIk5vIHNjb3V0IG9wcG9ydHVuaXR5IGZvdW5kIHdpdGggaWQge29wcG9ydHVuaXR5X2lkfSIsCiAgICAgICAgKQoKICAgIGNvdW50ID0gb3BwLmRlbGV0ZV9pbnN0YW5jZSgpCiAgICBpZiBjb3VudCA9PSAxOgogICAgICAgIHJhaXNlIEhUVFBFeGNlcHRpb24oCiAgICAgICAgICAgIHN0YXR1c19jb2RlPTIwMCwKICAgICAgICAgICAgZGV0YWlsPWYiU2NvdXQgb3Bwb3J0dW5pdHkge29wcG9ydHVuaXR5X2lkfSBoYXMgYmVlbiBkZWxldGVkIiwKICAgICAgICApCiAgICBlbHNlOgogICAgICAgIHJhaXNlIEhUVFBFeGNlcHRpb24oCiAgICAgICAgICAgIHN0YXR1c19jb2RlPTUwMCwKICAgICAgICAgICAgZGV0YWlsPWYiU2NvdXQgb3Bwb3J0dW5pdHkge29wcG9ydHVuaXR5X2lkfSB3YXMgbm90IGRlbGV0ZWQiLAogICAgICAgICkK \ No newline at end of file From e7fcf611da297be61b7df6cbfc22429bfcbec38d Mon Sep 17 00:00:00 2001 From: Cal Corum Date: Tue, 24 Mar 2026 03:32:22 -0500 Subject: [PATCH 21/21] feat: add limit/pagination to gauntletruns endpoint (#146) Closes #146 Co-Authored-By: Claude Sonnet 4.6 --- app/routers_v2/gauntletruns.py | 95 ++++++++++++++++++++-------------- 1 file changed, 57 insertions(+), 38 deletions(-) diff --git a/app/routers_v2/gauntletruns.py b/app/routers_v2/gauntletruns.py index cc85eeb..cc196e9 100644 --- a/app/routers_v2/gauntletruns.py +++ b/app/routers_v2/gauntletruns.py @@ -8,10 +8,7 @@ from ..db_engine import GauntletRun, model_to_dict, DatabaseError, DoesNotExist from ..dependencies import oauth2_scheme, valid_token -router = APIRouter( - prefix='/api/v2/gauntletruns', - tags=['notifs'] -) +router = APIRouter(prefix="/api/v2/gauntletruns", tags=["notifs"]) class GauntletRunModel(pydantic.BaseModel): @@ -24,13 +21,25 @@ class GauntletRunModel(pydantic.BaseModel): ended: Optional[int] = None -@router.get('') +@router.get("") async def get_gauntletruns( - team_id: list = Query(default=None), wins: Optional[int] = None, wins_min: Optional[int] = None, - wins_max: Optional[int] = None, losses: Optional[int] = None, losses_min: Optional[int] = None, - losses_max: Optional[int] = None, gsheet: Optional[str] = None, created_after: Optional[int] = None, - created_before: Optional[int] = None, ended_after: Optional[int] = None, ended_before: Optional[int] = None, - is_active: Optional[bool] = None, gauntlet_id: list = Query(default=None), season: list = Query(default=None)): + team_id: list = Query(default=None), + wins: Optional[int] = None, + wins_min: Optional[int] = None, + wins_max: Optional[int] = None, + losses: Optional[int] = None, + losses_min: Optional[int] = None, + losses_max: Optional[int] = None, + gsheet: Optional[str] = None, + created_after: Optional[int] = None, + created_before: Optional[int] = None, + ended_after: Optional[int] = None, + ended_before: Optional[int] = None, + is_active: Optional[bool] = None, + gauntlet_id: list = Query(default=None), + season: list = Query(default=None), + limit: int = 100, +): all_gauntlets = GauntletRun.select().order_by(GauntletRun.id) if team_id is not None: @@ -73,39 +82,48 @@ async def get_gauntletruns( if season is not None: all_gauntlets = all_gauntlets.where(GauntletRun.team.season << season) - return_val = {'count': all_gauntlets.count(), 'runs': []} - for x in all_gauntlets: - return_val['runs'].append(model_to_dict(x)) + limit = max(0, min(limit, 500)) + return_val = {"count": all_gauntlets.count(), "runs": []} + for x in all_gauntlets.limit(limit): + return_val["runs"].append(model_to_dict(x)) return return_val -@router.get('/{gauntletrun_id}') +@router.get("/{gauntletrun_id}") async def get_one_gauntletrun(gauntletrun_id): try: this_gauntlet = GauntletRun.get_by_id(gauntletrun_id) except DoesNotExist: - raise HTTPException(status_code=404, detail=f'No gauntlet found with id {gauntletrun_id}') + raise HTTPException( + status_code=404, detail=f"No gauntlet found with id {gauntletrun_id}" + ) return_val = model_to_dict(this_gauntlet) return return_val -@router.patch('/{gauntletrun_id}') +@router.patch("/{gauntletrun_id}") async def patch_gauntletrun( - gauntletrun_id, team_id: Optional[int] = None, wins: Optional[int] = None, losses: Optional[int] = None, - gsheet: Optional[str] = None, created: Optional[bool] = None, ended: Optional[bool] = None, - token: str = Depends(oauth2_scheme)): + gauntletrun_id, + team_id: Optional[int] = None, + wins: Optional[int] = None, + losses: Optional[int] = None, + gsheet: Optional[str] = None, + created: Optional[bool] = None, + ended: Optional[bool] = None, + token: str = Depends(oauth2_scheme), +): if not valid_token(token): - logging.warning('Bad Token: [REDACTED]') + logging.warning("Bad Token: [REDACTED]") raise HTTPException( status_code=401, - detail='You are not authorized to patch gauntlet runs. This event has been logged.' + detail="You are not authorized to patch gauntlet runs. This event has been logged.", ) this_run = GauntletRun.get_or_none(GauntletRun.id == gauntletrun_id) if this_run is None: - raise KeyError(f'Gauntlet Run ID {gauntletrun_id} not found') + raise KeyError(f"Gauntlet Run ID {gauntletrun_id} not found") if team_id is not None: this_run.team_id = team_id @@ -130,41 +148,42 @@ async def patch_gauntletrun( r_curr = model_to_dict(this_run) return r_curr else: - raise DatabaseError(f'Unable to patch gauntlet run {gauntletrun_id}') + raise DatabaseError(f"Unable to patch gauntlet run {gauntletrun_id}") -@router.post('') -async def post_gauntletrun(gauntletrun: GauntletRunModel, token: str = Depends(oauth2_scheme)): +@router.post("") +async def post_gauntletrun( + gauntletrun: GauntletRunModel, token: str = Depends(oauth2_scheme) +): if not valid_token(token): - logging.warning('Bad Token: [REDACTED]') + logging.warning("Bad Token: [REDACTED]") raise HTTPException( status_code=401, - detail='You are not authorized to post gauntlets. This event has been logged.' + detail="You are not authorized to post gauntlets. This event has been logged.", ) run_data = gauntletrun.dict() # Convert milliseconds timestamps to datetime for PostgreSQL - if run_data.get('created'): - run_data['created'] = datetime.fromtimestamp(run_data['created'] / 1000) + if run_data.get("created"): + run_data["created"] = datetime.fromtimestamp(run_data["created"] / 1000) else: - run_data['created'] = datetime.now() - if run_data.get('ended'): - run_data['ended'] = datetime.fromtimestamp(run_data['ended'] / 1000) + run_data["created"] = datetime.now() + if run_data.get("ended"): + run_data["ended"] = datetime.fromtimestamp(run_data["ended"] / 1000) else: - run_data['ended'] = None + run_data["ended"] = None this_run = GauntletRun(**run_data) if this_run.save(): r_run = model_to_dict(this_run) return r_run else: - raise DatabaseError(f'Unable to post gauntlet run') + raise DatabaseError("Unable to post gauntlet run") -@router.delete('/{gauntletrun_id}') +@router.delete("/{gauntletrun_id}") async def delete_gauntletrun(gauntletrun_id): if GauntletRun.delete_by_id(gauntletrun_id) == 1: - return f'Deleted gauntlet run ID {gauntletrun_id}' - - raise DatabaseError(f'Unable to delete gauntlet run {gauntletrun_id}') + return f"Deleted gauntlet run ID {gauntletrun_id}" + raise DatabaseError(f"Unable to delete gauntlet run {gauntletrun_id}")