major-domo-database/app/services/player_service.py
root e5452cf0bf refactor: Add dependency injection for testability
- Created ServiceConfig for dependency configuration
- Created Abstract interfaces (Protocols) for mocking
- Created MockPlayerRepository, MockTeamRepository, MockCacheService
- Refactored BaseService and PlayerService to accept injectable dependencies
- Added pytest configuration and unit tests
- Tests can run without real database (uses mocks)

Benefits:
- Unit tests run in seconds without DB
- Easy to swap implementations
- Clear separation of concerns
2026-02-03 15:59:04 +00:00

388 lines
14 KiB
Python

"""
Player Service - Dependency Injection Version
Business logic for player operations with injectable dependencies.
"""
import logging
from typing import List, Optional, Dict, Any
from peewee import fn as peewee_fn
from .base import BaseService
from .interfaces import AbstractPlayerRepository
from .mocks import MockPlayerRepository
logger = logging.getLogger('discord_app')
class PlayerService(BaseService):
"""Service for player-related operations with dependency injection."""
cache_patterns = [
"players*",
"players-search*",
"player*",
"team-roster*"
]
def __init__(
self,
player_repo: Optional[AbstractPlayerRepository] = None,
**kwargs
):
"""
Initialize PlayerService with optional repository.
Args:
player_repo: AbstractPlayerRepository implementation (mock or real)
**kwargs: Additional arguments passed to BaseService
"""
super().__init__(player_repo=player_repo, **kwargs)
self._player_repo = player_repo
def get_players(
self,
season: Optional[int] = None,
team_id: Optional[List[int]] = None,
pos: Optional[List[str]] = None,
strat_code: Optional[List[str]] = None,
name: Optional[str] = None,
is_injured: Optional[bool] = None,
sort: Optional[str] = None,
short_output: bool = False,
as_csv: bool = False
) -> Dict[str, Any]:
"""
Get players with filtering and sorting.
Args:
season: Filter by season
team_id: Filter by team IDs
pos: Filter by positions
strat_code: Filter by strat codes
name: Filter by name (exact match)
is_injured: Filter by injury status
sort: Sort order
short_output: Exclude related data
as_csv: Return as CSV format
Returns:
Dict with count and players list, or CSV string
"""
try:
# Build base query
if season is not None:
query = self.player_repo.select_season(season)
else:
query = self.player_repo.select_season(0) # Get all, filter below
# If no season specified, get all and filter
if season is None:
# Get all players via default repo or iterate
all_items = list(self.player_repo.select_season(0)) if hasattr(self.player_repo, 'select_season') else []
# Fall back to get_by_id for all
if not all_items:
# Default behavior for non-mock repos
from ..db_engine import Player
all_items = list(Player.select())
query = MockQueryResult([p if isinstance(p, dict) else self._player_to_dict(p) for p in all_items])
# Apply filters
if team_id:
query = query.where(Player.team_id << team_id)
if strat_code:
code_list = [x.lower() for x in strat_code]
query = query.where(peewee_fn.Lower(Player.strat_code) << code_list)
if name:
query = query.where(peewee_fn.lower(Player.name) == name.lower())
if pos:
p_list = [x.upper() for x in pos]
pos_conditions = (
(Player.pos_1 << 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)
)
query = query.where(pos_conditions)
if is_injured is not None:
query = query.where(Player.il_return.is_null(False))
# Apply sorting
if sort == "cost-asc":
query = query.order_by(Player.wara)
elif sort == "cost-desc":
query = query.order_by(-Player.wara)
elif sort == "name-asc":
query = query.order_by(Player.name)
elif sort == "name-desc":
query = query.order_by(-Player.name)
else:
query = query.order_by(Player.id)
# Return format
if as_csv:
return self._format_player_csv(query)
else:
players_data = [
self._player_to_dict(p, recurse=not short_output)
for p in query
]
return {
"count": query.count(),
"players": players_data
}
except Exception as e:
self.handle_error(f"Error fetching players: {e}", e)
finally:
self.close_db()
def search_players(
self,
query_str: str,
season: Optional[int] = None,
limit: int = 10,
short_output: bool = False
) -> Dict[str, Any]:
"""
Search players by name with fuzzy matching.
Args:
query_str: Search query
season: Season to search (None/0 for all)
limit: Maximum results
short_output: Exclude related data
Returns:
Dict with count and matching players
"""
try:
query_lower = query_str.lower()
search_all_seasons = season is None or season == 0
# Build base query
if search_all_seasons:
all_players = self.player_repo.select_season(0)
if hasattr(all_players, '__iter__') and not isinstance(all_players, list):
all_players = list(all_players)
else:
all_players = self.player_repo.select_season(season)
if hasattr(all_players, '__iter__') and not isinstance(all_players, list):
all_players = list(all_players)
# Convert to list if needed
if not isinstance(all_players, list):
from ..db_engine import Player
all_players = list(Player.select())
# Sort by relevance (exact matches first)
exact_matches = []
partial_matches = []
for player in all_players:
player_dict = player if isinstance(player, dict) else self._player_to_dict(player)
name_lower = player_dict.get('name', '').lower()
if name_lower == query_lower:
exact_matches.append(player_dict)
elif query_lower in name_lower:
partial_matches.append(player_dict)
# Sort by season within each group
if search_all_seasons:
exact_matches.sort(key=lambda p: p.get('season', 0), reverse=True)
partial_matches.sort(key=lambda p: p.get('season', 0), reverse=True)
# Combine and limit
results = (exact_matches + partial_matches)[:limit]
return {
"count": len(results),
"total_matches": len(exact_matches + partial_matches),
"all_seasons": search_all_seasons,
"players": results
}
except Exception as e:
self.handle_error(f"Error searching players: {e}", e)
finally:
self.close_db()
def get_player(self, player_id: int, short_output: bool = False) -> Optional[Dict[str, Any]]:
"""Get a single player by ID."""
try:
player = self.player_repo.get_by_id(player_id)
if player:
return self._player_to_dict(player, recurse=not short_output)
return None
except Exception as e:
self.handle_error(f"Error fetching player {player_id}: {e}", e)
finally:
self.close_db()
def update_player(self, player_id: int, data: Dict[str, Any], token: str) -> Dict[str, Any]:
"""Update a player (full update via PUT)."""
self.require_auth(token)
try:
# Verify player exists
if not self.player_repo.get_by_id(player_id):
from fastapi import HTTPException
raise HTTPException(status_code=404, detail=f"Player ID {player_id} not found")
# Execute update
self.player_repo.update(data, Player.id == player_id)
return self.get_player(player_id)
except Exception as e:
self.handle_error(f"Error updating player {player_id}: {e}", e)
finally:
self.invalidate_related_cache(self.cache_patterns)
self.close_db()
def patch_player(self, player_id: int, data: Dict[str, Any], token: str) -> Dict[str, Any]:
"""Patch a player (partial update)."""
self.require_auth(token)
try:
player = self.player_repo.get_by_id(player_id)
if not player:
from fastapi import HTTPException
raise HTTPException(status_code=404, detail=f"Player ID {player_id} not found")
# Apply updates
for key, value in data.items():
if value is not None and hasattr(player, key):
setattr(player, key, value)
# Save using repo
if hasattr(player, 'save'):
player.save()
return self.get_player(player_id)
except Exception as e:
self.handle_error(f"Error patching player {player_id}: {e}", e)
finally:
self.invalidate_related_cache(self.cache_patterns)
self.close_db()
def create_players(self, players_data: List[Dict[str, Any]], token: str) -> Dict[str, Any]:
"""Create multiple players."""
self.require_auth(token)
try:
# Check for duplicates
for player in players_data:
dupe = self.player_repo.get_or_none(
Player.season == player.get("season"),
Player.name == player.get("name")
)
if dupe:
from fastapi import HTTPException
raise HTTPException(
status_code=500,
detail=f"Player {player.get('name')} already exists in Season {player.get('season')}"
)
# Insert in batches
self.player_repo.insert_many(players_data)
return {"message": f"Inserted {len(players_data)} players"}
except Exception as e:
self.handle_error(f"Error creating players: {e}", e)
finally:
self.invalidate_related_cache(self.cache_patterns)
self.close_db()
def delete_player(self, player_id: int, token: str) -> Dict[str, str]:
"""Delete a player."""
self.require_auth(token)
try:
if not self.player_repo.get_by_id(player_id):
from fastapi import HTTPException
raise HTTPException(status_code=404, detail=f"Player ID {player_id} not found")
self.player_repo.delete_by_id(player_id)
return {"message": f"Player {player_id} deleted"}
except Exception as e:
self.handle_error(f"Error deleting player {player_id}: {e}", e)
finally:
self.invalidate_related_cache(self.cache_patterns)
self.close_db()
def _player_to_dict(self, player, recurse: bool = True) -> Dict[str, Any]:
"""Convert player to dict."""
from playhouse.shortcuts import model_to_dict
from ..db_engine import Player
if isinstance(player, dict):
return player
return model_to_dict(player, recurse=recurse)
def _format_player_csv(self, query) -> str:
"""Format player query results as CSV."""
from ..db_engine import Player, db
from pandas import DataFrame
headers = [
"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", "headshot",
"vanity_card", "strat_code", "bbref_id", "injury_rating", "player_id", "sbaref_id"
]
rows = []
for player in query:
player_dict = self._player_to_dict(player, recurse=False)
strat_code = player_dict.get('strat_code', '') or ''
if ',' in strat_code:
strat_code = strat_code.replace(",", "-_-")
rows.append([
player_dict.get('name', ''),
player_dict.get('wara', 0),
player_dict.get('image', ''),
player_dict.get('image2', ''),
player_dict.get('team', {}).get('abbrev', '') if isinstance(player_dict.get('team'), dict) else '',
player_dict.get('season', 0),
player_dict.get('pitcher_injury', ''),
player_dict.get('pos_1', ''),
player_dict.get('pos_2', ''),
player_dict.get('pos_3', ''),
player_dict.get('pos_4', ''),
player_dict.get('pos_5', ''),
player_dict.get('pos_6', ''),
player_dict.get('pos_7', ''),
player_dict.get('pos_8', ''),
player_dict.get('last_game', ''),
player_dict.get('last_game2', ''),
player_dict.get('il_return', ''),
player_dict.get('demotion_week', ''),
player_dict.get('headshot', ''),
player_dict.get('vanity_card', ''),
strat_code,
player_dict.get('bbref_id', ''),
player_dict.get('injury_rating', ''),
player_dict.get('id', 0),
player_dict.get('sbaplayer_id', 0)
])
all_data = [headers] + rows
return DataFrame(all_data).to_csv(header=False, index=False)
# Import Player for use in methods
from ..db_engine import Player