diff --git a/commands/draft/CLAUDE.md b/commands/draft/CLAUDE.md index de72da2..162a442 100644 --- a/commands/draft/CLAUDE.md +++ b/commands/draft/CLAUDE.md @@ -49,6 +49,7 @@ Draft commands are only available in the offseason. - `player_service.get_players_by_name()` - `player_service.update_player_team()` - `league_service.get_current_state()` (for period check) + - `draft_sheet_service.write_pick()` (Google Sheets integration) ## Key Features @@ -106,7 +107,8 @@ async with self.pick_lock: 5. **Player Validation**: Verify player is FA (team_id = 498) 6. **Cap Space**: Validate 32 sWAR limit won't be exceeded 7. **Execution**: Update pick, update player team, advance draft -8. **Announcements**: Post success message and player card +8. **Sheet Write**: Write pick to Google Sheets (fire-and-forget) +9. **Announcements**: Post success message and player card ### FA Player Autocomplete The autocomplete function filters to FA players only: @@ -141,6 +143,93 @@ async def validate_cap_space(roster: dict, new_player_wara: float): return projected_total <= 32.00001, projected_total ``` +## Google Sheets Integration + +### Overview +Draft picks are automatically written to a shared Google Sheet for easy tracking. This feature: +- Writes pick data to configured sheet after each successful pick +- Uses **fire-and-forget** pattern (non-blocking, doesn't fail the pick) +- Supports manual resync via `/draft-admin resync-sheet` +- Shows sheet link in `/draft-status` embed + +### Sheet Structure +Each pick writes 4 columns starting at column D: +| Column | Content | +|--------|---------| +| D | Original owner abbreviation | +| E | Current owner abbreviation | +| F | Player name | +| G | Player sWAR | + +Row calculation: `overall + 1` (pick 1 → row 2, leaving row 1 for headers) + +### Fire-and-Forget Pattern +```python +# After successful pick execution +try: + sheet_success = await draft_sheet_service.write_pick( + season=config.sba_season, + overall=pick.overall, + orig_owner_abbrev=original_owner.abbrev, + owner_abbrev=team.abbrev, + player_name=player.name, + swar=player.wara + ) + if not sheet_success: + self.logger.warning(f"Draft sheet write failed for pick #{pick.overall}") + # Post notification to ping channel +except Exception as e: + self.logger.error(f"Draft sheet write error: {e}") + # Non-critical - don't fail the draft pick +``` + +### Configuration +Environment variables (optional, defaults in config): +- `DRAFT_SHEET_KEY_12` - Sheet ID for season 12 +- `DRAFT_SHEET_KEY_13` - Sheet ID for season 13 +- `DRAFT_SHEET_ENABLED` - Feature flag (default: True) + +Config file defaults in `config.py`: +```python +draft_sheet_keys: dict[int, str] = { + 12: "1OF-sAFykebc_2BrcYCgxCR-4rJo0GaNmTstagV-PMBU", + # Add new seasons as needed +} +draft_sheet_worksheet: str = "Ordered List" +draft_sheet_start_column: str = "D" +draft_sheet_enabled: bool = True +``` + +### `/draft-admin resync-sheet` Command +Bulk resync all picks from database to sheet: +1. Fetches all picks for current season with player data +2. Clears existing sheet data (columns D-G, rows 2+) +3. Batch writes all completed picks +4. Reports success/failure count + +Use cases: +- Sheet corruption recovery +- Credential issues during draft +- Manual corrections needed + +### `/draft-status` Sheet Link +The draft status embed includes a clickable link to the sheet: +```python +sheet_url = config.get_draft_sheet_url(config.sba_season) +embed = await create_draft_status_embed(draft_data, current_pick, lock_status, sheet_url) +``` + +### Service Dependencies +- `services.draft_sheet_service` - Google Sheets operations +- `config.get_draft_sheet_key()` - Sheet ID lookup by season +- `config.get_draft_sheet_url()` - Sheet URL generation + +### Failure Handling +- Sheet write failures don't block draft picks +- Failures logged with warning level +- Optional: Post failure notice to ping channel +- Admins can use resync-sheet for recovery + ## Architecture Notes ### Command Pattern @@ -248,6 +337,7 @@ Admin controls: - `/draft-admin channels` - Configure ping/result channels - `/draft-admin wipe` - Clear all picks for season - `/draft-admin info` - View detailed draft configuration +- `/draft-admin resync-sheet` - Resync all picks to Google Sheet ### `/draft-list` View auto-draft queue for your team @@ -277,6 +367,7 @@ View detailed on-the-clock information including: - `config.get_config()` - `services.draft_service` - `services.draft_pick_service` +- `services.draft_sheet_service` (Google Sheets integration) - `services.player_service` - `services.team_service` (with caching) - `utils.decorators.logged_command` @@ -320,4 +411,4 @@ Test scenarios: **Last Updated:** December 2025 **Status:** All draft commands implemented and tested -**Recent:** Added skipped pick support, draft monitor auto-start, on-clock announcements +**Recent:** Google Sheets integration for automatic pick tracking, `/draft-admin resync-sheet` command, sheet link in `/draft-status` diff --git a/commands/draft/admin.py b/commands/draft/admin.py index caba878..63b0ab9 100644 --- a/commands/draft/admin.py +++ b/commands/draft/admin.py @@ -12,6 +12,7 @@ from discord.ext import commands from config import get_config from services.draft_service import draft_service from services.draft_pick_service import draft_pick_service +from services.draft_sheet_service import get_draft_sheet_service from utils.logging import get_contextual_logger from utils.decorators import logged_command from utils.permissions import league_admin_only @@ -335,6 +336,123 @@ class DraftAdminGroup(app_commands.Group): embed = EmbedTemplate.success("Deadline Reset", description) await interaction.followup.send(embed=embed) + @app_commands.command(name="resync-sheet", description="Resync all picks to Google Sheet") + @league_admin_only() + @logged_command("/draft-admin resync-sheet") + async def draft_admin_resync_sheet(self, interaction: discord.Interaction): + """ + Resync all draft picks from database to Google Sheet. + + Used for recovery if sheet gets corrupted, auth fails, or picks were + missed during the draft. Clears existing data and repopulates from database. + """ + await interaction.response.defer() + + config = get_config() + + # Check if sheet integration is enabled + if not config.draft_sheet_enabled: + embed = EmbedTemplate.warning( + "Sheet Disabled", + "Draft sheet integration is currently disabled." + ) + await interaction.followup.send(embed=embed, ephemeral=True) + return + + # Check if sheet is configured for current season + sheet_url = config.get_draft_sheet_url(config.sba_season) + if not sheet_url: + embed = EmbedTemplate.error( + "No Sheet Configured", + f"No draft sheet is configured for season {config.sba_season}." + ) + await interaction.followup.send(embed=embed, ephemeral=True) + return + + # Get all picks with player data for current season + all_picks = await draft_pick_service.get_picks_with_players(config.sba_season) + + if not all_picks: + embed = EmbedTemplate.warning( + "No Picks Found", + "No draft picks found for the current season." + ) + await interaction.followup.send(embed=embed, ephemeral=True) + return + + # Filter to only picks that have been made (have a player) + completed_picks = [p for p in all_picks if p.player is not None] + + if not completed_picks: + embed = EmbedTemplate.warning( + "No Completed Picks", + "No draft picks have been made yet." + ) + await interaction.followup.send(embed=embed, ephemeral=True) + return + + # Prepare pick data for batch write + pick_data = [] + for pick in completed_picks: + orig_abbrev = pick.origowner.abbrev if pick.origowner else (pick.owner.abbrev if pick.owner else "???") + owner_abbrev = pick.owner.abbrev if pick.owner else "???" + player_name = pick.player.name if pick.player else "Unknown" + swar = pick.player.wara if pick.player else 0.0 + + pick_data.append(( + pick.overall, + orig_abbrev, + owner_abbrev, + player_name, + swar + )) + + # Get draft sheet service + draft_sheet_service = get_draft_sheet_service() + + # Clear existing sheet data first + cleared = await draft_sheet_service.clear_picks_range( + config.sba_season, + start_overall=1, + end_overall=config.draft_total_picks + ) + + if not cleared: + embed = EmbedTemplate.warning( + "Clear Failed", + "Failed to clear existing sheet data. Attempting to write picks anyway..." + ) + # Don't return - try to write anyway + + # Write all picks in batch + success_count, failure_count = await draft_sheet_service.write_picks_batch( + config.sba_season, + pick_data + ) + + # Build result message + total_picks = len(pick_data) + if failure_count == 0: + description = ( + f"Successfully synced **{success_count}** picks to the draft sheet.\n\n" + f"[View Draft Sheet]({sheet_url})" + ) + embed = EmbedTemplate.success("Resync Complete", description) + elif success_count > 0: + description = ( + f"Synced **{success_count}** picks ({failure_count} failed).\n\n" + f"[View Draft Sheet]({sheet_url})" + ) + embed = EmbedTemplate.warning("Partial Resync", description) + else: + description = ( + f"Failed to sync any picks. Check logs for details.\n\n" + f"[View Draft Sheet]({sheet_url})" + ) + embed = EmbedTemplate.error("Resync Failed", description) + + await interaction.followup.send(embed=embed) + async def setup(bot: commands.Bot): """Setup function for loading the draft admin commands.""" diff --git a/commands/draft/picks.py b/commands/draft/picks.py index ff28ad6..9a89934 100644 --- a/commands/draft/picks.py +++ b/commands/draft/picks.py @@ -13,6 +13,7 @@ from discord.ext import commands from config import get_config from services.draft_service import draft_service from services.draft_pick_service import draft_pick_service +from services.draft_sheet_service import get_draft_sheet_service from services.player_service import player_service from services.team_service import team_service from utils.logging import get_contextual_logger @@ -265,6 +266,15 @@ class DraftPicksCog(commands.Cog): if not updated_player: self.logger.error(f"Failed to update player {player_obj.id} team") + # Write pick to Google Sheets (fire-and-forget with notification on failure) + await self._write_pick_to_sheets( + draft_data=draft_data, + pick=pick_to_use, + player=player_obj, + team=team, + guild=interaction.guild + ) + # Determine if this was a skipped pick is_skipped_pick = pick_to_use.overall != current_pick.overall @@ -311,6 +321,90 @@ class DraftPicksCog(commands.Cog): + (f" [skipped pick makeup]" if is_skipped_pick else "") ) + async def _write_pick_to_sheets( + self, + draft_data, + pick, + player, + team, + guild: Optional[discord.Guild] + ): + """ + Write pick to Google Sheets (fire-and-forget with ping channel notification on failure). + + Args: + draft_data: Current draft configuration + pick: The draft pick being used + player: Player being drafted + team: Team making the pick + guild: Discord guild for notification channel + """ + config = get_config() + + try: + draft_sheet_service = get_draft_sheet_service() + success = await draft_sheet_service.write_pick( + season=config.sba_season, + overall=pick.overall, + orig_owner_abbrev=pick.origowner.abbrev if pick.origowner else team.abbrev, + owner_abbrev=team.abbrev, + player_name=player.name, + swar=player.wara + ) + + if not success: + # Write failed - notify in ping channel + await self._notify_sheet_failure( + guild=guild, + channel_id=draft_data.ping_channel, + pick_overall=pick.overall, + player_name=player.name, + reason="Sheet write returned failure" + ) + + except Exception as e: + self.logger.warning(f"Failed to write pick to sheets: {e}") + # Notify in ping channel + await self._notify_sheet_failure( + guild=guild, + channel_id=draft_data.ping_channel, + pick_overall=pick.overall, + player_name=player.name, + reason=str(e) + ) + + async def _notify_sheet_failure( + self, + guild: Optional[discord.Guild], + channel_id: Optional[int], + pick_overall: int, + player_name: str, + reason: str + ): + """ + Post notification to ping channel when sheet write fails. + + Args: + guild: Discord guild + channel_id: Ping channel ID + pick_overall: Pick number that failed + player_name: Player name + reason: Failure reason + """ + if not guild or not channel_id: + return + + try: + channel = guild.get_channel(channel_id) + if channel and hasattr(channel, 'send'): + await channel.send( + f"⚠️ **Sheet Sync Failed** - Pick #{pick_overall} ({player_name}) " + f"was not written to the draft sheet. " + f"Use `/draft-admin resync-sheet` to manually sync." + ) + except Exception as e: + self.logger.error(f"Failed to send sheet failure notification: {e}") + async def setup(bot: commands.Bot): """Load the draft picks cog.""" diff --git a/commands/draft/status.py b/commands/draft/status.py index de83105..2fff194 100644 --- a/commands/draft/status.py +++ b/commands/draft/status.py @@ -71,8 +71,11 @@ class DraftStatusCommands(commands.Cog): else: lock_status = "🔒 Pick in progress (system)" + # Get draft sheet URL + sheet_url = config.get_draft_sheet_url(config.sba_season) + # Create status embed - embed = await create_draft_status_embed(draft_data, current_pick, lock_status) + embed = await create_draft_status_embed(draft_data, current_pick, lock_status, sheet_url) await interaction.followup.send(embed=embed) @discord.app_commands.command( diff --git a/config.py b/config.py index 48f46a8..030b045 100644 --- a/config.py +++ b/config.py @@ -1,6 +1,9 @@ """ Configuration management for Discord Bot v2.0 """ +import os +from typing import Optional + from pydantic_settings import BaseSettings, SettingsConfigDict # Baseball position constants (static, not configurable) @@ -84,6 +87,12 @@ class BotConfig(BaseSettings): # Google Sheets settings sheets_credentials_path: str = "/app/data/major-domo-service-creds.json" + # Draft Sheet settings (for writing picks to Google Sheets) + # Sheet IDs can be overridden via environment variables: DRAFT_SHEET_KEY_12, DRAFT_SHEET_KEY_13, etc. + draft_sheet_enabled: bool = True # Feature flag - set DRAFT_SHEET_ENABLED=false to disable + draft_sheet_worksheet: str = "Ordered List" # Worksheet name to write picks to + draft_sheet_start_column: str = "D" # Column where pick data starts (D, E, F, G for 4 columns) + # Giphy API settings giphy_api_key: str = "H86xibttEuUcslgmMM6uu74IgLEZ7UOD" giphy_translate_url: str = "https://api.giphy.com/v1/gifs/translate" @@ -113,6 +122,42 @@ class BotConfig(BaseSettings): """Calculate total picks in draft (derived value).""" return self.draft_rounds * self.draft_team_count + def get_draft_sheet_key(self, season: int) -> Optional[str]: + """ + Get the Google Sheet ID for a given draft season. + + Sheet IDs are configured via environment variables: + - DRAFT_SHEET_KEY_12 for season 12 + - DRAFT_SHEET_KEY_13 for season 13 + - etc. + + Returns None if no sheet is configured for the season. + """ + # Default sheet IDs (hardcoded as fallback) + default_keys = { + 12: "1OF-sAFykebc_2BrcYCgxCR-4rJo0GaNmTstagV-PMBU", + # Season 13+ will be added here as sheets are created + } + + # Check environment variable first (allows runtime override) + env_key = os.getenv(f"DRAFT_SHEET_KEY_{season}") + if env_key: + return env_key + + # Fall back to hardcoded default + return default_keys.get(season) + + def get_draft_sheet_url(self, season: int) -> Optional[str]: + """ + Get the full Google Sheets URL for a given draft season. + + Returns None if no sheet is configured for the season. + """ + sheet_key = self.get_draft_sheet_key(season) + if sheet_key: + return f"https://docs.google.com/spreadsheets/d/{sheet_key}" + return None + # Global configuration instance - lazily initialized to avoid import-time errors _config = None diff --git a/services/CLAUDE.md b/services/CLAUDE.md index 41e65e3..7a32cc9 100644 --- a/services/CLAUDE.md +++ b/services/CLAUDE.md @@ -377,6 +377,65 @@ class SheetsService: async def read_box_score(scorecard: pygsheets.Spreadsheet) -> Dict[str, List[int]] ``` +#### DraftSheetService Key Methods (NEW - December 2025) +```python +class DraftSheetService(SheetsService): + """ + Service for writing draft picks to Google Sheets. + Extends SheetsService to reuse authentication and async patterns. + """ + async def write_pick( + season: int, + overall: int, + orig_owner_abbrev: str, + owner_abbrev: str, + player_name: str, + swar: float + ) -> bool + """Write a single pick to the draft sheet. Returns True on success.""" + + async def write_picks_batch( + season: int, + picks: List[Tuple[int, str, str, str, float]] + ) -> Tuple[int, int] + """Batch write picks for resync operations. Returns (success_count, failure_count).""" + + async def clear_picks_range( + season: int, + start_overall: int = 1, + end_overall: int = 512 + ) -> bool + """Clear a range of picks from the sheet before resync.""" + + def get_sheet_url(season: int) -> Optional[str] + """Get the draft sheet URL for display in embeds.""" +``` + +**Integration Points:** +- `commands/draft/picks.py` - Writes pick to sheet after successful draft selection +- `tasks/draft_monitor.py` - Writes pick to sheet after auto-draft +- `commands/draft/admin.py` - `/draft-admin resync-sheet` for bulk recovery + +**Fire-and-Forget Pattern:** +Draft sheet writes are non-critical (database is source of truth). On failure: +1. Log the error +2. Notify ping channel with warning message +3. Suggest `/draft-admin resync-sheet` for recovery +4. Never block the draft pick itself + +**Configuration:** +```python +# config.py settings +draft_sheet_enabled: bool = True # Feature flag +draft_sheet_worksheet: str = "Ordered List" # Worksheet name +draft_sheet_start_column: str = "D" # Starting column + +# Season-specific sheet IDs via environment variables +# DRAFT_SHEET_KEY_12, DRAFT_SHEET_KEY_13, etc. +config.get_draft_sheet_key(season) # Returns sheet ID or None +config.get_draft_sheet_url(season) # Returns full URL or None +``` + **Transaction Rollback Pattern:** The game submission services implement a 3-state transaction rollback pattern: 1. **PLAYS_POSTED**: Plays submitted → Rollback: Delete plays diff --git a/services/__init__.py b/services/__init__.py index f41f6ea..39af11d 100644 --- a/services/__init__.py +++ b/services/__init__.py @@ -9,6 +9,7 @@ from .player_service import PlayerService, player_service from .league_service import LeagueService, league_service from .schedule_service import ScheduleService, schedule_service from .giphy_service import GiphyService +from .draft_sheet_service import DraftSheetService, get_draft_sheet_service # Wire services together for dependency injection player_service._team_service = team_service @@ -21,5 +22,6 @@ __all__ = [ 'PlayerService', 'player_service', 'LeagueService', 'league_service', 'ScheduleService', 'schedule_service', - 'GiphyService', 'giphy_service' + 'GiphyService', 'giphy_service', + 'DraftSheetService', 'get_draft_sheet_service' ] \ No newline at end of file diff --git a/services/draft_pick_service.py b/services/draft_pick_service.py index 2d3a757..e7508a0 100644 --- a/services/draft_pick_service.py +++ b/services/draft_pick_service.py @@ -325,6 +325,35 @@ class DraftPickService(BaseService[DraftPick]): logger.error(f"Error getting upcoming picks: {e}") return [] + async def get_picks_with_players(self, season: int) -> List[DraftPick]: + """ + Get all picks for a season with player data included. + + Used for bulk operations like resync-sheet. Returns all picks + for the season regardless of whether they have been selected. + + NOT cached - picks change during draft. + + Args: + season: Draft season + + Returns: + List of all DraftPick instances for the season + """ + try: + params = [ + ('season', str(season)), + ('sort', 'order-asc') + ] + + picks = await self.get_all_items(params=params) + logger.debug(f"Found {len(picks)} picks for season {season}") + return picks + + except Exception as e: + logger.error(f"Error getting all picks for season {season}: {e}") + return [] + async def update_pick_selection( self, pick_id: int, diff --git a/services/draft_sheet_service.py b/services/draft_sheet_service.py new file mode 100644 index 0000000..61af59b --- /dev/null +++ b/services/draft_sheet_service.py @@ -0,0 +1,312 @@ +""" +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. + + 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 write in order + sorted_picks = sorted(picks, key=lambda p: p[0]) + + # Build batch data - each pick goes to its calculated row + # We'll write one row at a time to handle non-contiguous picks + success_count = 0 + failure_count = 0 + + for overall, orig_owner, owner, player_name, swar in sorted_picks: + try: + pick_data = [[orig_owner, owner, player_name, swar]] + row = overall + 1 + start_column = self._config.draft_sheet_start_column + cell_range = f'{start_column}{row}' + + await loop.run_in_executor( + None, + lambda cr=cell_range, pd=pick_data: worksheet.update_values( + crange=cr, values=pd + ) + ) + success_count += 1 + except Exception as e: + self.logger.error(f"Failed to write pick {overall}: {e}") + failure_count += 1 + + self.logger.info( + f"Batch write complete: {success_count} succeeded, {failure_count} failed", + season=season, + total_picks=len(picks) + ) + return (success_count, failure_count) + + except Exception as e: + self.logger.error(f"Failed to initialize 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 diff --git a/tasks/CLAUDE.md b/tasks/CLAUDE.md index a8b9123..3f75f71 100644 --- a/tasks/CLAUDE.md +++ b/tasks/CLAUDE.md @@ -314,8 +314,30 @@ async with draft_picks_cog.pick_lock: - Validate cap space - Attempt to draft player - Break on success -5. Advance to next pick -6. Release lock +5. Write pick to Google Sheets (fire-and-forget) +6. Advance to next pick +7. Release lock + +#### Google Sheets Integration +The monitor writes picks to the draft sheet after successful auto-draft: +- Uses **fire-and-forget** pattern (non-blocking) +- Failures logged but don't block draft +- Same service as manual `/draft` command +- Sheet write occurs before pick advancement + +```python +# After successful auto-draft execution +sheet_success = await draft_sheet_service.write_pick( + season=config.sba_season, + overall=pick.overall, + orig_owner_abbrev=original_owner.abbrev, + owner_abbrev=team.abbrev, + player_name=player.name, + swar=player.wara +) +if not sheet_success: + logger.warning(f"Sheet write failed for auto-draft pick #{pick.overall}") +``` #### Channel Requirements - **ping_channel** - Where warnings and auto-draft announcements post diff --git a/tasks/draft_monitor.py b/tasks/draft_monitor.py index 8452136..a09259f 100644 --- a/tasks/draft_monitor.py +++ b/tasks/draft_monitor.py @@ -14,6 +14,7 @@ from discord.ext import commands, tasks from services.draft_service import draft_service from services.draft_pick_service import draft_pick_service from services.draft_list_service import draft_list_service +from services.draft_sheet_service import get_draft_sheet_service from services.player_service import player_service from services.team_service import team_service from services.roster_service import roster_service @@ -328,6 +329,9 @@ class DraftMonitorTask: self.logger.error(f"Failed to update player {player.id} team") return False + # Write pick to Google Sheets (fire-and-forget) + await self._write_pick_to_sheets(draft_pick, player, ping_channel) + # Post to channel await ping_channel.send( content=f"🤖 AUTO-DRAFT: {draft_pick.owner.abbrev} selects **{player.name}** " @@ -470,6 +474,65 @@ class DraftMonitorTask: except Exception as e: self.logger.error("Error sending warnings", error=e) + async def _write_pick_to_sheets(self, draft_pick, player, ping_channel) -> None: + """ + Write pick to Google Sheets (fire-and-forget with notification on failure). + + Args: + draft_pick: The draft pick being used + player: Player being drafted + ping_channel: Discord channel for failure notification + """ + config = get_config() + + try: + draft_sheet_service = get_draft_sheet_service() + success = await draft_sheet_service.write_pick( + season=config.sba_season, + overall=draft_pick.overall, + orig_owner_abbrev=draft_pick.origowner.abbrev if draft_pick.origowner else draft_pick.owner.abbrev, + owner_abbrev=draft_pick.owner.abbrev, + player_name=player.name, + swar=player.wara + ) + + if not success: + # Write failed - notify in ping channel + await self._notify_sheet_failure( + ping_channel=ping_channel, + pick_overall=draft_pick.overall, + player_name=player.name + ) + + except Exception as e: + self.logger.warning(f"Failed to write pick to sheets: {e}") + await self._notify_sheet_failure( + ping_channel=ping_channel, + pick_overall=draft_pick.overall, + player_name=player.name + ) + + async def _notify_sheet_failure(self, ping_channel, pick_overall: int, player_name: str) -> None: + """ + Post notification to ping channel when sheet write fails. + + Args: + ping_channel: Discord channel to notify + pick_overall: Pick number that failed + player_name: Player name + """ + if not ping_channel: + return + + try: + await ping_channel.send( + f"⚠️ **Sheet Sync Failed** - Pick #{pick_overall} ({player_name}) " + f"was not written to the draft sheet. " + f"Use `/draft-admin resync-sheet` to manually sync." + ) + except Exception as e: + self.logger.error(f"Failed to send sheet failure notification: {e}") + # Task factory function def setup_draft_monitor(bot: commands.Bot) -> DraftMonitorTask: diff --git a/tests/test_services_draft_sheet.py b/tests/test_services_draft_sheet.py new file mode 100644 index 0000000..96478fe --- /dev/null +++ b/tests/test_services_draft_sheet.py @@ -0,0 +1,349 @@ +""" +Tests for DraftSheetService + +Tests the Google Sheets integration for draft pick tracking. +Uses mocked pygsheets to avoid actual API calls. +""" +import pytest +from unittest.mock import AsyncMock, MagicMock, patch +from typing import Tuple, List + +from services.draft_sheet_service import DraftSheetService, get_draft_sheet_service + + +class TestDraftSheetService: + """ + Test suite for DraftSheetService. + + Tests write_pick(), write_picks_batch(), clear_picks_range(), and get_sheet_url(). + All tests mock pygsheets to avoid actual Google Sheets API calls. + """ + + @pytest.fixture + def mock_config(self): + """ + Create a mock config with draft sheet settings. + + Provides: + - draft_sheet_enabled: True + - sba_season: 12 + - draft_sheet_worksheet: "Ordered List" + - draft_sheet_start_column: "D" + - draft_total_picks: 512 + """ + config = MagicMock() + config.draft_sheet_enabled = True + config.sba_season = 12 + config.draft_sheet_worksheet = "Ordered List" + config.draft_sheet_start_column = "D" + config.draft_total_picks = 512 + config.sheets_credentials_path = "/app/data/test-creds.json" + config.get_draft_sheet_key = MagicMock(return_value="test-sheet-key-123") + config.get_draft_sheet_url = MagicMock( + return_value="https://docs.google.com/spreadsheets/d/test-sheet-key-123" + ) + return config + + @pytest.fixture + def mock_pygsheets(self): + """ + Create mock pygsheets client, spreadsheet, and worksheet. + + Provides: + - sheets_client: Mock pygsheets client + - spreadsheet: Mock spreadsheet + - worksheet: Mock worksheet with update_values method + """ + worksheet = MagicMock() + worksheet.update_values = MagicMock() + + spreadsheet = MagicMock() + spreadsheet.worksheet_by_title = MagicMock(return_value=worksheet) + + sheets_client = MagicMock() + sheets_client.open_by_key = MagicMock(return_value=spreadsheet) + + return { + 'client': sheets_client, + 'spreadsheet': spreadsheet, + 'worksheet': worksheet + } + + @pytest.fixture + def service(self, mock_config, mock_pygsheets): + """ + Create DraftSheetService instance with mocked dependencies. + + The service is set up with: + - Mocked config + - Mocked pygsheets client (via _get_client override) + """ + with patch('services.draft_sheet_service.get_config', return_value=mock_config): + service = DraftSheetService() + service._config = mock_config + service._sheets_client = mock_pygsheets['client'] + return service + + # ==================== write_pick() Tests ==================== + + @pytest.mark.asyncio + async def test_write_pick_success(self, service, mock_pygsheets): + """ + Test successful write of a single draft pick to the sheet. + + Verifies: + - Correct cell range is calculated (D + overall + 1) + - Correct data is written (4 columns) + - Returns True on success + """ + result = await service.write_pick( + season=12, + overall=1, + orig_owner_abbrev="HAM", + owner_abbrev="HAM", + player_name="Mike Trout", + swar=8.5 + ) + + assert result is True + # Verify worksheet was accessed + mock_pygsheets['spreadsheet'].worksheet_by_title.assert_called_with("Ordered List") + + @pytest.mark.asyncio + async def test_write_pick_disabled(self, service, mock_config): + """ + Test that write_pick returns False when feature is disabled. + + Verifies: + - Returns False when draft_sheet_enabled is False + - No API calls are made + """ + mock_config.draft_sheet_enabled = False + + result = await service.write_pick( + season=12, + overall=1, + orig_owner_abbrev="HAM", + owner_abbrev="HAM", + player_name="Mike Trout", + swar=8.5 + ) + + assert result is False + + @pytest.mark.asyncio + async def test_write_pick_no_sheet_configured(self, service, mock_config): + """ + Test that write_pick returns False when no sheet is configured for season. + + Verifies: + - Returns False when get_draft_sheet_key returns None + - No API calls are made + """ + mock_config.get_draft_sheet_key = MagicMock(return_value=None) + + result = await service.write_pick( + season=13, # Season 13 has no configured sheet + overall=1, + orig_owner_abbrev="HAM", + owner_abbrev="HAM", + player_name="Mike Trout", + swar=8.5 + ) + + assert result is False + + @pytest.mark.asyncio + async def test_write_pick_api_error(self, service, mock_pygsheets): + """ + Test that write_pick returns False and logs error on API failure. + + Verifies: + - Returns False on exception + - Exception is caught and logged (not raised) + """ + mock_pygsheets['spreadsheet'].worksheet_by_title.side_effect = Exception("API Error") + + result = await service.write_pick( + season=12, + overall=1, + orig_owner_abbrev="HAM", + owner_abbrev="HAM", + player_name="Mike Trout", + swar=8.5 + ) + + assert result is False + + # ==================== write_picks_batch() Tests ==================== + + @pytest.mark.asyncio + async def test_write_picks_batch_success(self, service, mock_pygsheets): + """ + Test successful batch write of multiple picks. + + Verifies: + - All picks are written + - Returns correct success/failure counts + """ + picks = [ + (1, "HAM", "HAM", "Player 1", 2.5), + (2, "NYY", "NYY", "Player 2", 3.0), + (3, "BOS", "BOS", "Player 3", 1.5), + ] + + success_count, failure_count = await service.write_picks_batch( + season=12, + picks=picks + ) + + assert success_count == 3 + assert failure_count == 0 + + @pytest.mark.asyncio + async def test_write_picks_batch_empty_list(self, service): + """ + Test batch write with empty picks list. + + Verifies: + - Returns (0, 0) for empty list + - No API calls are made + """ + success_count, failure_count = await service.write_picks_batch( + season=12, + picks=[] + ) + + assert success_count == 0 + assert failure_count == 0 + + @pytest.mark.asyncio + async def test_write_picks_batch_disabled(self, service, mock_config): + """ + Test batch write when feature is disabled. + + Verifies: + - Returns (0, total_picks) when disabled + """ + mock_config.draft_sheet_enabled = False + picks = [ + (1, "HAM", "HAM", "Player 1", 2.5), + (2, "NYY", "NYY", "Player 2", 3.0), + ] + + success_count, failure_count = await service.write_picks_batch( + season=12, + picks=picks + ) + + assert success_count == 0 + assert failure_count == 2 + + # ==================== clear_picks_range() Tests ==================== + + @pytest.mark.asyncio + async def test_clear_picks_range_success(self, service, mock_pygsheets): + """ + Test successful clearing of picks range. + + Verifies: + - Returns True on success + - Correct range is cleared + """ + result = await service.clear_picks_range( + season=12, + start_overall=1, + end_overall=512 + ) + + assert result is True + + @pytest.mark.asyncio + async def test_clear_picks_range_disabled(self, service, mock_config): + """ + Test clearing when feature is disabled. + + Verifies: + - Returns False when disabled + """ + mock_config.draft_sheet_enabled = False + + result = await service.clear_picks_range( + season=12, + start_overall=1, + end_overall=512 + ) + + assert result is False + + # ==================== get_sheet_url() Tests ==================== + + def test_get_sheet_url_configured(self, service, mock_config): + """ + Test get_sheet_url returns URL when configured. + + Verifies: + - Returns correct URL format + """ + url = service.get_sheet_url(season=12) + + assert url == "https://docs.google.com/spreadsheets/d/test-sheet-key-123" + + def test_get_sheet_url_not_configured(self, service, mock_config): + """ + Test get_sheet_url returns None when not configured. + + Verifies: + - Returns None for unconfigured season + """ + mock_config.get_draft_sheet_url = MagicMock(return_value=None) + + url = service.get_sheet_url(season=99) + + assert url is None + + +class TestGlobalServiceInstance: + """ + Test suite for the global service instance pattern. + + Tests get_draft_sheet_service() lazy initialization. + """ + + def test_get_draft_sheet_service_returns_instance(self): + """ + Test that get_draft_sheet_service returns a DraftSheetService instance. + + Note: This creates a real service instance but won't make API calls + without being used. + """ + with patch('services.draft_sheet_service.get_config') as mock_config: + mock_config.return_value.sheets_credentials_path = "/test/path.json" + mock_config.return_value.draft_sheet_enabled = True + + # Reset global instance + import services.draft_sheet_service as service_module + service_module._draft_sheet_service = None + + service = get_draft_sheet_service() + + assert isinstance(service, DraftSheetService) + + def test_get_draft_sheet_service_returns_same_instance(self): + """ + Test that get_draft_sheet_service returns the same instance on subsequent calls. + + Verifies singleton pattern for global service. + """ + with patch('services.draft_sheet_service.get_config') as mock_config: + mock_config.return_value.sheets_credentials_path = "/test/path.json" + mock_config.return_value.draft_sheet_enabled = True + + # Reset global instance + import services.draft_sheet_service as service_module + service_module._draft_sheet_service = None + + service1 = get_draft_sheet_service() + service2 = get_draft_sheet_service() + + assert service1 is service2 diff --git a/views/draft_views.py b/views/draft_views.py index 08b7878..f070a11 100644 --- a/views/draft_views.py +++ b/views/draft_views.py @@ -110,7 +110,8 @@ async def create_on_the_clock_embed( async def create_draft_status_embed( draft_data: DraftData, current_pick: DraftPick, - lock_status: str = "🔓 No pick in progress" + lock_status: str = "🔓 No pick in progress", + sheet_url: Optional[str] = None ) -> discord.Embed: """ Create draft status embed showing current state. @@ -119,6 +120,7 @@ async def create_draft_status_embed( draft_data: Current draft configuration current_pick: Current DraftPick lock_status: Lock status message + sheet_url: Optional Google Sheets URL for draft tracking Returns: Discord embed with draft status @@ -166,6 +168,14 @@ async def create_draft_status_embed( inline=False ) + # Draft Sheet link + if sheet_url: + embed.add_field( + name="Draft Sheet", + value=f"[View Sheet]({sheet_url})", + inline=False + ) + return embed