major-domo-v2/services/sheets_service.py
Cal Corum 2409c27c1d CLAUDE: Add comprehensive scorecard submission system
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>
2025-10-16 00:21:32 -05:00

316 lines
10 KiB
Python

"""
Google Sheets Service
Handles reading data from Google Sheets scorecards for game submission.
"""
import asyncio
from typing import Dict, List, Any, Optional
import pygsheets
from utils.logging import get_contextual_logger
from exceptions import SheetsException
class SheetsService:
"""Google Sheets integration for scorecard reading."""
def __init__(self, credentials_path: Optional[str] = None):
"""
Initialize sheets service.
Args:
credentials_path: Path to service account credentials JSON
If None, will use path from config
"""
if credentials_path is None:
from config import get_config
credentials_path = get_config().sheets_credentials_path
self.credentials_path = credentials_path
self.logger = get_contextual_logger(f'{__name__}.SheetsService')
self._sheets_client = None
def _get_client(self) -> pygsheets.client.Client:
"""Get or create pygsheets client."""
if self._sheets_client is None:
self._sheets_client = pygsheets.authorize(
service_file=self.credentials_path
)
return self._sheets_client
async def open_scorecard(self, sheet_url: str) -> pygsheets.Spreadsheet:
"""
Open and validate access to a Google Sheet.
Args:
sheet_url: Full URL to Google Sheet
Returns:
Opened spreadsheet object
Raises:
SheetsException: If sheet cannot be accessed
"""
try:
# Run in thread pool since pygsheets is synchronous
loop = asyncio.get_event_loop()
sheets = await loop.run_in_executor(
None,
self._get_client
)
scorecard = await loop.run_in_executor(
None,
sheets.open_by_url,
sheet_url
)
self.logger.info(f"Opened scorecard: {scorecard.title}")
return scorecard
except Exception as e:
self.logger.error(f"Failed to open scorecard {sheet_url}: {e}")
raise SheetsException(
"Unable to access scorecard. Is it publicly readable?"
) from e
async def read_setup_data(
self,
scorecard: pygsheets.Spreadsheet
) -> Dict[str, Any]:
"""
Read game metadata from Setup tab.
Cell mappings:
- V35: Scorecard version
- C3:D7: Game data (week, game_num, teams, managers)
Returns:
Dictionary with keys:
- version: str
- week: int
- game_num: int
- away_team_abbrev: str
- home_team_abbrev: str
- away_manager_name: str
- home_manager_name: str
"""
try:
loop = asyncio.get_event_loop()
# Get Setup tab
setup_tab = await loop.run_in_executor(
None,
scorecard.worksheet_by_title,
'Setup'
)
# Read version
version = await loop.run_in_executor(
None,
setup_tab.get_value,
'V35'
)
# Read game data (C3:D7)
g_data = await loop.run_in_executor(
None,
setup_tab.get_values,
'C3',
'D7'
)
return {
'version': version,
'week': int(g_data[1][0]),
'game_num': int(g_data[2][0]),
'away_team_abbrev': g_data[3][0],
'home_team_abbrev': g_data[4][0],
'away_manager_name': g_data[3][1],
'home_manager_name': g_data[4][1]
}
except Exception as e:
self.logger.error(f"Failed to read setup data: {e}")
raise SheetsException("Unable to read game setup data") from e
async def read_playtable_data(
self,
scorecard: pygsheets.Spreadsheet
) -> List[Dict[str, Any]]:
"""
Read all plays from Playtable tab.
Reads range B3:BW300 which contains up to 297 rows of play data
with 68 columns per row.
Returns:
List of play dictionaries with field names mapped
"""
try:
loop = asyncio.get_event_loop()
# Get Playtable tab
playtable = await loop.run_in_executor(
None,
scorecard.worksheet_by_title,
'Playtable'
)
# Read play data
all_plays = await loop.run_in_executor(
None,
playtable.get_values,
'B3',
'BW300'
)
# Field names in order (from old bot lines 1621-1632)
play_keys = [
'play_num', 'batter_id', 'batter_pos', 'pitcher_id',
'on_base_code', 'inning_half', 'inning_num', 'batting_order',
'starting_outs', 'away_score', 'home_score', 'on_first_id',
'on_first_final', 'on_second_id', 'on_second_final',
'on_third_id', 'on_third_final', 'batter_final', 'pa', 'ab',
'run', 'e_run', 'hit', 'rbi', 'double', 'triple', 'homerun',
'bb', 'so', 'hbp', 'sac', 'ibb', 'gidp', 'bphr', 'bpfo',
'bp1b', 'bplo', 'sb', 'cs', 'outs', 'pitcher_rest_outs',
'wpa', 'catcher_id', 'defender_id', 'runner_id', 'check_pos',
'error', 'wild_pitch', 'passed_ball', 'pick_off', 'balk',
'is_go_ahead', 'is_tied', 'is_new_inning', 'inherited_runners',
'inherited_scored', 'on_hook_for_loss', 'run_differential',
'unused-manager', 'unused-pitcherpow', 'unused-pitcherrestip',
'unused-runners', 'unused-fatigue', 'unused-roundedip',
'unused-elitestart', 'unused-scenario', 'unused-winxaway',
'unused-winxhome', 'unused-pinchrunner', 'unused-order',
'hand_batting', 'hand_pitching', 're24_primary', 're24_running'
]
p_data = []
for line in all_plays:
this_data = {}
for count, value in enumerate(line):
if value != '' and count < len(play_keys):
this_data[play_keys[count]] = value
# Only include rows with meaningful data (>5 fields)
if len(this_data.keys()) > 5:
p_data.append(this_data)
self.logger.info(f"Read {len(p_data)} plays from scorecard")
return p_data
except Exception as e:
self.logger.error(f"Failed to read playtable data: {e}")
raise SheetsException("Unable to read play-by-play data") from e
async def read_pitching_decisions(
self,
scorecard: pygsheets.Spreadsheet
) -> List[Dict[str, Any]]:
"""
Read pitching decisions from Pitcherstats tab.
Reads range B3:O30 which contains up to 27 rows of pitcher data
with 14 columns per row.
Returns:
List of decision dictionaries with field names mapped
"""
try:
loop = asyncio.get_event_loop()
# Get Pitcherstats tab
pitching = await loop.run_in_executor(
None,
scorecard.worksheet_by_title,
'Pitcherstats'
)
# Read decision data
all_decisions = await loop.run_in_executor(
None,
pitching.get_values,
'B3',
'O30'
)
# Field names in order (from old bot lines 1688-1691)
pit_keys = [
'pitcher_id', 'rest_ip', 'is_start', 'base_rest',
'extra_rest', 'rest_required', 'win', 'loss', 'is_save',
'hold', 'b_save', 'irunners', 'irunners_scored', 'team_id'
]
pit_data = []
for line in all_decisions:
if not line: # Skip empty rows
continue
this_data = {}
for count, value in enumerate(line):
if value != '' and count < len(pit_keys):
this_data[pit_keys[count]] = value
if this_data: # Only include non-empty rows
pit_data.append(this_data)
self.logger.info(f"Read {len(pit_data)} pitching decisions")
return pit_data
except Exception as e:
self.logger.error(f"Failed to read pitching decisions: {e}")
raise SheetsException("Unable to read pitching decisions") from e
async def read_box_score(
self,
scorecard: pygsheets.Spreadsheet
) -> Dict[str, List[int]]:
"""
Read box score from Scorecard or Box Score tab.
Tries 'Scorecard' tab first (BW8:BY9), falls back to
'Box Score' tab (T6:V7).
Returns:
Dictionary with 'away' and 'home' keys, each containing
[runs, hits, errors]
"""
try:
loop = asyncio.get_event_loop()
# Try Scorecard tab first
try:
sc_tab = await loop.run_in_executor(
None,
scorecard.worksheet_by_title,
'Scorecard'
)
score_table = await loop.run_in_executor(
None,
sc_tab.get_values,
'BW8',
'BY9'
)
except pygsheets.WorksheetNotFound:
# Fallback to Box Score tab
sc_tab = await loop.run_in_executor(
None,
scorecard.worksheet_by_title,
'Box Score'
)
score_table = await loop.run_in_executor(
None,
sc_tab.get_values,
'T6',
'V7'
)
return {
'away': [int(x) for x in score_table[0]], # [R, H, E]
'home': [int(x) for x in score_table[1]] # [R, H, E]
}
except Exception as e:
self.logger.error(f"Failed to read box score: {e}")
raise SheetsException("Unable to read box score") from e