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:
parent
0294f89f4a
commit
9093055bb5
@ -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`
|
||||||
|
|||||||
@ -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."""
|
||||||
|
|||||||
@ -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."""
|
||||||
|
|||||||
@ -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(
|
||||||
|
|||||||
45
config.py
45
config.py
@ -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
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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'
|
||||||
]
|
]
|
||||||
@ -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,
|
||||||
|
|||||||
312
services/draft_sheet_service.py
Normal file
312
services/draft_sheet_service.py
Normal 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
|
||||||
@ -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
|
||||||
|
|||||||
@ -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:
|
||||||
|
|||||||
349
tests/test_services_draft_sheet.py
Normal file
349
tests/test_services_draft_sheet.py
Normal 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
|
||||||
@ -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
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user