Implements full Google Sheets scorecard submission with: - Complete game data extraction (68 play fields, pitching decisions, box score) - Transaction rollback support at 3 states (plays/game/complete) - Duplicate game detection with confirmation dialog - Permission-based submission (GMs only) - Automated results posting to news channel - Automatic standings recalculation - Key plays display with WPA sorting New Components: - Play, Decision, Game models with full validation - SheetsService for Google Sheets integration - GameService, PlayService, DecisionService for data management - ConfirmationView for user confirmations - Discord helper utilities for channel operations Services Enhanced: - StandingsService: Added recalculate_standings() method - CustomCommandsService: Fixed creator endpoint path - Team/Player models: Added helper methods for display Configuration: - Added SHEETS_CREDENTIALS_PATH environment variable - Added SBA_NETWORK_NEWS_CHANNEL and role constants - Enabled pygsheets dependency Documentation: - Comprehensive README updates across all modules - Added command, service, model, and view documentation - Detailed workflow and error handling documentation 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
183 lines
5.0 KiB
Python
183 lines
5.0 KiB
Python
"""
|
|
Game Service
|
|
|
|
Manages game CRUD operations and game-specific workflows for scorecard submission.
|
|
"""
|
|
from typing import Optional
|
|
|
|
from services.base_service import BaseService
|
|
from models.game import Game
|
|
from utils.logging import get_contextual_logger
|
|
from exceptions import APIException
|
|
|
|
|
|
class GameService(BaseService[Game]):
|
|
"""Game management service with specialized game operations."""
|
|
|
|
def __init__(self):
|
|
"""Initialize game service."""
|
|
super().__init__(Game, 'games')
|
|
self.logger = get_contextual_logger(f'{__name__}.GameService')
|
|
|
|
async def find_duplicate_game(
|
|
self,
|
|
season: int,
|
|
week: int,
|
|
game_num: int,
|
|
away_team_id: int,
|
|
home_team_id: int
|
|
) -> Optional[Game]:
|
|
"""
|
|
Check for already-played duplicate game.
|
|
|
|
Args:
|
|
season: Season number
|
|
week: Week number
|
|
game_num: Game number in series
|
|
away_team_id: Away team ID
|
|
home_team_id: Home team ID
|
|
|
|
Returns:
|
|
Game if duplicate found (game_num is set), None otherwise
|
|
"""
|
|
params = [
|
|
('season', str(season)),
|
|
('week', str(week)),
|
|
('game_num', str(game_num)),
|
|
('away_team_id', str(away_team_id)),
|
|
('home_team_id', str(home_team_id))
|
|
]
|
|
|
|
games, count = await self.get_all(params=params)
|
|
|
|
if count > 0:
|
|
self.logger.warning(
|
|
f"Found duplicate game: S{season} W{week} G{game_num} "
|
|
f"({away_team_id} @ {home_team_id})"
|
|
)
|
|
return games[0]
|
|
|
|
return None
|
|
|
|
async def find_scheduled_game(
|
|
self,
|
|
season: int,
|
|
week: int,
|
|
away_team_id: int,
|
|
home_team_id: int
|
|
) -> Optional[Game]:
|
|
"""
|
|
Find unplayed scheduled game matching teams and week.
|
|
|
|
Args:
|
|
season: Season number
|
|
week: Week number
|
|
away_team_id: Away team ID
|
|
home_team_id: Home team ID
|
|
|
|
Returns:
|
|
Game if found and not yet played (game_num is None), None otherwise
|
|
"""
|
|
params = [
|
|
('season', str(season)),
|
|
('week', str(week)),
|
|
('away_team_id', str(away_team_id)),
|
|
('home_team_id', str(home_team_id)),
|
|
('played', 'false') # Only unplayed games
|
|
]
|
|
|
|
games, count = await self.get_all(params=params)
|
|
|
|
if count == 0:
|
|
self.logger.warning(
|
|
f"No scheduled game found for S{season} W{week} "
|
|
f"({away_team_id} @ {home_team_id})"
|
|
)
|
|
return None
|
|
|
|
return games[0]
|
|
|
|
async def wipe_game_data(self, game_id: int) -> bool:
|
|
"""
|
|
Wipe game scores and manager assignments.
|
|
|
|
Calls POST /games/wipe/{game_id} which sets:
|
|
- away_score = None
|
|
- home_score = None
|
|
- game_num = None
|
|
- away_manager = None
|
|
- home_manager = None
|
|
|
|
Args:
|
|
game_id: Game ID to wipe
|
|
|
|
Returns:
|
|
True if successful
|
|
|
|
Raises:
|
|
APIException: If wipe fails
|
|
"""
|
|
try:
|
|
client = await self.get_client()
|
|
response = await client.post(f'games/wipe/{game_id}', {})
|
|
|
|
self.logger.info(f"Wiped game {game_id}")
|
|
return True
|
|
|
|
except Exception as e:
|
|
self.logger.error(f"Failed to wipe game {game_id}: {e}")
|
|
raise APIException(f"Failed to wipe game data: {e}")
|
|
|
|
async def update_game_result(
|
|
self,
|
|
game_id: int,
|
|
away_score: int,
|
|
home_score: int,
|
|
away_manager_id: int,
|
|
home_manager_id: int,
|
|
game_num: int,
|
|
scorecard_url: str
|
|
) -> Game:
|
|
"""
|
|
Update game with scores, managers, and scorecard URL.
|
|
|
|
Args:
|
|
game_id: Game ID to update
|
|
away_score: Away team final score
|
|
home_score: Home team final score
|
|
away_manager_id: Away team manager ID
|
|
home_manager_id: Home team manager ID
|
|
game_num: Game number in series
|
|
scorecard_url: URL to scorecard
|
|
|
|
Returns:
|
|
Updated game object
|
|
|
|
Raises:
|
|
APIException: If update fails
|
|
"""
|
|
update_data = {
|
|
'away_score': away_score,
|
|
'home_score': home_score,
|
|
'away_manager_id': away_manager_id,
|
|
'home_manager_id': home_manager_id,
|
|
'game_num': game_num,
|
|
'scorecard_url': scorecard_url
|
|
}
|
|
|
|
updated_game = await self.patch(
|
|
game_id,
|
|
update_data,
|
|
use_query_params=True # API expects query params for PATCH
|
|
)
|
|
|
|
if updated_game is None:
|
|
raise APIException(f"Game {game_id} not found for update")
|
|
|
|
self.logger.info(f"Updated game {game_id} with final score")
|
|
return updated_game
|
|
|
|
|
|
# Global service instance
|
|
game_service = GameService()
|