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.update_player_team()`
|
||||
- `league_service.get_current_state()` (for period check)
|
||||
- `draft_sheet_service.write_pick()` (Google Sheets integration)
|
||||
|
||||
## Key Features
|
||||
|
||||
@ -106,7 +107,8 @@ async with self.pick_lock:
|
||||
5. **Player Validation**: Verify player is FA (team_id = 498)
|
||||
6. **Cap Space**: Validate 32 sWAR limit won't be exceeded
|
||||
7. **Execution**: Update pick, update player team, advance draft
|
||||
8. **Announcements**: Post success message and player card
|
||||
8. **Sheet Write**: Write pick to Google Sheets (fire-and-forget)
|
||||
9. **Announcements**: Post success message and player card
|
||||
|
||||
### FA Player Autocomplete
|
||||
The autocomplete function filters to FA players only:
|
||||
@ -141,6 +143,93 @@ async def validate_cap_space(roster: dict, new_player_wara: float):
|
||||
return projected_total <= 32.00001, projected_total
|
||||
```
|
||||
|
||||
## Google Sheets Integration
|
||||
|
||||
### Overview
|
||||
Draft picks are automatically written to a shared Google Sheet for easy tracking. This feature:
|
||||
- Writes pick data to configured sheet after each successful pick
|
||||
- Uses **fire-and-forget** pattern (non-blocking, doesn't fail the pick)
|
||||
- Supports manual resync via `/draft-admin resync-sheet`
|
||||
- Shows sheet link in `/draft-status` embed
|
||||
|
||||
### Sheet Structure
|
||||
Each pick writes 4 columns starting at column D:
|
||||
| Column | Content |
|
||||
|--------|---------|
|
||||
| D | Original owner abbreviation |
|
||||
| E | Current owner abbreviation |
|
||||
| F | Player name |
|
||||
| G | Player sWAR |
|
||||
|
||||
Row calculation: `overall + 1` (pick 1 → row 2, leaving row 1 for headers)
|
||||
|
||||
### Fire-and-Forget Pattern
|
||||
```python
|
||||
# After successful pick execution
|
||||
try:
|
||||
sheet_success = await draft_sheet_service.write_pick(
|
||||
season=config.sba_season,
|
||||
overall=pick.overall,
|
||||
orig_owner_abbrev=original_owner.abbrev,
|
||||
owner_abbrev=team.abbrev,
|
||||
player_name=player.name,
|
||||
swar=player.wara
|
||||
)
|
||||
if not sheet_success:
|
||||
self.logger.warning(f"Draft sheet write failed for pick #{pick.overall}")
|
||||
# Post notification to ping channel
|
||||
except Exception as e:
|
||||
self.logger.error(f"Draft sheet write error: {e}")
|
||||
# Non-critical - don't fail the draft pick
|
||||
```
|
||||
|
||||
### Configuration
|
||||
Environment variables (optional, defaults in config):
|
||||
- `DRAFT_SHEET_KEY_12` - Sheet ID for season 12
|
||||
- `DRAFT_SHEET_KEY_13` - Sheet ID for season 13
|
||||
- `DRAFT_SHEET_ENABLED` - Feature flag (default: True)
|
||||
|
||||
Config file defaults in `config.py`:
|
||||
```python
|
||||
draft_sheet_keys: dict[int, str] = {
|
||||
12: "1OF-sAFykebc_2BrcYCgxCR-4rJo0GaNmTstagV-PMBU",
|
||||
# Add new seasons as needed
|
||||
}
|
||||
draft_sheet_worksheet: str = "Ordered List"
|
||||
draft_sheet_start_column: str = "D"
|
||||
draft_sheet_enabled: bool = True
|
||||
```
|
||||
|
||||
### `/draft-admin resync-sheet` Command
|
||||
Bulk resync all picks from database to sheet:
|
||||
1. Fetches all picks for current season with player data
|
||||
2. Clears existing sheet data (columns D-G, rows 2+)
|
||||
3. Batch writes all completed picks
|
||||
4. Reports success/failure count
|
||||
|
||||
Use cases:
|
||||
- Sheet corruption recovery
|
||||
- Credential issues during draft
|
||||
- Manual corrections needed
|
||||
|
||||
### `/draft-status` Sheet Link
|
||||
The draft status embed includes a clickable link to the sheet:
|
||||
```python
|
||||
sheet_url = config.get_draft_sheet_url(config.sba_season)
|
||||
embed = await create_draft_status_embed(draft_data, current_pick, lock_status, sheet_url)
|
||||
```
|
||||
|
||||
### Service Dependencies
|
||||
- `services.draft_sheet_service` - Google Sheets operations
|
||||
- `config.get_draft_sheet_key()` - Sheet ID lookup by season
|
||||
- `config.get_draft_sheet_url()` - Sheet URL generation
|
||||
|
||||
### Failure Handling
|
||||
- Sheet write failures don't block draft picks
|
||||
- Failures logged with warning level
|
||||
- Optional: Post failure notice to ping channel
|
||||
- Admins can use resync-sheet for recovery
|
||||
|
||||
## Architecture Notes
|
||||
|
||||
### Command Pattern
|
||||
@ -248,6 +337,7 @@ Admin controls:
|
||||
- `/draft-admin channels` - Configure ping/result channels
|
||||
- `/draft-admin wipe` - Clear all picks for season
|
||||
- `/draft-admin info` - View detailed draft configuration
|
||||
- `/draft-admin resync-sheet` - Resync all picks to Google Sheet
|
||||
|
||||
### `/draft-list`
|
||||
View auto-draft queue for your team
|
||||
@ -277,6 +367,7 @@ View detailed on-the-clock information including:
|
||||
- `config.get_config()`
|
||||
- `services.draft_service`
|
||||
- `services.draft_pick_service`
|
||||
- `services.draft_sheet_service` (Google Sheets integration)
|
||||
- `services.player_service`
|
||||
- `services.team_service` (with caching)
|
||||
- `utils.decorators.logged_command`
|
||||
@ -320,4 +411,4 @@ Test scenarios:
|
||||
|
||||
**Last Updated:** December 2025
|
||||
**Status:** All draft commands implemented and tested
|
||||
**Recent:** Added skipped pick support, draft monitor auto-start, on-clock announcements
|
||||
**Recent:** Google Sheets integration for automatic pick tracking, `/draft-admin resync-sheet` command, sheet link in `/draft-status`
|
||||
|
||||
@ -12,6 +12,7 @@ from discord.ext import commands
|
||||
from config import get_config
|
||||
from services.draft_service import draft_service
|
||||
from services.draft_pick_service import draft_pick_service
|
||||
from services.draft_sheet_service import get_draft_sheet_service
|
||||
from utils.logging import get_contextual_logger
|
||||
from utils.decorators import logged_command
|
||||
from utils.permissions import league_admin_only
|
||||
@ -335,6 +336,123 @@ class DraftAdminGroup(app_commands.Group):
|
||||
embed = EmbedTemplate.success("Deadline Reset", description)
|
||||
await interaction.followup.send(embed=embed)
|
||||
|
||||
@app_commands.command(name="resync-sheet", description="Resync all picks to Google Sheet")
|
||||
@league_admin_only()
|
||||
@logged_command("/draft-admin resync-sheet")
|
||||
async def draft_admin_resync_sheet(self, interaction: discord.Interaction):
|
||||
"""
|
||||
Resync all draft picks from database to Google Sheet.
|
||||
|
||||
Used for recovery if sheet gets corrupted, auth fails, or picks were
|
||||
missed during the draft. Clears existing data and repopulates from database.
|
||||
"""
|
||||
await interaction.response.defer()
|
||||
|
||||
config = get_config()
|
||||
|
||||
# Check if sheet integration is enabled
|
||||
if not config.draft_sheet_enabled:
|
||||
embed = EmbedTemplate.warning(
|
||||
"Sheet Disabled",
|
||||
"Draft sheet integration is currently disabled."
|
||||
)
|
||||
await interaction.followup.send(embed=embed, ephemeral=True)
|
||||
return
|
||||
|
||||
# Check if sheet is configured for current season
|
||||
sheet_url = config.get_draft_sheet_url(config.sba_season)
|
||||
if not sheet_url:
|
||||
embed = EmbedTemplate.error(
|
||||
"No Sheet Configured",
|
||||
f"No draft sheet is configured for season {config.sba_season}."
|
||||
)
|
||||
await interaction.followup.send(embed=embed, ephemeral=True)
|
||||
return
|
||||
|
||||
# Get all picks with player data for current season
|
||||
all_picks = await draft_pick_service.get_picks_with_players(config.sba_season)
|
||||
|
||||
if not all_picks:
|
||||
embed = EmbedTemplate.warning(
|
||||
"No Picks Found",
|
||||
"No draft picks found for the current season."
|
||||
)
|
||||
await interaction.followup.send(embed=embed, ephemeral=True)
|
||||
return
|
||||
|
||||
# Filter to only picks that have been made (have a player)
|
||||
completed_picks = [p for p in all_picks if p.player is not None]
|
||||
|
||||
if not completed_picks:
|
||||
embed = EmbedTemplate.warning(
|
||||
"No Completed Picks",
|
||||
"No draft picks have been made yet."
|
||||
)
|
||||
await interaction.followup.send(embed=embed, ephemeral=True)
|
||||
return
|
||||
|
||||
# Prepare pick data for batch write
|
||||
pick_data = []
|
||||
for pick in completed_picks:
|
||||
orig_abbrev = pick.origowner.abbrev if pick.origowner else (pick.owner.abbrev if pick.owner else "???")
|
||||
owner_abbrev = pick.owner.abbrev if pick.owner else "???"
|
||||
player_name = pick.player.name if pick.player else "Unknown"
|
||||
swar = pick.player.wara if pick.player else 0.0
|
||||
|
||||
pick_data.append((
|
||||
pick.overall,
|
||||
orig_abbrev,
|
||||
owner_abbrev,
|
||||
player_name,
|
||||
swar
|
||||
))
|
||||
|
||||
# Get draft sheet service
|
||||
draft_sheet_service = get_draft_sheet_service()
|
||||
|
||||
# Clear existing sheet data first
|
||||
cleared = await draft_sheet_service.clear_picks_range(
|
||||
config.sba_season,
|
||||
start_overall=1,
|
||||
end_overall=config.draft_total_picks
|
||||
)
|
||||
|
||||
if not cleared:
|
||||
embed = EmbedTemplate.warning(
|
||||
"Clear Failed",
|
||||
"Failed to clear existing sheet data. Attempting to write picks anyway..."
|
||||
)
|
||||
# Don't return - try to write anyway
|
||||
|
||||
# Write all picks in batch
|
||||
success_count, failure_count = await draft_sheet_service.write_picks_batch(
|
||||
config.sba_season,
|
||||
pick_data
|
||||
)
|
||||
|
||||
# Build result message
|
||||
total_picks = len(pick_data)
|
||||
if failure_count == 0:
|
||||
description = (
|
||||
f"Successfully synced **{success_count}** picks to the draft sheet.\n\n"
|
||||
f"[View Draft Sheet]({sheet_url})"
|
||||
)
|
||||
embed = EmbedTemplate.success("Resync Complete", description)
|
||||
elif success_count > 0:
|
||||
description = (
|
||||
f"Synced **{success_count}** picks ({failure_count} failed).\n\n"
|
||||
f"[View Draft Sheet]({sheet_url})"
|
||||
)
|
||||
embed = EmbedTemplate.warning("Partial Resync", description)
|
||||
else:
|
||||
description = (
|
||||
f"Failed to sync any picks. Check logs for details.\n\n"
|
||||
f"[View Draft Sheet]({sheet_url})"
|
||||
)
|
||||
embed = EmbedTemplate.error("Resync Failed", description)
|
||||
|
||||
await interaction.followup.send(embed=embed)
|
||||
|
||||
|
||||
async def setup(bot: commands.Bot):
|
||||
"""Setup function for loading the draft admin commands."""
|
||||
|
||||
@ -13,6 +13,7 @@ from discord.ext import commands
|
||||
from config import get_config
|
||||
from services.draft_service import draft_service
|
||||
from services.draft_pick_service import draft_pick_service
|
||||
from services.draft_sheet_service import get_draft_sheet_service
|
||||
from services.player_service import player_service
|
||||
from services.team_service import team_service
|
||||
from utils.logging import get_contextual_logger
|
||||
@ -265,6 +266,15 @@ class DraftPicksCog(commands.Cog):
|
||||
if not updated_player:
|
||||
self.logger.error(f"Failed to update player {player_obj.id} team")
|
||||
|
||||
# Write pick to Google Sheets (fire-and-forget with notification on failure)
|
||||
await self._write_pick_to_sheets(
|
||||
draft_data=draft_data,
|
||||
pick=pick_to_use,
|
||||
player=player_obj,
|
||||
team=team,
|
||||
guild=interaction.guild
|
||||
)
|
||||
|
||||
# Determine if this was a skipped pick
|
||||
is_skipped_pick = pick_to_use.overall != current_pick.overall
|
||||
|
||||
@ -311,6 +321,90 @@ class DraftPicksCog(commands.Cog):
|
||||
+ (f" [skipped pick makeup]" if is_skipped_pick else "")
|
||||
)
|
||||
|
||||
async def _write_pick_to_sheets(
|
||||
self,
|
||||
draft_data,
|
||||
pick,
|
||||
player,
|
||||
team,
|
||||
guild: Optional[discord.Guild]
|
||||
):
|
||||
"""
|
||||
Write pick to Google Sheets (fire-and-forget with ping channel notification on failure).
|
||||
|
||||
Args:
|
||||
draft_data: Current draft configuration
|
||||
pick: The draft pick being used
|
||||
player: Player being drafted
|
||||
team: Team making the pick
|
||||
guild: Discord guild for notification channel
|
||||
"""
|
||||
config = get_config()
|
||||
|
||||
try:
|
||||
draft_sheet_service = get_draft_sheet_service()
|
||||
success = await draft_sheet_service.write_pick(
|
||||
season=config.sba_season,
|
||||
overall=pick.overall,
|
||||
orig_owner_abbrev=pick.origowner.abbrev if pick.origowner else team.abbrev,
|
||||
owner_abbrev=team.abbrev,
|
||||
player_name=player.name,
|
||||
swar=player.wara
|
||||
)
|
||||
|
||||
if not success:
|
||||
# Write failed - notify in ping channel
|
||||
await self._notify_sheet_failure(
|
||||
guild=guild,
|
||||
channel_id=draft_data.ping_channel,
|
||||
pick_overall=pick.overall,
|
||||
player_name=player.name,
|
||||
reason="Sheet write returned failure"
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
self.logger.warning(f"Failed to write pick to sheets: {e}")
|
||||
# Notify in ping channel
|
||||
await self._notify_sheet_failure(
|
||||
guild=guild,
|
||||
channel_id=draft_data.ping_channel,
|
||||
pick_overall=pick.overall,
|
||||
player_name=player.name,
|
||||
reason=str(e)
|
||||
)
|
||||
|
||||
async def _notify_sheet_failure(
|
||||
self,
|
||||
guild: Optional[discord.Guild],
|
||||
channel_id: Optional[int],
|
||||
pick_overall: int,
|
||||
player_name: str,
|
||||
reason: str
|
||||
):
|
||||
"""
|
||||
Post notification to ping channel when sheet write fails.
|
||||
|
||||
Args:
|
||||
guild: Discord guild
|
||||
channel_id: Ping channel ID
|
||||
pick_overall: Pick number that failed
|
||||
player_name: Player name
|
||||
reason: Failure reason
|
||||
"""
|
||||
if not guild or not channel_id:
|
||||
return
|
||||
|
||||
try:
|
||||
channel = guild.get_channel(channel_id)
|
||||
if channel and hasattr(channel, 'send'):
|
||||
await channel.send(
|
||||
f"⚠️ **Sheet Sync Failed** - Pick #{pick_overall} ({player_name}) "
|
||||
f"was not written to the draft sheet. "
|
||||
f"Use `/draft-admin resync-sheet` to manually sync."
|
||||
)
|
||||
except Exception as e:
|
||||
self.logger.error(f"Failed to send sheet failure notification: {e}")
|
||||
|
||||
|
||||
async def setup(bot: commands.Bot):
|
||||
"""Load the draft picks cog."""
|
||||
|
||||
@ -71,8 +71,11 @@ class DraftStatusCommands(commands.Cog):
|
||||
else:
|
||||
lock_status = "🔒 Pick in progress (system)"
|
||||
|
||||
# Get draft sheet URL
|
||||
sheet_url = config.get_draft_sheet_url(config.sba_season)
|
||||
|
||||
# Create status embed
|
||||
embed = await create_draft_status_embed(draft_data, current_pick, lock_status)
|
||||
embed = await create_draft_status_embed(draft_data, current_pick, lock_status, sheet_url)
|
||||
await interaction.followup.send(embed=embed)
|
||||
|
||||
@discord.app_commands.command(
|
||||
|
||||
45
config.py
45
config.py
@ -1,6 +1,9 @@
|
||||
"""
|
||||
Configuration management for Discord Bot v2.0
|
||||
"""
|
||||
import os
|
||||
from typing import Optional
|
||||
|
||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||
|
||||
# Baseball position constants (static, not configurable)
|
||||
@ -84,6 +87,12 @@ class BotConfig(BaseSettings):
|
||||
# Google Sheets settings
|
||||
sheets_credentials_path: str = "/app/data/major-domo-service-creds.json"
|
||||
|
||||
# Draft Sheet settings (for writing picks to Google Sheets)
|
||||
# Sheet IDs can be overridden via environment variables: DRAFT_SHEET_KEY_12, DRAFT_SHEET_KEY_13, etc.
|
||||
draft_sheet_enabled: bool = True # Feature flag - set DRAFT_SHEET_ENABLED=false to disable
|
||||
draft_sheet_worksheet: str = "Ordered List" # Worksheet name to write picks to
|
||||
draft_sheet_start_column: str = "D" # Column where pick data starts (D, E, F, G for 4 columns)
|
||||
|
||||
# Giphy API settings
|
||||
giphy_api_key: str = "H86xibttEuUcslgmMM6uu74IgLEZ7UOD"
|
||||
giphy_translate_url: str = "https://api.giphy.com/v1/gifs/translate"
|
||||
@ -113,6 +122,42 @@ class BotConfig(BaseSettings):
|
||||
"""Calculate total picks in draft (derived value)."""
|
||||
return self.draft_rounds * self.draft_team_count
|
||||
|
||||
def get_draft_sheet_key(self, season: int) -> Optional[str]:
|
||||
"""
|
||||
Get the Google Sheet ID for a given draft season.
|
||||
|
||||
Sheet IDs are configured via environment variables:
|
||||
- DRAFT_SHEET_KEY_12 for season 12
|
||||
- DRAFT_SHEET_KEY_13 for season 13
|
||||
- etc.
|
||||
|
||||
Returns None if no sheet is configured for the season.
|
||||
"""
|
||||
# Default sheet IDs (hardcoded as fallback)
|
||||
default_keys = {
|
||||
12: "1OF-sAFykebc_2BrcYCgxCR-4rJo0GaNmTstagV-PMBU",
|
||||
# Season 13+ will be added here as sheets are created
|
||||
}
|
||||
|
||||
# Check environment variable first (allows runtime override)
|
||||
env_key = os.getenv(f"DRAFT_SHEET_KEY_{season}")
|
||||
if env_key:
|
||||
return env_key
|
||||
|
||||
# Fall back to hardcoded default
|
||||
return default_keys.get(season)
|
||||
|
||||
def get_draft_sheet_url(self, season: int) -> Optional[str]:
|
||||
"""
|
||||
Get the full Google Sheets URL for a given draft season.
|
||||
|
||||
Returns None if no sheet is configured for the season.
|
||||
"""
|
||||
sheet_key = self.get_draft_sheet_key(season)
|
||||
if sheet_key:
|
||||
return f"https://docs.google.com/spreadsheets/d/{sheet_key}"
|
||||
return None
|
||||
|
||||
|
||||
# Global configuration instance - lazily initialized to avoid import-time errors
|
||||
_config = None
|
||||
|
||||
@ -377,6 +377,65 @@ class SheetsService:
|
||||
async def read_box_score(scorecard: pygsheets.Spreadsheet) -> Dict[str, List[int]]
|
||||
```
|
||||
|
||||
#### DraftSheetService Key Methods (NEW - December 2025)
|
||||
```python
|
||||
class DraftSheetService(SheetsService):
|
||||
"""
|
||||
Service for writing draft picks to Google Sheets.
|
||||
Extends SheetsService to reuse authentication and async patterns.
|
||||
"""
|
||||
async def write_pick(
|
||||
season: int,
|
||||
overall: int,
|
||||
orig_owner_abbrev: str,
|
||||
owner_abbrev: str,
|
||||
player_name: str,
|
||||
swar: float
|
||||
) -> bool
|
||||
"""Write a single pick to the draft sheet. Returns True on success."""
|
||||
|
||||
async def write_picks_batch(
|
||||
season: int,
|
||||
picks: List[Tuple[int, str, str, str, float]]
|
||||
) -> Tuple[int, int]
|
||||
"""Batch write picks for resync operations. Returns (success_count, failure_count)."""
|
||||
|
||||
async def clear_picks_range(
|
||||
season: int,
|
||||
start_overall: int = 1,
|
||||
end_overall: int = 512
|
||||
) -> bool
|
||||
"""Clear a range of picks from the sheet before resync."""
|
||||
|
||||
def get_sheet_url(season: int) -> Optional[str]
|
||||
"""Get the draft sheet URL for display in embeds."""
|
||||
```
|
||||
|
||||
**Integration Points:**
|
||||
- `commands/draft/picks.py` - Writes pick to sheet after successful draft selection
|
||||
- `tasks/draft_monitor.py` - Writes pick to sheet after auto-draft
|
||||
- `commands/draft/admin.py` - `/draft-admin resync-sheet` for bulk recovery
|
||||
|
||||
**Fire-and-Forget Pattern:**
|
||||
Draft sheet writes are non-critical (database is source of truth). On failure:
|
||||
1. Log the error
|
||||
2. Notify ping channel with warning message
|
||||
3. Suggest `/draft-admin resync-sheet` for recovery
|
||||
4. Never block the draft pick itself
|
||||
|
||||
**Configuration:**
|
||||
```python
|
||||
# config.py settings
|
||||
draft_sheet_enabled: bool = True # Feature flag
|
||||
draft_sheet_worksheet: str = "Ordered List" # Worksheet name
|
||||
draft_sheet_start_column: str = "D" # Starting column
|
||||
|
||||
# Season-specific sheet IDs via environment variables
|
||||
# DRAFT_SHEET_KEY_12, DRAFT_SHEET_KEY_13, etc.
|
||||
config.get_draft_sheet_key(season) # Returns sheet ID or None
|
||||
config.get_draft_sheet_url(season) # Returns full URL or None
|
||||
```
|
||||
|
||||
**Transaction Rollback Pattern:**
|
||||
The game submission services implement a 3-state transaction rollback pattern:
|
||||
1. **PLAYS_POSTED**: Plays submitted → Rollback: Delete plays
|
||||
|
||||
@ -9,6 +9,7 @@ from .player_service import PlayerService, player_service
|
||||
from .league_service import LeagueService, league_service
|
||||
from .schedule_service import ScheduleService, schedule_service
|
||||
from .giphy_service import GiphyService
|
||||
from .draft_sheet_service import DraftSheetService, get_draft_sheet_service
|
||||
|
||||
# Wire services together for dependency injection
|
||||
player_service._team_service = team_service
|
||||
@ -21,5 +22,6 @@ __all__ = [
|
||||
'PlayerService', 'player_service',
|
||||
'LeagueService', 'league_service',
|
||||
'ScheduleService', 'schedule_service',
|
||||
'GiphyService', 'giphy_service'
|
||||
'GiphyService', 'giphy_service',
|
||||
'DraftSheetService', 'get_draft_sheet_service'
|
||||
]
|
||||
@ -325,6 +325,35 @@ class DraftPickService(BaseService[DraftPick]):
|
||||
logger.error(f"Error getting upcoming picks: {e}")
|
||||
return []
|
||||
|
||||
async def get_picks_with_players(self, season: int) -> List[DraftPick]:
|
||||
"""
|
||||
Get all picks for a season with player data included.
|
||||
|
||||
Used for bulk operations like resync-sheet. Returns all picks
|
||||
for the season regardless of whether they have been selected.
|
||||
|
||||
NOT cached - picks change during draft.
|
||||
|
||||
Args:
|
||||
season: Draft season
|
||||
|
||||
Returns:
|
||||
List of all DraftPick instances for the season
|
||||
"""
|
||||
try:
|
||||
params = [
|
||||
('season', str(season)),
|
||||
('sort', 'order-asc')
|
||||
]
|
||||
|
||||
picks = await self.get_all_items(params=params)
|
||||
logger.debug(f"Found {len(picks)} picks for season {season}")
|
||||
return picks
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting all picks for season {season}: {e}")
|
||||
return []
|
||||
|
||||
async def update_pick_selection(
|
||||
self,
|
||||
pick_id: int,
|
||||
|
||||
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
|
||||
- Attempt to draft player
|
||||
- Break on success
|
||||
5. Advance to next pick
|
||||
6. Release lock
|
||||
5. Write pick to Google Sheets (fire-and-forget)
|
||||
6. Advance to next pick
|
||||
7. Release lock
|
||||
|
||||
#### Google Sheets Integration
|
||||
The monitor writes picks to the draft sheet after successful auto-draft:
|
||||
- Uses **fire-and-forget** pattern (non-blocking)
|
||||
- Failures logged but don't block draft
|
||||
- Same service as manual `/draft` command
|
||||
- Sheet write occurs before pick advancement
|
||||
|
||||
```python
|
||||
# After successful auto-draft execution
|
||||
sheet_success = await draft_sheet_service.write_pick(
|
||||
season=config.sba_season,
|
||||
overall=pick.overall,
|
||||
orig_owner_abbrev=original_owner.abbrev,
|
||||
owner_abbrev=team.abbrev,
|
||||
player_name=player.name,
|
||||
swar=player.wara
|
||||
)
|
||||
if not sheet_success:
|
||||
logger.warning(f"Sheet write failed for auto-draft pick #{pick.overall}")
|
||||
```
|
||||
|
||||
#### Channel Requirements
|
||||
- **ping_channel** - Where warnings and auto-draft announcements post
|
||||
|
||||
@ -14,6 +14,7 @@ from discord.ext import commands, tasks
|
||||
from services.draft_service import draft_service
|
||||
from services.draft_pick_service import draft_pick_service
|
||||
from services.draft_list_service import draft_list_service
|
||||
from services.draft_sheet_service import get_draft_sheet_service
|
||||
from services.player_service import player_service
|
||||
from services.team_service import team_service
|
||||
from services.roster_service import roster_service
|
||||
@ -328,6 +329,9 @@ class DraftMonitorTask:
|
||||
self.logger.error(f"Failed to update player {player.id} team")
|
||||
return False
|
||||
|
||||
# Write pick to Google Sheets (fire-and-forget)
|
||||
await self._write_pick_to_sheets(draft_pick, player, ping_channel)
|
||||
|
||||
# Post to channel
|
||||
await ping_channel.send(
|
||||
content=f"🤖 AUTO-DRAFT: {draft_pick.owner.abbrev} selects **{player.name}** "
|
||||
@ -470,6 +474,65 @@ class DraftMonitorTask:
|
||||
except Exception as e:
|
||||
self.logger.error("Error sending warnings", error=e)
|
||||
|
||||
async def _write_pick_to_sheets(self, draft_pick, player, ping_channel) -> None:
|
||||
"""
|
||||
Write pick to Google Sheets (fire-and-forget with notification on failure).
|
||||
|
||||
Args:
|
||||
draft_pick: The draft pick being used
|
||||
player: Player being drafted
|
||||
ping_channel: Discord channel for failure notification
|
||||
"""
|
||||
config = get_config()
|
||||
|
||||
try:
|
||||
draft_sheet_service = get_draft_sheet_service()
|
||||
success = await draft_sheet_service.write_pick(
|
||||
season=config.sba_season,
|
||||
overall=draft_pick.overall,
|
||||
orig_owner_abbrev=draft_pick.origowner.abbrev if draft_pick.origowner else draft_pick.owner.abbrev,
|
||||
owner_abbrev=draft_pick.owner.abbrev,
|
||||
player_name=player.name,
|
||||
swar=player.wara
|
||||
)
|
||||
|
||||
if not success:
|
||||
# Write failed - notify in ping channel
|
||||
await self._notify_sheet_failure(
|
||||
ping_channel=ping_channel,
|
||||
pick_overall=draft_pick.overall,
|
||||
player_name=player.name
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
self.logger.warning(f"Failed to write pick to sheets: {e}")
|
||||
await self._notify_sheet_failure(
|
||||
ping_channel=ping_channel,
|
||||
pick_overall=draft_pick.overall,
|
||||
player_name=player.name
|
||||
)
|
||||
|
||||
async def _notify_sheet_failure(self, ping_channel, pick_overall: int, player_name: str) -> None:
|
||||
"""
|
||||
Post notification to ping channel when sheet write fails.
|
||||
|
||||
Args:
|
||||
ping_channel: Discord channel to notify
|
||||
pick_overall: Pick number that failed
|
||||
player_name: Player name
|
||||
"""
|
||||
if not ping_channel:
|
||||
return
|
||||
|
||||
try:
|
||||
await ping_channel.send(
|
||||
f"⚠️ **Sheet Sync Failed** - Pick #{pick_overall} ({player_name}) "
|
||||
f"was not written to the draft sheet. "
|
||||
f"Use `/draft-admin resync-sheet` to manually sync."
|
||||
)
|
||||
except Exception as e:
|
||||
self.logger.error(f"Failed to send sheet failure notification: {e}")
|
||||
|
||||
|
||||
# Task factory function
|
||||
def setup_draft_monitor(bot: commands.Bot) -> DraftMonitorTask:
|
||||
|
||||
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(
|
||||
draft_data: DraftData,
|
||||
current_pick: DraftPick,
|
||||
lock_status: str = "🔓 No pick in progress"
|
||||
lock_status: str = "🔓 No pick in progress",
|
||||
sheet_url: Optional[str] = None
|
||||
) -> discord.Embed:
|
||||
"""
|
||||
Create draft status embed showing current state.
|
||||
@ -119,6 +120,7 @@ async def create_draft_status_embed(
|
||||
draft_data: Current draft configuration
|
||||
current_pick: Current DraftPick
|
||||
lock_status: Lock status message
|
||||
sheet_url: Optional Google Sheets URL for draft tracking
|
||||
|
||||
Returns:
|
||||
Discord embed with draft status
|
||||
@ -166,6 +168,14 @@ async def create_draft_status_embed(
|
||||
inline=False
|
||||
)
|
||||
|
||||
# Draft Sheet link
|
||||
if sheet_url:
|
||||
embed.add_field(
|
||||
name="Draft Sheet",
|
||||
value=f"[View Sheet]({sheet_url})",
|
||||
inline=False
|
||||
)
|
||||
|
||||
return embed
|
||||
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user