major-domo-v2/services/draft_sheet_service.py
Cal Corum a814aadd61 Optimize draft sheet batch writes to single API call
- Changed write_picks_batch() from 105 individual API calls to 1 batch call
- Builds 2D array covering full pick range and writes in single update_values()
- Eliminates Google Sheets 429 rate limiting during resync operations
- Reduces resync time from ~2 minutes to seconds

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-12 15:01:48 -06:00

325 lines
11 KiB
Python

"""
Draft Sheet Service
Handles writing draft picks to Google Sheets for public tracking.
Extends SheetsService to reuse authentication and async patterns.
"""
import asyncio
from typing import List, Optional, Tuple
from config import get_config
from exceptions import SheetsException
from services.sheets_service import SheetsService
from utils.logging import get_contextual_logger
class DraftSheetService(SheetsService):
"""Service for writing draft picks to Google Sheets."""
def __init__(self, credentials_path: Optional[str] = None):
"""
Initialize draft sheet service.
Args:
credentials_path: Path to service account credentials JSON
If None, will use path from config
"""
super().__init__(credentials_path)
self.logger = get_contextual_logger(f'{__name__}.DraftSheetService')
self._config = get_config()
async def write_pick(
self,
season: int,
overall: int,
orig_owner_abbrev: str,
owner_abbrev: str,
player_name: str,
swar: float
) -> bool:
"""
Write a single draft pick to the season's draft sheet.
Data is written to columns D-G (4 columns):
- D: Original owner abbreviation (for traded picks)
- E: Current owner abbreviation
- F: Player name
- G: Player sWAR value
Row number is calculated as: overall + 1 (pick 1 goes to row 2).
Args:
season: Draft season number
overall: Overall pick number (1-512)
orig_owner_abbrev: Original owner team abbreviation
owner_abbrev: Current owner team abbreviation
player_name: Name of the drafted player
swar: Player's sWAR (WAR Above Replacement) value
Returns:
True if write succeeded, False otherwise
"""
if not self._config.draft_sheet_enabled:
self.logger.debug("Draft sheet writes are disabled")
return False
sheet_key = self._config.get_draft_sheet_key(season)
if not sheet_key:
self.logger.warning(f"No draft sheet configured for season {season}")
return False
try:
loop = asyncio.get_event_loop()
# Get pygsheets client
sheets = await loop.run_in_executor(None, self._get_client)
# Open the draft sheet by key
spreadsheet = await loop.run_in_executor(
None,
sheets.open_by_key,
sheet_key
)
# Get the worksheet
worksheet = await loop.run_in_executor(
None,
spreadsheet.worksheet_by_title,
self._config.draft_sheet_worksheet
)
# Prepare pick data (4 columns: orig_owner, owner, player, swar)
pick_data = [[orig_owner_abbrev, owner_abbrev, player_name, swar]]
# Calculate row (overall + 1 to leave row 1 for headers)
row = overall + 1
start_column = self._config.draft_sheet_start_column
cell_range = f'{start_column}{row}'
# Write the pick data
await loop.run_in_executor(
None,
lambda: worksheet.update_values(crange=cell_range, values=pick_data)
)
self.logger.info(
f"Wrote pick {overall} to draft sheet",
season=season,
overall=overall,
player=player_name,
owner=owner_abbrev
)
return True
except Exception as e:
self.logger.error(
f"Failed to write pick to draft sheet: {e}",
season=season,
overall=overall,
player=player_name
)
return False
async def write_picks_batch(
self,
season: int,
picks: List[Tuple[int, str, str, str, float]]
) -> Tuple[int, int]:
"""
Write multiple draft picks to the sheet in a single batch operation.
Used for resync operations to repopulate the entire sheet from database.
Uses true batch updates to write all picks in a single API call,
avoiding rate limiting issues.
Args:
season: Draft season number
picks: List of tuples (overall, orig_owner_abbrev, owner_abbrev, player_name, swar)
Returns:
Tuple of (success_count, failure_count)
"""
if not self._config.draft_sheet_enabled:
self.logger.debug("Draft sheet writes are disabled")
return (0, len(picks))
sheet_key = self._config.get_draft_sheet_key(season)
if not sheet_key:
self.logger.warning(f"No draft sheet configured for season {season}")
return (0, len(picks))
if not picks:
return (0, 0)
try:
loop = asyncio.get_event_loop()
# Get pygsheets client
sheets = await loop.run_in_executor(None, self._get_client)
# Open the draft sheet by key
spreadsheet = await loop.run_in_executor(
None,
sheets.open_by_key,
sheet_key
)
# Get the worksheet
worksheet = await loop.run_in_executor(
None,
spreadsheet.worksheet_by_title,
self._config.draft_sheet_worksheet
)
# Sort picks by overall to find range bounds
sorted_picks = sorted(picks, key=lambda p: p[0])
# Find min and max overall to determine row range
min_overall = sorted_picks[0][0]
max_overall = sorted_picks[-1][0]
# Build a 2D array for the entire range (sparse - empty rows for missing picks)
# Row index 0 = min_overall, row index N = max_overall
num_rows = max_overall - min_overall + 1
batch_data: List[List[str]] = [['', '', '', ''] for _ in range(num_rows)]
# Populate the batch data array
for overall, orig_owner, owner, player_name, swar in sorted_picks:
row_index = overall - min_overall
batch_data[row_index] = [orig_owner, owner, player_name, str(swar)]
# Calculate the cell range for the batch write
start_row = min_overall + 1 # +1 for header row
start_column = self._config.draft_sheet_start_column
end_column = chr(ord(start_column) + 3) # 4 columns: D -> G
end_row = max_overall + 1
cell_range = f'{start_column}{start_row}:{end_column}{end_row}'
self.logger.info(
f"Writing {len(picks)} picks in single batch to range {cell_range}",
season=season
)
# Write all picks in a single API call
await loop.run_in_executor(
None,
lambda: worksheet.update_values(crange=cell_range, values=batch_data)
)
self.logger.info(
f"Batch write complete: {len(picks)} picks written successfully",
season=season,
total_picks=len(picks)
)
return (len(picks), 0)
except Exception as e:
self.logger.error(f"Failed batch write: {e}", season=season)
return (0, len(picks))
async def clear_picks_range(
self,
season: int,
start_overall: int = 1,
end_overall: int = 512
) -> bool:
"""
Clear a range of picks from the draft sheet.
Used before resync to clear existing data.
Args:
season: Draft season number
start_overall: First pick to clear (default: 1)
end_overall: Last pick to clear (default: 512 for 32 rounds * 16 teams)
Returns:
True if clear succeeded, False otherwise
"""
if not self._config.draft_sheet_enabled:
self.logger.debug("Draft sheet writes are disabled")
return False
sheet_key = self._config.get_draft_sheet_key(season)
if not sheet_key:
self.logger.warning(f"No draft sheet configured for season {season}")
return False
try:
loop = asyncio.get_event_loop()
# Get pygsheets client
sheets = await loop.run_in_executor(None, self._get_client)
# Open the draft sheet by key
spreadsheet = await loop.run_in_executor(
None,
sheets.open_by_key,
sheet_key
)
# Get the worksheet
worksheet = await loop.run_in_executor(
None,
spreadsheet.worksheet_by_title,
self._config.draft_sheet_worksheet
)
# Calculate range (4 columns: D through G)
start_row = start_overall + 1
end_row = end_overall + 1
start_column = self._config.draft_sheet_start_column
# Convert start column letter to end column (D -> G for 4 columns)
end_column = chr(ord(start_column) + 3)
cell_range = f'{start_column}{start_row}:{end_column}{end_row}'
# Clear the range by setting empty values
# We create a 2D array of empty strings
num_rows = end_row - start_row + 1
empty_data = [['', '', '', ''] for _ in range(num_rows)]
await loop.run_in_executor(
None,
lambda: worksheet.update_values(
crange=f'{start_column}{start_row}',
values=empty_data
)
)
self.logger.info(
f"Cleared picks {start_overall}-{end_overall} from draft sheet",
season=season
)
return True
except Exception as e:
self.logger.error(f"Failed to clear draft sheet: {e}", season=season)
return False
def get_sheet_url(self, season: int) -> Optional[str]:
"""
Get the full Google Sheets URL for a given draft season.
Args:
season: Draft season number
Returns:
Full URL to the draft sheet, or None if not configured
"""
return self._config.get_draft_sheet_url(season)
# Global service instance - lazily initialized
_draft_sheet_service: Optional[DraftSheetService] = None
def get_draft_sheet_service() -> DraftSheetService:
"""Get the global draft sheet service instance."""
global _draft_sheet_service
if _draft_sheet_service is None:
_draft_sheet_service = DraftSheetService()
return _draft_sheet_service