The API expects 'demotion_week' as the query parameter name, not 'dem_week'. Updated service to send correct parameter name and tests to verify. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
451 lines
15 KiB
Python
451 lines
15 KiB
Python
"""
|
|
Player service for Discord Bot v2.0
|
|
|
|
Handles player-related operations with team population and search functionality.
|
|
"""
|
|
|
|
import logging
|
|
from typing import Optional, List, TYPE_CHECKING
|
|
|
|
from config import get_config
|
|
from services.base_service import BaseService
|
|
from models.player import Player
|
|
from exceptions import APIException
|
|
|
|
if TYPE_CHECKING:
|
|
from services.team_service import TeamService
|
|
|
|
logger = logging.getLogger(f"{__name__}.PlayerService")
|
|
|
|
|
|
class PlayerService(BaseService[Player]):
|
|
"""
|
|
Service for player-related operations.
|
|
|
|
Features:
|
|
- Player retrieval with team population
|
|
- Team roster queries
|
|
- Name-based search with exact matching
|
|
- Season-specific filtering
|
|
- Free agent handling via constants
|
|
"""
|
|
|
|
def __init__(self, team_service: Optional["TeamService"] = None):
|
|
"""Initialize player service."""
|
|
super().__init__(Player, "players")
|
|
self._team_service = team_service
|
|
logger.debug("PlayerService initialized")
|
|
|
|
async def get_player(self, player_id: int) -> Optional[Player]:
|
|
"""
|
|
Get player by ID with error handling.
|
|
|
|
Args:
|
|
player_id: Unique player identifier
|
|
|
|
Returns:
|
|
Player instance or None if not found
|
|
"""
|
|
try:
|
|
return await self.get_by_id(player_id)
|
|
except APIException:
|
|
logger.error(f"Failed to get player {player_id}")
|
|
return None
|
|
except Exception as e:
|
|
logger.error(f"Unexpected error getting player {player_id}: {e}")
|
|
return None
|
|
|
|
async def get_players_by_team(
|
|
self, team_id: int, season: int, sort: Optional[str] = None
|
|
) -> List[Player]:
|
|
"""
|
|
Get all players for a specific team.
|
|
|
|
Args:
|
|
team_id: Team identifier
|
|
season: Season number (required)
|
|
sort: Sort order - 'cost-asc', 'cost-desc', 'name-asc', 'name-desc' (optional)
|
|
|
|
Returns:
|
|
List of players on the team, optionally sorted
|
|
"""
|
|
try:
|
|
params = [("season", str(season)), ("team_id", str(team_id))]
|
|
|
|
# Add sort parameter if specified
|
|
if sort:
|
|
valid_sorts = ["cost-asc", "cost-desc", "name-asc", "name-desc"]
|
|
if sort in valid_sorts:
|
|
params.append(("sort", sort))
|
|
logger.debug(f"Applying sort '{sort}' to team {team_id} players")
|
|
else:
|
|
logger.warning(f"Invalid sort parameter '{sort}' - ignoring")
|
|
|
|
players = await self.get_all_items(params=params)
|
|
logger.debug(
|
|
f"Retrieved {len(players)} players for team {team_id} in season {season}"
|
|
)
|
|
return players
|
|
|
|
except Exception as e:
|
|
logger.error(f"Failed to get players for team {team_id}: {e}")
|
|
return []
|
|
|
|
async def get_players_by_name(self, name: str, season: int) -> List[Player]:
|
|
"""
|
|
Search for players by name (partial match).
|
|
|
|
Args:
|
|
name: Player name or partial name
|
|
season: Season number (required)
|
|
|
|
Returns:
|
|
List of matching players
|
|
"""
|
|
try:
|
|
params = [("season", str(season)), ("name", name)]
|
|
|
|
players = await self.get_all_items(params=params)
|
|
logger.debug(
|
|
f"Found {len(players)} players matching '{name}' in season {season}"
|
|
)
|
|
return players
|
|
|
|
except Exception as e:
|
|
logger.error(f"Failed to search players by name '{name}': {e}")
|
|
return []
|
|
|
|
async def get_player_by_name_exact(
|
|
self, name: str, season: int
|
|
) -> Optional[Player]:
|
|
"""
|
|
Get player by exact name match (case-insensitive).
|
|
|
|
Args:
|
|
name: Exact player name
|
|
season: Season number (required)
|
|
|
|
Returns:
|
|
Player instance or None if not found
|
|
"""
|
|
try:
|
|
players = await self.get_players_by_name(name, season)
|
|
|
|
# Look for exact case-insensitive match
|
|
name_lower = name.lower()
|
|
for player in players:
|
|
if player.name.lower() == name_lower:
|
|
logger.debug(f"Found exact match for '{name}': {player.name}")
|
|
return player
|
|
|
|
logger.debug(f"No exact match found for '{name}'")
|
|
return None
|
|
|
|
except Exception as e:
|
|
logger.error(f"Error finding exact player match for '{name}': {e}")
|
|
return None
|
|
|
|
async def search_players(
|
|
self,
|
|
query: str,
|
|
limit: int = 10,
|
|
season: Optional[int] = None,
|
|
all_seasons: bool = False,
|
|
) -> List[Player]:
|
|
"""
|
|
Search for players using the dedicated /v3/players/search endpoint.
|
|
|
|
Args:
|
|
query: Search query for player name
|
|
limit: Maximum number of results to return (1-50)
|
|
season: Season to search in (defaults to current season if all_seasons=False)
|
|
all_seasons: If True, search across all seasons (ignores season parameter)
|
|
|
|
Returns:
|
|
List of matching players (up to limit)
|
|
"""
|
|
try:
|
|
params = [("q", query), ("limit", str(limit))]
|
|
|
|
if all_seasons:
|
|
# Pass season=0 to API to search all seasons
|
|
params.append(("season", "0"))
|
|
elif season is not None:
|
|
params.append(("season", str(season)))
|
|
# If neither all_seasons nor season specified, API defaults to current season
|
|
|
|
client = await self.get_client()
|
|
data = await client.get("players/search", params=params)
|
|
|
|
if not data:
|
|
logger.debug(f"No players found for search query '{query}'")
|
|
return []
|
|
|
|
# Handle API response format: {'count': int, 'players': [...]}
|
|
items, count = self._extract_items_and_count_from_response(data)
|
|
players = [self.model_class.from_api_data(item) for item in items]
|
|
|
|
logger.debug(
|
|
f"Search '{query}' returned {len(players)} of {count} matches (all_seasons={all_seasons})"
|
|
)
|
|
return players
|
|
|
|
except Exception as e:
|
|
logger.error(f"Error in player search for '{query}': {e}")
|
|
return []
|
|
|
|
async def search_players_fuzzy(
|
|
self,
|
|
query: str,
|
|
limit: int = 10,
|
|
season: Optional[int] = None,
|
|
all_seasons: bool = False,
|
|
) -> List[Player]:
|
|
"""
|
|
Fuzzy search for players by name with limit using the search endpoint.
|
|
|
|
Args:
|
|
query: Search query
|
|
limit: Maximum results to return
|
|
season: Season to search in (defaults to current season if all_seasons=False)
|
|
all_seasons: If True, search across all seasons
|
|
|
|
Returns:
|
|
List of matching players (up to limit)
|
|
"""
|
|
try:
|
|
# Use the search endpoint which handles fuzzy matching and all-seasons
|
|
return await self.search_players(
|
|
query, limit=limit, season=season, all_seasons=all_seasons
|
|
)
|
|
|
|
except Exception as e:
|
|
logger.error(f"Error in fuzzy search for '{query}': {e}")
|
|
return []
|
|
|
|
async def _search_players_fuzzy_legacy(
|
|
self, query: str, limit: int = 10, season: Optional[int] = None
|
|
) -> List[Player]:
|
|
"""
|
|
Legacy fuzzy search for players by name with limit using existing name search functionality.
|
|
Kept for backwards compatibility if needed.
|
|
|
|
Args:
|
|
query: Search query
|
|
limit: Maximum results to return
|
|
season: Season to search in (defaults to current season)
|
|
|
|
Returns:
|
|
List of matching players (up to limit)
|
|
"""
|
|
try:
|
|
if season is None:
|
|
season = get_config().sba_season
|
|
|
|
# Use the existing name-based search that actually works
|
|
players = await self.get_players_by_name(query, season)
|
|
|
|
# Sort by relevance (exact matches first, then partial)
|
|
query_lower = query.lower()
|
|
exact_matches = []
|
|
partial_matches = []
|
|
|
|
for player in players:
|
|
name_lower = player.name.lower()
|
|
if name_lower == query_lower:
|
|
exact_matches.append(player)
|
|
elif query_lower in name_lower:
|
|
partial_matches.append(player)
|
|
|
|
# Combine and limit results
|
|
results = exact_matches + partial_matches
|
|
limited_results = results[:limit]
|
|
|
|
logger.debug(
|
|
f"Fuzzy search '{query}' returned {len(limited_results)} of {len(results)} matches"
|
|
)
|
|
return limited_results
|
|
|
|
except Exception as e:
|
|
logger.error(f"Error in fuzzy search for '{query}': {e}")
|
|
return []
|
|
|
|
async def get_free_agents(self, season: int) -> List[Player]:
|
|
"""
|
|
Get all free agent players.
|
|
|
|
Args:
|
|
season: Season number (required)
|
|
|
|
Returns:
|
|
List of free agent players
|
|
"""
|
|
try:
|
|
params = [
|
|
("team_id", get_config().free_agent_team_id),
|
|
("season", str(season)),
|
|
]
|
|
|
|
players = await self.get_all_items(params=params)
|
|
logger.debug(f"Retrieved {len(players)} free agents")
|
|
return players
|
|
|
|
except Exception as e:
|
|
logger.error(f"Failed to get free agents: {e}")
|
|
return []
|
|
|
|
async def is_free_agent(self, player: Player) -> bool:
|
|
"""
|
|
Check if a player is a free agent.
|
|
|
|
Args:
|
|
player: Player instance to check
|
|
|
|
Returns:
|
|
True if player is a free agent
|
|
"""
|
|
return player.team_id == get_config().free_agent_team_id
|
|
|
|
async def get_top_free_agents(self, season: int, limit: int = 5) -> List[Player]:
|
|
"""
|
|
Get top free agents sorted by sWAR (wara) descending.
|
|
|
|
Args:
|
|
season: Season number (required)
|
|
limit: Maximum number of players to return (default 5)
|
|
|
|
Returns:
|
|
List of top free agent players sorted by sWAR
|
|
"""
|
|
try:
|
|
free_agents = await self.get_free_agents(season)
|
|
# Sort by wara descending and take top N
|
|
sorted_fa = sorted(
|
|
free_agents, key=lambda p: p.wara if p.wara else 0.0, reverse=True
|
|
)
|
|
return sorted_fa[:limit]
|
|
except Exception as e:
|
|
logger.error(f"Failed to get top free agents: {e}")
|
|
return []
|
|
|
|
async def get_players_by_position(self, position: str, season: int) -> List[Player]:
|
|
"""
|
|
Get players by position.
|
|
|
|
Args:
|
|
position: Player position (e.g., 'C', '1B', 'OF')
|
|
season: Season number (required)
|
|
|
|
Returns:
|
|
List of players at the position
|
|
"""
|
|
try:
|
|
params = [("position", position), ("season", str(season))]
|
|
|
|
players = await self.get_all_items(params=params)
|
|
logger.debug(f"Retrieved {len(players)} players at position {position}")
|
|
return players
|
|
|
|
except Exception as e:
|
|
logger.error(f"Failed to get players by position {position}: {e}")
|
|
return []
|
|
|
|
async def update_player(self, player_id: int, updates: dict) -> Optional[Player]:
|
|
"""
|
|
Update player information.
|
|
|
|
Args:
|
|
player_id: Player ID to update
|
|
updates: Dictionary of fields to update
|
|
|
|
Returns:
|
|
Updated player instance or None
|
|
|
|
Note:
|
|
The player PATCH endpoint uses query parameters instead of JSON body,
|
|
so we pass use_query_params=True to the patch method.
|
|
"""
|
|
try:
|
|
return await self.patch(player_id, updates, use_query_params=True)
|
|
except Exception as e:
|
|
logger.error(f"Failed to update player {player_id}: {e}")
|
|
return None
|
|
|
|
async def update_player_team(
|
|
self,
|
|
player_id: int,
|
|
new_team_id: int,
|
|
dem_week: Optional[int] = None
|
|
) -> Optional[Player]:
|
|
"""
|
|
Update a player's team assignment.
|
|
|
|
Args:
|
|
player_id: Player ID to update
|
|
new_team_id: New team ID to assign
|
|
dem_week: Optional demotion/designation week to set.
|
|
- Transaction freeze: Current.week + 2
|
|
- Draft picks: Current.week + 2
|
|
- IL moves: Current.week
|
|
- Admin/Other: Not set (None)
|
|
|
|
Returns:
|
|
Updated player instance or None
|
|
|
|
Raises:
|
|
APIException: If player update fails
|
|
|
|
Examples:
|
|
# Transaction freeze (Monday morning)
|
|
await player_service.update_player_team(
|
|
player_id, new_team_id, dem_week=current.week + 2
|
|
)
|
|
|
|
# Draft pick (manual or auto-draft)
|
|
await player_service.update_player_team(
|
|
player_id, new_team_id, dem_week=current.week + 2
|
|
)
|
|
|
|
# IL move (/ilmove command)
|
|
await player_service.update_player_team(
|
|
player_id, new_team_id, dem_week=current.week
|
|
)
|
|
|
|
# Admin move (no dem_week)
|
|
await player_service.update_player_team(player_id, new_team_id)
|
|
"""
|
|
try:
|
|
logger.info(
|
|
f"Updating player {player_id} team to {new_team_id}"
|
|
+ (f" with dem_week={dem_week}" if dem_week is not None else "")
|
|
)
|
|
|
|
# Build update dictionary
|
|
updates = {"team_id": new_team_id}
|
|
if dem_week is not None:
|
|
updates["demotion_week"] = dem_week
|
|
|
|
updated_player = await self.update_player(player_id, updates)
|
|
|
|
if updated_player:
|
|
logger.info(
|
|
f"Successfully updated player {player_id} to team {new_team_id}"
|
|
+ (f" with dem_week={dem_week}" if dem_week is not None else "")
|
|
)
|
|
return updated_player
|
|
else:
|
|
logger.error(
|
|
f"Failed to update player {player_id} team - no response from API"
|
|
)
|
|
raise APIException(
|
|
f"Failed to update player {player_id} team assignment"
|
|
)
|
|
|
|
except Exception as e:
|
|
logger.error(f"Error updating player {player_id} team: {e}")
|
|
raise APIException(f"Failed to update player team: {e}")
|
|
|
|
|
|
# Global service instance - will be properly initialized in __init__.py
|
|
player_service = PlayerService()
|