Add Google Sheets integration for draft pick tracking

- Add DraftSheetService with write_pick(), write_picks_batch(),
  clear_picks_range(), and get_sheet_url() methods
- Integrate sheet writes in /draft command (fire-and-forget pattern)
- Integrate sheet writes in draft_monitor.py for auto-draft picks
- Add /draft-admin resync-sheet command for bulk recovery
- Add sheet link to /draft-status embed
- Add draft_sheet_keys config with env var overrides per season
- Add get_picks_with_players() to draft_pick_service for resync
- Add 13 unit tests for DraftSheetService (all passing)
- Update CLAUDE.md documentation files

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Cal Corum 2025-12-11 11:18:27 -06:00
parent 0294f89f4a
commit 9093055bb5
13 changed files with 1204 additions and 7 deletions

View File

@ -49,6 +49,7 @@ Draft commands are only available in the offseason.
- `player_service.get_players_by_name()` - `player_service.get_players_by_name()`
- `player_service.update_player_team()` - `player_service.update_player_team()`
- `league_service.get_current_state()` (for period check) - `league_service.get_current_state()` (for period check)
- `draft_sheet_service.write_pick()` (Google Sheets integration)
## Key Features ## Key Features
@ -106,7 +107,8 @@ async with self.pick_lock:
5. **Player Validation**: Verify player is FA (team_id = 498) 5. **Player Validation**: Verify player is FA (team_id = 498)
6. **Cap Space**: Validate 32 sWAR limit won't be exceeded 6. **Cap Space**: Validate 32 sWAR limit won't be exceeded
7. **Execution**: Update pick, update player team, advance draft 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 ### FA Player Autocomplete
The autocomplete function filters to FA players only: 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 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 ## Architecture Notes
### Command Pattern ### Command Pattern
@ -248,6 +337,7 @@ Admin controls:
- `/draft-admin channels` - Configure ping/result channels - `/draft-admin channels` - Configure ping/result channels
- `/draft-admin wipe` - Clear all picks for season - `/draft-admin wipe` - Clear all picks for season
- `/draft-admin info` - View detailed draft configuration - `/draft-admin info` - View detailed draft configuration
- `/draft-admin resync-sheet` - Resync all picks to Google Sheet
### `/draft-list` ### `/draft-list`
View auto-draft queue for your team View auto-draft queue for your team
@ -277,6 +367,7 @@ View detailed on-the-clock information including:
- `config.get_config()` - `config.get_config()`
- `services.draft_service` - `services.draft_service`
- `services.draft_pick_service` - `services.draft_pick_service`
- `services.draft_sheet_service` (Google Sheets integration)
- `services.player_service` - `services.player_service`
- `services.team_service` (with caching) - `services.team_service` (with caching)
- `utils.decorators.logged_command` - `utils.decorators.logged_command`
@ -320,4 +411,4 @@ Test scenarios:
**Last Updated:** December 2025 **Last Updated:** December 2025
**Status:** All draft commands implemented and tested **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`

View File

@ -12,6 +12,7 @@ from discord.ext import commands
from config import get_config from config import get_config
from services.draft_service import draft_service from services.draft_service import draft_service
from services.draft_pick_service import draft_pick_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.logging import get_contextual_logger
from utils.decorators import logged_command from utils.decorators import logged_command
from utils.permissions import league_admin_only from utils.permissions import league_admin_only
@ -335,6 +336,123 @@ class DraftAdminGroup(app_commands.Group):
embed = EmbedTemplate.success("Deadline Reset", description) embed = EmbedTemplate.success("Deadline Reset", description)
await interaction.followup.send(embed=embed) 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): async def setup(bot: commands.Bot):
"""Setup function for loading the draft admin commands.""" """Setup function for loading the draft admin commands."""

View File

@ -13,6 +13,7 @@ from discord.ext import commands
from config import get_config from config import get_config
from services.draft_service import draft_service from services.draft_service import draft_service
from services.draft_pick_service import draft_pick_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.player_service import player_service
from services.team_service import team_service from services.team_service import team_service
from utils.logging import get_contextual_logger from utils.logging import get_contextual_logger
@ -265,6 +266,15 @@ class DraftPicksCog(commands.Cog):
if not updated_player: if not updated_player:
self.logger.error(f"Failed to update player {player_obj.id} team") 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 # Determine if this was a skipped pick
is_skipped_pick = pick_to_use.overall != current_pick.overall 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 "") + (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): async def setup(bot: commands.Bot):
"""Load the draft picks cog.""" """Load the draft picks cog."""

View File

@ -71,8 +71,11 @@ class DraftStatusCommands(commands.Cog):
else: else:
lock_status = "🔒 Pick in progress (system)" lock_status = "🔒 Pick in progress (system)"
# Get draft sheet URL
sheet_url = config.get_draft_sheet_url(config.sba_season)
# Create status embed # 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) await interaction.followup.send(embed=embed)
@discord.app_commands.command( @discord.app_commands.command(

View File

@ -1,6 +1,9 @@
""" """
Configuration management for Discord Bot v2.0 Configuration management for Discord Bot v2.0
""" """
import os
from typing import Optional
from pydantic_settings import BaseSettings, SettingsConfigDict from pydantic_settings import BaseSettings, SettingsConfigDict
# Baseball position constants (static, not configurable) # Baseball position constants (static, not configurable)
@ -84,6 +87,12 @@ class BotConfig(BaseSettings):
# Google Sheets settings # Google Sheets settings
sheets_credentials_path: str = "/app/data/major-domo-service-creds.json" 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 settings
giphy_api_key: str = "H86xibttEuUcslgmMM6uu74IgLEZ7UOD" giphy_api_key: str = "H86xibttEuUcslgmMM6uu74IgLEZ7UOD"
giphy_translate_url: str = "https://api.giphy.com/v1/gifs/translate" 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).""" """Calculate total picks in draft (derived value)."""
return self.draft_rounds * self.draft_team_count 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 # Global configuration instance - lazily initialized to avoid import-time errors
_config = None _config = None

View File

@ -377,6 +377,65 @@ class SheetsService:
async def read_box_score(scorecard: pygsheets.Spreadsheet) -> Dict[str, List[int]] 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:** **Transaction Rollback Pattern:**
The game submission services implement a 3-state transaction rollback pattern: The game submission services implement a 3-state transaction rollback pattern:
1. **PLAYS_POSTED**: Plays submitted → Rollback: Delete plays 1. **PLAYS_POSTED**: Plays submitted → Rollback: Delete plays

View File

@ -9,6 +9,7 @@ from .player_service import PlayerService, player_service
from .league_service import LeagueService, league_service from .league_service import LeagueService, league_service
from .schedule_service import ScheduleService, schedule_service from .schedule_service import ScheduleService, schedule_service
from .giphy_service import GiphyService from .giphy_service import GiphyService
from .draft_sheet_service import DraftSheetService, get_draft_sheet_service
# Wire services together for dependency injection # Wire services together for dependency injection
player_service._team_service = team_service player_service._team_service = team_service
@ -21,5 +22,6 @@ __all__ = [
'PlayerService', 'player_service', 'PlayerService', 'player_service',
'LeagueService', 'league_service', 'LeagueService', 'league_service',
'ScheduleService', 'schedule_service', 'ScheduleService', 'schedule_service',
'GiphyService', 'giphy_service' 'GiphyService', 'giphy_service',
'DraftSheetService', 'get_draft_sheet_service'
] ]

View File

@ -325,6 +325,35 @@ class DraftPickService(BaseService[DraftPick]):
logger.error(f"Error getting upcoming picks: {e}") logger.error(f"Error getting upcoming picks: {e}")
return [] 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( async def update_pick_selection(
self, self,
pick_id: int, pick_id: int,

View File

@ -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

View File

@ -314,8 +314,30 @@ async with draft_picks_cog.pick_lock:
- Validate cap space - Validate cap space
- Attempt to draft player - Attempt to draft player
- Break on success - Break on success
5. Advance to next pick 5. Write pick to Google Sheets (fire-and-forget)
6. Release lock 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 #### Channel Requirements
- **ping_channel** - Where warnings and auto-draft announcements post - **ping_channel** - Where warnings and auto-draft announcements post

View File

@ -14,6 +14,7 @@ from discord.ext import commands, tasks
from services.draft_service import draft_service from services.draft_service import draft_service
from services.draft_pick_service import draft_pick_service from services.draft_pick_service import draft_pick_service
from services.draft_list_service import draft_list_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.player_service import player_service
from services.team_service import team_service from services.team_service import team_service
from services.roster_service import roster_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") self.logger.error(f"Failed to update player {player.id} team")
return False return False
# Write pick to Google Sheets (fire-and-forget)
await self._write_pick_to_sheets(draft_pick, player, ping_channel)
# Post to channel # Post to channel
await ping_channel.send( await ping_channel.send(
content=f"🤖 AUTO-DRAFT: {draft_pick.owner.abbrev} selects **{player.name}** " content=f"🤖 AUTO-DRAFT: {draft_pick.owner.abbrev} selects **{player.name}** "
@ -470,6 +474,65 @@ class DraftMonitorTask:
except Exception as e: except Exception as e:
self.logger.error("Error sending warnings", error=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 # Task factory function
def setup_draft_monitor(bot: commands.Bot) -> DraftMonitorTask: def setup_draft_monitor(bot: commands.Bot) -> DraftMonitorTask:

View File

@ -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

View File

@ -110,7 +110,8 @@ async def create_on_the_clock_embed(
async def create_draft_status_embed( async def create_draft_status_embed(
draft_data: DraftData, draft_data: DraftData,
current_pick: DraftPick, current_pick: DraftPick,
lock_status: str = "🔓 No pick in progress" lock_status: str = "🔓 No pick in progress",
sheet_url: Optional[str] = None
) -> discord.Embed: ) -> discord.Embed:
""" """
Create draft status embed showing current state. Create draft status embed showing current state.
@ -119,6 +120,7 @@ async def create_draft_status_embed(
draft_data: Current draft configuration draft_data: Current draft configuration
current_pick: Current DraftPick current_pick: Current DraftPick
lock_status: Lock status message lock_status: Lock status message
sheet_url: Optional Google Sheets URL for draft tracking
Returns: Returns:
Discord embed with draft status Discord embed with draft status
@ -166,6 +168,14 @@ async def create_draft_status_embed(
inline=False inline=False
) )
# Draft Sheet link
if sheet_url:
embed.add_field(
name="Draft Sheet",
value=f"[View Sheet]({sheet_url})",
inline=False
)
return embed return embed