Add all-season player search endpoint

- /api/v3/players/search now supports season=0 or omitting season to search ALL seasons
- Results ordered by most recent season first
- Added all_seasons field in response to indicate search mode
- Added numpy<2.0.0 constraint for server compatibility

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Cal Corum 2026-01-23 14:26:16 -06:00
parent 99f501e748
commit 6ff7ac1f62
2 changed files with 220 additions and 96 deletions

View File

@ -5,15 +5,20 @@ import pydantic
from pandas import DataFrame from pandas import DataFrame
from ..db_engine import db, Player, model_to_dict, chunked, fn, complex_data_to_csv from ..db_engine import db, Player, model_to_dict, chunked, fn, complex_data_to_csv
from ..dependencies import add_cache_headers, cache_result, oauth2_scheme, valid_token, PRIVATE_IN_SCHEMA, handle_db_errors, invalidate_cache from ..dependencies import (
add_cache_headers,
logger = logging.getLogger('discord_app') cache_result,
oauth2_scheme,
router = APIRouter( valid_token,
prefix='/api/v3/players', PRIVATE_IN_SCHEMA,
tags=['players'] handle_db_errors,
invalidate_cache,
) )
logger = logging.getLogger("discord_app")
router = APIRouter(prefix="/api/v3/players", tags=["players"])
class PlayerModel(pydantic.BaseModel): class PlayerModel(pydantic.BaseModel):
name: str name: str
@ -47,14 +52,23 @@ class PlayerList(pydantic.BaseModel):
players: List[PlayerModel] players: List[PlayerModel]
@router.get('') @router.get("")
@handle_db_errors @handle_db_errors
@add_cache_headers(max_age=30*60) # 30 minutes - safe with cache invalidation on writes @add_cache_headers(
@cache_result(ttl=30*60, key_prefix='players') max_age=30 * 60
) # 30 minutes - safe with cache invalidation on writes
@cache_result(ttl=30 * 60, key_prefix="players")
async def get_players( async def get_players(
season: Optional[int], name: Optional[str] = None, team_id: list = Query(default=None), season: Optional[int],
pos: list = Query(default=None), strat_code: list = Query(default=None), is_injured: Optional[bool] = None, name: Optional[str] = None,
sort: Optional[str] = None, short_output: Optional[bool] = False, csv: Optional[bool] = False): team_id: list = Query(default=None),
pos: list = Query(default=None),
strat_code: list = Query(default=None),
is_injured: Optional[bool] = None,
sort: Optional[str] = None,
short_output: Optional[bool] = False,
csv: Optional[bool] = False,
):
all_players = Player.select_season(season) all_players = Player.select_season(season)
if team_id is not None: if team_id is not None:
@ -70,54 +84,110 @@ async def get_players(
if pos is not None: if pos is not None:
p_list = [x.upper() for x in pos] p_list = [x.upper() for x in pos]
all_players = all_players.where( all_players = all_players.where(
(Player.pos_1 << p_list) | (Player.pos_2 << p_list) | (Player.pos_3 << p_list) | (Player.pos_4 << p_list) | (Player.pos_1 << p_list)
(Player.pos_5 << p_list) | (Player.pos_6 << p_list) | (Player.pos_7 << p_list) | (Player.pos_8 << p_list) | (Player.pos_2 << p_list)
| (Player.pos_3 << p_list)
| (Player.pos_4 << p_list)
| (Player.pos_5 << p_list)
| (Player.pos_6 << p_list)
| (Player.pos_7 << p_list)
| (Player.pos_8 << p_list)
) )
if is_injured is not None: if is_injured is not None:
all_players = all_players.where(Player.il_return.is_null(False)) all_players = all_players.where(Player.il_return.is_null(False))
if sort is not None: if sort is not None:
if sort == 'cost-asc': if sort == "cost-asc":
all_players = all_players.order_by(Player.wara) all_players = all_players.order_by(Player.wara)
elif sort == 'cost-desc': elif sort == "cost-desc":
all_players = all_players.order_by(-Player.wara) all_players = all_players.order_by(-Player.wara)
elif sort == 'name-asc': elif sort == "name-asc":
all_players = all_players.order_by(Player.name) all_players = all_players.order_by(Player.name)
elif sort == 'name-desc': elif sort == "name-desc":
all_players = all_players.order_by(-Player.name) all_players = all_players.order_by(-Player.name)
else: else:
all_players = all_players.order_by(Player.id) all_players = all_players.order_by(Player.id)
if csv: if csv:
player_list = [ player_list = [
['name', 'wara', 'image', 'image2', 'team', 'season', 'pitcher_injury', 'pos_1', 'pos_2', 'pos_3', [
'pos_4', 'pos_5', 'pos_6', 'pos_7', 'pos_8', 'last_game', 'last_game2', 'il_return', 'demotion_week', "name",
'headshot', 'vanity_card', 'strat_code', 'bbref_id', 'injury_rating', 'player_id', 'sbaref_id'] "wara",
"image",
"image2",
"team",
"season",
"pitcher_injury",
"pos_1",
"pos_2",
"pos_3",
"pos_4",
"pos_5",
"pos_6",
"pos_7",
"pos_8",
"last_game",
"last_game2",
"il_return",
"demotion_week",
"headshot",
"vanity_card",
"strat_code",
"bbref_id",
"injury_rating",
"player_id",
"sbaref_id",
]
] ]
for line in all_players: for line in all_players:
player_list.append( player_list.append(
[ [
line.name, line.wara, line.image, line.image2, line.team.abbrev, line.season, line.pitcher_injury, line.name,
line.pos_1, line.pos_2, line.pos_3, line.pos_4, line.pos_5, line.pos_6, line.pos_7, line.pos_8, line.wara,
line.last_game, line.last_game2, line.il_return, line.demotion_week, line.headshot, line.image,
line.vanity_card, line.strat_code.replace(",", "-_-") if line.strat_code is not None else "", line.image2,
line.bbref_id, line.injury_rating, line.id, line.sbaplayer line.team.abbrev,
line.season,
line.pitcher_injury,
line.pos_1,
line.pos_2,
line.pos_3,
line.pos_4,
line.pos_5,
line.pos_6,
line.pos_7,
line.pos_8,
line.last_game,
line.last_game2,
line.il_return,
line.demotion_week,
line.headshot,
line.vanity_card,
line.strat_code.replace(",", "-_-")
if line.strat_code is not None
else "",
line.bbref_id,
line.injury_rating,
line.id,
line.sbaplayer,
] ]
) )
return_players = { return_players = {
'count': all_players.count(), "count": all_players.count(),
'players': DataFrame(player_list).to_csv(header=False, index=False), "players": DataFrame(player_list).to_csv(header=False, index=False),
'csv': True "csv": True,
} }
db.close() db.close()
return Response(content=return_players['players'], media_type='text/csv') return Response(content=return_players["players"], media_type="text/csv")
else: else:
return_players = { return_players = {
'count': all_players.count(), "count": all_players.count(),
'players': [model_to_dict(x, recurse=not short_output) for x in all_players] "players": [
model_to_dict(x, recurse=not short_output) for x in all_players
],
} }
db.close() db.close()
# if csv: # if csv:
@ -125,35 +195,52 @@ async def get_players(
return return_players return return_players
@router.get('/search') @router.get("/search")
@handle_db_errors @handle_db_errors
@add_cache_headers(max_age=15*60) # 15 minutes - safe with cache invalidation on writes @add_cache_headers(
@cache_result(ttl=15*60, key_prefix='players-search') max_age=15 * 60
) # 15 minutes - safe with cache invalidation on writes
@cache_result(ttl=15 * 60, key_prefix="players-search")
async def search_players( async def search_players(
q: str = Query(..., description="Search query for player name"), q: str = Query(..., description="Search query for player name"),
season: Optional[int] = Query(default=None, description="Season to search in (defaults to current)"), season: Optional[int] = Query(
limit: int = Query(default=10, ge=1, le=50, description="Maximum number of results to return"), default=None,
short_output: bool = False): description="Season to search in. Use 0 or omit for all seasons, or specific season number.",
),
limit: int = Query(
default=10, ge=1, le=50, description="Maximum number of results to return"
),
short_output: bool = False,
):
""" """
Real-time fuzzy search for players by name. Real-time fuzzy search for players by name.
Returns players matching the query with exact matches prioritized over partial matches. Returns players matching the query with exact matches prioritized over partial matches.
"""
if season is None:
# Get current season from the database - using a simple approach
from ..db_engine import Current
current = Current.select().first()
season = current.season if current else 12 # fallback to season 12
# Get all players matching the name pattern (partial match supported by existing logic) Season parameter:
all_players = Player.select_season(season).where( - Omit or use 0: Search across ALL seasons (most recent seasons prioritized)
fn.lower(Player.name).contains(q.lower()) - Specific number (1-13+): Search only that season
) """
search_all_seasons = season is None or season == 0
if search_all_seasons:
# Search across all seasons - no season filter
all_players = (
Player.select()
.where(fn.lower(Player.name).contains(q.lower()))
.order_by(-Player.season)
) # Most recent seasons first
else:
# Search specific season
all_players = Player.select_season(season).where(
fn.lower(Player.name).contains(q.lower())
)
# Convert to list for sorting # Convert to list for sorting
players_list = list(all_players) players_list = list(all_players)
# Sort by relevance (exact matches first, then partial) # Sort by relevance (exact matches first, then partial)
# For all-season search, also prioritize by season (most recent first)
query_lower = q.lower() query_lower = q.lower()
exact_matches = [] exact_matches = []
partial_matches = [] partial_matches = []
@ -165,22 +252,32 @@ async def search_players(
elif query_lower in name_lower: elif query_lower in name_lower:
partial_matches.append(player) partial_matches.append(player)
# Sort exact and partial matches by season (most recent first) when searching all seasons
if search_all_seasons:
exact_matches.sort(key=lambda p: p.season, reverse=True)
partial_matches.sort(key=lambda p: p.season, reverse=True)
# Combine and limit results # Combine and limit results
results = exact_matches + partial_matches results = exact_matches + partial_matches
limited_results = results[:limit] limited_results = results[:limit]
db.close() db.close()
return { return {
'count': len(limited_results), "count": len(limited_results),
'total_matches': len(results), "total_matches": len(results),
'players': [model_to_dict(x, recurse=not short_output) for x in limited_results] "all_seasons": search_all_seasons,
"players": [
model_to_dict(x, recurse=not short_output) for x in limited_results
],
} }
@router.get('/{player_id}') @router.get("/{player_id}")
@handle_db_errors @handle_db_errors
@add_cache_headers(max_age=30*60) # 30 minutes - safe with cache invalidation on writes @add_cache_headers(
@cache_result(ttl=30*60, key_prefix='player') max_age=30 * 60
) # 30 minutes - safe with cache invalidation on writes
@cache_result(ttl=30 * 60, key_prefix="player")
async def get_one_player(player_id: int, short_output: Optional[bool] = False): async def get_one_player(player_id: int, short_output: Optional[bool] = False):
this_player = Player.get_or_none(Player.id == player_id) this_player = Player.get_or_none(Player.id == player_id)
if this_player: if this_player:
@ -191,17 +288,18 @@ async def get_one_player(player_id: int, short_output: Optional[bool] = False):
return r_player return r_player
@router.put('/{player_id}', include_in_schema=PRIVATE_IN_SCHEMA) @router.put("/{player_id}", include_in_schema=PRIVATE_IN_SCHEMA)
@handle_db_errors @handle_db_errors
async def put_player( async def put_player(
player_id: int, new_player: PlayerModel, token: str = Depends(oauth2_scheme)): player_id: int, new_player: PlayerModel, token: str = Depends(oauth2_scheme)
):
if not valid_token(token): if not valid_token(token):
logger.warning(f'patch_player - Bad Token: {token}') logger.warning(f"patch_player - Bad Token: {token}")
raise HTTPException(status_code=401, detail='Unauthorized') raise HTTPException(status_code=401, detail="Unauthorized")
if Player.get_or_none(Player.id == player_id) is None: if Player.get_or_none(Player.id == player_id) is None:
db.close() db.close()
raise HTTPException(status_code=404, detail=f'Player ID {player_id} not found') raise HTTPException(status_code=404, detail=f"Player ID {player_id} not found")
Player.update(**new_player.dict()).where(Player.id == player_id).execute() Player.update(**new_player.dict()).where(Player.id == player_id).execute()
r_player = model_to_dict(Player.get_by_id(player_id)) r_player = model_to_dict(Player.get_by_id(player_id))
@ -217,29 +315,46 @@ async def put_player(
return r_player return r_player
@router.patch('/{player_id}', include_in_schema=PRIVATE_IN_SCHEMA) @router.patch("/{player_id}", include_in_schema=PRIVATE_IN_SCHEMA)
@handle_db_errors @handle_db_errors
async def patch_player( async def patch_player(
player_id: int, token: str = Depends(oauth2_scheme), name: Optional[str] = None, player_id: int,
wara: Optional[float] = None, image: Optional[str] = None, image2: Optional[str] = None, token: str = Depends(oauth2_scheme),
team_id: Optional[int] = None, season: Optional[int] = None, pos_1: Optional[str] = None, name: Optional[str] = None,
pos_2: Optional[str] = None, pos_3: Optional[str] = None, pos_4: Optional[str] = None, wara: Optional[float] = None,
pos_5: Optional[str] = None, pos_6: Optional[str] = None, pos_7: Optional[str] = None, image: Optional[str] = None,
pos_8: Optional[str] = None, vanity_card: Optional[str] = None, headshot: Optional[str] = None, image2: Optional[str] = None,
il_return: Optional[str] = None, demotion_week: Optional[int] = None, strat_code: Optional[str] = None, team_id: Optional[int] = None,
bbref_id: Optional[str] = None, injury_rating: Optional[str] = None, sbaref_id: Optional[int] = None): season: Optional[int] = None,
pos_1: Optional[str] = None,
pos_2: Optional[str] = None,
pos_3: Optional[str] = None,
pos_4: Optional[str] = None,
pos_5: Optional[str] = None,
pos_6: Optional[str] = None,
pos_7: Optional[str] = None,
pos_8: Optional[str] = None,
vanity_card: Optional[str] = None,
headshot: Optional[str] = None,
il_return: Optional[str] = None,
demotion_week: Optional[int] = None,
strat_code: Optional[str] = None,
bbref_id: Optional[str] = None,
injury_rating: Optional[str] = None,
sbaref_id: Optional[int] = None,
):
if not valid_token(token): if not valid_token(token):
logger.warning(f'patch_player - Bad Token: {token}') logger.warning(f"patch_player - Bad Token: {token}")
raise HTTPException(status_code=401, detail='Unauthorized') raise HTTPException(status_code=401, detail="Unauthorized")
if Player.get_or_none(Player.id == player_id) is None: if Player.get_or_none(Player.id == player_id) is None:
db.close() db.close()
raise HTTPException(status_code=404, detail=f'Player ID {player_id} not found') raise HTTPException(status_code=404, detail=f"Player ID {player_id} not found")
this_player = Player.get_or_none(Player.id == player_id) this_player = Player.get_or_none(Player.id == player_id)
if this_player is None: if this_player is None:
db.close() db.close()
raise HTTPException(status_code=404, detail=f'Player ID {player_id} not found') raise HTTPException(status_code=404, detail=f"Player ID {player_id} not found")
if name is not None: if name is not None:
this_player.name = name this_player.name = name
@ -279,7 +394,9 @@ async def patch_player(
this_player.headshot = headshot this_player.headshot = headshot
if il_return is not None: if il_return is not None:
this_player.il_return = None if not il_return or il_return.lower() == 'none' else il_return this_player.il_return = (
None if not il_return or il_return.lower() == "none" else il_return
)
if demotion_week is not None: if demotion_week is not None:
this_player.demotion_week = demotion_week this_player.demotion_week = demotion_week
if strat_code is not None: if strat_code is not None:
@ -305,24 +422,28 @@ async def patch_player(
return r_player return r_player
else: else:
db.close() db.close()
raise HTTPException(status_code=500, detail=f'Unable to patch player {player_id}') raise HTTPException(
status_code=500, detail=f"Unable to patch player {player_id}"
)
@router.post('', include_in_schema=PRIVATE_IN_SCHEMA) @router.post("", include_in_schema=PRIVATE_IN_SCHEMA)
@handle_db_errors @handle_db_errors
async def post_players(p_list: PlayerList, token: str = Depends(oauth2_scheme)): async def post_players(p_list: PlayerList, token: str = Depends(oauth2_scheme)):
if not valid_token(token): if not valid_token(token):
logger.warning(f'post_players - Bad Token: {token}') logger.warning(f"post_players - Bad Token: {token}")
raise HTTPException(status_code=401, detail='Unauthorized') raise HTTPException(status_code=401, detail="Unauthorized")
new_players = [] new_players = []
for player in p_list.players: for player in p_list.players:
dupe = Player.get_or_none(Player.season == player.season, Player.name == player.name) dupe = Player.get_or_none(
Player.season == player.season, Player.name == player.name
)
if dupe: if dupe:
db.close() db.close()
raise HTTPException( raise HTTPException(
status_code=500, status_code=500,
detail=f'Player name {player.name} already in use in Season {player.season}' detail=f"Player name {player.name} already in use in Season {player.season}",
) )
new_players.append(player.dict()) new_players.append(player.dict())
@ -339,20 +460,20 @@ async def post_players(p_list: PlayerList, token: str = Depends(oauth2_scheme)):
# Invalidate team roster cache (new players added to teams) # Invalidate team roster cache (new players added to teams)
invalidate_cache("team-roster*") invalidate_cache("team-roster*")
return f'Inserted {len(new_players)} players' return f"Inserted {len(new_players)} players"
@router.delete('/{player_id}', include_in_schema=PRIVATE_IN_SCHEMA) @router.delete("/{player_id}", include_in_schema=PRIVATE_IN_SCHEMA)
@handle_db_errors @handle_db_errors
async def delete_player(player_id: int, token: str = Depends(oauth2_scheme)): async def delete_player(player_id: int, token: str = Depends(oauth2_scheme)):
if not valid_token(token): if not valid_token(token):
logger.warning(f'delete_player - Bad Token: {token}') logger.warning(f"delete_player - Bad Token: {token}")
raise HTTPException(status_code=401, detail='Unauthorized') raise HTTPException(status_code=401, detail="Unauthorized")
this_player = Player.get_or_none(Player.id == player_id) this_player = Player.get_or_none(Player.id == player_id)
if not this_player: if not this_player:
db.close() db.close()
raise HTTPException(status_code=404, detail=f'Player ID {player_id} not found') raise HTTPException(status_code=404, detail=f"Player ID {player_id} not found")
count = this_player.delete_instance() count = this_player.delete_instance()
db.close() db.close()
@ -365,6 +486,8 @@ async def delete_player(player_id: int, token: str = Depends(oauth2_scheme)):
# Invalidate team roster cache (player removed from team) # Invalidate team roster cache (player removed from team)
invalidate_cache("team-roster*") invalidate_cache("team-roster*")
return f'Player {player_id} has been deleted' return f"Player {player_id} has been deleted"
else: else:
raise HTTPException(status_code=500, detail=f'Player {player_id} could not be deleted') raise HTTPException(
status_code=500, detail=f"Player {player_id} could not be deleted"
)

View File

@ -2,6 +2,7 @@ fastapi
uvicorn uvicorn
peewee==3.13.3 peewee==3.13.3
python-multipart python-multipart
numpy<2.0.0
pandas pandas
psycopg2-binary>=2.9.0 psycopg2-binary>=2.9.0
requests requests