diff --git a/app/routers_v2/evolution.py b/app/routers_v2/evolution.py index e5e957e..6fbdb06 100644 --- a/app/routers_v2/evolution.py +++ b/app/routers_v2/evolution.py @@ -7,6 +7,50 @@ from ..dependencies import oauth2_scheme, valid_token router = APIRouter(prefix="/api/v2/evolution", tags=["evolution"]) +# Tier -> threshold attribute name. Index = current_tier; value is the +# attribute on EvolutionTrack 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", + 1: "t2_threshold", + 2: "t3_threshold", + 3: "t4_threshold", + 4: None, +} + + +def _build_card_state_response(state) -> dict: + """Serialise an EvolutionCardState into the standard API response shape. + + Produces a flat dict with player_id and team_id as plain integers, + a nested 'track' dict with all threshold fields, and a computed + 'next_threshold' field: + - For tiers 0-3: the threshold value for the tier immediately above. + - For tier 4 (fully evolved): None. + + Uses model_to_dict(recurse=False) internally so FK fields are returned + as IDs rather than nested objects, then promotes the needed IDs up to + the top level. + """ + track = state.track + track_dict = model_to_dict(track, recurse=False) + + next_attr = _NEXT_THRESHOLD_ATTR.get(state.current_tier) + next_threshold = getattr(track, next_attr) if next_attr else None + + return { + "player_id": state.player_id, + "team_id": state.team_id, + "current_tier": state.current_tier, + "current_value": state.current_value, + "fully_evolved": state.fully_evolved, + "last_evaluated_at": ( + state.last_evaluated_at.isoformat() if state.last_evaluated_at else None + ), + "track": track_dict, + "next_threshold": next_threshold, + } + @router.get("/tracks") async def list_tracks( @@ -43,6 +87,52 @@ async def get_track(track_id: int, token: str = Depends(oauth2_scheme)): return model_to_dict(track, recurse=False) +@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. + + Resolves card_id -> (player_id, team_id) via the Card table, then looks + up the matching EvolutionCardState 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. + """ + 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 + + # Resolve card_id to player+team + try: + card = Card.get_by_id(card_id) + 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 + # track so a single query resolves both rows. + try: + state = ( + EvolutionCardState.select(EvolutionCardState, EvolutionTrack) + .join(EvolutionTrack) + .where( + (EvolutionCardState.player == card.player_id) + & (EvolutionCardState.team == card.team_id) + ) + .get() + ) + except DoesNotExist: + raise HTTPException( + status_code=404, + detail=f"No evolution state for card {card_id}", + ) + + return _build_card_state_response(state) + + @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. diff --git a/app/routers_v2/teams.py b/app/routers_v2/teams.py index c39057a..58394f7 100644 --- a/app/routers_v2/teams.py +++ b/app/routers_v2/teams.py @@ -135,15 +135,15 @@ async def get_teams( if has_guide is not None: # Use boolean comparison (PostgreSQL-compatible) if not has_guide: - all_teams = all_teams.where(Team.has_guide == False) + all_teams = all_teams.where(Team.has_guide == False) # noqa: E712 else: - all_teams = all_teams.where(Team.has_guide == True) + all_teams = all_teams.where(Team.has_guide == True) # noqa: E712 if is_ai is not None: if not is_ai: - all_teams = all_teams.where(Team.is_ai == False) + all_teams = all_teams.where(Team.is_ai == False) # noqa: E712 else: - all_teams = all_teams.where(Team.is_ai == True) + all_teams = all_teams.where(Team.is_ai == True) # noqa: E712 if event_id is not None: all_teams = all_teams.where(Team.event_id == event_id) @@ -254,24 +254,24 @@ def get_scouting_dfs(allowed_players, position: str): if position in ["LF", "CF", "RF"]: series_list.append( pd.Series( - dict([(x.player.player_id, x.arm) for x in positions]), name=f"Arm OF" + dict([(x.player.player_id, x.arm) for x in positions]), name="Arm OF" ) ) elif position == "C": series_list.append( pd.Series( - dict([(x.player.player_id, x.arm) for x in positions]), name=f"Arm C" + dict([(x.player.player_id, x.arm) for x in positions]), name="Arm C" ) ) series_list.append( pd.Series( - dict([(x.player.player_id, x.pb) for x in positions]), name=f"PB C" + dict([(x.player.player_id, x.pb) for x in positions]), name="PB C" ) ) series_list.append( pd.Series( dict([(x.player.player_id, x.overthrow) for x in positions]), - name=f"Throw C", + name="Throw C", ) ) @@ -314,11 +314,11 @@ async def get_team_lineup( all_players = Player.select().where(Player.franchise == this_team.sname) if difficulty_name == "exhibition": - logging.info(f"pulling an exhibition lineup") + logging.info("pulling an exhibition lineup") if cardset_id is None: raise HTTPException( status_code=400, - detail=f"Must provide at least one cardset_id for exhibition lineups", + detail="Must provide at least one cardset_id for exhibition lineups", ) legal_players = all_players.where(Player.cardset_id << cardset_id) @@ -404,17 +404,17 @@ async def get_team_lineup( # if x.battingcard.player.p_name not in player_names: # starting_nine['DH'] = x.battingcard.player # break - logging.debug(f"Searching for a DH!") + logging.debug("Searching for a DH!") dh_query = legal_players.order_by(Player.cost.desc()) for x in dh_query: logging.debug(f"checking {x.p_name} for {position}") if x.p_name not in player_names and "P" not in x.pos_1: - logging.debug(f"adding!") + logging.debug("adding!") starting_nine["DH"]["player"] = model_to_dict(x) try: vl, vr, total_ops = get_bratings(x.player_id) - except AttributeError as e: - logging.debug(f"Could not find batting lines") + except AttributeError: + logging.debug("Could not find batting lines") else: # starting_nine[position]['vl'] = vl # starting_nine[position]['vr'] = vr @@ -429,12 +429,12 @@ async def get_team_lineup( for x in dh_query: logging.debug(f"checking {x.p_name} for {position}") if x.p_name not in player_names: - logging.debug(f"adding!") + logging.debug("adding!") starting_nine["DH"]["player"] = model_to_dict(x) try: vl, vr, total_ops = get_bratings(x.player_id) - except AttributeError as e: - logging.debug(f"Could not find batting lines") + except AttributeError: + logging.debug("Could not find batting lines") else: vl, vr, total_ops = get_bratings(x.player_id) starting_nine[position]["vl"] = vl["obp"] + vl["slg"] @@ -464,7 +464,7 @@ async def get_team_lineup( x.player.p_name not in player_names and x.player.p_name.lower() != pitcher_name ): - logging.debug(f"adding!") + logging.debug("adding!") starting_nine[position]["player"] = model_to_dict(x.player) vl, vr, total_ops = get_bratings(x.player.player_id) starting_nine[position]["vl"] = vl @@ -542,7 +542,7 @@ async def get_team_lineup( x.player.p_name not in player_names and x.player.p_name.lower() != pitcher_name ): - logging.debug(f"adding!") + logging.debug("adding!") starting_nine[position]["player"] = model_to_dict(x.player) vl, vr, total_ops = get_bratings(x.player.player_id) starting_nine[position]["vl"] = vl["obp"] + vl["slg"] @@ -649,11 +649,11 @@ async def get_team_sp( all_players = Player.select().where(Player.franchise == this_team.sname) if difficulty_name == "exhibition": - logging.info(f"pulling an exhibition lineup") + logging.info("pulling an exhibition lineup") if cardset_id is None: raise HTTPException( status_code=400, - detail=f"Must provide at least one cardset_id for exhibition lineups", + detail="Must provide at least one cardset_id for exhibition lineups", ) legal_players = all_players.where(Player.cardset_id << cardset_id) @@ -778,11 +778,11 @@ async def get_team_rp( ) if difficulty_name == "exhibition": - logging.info(f"pulling an exhibition RP") + logging.info("pulling an exhibition RP") if cardset_id is None: raise HTTPException( status_code=400, - detail=f"Must provide at least one cardset_id for exhibition lineups", + detail="Must provide at least one cardset_id for exhibition lineups", ) legal_players = all_players.where(Player.cardset_id << cardset_id) @@ -934,7 +934,7 @@ async def get_team_rp( ) return this_player - logging.info(f"Falling to last chance pitcher") + logging.info("Falling to last chance pitcher") all_relievers = sort_pitchers( PitchingCard.select() .join(Player) @@ -957,7 +957,7 @@ async def get_team_record(team_id: int, season: int): all_games = StratGame.select().where( ((StratGame.away_team_id == team_id) | (StratGame.home_team_id == team_id)) & (StratGame.season == season) - & (StratGame.short_game == False) + & (StratGame.short_game == False) # noqa: E712 ) template = { @@ -1049,8 +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.", ) - last_card = Card.select(Card.id).order_by(-Card.id).limit(1) - lc_id = last_card[0].id all_ids = ids.split(",") conf_message = "" @@ -1098,7 +1096,7 @@ async def team_buy_players(team_id: int, ids: str, ts: str): if this_player.rarity.value >= 2: new_notif = Notification( created=datetime.now(), - title=f"Price Change", + title="Price Change", desc="Modified by buying and selling", field_name=f"{this_player.description} " f"{this_player.p_name if this_player.p_name not in this_player.description else ''}", @@ -1242,7 +1240,7 @@ async def team_sell_cards(team_id: int, ids: str, ts: str): if this_player.rarity.value >= 2: new_notif = Notification( created=datetime.now(), - title=f"Price Change", + title="Price Change", desc="Modified by buying and selling", field_name=f"{this_player.description} " f"{this_player.p_name if this_player.p_name not in this_player.description else ''}", @@ -1293,7 +1291,7 @@ async def get_team_cards(team_id, csv: Optional[bool] = True): .order_by(-Card.player.rarity.value, Card.player.p_name) ) if all_cards.count() == 0: - raise HTTPException(status_code=404, detail=f"No cards found") + raise HTTPException(status_code=404, detail="No cards found") card_vals = [model_to_dict(x) for x in all_cards] @@ -1391,7 +1389,7 @@ async def team_season_update(new_season: int, token: str = Depends(oauth2_scheme detail="You are not authorized to post teams. This event has been logged.", ) - r_query = Team.update( + Team.update( ranking=1000, season=new_season, wallet=Team.wallet + 250, has_guide=False ).execute() current = Current.latest() @@ -1531,3 +1529,57 @@ async def delete_team(team_id, token: str = Depends(oauth2_scheme)): raise HTTPException(status_code=200, detail=f"Team {team_id} has been deleted") else: raise HTTPException(status_code=500, detail=f"Team {team_id} was not deleted") + + +@router.get("/{team_id}/evolutions") +async def list_team_evolutions( + team_id: int, + card_type: Optional[str] = Query(default=None), + tier: Optional[int] = Query(default=None), + page: int = Query(default=1, ge=1), + 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. + + Joins EvolutionCardState to EvolutionTrack 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. + + Query parameters: + card_type -- filter to states whose track.card_type matches (e.g. 'batter', 'sp') + tier -- filter to states at a specific current_tier (0-4) + page -- 1-indexed page number (default 1) + per_page -- items per page (default 10, max 100) + + Response shape: + {"count": N, "items": [card_state_with_threshold_context, ...]} + + Each item in 'items' has the same shape as GET /evolution/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 + + query = ( + EvolutionCardState.select(EvolutionCardState, EvolutionTrack) + .join(EvolutionTrack) + .where(EvolutionCardState.team == team_id) + .order_by(EvolutionCardState.player_id) + ) + + if card_type is not None: + query = query.where(EvolutionTrack.card_type == card_type) + + if tier is not None: + query = query.where(EvolutionCardState.current_tier == tier) + + total = query.count() + offset = (page - 1) * per_page + page_query = query.offset(offset).limit(per_page) + + items = [_build_card_state_response(state) for state in page_query] + return {"count": total, "items": items} diff --git a/tests/conftest.py b/tests/conftest.py index 6701cc7..22b3d10 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -11,6 +11,7 @@ tests. import os import pytest +import psycopg2 from peewee import SqliteDatabase # Set DATABASE_TYPE=postgresql so that the module-level SKIP_TABLE_CREATION @@ -173,3 +174,39 @@ def track(): t3_threshold=448, t4_threshold=896, ) + + +# --------------------------------------------------------------------------- +# PostgreSQL integration fixture (used by test_evolution_*_api.py) +# --------------------------------------------------------------------------- + + +@pytest.fixture(scope="session") +def pg_conn(): + """Open a psycopg2 connection to the PostgreSQL instance for integration tests. + + Reads connection parameters from the standard POSTGRES_* env vars that the + CI workflow injects when a postgres service container is running. Skips the + entire session (via pytest.skip) when POSTGRES_HOST is not set, keeping + local runs clean. + + The connection is shared for the whole session (scope="session") because + the integration test modules use module-scoped fixtures that rely on it; + creating a new connection per test would break those module-scoped fixtures. + + Teardown: the connection is closed once all tests have finished. + """ + host = os.environ.get("POSTGRES_HOST") + if not host: + pytest.skip("POSTGRES_HOST not set — PostgreSQL integration tests skipped") + + conn = psycopg2.connect( + host=host, + port=int(os.environ.get("POSTGRES_PORT", "5432")), + dbname=os.environ.get("POSTGRES_DB", "paper_dynasty"), + user=os.environ.get("POSTGRES_USER", "postgres"), + password=os.environ.get("POSTGRES_PASSWORD", ""), + ) + conn.autocommit = False + yield conn + conn.close() diff --git a/tests/test_evolution_state_api.py b/tests/test_evolution_state_api.py index 7d870b6..a9b7e47 100644 --- a/tests/test_evolution_state_api.py +++ b/tests/test_evolution_state_api.py @@ -227,7 +227,7 @@ def seeded_data(pg_conn): card_no_state_id = cur.fetchone()[0] # Evolution card states - # Batter player at tier 1 + # Batter player at tier 1, value 87.5 cur.execute( """ INSERT INTO evolution_card_state @@ -258,9 +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 evolution_card_state WHERE id = %s", (state_id,)) cur.execute( "DELETE FROM card WHERE id = ANY(%s)", ([card_id, card2_id, card_no_state_id],), @@ -322,7 +320,7 @@ def test_list_filter_by_card_type(client, seeded_data, pg_conn): Verifies the JOIN to evolution_track and the WHERE predicate on card_type. """ cur = pg_conn.cursor() - # Add a state for the sp player so we have two types + # Add a state for the sp player so we have two types in this team cur.execute( """ INSERT INTO evolution_card_state @@ -415,7 +413,11 @@ def test_list_pagination(client, seeded_data, pg_conn): VALUES (%s, %s, %s, 0, 0.0, false) RETURNING id """, - (seeded_data["player2_id"], seeded_data["team_id"], seeded_data["batter_track_id"]), + ( + seeded_data["player2_id"], + seeded_data["team_id"], + seeded_data["batter_track_id"], + ), ) extra_state_id = cur.fetchone()[0] pg_conn.commit() @@ -478,7 +480,7 @@ def test_get_card_state_shape(client, seeded_data): assert t["t3_threshold"] == 448 assert t["t4_threshold"] == 896 - # tier=1 -> next is t2_threshold + # tier=1 -> next threshold is t2_threshold assert data["next_threshold"] == 149 @@ -510,11 +512,12 @@ def test_get_card_state_next_threshold(client, seeded_data, pg_conn): try: resp = client.get(f"/api/v2/evolution/cards/{card_id}", headers=AUTH_HEADER) assert resp.status_code == 200 - assert resp.json()["next_threshold"] == 448 + 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 WHERE id = %s", + "UPDATE evolution_card_state SET current_tier = 4, fully_evolved = true " + "WHERE id = %s", (state_id,), ) pg_conn.commit() @@ -524,7 +527,8 @@ def test_get_card_state_next_threshold(client, seeded_data, pg_conn): assert resp2.json()["next_threshold"] is None finally: cur.execute( - "UPDATE evolution_card_state SET current_tier = 1, fully_evolved = false WHERE id = %s", + "UPDATE evolution_card_state SET current_tier = 1, fully_evolved = false " + "WHERE id = %s", (state_id,), ) pg_conn.commit()